merge 'main' into local-pr-3734
This commit is contained in:
@@ -6,3 +6,4 @@ tsconfig.json
|
||||
tsconfig.*.json
|
||||
CHANGELOG*.md
|
||||
agents.json
|
||||
src/renderer/src/integration/nutstore/sso/lib
|
||||
|
||||
+8
-1
@@ -53,6 +53,13 @@ export default defineConfig([
|
||||
}
|
||||
],
|
||||
{
|
||||
ignores: ['node_modules/**', 'dist/**', 'out/**', 'local/**', '.gitignore', 'scripts/cloudflare-worker.js']
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'dist/**',
|
||||
'out/**',
|
||||
'.gitignore',
|
||||
'scripts/cloudflare-worker.js',
|
||||
'src/renderer/src/integration/nutstore/sso/lib/**'
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
"electron-updater": "^6.3.9",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
|
||||
"fast-xml-parser": "^5.0.9",
|
||||
"fetch-socks": "^1.3.2",
|
||||
"fs-extra": "^11.2.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
|
||||
@@ -88,7 +88,7 @@ export const textExts = [
|
||||
'.ctp', // CakePHP 视图文件
|
||||
'.cfm', // ColdFusion 标记语言文件
|
||||
'.cfc', // ColdFusion 组件文件
|
||||
'.m', // Objective-C 源文件
|
||||
'.m', // Objective-C 或 MATLAB 源文件
|
||||
'.mm', // Objective-C++ 源文件
|
||||
'.gradle', // Gradle 构建文件
|
||||
'.groovy', // Gradle 构建文件
|
||||
@@ -106,7 +106,32 @@ export const textExts = [
|
||||
'.ixx', // C++20 模块实现文件
|
||||
'.f90', // Fortran 90 源文件
|
||||
'.f', // Fortran 固定格式源代码文件
|
||||
'.f03' // Fortran 2003+ 源代码文件
|
||||
'.f03', // Fortran 2003+ 源代码文件
|
||||
'.ahk', // AutoHotKey 语言文件
|
||||
'.tcl', // Tcl 脚本
|
||||
'.do', // Questa 或 Modelsim Tcl 脚本
|
||||
'.v', // Verilog 源文件
|
||||
'.sv', // SystemVerilog 源文件
|
||||
'.svh', // SystemVerilog 头文件
|
||||
'.vhd', // VHDL 源文件
|
||||
'.vhdl', // VHDL 源文件
|
||||
'.lef', // Library Exchange Format
|
||||
'.def', // Design Exchange Format
|
||||
'.edif', // Electronic Design Interchange Format
|
||||
'.sdf', // Standard Delay Format
|
||||
'.sdc', // Synopsys Design Constraints
|
||||
'.xdc', // Xilinx Design Constraints
|
||||
'.rpt', // 报告文件
|
||||
'.lisp', // Lisp 脚本
|
||||
'.il', // Cadence SKILL 脚本
|
||||
'.ils', // Cadence SKILL++ 脚本
|
||||
'.sp', // SPICE netlist 文件
|
||||
'.spi', // SPICE netlist 文件
|
||||
'.cir', // SPICE netlist 文件
|
||||
'.net', // SPICE netlist 文件
|
||||
'.scs', // Spectre netlist 文件
|
||||
'.asc', // LTspice netlist schematic 文件
|
||||
'.tf' // Technology File
|
||||
]
|
||||
|
||||
export const ZOOM_SHORTCUTS = [
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const NUTSTORE_HOST = 'https://dav.jianguoyun.com/dav'
|
||||
+23
-1
@@ -5,6 +5,7 @@ import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||
|
||||
import { registerIpc } from './ipc'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import { CHERRY_STUDIO_PROTOCOL, handleProtocolUrl, registerProtocolClient } from './services/ProtocolClient'
|
||||
import { registerShortcuts } from './services/ShortcutService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { windowService } from './services/WindowService'
|
||||
@@ -56,9 +57,30 @@ if (!app.requestSingleInstanceLock()) {
|
||||
})
|
||||
})
|
||||
|
||||
registerProtocolClient(app)
|
||||
|
||||
// macOS specific: handle protocol when app is already running
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault()
|
||||
handleProtocolUrl(url)
|
||||
})
|
||||
|
||||
registerProtocolClient(app)
|
||||
|
||||
// macOS specific: handle protocol when app is already running
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault()
|
||||
handleProtocolUrl(url)
|
||||
})
|
||||
|
||||
// Listen for second instance
|
||||
app.on('second-instance', () => {
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
windowService.showMainWindow()
|
||||
|
||||
// Protocol handler for Windows/Linux
|
||||
// The commandLine is an array of strings where the last item might be the URL
|
||||
const url = argv.find((arg) => arg.startsWith(CHERRY_STUDIO_PROTOCOL + '://'))
|
||||
if (url) handleProtocolUrl(url)
|
||||
})
|
||||
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
declare function decrypt(app: string, s: string): string;
|
||||
|
||||
interface Secret {
|
||||
app: string;
|
||||
}
|
||||
declare function createOAuthUrl(secret: Secret): string;
|
||||
|
||||
export { type Secret, createOAuthUrl, decrypt };
|
||||
File diff suppressed because one or more lines are too long
@@ -17,6 +17,7 @@ import FileService from './services/FileService'
|
||||
import FileStorage from './services/FileStorage'
|
||||
import KnowledgeService from './services/KnowledgeService'
|
||||
import MCPService from './services/MCPService'
|
||||
import * as NutstoreService from './services/NutstoreService'
|
||||
import { ProxyConfig, proxyManager } from './services/ProxyManager'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
@@ -164,6 +165,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle('backup:backupToWebdav', backupManager.backupToWebdav)
|
||||
ipcMain.handle('backup:restoreFromWebdav', backupManager.restoreFromWebdav)
|
||||
ipcMain.handle('backup:listWebdavFiles', backupManager.listWebdavFiles)
|
||||
ipcMain.handle('backup:checkConnection', backupManager.checkConnection)
|
||||
ipcMain.handle('backup:createDirectory', backupManager.createDirectory)
|
||||
|
||||
// file
|
||||
ipcMain.handle('file:open', fileManager.open)
|
||||
@@ -312,4 +315,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle('copilot:get-token', CopilotService.getToken)
|
||||
ipcMain.handle('copilot:logout', CopilotService.logout)
|
||||
ipcMain.handle('copilot:get-user', CopilotService.getUser)
|
||||
|
||||
// nutstore
|
||||
ipcMain.handle('nutstore:get-sso-url', NutstoreService.getNutstoreSSOUrl)
|
||||
ipcMain.handle('nutstore:decrypt-token', (_, token: string) => NutstoreService.decryptToken(token))
|
||||
ipcMain.handle('nutstore:get-directory-contents', (_, token: string, path: string) =>
|
||||
NutstoreService.getDirectoryContents(token, path)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +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 { createClient, FileStat, CreateDirectoryOptions } from 'webdav'
|
||||
|
||||
import WebDav from './WebDav'
|
||||
import { windowService } from './WindowService'
|
||||
@@ -15,6 +15,7 @@ class BackupManager {
|
||||
private backupDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup')
|
||||
|
||||
constructor() {
|
||||
this.checkConnection = this.checkConnection.bind(this)
|
||||
this.backup = this.backup.bind(this)
|
||||
this.restore = this.restore.bind(this)
|
||||
this.backupToWebdav = this.backupToWebdav.bind(this)
|
||||
@@ -278,6 +279,21 @@ class BackupManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
|
||||
const webdavClient = new WebDav(webdavConfig)
|
||||
return await webdavClient.checkConnection()
|
||||
}
|
||||
|
||||
async createDirectory(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
webdavConfig: WebDavConfig,
|
||||
path: string,
|
||||
options?: CreateDirectoryOptions
|
||||
) {
|
||||
const webdavClient = new WebDav(webdavConfig)
|
||||
return await webdavClient.createDirectory(path, options)
|
||||
}
|
||||
}
|
||||
|
||||
export default BackupManager
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { NUTSTORE_HOST } from '@shared/config/nutstore'
|
||||
import { XMLParser } from 'fast-xml-parser'
|
||||
import { isNil, partial } from 'lodash'
|
||||
import { type FileStat } from 'webdav'
|
||||
|
||||
interface OAuthResponse {
|
||||
username: string
|
||||
userid: string
|
||||
access_token: string
|
||||
}
|
||||
|
||||
interface WebDAVResponse {
|
||||
multistatus: {
|
||||
response: Array<{
|
||||
href: string
|
||||
propstat: {
|
||||
prop: {
|
||||
displayname: string
|
||||
resourcetype: { collection?: any }
|
||||
getlastmodified?: string
|
||||
getcontentlength?: string
|
||||
getcontenttype?: string
|
||||
}
|
||||
status: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNutstoreSSOUrl() {
|
||||
const { createOAuthUrl } = await import('../integration/nutstore/sso/lib')
|
||||
|
||||
const url = createOAuthUrl({
|
||||
app: 'cherrystudio'
|
||||
})
|
||||
return url
|
||||
}
|
||||
|
||||
export async function decryptToken(token: string) {
|
||||
const { decrypt } = await import('../integration/nutstore/sso/lib')
|
||||
try {
|
||||
const decrypted = decrypt('cherrystudio', token)
|
||||
return JSON.parse(decrypted) as OAuthResponse
|
||||
} catch (error) {
|
||||
console.error('解密失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDirectoryContents(token: string, target: string): Promise<FileStat[]> {
|
||||
const contents: FileStat[] = []
|
||||
if (!target.startsWith('/')) {
|
||||
target = '/' + target
|
||||
}
|
||||
|
||||
let currentUrl = `${NUTSTORE_HOST}${target}`
|
||||
|
||||
while (true) {
|
||||
const response = await fetch(currentUrl, {
|
||||
method: 'PROPFIND',
|
||||
headers: {
|
||||
Authorization: `Basic ${token}`,
|
||||
'Content-Type': 'application/xml',
|
||||
Depth: '1'
|
||||
},
|
||||
body: `<?xml version="1.0" encoding="utf-8"?>
|
||||
<propfind xmlns="DAV:">
|
||||
<prop>
|
||||
<displayname/>
|
||||
<resourcetype/>
|
||||
<getlastmodified/>
|
||||
<getcontentlength/>
|
||||
<getcontenttype/>
|
||||
</prop>
|
||||
</propfind>`
|
||||
})
|
||||
|
||||
const text = await response.text()
|
||||
|
||||
const result = parseXml<WebDAVResponse>(text)
|
||||
const items = Array.isArray(result.multistatus.response)
|
||||
? result.multistatus.response
|
||||
: [result.multistatus.response]
|
||||
|
||||
// 跳过第一个条目(当前目录)
|
||||
contents.push(...items.slice(1).map(partial(convertToFileStat, '/dav')))
|
||||
|
||||
const linkHeader = response.headers['link'] || response.headers['Link']
|
||||
if (!linkHeader) {
|
||||
break
|
||||
}
|
||||
|
||||
const nextLink = extractNextLink(linkHeader)
|
||||
if (!nextLink) {
|
||||
break
|
||||
}
|
||||
|
||||
currentUrl = decodeURI(nextLink)
|
||||
}
|
||||
|
||||
return contents
|
||||
}
|
||||
|
||||
function extractNextLink(linkHeader: string): string | null {
|
||||
const matches = linkHeader.match(/<([^>]+)>;\s*rel="next"/)
|
||||
return matches ? matches[1] : null
|
||||
}
|
||||
|
||||
function convertToFileStat(serverBase: string, item: WebDAVResponse['multistatus']['response'][number]): FileStat {
|
||||
const props = item.propstat.prop
|
||||
const isDir = !isNil(props.resourcetype?.collection)
|
||||
const href = decodeURIComponent(item.href)
|
||||
const filename = serverBase === '/' ? href : path.join('/', href.replace(serverBase, ''))
|
||||
|
||||
return {
|
||||
filename,
|
||||
basename: path.basename(filename),
|
||||
lastmod: props.getlastmodified || '',
|
||||
size: props.getcontentlength ? parseInt(props.getcontentlength, 10) : 0,
|
||||
type: isDir ? 'directory' : 'file',
|
||||
etag: null,
|
||||
mime: props.getcontenttype
|
||||
}
|
||||
}
|
||||
|
||||
function parseXml<T>(xml: string) {
|
||||
const parser = new XMLParser({
|
||||
attributeNamePrefix: '',
|
||||
removeNSPrefix: true
|
||||
})
|
||||
return parser.parse(xml) as T
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
export const CHERRY_STUDIO_PROTOCOL = 'cherrystudio'
|
||||
|
||||
export function registerProtocolClient(app: Electron.App) {
|
||||
if (process.defaultApp) {
|
||||
if (process.argv.length >= 2) {
|
||||
app.setAsDefaultProtocolClient(CHERRY_STUDIO_PROTOCOL, process.execPath, [process.argv[1]])
|
||||
}
|
||||
}
|
||||
|
||||
app.setAsDefaultProtocolClient('cherrystudio')
|
||||
}
|
||||
|
||||
export function handleProtocolUrl(url: string) {
|
||||
if (!url) return
|
||||
// Process the URL that was used to open the app
|
||||
// The url will be in the format: cherrystudio://data?param1=value1¶m2=value2
|
||||
console.log('Received URL:', url)
|
||||
|
||||
// Parse the URL and extract parameters
|
||||
const urlObj = new URL(url)
|
||||
const params = new URLSearchParams(urlObj.search)
|
||||
|
||||
// You can send the data to your renderer process
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('protocol-data', {
|
||||
url,
|
||||
params: Object.fromEntries(params.entries())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -221,9 +221,13 @@ export function registerShortcuts(window: BrowserWindow) {
|
||||
|
||||
// only register the event handlers once
|
||||
if (undefined === windowOnHandlers.get(window)) {
|
||||
window.on('focus', register)
|
||||
// pass register() directly to listener, the func will receive Event as argument, it's not expected
|
||||
const registerHandler = () => {
|
||||
register()
|
||||
}
|
||||
window.on('focus', registerHandler)
|
||||
window.on('blur', unregister)
|
||||
windowOnHandlers.set(window, { onFocusHandler: register, onBlurHandler: unregister })
|
||||
windowOnHandlers.set(window, { onFocusHandler: registerHandler, onBlurHandler: unregister })
|
||||
}
|
||||
|
||||
if (!window.isDestroyed() && window.isFocused()) {
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { WebDavConfig } from '@types'
|
||||
import Logger from 'electron-log'
|
||||
import Stream from 'stream'
|
||||
import { BufferLike, createClient, GetFileContentsOptions, PutFileContentsOptions, WebDAVClient } from 'webdav'
|
||||
import {
|
||||
BufferLike,
|
||||
createClient,
|
||||
CreateDirectoryOptions,
|
||||
GetFileContentsOptions,
|
||||
PutFileContentsOptions,
|
||||
WebDAVClient
|
||||
} from 'webdav'
|
||||
export default class WebDav {
|
||||
public instance: WebDAVClient | undefined
|
||||
private webdavPath: string
|
||||
@@ -18,6 +25,7 @@ export default class WebDav {
|
||||
|
||||
this.putFileContents = this.putFileContents.bind(this)
|
||||
this.getFileContents = this.getFileContents.bind(this)
|
||||
this.createDirectory = this.createDirectory.bind(this)
|
||||
}
|
||||
|
||||
public putFileContents = async (
|
||||
@@ -64,4 +72,30 @@ export default class WebDav {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public checkConnection = async () => {
|
||||
if (!this.instance) {
|
||||
throw new Error('WebDAV client not initialized')
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.instance.exists('/')
|
||||
} catch (error) {
|
||||
Logger.error('[WebDAV] Error checking connection:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public createDirectory = async (path: string, options?: CreateDirectoryOptions) => {
|
||||
if (!this.instance) {
|
||||
throw new Error('WebDAV client not initialized')
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.instance.createDirectory(path, options)
|
||||
} catch (error) {
|
||||
Logger.error('[WebDAV] Error creating directory on WebDAV:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,8 @@ export class WindowService {
|
||||
visualEffectState: 'active',
|
||||
titleBarStyle: isLinux ? 'default' : 'hidden',
|
||||
titleBarOverlay: theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight,
|
||||
backgroundColor: isMac ? undefined : theme === 'dark' ? '#181818' : '#FFFFFF',
|
||||
backgroundColor: isMac || isWin ? undefined : theme === 'dark' ? '#181818' : '#FFFFFF',
|
||||
backgroundMaterial: 'acrylic',
|
||||
trafficLightPosition: { x: 8, y: 12 },
|
||||
...(process.platform === 'linux' ? { icon } : {}),
|
||||
webPreferences: {
|
||||
|
||||
Vendored
+10
@@ -53,6 +53,8 @@ declare global {
|
||||
backupToWebdav: (data: string, webdavConfig: WebDavConfig) => Promise<boolean>
|
||||
restoreFromWebdav: (webdavConfig: WebDavConfig) => Promise<string>
|
||||
listWebdavFiles: (webdavConfig: WebDavConfig) => Promise<BackupFile[]>
|
||||
checkConnection: (webdavConfig: WebDavConfig) => Promise<boolean>
|
||||
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) => Promise<void>
|
||||
}
|
||||
file: {
|
||||
select: (options?: OpenDialogOptions) => Promise<FileType[] | null>
|
||||
@@ -179,6 +181,14 @@ declare global {
|
||||
getBinaryPath: (name: string) => Promise<string>
|
||||
installUVBinary: () => Promise<void>
|
||||
installBunBinary: () => Promise<void>
|
||||
protocol: {
|
||||
onReceiveData: (callback: (data: { url: string; params: any }) => void) => () => void
|
||||
}
|
||||
nutstore: {
|
||||
getSSOUrl: () => Promise<string>
|
||||
decryptToken: (token: string) => Promise<{ username: string; access_token: string }>
|
||||
getDirectoryContents: (token: string, path: string) => Promise<any>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
-2
@@ -2,6 +2,7 @@ import { electronAPI } from '@electron-toolkit/preload'
|
||||
import type { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, WebDavConfig } from '@types'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions, shell } from 'electron'
|
||||
import { CreateDirectoryOptions } from 'webdav'
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {
|
||||
@@ -34,7 +35,10 @@ const api = {
|
||||
backupToWebdav: (data: string, webdavConfig: WebDavConfig) =>
|
||||
ipcRenderer.invoke('backup:backupToWebdav', data, webdavConfig),
|
||||
restoreFromWebdav: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:restoreFromWebdav', webdavConfig),
|
||||
listWebdavFiles: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:listWebdavFiles', webdavConfig)
|
||||
listWebdavFiles: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:listWebdavFiles', webdavConfig),
|
||||
checkConnection: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:checkConnection', webdavConfig),
|
||||
createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) =>
|
||||
ipcRenderer.invoke('backup:createDirectory', webdavConfig, path, options)
|
||||
},
|
||||
file: {
|
||||
select: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options),
|
||||
@@ -147,7 +151,24 @@ const api = {
|
||||
isBinaryExist: (name: string) => ipcRenderer.invoke('app:is-binary-exist', name),
|
||||
getBinaryPath: (name: string) => ipcRenderer.invoke('app:get-binary-path', name),
|
||||
installUVBinary: () => ipcRenderer.invoke('app:install-uv-binary'),
|
||||
installBunBinary: () => ipcRenderer.invoke('app:install-bun-binary')
|
||||
installBunBinary: () => ipcRenderer.invoke('app:install-bun-binary'),
|
||||
protocol: {
|
||||
onReceiveData: (callback: (data: { url: string; params: any }) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: { url: string; params: any }) => {
|
||||
callback(data)
|
||||
}
|
||||
ipcRenderer.on('protocol-data', listener)
|
||||
return () => {
|
||||
ipcRenderer.off('protocol-data', listener)
|
||||
}
|
||||
}
|
||||
},
|
||||
nutstore: {
|
||||
getSSOUrl: () => ipcRenderer.invoke('nutstore:get-sso-url'),
|
||||
decryptToken: (token: string) => ipcRenderer.invoke('nutstore:decrypt-token', token),
|
||||
getDirectoryContents: (token: string, path: string) =>
|
||||
ipcRenderer.invoke('nutstore:get-directory-contents', token, path)
|
||||
}
|
||||
}
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
|
||||
@@ -39,5 +39,4 @@
|
||||
<script type="module" src="/src/init.ts"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--pulse-color), 0.5);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 var(--pulse-size) rgba(var(--pulse-color), 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--pulse-color), 0);
|
||||
}
|
||||
}
|
||||
|
||||
// 电磁波扩散效果
|
||||
.animation-pulse {
|
||||
--pulse-color: 59, 130, 246;
|
||||
--pulse-size: 8px;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
@use './ant.scss';
|
||||
@use './scrollbar.scss';
|
||||
@use './container.scss';
|
||||
@use './animation.scss';
|
||||
@import '../fonts/icon-fonts/iconfont.css';
|
||||
@import '../fonts/ubuntu/ubuntu.css';
|
||||
|
||||
@@ -51,7 +52,7 @@
|
||||
--color-reference-background: #0b0e12;
|
||||
|
||||
--navbar-background-mac: rgba(20, 20, 20, 0.55);
|
||||
--navbar-background: #1f1f1f;
|
||||
--navbar-background: rgba(20, 20, 20, 0.4);
|
||||
|
||||
--navbar-height: 40px;
|
||||
--sidebar-width: 50px;
|
||||
@@ -123,7 +124,7 @@ body[theme-mode='light'] {
|
||||
--color-reference-background: #f1f7ff;
|
||||
|
||||
--navbar-background-mac: rgba(255, 255, 255, 0.55);
|
||||
--navbar-background: rgba(244, 244, 244);
|
||||
--navbar-background: rgba(255, 255, 255, 0.4);
|
||||
|
||||
--chat-background: #f3f3f3;
|
||||
--chat-background-user: #95ec69;
|
||||
|
||||
@@ -294,3 +294,11 @@
|
||||
emoji-picker {
|
||||
--border-size: 0;
|
||||
}
|
||||
|
||||
.katex-display{
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
mjx-container{
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -8,9 +8,10 @@ interface Props {
|
||||
model: Model
|
||||
size: number
|
||||
props?: AvatarProps
|
||||
className?: string
|
||||
}
|
||||
|
||||
const ModelAvatar: FC<Props> = ({ model, size, props }) => {
|
||||
const ModelAvatar: FC<Props> = ({ model, size, props, className }) => {
|
||||
return (
|
||||
<Avatar
|
||||
src={getModelLogo(model?.id || '')}
|
||||
@@ -23,7 +24,8 @@ const ModelAvatar: FC<Props> = ({ model, size, props }) => {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
{...props}>
|
||||
{...props}
|
||||
className={className}>
|
||||
{first(model?.name)}
|
||||
</Avatar>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
const IconSpan = styled.span`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
export function NutstoreIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<IconSpan>
|
||||
<svg
|
||||
{...props}
|
||||
width="16px"
|
||||
height="16px"
|
||||
viewBox="0 0 20 20"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink">
|
||||
<title>线性单坚果</title>
|
||||
<g id="线性单坚果" stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
|
||||
<path
|
||||
d="M10.1590439,0.886175571 C10.1753674,0.890326544 10.291709,0.910777855 10.428428,0.935202765 L10.6388345,0.973279488 C10.7074276,0.985937901 10.77116,0.998048871 10.8200766,1.00807156 C11.2437905,1.09488771 11.6662387,1.21011472 12.1133986,1.37210166 C13.2580363,1.78675499 14.3714894,2.43940777 15.4224927,3.39703693 L15.621,3.584 L15.6351722,3.57092562 C16.53166,2.76294504 17.6751418,2.31986999 18.4291849,2.58060734 L18.5580792,2.63399481 C18.9455012,2.81584984 19.2328582,3.16284846 19.437028,3.61729231 C19.5709871,3.91546021 19.6526725,4.21929758 19.6985752,4.50662941 C19.7148596,4.80478115 19.5904581,5.0358501 19.4098118,5.1582622 C19.3815042,5.17858714 19.3523426,5.19648783 19.3224017,5.21197531 C19.1152073,5.31915066 18.9086763,5.30466603 18.6939183,5.22086872 C18.6620576,5.20843687 18.6328325,5.19564599 18.6006654,5.18105502 C18.4394695,5.11546938 18.2846309,5.06753532 18.1365915,5.04232952 C17.7415971,4.96197402 17.3578102,5.06378907 17.051656,5.32621284 L17.046624,5.33098744 L17.1856424,5.55157847 C18.0964209,7.0577136 18.6880009,8.98631362 18.5914984,10.988329 L18.5672508,11.3423168 C18.518886,12.3590196 18.336046,13.2889191 17.9959883,14.1391815 C17.4227031,15.6418626 16.5311196,16.5912538 15.4105898,16.2529712 L15.278,16.207 C15.204042,16.2889459 15.1247235,16.3618831 15.0410669,16.4278107 L14.9126231,16.5212291 C13.2906651,17.9150353 10.9315401,19.0281897 7.99389616,19.2 L7.17106258,19.2 C3.43360072,19.2 1.02132454,17.63803 0.534391412,16.0333683 L0.513,15.954 L0.504265285,15.9449232 C-0.110228462,15.1972878 0.264421351,10.4760569 2.09599684,6.99794495 L2.22026541,6.76796973 C2.29571954,6.63016882 2.43695112,6.39220857 2.63659846,6.08729923 C2.9688861,5.57981633 3.34471126,5.07232148 3.75709487,4.59788661 C4.2749895,4.0020645 4.81413532,3.50121679 5.3386949,3.15177019 C5.36355777,3.12648036 5.4278064,3.07827062 5.50910569,3.02364741 L5.559,2.991 L5.5530361,2.96941337 C5.48899059,2.69876461 5.47862138,2.4784725 5.54146387,2.2521942 L5.58811106,2.11525813 C5.68308256,1.86409186 5.94349142,1.57994703 6.25873284,1.38755406 C6.58654657,1.18748816 7.23187921,0.95895859 7.69473739,0.883035787 C8.37505518,0.763266442 9.38159553,0.78076773 10.1590439,0.886175571 Z M6.59801776,3.85068129 C6.46732353,3.85068129 6.2240354,3.97828097 6.07844768,4.1001814 C5.59811888,4.42589962 5.12194443,4.87010868 4.65860433,5.40361803 C4.52372819,5.55892011 4.37448327,5.74624534 4.22515758,5.94252901 L4.04684241,6.18089332 C3.57610889,6.82012555 3.16307203,7.45661922 3.27592159,7.33459023 C1.39280393,10.7336939 1.18786427,14.1190682 1.66513528,15.5784041 C1.72944314,15.8645824 2.24255786,16.4352772 2.98506717,16.8902532 C4.03558482,17.5339627 5.43381914,17.9303112 7.15636912,17.9630362 L7.95282724,17.9633776 C10.5671194,17.8104156 12.6011819,16.8513512 14.1270746,15.5866906 L14.2005419,15.5269075 L14.2189125,15.5136158 C14.591184,15.2751975 14.6855045,14.9945722 14.5299888,14.3127204 C14.1480256,12.8500475 13.2023047,10.9705228 11.4802274,8.76564869 C10.6761315,7.73569508 9.84271439,6.77270459 8.9812637,5.88185595 C8.26651717,5.13999817 7.48191474,4.46126051 6.65303256,3.86947602 C6.6343697,3.85523851 6.62003281,3.85068129 6.59801776,3.85068129 Z M8.0520431,2.14478343 C7.34750556,2.24716005 6.81392621,2.48276912 6.75769294,2.58286729 C6.75315545,2.59094425 6.75172186,2.59912409 6.75788522,2.63367631 L6.761,2.653 C6.92447955,2.67441039 7.07755879,2.72514333 7.22081781,2.80306173 L7.36053304,2.88992896 C8.25106173,3.52400396 9.08393795,4.2496146 9.84209216,5.05104835 C10.7498631,5.98954517 11.620838,6.99715009 12.4127624,8.02643665 C14.2357617,10.3660968 15.255676,12.4067536 15.6810213,14.0171728 C15.7810435,14.3986973 15.8140553,14.7531702 15.7838468,15.0855202 L15.779624,15.1139874 L15.7923351,15.1170186 C16.0195271,15.1453183 16.2337261,14.9383655 16.4514,14.5090146 L16.5168229,14.3735502 C16.5998938,14.1934825 16.8522658,13.5389313 16.8131724,13.6336744 L16.800624,13.6629874 L16.8933423,13.4088509 C17.1021765,12.7846983 17.2487406,12.0003637 17.2861365,11.2776414 C17.4525549,9.34169753 16.8847303,7.51332101 15.9618076,5.9792161 C15.8725231,5.8278532 15.7620551,5.66138642 15.6942132,5.57820575 C14.7595226,4.31701776 13.5999579,3.42705248 12.3136888,2.84260842 C11.4827868,2.46507019 10.794487,2.2853603 10.1559862,2.18983638 C9.43796126,2.09113972 8.59553714,2.05880421 8.0520431,2.14478343 Z M16.4823653,4.32067121 L16.364,4.418 L16.393,4.454 L16.5100007,4.3621392 C17.0306065,3.97118443 17.6106194,3.7900296 18.1665334,3.88918284 L18.233,3.904 L18.2063581,3.87419362 C18.1376794,3.79892884 18.0675642,3.72412847 18.0165076,3.68190508 L17.972563,3.65173005 C17.800955,3.56958653 17.0606024,3.86572493 16.4823653,4.32067121 Z"
|
||||
id="形状结合"
|
||||
fill="currentColor"
|
||||
fillRule="nonzero"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</IconSpan>
|
||||
)
|
||||
}
|
||||
|
||||
export function FolderIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<IconSpan>
|
||||
<svg width="16px" height="16px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" {...props}>
|
||||
<title>folder</title>
|
||||
<path
|
||||
d="M396.5,185.7l22.7,27.2a36.1,36.1,0,0,0,27.7,12.7H906.8c29.4,0,53.2,22.8,53.2,50.9V800.1c0,28.1-23.8,50.9-53.2,50.9H117.2C87.8,851,64,828.2,64,800.1V223.9c0-28.1,23.8-50.9,53.2-50.9H368.8A36.1,36.1,0,0,1,396.5,185.7Z"
|
||||
style={{ fill: '#9fddff' }}
|
||||
/>
|
||||
<path
|
||||
d="M64,342.5V797.8c0,29.4,24,53.2,53.6,53.2H906.4c29.6,0,53.6-23.8,53.6-53.2V342.5Z"
|
||||
style={{ fill: '#74c6ff' }}
|
||||
/>
|
||||
</svg>
|
||||
</IconSpan>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { FolderIcon as NutstoreFolderIcon } from '@renderer/components/Icons/NutstoreIcons'
|
||||
import { Button, Input } from 'antd'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { HStack } from './Layout'
|
||||
|
||||
interface NewFolderProps {
|
||||
onConfirm: (name: string) => void
|
||||
onCancel: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const NewFolderContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 4px;
|
||||
`
|
||||
|
||||
const FolderIcon = styled(NutstoreFolderIcon)`
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
`
|
||||
|
||||
function NewFolder(props: NewFolderProps) {
|
||||
const { onConfirm, onCancel } = props
|
||||
const [name, setName] = useState('')
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<NewFolderContainer>
|
||||
<FolderIcon className={props.className}></FolderIcon>
|
||||
<Input type="text" style={{ flex: 1 }} autoFocus value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Button type="primary" size="small" onClick={() => onConfirm(name)}>
|
||||
{t('settings.data.nutstore.new_folder.button.confirm')}
|
||||
</Button>
|
||||
<Button type="default" size="small" onClick={() => onCancel()}>
|
||||
{t('settings.data.nutstore.new_folder.button.cancel')}
|
||||
</Button>
|
||||
</NewFolderContainer>
|
||||
)
|
||||
}
|
||||
|
||||
interface FolderProps {
|
||||
name: string
|
||||
path: string
|
||||
onClick: (path: string) => void
|
||||
}
|
||||
|
||||
const FolderContainer = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
padding: 0 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
}
|
||||
|
||||
.nutstore-pathname {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
`
|
||||
|
||||
function Folder(props: FolderProps) {
|
||||
return (
|
||||
<FolderContainer onClick={() => props.onClick(props.path)}>
|
||||
<FolderIcon></FolderIcon>
|
||||
<span className="nutstore-pathname">{props.name}</span>
|
||||
</FolderContainer>
|
||||
)
|
||||
}
|
||||
|
||||
interface FileListProps {
|
||||
path: string
|
||||
fs: Nutstore.Fs
|
||||
onClick: (file: Nutstore.FileStat) => void
|
||||
}
|
||||
|
||||
function FileList(props: FileListProps) {
|
||||
const [files, setFiles] = useState<Nutstore.FileStat[]>([])
|
||||
|
||||
const folders = files.filter((file) => file.isDir).sort((a, b) => a.basename.localeCompare(b.basename, ['zh']))
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchFiles() {
|
||||
try {
|
||||
const items = await props.fs.ls(props.path)
|
||||
setFiles(items)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(error)
|
||||
window.modal.error({
|
||||
content: error.message,
|
||||
centered: true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
fetchFiles()
|
||||
}, [props.path, props.fs])
|
||||
|
||||
return (
|
||||
<>
|
||||
{folders.map((folder) => (
|
||||
<Folder key={folder.path} name={folder.basename} path={folder.path} onClick={() => props.onClick(folder)} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SingleFileListContainer = styled.div`
|
||||
height: 300px;
|
||||
overflow: hidden;
|
||||
.scroll-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.new-folder {
|
||||
margin-top: 4px;
|
||||
}
|
||||
`
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
.nutstore-current-path-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.nutstore-current-path {
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.nutstore-path-operater {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
`
|
||||
|
||||
interface Props {
|
||||
fs: Nutstore.Fs
|
||||
onConfirm: (path: string) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function NutstorePathSelector(props: Props) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [stack, setStack] = useState<string[]>(['/'])
|
||||
const [showNewFolder, setShowNewFolder] = useState(false)
|
||||
|
||||
const cwd = stack.at(-1)
|
||||
|
||||
const enter = useCallback((path: string) => {
|
||||
setStack((prev) => [...prev, path])
|
||||
}, [])
|
||||
|
||||
const pop = useCallback(() => {
|
||||
setStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev))
|
||||
}, [])
|
||||
|
||||
const handleNewFolder = useCallback(
|
||||
async (name: string) => {
|
||||
const target = (cwd ?? '/') + (cwd && cwd !== '/' ? '/' : '') + name
|
||||
await props.fs.mkdirs(target)
|
||||
setShowNewFolder(false)
|
||||
enter(target)
|
||||
},
|
||||
[cwd, props.fs, enter]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<SingleFileListContainer>
|
||||
<div className="scroll-container">
|
||||
{showNewFolder && (
|
||||
<NewFolder className="new-folder" onConfirm={handleNewFolder} onCancel={() => setShowNewFolder(false)} />
|
||||
)}
|
||||
<FileList path={cwd ?? ''} fs={props.fs} onClick={(f) => enter(f.path)} />
|
||||
</div>
|
||||
</SingleFileListContainer>
|
||||
<div className="nutstore-current-path-container">
|
||||
<span>{t('settings.data.nutstore.pathSelector.currentPath')}</span>
|
||||
<span className="nutstore-current-path">{cwd ?? '/'}</span>
|
||||
</div>
|
||||
</Container>
|
||||
<NustorePathSelectorFooter
|
||||
returnPrev={pop}
|
||||
mkdir={() => setShowNewFolder(true)}
|
||||
cancel={props.onCancel}
|
||||
confirm={() => props.onConfirm(cwd ?? '')}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const FooterContainer = styled(HStack)`
|
||||
background: transparent;
|
||||
margin-top: 12px;
|
||||
padding: 0;
|
||||
border-top: none;
|
||||
border-radius: 0;
|
||||
`
|
||||
|
||||
interface FooterProps {
|
||||
returnPrev: () => void
|
||||
mkdir: () => void
|
||||
cancel: () => void
|
||||
confirm: () => void
|
||||
}
|
||||
|
||||
export function NustorePathSelectorFooter(props: FooterProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<FooterContainer justifyContent="space-between">
|
||||
<HStack gap={8} alignItems="center">
|
||||
<Button onClick={props.returnPrev}>{t('settings.data.nutstore.pathSelector.return')}</Button>
|
||||
<Button size="small" type="link" onClick={props.mkdir}>
|
||||
{t('settings.data.nutstore.new_folder.button')}
|
||||
</Button>
|
||||
</HStack>
|
||||
<HStack gap={8} alignItems="center">
|
||||
<Button type="default" onClick={props.cancel}>
|
||||
{t('settings.data.nutstore.new_folder.button.cancel')}
|
||||
</Button>
|
||||
<Button type="primary" onClick={props.confirm}>
|
||||
{t('backup.confirm.button')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</FooterContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Modal } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { NutstorePathSelector } from '../NutstorePathSelector'
|
||||
import { TopView } from '../TopView'
|
||||
|
||||
interface Props {
|
||||
fs: Nutstore.Fs
|
||||
resolve: (data: string | null) => void
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ resolve, fs }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onCancel = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
resolve(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={t('settings.data.nutstore.pathSelector.title')}
|
||||
transitionName="ant-move-down"
|
||||
afterClose={onClose}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
centered>
|
||||
<NutstorePathSelector fs={fs} onConfirm={resolve} onCancel={onCancel} />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const TopViewKey = 'NutstorePathPopup'
|
||||
|
||||
export default class NutstorePathPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
static show(fs: Nutstore.Fs) {
|
||||
return new Promise<any>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
fs={fs}
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
TopView.hide(TopViewKey)
|
||||
}}
|
||||
/>,
|
||||
TopViewKey
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { backupToWebdav, restoreFromWebdav } from '@renderer/services/BackupService'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Input, Modal, Select, Spin } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface BackupFile {
|
||||
fileName: string
|
||||
modifiedTime: string
|
||||
size: number
|
||||
}
|
||||
|
||||
interface WebdavModalProps {
|
||||
isModalVisible: boolean
|
||||
handleBackup: () => void
|
||||
handleCancel: () => void
|
||||
backuping: boolean
|
||||
customFileName: string
|
||||
setCustomFileName: (value: string) => void
|
||||
}
|
||||
|
||||
export function useWebdavBackupModal({ backupMethod }: { backupMethod?: typeof backupToWebdav } = {}) {
|
||||
const [customFileName, setCustomFileName] = useState('')
|
||||
const [isModalVisible, setIsModalVisible] = useState(false)
|
||||
const [backuping, setBackuping] = useState(false)
|
||||
|
||||
const handleBackup = async () => {
|
||||
setBackuping(true)
|
||||
try {
|
||||
await (backupMethod ?? backupToWebdav)({ showMessage: true, customFileName })
|
||||
} finally {
|
||||
setBackuping(false)
|
||||
setIsModalVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsModalVisible(false)
|
||||
}
|
||||
|
||||
const showBackupModal = useCallback(async () => {
|
||||
// 获取默认文件名
|
||||
const deviceType = await window.api.system.getDeviceType()
|
||||
const timestamp = dayjs().format('YYYYMMDDHHmmss')
|
||||
const defaultFileName = `cherry-studio.${timestamp}.${deviceType}.zip`
|
||||
setCustomFileName(defaultFileName)
|
||||
setIsModalVisible(true)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isModalVisible,
|
||||
handleBackup,
|
||||
handleCancel,
|
||||
backuping,
|
||||
customFileName,
|
||||
setCustomFileName,
|
||||
showBackupModal
|
||||
}
|
||||
}
|
||||
|
||||
export function WebdavBackupModal({
|
||||
isModalVisible,
|
||||
handleBackup,
|
||||
handleCancel,
|
||||
backuping,
|
||||
customFileName,
|
||||
setCustomFileName
|
||||
}: WebdavModalProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
interface WebdavRestoreModalProps {
|
||||
isRestoreModalVisible: boolean
|
||||
handleRestore: () => void
|
||||
handleCancel: () => void
|
||||
restoring: boolean
|
||||
selectedFile: string | null
|
||||
setSelectedFile: (value: string | null) => void
|
||||
loadingFiles: boolean
|
||||
backupFiles: BackupFile[]
|
||||
}
|
||||
|
||||
interface UseWebdavRestoreModalProps {
|
||||
webdavHost: string | undefined
|
||||
webdavUser: string | undefined
|
||||
webdavPass: string | undefined
|
||||
webdavPath: string | undefined
|
||||
restoreMethod?: typeof restoreFromWebdav
|
||||
}
|
||||
|
||||
export function useWebdavRestoreModal({
|
||||
webdavHost,
|
||||
webdavUser,
|
||||
webdavPass,
|
||||
webdavPath,
|
||||
restoreMethod
|
||||
}: UseWebdavRestoreModalProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [isRestoreModalVisible, setIsRestoreModalVisible] = useState(false)
|
||||
const [restoring, setRestoring] = useState(false)
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
||||
const [loadingFiles, setLoadingFiles] = useState(false)
|
||||
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
|
||||
|
||||
const showRestoreModal = useCallback(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)
|
||||
}
|
||||
}, [webdavHost, webdavUser, webdavPass, webdavPath, t])
|
||||
|
||||
const handleRestore = useCallback(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 (restoreMethod ?? restoreFromWebdav)(selectedFile)
|
||||
setIsRestoreModalVisible(false)
|
||||
} catch (error: any) {
|
||||
window.message.error({ content: error.message, key: 'restore-error' })
|
||||
} finally {
|
||||
setRestoring(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [selectedFile, webdavHost, webdavUser, webdavPass, webdavPath, t, restoreMethod])
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsRestoreModalVisible(false)
|
||||
}
|
||||
|
||||
return {
|
||||
isRestoreModalVisible,
|
||||
handleRestore,
|
||||
handleCancel,
|
||||
restoring,
|
||||
selectedFile,
|
||||
setSelectedFile,
|
||||
loadingFiles,
|
||||
backupFiles,
|
||||
showRestoreModal
|
||||
}
|
||||
}
|
||||
|
||||
export function WebdavRestoreModal({
|
||||
isRestoreModalVisible,
|
||||
handleRestore,
|
||||
handleCancel,
|
||||
restoring,
|
||||
selectedFile,
|
||||
setSelectedFile,
|
||||
loadingFiles,
|
||||
backupFiles
|
||||
}: WebdavRestoreModalProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('settings.data.webdav.restore.modal.title')}
|
||||
open={isRestoreModalVisible}
|
||||
onOk={handleRestore}
|
||||
onCancel={handleCancel}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function 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
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ const Sidebar: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { sidebarIcons } = useSettings()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const { theme, settingTheme, toggleTheme } = useTheme()
|
||||
const { pinned } = useMinapps()
|
||||
|
||||
const onEditUser = () => UserPopup.show()
|
||||
@@ -87,7 +87,10 @@ const Sidebar: FC = () => {
|
||||
<QuestionCircleOutlined />
|
||||
</Icon>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('settings.theme.title')} mouseEnterDelay={0.8} placement="right">
|
||||
<Tooltip
|
||||
title={t('settings.theme.title') + ': ' + t(`settings.theme.${settingTheme}`)}
|
||||
mouseEnterDelay={0.8}
|
||||
placement="right">
|
||||
<Icon theme={theme} onClick={() => toggleTheme()}>
|
||||
{theme === 'dark' ? (
|
||||
<i className="iconfont icon-theme icon-dark1" />
|
||||
|
||||
@@ -173,7 +173,7 @@ export const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-|dall|cogview|
|
||||
|
||||
// Reasoning models
|
||||
export const REASONING_REGEX =
|
||||
/^(o\d+(?:-[\w-]+)?|.*\b(?:reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*)$/i
|
||||
/^(o\d+(?:-[\w-]+)?|.*\b(?:reasoner|thinking)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bglm-zero-preview\b.*)$/i
|
||||
|
||||
// Embedding models
|
||||
export const EMBEDDING_REGEX =
|
||||
|
||||
@@ -5,11 +5,13 @@ import React, { createContext, PropsWithChildren, useContext, useEffect, useStat
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: ThemeMode
|
||||
settingTheme: ThemeMode
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({
|
||||
theme: ThemeMode.light,
|
||||
settingTheme: ThemeMode.light,
|
||||
toggleTheme: () => {}
|
||||
})
|
||||
|
||||
@@ -55,7 +57,11 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, defaultT
|
||||
}
|
||||
})
|
||||
|
||||
return <ThemeContext.Provider value={{ theme: _theme, toggleTheme }}>{children}</ThemeContext.Provider>
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme: _theme, settingTheme: theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTheme = () => useContext(ThemeContext)
|
||||
|
||||
@@ -18,7 +18,7 @@ import useUpdateHandler from './useUpdateHandler'
|
||||
|
||||
export function useAppInit() {
|
||||
const dispatch = useAppDispatch()
|
||||
const { proxyUrl, language, windowStyle, manualUpdateCheck, proxyMode, customCss } = useSettings()
|
||||
const { proxyUrl, language, windowStyle, autoCheckUpdate, proxyMode, customCss } = useSettings()
|
||||
const { minappShow } = useRuntime()
|
||||
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
|
||||
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
|
||||
@@ -36,13 +36,13 @@ export function useAppInit() {
|
||||
document.getElementById('spinner')?.remove()
|
||||
runAsyncFunction(async () => {
|
||||
const { isPackaged } = await window.api.getAppInfo()
|
||||
if (isPackaged && !manualUpdateCheck) {
|
||||
if (isPackaged && autoCheckUpdate) {
|
||||
await delay(2)
|
||||
const { updateInfo } = await window.api.checkForUpdate()
|
||||
dispatch(setUpdateState({ info: updateInfo }))
|
||||
}
|
||||
})
|
||||
}, [dispatch, manualUpdateCheck])
|
||||
}, [dispatch, autoCheckUpdate])
|
||||
|
||||
useEffect(() => {
|
||||
if (proxyMode === 'system') {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
export function useNutstoreSSO() {
|
||||
const nutstoreSSOHandler = useCallback(() => {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const removeListener = window.api.protocol.onReceiveData(async (data) => {
|
||||
try {
|
||||
const url = new URL(data.url)
|
||||
const params = new URLSearchParams(url.search)
|
||||
const encryptedToken = params.get('s')
|
||||
if (!encryptedToken) return reject(null)
|
||||
resolve(encryptedToken)
|
||||
} catch (error) {
|
||||
console.error('解析URL失败:', error)
|
||||
reject(null)
|
||||
} finally {
|
||||
removeListener()
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
return nutstoreSSOHandler
|
||||
}
|
||||
@@ -211,7 +211,8 @@
|
||||
"topics.prompt.tips": "Topic Prompts: Additional supplementary prompts provided for the current topic",
|
||||
"topics.title": "Topics",
|
||||
"topics.unpinned": "Unpinned Topics",
|
||||
"translate": "Translate"
|
||||
"translate": "Translate",
|
||||
"topics.export.siyuan": "Export to Siyuan Note"
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "Collapse",
|
||||
@@ -288,7 +289,8 @@
|
||||
"description": "Failed to render formula. Please check if the formula format is correct",
|
||||
"title": "Render Error"
|
||||
},
|
||||
"user_message_not_found": "Cannot find original user message to resend"
|
||||
"user_message_not_found": "Cannot find original user message to resend",
|
||||
"unknown": "Unknown error"
|
||||
},
|
||||
"export": {
|
||||
"assistant": "Assistant",
|
||||
@@ -473,6 +475,8 @@
|
||||
"error.invalid.webdav": "Invalid WebDAV settings",
|
||||
"error.joplin.export": "Failed to export to Joplin. Please keep Joplin running and check connection status or configuration",
|
||||
"error.joplin.no_config": "Joplin Authorization Token or URL is not configured",
|
||||
"error.invalid.nutstore": "Invalid Nutstore settings",
|
||||
"error.invalid.nutstore_token": "Invalid Nutstore Token",
|
||||
"error.markdown.export.preconf": "Failed to export the Markdown file to the preconfigured path",
|
||||
"error.markdown.export.specified": "Failed to export the Markdown file",
|
||||
"error.notion.export": "Failed to export to Notion. Please check connection status and configuration according to documentation",
|
||||
@@ -522,12 +526,18 @@
|
||||
"upgrade.success.content": "Please restart the application to complete the upgrade",
|
||||
"upgrade.success.title": "Upgrade successfully",
|
||||
"warn.notion.exporting": "Exporting to Notion, please do not request export repeatedly!",
|
||||
"warning.rate.limit": "Too many requests. Please wait {{seconds}} seconds before trying again."
|
||||
"warning.rate.limit": "Too many requests. Please wait {{seconds}} seconds before trying again.",
|
||||
"error.siyuan.export": "Failed to export to Siyuan Note, please check connection status and configuration according to documentation",
|
||||
"error.siyuan.no_config": "Siyuan Note API address or token is not configured",
|
||||
"success.siyuan.export": "Successfully exported to Siyuan Note",
|
||||
"warn.yuque.exporting": "Exporting to Yuque, please do not request export repeatedly!",
|
||||
"warn.siyuan.exporting": "Exporting to Siyuan Note, please do not request export repeatedly!"
|
||||
},
|
||||
"minapp": {
|
||||
"sidebar.add.title": "Add to sidebar",
|
||||
"sidebar.remove.title": "Remove from sidebar",
|
||||
"title": "MinApp"
|
||||
"title": "MinApp",
|
||||
"sidebar.hide.title": "Hide MinApp"
|
||||
},
|
||||
"miniwindow": {
|
||||
"clipboard": {
|
||||
@@ -843,6 +853,48 @@
|
||||
"title": "Yuque Configuration",
|
||||
"token": "Yuque Token",
|
||||
"token_placeholder": "Please enter the Yuque Token"
|
||||
},
|
||||
"siyuan": {
|
||||
"title": "Siyuan Note Configuration",
|
||||
"api_url": "Siyuan Note API URL",
|
||||
"api_url_placeholder": "e.g.: http://127.0.0.1:6806",
|
||||
"token": "Siyuan Note Token",
|
||||
"token.help": "Get Siyuan Note Token",
|
||||
"token_placeholder": "Please enter Siyuan Note Token",
|
||||
"box_id": "Siyuan Note Box ID",
|
||||
"box_id_placeholder": "Please enter Siyuan Note Box ID",
|
||||
"root_path": "Siyuan Note Root Path",
|
||||
"root_path_placeholder": "e.g.: /CherryStudio",
|
||||
"check": {
|
||||
"title": "Connection Check",
|
||||
"button": "Check",
|
||||
"empty_config": "Please fill in the API address and token",
|
||||
"success": "Connection successful",
|
||||
"fail": "Connection failed, please check API address and token",
|
||||
"error": "Connection error, please check network connection"
|
||||
}
|
||||
},
|
||||
"nutstore": {
|
||||
"title": "Nutstore Configuration",
|
||||
"isLogin": "Logged in",
|
||||
"notLogin": "Not logged in",
|
||||
"login.button": "Login",
|
||||
"logout.button": "Logout",
|
||||
"logout.title": "Are you sure you want to logout from Nutstore?",
|
||||
"logout.content": "After logout, you will not be able to backup to Nutstore or restore from Nutstore.",
|
||||
"checkConnection.name": "Check Connection",
|
||||
"checkConnection.success": "Connected to Nutstore",
|
||||
"checkConnection.fail": "Nutstore connection failed",
|
||||
"username": "Nutstore Username",
|
||||
"path": "Nutstore Storage Path",
|
||||
"path.placeholder": "Enter Nutstore storage path",
|
||||
"backup.button": "Backup to Nutstore",
|
||||
"restore.button": "Restore from Nutstore",
|
||||
"pathSelector.title": "Nutstore Storage Path",
|
||||
"pathSelector.return": "Return",
|
||||
"pathSelector.currentPath": "Current Path",
|
||||
"new_folder.button.confirm": "Confirm",
|
||||
"new_folder.button.cancel": "Cancel"
|
||||
}
|
||||
},
|
||||
"display.assistant.title": "Assistant Settings",
|
||||
@@ -873,7 +925,7 @@
|
||||
"general.display.title": "Display Settings",
|
||||
"general.emoji_picker": "Emoji Picker",
|
||||
"general.image_upload": "Image Upload",
|
||||
"general.manually_check_update.title": "Turn off update checking",
|
||||
"general.auto_check_update.title": "Auto update checking",
|
||||
"general.reset.button": "Reset",
|
||||
"general.reset.title": "Data Reset",
|
||||
"general.restore.button": "Restore",
|
||||
|
||||
@@ -211,7 +211,8 @@
|
||||
"topics.prompt.tips": "トピック提示語:現在のトピックに対して追加の補足提示語を提供",
|
||||
"topics.title": "トピック",
|
||||
"topics.unpinned": "固定解除",
|
||||
"translate": "翻訳"
|
||||
"translate": "翻訳",
|
||||
"topics.export.siyuan": "思源笔记にエクスポート"
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "折りたたむ",
|
||||
@@ -288,7 +289,8 @@
|
||||
"description": "数式のレンダリングに失敗しました。数式の形式が正しいか確認してください",
|
||||
"title": "レンダリングエラー"
|
||||
},
|
||||
"user_message_not_found": "元のユーザーメッセージを見つけることができませんでした"
|
||||
"user_message_not_found": "元のユーザーメッセージを見つけることができませんでした",
|
||||
"unknown": "不明なエラー"
|
||||
},
|
||||
"export": {
|
||||
"assistant": "アシスタント",
|
||||
@@ -473,12 +475,13 @@
|
||||
"error.invalid.webdav": "無効なWebDAV設定",
|
||||
"error.joplin.export": "Joplin へのエクスポートに失敗しました。Joplin が実行中であることを確認してください",
|
||||
"error.joplin.no_config": "Joplin 認証トークン または URL が設定されていません",
|
||||
"error.invalid.nutstore": "無効なNutstore設定です",
|
||||
"error.invalid.nutstore_token": "無効なNutstoreトークンです",
|
||||
"error.markdown.export.preconf": "Markdown ファイルを事前設定されたパスにエクスポートできませんでした",
|
||||
"error.markdown.export.specified": "Markdown ファイルのエクスポートに失敗しました",
|
||||
"error.notion.export": "Notionへのエクスポートに失敗しました。接続状態と設定を確認してください",
|
||||
"error.notion.no_api_key": "Notion ApiKey または Notion DatabaseID が設定されていません",
|
||||
"error.yuque.export": "語雀へのエクスポートに失敗しました。接続状態と設定を確認してください",
|
||||
"error.yuque.no_config": "語雀Token または 知識ベースID が設定されていません",
|
||||
"group.delete.content": "分組メッセージを削除するとユーザーの質問と助け手の回答がすべて削除されます",
|
||||
"group.delete.title": "分組メッセージを削除",
|
||||
"ignore.knowledge.base": "インターネットモードが有効になっています。ナレッジベースを無視します",
|
||||
@@ -522,11 +525,18 @@
|
||||
"upgrade.success.content": "アップグレードを完了するためにアプリケーションを再起動してください",
|
||||
"upgrade.success.title": "アップグレードに成功しました",
|
||||
"warn.notion.exporting": "Notionにエクスポート中です。重複してエクスポートしないでください! ",
|
||||
"warning.rate.limit": "送信が頻繁すぎます。{{seconds}} 秒待ってから再試行してください。"
|
||||
"warning.rate.limit": "送信が頻繁すぎます。{{seconds}} 秒待ってから再試行してください。",
|
||||
"error.siyuan.export": "思源ノートのエクスポートに失敗しました。接続状態を確認し、ドキュメントに従って設定を確認してください",
|
||||
"error.siyuan.no_config": "思源ノートのAPIアドレスまたはトークンが設定されていません",
|
||||
"success.siyuan.export": "思源ノートへのエクスポートに成功しました",
|
||||
"warn.yuque.exporting": "語雀にエクスポート中です。重複してエクスポートしないでください!",
|
||||
"warn.siyuan.exporting": "思源ノートにエクスポート中です。重複してエクスポートしないでください!",
|
||||
"error.yuque.no_config": "語雀のAPIアドレスまたはトークンが設定されていません"
|
||||
},
|
||||
"minapp": {
|
||||
"sidebar.add.title": "サイドバーに追加",
|
||||
"sidebar.remove.title": "サイドバーから削除",
|
||||
"sidebar.hide.title": "ミニアプリを隠す",
|
||||
"title": "ミニアプリ"
|
||||
},
|
||||
"miniwindow": {
|
||||
@@ -843,6 +853,48 @@
|
||||
"title": "Yuque設定",
|
||||
"token": "Yuqueトークン",
|
||||
"token_placeholder": "Yuqueトークンを入力してください"
|
||||
},
|
||||
"siyuan": {
|
||||
"title": "思源ノート設定",
|
||||
"api_url": "APIアドレス",
|
||||
"api_url_placeholder": "例:http://127.0.0.1:6806",
|
||||
"token": "APIトークン",
|
||||
"token.help": "思源ノート->設定->について で取得",
|
||||
"token_placeholder": "思源ノートトークンを入力してください",
|
||||
"box_id": "ノートブックID",
|
||||
"box_id_placeholder": "ノートブックIDを入力してください",
|
||||
"root_path": "ドキュメントルートパス",
|
||||
"root_path_placeholder": "例:/CherryStudio",
|
||||
"check": {
|
||||
"title": "接続チェック",
|
||||
"button": "チェック",
|
||||
"empty_config": "APIアドレスとトークンを入力してください",
|
||||
"success": "接続成功",
|
||||
"fail": "接続失敗、APIアドレスとトークンを確認してください",
|
||||
"error": "接続エラー、ネットワーク接続を確認してください"
|
||||
}
|
||||
},
|
||||
"nutstore": {
|
||||
"title": "Nutstore設定",
|
||||
"isLogin": "ログイン済み",
|
||||
"notLogin": "未ログイン",
|
||||
"login.button": "ログイン",
|
||||
"logout.button": "ログアウト",
|
||||
"logout.title": "Nutstoreからログアウトしますか?",
|
||||
"logout.content": "ログアウト後、Nutstoreへのバックアップや復元ができなくなります。",
|
||||
"checkConnection.name": "接続確認",
|
||||
"checkConnection.success": "Nutstoreに接続しました",
|
||||
"checkConnection.fail": "Nutstore接続に失敗しました",
|
||||
"username": "Nutstoreユーザー名",
|
||||
"path": "Nutstoreストレージパス",
|
||||
"path.placeholder": "Nutstoreストレージパスを入力",
|
||||
"backup.button": "Nutstoreにバックアップ",
|
||||
"restore.button": "Nutstoreから復元",
|
||||
"pathSelector.title": "Nutstoreストレージパス",
|
||||
"pathSelector.return": "戻る",
|
||||
"pathSelector.currentPath": "現在のパス",
|
||||
"new_folder.button.confirm": "確認",
|
||||
"new_folder.button.cancel": "キャンセル"
|
||||
}
|
||||
},
|
||||
"display.assistant.title": "アシスタント設定",
|
||||
@@ -873,7 +925,6 @@
|
||||
"general.display.title": "表示設定",
|
||||
"general.emoji_picker": "絵文字ピッカー",
|
||||
"general.image_upload": "画像アップロード",
|
||||
"general.manually_check_update.title": "更新チェックを無効にする",
|
||||
"general.reset.button": "リセット",
|
||||
"general.reset.title": "データをリセット",
|
||||
"general.restore.button": "復元",
|
||||
@@ -1159,6 +1210,7 @@
|
||||
"tray.show": "トレイアイコンを表示",
|
||||
"tray.title": "トレイ"
|
||||
},
|
||||
"general.auto_check_update.title": "自動更新チェックを有効にする",
|
||||
"translate": {
|
||||
"any.language": "任意の言語",
|
||||
"button.translate": "翻訳",
|
||||
|
||||
@@ -211,7 +211,8 @@
|
||||
"topics.prompt.tips": "Тематические подсказки: Дополнительные подсказки, предоставленные для текущей темы",
|
||||
"topics.title": "Топики",
|
||||
"topics.unpinned": "Открепленные темы",
|
||||
"translate": "Перевести"
|
||||
"translate": "Перевести",
|
||||
"topics.export.siyuan": "Экспорт в Siyuan Note"
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "Свернуть",
|
||||
@@ -288,7 +289,8 @@
|
||||
"description": "Не удалось рендерить формулу. Пожалуйста, проверьте, правильно ли формат формулы",
|
||||
"title": "Ошибка рендеринга"
|
||||
},
|
||||
"user_message_not_found": "Не удалось найти исходное сообщение пользователя"
|
||||
"user_message_not_found": "Не удалось найти исходное сообщение пользователя",
|
||||
"unknown": "Неизвестная ошибка"
|
||||
},
|
||||
"export": {
|
||||
"assistant": "Ассистент",
|
||||
@@ -473,6 +475,8 @@
|
||||
"error.invalid.webdav": "Неверные настройки WebDAV",
|
||||
"error.joplin.export": "Не удалось экспортировать в Joplin, пожалуйста, убедитесь, что Joplin запущен и проверьте состояние подключения или настройки",
|
||||
"error.joplin.no_config": "Joplin Authorization Token или URL не настроен",
|
||||
"error.invalid.nutstore": "Неверные настройки Nutstore",
|
||||
"error.invalid.nutstore_token": "Неверный Nutstore токен",
|
||||
"error.markdown.export.preconf": "Не удалось экспортировать файл Markdown в предуказанный путь",
|
||||
"error.markdown.export.specified": "Не удалось экспортировать файл Markdown",
|
||||
"error.notion.export": "Ошибка экспорта в Notion, пожалуйста, проверьте состояние подключения и настройки в документации",
|
||||
@@ -522,11 +526,17 @@
|
||||
"upgrade.success.content": "Пожалуйста, перезапустите приложение для завершения обновления",
|
||||
"upgrade.success.title": "Обновление успешно",
|
||||
"warn.notion.exporting": "Экспортируется в Notion, пожалуйста, не отправляйте повторные запросы!",
|
||||
"warning.rate.limit": "Отправка слишком частая, пожалуйста, подождите {{seconds}} секунд, прежде чем попробовать снова."
|
||||
"warning.rate.limit": "Отправка слишком частая, пожалуйста, подождите {{seconds}} секунд, прежде чем попробовать снова.",
|
||||
"error.siyuan.export": "Ошибка экспорта в Siyuan, пожалуйста, проверьте состояние подключения и настройки в документации",
|
||||
"error.siyuan.no_config": "Не настроен API адрес или токен Siyuan",
|
||||
"success.siyuan.export": "Успешный экспорт в Siyuan",
|
||||
"warn.yuque.exporting": "Экспортируется в Yuque, пожалуйста, не отправляйте повторные запросы!",
|
||||
"warn.siyuan.exporting": "Экспортируется в Siyuan, пожалуйста, не отправляйте повторные запросы!"
|
||||
},
|
||||
"minapp": {
|
||||
"sidebar.add.title": "Добавить в боковую панель",
|
||||
"sidebar.remove.title": "Удалить из боковой панели",
|
||||
"sidebar.hide.title": "Скрыть приложение",
|
||||
"title": "Встроенные приложения"
|
||||
},
|
||||
"miniwindow": {
|
||||
@@ -843,6 +853,48 @@
|
||||
"title": "Настройка Yuque",
|
||||
"token": "Токен Yuque",
|
||||
"token_placeholder": "Введите токен Yuque"
|
||||
},
|
||||
"siyuan": {
|
||||
"title": "Конфигурация SiYuan Note",
|
||||
"api_url": "API адрес",
|
||||
"api_url_placeholder": "Например: http://127.0.0.1:6806",
|
||||
"token": "API токен",
|
||||
"token.help": "Получите в SiYuan Note -> Настройки -> О программе",
|
||||
"token_placeholder": "Введите токен SiYuan Note",
|
||||
"box_id": "ID блокнота",
|
||||
"box_id_placeholder": "Введите ID блокнота",
|
||||
"root_path": "Корневой путь документа",
|
||||
"root_path_placeholder": "Например: /CherryStudio",
|
||||
"check": {
|
||||
"title": "Проверка соединения",
|
||||
"button": "Проверить",
|
||||
"empty_config": "Пожалуйста, заполните API адрес и токен",
|
||||
"success": "Соединение успешно",
|
||||
"fail": "Не удалось подключиться, проверьте API адрес и токен",
|
||||
"error": "Ошибка соединения, проверьте сетевое подключение"
|
||||
}
|
||||
},
|
||||
"nutstore": {
|
||||
"title": "Настройки Nutstore",
|
||||
"isLogin": "Выполнен вход",
|
||||
"notLogin": "Вход не выполнен",
|
||||
"login.button": "Войти",
|
||||
"logout.button": "Выйти",
|
||||
"logout.title": "Вы уверены, что хотите выйти из Nutstore?",
|
||||
"logout.content": "После выхода вы не сможете создавать резервные копии в Nutstore или восстанавливать данные из Nutstore.",
|
||||
"checkConnection.name": "Проверить соединение",
|
||||
"checkConnection.success": "Подключение к Nutstore установлено",
|
||||
"checkConnection.fail": "Ошибка подключения к Nutstore",
|
||||
"username": "Имя пользователя Nutstore",
|
||||
"path": "Путь хранения Nutstore",
|
||||
"path.placeholder": "Введите путь хранения Nutstore",
|
||||
"backup.button": "Резервное копирование в Nutstore",
|
||||
"restore.button": "Восстановление из Nutstore",
|
||||
"pathSelector.title": "Путь хранения Nutstore",
|
||||
"pathSelector.return": "Назад",
|
||||
"pathSelector.currentPath": "Текущий путь",
|
||||
"new_folder.button.confirm": "Подтвердить",
|
||||
"new_folder.button.cancel": "Отмена"
|
||||
}
|
||||
},
|
||||
"display.assistant.title": "Настройки ассистентов",
|
||||
@@ -873,7 +925,6 @@
|
||||
"general.display.title": "Настройки отображения",
|
||||
"general.emoji_picker": "Выбор эмодзи",
|
||||
"general.image_upload": "Загрузка изображений",
|
||||
"general.manually_check_update.title": "Отключить проверку обновлений",
|
||||
"general.reset.button": "Сброс",
|
||||
"general.reset.title": "Сброс данных",
|
||||
"general.restore.button": "Восстановление",
|
||||
@@ -1147,7 +1198,8 @@
|
||||
"title": "Tavily"
|
||||
},
|
||||
"title": "Поиск в Интернете"
|
||||
}
|
||||
},
|
||||
"general.auto_check_update.title": "Включить автоматическую проверку обновлений"
|
||||
},
|
||||
"topic.position": "Позиция топиков",
|
||||
"topic.position.left": "Слева",
|
||||
@@ -1157,6 +1209,7 @@
|
||||
"tray.show": "Показать значок в трее",
|
||||
"tray.title": "Трей"
|
||||
},
|
||||
"general.auto_check_update.title": "Включить автоматическую проверку обновлений",
|
||||
"translate": {
|
||||
"any.language": "Любой язык",
|
||||
"button.translate": "Перевести",
|
||||
|
||||
@@ -211,7 +211,8 @@
|
||||
"topics.prompt.tips": "话题提示词: 针对当前话题提供额外的补充提示词",
|
||||
"topics.title": "话题",
|
||||
"topics.unpinned": "取消固定",
|
||||
"translate": "翻译"
|
||||
"translate": "翻译",
|
||||
"topics.export.siyuan": "导出到思源笔记"
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "收起",
|
||||
@@ -288,7 +289,8 @@
|
||||
"description": "渲染公式失败,请检查公式格式是否正确",
|
||||
"title": "渲染错误"
|
||||
},
|
||||
"user_message_not_found": "无法找到原始用户消息"
|
||||
"user_message_not_found": "无法找到原始用户消息",
|
||||
"unknown": "未知错误"
|
||||
},
|
||||
"export": {
|
||||
"assistant": "助手",
|
||||
@@ -469,6 +471,8 @@
|
||||
"error.invalid.webdav": "无效的 WebDAV 设置",
|
||||
"error.joplin.export": "导出 Joplin 失败,请保持 Joplin 已运行并检查连接状态或检查配置",
|
||||
"error.joplin.no_config": "未配置 Joplin 授权令牌 或 URL",
|
||||
"error.invalid.nutstore": "无效的坚果云设置",
|
||||
"error.invalid.nutstore_token": "无效的坚果云 Token",
|
||||
"error.markdown.export.preconf": "导出Markdown文件到预先设定的路径失败",
|
||||
"error.markdown.export.specified": "导出Markdown文件失败",
|
||||
"error.notion.export": "导出 Notion 错误,请检查连接状态并对照文档检查配置",
|
||||
@@ -518,11 +522,17 @@
|
||||
"upgrade.success.content": "重启用以完成升级",
|
||||
"upgrade.success.title": "升级成功",
|
||||
"warn.notion.exporting": "正在导出到 Notion, 请勿重复请求导出!",
|
||||
"warning.rate.limit": "发送过于频繁,请等待 {{seconds}} 秒后再尝试"
|
||||
"warning.rate.limit": "发送过于频繁,请等待 {{seconds}} 秒后再尝试",
|
||||
"error.siyuan.export": "导出思源笔记失败,请检查连接状态并对照文档检查配置",
|
||||
"error.siyuan.no_config": "未配置思源笔记API地址或令牌",
|
||||
"success.siyuan.export": "导出到思源笔记成功",
|
||||
"warn.yuque.exporting": "正在导出语雀, 请勿重复请求导出!",
|
||||
"warn.siyuan.exporting": "正在导出到思源笔记,请勿重复请求导出!"
|
||||
},
|
||||
"minapp": {
|
||||
"sidebar.add.title": "添加到侧边栏",
|
||||
"sidebar.remove.title": "从侧边栏移除",
|
||||
"sidebar.hide.title": "隐藏小程序",
|
||||
"title": "小程序"
|
||||
},
|
||||
"miniwindow": {
|
||||
@@ -679,7 +689,7 @@
|
||||
"yi": "零一万物",
|
||||
"zhinao": "360智脑",
|
||||
"zhipu": "智谱AI",
|
||||
"voyageai":"Voyage AI"
|
||||
"voyageai": "Voyage AI"
|
||||
},
|
||||
"restore": {
|
||||
"confirm": "确定要恢复数据吗?",
|
||||
@@ -839,6 +849,48 @@
|
||||
"title": "语雀配置",
|
||||
"token": "语雀 Token",
|
||||
"token_placeholder": "请输入语雀Token"
|
||||
},
|
||||
"siyuan": {
|
||||
"title": "思源笔记配置",
|
||||
"api_url": "API地址",
|
||||
"api_url_placeholder": "例如:http://127.0.0.1:6806",
|
||||
"token": "API令牌",
|
||||
"token.help": "在思源笔记->设置->关于中获取",
|
||||
"token_placeholder": "请输入思源笔记令牌",
|
||||
"box_id": "笔记本ID",
|
||||
"box_id_placeholder": "请输入笔记本ID",
|
||||
"root_path": "文档根路径",
|
||||
"root_path_placeholder": "例如:/CherryStudio",
|
||||
"check": {
|
||||
"title": "连接检查",
|
||||
"button": "检查",
|
||||
"empty_config": "请填写API地址和令牌",
|
||||
"success": "连接成功",
|
||||
"fail": "连接失败,请检查API地址和令牌",
|
||||
"error": "连接异常,请检查网络连接"
|
||||
}
|
||||
},
|
||||
"nutstore": {
|
||||
"title": "坚果云配置",
|
||||
"isLogin": "已登录",
|
||||
"notLogin": "未登录",
|
||||
"login.button": "登录",
|
||||
"logout.button": "退出登录",
|
||||
"logout.title": "确定要退出坚果云登录?",
|
||||
"logout.content": "退出后将无法备份至坚果云和从坚果云恢复",
|
||||
"checkConnection.name": "检查连接",
|
||||
"checkConnection.success": "已连接坚果云",
|
||||
"checkConnection.fail": "坚果云连接失败",
|
||||
"username": "坚果云用户名",
|
||||
"path": "坚果云存储路径",
|
||||
"path.placeholder": "请输入坚果云的存储路径",
|
||||
"backup.button": "备份到坚果云",
|
||||
"restore.button": "从坚果云恢复",
|
||||
"pathSelector.title": "坚果云存储路径",
|
||||
"pathSelector.return": "返回",
|
||||
"pathSelector.currentPath": "当前路径",
|
||||
"new_folder.button.confirm": "确定",
|
||||
"new_folder.button.cancel": "取消"
|
||||
}
|
||||
},
|
||||
"display.assistant.title": "助手设置",
|
||||
@@ -869,7 +921,7 @@
|
||||
"general.display.title": "显示设置",
|
||||
"general.emoji_picker": "表情选择器",
|
||||
"general.image_upload": "图片上传",
|
||||
"general.manually_check_update.title": "关闭更新检测",
|
||||
"general.auto_check_update.title": "自动检测更新",
|
||||
"general.reset.button": "重置",
|
||||
"general.reset.title": "重置数据",
|
||||
"general.restore.button": "恢复",
|
||||
|
||||
@@ -211,7 +211,8 @@
|
||||
"topics.prompt.tips": "話題提示詞:針對目前話題提供額外的補充提示詞",
|
||||
"topics.title": "話題",
|
||||
"topics.unpinned": "取消固定",
|
||||
"translate": "翻譯"
|
||||
"translate": "翻譯",
|
||||
"topics.export.siyuan": "匯出到思源筆記"
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "折疊",
|
||||
@@ -288,7 +289,8 @@
|
||||
"description": "渲染公式失敗,請檢查公式格式是否正確",
|
||||
"title": "渲染錯誤"
|
||||
},
|
||||
"user_message_not_found": "無法找到原始用戶訊息"
|
||||
"user_message_not_found": "無法找到原始用戶訊息",
|
||||
"unknown": "未知錯誤"
|
||||
},
|
||||
"export": {
|
||||
"assistant": "助手",
|
||||
@@ -473,6 +475,8 @@
|
||||
"error.invalid.webdav": "無效的 WebDAV 設定",
|
||||
"error.joplin.export": "匯出 Joplin 失敗,請保持 Joplin 已運行並檢查連接狀態或檢查設定",
|
||||
"error.joplin.no_config": "未設定 Joplin 授權Token 或 URL",
|
||||
"error.invalid.nutstore": "無效的坚果云設定",
|
||||
"error.invalid.nutstore_token": "無效的坚果云 Token",
|
||||
"error.markdown.export.preconf": "導出 Markdown 文件到預先設定的路徑失敗",
|
||||
"error.markdown.export.specified": "導出 Markdown 文件失敗",
|
||||
"error.notion.export": "匯出 Notion 錯誤,請檢查連接狀態並對照文件檢查設定",
|
||||
@@ -522,11 +526,17 @@
|
||||
"upgrade.success.content": "請重新啟動程式以完成升級",
|
||||
"upgrade.success.title": "升級成功",
|
||||
"warn.notion.exporting": "正在匯出到 Notion,請勿重複請求匯出!",
|
||||
"warning.rate.limit": "發送過於頻繁,請在 {{seconds}} 秒後再嘗試"
|
||||
"warning.rate.limit": "發送過於頻繁,請在 {{seconds}} 秒後再嘗試",
|
||||
"error.siyuan.export": "導出思源筆記失敗,請檢查連接狀態並對照文檔檢查配置",
|
||||
"error.siyuan.no_config": "未配置思源筆記API地址或令牌",
|
||||
"success.siyuan.export": "導出到思源筆記成功",
|
||||
"warn.yuque.exporting": "正在導出語雀,請勿重複請求導出!",
|
||||
"warn.siyuan.exporting": "正在導出到思源筆記,請勿重複請求導出!"
|
||||
},
|
||||
"minapp": {
|
||||
"sidebar.add.title": "新增到側邊欄",
|
||||
"sidebar.remove.title": "從側邊欄移除",
|
||||
"sidebar.hide.title": "隱藏小工具",
|
||||
"title": "小工具"
|
||||
},
|
||||
"miniwindow": {
|
||||
@@ -843,6 +853,48 @@
|
||||
"title": "語雀設定",
|
||||
"token": "語雀 Token",
|
||||
"token_placeholder": "請輸入語雀 Token"
|
||||
},
|
||||
"siyuan": {
|
||||
"title": "思源筆記配置",
|
||||
"api_url": "API地址",
|
||||
"api_url_placeholder": "例如:http://127.0.0.1:6806",
|
||||
"token": "API令牌",
|
||||
"token.help": "在思源筆記->設置->關於中獲取",
|
||||
"token_placeholder": "請輸入思源筆記令牌",
|
||||
"box_id": "筆記本ID",
|
||||
"box_id_placeholder": "請輸入筆記本ID",
|
||||
"root_path": "文檔根路徑",
|
||||
"root_path_placeholder": "例如:/CherryStudio",
|
||||
"check": {
|
||||
"title": "連接檢查",
|
||||
"button": "檢查",
|
||||
"empty_config": "請填寫API地址和令牌",
|
||||
"success": "連接成功",
|
||||
"fail": "連接失敗,請檢查API地址和令牌",
|
||||
"error": "連接異常,請檢查網絡連接"
|
||||
}
|
||||
},
|
||||
"nutstore": {
|
||||
"title": "堅果雲設定",
|
||||
"isLogin": "已登入",
|
||||
"notLogin": "未登入",
|
||||
"login.button": "登入",
|
||||
"logout.button": "退出登入",
|
||||
"logout.title": "確定要退出堅果雲登入?",
|
||||
"logout.content": "退出後將無法備份至堅果雲和從堅果雲恢復",
|
||||
"checkConnection.name": "檢查連接",
|
||||
"checkConnection.success": "已連接堅果雲",
|
||||
"checkConnection.fail": "堅果雲連接失敗",
|
||||
"username": "堅果雲用戶名",
|
||||
"path": "堅果雲存儲路徑",
|
||||
"path.placeholder": "請輸入堅果雲的存儲路徑",
|
||||
"backup.button": "備份到堅果雲",
|
||||
"restore.button": "從堅果雲恢復",
|
||||
"pathSelector.title": "堅果雲存儲路徑",
|
||||
"pathSelector.return": "返回",
|
||||
"pathSelector.currentPath": "當前路徑",
|
||||
"new_folder.button.confirm": "確定",
|
||||
"new_folder.button.cancel": "取消"
|
||||
}
|
||||
},
|
||||
"display.assistant.title": "助手設定",
|
||||
@@ -873,7 +925,6 @@
|
||||
"general.display.title": "顯示設定",
|
||||
"general.emoji_picker": "表情選擇器",
|
||||
"general.image_upload": "圖片上傳",
|
||||
"general.manually_check_update.title": "關閉更新檢查",
|
||||
"general.reset.button": "重設",
|
||||
"general.reset.title": "資料重設",
|
||||
"general.restore.button": "復原",
|
||||
@@ -1158,6 +1209,7 @@
|
||||
"tray.show": "顯示系统匣圖示",
|
||||
"tray.title": "系统匣"
|
||||
},
|
||||
"general.auto_check_update.title": "啟用自動更新檢查",
|
||||
"translate": {
|
||||
"any.language": "任意語言",
|
||||
"button.translate": "翻譯",
|
||||
|
||||
@@ -512,6 +512,7 @@
|
||||
"minapp": {
|
||||
"sidebar.add.title": "Προσθήκη στην πλευρή",
|
||||
"sidebar.remove.title": "Αφαίρεση από την πλευρή",
|
||||
"sidebar.hide.title": "Απόκρυψη μικροπρογράμματος",
|
||||
"title": "Μικρόπρογραμμα"
|
||||
},
|
||||
"miniwindow": {
|
||||
|
||||
@@ -512,6 +512,7 @@
|
||||
"minapp": {
|
||||
"sidebar.add.title": "Agregar al panel lateral",
|
||||
"sidebar.remove.title": "Quitar del panel lateral",
|
||||
"sidebar.hide.title": "Ocultar mini programa",
|
||||
"title": "Mini programa"
|
||||
},
|
||||
"miniwindow": {
|
||||
|
||||
@@ -512,6 +512,7 @@
|
||||
"minapp": {
|
||||
"sidebar.add.title": "Ajouter à la barre latérale",
|
||||
"sidebar.remove.title": "Supprimer de la barre latérale",
|
||||
"sidebar.hide.title": "Masquer le mini-programme",
|
||||
"title": "Mini-programme"
|
||||
},
|
||||
"miniwindow": {
|
||||
|
||||
@@ -512,6 +512,7 @@
|
||||
"minapp": {
|
||||
"sidebar.add.title": "Adicionar à barra lateral",
|
||||
"sidebar.remove.title": "Remover da barra lateral",
|
||||
"sidebar.hide.title": "Ocultar aplicativo",
|
||||
"title": "Pequeno aplicativo"
|
||||
},
|
||||
"miniwindow": {
|
||||
|
||||
@@ -16,7 +16,7 @@ interface Props {
|
||||
|
||||
const App: FC<Props> = ({ app, onClick, size = 60 }) => {
|
||||
const { t } = useTranslation()
|
||||
const { minapps, pinned, updatePinnedMinapps } = useMinapps()
|
||||
const { minapps, pinned, disabled, updateMinapps, updateDisabledMinapps, updatePinnedMinapps } = useMinapps()
|
||||
const isPinned = pinned.some((p) => p.id === app.id)
|
||||
const isVisible = minapps.some((m) => m.id === app.id)
|
||||
|
||||
@@ -33,6 +33,18 @@ const App: FC<Props> = ({ app, onClick, size = 60 }) => {
|
||||
const newPinned = isPinned ? pinned.filter((item) => item.id !== app.id) : [...(pinned || []), app]
|
||||
updatePinnedMinapps(newPinned)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'hide',
|
||||
label: t('minapp.sidebar.hide.title'),
|
||||
onClick: () => {
|
||||
const newMinapps = minapps.filter((item) => item.id !== app.id)
|
||||
updateMinapps(newMinapps)
|
||||
const newDisabled = [...(disabled || []), app]
|
||||
updateDisabledMinapps(newDisabled)
|
||||
const newPinned = pinned.filter((item) => item.id !== app.id)
|
||||
updatePinnedMinapps(newPinned)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import {
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
RotateLeftOutlined,
|
||||
RotateRightOutlined,
|
||||
SwapOutlined,
|
||||
UndoOutlined,
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { Message } from '@renderer/types'
|
||||
import { Image as AntdImage } from 'antd'
|
||||
import { Image as AntdImage, Space } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
@@ -8,10 +19,88 @@ interface Props {
|
||||
}
|
||||
|
||||
const MessageImage: FC<Props> = ({ message }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onDownload = (imageBase64: string, index: number) => {
|
||||
try {
|
||||
const link = document.createElement('a')
|
||||
link.href = imageBase64
|
||||
link.download = `image-${Date.now()}-${index}.png`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.message.success(t('message.download.success'))
|
||||
} catch (error) {
|
||||
console.error('下载图片失败:', error)
|
||||
window.message.error(t('message.download.failed'))
|
||||
}
|
||||
}
|
||||
|
||||
// 复制 base64 图片到剪贴板
|
||||
const onCopy = async (imageBase64: string) => {
|
||||
try {
|
||||
const base64Data = imageBase64.split(',')[1]
|
||||
const mimeType = imageBase64.split(';')[0].split(':')[1]
|
||||
|
||||
const byteCharacters = atob(base64Data)
|
||||
const byteArrays: Uint8Array[] = []
|
||||
|
||||
for (let i = 0; i < byteCharacters.length; i += 512) {
|
||||
const slice = byteCharacters.slice(i, i + 512)
|
||||
|
||||
const byteNumbers = new Array(slice.length)
|
||||
for (let j = 0; j < slice.length; j++) {
|
||||
byteNumbers[j] = slice.charCodeAt(j)
|
||||
}
|
||||
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
byteArrays.push(byteArray)
|
||||
}
|
||||
|
||||
const blob = new Blob(byteArrays, { type: mimeType })
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[blob.type]: blob
|
||||
})
|
||||
])
|
||||
|
||||
window.message.success(t('message.copy.success'))
|
||||
} catch (error) {
|
||||
console.error('复制图片失败:', error)
|
||||
window.message.error(t('message.copy.failed'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container style={{ marginBottom: 8 }}>
|
||||
{message.metadata?.generateImage!.images.map((image, index) => (
|
||||
<Image src={image} key={`image-${index}`} width="33%" />
|
||||
<Image
|
||||
src={image}
|
||||
key={`image-${index}`}
|
||||
width="33%"
|
||||
preview={{
|
||||
toolbarRender: (
|
||||
_,
|
||||
{
|
||||
transform: { scale },
|
||||
actions: { onFlipY, onFlipX, onRotateLeft, onRotateRight, onZoomOut, onZoomIn, onReset }
|
||||
}
|
||||
) => (
|
||||
<ToobarWrapper size={12} className="toolbar-wrapper">
|
||||
<SwapOutlined rotate={90} onClick={onFlipY} />
|
||||
<SwapOutlined onClick={onFlipX} />
|
||||
<RotateLeftOutlined onClick={onRotateLeft} />
|
||||
<RotateRightOutlined onClick={onRotateRight} />
|
||||
<ZoomOutOutlined disabled={scale === 1} onClick={onZoomOut} />
|
||||
<ZoomInOutlined disabled={scale === 50} onClick={onZoomIn} />
|
||||
<UndoOutlined onClick={onReset} />
|
||||
<CopyOutlined onClick={() => onCopy(image)} />
|
||||
<DownloadOutlined onClick={() => onDownload(image, index)} />
|
||||
</ToobarWrapper>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Container>
|
||||
)
|
||||
@@ -25,5 +114,19 @@ const Container = styled.div`
|
||||
const Image = styled(AntdImage)`
|
||||
border-radius: 10px;
|
||||
`
|
||||
const ToobarWrapper = styled(Space)`
|
||||
padding: 0px 24px;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 100px;
|
||||
.anticon {
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.anticon:hover {
|
||||
opacity: 0.3;
|
||||
}
|
||||
`
|
||||
|
||||
export default MessageImage
|
||||
|
||||
@@ -27,6 +27,7 @@ import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL, removeTraili
|
||||
import {
|
||||
exportMarkdownToJoplin,
|
||||
exportMarkdownToNotion,
|
||||
exportMarkdownToSiyuan,
|
||||
exportMarkdownToYuque,
|
||||
exportMessageAsMarkdown,
|
||||
messageToMarkdown
|
||||
@@ -254,6 +255,15 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
const markdown = messageToMarkdown(message)
|
||||
exportMarkdownToJoplin(title, markdown)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('chat.topics.export.siyuan'),
|
||||
key: 'siyuan',
|
||||
onClick: async () => {
|
||||
const title = getMessageTitle(message)
|
||||
const markdown = messageToMarkdown(message)
|
||||
exportMarkdownToSiyuan(title, markdown)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,10 +9,11 @@ import { getDefaultModel, getDefaultTopic } from '@renderer/services/AssistantSe
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { hasTopicPendingRequests } from '@renderer/utils/queue'
|
||||
import { Dropdown } from 'antd'
|
||||
import { ItemType } from 'antd/es/menu/interface'
|
||||
import { omit } from 'lodash'
|
||||
import { FC, useCallback } from 'react'
|
||||
import { FC, useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -32,6 +33,17 @@ const AssistantItem: FC<AssistantItemProps> = ({ assistant, isActive, onSwitch,
|
||||
const { clickAssistantToShowTopic, topicPosition, showAssistantIcon } = useSettings()
|
||||
const defaultModel = getDefaultModel()
|
||||
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
setIsPending(false)
|
||||
}
|
||||
const hasPending = assistant.topics.some((topic) => hasTopicPendingRequests(topic.id))
|
||||
if (hasPending) {
|
||||
setIsPending(true)
|
||||
}
|
||||
}, [isActive, assistant.topics])
|
||||
|
||||
const getMenuItems = useCallback(
|
||||
(assistant: Assistant): ItemType[] => [
|
||||
{
|
||||
@@ -116,7 +128,13 @@ const AssistantItem: FC<AssistantItemProps> = ({ assistant, isActive, onSwitch,
|
||||
<Dropdown menu={{ items: getMenuItems(assistant) }} trigger={['contextMenu']}>
|
||||
<Container onClick={handleSwitch} className={isActive ? 'active' : ''}>
|
||||
<AssistantNameRow className="name" title={fullAssistantName}>
|
||||
{showAssistantIcon && <ModelAvatar model={assistant.model || defaultModel} size={22} />}
|
||||
{showAssistantIcon && (
|
||||
<ModelAvatar
|
||||
model={assistant.model || defaultModel}
|
||||
size={22}
|
||||
className={isPending && !isActive ? 'animation-pulse' : ''}
|
||||
/>
|
||||
)}
|
||||
<AssistantName className="text-nowrap">{showAssistantIcon ? assistantName : fullAssistantName}</AssistantName>
|
||||
</AssistantNameRow>
|
||||
{isActive && (
|
||||
|
||||
@@ -27,15 +27,17 @@ import { removeSpecialCharactersForFileName } from '@renderer/utils'
|
||||
import { copyTopicAsMarkdown } from '@renderer/utils/copy'
|
||||
import {
|
||||
exportMarkdownToJoplin,
|
||||
exportMarkdownToSiyuan,
|
||||
exportMarkdownToYuque,
|
||||
exportTopicAsMarkdown,
|
||||
exportTopicToNotion,
|
||||
topicToMarkdown
|
||||
} from '@renderer/utils/export'
|
||||
import { hasTopicPendingRequests } from '@renderer/utils/queue'
|
||||
import { Dropdown, MenuProps, Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { findIndex } from 'lodash'
|
||||
import { FC, useCallback, useRef, useState } from 'react'
|
||||
import { FC, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -56,6 +58,28 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
const [deletingTopicId, setDeletingTopicId] = useState<string | null>(null)
|
||||
const deleteTimerRef = useRef<NodeJS.Timeout>()
|
||||
|
||||
const pendingTopics = useMemo(() => {
|
||||
return new Set<string>()
|
||||
}, [])
|
||||
const isPending = useCallback(
|
||||
(topicId: string) => {
|
||||
const hasPending = hasTopicPendingRequests(topicId)
|
||||
if (topicId === activeTopic.id && !hasPending) {
|
||||
pendingTopics.delete(topicId)
|
||||
return false
|
||||
}
|
||||
if (pendingTopics.has(topicId)) {
|
||||
return true
|
||||
}
|
||||
if (hasPending) {
|
||||
pendingTopics.add(topicId)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[activeTopic.id, pendingTopics]
|
||||
)
|
||||
|
||||
const handleDeleteClick = useCallback((topicId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
|
||||
@@ -272,6 +296,14 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
const markdown = await topicToMarkdown(topic)
|
||||
exportMarkdownToJoplin(topic.name, markdown)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('chat.topics.export.siyuan'),
|
||||
key: 'siyuan',
|
||||
onClick: async () => {
|
||||
const markdown = await topicToMarkdown(topic)
|
||||
exportMarkdownToSiyuan(topic.name, markdown)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -322,6 +354,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
className={isActive ? 'active' : ''}
|
||||
onClick={() => onSwitchTopic(topic)}
|
||||
style={{ borderRadius }}>
|
||||
{isPending(topic.id) && !isActive && <PendingIndicator />}
|
||||
<TopicName className="name" title={topicName}>
|
||||
{topicName}
|
||||
</TopicName>
|
||||
@@ -395,6 +428,7 @@ const TopicListItem = styled.div`
|
||||
font-family: Ubuntu;
|
||||
cursor: pointer;
|
||||
border: 0.5px solid transparent;
|
||||
position: relative;
|
||||
.menu {
|
||||
opacity: 0;
|
||||
color: var(--color-text-3);
|
||||
@@ -427,6 +461,19 @@ const TopicName = styled.div`
|
||||
font-size: 13px;
|
||||
`
|
||||
|
||||
const PendingIndicator = styled.div.attrs({
|
||||
className: 'animation-pulse'
|
||||
})`
|
||||
--pulse-size: 5px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 15px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-primary);
|
||||
`
|
||||
|
||||
const TopicPromptText = styled.div`
|
||||
color: var(--color-text-2);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setUpdateState } from '@renderer/store/runtime'
|
||||
import { setManualUpdateCheck } from '@renderer/store/settings'
|
||||
import { setAutoCheckUpdate } from '@renderer/store/settings'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { compareVersions, runAsyncFunction } from '@renderer/utils'
|
||||
import { Avatar, Button, Progress, Row, Switch, Tag } from 'antd'
|
||||
@@ -25,7 +25,7 @@ import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingTitl
|
||||
const AboutSettings: FC = () => {
|
||||
const [version, setVersion] = useState('')
|
||||
const { t } = useTranslation()
|
||||
const { manualUpdateCheck } = useSettings()
|
||||
const { autoCheckUpdate } = useSettings()
|
||||
const { theme } = useTheme()
|
||||
const dispatch = useAppDispatch()
|
||||
const { update } = useRuntime()
|
||||
@@ -146,8 +146,8 @@ const AboutSettings: FC = () => {
|
||||
</AboutHeader>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.general.manually_check_update.title')}</SettingRowTitle>
|
||||
<Switch value={manualUpdateCheck} onChange={(v) => dispatch(setManualUpdateCheck(v))} />
|
||||
<SettingRowTitle>{t('settings.general.auto_check_update.title')}</SettingRowTitle>
|
||||
<Switch value={autoCheckUpdate} onChange={(v) => dispatch(setAutoCheckUpdate(v))} />
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
{hasNewVersion && update.info && (
|
||||
@@ -161,7 +161,7 @@ const AboutSettings: FC = () => {
|
||||
<UpdateNotesWrapper>
|
||||
<Markdown>
|
||||
{typeof update.info.releaseNotes === 'string'
|
||||
? update.info.releaseNotes.replaceAll('\n', '\n\n')
|
||||
? update.info.releaseNotes.replace(/\n/g, '\n\n')
|
||||
: update.info.releaseNotes?.map((note) => note.note).join('\n')}
|
||||
</Markdown>
|
||||
</UpdateNotesWrapper>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
SaveOutlined,
|
||||
YuqueOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { NutstoreIcon } from '@renderer/components/Icons/NutstoreIcons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import ListItem from '@renderer/components/ListItem'
|
||||
import BackupPopup from '@renderer/components/Popups/BackupPopup'
|
||||
@@ -25,7 +26,9 @@ import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowT
|
||||
import JoplinSettings from './JoplinSettings'
|
||||
import MarkdownExportSettings from './MarkdownExportSettings'
|
||||
import NotionSettings from './NotionSettings'
|
||||
import NutstoreSettings from './NutstoreSettings'
|
||||
import ObsidianSettings from './ObsidianSettings'
|
||||
import SiyuanSettings from './SiyuanSettings'
|
||||
import WebDavSettings from './WebDavSettings'
|
||||
import YuqueSettings from './YuqueSettings'
|
||||
|
||||
@@ -43,9 +46,26 @@ const DataSettings: FC = () => {
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SiyuanIcon = () => (
|
||||
<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2962" width="16" height="16">
|
||||
<path
|
||||
d="M309.76 148.16a84.8 84.8 0 0 0-10.88 11.84S288 170.24 288 171.2s-6.72 4.8-6.72 6.72-3.52 1.92-2.88 2.88a12.48 12.48 0 0 0-6.4 6.4 121.28 121.28 0 0 0-20.8 19.2 456.64 456.64 0 0 1-37.76 37.12v2.88c0 2.88 0 0 0 0s-3.52 1.92-6.72 5.12c-8.64 9.28-19.84 20.48-28.16 28.16l-7.04 7.04-2.56 2.88a114.88 114.88 0 0 0-20.16 21.76 2.88 2.88 0 0 1-8 8.64l-1.6 1.6a99.52 99.52 0 0 0-19.52 18.88 21.44 21.44 0 0 0-6.4 5.44c-14.08 14.4-22.4 23.04-22.72 23.04l-9.28 8.96-8.96 8.96V887.04c0 1.28 3.2 2.56 6.72-1.92s3.52-3.84 4.16-3.84 0-1.6 0 0S163.84 800 219.84 744.64l38.4-38.08c16-16.32 29.12-29.76 28.8-30.4s6.72-4.16 5.76-5.76 5.44-3.2 5.44-5.12 23.68-23.04 23.04-26.56 0-115.52 0-252.16V138.56a128 128 0 0 0-11.84 10.88z m373.76 2.24a96 96 0 0 0-13.44 15.04s-33.92 32-76.48 74.56l-42.56 42.88L512 320v504.96s5.76-5.12 5.12-5.76a29.44 29.44 0 0 0 8.32-7.68c3.84-4.16 9.92-10.24 13.76-13.76l21.44-21.76 21.76-21.44c18.56-18.24 32-32 32-32l8.96-9.6a69.76 69.76 0 0 1 10.56-9.6s3.84-1.92 3.84-3.52 6.4-4.48 5.76-5.12 3.2-2.56 2.56-3.2 1.6 0 0 0 11.52-10.24 24-22.72l22.72-22.4v-256-251.84c0-0.96 0-2.24-15.36 11.84z"
|
||||
fill="#cdcdcd"
|
||||
p-id="2963"></path>
|
||||
<path
|
||||
d="M322.24 136h0c-1.6 0 0-0.64 0 0z m2.88 0v504.64l45.12 44.16c37.44 36.8 93.76 92.8 116.48 114.88l14.4 15.04a64 64 0 0 0 10.24 9.6V320l-4.8-4.48c-2.88-2.24-7.68-7.36-11.52-10.88l-42.24-41.92-20.8-21.12-16-14.4a76.48 76.48 0 0 1-7.36-7.04l-23.36-23.68-42.56-44.16c-15.04-15.04-16-16-17.6-14.72z m376 1.92V640l123.84 123.84c98.24 97.92 124.48 123.52 126.4 123.52h2.56V386.56l-124.8-124.8C760 192 704 136.96 704 136.96a3.52 3.52 0 0 0-1.6 2.56z"
|
||||
fill="#707070"
|
||||
p-id="2964"></path>
|
||||
<path
|
||||
d="M699.52 136.64V136z m-376.96 249.6V136.96s-0.32 50.56 0 249.28zM512 573.76v-127.04zM667.84 672l-6.72 7.36 7.04-7.04c6.72-6.08 7.68-7.36 6.72-7.36zM184 272.96v1.92l2.56-1.92c2.56-1.92 0-2.24 0-2.24a5.44 5.44 0 0 0-2.56 2.24zM141.76 314.88a2.24 2.24 0 0 0 1.92 0v-1.6z m483.2 399.04a71.36 71.36 0 0 0-8.96 10.24 69.76 69.76 0 0 0 10.56-9.6 56 56 0 0 0 8.96-10.24 73.28 73.28 0 0 0-10.56 9.6z m-448 75.52l-3.2 3.2 3.52-2.88 3.52-3.52s-2.56 0-5.44 3.2z m-97.92 96v1.92l2.88-1.92s1.92-2.24 0-2.24a6.72 6.72 0 0 0-4.48 2.88z"
|
||||
p-id="2965"></path>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const menuItems = [
|
||||
{ key: 'data', title: 'settings.data.data.title', icon: <DatabaseOutlined style={{ fontSize: 16 }} /> },
|
||||
{ key: 'webdav', title: 'settings.data.webdav.title', icon: <CloudSyncOutlined style={{ fontSize: 16 }} /> },
|
||||
{ key: 'nutstore', title: 'settings.data.nutstore.title', icon: <NutstoreIcon /> },
|
||||
{
|
||||
key: 'markdown_export',
|
||||
title: 'settings.data.markdown_export.title',
|
||||
@@ -67,6 +87,11 @@ const DataSettings: FC = () => {
|
||||
title: 'settings.data.joplin.title',
|
||||
//joplin icon needs to be updated into iconfont
|
||||
icon: <JoplinIcon />
|
||||
},
|
||||
{
|
||||
key: 'siyuan',
|
||||
title: 'settings.data.siyuan.title',
|
||||
icon: <SiyuanIcon />
|
||||
}
|
||||
]
|
||||
|
||||
@@ -201,11 +226,13 @@ const DataSettings: FC = () => {
|
||||
</>
|
||||
)}
|
||||
{menu === 'webdav' && <WebDavSettings />}
|
||||
{menu === 'nutstore' && <NutstoreSettings />}
|
||||
{menu === 'markdown_export' && <MarkdownExportSettings />}
|
||||
{menu === 'notion' && <NotionSettings />}
|
||||
{menu === 'yuque' && <YuqueSettings />}
|
||||
{menu === 'obsidian' && <ObsidianSettings />}
|
||||
{menu === 'joplin' && <JoplinSettings />}
|
||||
{menu === 'siyuan' && <SiyuanSettings />}
|
||||
</SettingContainer>
|
||||
</Container>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { CheckOutlined, FolderOutlined, LoadingOutlined, SyncOutlined, WarningOutlined } from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import NutstorePathPopup from '@renderer/components/Popups/NutsorePathPopup'
|
||||
import {
|
||||
useWebdavBackupModal,
|
||||
useWebdavRestoreModal,
|
||||
WebdavBackupModal,
|
||||
WebdavRestoreModal
|
||||
} from '@renderer/components/WebdavModals'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useNutstoreSSO } from '@renderer/hooks/useNutstoreSSO'
|
||||
import {
|
||||
backupToNutstore,
|
||||
checkConnection,
|
||||
createDirectory,
|
||||
restoreFromNutstore,
|
||||
startNutstoreAutoSync,
|
||||
stopNutstoreAutoSync
|
||||
} from '@renderer/services/NutstoreService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import {
|
||||
setNutstoreAutoSync,
|
||||
setNutstorePath,
|
||||
setNutstoreSyncInterval,
|
||||
setNutstoreToken
|
||||
} from '@renderer/store/nutstore'
|
||||
import { modalConfirm } from '@renderer/utils'
|
||||
import { NUTSTORE_HOST } from '@shared/config/nutstore'
|
||||
import { Button, Input, Select, Tooltip, Typography } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { FC, useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { type FileStat } from 'webdav'
|
||||
|
||||
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
|
||||
const NutstoreSettings: FC = () => {
|
||||
const { theme } = useTheme()
|
||||
const { t } = useTranslation()
|
||||
const { nutstoreToken, nutstorePath, nutstoreSyncInterval, nutstoreAutoSync, nutstoreSyncState } = useAppSelector(
|
||||
(state) => state.nutstore
|
||||
)
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const [nutstoreUsername, setNutstoreUsername] = useState<string | undefined>(undefined)
|
||||
const [nutstorePass, setNutstorePass] = useState<string | undefined>(undefined)
|
||||
const [storagePath, setStoragePath] = useState<string | undefined>(nutstorePath)
|
||||
|
||||
const [checkConnectionLoading, setCheckConnectionLoading] = useState(false)
|
||||
const [nsConnected, setNsConnected] = useState<boolean>(false)
|
||||
|
||||
const [syncInterval, setSyncInterval] = useState<number>(nutstoreSyncInterval)
|
||||
|
||||
const nutstoreSSOHandler = useNutstoreSSO()
|
||||
|
||||
const handleClickNutstoreSSO = useCallback(async () => {
|
||||
const ssoUrl = await window.api.nutstore.getSSOUrl()
|
||||
window.open(ssoUrl, '_blank')
|
||||
const nutstoreToken = await nutstoreSSOHandler()
|
||||
|
||||
dispatch(setNutstoreToken(nutstoreToken))
|
||||
}, [dispatch, nutstoreSSOHandler])
|
||||
|
||||
useEffect(() => {
|
||||
async function decryptTokenEffect() {
|
||||
if (nutstoreToken) {
|
||||
const decrypted = await window.api.nutstore.decryptToken(nutstoreToken)
|
||||
|
||||
if (decrypted) {
|
||||
setNutstoreUsername(decrypted.username)
|
||||
setNutstorePass(decrypted.access_token)
|
||||
if (!nutstorePath) {
|
||||
dispatch(setNutstorePath('/cherry-studio'))
|
||||
setStoragePath('/cherry-studio')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
decryptTokenEffect()
|
||||
}, [nutstoreToken, dispatch, nutstorePath])
|
||||
|
||||
const handleLayout = useCallback(async () => {
|
||||
const confirmedLogout = await modalConfirm({
|
||||
title: t('settings.data.nutstore.logout.title'),
|
||||
content: t('settings.data.nutstore.logout.content')
|
||||
})
|
||||
if (confirmedLogout) {
|
||||
dispatch(setNutstoreToken(''))
|
||||
dispatch(setNutstorePath(''))
|
||||
setNutstoreUsername('')
|
||||
setStoragePath(undefined)
|
||||
}
|
||||
}, [dispatch, t])
|
||||
|
||||
const handleCheckConnection = async () => {
|
||||
if (!nutstoreToken) return
|
||||
setCheckConnectionLoading(true)
|
||||
const isConnectedToNutstore = await checkConnection()
|
||||
|
||||
window.message[isConnectedToNutstore ? 'success' : 'error']({
|
||||
key: 'api-check',
|
||||
style: { marginTop: '3vh' },
|
||||
duration: 2,
|
||||
content: isConnectedToNutstore
|
||||
? t('settings.data.nutstore.checkConnection.success')
|
||||
: t('settings.data.nutstore.checkConnection.fail')
|
||||
})
|
||||
|
||||
setNsConnected(isConnectedToNutstore)
|
||||
setCheckConnectionLoading(false)
|
||||
|
||||
setTimeout(() => setNsConnected(false), 3000)
|
||||
}
|
||||
|
||||
const { isModalVisible, handleBackup, handleCancel, backuping, customFileName, setCustomFileName, showBackupModal } =
|
||||
useWebdavBackupModal({
|
||||
backupMethod: backupToNutstore
|
||||
})
|
||||
|
||||
const {
|
||||
isRestoreModalVisible,
|
||||
handleRestore,
|
||||
handleCancel: handleCancelRestore,
|
||||
restoring,
|
||||
selectedFile,
|
||||
setSelectedFile,
|
||||
loadingFiles,
|
||||
backupFiles,
|
||||
showRestoreModal
|
||||
} = useWebdavRestoreModal({
|
||||
restoreMethod: restoreFromNutstore,
|
||||
webdavHost: NUTSTORE_HOST,
|
||||
webdavUser: nutstoreUsername,
|
||||
webdavPass: nutstorePass,
|
||||
webdavPath: storagePath
|
||||
})
|
||||
|
||||
const onSyncIntervalChange = (value: number) => {
|
||||
setSyncInterval(value)
|
||||
dispatch(setNutstoreSyncInterval(value))
|
||||
if (value === 0) {
|
||||
dispatch(setNutstoreAutoSync(false))
|
||||
stopNutstoreAutoSync()
|
||||
} else {
|
||||
dispatch(setNutstoreAutoSync(true))
|
||||
startNutstoreAutoSync()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickPathChange = async () => {
|
||||
if (!nutstoreToken) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await window.api.nutstore.decryptToken(nutstoreToken)
|
||||
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetPath = await NutstorePathPopup.show({
|
||||
ls: async (target: string) => {
|
||||
const { username, access_token } = result
|
||||
const token = window.btoa(`${username}:${access_token}`)
|
||||
const items = await window.api.nutstore.getDirectoryContents(token, target)
|
||||
return items.map(fileStatToStatModel)
|
||||
},
|
||||
mkdirs: async (path) => {
|
||||
await createDirectory(path)
|
||||
}
|
||||
})
|
||||
|
||||
if (!targetPath) {
|
||||
return
|
||||
}
|
||||
|
||||
setStoragePath(targetPath)
|
||||
dispatch(setNutstorePath(targetPath))
|
||||
}
|
||||
|
||||
const renderSyncStatus = () => {
|
||||
if (!nutstoreToken) return null
|
||||
|
||||
if (!nutstoreSyncState.lastSyncTime && !nutstoreSyncState.syncing && !nutstoreSyncState.lastSyncError) {
|
||||
return <span style={{ color: 'var(--text-secondary)' }}>{t('settings.data.webdav.noSync')}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack gap="5px" alignItems="center">
|
||||
{nutstoreSyncState.syncing && <SyncOutlined spin />}
|
||||
{!nutstoreSyncState.syncing && nutstoreSyncState.lastSyncError && (
|
||||
<Tooltip title={`${t('settings.data.webdav.syncError')}: ${nutstoreSyncState.lastSyncError}`}>
|
||||
<WarningOutlined style={{ color: 'red' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{nutstoreSyncState.lastSyncTime && (
|
||||
<span style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('settings.data.webdav.lastSync')}: {dayjs(nutstoreSyncState.lastSyncTime).format('HH:mm:ss')}
|
||||
</span>
|
||||
)}
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
const isLogin = nutstoreToken && nutstoreUsername
|
||||
|
||||
return (
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.data.nutstore.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
{isLogin ? t('settings.data.nutstore.isLogin') : t('settings.data.nutstore.notLogin')}
|
||||
</SettingRowTitle>
|
||||
{isLogin ? (
|
||||
<HStack gap="5px" justifyContent="space-between" alignItems="center">
|
||||
<Button
|
||||
type={nsConnected ? 'primary' : 'default'}
|
||||
ghost={nsConnected}
|
||||
onClick={handleCheckConnection}
|
||||
loading={checkConnectionLoading}>
|
||||
{checkConnectionLoading ? (
|
||||
<LoadingOutlined spin />
|
||||
) : nsConnected ? (
|
||||
<CheckOutlined />
|
||||
) : (
|
||||
t('settings.data.nutstore.checkConnection.name')
|
||||
)}
|
||||
</Button>
|
||||
<Button type="primary" danger onClick={handleLayout}>
|
||||
{t('settings.data.nutstore.logout.button')}
|
||||
</Button>
|
||||
</HStack>
|
||||
) : (
|
||||
<Button onClick={handleClickNutstoreSSO}>{t('settings.data.nutstore.login.button')}</Button>
|
||||
)}
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
{isLogin && (
|
||||
<>
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.nutstore.username')}</SettingRowTitle>
|
||||
<Typography.Text style={{ color: 'var(--color-text-3)' }}>{nutstoreUsername}</Typography.Text>
|
||||
</SettingRow>
|
||||
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.nutstore.path')}</SettingRowTitle>
|
||||
<HStack gap="4px" justifyContent="space-between">
|
||||
<Input
|
||||
placeholder={t('settings.data.nutstore.path.placeholder')}
|
||||
style={{ width: 250 }}
|
||||
value={nutstorePath}
|
||||
onChange={(e) => setStoragePath(e.target.value)}
|
||||
onBlur={() => dispatch(setNutstorePath(storagePath || ''))}
|
||||
/>
|
||||
<Button type="default" onClick={handleClickPathChange}>
|
||||
<FolderOutlined />
|
||||
</Button>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
|
||||
<HStack gap="5px" justifyContent="space-between">
|
||||
<Button onClick={showBackupModal} loading={backuping}>
|
||||
{t('settings.data.nutstore.backup.button')}
|
||||
</Button>
|
||||
<Button onClick={showRestoreModal} loading={restoring}>
|
||||
{t('settings.data.nutstore.restore.button')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.webdav.autoSync')}</SettingRowTitle>
|
||||
<Select value={syncInterval} onChange={onSyncIntervalChange} style={{ width: 120 }}>
|
||||
<Select.Option value={0}>{t('settings.data.webdav.autoSync.off')}</Select.Option>
|
||||
<Select.Option value={1}>{t('settings.data.webdav.minute_interval', { count: 1 })}</Select.Option>
|
||||
<Select.Option value={5}>{t('settings.data.webdav.minute_interval', { count: 5 })}</Select.Option>
|
||||
<Select.Option value={15}>{t('settings.data.webdav.minute_interval', { count: 15 })}</Select.Option>
|
||||
<Select.Option value={30}>{t('settings.data.webdav.minute_interval', { count: 30 })}</Select.Option>
|
||||
<Select.Option value={60}>{t('settings.data.webdav.hour_interval', { count: 1 })}</Select.Option>
|
||||
<Select.Option value={120}>{t('settings.data.webdav.hour_interval', { count: 2 })}</Select.Option>
|
||||
<Select.Option value={360}>{t('settings.data.webdav.hour_interval', { count: 6 })}</Select.Option>
|
||||
<Select.Option value={720}>{t('settings.data.webdav.hour_interval', { count: 12 })}</Select.Option>
|
||||
<Select.Option value={1440}>{t('settings.data.webdav.hour_interval', { count: 24 })}</Select.Option>
|
||||
</Select>
|
||||
</SettingRow>
|
||||
{nutstoreAutoSync && syncInterval > 0 && (
|
||||
<>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.webdav.syncStatus')}</SettingRowTitle>
|
||||
{renderSyncStatus()}
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
<WebdavBackupModal
|
||||
isModalVisible={isModalVisible}
|
||||
handleBackup={handleBackup}
|
||||
handleCancel={handleCancel}
|
||||
backuping={backuping}
|
||||
customFileName={customFileName}
|
||||
setCustomFileName={setCustomFileName}
|
||||
/>
|
||||
|
||||
<WebdavRestoreModal
|
||||
isRestoreModalVisible={isRestoreModalVisible}
|
||||
handleRestore={handleRestore}
|
||||
handleCancel={handleCancelRestore}
|
||||
restoring={restoring}
|
||||
selectedFile={selectedFile}
|
||||
setSelectedFile={setSelectedFile}
|
||||
loadingFiles={loadingFiles}
|
||||
backupFiles={backupFiles}
|
||||
/>
|
||||
</>
|
||||
</SettingGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export interface StatModel {
|
||||
path: string
|
||||
basename: string
|
||||
isDir: boolean
|
||||
isDeleted: boolean
|
||||
mtime: number
|
||||
size: number
|
||||
}
|
||||
|
||||
function fileStatToStatModel(from: FileStat): StatModel {
|
||||
return {
|
||||
path: from.filename,
|
||||
basename: from.basename,
|
||||
isDir: from.type === 'directory',
|
||||
isDeleted: false,
|
||||
mtime: new Date(from.lastmod).valueOf(),
|
||||
size: from.size
|
||||
}
|
||||
}
|
||||
|
||||
export default NutstoreSettings
|
||||
@@ -0,0 +1,150 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import MinApp from '@renderer/components/MinApp'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { RootState, useAppDispatch } from '@renderer/store'
|
||||
import { setSiyuanApiUrl, setSiyuanBoxId, setSiyuanRootPath, setSiyuanToken } from '@renderer/store/settings'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
import Input from 'antd/es/input/Input'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector } from 'react-redux'
|
||||
|
||||
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
|
||||
const SiyuanSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const siyuanApiUrl = useSelector((state: RootState) => state.settings.siyuanApiUrl)
|
||||
const siyuanToken = useSelector((state: RootState) => state.settings.siyuanToken)
|
||||
const siyuanBoxId = useSelector((state: RootState) => state.settings.siyuanBoxId)
|
||||
const siyuanRootPath = useSelector((state: RootState) => state.settings.siyuanRootPath)
|
||||
|
||||
const handleApiUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
dispatch(setSiyuanApiUrl(e.target.value))
|
||||
}
|
||||
|
||||
const handleTokenChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
dispatch(setSiyuanToken(e.target.value))
|
||||
}
|
||||
|
||||
const handleBoxIdChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
dispatch(setSiyuanBoxId(e.target.value))
|
||||
}
|
||||
|
||||
const handleRootPathChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
dispatch(setSiyuanRootPath(e.target.value))
|
||||
}
|
||||
|
||||
const handleSiyuanHelpClick = () => {
|
||||
MinApp.start({
|
||||
id: 'siyuan-help',
|
||||
name: 'Siyuan Help',
|
||||
url: 'https://docs.cherry-ai.com/advanced-basic/siyuan'
|
||||
})
|
||||
}
|
||||
|
||||
const handleCheckConnection = async () => {
|
||||
try {
|
||||
if (!siyuanApiUrl || !siyuanToken) {
|
||||
window.message.error(t('settings.data.siyuan.check.empty_config'))
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(`${siyuanApiUrl}/api/notebook/lsNotebooks`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Token ${siyuanToken}`
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
window.message.error(t('settings.data.siyuan.check.fail'))
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code !== 0) {
|
||||
window.message.error(t('settings.data.siyuan.check.fail'))
|
||||
return
|
||||
}
|
||||
|
||||
window.message.success(t('settings.data.siyuan.check.success'))
|
||||
} catch (error) {
|
||||
console.error('Check Siyuan connection failed:', error)
|
||||
window.message.error(t('settings.data.siyuan.check.error'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.data.siyuan.title')}</SettingTitle>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.siyuan.api_url')}</SettingRowTitle>
|
||||
<HStack alignItems="center" gap="5px" style={{ width: 315 }}>
|
||||
<Input
|
||||
type="text"
|
||||
value={siyuanApiUrl || ''}
|
||||
onChange={handleApiUrlChange}
|
||||
style={{ width: 315 }}
|
||||
placeholder={t('settings.data.siyuan.api_url_placeholder')}
|
||||
/>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span>{t('settings.data.siyuan.token')}</span>
|
||||
<Tooltip title={t('settings.data.siyuan.token.help')} placement="left">
|
||||
<InfoCircleOutlined
|
||||
style={{ color: 'var(--color-text-2)', cursor: 'pointer', marginLeft: 4 }}
|
||||
onClick={handleSiyuanHelpClick}
|
||||
/>
|
||||
</Tooltip>
|
||||
</SettingRowTitle>
|
||||
<HStack alignItems="center" gap="5px" style={{ width: 315 }}>
|
||||
<Input
|
||||
type="password"
|
||||
value={siyuanToken || ''}
|
||||
onChange={handleTokenChange}
|
||||
style={{ width: 250 }}
|
||||
placeholder={t('settings.data.siyuan.token_placeholder')}
|
||||
/>
|
||||
<Button onClick={handleCheckConnection}>{t('settings.data.siyuan.check.button')}</Button>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.siyuan.box_id')}</SettingRowTitle>
|
||||
<HStack alignItems="center" gap="5px" style={{ width: 315 }}>
|
||||
<Input
|
||||
type="text"
|
||||
value={siyuanBoxId || ''}
|
||||
onChange={handleBoxIdChange}
|
||||
style={{ width: 315 }}
|
||||
placeholder={t('settings.data.siyuan.box_id_placeholder')}
|
||||
/>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.siyuan.root_path')}</SettingRowTitle>
|
||||
<HStack alignItems="center" gap="5px" style={{ width: 315 }}>
|
||||
<Input
|
||||
type="text"
|
||||
value={siyuanRootPath || ''}
|
||||
onChange={handleRootPathChange}
|
||||
style={{ width: 315 }}
|
||||
placeholder={t('settings.data.siyuan.root_path_placeholder')}
|
||||
/>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export default SiyuanSettings
|
||||
@@ -1,8 +1,14 @@
|
||||
import { FolderOpenOutlined, SaveOutlined, SyncOutlined, WarningOutlined } from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import {
|
||||
useWebdavBackupModal,
|
||||
useWebdavRestoreModal,
|
||||
WebdavBackupModal,
|
||||
WebdavRestoreModal
|
||||
} from '@renderer/components/WebdavModals'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { backupToWebdav, restoreFromWebdav, startAutoSync, stopAutoSync } from '@renderer/services/BackupService'
|
||||
import { startAutoSync, stopAutoSync } from '@renderer/services/BackupService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import {
|
||||
setWebdavAutoSync,
|
||||
@@ -12,20 +18,13 @@ import {
|
||||
setWebdavSyncInterval as _setWebdavSyncInterval,
|
||||
setWebdavUser as _setWebdavUser
|
||||
} from '@renderer/store/settings'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Button, Input, Modal, Select, Spin, Tooltip } from 'antd'
|
||||
import { Button, Input, Select, Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
|
||||
interface BackupFile {
|
||||
fileName: string
|
||||
modifiedTime: string
|
||||
size: number
|
||||
}
|
||||
|
||||
const WebDavSettings: FC = () => {
|
||||
const {
|
||||
webdavHost: webDAVHost,
|
||||
@@ -42,15 +41,6 @@ const WebDavSettings: FC = () => {
|
||||
|
||||
const [syncInterval, setSyncInterval] = useState<number>(webDAVSyncInterval)
|
||||
|
||||
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()
|
||||
|
||||
@@ -96,87 +86,20 @@ const WebDavSettings: FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
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 { isModalVisible, handleBackup, handleCancel, backuping, customFileName, setCustomFileName, showBackupModal } =
|
||||
useWebdavBackupModal()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
const {
|
||||
isRestoreModalVisible,
|
||||
handleRestore,
|
||||
handleCancel: handleCancelRestore,
|
||||
restoring,
|
||||
selectedFile,
|
||||
setSelectedFile,
|
||||
loadingFiles,
|
||||
backupFiles,
|
||||
showRestoreModal
|
||||
} = useWebdavRestoreModal({ webdavHost, webdavUser, webdavPass, webdavPath })
|
||||
|
||||
return (
|
||||
<SettingGroup theme={theme}>
|
||||
@@ -264,44 +187,25 @@ const WebDavSettings: FC = () => {
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
<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>
|
||||
<WebdavBackupModal
|
||||
isModalVisible={isModalVisible}
|
||||
handleBackup={handleBackup}
|
||||
handleCancel={handleCancel}
|
||||
backuping={backuping}
|
||||
customFileName={customFileName}
|
||||
setCustomFileName={setCustomFileName}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<WebdavRestoreModal
|
||||
isRestoreModalVisible={isRestoreModalVisible}
|
||||
handleRestore={handleRestore}
|
||||
handleCancel={handleCancelRestore}
|
||||
restoring={restoring}
|
||||
selectedFile={selectedFile}
|
||||
setSelectedFile={setSelectedFile}
|
||||
loadingFiles={loadingFiles}
|
||||
backupFiles={backupFiles}
|
||||
/>
|
||||
</>
|
||||
</SettingGroup>
|
||||
)
|
||||
|
||||
@@ -594,7 +594,7 @@ export default class GeminiProvider extends BaseProvider {
|
||||
private async generateImageExp({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void> {
|
||||
const defaultModel = getDefaultModel()
|
||||
const model = assistant.model || defaultModel
|
||||
const { contextCount } = getAssistantSettings(assistant)
|
||||
const { contextCount, streamOutput, maxTokens } = getAssistantSettings(assistant)
|
||||
|
||||
const userMessages = filterUserRoleStartMessages(filterContextMessages(takeRight(messages, contextCount + 2)))
|
||||
onFilterMessages(userMessages)
|
||||
@@ -617,16 +617,22 @@ export default class GeminiProvider extends BaseProvider {
|
||||
|
||||
contents = await this.addImageFileToContents(userLastMessage, contents)
|
||||
|
||||
const response = await this.callGeminiGenerateContent(model.id, contents)
|
||||
if (!streamOutput) {
|
||||
const response = await this.callGeminiGenerateContent(model.id, contents, maxTokens)
|
||||
|
||||
console.log('response', response)
|
||||
const { isValid, message } = this.isValidGeminiResponse(response)
|
||||
if (!isValid) {
|
||||
throw new Error(`Gemini API error: ${message}`)
|
||||
}
|
||||
|
||||
const { isValid, message } = this.isValidGeminiResponse(response)
|
||||
if (!isValid) {
|
||||
throw new Error(`Gemini API error: ${message}`)
|
||||
this.processGeminiImageResponse(response, onChunk)
|
||||
return
|
||||
}
|
||||
const response = await this.callGeminiGenerateContentStream(model.id, contents, maxTokens)
|
||||
|
||||
this.processGeminiImageResponse(response, onChunk)
|
||||
for await (const chunk of response) {
|
||||
this.processGeminiImageResponse(chunk, onChunk)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -656,7 +662,8 @@ export default class GeminiProvider extends BaseProvider {
|
||||
*/
|
||||
private async callGeminiGenerateContent(
|
||||
modelId: string,
|
||||
contents: ContentListUnion
|
||||
contents: ContentListUnion,
|
||||
maxTokens?: number
|
||||
): Promise<GenerateContentResponse> {
|
||||
try {
|
||||
return await this.imageSdk.models.generateContent({
|
||||
@@ -664,7 +671,29 @@ export default class GeminiProvider extends BaseProvider {
|
||||
contents: contents,
|
||||
config: {
|
||||
responseModalities: ['Text', 'Image'],
|
||||
responseMimeType: 'text/plain'
|
||||
responseMimeType: 'text/plain',
|
||||
maxOutputTokens: maxTokens
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Gemini API error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async callGeminiGenerateContentStream(
|
||||
modelId: string,
|
||||
contents: ContentListUnion,
|
||||
maxTokens?: number
|
||||
): Promise<AsyncGenerator<GenerateContentResponse>> {
|
||||
try {
|
||||
return await this.imageSdk.models.generateContentStream({
|
||||
model: modelId,
|
||||
contents: contents,
|
||||
config: {
|
||||
responseModalities: ['Text', 'Image'],
|
||||
responseMimeType: 'text/plain',
|
||||
maxOutputTokens: maxTokens
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -692,7 +721,9 @@ export default class GeminiProvider extends BaseProvider {
|
||||
*/
|
||||
private processGeminiImageResponse(response: any, onChunk: (chunk: ChunkCallbackData) => void): void {
|
||||
const parts = response.candidates[0].content.parts
|
||||
|
||||
if (!parts) {
|
||||
return
|
||||
}
|
||||
// 提取图像数据
|
||||
const images = parts
|
||||
.filter((part: Part) => part.inlineData)
|
||||
|
||||
+10
-1
@@ -15,7 +15,16 @@ interface ChunkCallbackData {
|
||||
interface CompletionsParams {
|
||||
messages: Message[]
|
||||
assistant: Assistant
|
||||
onChunk: ({ text, reasoning_content, usage, metrics, search, citations, mcpToolResponse }: ChunkCallbackData) => void
|
||||
onChunk: ({
|
||||
text,
|
||||
reasoning_content,
|
||||
usage,
|
||||
metrics,
|
||||
search,
|
||||
citations,
|
||||
mcpToolResponse,
|
||||
generateImage
|
||||
}: ChunkCallbackData) => void
|
||||
onFilterMessages: (messages: Message[]) => void
|
||||
mcpTools?: MCPTool[]
|
||||
}
|
||||
|
||||
@@ -127,7 +127,10 @@ export async function fetchChatCompletion({
|
||||
if (mcpToolResponse) {
|
||||
message.metadata = { ...message.metadata, mcpTools: cloneDeep(mcpToolResponse) }
|
||||
}
|
||||
if (generateImage) {
|
||||
if (generateImage && generateImage.images.length > 0) {
|
||||
const existingImages = message.metadata?.generateImage?.images || []
|
||||
generateImage.images = [...existingImages, ...generateImage.images]
|
||||
console.log('generateImage', generateImage)
|
||||
message.metadata = {
|
||||
...message.metadata,
|
||||
generateImage: generateImage
|
||||
|
||||
@@ -229,7 +229,7 @@ export function stopAutoSync() {
|
||||
autoSyncStarted = false
|
||||
}
|
||||
|
||||
async function getBackupData() {
|
||||
export async function getBackupData() {
|
||||
return JSON.stringify({
|
||||
time: new Date().getTime(),
|
||||
version: 3,
|
||||
@@ -239,7 +239,7 @@ async function getBackupData() {
|
||||
}
|
||||
|
||||
/************************************* Backup Utils ************************************** */
|
||||
async function handleData(data: Record<string, any>) {
|
||||
export async function handleData(data: Record<string, any>) {
|
||||
if (data.version === 1) {
|
||||
await clearDatabase()
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import i18n from '@renderer/i18n'
|
||||
import store from '@renderer/store'
|
||||
import { setNutstoreSyncState } from '@renderer/store/nutstore'
|
||||
import { WebDavConfig } from '@renderer/types'
|
||||
import { NUTSTORE_HOST } from '@shared/config/nutstore'
|
||||
import { type CreateDirectoryOptions } from 'webdav'
|
||||
|
||||
import { getBackupData, handleData } from './BackupService'
|
||||
|
||||
function getNutstoreToken() {
|
||||
const nutstoreToken = store.getState().nutstore.nutstoreToken
|
||||
|
||||
if (!nutstoreToken) {
|
||||
window.message.error({ content: i18n.t('error.invalid.nutstore_token'), key: 'nutstore' })
|
||||
return null
|
||||
}
|
||||
return nutstoreToken
|
||||
}
|
||||
|
||||
async function createNutstoreConfig(nutstoreToken: string): Promise<WebDavConfig | null> {
|
||||
const result = await window.api.nutstore.decryptToken(nutstoreToken)
|
||||
if (!result) {
|
||||
console.log('Invalid nutstore token')
|
||||
return null
|
||||
}
|
||||
|
||||
const nutstorePath = store.getState().nutstore.nutstorePath
|
||||
|
||||
const { username, access_token } = result
|
||||
return {
|
||||
webdavHost: NUTSTORE_HOST,
|
||||
webdavUser: username,
|
||||
webdavPass: access_token,
|
||||
webdavPath: nutstorePath
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkConnection() {
|
||||
const nutstoreToken = getNutstoreToken()
|
||||
if (!nutstoreToken) {
|
||||
return false
|
||||
}
|
||||
|
||||
const config = await createNutstoreConfig(nutstoreToken)
|
||||
if (!config) {
|
||||
return false
|
||||
}
|
||||
|
||||
const isSuccess = await window.api.backup.checkConnection({
|
||||
...config,
|
||||
webdavPath: '/'
|
||||
})
|
||||
|
||||
return isSuccess
|
||||
}
|
||||
|
||||
let autoSyncStarted = false
|
||||
let syncTimeout: NodeJS.Timeout | null = null
|
||||
let isAutoBackupRunning = false
|
||||
let isManualBackupRunning = false
|
||||
|
||||
export async function backupToNutstore(options: { showMessage?: boolean } = {}) {
|
||||
const nutstoreToken = getNutstoreToken()
|
||||
if (!nutstoreToken) {
|
||||
return
|
||||
}
|
||||
|
||||
const { showMessage = false } = options
|
||||
if (isManualBackupRunning) {
|
||||
console.log('Backup already in progress')
|
||||
return
|
||||
}
|
||||
|
||||
const config = await createNutstoreConfig(nutstoreToken)
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
isManualBackupRunning = true
|
||||
|
||||
store.dispatch(setNutstoreSyncState({ syncing: true, lastSyncError: null }))
|
||||
|
||||
const backupData = await getBackupData()
|
||||
|
||||
try {
|
||||
const isSuccess = await window.api.backup.backupToWebdav(backupData, config)
|
||||
|
||||
if (isSuccess) {
|
||||
store.dispatch(
|
||||
setNutstoreSyncState({
|
||||
lastSyncError: null
|
||||
})
|
||||
)
|
||||
showMessage && window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' })
|
||||
} else {
|
||||
store.dispatch(setNutstoreSyncState({ lastSyncError: 'Backup failed' }))
|
||||
window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' })
|
||||
}
|
||||
} catch (error) {
|
||||
store.dispatch(setNutstoreSyncState({ lastSyncError: 'Backup failed' }))
|
||||
console.error('[Nutstore] Backup failed:', error)
|
||||
window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' })
|
||||
} finally {
|
||||
store.dispatch(setNutstoreSyncState({ lastSyncTime: Date.now(), syncing: false }))
|
||||
isManualBackupRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreFromNutstore() {
|
||||
const nutstoreToken = getNutstoreToken()
|
||||
if (!nutstoreToken) {
|
||||
return
|
||||
}
|
||||
|
||||
const config = await createNutstoreConfig(nutstoreToken)
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
let data = ''
|
||||
|
||||
try {
|
||||
data = await window.api.backup.restoreFromWebdav(config)
|
||||
} catch (error: any) {
|
||||
console.error('[backup] restoreFromWebdav: Error downloading file from WebDAV:', error)
|
||||
window.modal.error({
|
||||
title: i18n.t('message.restore.failed'),
|
||||
content: error.message
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await handleData(JSON.parse(data))
|
||||
} catch (error) {
|
||||
console.error('[backup] Error downloading file from WebDAV:', error)
|
||||
window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' })
|
||||
}
|
||||
}
|
||||
|
||||
export async function startNutstoreAutoSync() {
|
||||
if (autoSyncStarted) {
|
||||
return
|
||||
}
|
||||
|
||||
const nutstoreToken = getNutstoreToken()
|
||||
if (!nutstoreToken) {
|
||||
window.message.error({ content: i18n.t('error.invalid.nutstore_token'), key: 'nutstore' })
|
||||
return
|
||||
}
|
||||
|
||||
autoSyncStarted = true
|
||||
|
||||
stopNutstoreAutoSync()
|
||||
|
||||
scheduleNextBackup()
|
||||
|
||||
function scheduleNextBackup() {
|
||||
if (syncTimeout) {
|
||||
clearTimeout(syncTimeout)
|
||||
syncTimeout = null
|
||||
}
|
||||
|
||||
const { nutstoreSyncInterval, nutstoreSyncState } = store.getState().nutstore
|
||||
|
||||
if (nutstoreSyncInterval <= 0) {
|
||||
console.log('[Nutstore AutoSync] Invalid sync interval, nutstore auto sync disabled')
|
||||
stopNutstoreAutoSync()
|
||||
return
|
||||
}
|
||||
|
||||
// 用户指定的自动备份时间间隔(毫秒)
|
||||
const requiredInterval = nutstoreSyncInterval * 60 * 1000
|
||||
|
||||
// 如果存在最后一次同步WebDAV的时间,以它为参考计算下一次同步的时间
|
||||
const timeUntilNextSync = nutstoreSyncState?.lastSyncTime
|
||||
? Math.max(1000, nutstoreSyncState.lastSyncTime + requiredInterval - Date.now())
|
||||
: requiredInterval
|
||||
|
||||
syncTimeout = setTimeout(performAutoBackup, timeUntilNextSync)
|
||||
|
||||
console.log(
|
||||
`[Nutstore AutoSync] Next sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor(
|
||||
(timeUntilNextSync / 1000) % 60
|
||||
)} seconds`
|
||||
)
|
||||
}
|
||||
|
||||
async function performAutoBackup() {
|
||||
if (isAutoBackupRunning || isManualBackupRunning) {
|
||||
console.log('[Nutstore AutoSync] Backup already in progress, rescheduling')
|
||||
scheduleNextBackup()
|
||||
return
|
||||
}
|
||||
|
||||
isAutoBackupRunning = true
|
||||
try {
|
||||
console.log('[Nutstore AutoSync] Starting auto backup...')
|
||||
await backupToNutstore({ showMessage: false })
|
||||
} catch (error) {
|
||||
console.error('[Nutstore AutoSync] Auto backup failed:', error)
|
||||
} finally {
|
||||
isAutoBackupRunning = false
|
||||
scheduleNextBackup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function stopNutstoreAutoSync() {
|
||||
if (syncTimeout) {
|
||||
console.log('[Nutstore AutoSync] Stopping nutstore auto sync')
|
||||
clearTimeout(syncTimeout)
|
||||
syncTimeout = null
|
||||
}
|
||||
isAutoBackupRunning = false
|
||||
autoSyncStarted = false
|
||||
}
|
||||
|
||||
export async function createDirectory(path: string, options?: CreateDirectoryOptions) {
|
||||
const nutstoreToken = getNutstoreToken()
|
||||
if (!nutstoreToken) {
|
||||
return
|
||||
}
|
||||
const config = await createNutstoreConfig(nutstoreToken)
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
await window.api.backup.createDirectory(config, path, options)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import mcp from './mcp'
|
||||
import messagesReducer from './messages'
|
||||
import migrate from './migrate'
|
||||
import minapps from './minapps'
|
||||
import nutstore from './nutstore'
|
||||
import ocr from './ocr'
|
||||
import paintings from './paintings'
|
||||
import runtime from './runtime'
|
||||
@@ -24,6 +25,7 @@ const rootReducer = combineReducers({
|
||||
assistants,
|
||||
agents,
|
||||
backup,
|
||||
nutstore,
|
||||
paintings,
|
||||
llm,
|
||||
settings,
|
||||
@@ -42,7 +44,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 85,
|
||||
version: 86,
|
||||
blacklist: ['runtime', 'messages'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@@ -801,6 +801,13 @@ const migrateConfig = {
|
||||
return state
|
||||
},
|
||||
'85': (state: RootState) => {
|
||||
// @ts-ignore eslint-disable-next-line
|
||||
state.settings.autoCheckUpdate = !state.settings.manualUpdateCheck
|
||||
// @ts-ignore eslint-disable-next-line
|
||||
delete state.settings.manualUpdateCheck
|
||||
return state
|
||||
},
|
||||
'86': (state: RootState) => {
|
||||
if (!state.ocr.providers) {
|
||||
state.ocr.providers = [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
|
||||
import { WebDAVSyncState } from './backup'
|
||||
|
||||
export interface NutstoreSyncState extends WebDAVSyncState {}
|
||||
|
||||
export interface NutstoreState {
|
||||
nutstoreToken: string | null
|
||||
nutstorePath: string
|
||||
nutstoreAutoSync: boolean
|
||||
nutstoreSyncInterval: number
|
||||
nutstoreSyncState: NutstoreSyncState
|
||||
}
|
||||
|
||||
const initialState: NutstoreState = {
|
||||
nutstoreToken: '',
|
||||
nutstorePath: '/cherry-studio',
|
||||
nutstoreAutoSync: false,
|
||||
nutstoreSyncInterval: 0,
|
||||
nutstoreSyncState: {
|
||||
lastSyncTime: null,
|
||||
syncing: false,
|
||||
lastSyncError: null
|
||||
}
|
||||
}
|
||||
|
||||
const nutstoreSlice = createSlice({
|
||||
name: 'nutstore',
|
||||
initialState,
|
||||
reducers: {
|
||||
setNutstoreToken: (state, action: PayloadAction<string>) => {
|
||||
state.nutstoreToken = action.payload
|
||||
},
|
||||
setNutstorePath: (state, action: PayloadAction<string>) => {
|
||||
console.log(state, action.payload)
|
||||
state.nutstorePath = action.payload
|
||||
},
|
||||
setNutstoreAutoSync: (state, action: PayloadAction<boolean>) => {
|
||||
state.nutstoreAutoSync = action.payload
|
||||
},
|
||||
setNutstoreSyncInterval: (state, action: PayloadAction<number>) => {
|
||||
state.nutstoreSyncInterval = action.payload
|
||||
},
|
||||
setNutstoreSyncState: (state, action: PayloadAction<Partial<WebDAVSyncState>>) => {
|
||||
state.nutstoreSyncState = { ...state.nutstoreSyncState, ...action.payload }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const { setNutstoreToken, setNutstorePath, setNutstoreAutoSync, setNutstoreSyncInterval, setNutstoreSyncState } =
|
||||
nutstoreSlice.actions
|
||||
|
||||
export default nutstoreSlice.reducer
|
||||
@@ -2,6 +2,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import { TRANSLATE_PROMPT } from '@renderer/config/prompts'
|
||||
import { CodeStyleVarious, LanguageVarious, ThemeMode, TranslateLanguageVarious } from '@renderer/types'
|
||||
|
||||
import { WebDAVSyncState } from './backup'
|
||||
|
||||
export type SendMessageShortcut = 'Enter' | 'Shift+Enter' | 'Ctrl+Enter' | 'Command+Enter'
|
||||
|
||||
export type SidebarIcon = 'assistants' | 'agents' | 'paintings' | 'translate' | 'minapp' | 'knowledge' | 'files'
|
||||
@@ -16,6 +18,8 @@ export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [
|
||||
'files'
|
||||
]
|
||||
|
||||
export interface NutstoreSyncRuntime extends WebDAVSyncState {}
|
||||
|
||||
export interface SettingsState {
|
||||
showAssistants: boolean
|
||||
showTopics: boolean
|
||||
@@ -41,7 +45,7 @@ export interface SettingsState {
|
||||
pasteLongTextAsFile: boolean
|
||||
pasteLongTextThreshold: number
|
||||
clickAssistantToShowTopic: boolean
|
||||
manualUpdateCheck: boolean
|
||||
autoCheckUpdate: boolean
|
||||
renderInputMessageAsMarkdown: boolean
|
||||
codeShowLineNumbers: boolean
|
||||
codeCollapsible: boolean
|
||||
@@ -92,6 +96,11 @@ export interface SettingsState {
|
||||
obsidianTages: string | null
|
||||
joplinToken: string | null
|
||||
joplinUrl: string | null
|
||||
// 思源笔记配置
|
||||
siyuanApiUrl: string | null
|
||||
siyuanToken: string | null
|
||||
siyuanBoxId: string | null
|
||||
siyuanRootPath: string | null
|
||||
}
|
||||
|
||||
export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid'
|
||||
@@ -121,7 +130,7 @@ const initialState: SettingsState = {
|
||||
pasteLongTextAsFile: false,
|
||||
pasteLongTextThreshold: 1500,
|
||||
clickAssistantToShowTopic: false,
|
||||
manualUpdateCheck: false,
|
||||
autoCheckUpdate: true,
|
||||
renderInputMessageAsMarkdown: false,
|
||||
codeShowLineNumbers: false,
|
||||
codeCollapsible: false,
|
||||
@@ -167,7 +176,12 @@ const initialState: SettingsState = {
|
||||
obsidianFolder: '',
|
||||
obsidianTages: '',
|
||||
joplinToken: '',
|
||||
joplinUrl: ''
|
||||
joplinUrl: '',
|
||||
// 思源笔记配置初始值
|
||||
siyuanApiUrl: null,
|
||||
siyuanToken: null,
|
||||
siyuanBoxId: null,
|
||||
siyuanRootPath: null
|
||||
}
|
||||
|
||||
const settingsSlice = createSlice({
|
||||
@@ -247,15 +261,15 @@ const settingsSlice = createSlice({
|
||||
setPasteLongTextAsFile: (state, action: PayloadAction<boolean>) => {
|
||||
state.pasteLongTextAsFile = action.payload
|
||||
},
|
||||
setAutoCheckUpdate: (state, action: PayloadAction<boolean>) => {
|
||||
state.autoCheckUpdate = action.payload
|
||||
},
|
||||
setRenderInputMessageAsMarkdown: (state, action: PayloadAction<boolean>) => {
|
||||
state.renderInputMessageAsMarkdown = action.payload
|
||||
},
|
||||
setClickAssistantToShowTopic: (state, action: PayloadAction<boolean>) => {
|
||||
state.clickAssistantToShowTopic = action.payload
|
||||
},
|
||||
setManualUpdateCheck: (state, action: PayloadAction<boolean>) => {
|
||||
state.manualUpdateCheck = action.payload
|
||||
},
|
||||
setWebdavHost: (state, action: PayloadAction<string>) => {
|
||||
state.webdavHost = action.payload
|
||||
},
|
||||
@@ -387,6 +401,18 @@ const settingsSlice = createSlice({
|
||||
setJoplinUrl: (state, action: PayloadAction<string>) => {
|
||||
state.joplinUrl = action.payload
|
||||
},
|
||||
setSiyuanApiUrl: (state, action: PayloadAction<string>) => {
|
||||
state.siyuanApiUrl = action.payload
|
||||
},
|
||||
setSiyuanToken: (state, action: PayloadAction<string>) => {
|
||||
state.siyuanToken = action.payload
|
||||
},
|
||||
setSiyuanBoxId: (state, action: PayloadAction<string>) => {
|
||||
state.siyuanBoxId = action.payload
|
||||
},
|
||||
setSiyuanRootPath: (state, action: PayloadAction<string>) => {
|
||||
state.siyuanRootPath = action.payload
|
||||
},
|
||||
setMessageNavigation: (state, action: PayloadAction<'none' | 'buttons' | 'anchor'>) => {
|
||||
state.messageNavigation = action.payload
|
||||
}
|
||||
@@ -418,9 +444,9 @@ export const {
|
||||
setShowTopicTime,
|
||||
setShowAssistantIcon,
|
||||
setPasteLongTextAsFile,
|
||||
setAutoCheckUpdate,
|
||||
setRenderInputMessageAsMarkdown,
|
||||
setClickAssistantToShowTopic,
|
||||
setManualUpdateCheck,
|
||||
setWebdavHost,
|
||||
setWebdavUser,
|
||||
setWebdavPass,
|
||||
@@ -463,6 +489,10 @@ export const {
|
||||
setObsidianTages,
|
||||
setJoplinToken,
|
||||
setJoplinUrl,
|
||||
setSiyuanApiUrl,
|
||||
setSiyuanToken,
|
||||
setSiyuanBoxId,
|
||||
setSiyuanRootPath,
|
||||
setMessageNavigation
|
||||
} = settingsSlice.actions
|
||||
|
||||
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
declare namespace Nutstore {
|
||||
export interface FileStat {
|
||||
path: string
|
||||
basename: string
|
||||
isDir: boolean
|
||||
}
|
||||
|
||||
type MaybePromise<T> = Promise<T> | T
|
||||
|
||||
export interface Fs {
|
||||
ls: (path: string) => MaybePromise<FileStat[]>
|
||||
mkdirs: (path: string) => Promise<void>
|
||||
}
|
||||
}
|
||||
@@ -450,3 +450,99 @@ export const exportMarkdownToJoplin = async (title: string, content: string) =>
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出Markdown到思源笔记
|
||||
* @param title 笔记标题
|
||||
* @param content 笔记内容
|
||||
*/
|
||||
export const exportMarkdownToSiyuan = async (title: string, content: string) => {
|
||||
const { isExporting } = store.getState().runtime.export
|
||||
const { siyuanApiUrl, siyuanToken, siyuanBoxId, siyuanRootPath } = store.getState().settings
|
||||
|
||||
if (isExporting) {
|
||||
window.message.warning({ content: i18n.t('message.warn.siyuan.exporting'), key: 'siyuan-exporting' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!siyuanApiUrl || !siyuanToken || !siyuanBoxId) {
|
||||
window.message.error({ content: i18n.t('message.error.siyuan.no_config'), key: 'siyuan-no-config-error' })
|
||||
return
|
||||
}
|
||||
|
||||
setExportState({ isExporting: true })
|
||||
|
||||
try {
|
||||
// test connection
|
||||
const testResponse = await fetch(`${siyuanApiUrl}/api/notebook/lsNotebooks`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Token ${siyuanToken}`
|
||||
}
|
||||
})
|
||||
|
||||
if (!testResponse.ok) {
|
||||
throw new Error('API请求失败')
|
||||
}
|
||||
|
||||
const testData = await testResponse.json()
|
||||
if (testData.code !== 0) {
|
||||
throw new Error(`${testData.msg || i18n.t('message.error.unknown')}`)
|
||||
}
|
||||
|
||||
// 确保根路径以/开头
|
||||
const rootPath = siyuanRootPath?.startsWith('/') ? siyuanRootPath : `/${siyuanRootPath || 'CherryStudio'}`
|
||||
|
||||
// 创建文档
|
||||
const docTitle = `${title.replace(/[#|\\^\\[\]]/g, '')}`
|
||||
const docPath = `${rootPath}/${docTitle}`
|
||||
|
||||
// 创建文档
|
||||
await createSiyuanDoc(siyuanApiUrl, siyuanToken, siyuanBoxId, docPath, content)
|
||||
|
||||
window.message.success({
|
||||
content: i18n.t('message.success.siyuan.export'),
|
||||
key: 'siyuan-success'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('导出到思源笔记失败:', error)
|
||||
window.message.error({
|
||||
content: i18n.t('message.error.siyuan.export') + (error instanceof Error ? `: ${error.message}` : ''),
|
||||
key: 'siyuan-error'
|
||||
})
|
||||
} finally {
|
||||
setExportState({ isExporting: false })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建思源笔记文档
|
||||
*/
|
||||
async function createSiyuanDoc(
|
||||
apiUrl: string,
|
||||
token: string,
|
||||
boxId: string,
|
||||
path: string,
|
||||
markdown: string
|
||||
): Promise<string> {
|
||||
const response = await fetch(`${apiUrl}/api/filetree/createDocWithMd`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Token ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
notebook: boxId,
|
||||
path: path,
|
||||
markdown: markdown
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code !== 0) {
|
||||
throw new Error(`${data.msg || i18n.t('message.error.unknown')}`)
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isReasoningModel } from '@renderer/config/models'
|
||||
import { getAssistantById } from '@renderer/services/AssistantService'
|
||||
import { Message } from '@renderer/types'
|
||||
|
||||
export function escapeDollarNumber(text: string) {
|
||||
@@ -91,11 +92,8 @@ const glmZeroPreviewProcessor: ThoughtProcessor = {
|
||||
canProcess: (content: string, message?: Message) => {
|
||||
if (!message) return false
|
||||
|
||||
const model = message.model
|
||||
if (!model || !isReasoningModel(model)) return false
|
||||
|
||||
const modelId = message.modelId || ''
|
||||
const modelName = model.name || ''
|
||||
const modelName = message.model?.name || ''
|
||||
const isGLMZeroPreview =
|
||||
modelId.toLowerCase().includes('glm-zero-preview') || modelName.toLowerCase().includes('glm-zero-preview')
|
||||
|
||||
@@ -117,9 +115,6 @@ const thinkTagProcessor: ThoughtProcessor = {
|
||||
canProcess: (content: string, message?: Message) => {
|
||||
if (!message) return false
|
||||
|
||||
const model = message.model
|
||||
if (!model || !isReasoningModel(model)) return false
|
||||
|
||||
return content.startsWith('<think>') || content.includes('</think>')
|
||||
},
|
||||
process: (content: string) => {
|
||||
@@ -162,6 +157,15 @@ export function withMessageThought(message: Message) {
|
||||
return message
|
||||
}
|
||||
|
||||
const model = message.model
|
||||
if (!model || !isReasoningModel(model)) return message
|
||||
|
||||
const isClaude37Sonnet = model.id.includes('claude-3-7-sonnet') || model.id.includes('claude-3.7-sonnet')
|
||||
if (isClaude37Sonnet) {
|
||||
const assistant = getAssistantById(message.assistantId)
|
||||
if (!assistant?.settings?.reasoning_effort) return message
|
||||
}
|
||||
|
||||
const content = message.content.trim()
|
||||
const processors: ThoughtProcessor[] = [glmZeroPreviewProcessor, thinkTagProcessor]
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"src/main/**/*",
|
||||
"src/preload/**/*",
|
||||
"src/main/env.d.ts",
|
||||
"src/renderer/src/types/index.ts",
|
||||
"src/renderer/src/types/*",
|
||||
"packages/shared/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
|
||||
@@ -3919,6 +3919,7 @@ __metadata:
|
||||
eslint-plugin-react-hooks: "npm:^5.2.0"
|
||||
eslint-plugin-simple-import-sort: "npm:^12.1.1"
|
||||
eslint-plugin-unused-imports: "npm:^4.1.4"
|
||||
fast-xml-parser: "npm:^5.0.9"
|
||||
fetch-socks: "npm:^1.3.2"
|
||||
fs-extra: "npm:^11.2.0"
|
||||
html-to-image: "npm:^1.11.13"
|
||||
@@ -7402,6 +7403,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fast-xml-parser@npm:^5.0.9":
|
||||
version: 5.0.9
|
||||
resolution: "fast-xml-parser@npm:5.0.9"
|
||||
dependencies:
|
||||
strnum: "npm:^2.0.5"
|
||||
bin:
|
||||
fxparser: src/cli/cli.js
|
||||
checksum: 10c0/29aaa74cb5224ddf755c2777fefce41961514fb525ce153ba9a8cbfd03292c93b67c0c19f3f4fdb5d8fa96a4b70c42dc31504eefc6477a668cea71a11999bc45
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fastq@npm:^1.6.0":
|
||||
version: 1.19.1
|
||||
resolution: "fastq@npm:1.19.1"
|
||||
@@ -15121,6 +15133,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"strnum@npm:^2.0.5":
|
||||
version: 2.0.5
|
||||
resolution: "strnum@npm:2.0.5"
|
||||
checksum: 10c0/856026ef65eaf15359d340a313ece25822b6472377b3029201b00f2657a1a3fa1cd7a7ce349dad35afdd00faf451344153dbb3d8478f082b7af8c17a64799ea6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"strtok3@npm:^6.2.4":
|
||||
version: 6.3.0
|
||||
resolution: "strtok3@npm:6.3.0"
|
||||
|
||||
Reference in New Issue
Block a user