diff --git a/electron-builder.yml b/electron-builder.yml index 8164bb2fd9..59de8a4f50 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -91,6 +91,7 @@ afterSign: scripts/notarize.js artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | + ⚠️ 注意:升级前请备份数据,否则将无法降级 重构消息结构,支持不同类型消息按时间顺序显示 智能体支持导入和导出 快捷面板增加网络搜索引擎选择 diff --git a/package.json b/package.json index 3459bdb54d..8531b1bb1e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.3.4", + "version": "1.3.5", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", @@ -74,9 +74,6 @@ "@strongtz/win32-arm64-msvc": "^0.4.7", "@tanstack/react-query": "^5.27.0", "@types/react-infinite-scroll-component": "^5.0.0", - "@uiw/codemirror-extensions-langs": "^4.23.12", - "@uiw/codemirror-themes-all": "^4.23.12", - "@uiw/react-codemirror": "^4.23.12", "archiver": "^7.0.1", "async-mutex": "^0.5.0", "color": "^5.0.0", @@ -94,13 +91,10 @@ "got-scraping": "^4.1.1", "jsdom": "^26.0.0", "markdown-it": "^14.1.0", - "mermaid": "^11.6.0", "node-stream-zip": "^1.15.0", "officeparser": "^4.1.1", "os-proxy-config": "^1.1.2", "proxy-agent": "^6.5.0", - "rc-virtual-list": "^3.18.6", - "react-window": "^1.8.11", "tar": "^7.4.3", "turndown": "^7.2.0", "turndown-plugin-gfm": "^1.0.2", @@ -123,17 +117,14 @@ "@eslint/js": "^9.22.0", "@google/genai": "^0.13.0", "@hello-pangea/dnd": "^16.6.0", - "@iconify-json/svg-spinners": "^1.2.2", "@kangfenmao/keyv-storage": "^0.1.0", "@modelcontextprotocol/sdk": "^1.11.3", "@mozilla/readability": "^0.6.0", "@notionhq/client": "^2.2.15", "@reduxjs/toolkit": "^2.2.5", "@shikijs/markdown-it": "^3.2.2", - "@swc/plugin-styled-components": "^7.1.3", - "@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch", + "@swc/plugin-styled-components": "^7.1.5", "@tryfabric/martian": "^1.2.4", - "@types/adm-zip": "^0", "@types/diff": "^7", "@types/fs-extra": "^11", "@types/lodash": "^4.17.5", @@ -147,15 +138,15 @@ "@types/react-window": "^1", "@types/tinycolor2": "^1", "@types/ws": "^8", + "@uiw/codemirror-extensions-langs": "^4.23.12", + "@uiw/codemirror-themes-all": "^4.23.12", + "@uiw/react-codemirror": "^4.23.12", "@vitejs/plugin-react-swc": "^3.9.0", - "@vitest/coverage-v8": "^3.1.1", "@vitest/ui": "^3.1.1", "@vitest/web-worker": "^3.1.3", "@xyflow/react": "^12.4.4", "antd": "^5.22.5", - "applescript": "^1.0.0", "axios": "^1.7.3", - "babel-plugin-styled-components": "^2.1.4", "browser-image-compression": "^2.0.2", "dayjs": "^1.11.11", "dexie": "^4.0.8", @@ -179,12 +170,14 @@ "lodash": "^4.17.21", "lru-cache": "^11.1.0", "lucide-react": "^0.487.0", + "mermaid": "^11.6.0", "mime": "^4.0.4", "motion": "^12.10.5", "npx-scope-finder": "^1.2.0", "openai": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch", "p-queue": "^8.1.0", "prettier": "^3.5.3", + "rc-virtual-list": "^3.18.6", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hotkeys-hook": "^4.6.1", @@ -195,6 +188,7 @@ "react-router": "6", "react-router-dom": "6", "react-spinners": "^0.14.1", + "react-window": "^1.8.11", "redux": "^5.0.1", "redux-persist": "^6.0.0", "rehype-katex": "^7.0.1", @@ -204,12 +198,11 @@ "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "rollup-plugin-visualizer": "^5.12.0", - "sass": "^1.77.2", + "sass": "^1.88.0", "shiki": "^3.2.2", "string-width": "^7.2.0", "styled-components": "^6.1.11", "tiny-pinyin": "^1.3.2", - "tinycolor2": "^1.6.0", "tokenx": "^0.4.1", "typescript": "^5.6.2", "uuid": "^10.0.0", diff --git a/src/main/index.ts b/src/main/index.ts index fb79b1e842..f85803ed84 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,3 +1,5 @@ +import '@main/config' + import { electronApp, optimizer } from '@electron-toolkit/utils' import { replaceDevtoolsFont } from '@main/utils/windowUtil' import { IpcChannel } from '@shared/IpcChannel' @@ -42,7 +44,7 @@ if (!app.requestSingleInstanceLock()) { } else { // Portable dir must be setup before app ready setUserDataDir() - + // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index ea8521aa16..4d951f3698 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -77,7 +77,8 @@ class BackupManager { _: Electron.IpcMainInvokeEvent, fileName: string, data: string, - destinationPath: string = this.backupDir + destinationPath: string = this.backupDir, + skipBackupFile: boolean = false ): Promise { const mainWindow = windowService.getMainWindow() @@ -104,23 +105,30 @@ class BackupManager { onProgress({ stage: 'writing_data', progress: 20, total: 100 }) - // 复制 Data 目录到临时目录 - const sourcePath = path.join(app.getPath('userData'), 'Data') - const tempDataDir = path.join(this.tempDir, 'Data') + Logger.log('[BackupManager IPC] ', skipBackupFile) - // 获取源目录总大小 - const totalSize = await this.getDirSize(sourcePath) - let copiedSize = 0 + if (!skipBackupFile) { + // 复制 Data 目录到临时目录 + const sourcePath = path.join(app.getPath('userData'), 'Data') + const tempDataDir = path.join(this.tempDir, 'Data') - // 使用流式复制 - await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => { - copiedSize += size - const progress = Math.min(50, Math.floor((copiedSize / totalSize) * 50)) - onProgress({ stage: 'copying_files', progress, total: 100 }) - }) + // 获取源目录总大小 + const totalSize = await this.getDirSize(sourcePath) + let copiedSize = 0 - await this.setWritableRecursive(tempDataDir) - onProgress({ stage: 'preparing_compression', progress: 50, total: 100 }) + // 使用流式复制 + await this.copyDirWithProgress(sourcePath, tempDataDir, (size) => { + copiedSize += size + const progress = Math.min(50, Math.floor((copiedSize / totalSize) * 50)) + onProgress({ stage: 'copying_files', progress, total: 100 }) + }) + + await this.setWritableRecursive(tempDataDir) + onProgress({ stage: 'preparing_compression', progress: 50, total: 100 }) + } else { + Logger.log('[BackupManager] Skip the backup of the file') + await fs.promises.mkdir(path.join(this.tempDir, 'Data')) // 不创建空 Data 目录会导致 restore 失败 + } // 创建输出文件流 const backupedFilePath = path.join(destinationPath, fileName) @@ -279,7 +287,7 @@ class BackupManager { async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) { const filename = webdavConfig.fileName || 'cherry-studio.backup.zip' - const backupedFilePath = await this.backup(_, filename, data) + const backupedFilePath = await this.backup(_, filename, data, undefined, webdavConfig.skipBackupFile) const webdavClient = new WebDav(webdavConfig) try { const result = await webdavClient.putFileContents(filename, fs.createReadStream(backupedFilePath), { diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index 6242709385..bb0cbfc422 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -62,7 +62,7 @@ export class ConfigManager { } getTrayOnClose(): boolean { - return !!this.get(ConfigKeys.TrayOnClose, true) + return !!this.get(ConfigKeys.TrayOnClose, false) } setTrayOnClose(value: boolean) { diff --git a/src/main/utils/windowUtil.ts b/src/main/utils/windowUtil.ts index d64929deb1..4000156fff 100644 --- a/src/main/utils/windowUtil.ts +++ b/src/main/utils/windowUtil.ts @@ -1,5 +1,7 @@ import { BrowserWindow } from 'electron' +import { isDev, isWin } from '../constant' + function isTilingWindowManager() { if (process.platform === 'darwin') { return false @@ -15,31 +17,59 @@ function isTilingWindowManager() { return tilingSystems.some((system) => desktopEnv?.includes(system)) } +//see: https://github.com/electron/electron/issues/42055#issuecomment-2449365647 export const replaceDevtoolsFont = (browserWindow: BrowserWindow) => { - if (process.platform === 'win32') { + //only for windows and dev, don't do this in production to avoid performance issues + if (isWin && isDev) { browserWindow.webContents.on('devtools-opened', () => { const css = ` :root { --sys-color-base: var(--ref-palette-neutral100); - --source-code-font-family: consolas; + --source-code-font-family: consolas !important; --source-code-font-size: 12px; - --monospace-font-family: consolas; + --monospace-font-family: consolas !important; --monospace-font-size: 12px; --default-font-family: system-ui, sans-serif; --default-font-size: 12px; + --ref-palette-neutral99: #ffffffff; } - .-theme-with-dark-background { + .theme-with-dark-background { --sys-color-base: var(--ref-palette-secondary25); } body { - --default-font-family: system-ui,sans-serif; - }` - + --default-font-family: system-ui, sans-serif; + } + ` browserWindow.webContents.devToolsWebContents?.executeJavaScript(` const overriddenStyle = document.createElement('style'); overriddenStyle.innerHTML = '${css.replaceAll('\n', ' ')}'; document.body.append(overriddenStyle); - document.body.classList.remove('platform-windows');`) + document.querySelectorAll('.platform-windows').forEach(el => el.classList.remove('platform-windows')); + addStyleToAutoComplete(); + const observer = new MutationObserver((mutationList, observer) => { + for (const mutation of mutationList) { + if (mutation.type === 'childList') { + for (let i = 0; i < mutation.addedNodes.length; i++) { + const item = mutation.addedNodes[i]; + if (item.classList.contains('editor-tooltip-host')) { + addStyleToAutoComplete(); + } + } + } + } + }); + observer.observe(document.body, {childList: true}); + function addStyleToAutoComplete() { + document.querySelectorAll('.editor-tooltip-host').forEach(element => { + if (element.shadowRoot.querySelectorAll('[data-key="overridden-dev-tools-font"]').length === 0) { + const overriddenStyle = document.createElement('style'); + overriddenStyle.setAttribute('data-key', 'overridden-dev-tools-font'); + overriddenStyle.innerHTML = '.cm-tooltip-autocomplete ul[role=listbox] {font-family: consolas !important;}'; + element.shadowRoot.append(overriddenStyle); + } + }); + } + `) }) } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 2a2f378fa2..3679dd0802 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,7 +2,7 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import { electronAPI } from '@electron-toolkit/preload' import { IpcChannel } from '@shared/IpcChannel' import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, WebDavConfig } from '@types' -import { contextBridge, ipcRenderer, OpenDialogOptions, shell } from 'electron' +import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron' import { CreateDirectoryOptions } from 'webdav' // Custom APIs for renderer @@ -37,8 +37,8 @@ const api = { decompress: (text: Buffer) => ipcRenderer.invoke(IpcChannel.Zip_Decompress, text) }, backup: { - backup: (fileName: string, data: string, destinationPath?: string) => - ipcRenderer.invoke(IpcChannel.Backup_Backup, fileName, data, destinationPath), + backup: (fileName: string, data: string, destinationPath?: string, skipBackupFile?: boolean) => + ipcRenderer.invoke(IpcChannel.Backup_Backup, fileName, data, destinationPath, skipBackupFile), restore: (backupPath: string) => ipcRenderer.invoke(IpcChannel.Backup_Restore, backupPath), backupToWebdav: (data: string, webdavConfig: WebDavConfig) => ipcRenderer.invoke(IpcChannel.Backup_BackupToWebdav, data, webdavConfig), @@ -73,7 +73,8 @@ const api = { download: (url: string) => ipcRenderer.invoke(IpcChannel.File_Download, url), copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath), binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId), - base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId) + base64File: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64File, fileId), + getPathForFile: (file: File) => webUtils.getPathForFile(file) }, fs: { read: (path: string) => ipcRenderer.invoke(IpcChannel.Fs_Read, path) diff --git a/src/renderer/src/assets/styles/color.scss b/src/renderer/src/assets/styles/color.scss new file mode 100644 index 0000000000..c142e8e270 --- /dev/null +++ b/src/renderer/src/assets/styles/color.scss @@ -0,0 +1,130 @@ +:root { + --color-white: #ffffff; + --color-white-soft: rgba(255, 255, 255, 0.8); + --color-white-mute: rgba(255, 255, 255, 0.94); + + --color-black: #181818; + --color-black-soft: #222222; + --color-black-mute: #333333; + + --color-gray-1: #515c67; + --color-gray-2: #414853; + --color-gray-3: #32363f; + + --color-text-1: rgba(255, 255, 245, 0.9); + --color-text-2: rgba(235, 235, 245, 0.6); + --color-text-3: rgba(235, 235, 245, 0.38); + + --color-background: var(--color-black); + --color-background-soft: var(--color-black-soft); + --color-background-mute: var(--color-black-mute); + --color-background-opacity: rgba(34, 34, 34, 0.7); + --inner-glow-opacity: 0.3; // For the glassmorphism effect in the dropdown menu + + --color-primary: #00b96b; + --color-primary-soft: #00b96b99; + --color-primary-mute: #00b96b33; + + --color-text: var(--color-text-1); + --color-icon: #ffffff99; + --color-icon-white: #ffffff; + --color-border: #ffffff19; + --color-border-soft: #ffffff10; + --color-border-mute: #ffffff05; + --color-error: #f44336; + --color-link: #338cff; + --color-code-background: #323232; + --color-hover: rgba(40, 40, 40, 1); + --color-active: rgba(55, 55, 55, 1); + --color-frame-border: #333; + --color-group-background: var(--color-background-soft); + + --color-reference: #404040; + --color-reference-text: #ffffff; + --color-reference-background: #0b0e12; + + --modal-background: #1f1f1f; + + --color-highlight: rgba(0, 0, 0, 1); + --color-background-highlight: rgba(255, 255, 0, 0.9); + --color-background-highlight-accent: rgba(255, 150, 50, 0.9); + + --navbar-background-mac: rgba(20, 20, 20, 0.55); + --navbar-background: #1f1f1f; + + --navbar-height: 40px; + --sidebar-width: 50px; + --status-bar-height: 40px; + --input-bar-height: 100px; + + --assistants-width: 275px; + --topic-list-width: 275px; + --settings-width: 250px; + + --chat-background: #111111; + --chat-background-user: #28b561; + --chat-background-assistant: #2c2c2c; + --chat-text-user: var(--color-black); + + --list-item-border-radius: 16px; +} + +[theme-mode='light'] { + --color-white: #ffffff; + --color-white-soft: rgba(0, 0, 0, 0.04); + --color-white-mute: #eee; + + --color-black: #1b1b1f; + --color-black-soft: #262626; + --color-black-mute: #363636; + + --color-gray-1: #8e8e93; + --color-gray-2: #aeaeb2; + --color-gray-3: #c7c7cc; + + --color-text-1: rgba(0, 0, 0, 1); + --color-text-2: rgba(0, 0, 0, 0.6); + --color-text-3: rgba(0, 0, 0, 0.38); + + --color-background: var(--color-white); + --color-background-soft: var(--color-white-soft); + --color-background-mute: var(--color-white-mute); + --color-background-opacity: rgba(235, 235, 235, 0.7); + --inner-glow-opacity: 0.1; + + --color-primary: #00b96b; + --color-primary-soft: #00b96b99; + --color-primary-mute: #00b96b33; + + --color-text: var(--color-text-1); + --color-icon: #00000099; + --color-icon-white: #000000; + --color-border: #00000019; + --color-border-soft: #00000010; + --color-border-mute: #00000005; + --color-error: #f44336; + --color-link: #1677ff; + --color-code-background: #e3e3e3; + --color-hover: var(--color-white-mute); + --color-active: var(--color-white-soft); + --color-frame-border: #ddd; + --color-group-background: var(--color-white); + + --color-reference: #cfe1ff; + --color-reference-text: #000000; + --color-reference-background: #f1f7ff; + + --modal-background: var(--color-white); + + --color-highlight: initial; + --color-background-highlight: rgba(255, 255, 0, 0.5); + --color-background-highlight-accent: rgba(255, 150, 50, 0.5); + + --navbar-background-mac: rgba(255, 255, 255, 0.55); + --navbar-background: rgba(244, 244, 244); + + --chat-background: #f3f3f3; + --chat-background-user: #95ec69; + --chat-background-assistant: #ffffff; + --chat-text-user: var(--color-text); +} diff --git a/src/renderer/src/assets/styles/font.scss b/src/renderer/src/assets/styles/font.scss new file mode 100644 index 0000000000..9d2d139b53 --- /dev/null +++ b/src/renderer/src/assets/styles/font.scss @@ -0,0 +1,12 @@ +:root { + --font-family: + Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Roboto, Oxygen, Cantarell, 'Open Sans', + 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', + 'Noto Color Emoji'; + + --font-family-serif: + serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, Ubuntu, Roboto, Oxygen, Cantarell, 'Open Sans', + 'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + + --code-font-family: 'Cascadia Code', 'Fira Code', 'Consolas', Menlo, Courier, monospace; +} diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index 3b5d98e941..17bc04d6cc 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -1,3 +1,5 @@ +@use './color.scss'; +@use './font.scss'; @use './markdown.scss'; @use './ant.scss'; @use './scrollbar.scss'; @@ -6,73 +8,6 @@ @import '../fonts/icon-fonts/iconfont.css'; @import '../fonts/ubuntu/ubuntu.css'; -:root { - --color-white: #ffffff; - --color-white-soft: rgba(255, 255, 255, 0.8); - --color-white-mute: rgba(255, 255, 255, 0.94); - - --color-black: #181818; - --color-black-soft: #222222; - --color-black-mute: #333333; - - --color-gray-1: #515c67; - --color-gray-2: #414853; - --color-gray-3: #32363f; - - --color-text-1: rgba(255, 255, 245, 0.9); - --color-text-2: rgba(235, 235, 245, 0.6); - --color-text-3: rgba(235, 235, 245, 0.38); - - --color-background: var(--color-black); - --color-background-soft: var(--color-black-soft); - --color-background-mute: var(--color-black-mute); - --color-background-opacity: rgba(34, 34, 34, 0.7); - --inner-glow-opacity: 0.3; // For the glassmorphism effect in the dropdown menu - - --color-primary: #00b96b; - --color-primary-soft: #00b96b99; - --color-primary-mute: #00b96b33; - - --color-text: var(--color-text-1); - --color-icon: #ffffff99; - --color-icon-white: #ffffff; - --color-border: #ffffff19; - --color-border-soft: #ffffff10; - --color-border-mute: #ffffff05; - --color-error: #f44336; - --color-link: #338cff; - --color-code-background: #323232; - --color-hover: rgba(40, 40, 40, 1); - --color-active: rgba(55, 55, 55, 1); - --color-frame-border: #333; - --color-group-background: var(--color-background-soft); - - --color-reference: #404040; - --color-reference-text: #ffffff; - --color-reference-background: #0b0e12; - - --modal-background: #1f1f1f; - - --navbar-background-mac: rgba(20, 20, 20, 0.55); - --navbar-background: #1f1f1f; - - --navbar-height: 40px; - --sidebar-width: 50px; - --status-bar-height: 40px; - --input-bar-height: 100px; - - --assistants-width: 275px; - --topic-list-width: 275px; - --settings-width: 250px; - - --chat-background: #111111; - --chat-background-user: #28b561; - --chat-background-assistant: #2c2c2c; - --chat-text-user: var(--color-black); - - --list-item-border-radius: 16px; -} - body { -webkit-user-select: none; -moz-user-select: none; @@ -80,62 +15,6 @@ body { user-select: none; } -body[theme-mode='light'] { - --color-white: #ffffff; - --color-white-soft: rgba(0, 0, 0, 0.04); - --color-white-mute: #eee; - - --color-black: #1b1b1f; - --color-black-soft: #262626; - --color-black-mute: #363636; - - --color-gray-1: #8e8e93; - --color-gray-2: #aeaeb2; - --color-gray-3: #c7c7cc; - - --color-text-1: rgba(0, 0, 0, 1); - --color-text-2: rgba(0, 0, 0, 0.6); - --color-text-3: rgba(0, 0, 0, 0.38); - - --color-background: var(--color-white); - --color-background-soft: var(--color-white-soft); - --color-background-mute: var(--color-white-mute); - --color-background-opacity: rgba(235, 235, 235, 0.7); - --inner-glow-opacity: 0.1; - - --color-primary: #00b96b; - --color-primary-soft: #00b96b99; - --color-primary-mute: #00b96b33; - - --color-text: var(--color-text-1); - --color-icon: #00000099; - --color-icon-white: #000000; - --color-border: #00000019; - --color-border-soft: #00000010; - --color-border-mute: #00000005; - --color-error: #f44336; - --color-link: #1677ff; - --color-code-background: #e3e3e3; - --color-hover: var(--color-white-mute); - --color-active: var(--color-white-soft); - --color-frame-border: #ddd; - --color-group-background: var(--color-white); - - --color-reference: #cfe1ff; - --color-reference-text: #000000; - --color-reference-background: #f1f7ff; - - --modal-background: var(--color-white); - - --navbar-background-mac: rgba(255, 255, 255, 0.55); - --navbar-background: rgba(244, 244, 244); - - --chat-background: #f3f3f3; - --chat-background-user: #95ec69; - --chat-background-assistant: #ffffff; - --chat-text-user: var(--color-text); -} - *, *::before, *::after { @@ -163,9 +42,7 @@ body { font-size: 14px; line-height: 1.6; overflow: hidden; - font-family: - -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', - sans-serif; + font-family: var(--font-family); text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; @@ -291,3 +168,11 @@ body, .lucide { color: var(--color-icon); } + +span.highlight { + background-color: var(--color-background-highlight); + color: var(--color-highlight); +} +span.highlight.selected { + background-color: var(--color-background-highlight-accent); +} diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss index c96e5b2f58..40c0255468 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -20,10 +20,8 @@ h5, h6 { margin: 1em 0 1em 0; - font-weight: 800; - font-family: - -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', - sans-serif; + font-weight: bold; + font-family: var(--font-family); } h1 { @@ -117,7 +115,7 @@ } code { - font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; + font-family: var(--code-font-family); } pre { @@ -155,7 +153,7 @@ padding-left: 1em; color: var(--color-text-light); border-left: 4px solid var(--color-border); - font-family: Georgia, 'Times New Roman', Times, serif; + font-family: var(--font-family); } table { @@ -173,9 +171,7 @@ th { background-color: var(--color-background-mute); font-weight: bold; - font-family: - -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', - sans-serif; + font-family: var(--font-family); } img { @@ -310,7 +306,7 @@ mjx-container { /* CodeMirror 相关样式 */ .cm-editor { .cm-scroller { - font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; + font-family: var(--code-font-family); padding: 1px; border-radius: 5px; diff --git a/src/renderer/src/components/ContentSearch.tsx b/src/renderer/src/components/ContentSearch.tsx new file mode 100644 index 0000000000..1f207ce9db --- /dev/null +++ b/src/renderer/src/components/ContentSearch.tsx @@ -0,0 +1,710 @@ +import { ToolbarButton } from '@renderer/pages/home/Inputbar/Inputbar' +import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout' +import { Tooltip } from 'antd' +import { debounce } from 'lodash' +import { CaseSensitive, ChevronDown, ChevronUp, User, WholeWord, X } from 'lucide-react' +import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +const HIGHLIGHT_CLASS = 'highlight' +const HIGHLIGHT_SELECT_CLASS = 'selected' + +interface Props { + children?: React.ReactNode + searchTarget: React.RefObject | React.RefObject | HTMLElement + /** + * 过滤`node`,`node`只会是`Node.TEXT_NODE`类型的文本节点 + * + * 返回`true`表示该`node`会被搜索 + */ + filter: (node: Node) => boolean + includeUser?: boolean + onIncludeUserChange?: (value: boolean) => void +} + +enum SearchCompletedState { + NotSearched, + FirstSearched +} + +enum SearchTargetIndex { + Next, + Prev +} + +export interface ContentSearchRef { + disable(): void + enable(initialText?: string): void + // 搜索下一个并定位 + searchNext(): void + // 搜索上一个并定位 + searchPrev(): void + // 搜索并定位 + search(): void + // 搜索但不定位,或者说是更新 + silentSearch(): void + focus(): void +} + +interface MatchInfo { + index: number + length: number + text: string +} + +const escapeRegExp = (string: string): string => { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string +} + +const findWindowVerticalCenterElementIndex = (elementList: HTMLElement[]): number | null => { + if (!elementList || elementList.length === 0) { + return null + } + let closestElementIndex: number | null = null + let minVerticalDistance = Infinity + const windowCenterY = window.innerHeight / 2 + for (let i = 0; i < elementList.length; i++) { + const element = elementList[i] + if (!(element instanceof HTMLElement)) { + continue + } + const rect = element.getBoundingClientRect() + if (rect.bottom < 0 || rect.top > window.innerHeight) { + continue + } + const elementCenterY = rect.top + rect.height / 2 + const verticalDistance = Math.abs(elementCenterY - windowCenterY) + if (verticalDistance < minVerticalDistance) { + minVerticalDistance = verticalDistance + closestElementIndex = i + } + } + return closestElementIndex +} + +const highlightText = ( + textNode: Node, + searchText: string, + highlightClass: string, + isCaseSensitive: boolean, + isWholeWord: boolean +): HTMLSpanElement[] | null => { + const textNodeParentNode: HTMLElement | null = textNode.parentNode as HTMLElement + if (textNodeParentNode) { + if (textNodeParentNode.classList.contains(highlightClass)) { + return null + } + } + if (textNode.nodeType !== Node.TEXT_NODE || !textNode.textContent) { + return null + } + + const textContent = textNode.textContent + const escapedSearchText = escapeRegExp(searchText) + + // 检查搜索文本是否仅包含拉丁字母 + const hasOnlyLatinLetters = /^[a-zA-Z\s]+$/.test(searchText) + + // 只有当搜索文本仅包含拉丁字母时才应用大小写敏感 + const regexFlags = hasOnlyLatinLetters && isCaseSensitive ? 'g' : 'gi' + const regexPattern = isWholeWord ? `\\b${escapedSearchText}\\b` : escapedSearchText + const regex = new RegExp(regexPattern, regexFlags) + + let match + const matches: MatchInfo[] = [] + while ((match = regex.exec(textContent)) !== null) { + if (typeof match.index === 'number' && typeof match[0] === 'string') { + matches.push({ index: match.index, length: match[0].length, text: match[0] }) + } else { + console.error('Unexpected match format:', match) + } + } + + if (matches.length === 0) { + return null + } + + const parentNode = textNode.parentNode + if (!parentNode) { + return null + } + + const fragment = document.createDocumentFragment() + let currentIndex = 0 + const highlightTextSet = new Set() + + matches.forEach(({ index, length, text }) => { + if (index > currentIndex) { + fragment.appendChild(document.createTextNode(textContent.substring(currentIndex, index))) + } + const highlightSpan = document.createElement('span') + highlightSpan.className = highlightClass + highlightSpan.textContent = text // Use the matched text to preserve case if not case-sensitive + fragment.appendChild(highlightSpan) + highlightTextSet.add(highlightSpan) + currentIndex = index + length + }) + + if (currentIndex < textContent.length) { + fragment.appendChild(document.createTextNode(textContent.substring(currentIndex))) + } + + parentNode.replaceChild(fragment, textNode) + return [...highlightTextSet] +} + +const mergeAdjacentTextNodes = (node: HTMLElement) => { + const children = Array.from(node.childNodes) + const groups: Array = [] + let currentTextGroup: { text: string; nodes: Node[] } | null = null + + for (const child of children) { + if (child.nodeType === Node.TEXT_NODE) { + if (currentTextGroup === null) { + currentTextGroup = { + text: child.textContent ?? '', + nodes: [child] + } + } else { + currentTextGroup.text += child.textContent + currentTextGroup.nodes.push(child) + } + } else { + if (currentTextGroup !== null) { + groups.push(currentTextGroup!) + currentTextGroup = null + } + groups.push(child) + } + } + + if (currentTextGroup !== null) { + groups.push(currentTextGroup) + } + + const newChildren = groups.map((group) => { + if (group instanceof Node) { + return group + } else { + return document.createTextNode(group.text) + } + }) + + node.replaceChildren(...newChildren) +} + +// eslint-disable-next-line @eslint-react/no-forward-ref +export const ContentSearch = React.forwardRef( + ({ searchTarget, filter, includeUser = false, onIncludeUserChange }, ref) => { + const target: HTMLElement | null = (() => { + if (searchTarget instanceof HTMLElement) { + return searchTarget + } else { + return (searchTarget.current as HTMLElement) ?? null + } + })() + const containerRef = React.useRef(null) + const searchInputRef = React.useRef(null) + const [searchResultIndex, setSearchResultIndex] = useState(0) + const [totalCount, setTotalCount] = useState(0) + const [enableContentSearch, setEnableContentSearch] = useState(false) + const [searchCompleted, setSearchCompleted] = useState(SearchCompletedState.NotSearched) + const [isCaseSensitive, setIsCaseSensitive] = useState(false) + const [isWholeWord, setIsWholeWord] = useState(false) + const [shouldScroll, setShouldScroll] = useState(false) + const highlightTextSet = useState(new Set())[0] + const prevSearchText = useRef('') + const { t } = useTranslation() + + const locateByIndex = (index: number, shouldScroll = true) => { + if (target) { + const highlightTextNodes = [...highlightTextSet] as HTMLElement[] + highlightTextNodes.sort((a, b) => { + const { top: aTop } = a.getBoundingClientRect() + const { top: bTop } = b.getBoundingClientRect() + return aTop - bTop + }) + for (const node of highlightTextNodes) { + node.classList.remove(HIGHLIGHT_SELECT_CLASS) + } + setSearchResultIndex(index) + if (highlightTextNodes.length > 0) { + const highlightTextNode = highlightTextNodes[index] ?? null + if (highlightTextNode) { + highlightTextNode.classList.add(HIGHLIGHT_SELECT_CLASS) + if (shouldScroll) { + highlightTextNode.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center' + }) + } + } + } + } + } + + const restoreHighlight = () => { + const highlightTextParentNodeSet = new Set() + // Make a copy because the set might be modified during iteration indirectly + const nodesToRestore = [...highlightTextSet] + for (const highlightTextNode of nodesToRestore) { + if (highlightTextNode.textContent) { + const textNode = document.createTextNode(highlightTextNode.textContent) + const node = highlightTextNode as HTMLElement + if (node.parentNode) { + highlightTextParentNodeSet.add(node.parentNode as HTMLElement) + node.replaceWith(textNode) // This removes the node from the DOM + } + } + } + highlightTextSet.clear() // Clear the original set after processing + for (const parentNode of highlightTextParentNodeSet) { + mergeAdjacentTextNodes(parentNode) + } + // highlightTextSet.clear() // Already cleared + } + + const search = (searchTargetIndex?: SearchTargetIndex): number | null => { + const searchText = searchInputRef.current?.value.trim() ?? null + if (target && searchText !== null && searchText !== '') { + restoreHighlight() + const iter = document.createNodeIterator(target, NodeFilter.SHOW_TEXT) + let textNode: Node | null + const textNodeSet: Set = new Set() + while ((textNode = iter.nextNode())) { + if (filter(textNode)) { + textNodeSet.add(textNode) + } + } + + const highlightTextSetTemp = new Set() + for (const node of textNodeSet) { + const list = highlightText(node, searchText, HIGHLIGHT_CLASS, isCaseSensitive, isWholeWord) + if (list) { + list.forEach((node) => highlightTextSetTemp.add(node)) + } + } + const highlightTextList = [...highlightTextSetTemp] + setTotalCount(highlightTextList.length) + highlightTextSetTemp.forEach((node) => highlightTextSet.add(node)) + const changeIndex = () => { + let index: number + switch (searchTargetIndex) { + case SearchTargetIndex.Next: + { + index = (searchResultIndex + 1) % highlightTextList.length + } + break + case SearchTargetIndex.Prev: + { + index = (searchResultIndex - 1 + highlightTextList.length) % highlightTextList.length + } + break + default: { + index = searchResultIndex + } + } + return Math.max(index, 0) + } + + const targetIndex = (() => { + switch (searchCompleted) { + case SearchCompletedState.NotSearched: { + setSearchCompleted(SearchCompletedState.FirstSearched) + const index = findWindowVerticalCenterElementIndex(highlightTextList) + if (index !== null) { + setSearchResultIndex(index) + return index + } else { + setSearchResultIndex(0) + return 0 + } + } + case SearchCompletedState.FirstSearched: { + return changeIndex() + } + default: { + return null + } + } + })() + + if (targetIndex === null) { + return null + } else { + const totalCount = highlightTextSet.size + if (targetIndex >= totalCount) { + return totalCount - 1 + } else { + return targetIndex + } + } + } else { + return null + } + } + + const _searchHandlerDebounce = debounce(() => { + implementation.search() + }, 300) + const searchHandler = useCallback(_searchHandlerDebounce, [_searchHandlerDebounce]) + const userInputHandler = (event: React.ChangeEvent) => { + const value = event.target.value.trim() + if (value.length === 0) { + restoreHighlight() + setTotalCount(0) + setSearchResultIndex(0) + setSearchCompleted(SearchCompletedState.NotSearched) + } else { + // 用户输入时允许滚动 + setShouldScroll(true) + searchHandler() + } + prevSearchText.current = value + } + + const keyDownHandler = (event: React.KeyboardEvent) => { + const { code, key, shiftKey } = event + if (key === 'Process') { + return + } + + switch (code) { + case 'Enter': + { + if (shiftKey) { + implementation.searchPrev() + } else { + implementation.searchNext() + } + event.preventDefault() + } + break + case 'Escape': + { + implementation.disable() + } + break + } + } + + const searchInputFocus = () => requestAnimationFrame(() => searchInputRef.current?.focus()) + + const userOutlinedButtonOnClick = () => { + if (onIncludeUserChange) { + onIncludeUserChange(!includeUser) + } + searchInputFocus() + } + + const implementation = { + disable() { + setEnableContentSearch(false) + restoreHighlight() + setShouldScroll(false) + }, + enable(initialText?: string) { + setEnableContentSearch(true) + setShouldScroll(false) // Default to false, search itself might set it to true + if (searchInputRef.current) { + const inputEl = searchInputRef.current + if (initialText && initialText.trim().length > 0) { + inputEl.value = initialText + // Trigger search after setting initial text + // Need to make sure search() uses the new value + // and also to focus and select + requestAnimationFrame(() => { + inputEl.focus() + inputEl.select() + setShouldScroll(true) + const targetIndex = search() + if (targetIndex !== null) { + locateByIndex(targetIndex, true) // Ensure scrolling + } else { + // If search returns null (e.g., empty input or no matches with initial text), clear state + restoreHighlight() + setTotalCount(0) + setSearchResultIndex(0) + setSearchCompleted(SearchCompletedState.NotSearched) + } + }) + } else { + requestAnimationFrame(() => { + inputEl.focus() + inputEl.select() + }) + // Only search if there's existing text and no new initialText + if (inputEl.value.trim()) { + const targetIndex = search() + if (targetIndex !== null) { + setSearchResultIndex(targetIndex) + // locateByIndex(targetIndex, false); // Don't scroll if just enabling with existing text + } + } + } + } + }, + searchNext() { + if (enableContentSearch) { + const targetIndex = search(SearchTargetIndex.Next) + if (targetIndex !== null) { + locateByIndex(targetIndex) + } + } + }, + searchPrev() { + if (enableContentSearch) { + const targetIndex = search(SearchTargetIndex.Prev) + if (targetIndex !== null) { + locateByIndex(targetIndex) + } + } + }, + resetSearchState() { + if (enableContentSearch) { + setSearchCompleted(SearchCompletedState.NotSearched) + // Maybe also reset index? Depends on desired behavior + // setSearchResultIndex(0); + } + }, + search() { + if (enableContentSearch) { + const targetIndex = search() + if (targetIndex !== null) { + locateByIndex(targetIndex, shouldScroll) + } else { + // If search returns null (e.g., empty input), clear state + restoreHighlight() + setTotalCount(0) + setSearchResultIndex(0) + setSearchCompleted(SearchCompletedState.NotSearched) + } + } + }, + silentSearch() { + if (enableContentSearch) { + const targetIndex = search() + if (targetIndex !== null) { + // 只更新索引,不触发滚动 + locateByIndex(targetIndex, false) + } + } + }, + focus() { + searchInputFocus() + } + } + + useImperativeHandle(ref, () => ({ + disable() { + implementation.disable() + }, + enable(initialText?: string) { + implementation.enable(initialText) + }, + searchNext() { + implementation.searchNext() + }, + searchPrev() { + implementation.searchPrev() + }, + search() { + implementation.search() + }, + silentSearch() { + implementation.silentSearch() + }, + focus() { + implementation.focus() + } + })) + + // Re-run search when options change and search is active + useEffect(() => { + if (enableContentSearch && searchInputRef.current?.value.trim()) { + implementation.search() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isCaseSensitive, isWholeWord, enableContentSearch]) // Add enableContentSearch dependency + + const prevButtonOnClick = () => { + implementation.searchPrev() + searchInputFocus() + } + + const nextButtonOnClick = () => { + implementation.searchNext() + searchInputFocus() + } + + const closeButtonOnClick = () => { + implementation.disable() + } + + const caseSensitiveButtonOnClick = () => { + setIsCaseSensitive(!isCaseSensitive) + searchInputFocus() + } + + const wholeWordButtonOnClick = () => { + setIsWholeWord(!isWholeWord) + searchInputFocus() + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + {searchCompleted !== SearchCompletedState.NotSearched ? ( + totalCount > 0 ? ( + <> + {searchResultIndex + 1} + / + {totalCount} + + ) : ( + {t('common.no_results')} + ) + ) : ( + 0/0 + )} + + + + + + + + + + + + + + + + + ) + } +) + +ContentSearch.displayName = 'ContentSearch' + +const Container = styled.div` + display: flex; + flex-direction: row; + z-index: 2; +` + +const SearchBarContainer = styled.div` + border: 1px solid var(--color-border); + border-radius: 10px; + transition: all 0.2s ease; + position: relative; + margin: 5px 20px; + margin-bottom: 0; + padding: 6px 15px 8px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--color-background-opacity); + flex: 1 1 auto; /* Take up input's previous space */ +` + +const Placeholder = styled.div` + width: 5px; +` + +const InputWrapper = styled.div` + display: flex; + align-items: center; + flex: 1 1 auto; /* Take up input's previous space */ +` + +const Input = styled.input` + border: none; + color: var(--color-text); + background-color: transparent; + outline: none; + width: 100%; + padding: 0 5px; /* Adjust padding, wrapper will handle spacing */ + flex: 1; /* Allow input to grow */ + font-size: 14px; + font-family: Ubuntu; +` + +const ToolBar = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: tpx; +` + +const Separator = styled.div` + width: 1px; + height: 1.5em; + background-color: var(--color-border); + margin-left: 2px; + margin-right: 2px; + flex: 0 0 auto; +` + +const SearchResults = styled.div` + display: flex; + justify-content: center; + width: 80px; + margin: 0 2px; + flex: 0 0 auto; + color: var(--color-text-secondary); + font-size: 14px; + font-family: Ubuntu; +` + +const SearchResultsPlaceholder = styled.span` + color: var(--color-text-secondary); + opacity: 0.5; +` + +const NoResults = styled.span` + color: var(--color-text-secondary); +` + +const SearchResultCount = styled.span` + color: var(--color-text); +` + +const SearchResultSeparator = styled.span` + color: var(--color-text); + margin: 0 4px; +` + +const SearchResultTotalCount = styled.span` + color: var(--color-text); +` diff --git a/src/renderer/src/components/Layout/index.ts b/src/renderer/src/components/Layout/index.ts index 2e5a0b608a..6ebc5788c5 100644 --- a/src/renderer/src/components/Layout/index.ts +++ b/src/renderer/src/components/Layout/index.ts @@ -149,15 +149,6 @@ export const BaseTypography = styled(Box)<{ text-align: ${(props) => props.textAlign || 'left'}; ` -export const TypographyNormal = styled(BaseTypography)` - font-family: 'Ubuntu'; -` - -export const TypographyBold = styled(BaseTypography)` - font-family: 'Ubuntu'; - font-weight: bold; -` - export const Container = styled.main` display: flex; flex-direction: column; diff --git a/src/renderer/src/components/ListItem/index.tsx b/src/renderer/src/components/ListItem/index.tsx index b3d4132845..b93f86b8f6 100644 --- a/src/renderer/src/components/ListItem/index.tsx +++ b/src/renderer/src/components/ListItem/index.tsx @@ -35,7 +35,6 @@ const ListItemContainer = styled.div` flex-direction: column; justify-content: space-between; position: relative; - font-family: Ubuntu; cursor: pointer; border: 1px solid transparent; diff --git a/src/renderer/src/components/ModelIdWithTags.tsx b/src/renderer/src/components/ModelIdWithTags.tsx index cfd109d0aa..4b8ca86123 100644 --- a/src/renderer/src/components/ModelIdWithTags.tsx +++ b/src/renderer/src/components/ModelIdWithTags.tsx @@ -57,7 +57,6 @@ const NameSpan = styled.span` text-overflow: ellipsis; white-space: nowrap; cursor: help; - font-family: 'Ubuntu'; line-height: 30px; ` diff --git a/src/renderer/src/components/Popups/BackupPopup.tsx b/src/renderer/src/components/Popups/BackupPopup.tsx index 41eb268a16..dd19e0010b 100644 --- a/src/renderer/src/components/Popups/BackupPopup.tsx +++ b/src/renderer/src/components/Popups/BackupPopup.tsx @@ -1,6 +1,8 @@ import { backup } from '@renderer/services/BackupService' +import store from '@renderer/store' import { IpcChannel } from '@shared/IpcChannel' import { Modal, Progress } from 'antd' +import Logger from 'electron-log' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -20,6 +22,7 @@ const PopupContainer: React.FC = ({ resolve }) => { const [open, setOpen] = useState(true) const [progressData, setProgressData] = useState() const { t } = useTranslation() + const skipBackupFile = store.getState().settings.skipBackupFile useEffect(() => { const removeListener = window.electron.ipcRenderer.on(IpcChannel.BackupProgress, (_, data: ProgressData) => { @@ -32,7 +35,8 @@ const PopupContainer: React.FC = ({ resolve }) => { }, []) const onOk = async () => { - await backup() + Logger.log('[BackupManager] ', skipBackupFile) + await backup(skipBackupFile) setOpen(false) } diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx index 1602b6a4ac..ae05ac3466 100644 --- a/src/renderer/src/components/QuickPanel/view.tsx +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -647,7 +647,6 @@ const QuickPanelItem = styled.div` border-radius: 6px; cursor: pointer; transition: background-color 0.1s ease; - font-family: Ubuntu; &.selected { background-color: var(--selected-color); &.focused { diff --git a/src/renderer/src/components/app/Navbar.tsx b/src/renderer/src/components/app/Navbar.tsx index 7e74464612..85a5265560 100644 --- a/src/renderer/src/components/app/Navbar.tsx +++ b/src/renderer/src/components/app/Navbar.tsx @@ -1,5 +1,6 @@ import { isLinux, isMac, isWindows } from '@renderer/config/constant' import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor' +import { useFullscreen } from '@renderer/hooks/useFullscreen' import type { FC, PropsWithChildren } from 'react' import type { HTMLAttributes } from 'react' import styled from 'styled-components' @@ -25,7 +26,12 @@ export const NavbarCenter: FC = ({ children, ...props }) => { } export const NavbarRight: FC = ({ children, ...props }) => { - return {children} + const isFullscreen = useFullscreen() + return ( + + {children} + + ) } const NavbarContainer = styled.div` @@ -58,11 +64,11 @@ const NavbarCenterContainer = styled.div` color: var(--color-text-1); ` -const NavbarRightContainer = styled.div` +const NavbarRightContainer = styled.div<{ $isFullscreen: boolean }>` min-width: var(--topic-list-width); display: flex; align-items: center; padding: 0 12px; - padding-right: ${isWindows ? '140px' : isLinux ? '120px' : '12px'}; + padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWindows ? '140px' : isLinux ? '120px' : '12px')}; justify-content: flex-end; ` diff --git a/src/renderer/src/components/app/Sidebar.tsx b/src/renderer/src/components/app/Sidebar.tsx index c319123d29..08f4c1fa22 100644 --- a/src/renderer/src/components/app/Sidebar.tsx +++ b/src/renderer/src/components/app/Sidebar.tsx @@ -3,6 +3,7 @@ import { isMac } from '@renderer/config/constant' import { AppLogo, UserAvatar } from '@renderer/config/env' import { useTheme } from '@renderer/context/ThemeProvider' import useAvatar from '@renderer/hooks/useAvatar' +import { useFullscreen } from '@renderer/hooks/useFullscreen' import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { useMinapps } from '@renderer/hooks/useMinapps' import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor' @@ -68,8 +69,13 @@ const Sidebar: FC = () => { }) } + const isFullscreen = useFullscreen() + return ( - + {isEmoji(avatar) ? ( {avatar} @@ -311,7 +317,7 @@ const PinnedApps: FC = () => { ) } -const Container = styled.div` +const Container = styled.div<{ $isFullscreen: boolean }>` display: flex; flex-direction: column; align-items: center; @@ -319,9 +325,9 @@ const Container = styled.div` padding-bottom: 12px; width: var(--sidebar-width); min-width: var(--sidebar-width); - height: ${isMac ? 'calc(100vh - var(--navbar-height))' : '100vh'}; + height: ${({ $isFullscreen }) => (isMac && !$isFullscreen ? 'calc(100vh - var(--navbar-height))' : '100vh')}; -webkit-app-region: drag !important; - margin-top: ${isMac ? 'var(--navbar-height)' : 0}; + margin-top: ${({ $isFullscreen }) => (isMac && !$isFullscreen ? 'var(--navbar-height)' : 0)}; .sidebar-avatar { margin-bottom: ${isMac ? '12px' : '12px'}; diff --git a/src/renderer/src/config/constant.ts b/src/renderer/src/config/constant.ts index 5d2376d12f..db5f2c9174 100644 --- a/src/renderer/src/config/constant.ts +++ b/src/renderer/src/config/constant.ts @@ -3,8 +3,6 @@ export const DEFAULT_CONTEXTCOUNT = 5 export const DEFAULT_MAX_TOKENS = 4096 export const DEFAULT_KNOWLEDGE_DOCUMENT_COUNT = 6 export const DEFAULT_KNOWLEDGE_THRESHOLD = 0.0 -export const FONT_FAMILY = - "Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif" export const platform = window.electron?.process?.platform export const isMac = platform === 'darwin' diff --git a/src/renderer/src/config/minapps.ts b/src/renderer/src/config/minapps.ts index 4e688c8748..4dc80ad751 100644 --- a/src/renderer/src/config/minapps.ts +++ b/src/renderer/src/config/minapps.ts @@ -163,7 +163,7 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [ { id: 'minimax', name: '海螺', - url: 'https://hailuoai.com/', + url: 'https://chat.minimaxi.com/', logo: HailuoModelLogo }, { diff --git a/src/renderer/src/context/AntdProvider.tsx b/src/renderer/src/context/AntdProvider.tsx index 094144d8de..fdeed3d688 100644 --- a/src/renderer/src/context/AntdProvider.tsx +++ b/src/renderer/src/context/AntdProvider.tsx @@ -37,10 +37,14 @@ const AntdProvider: FC = ({ children }) => { }, Collapse: { headerBg: 'transparent' + }, + Tooltip: { + fontSize: 13 } }, token: { - colorPrimary: '#00b96b' + colorPrimary: '#00b96b', + fontFamily: 'var(--font-family)' } }}> {children} diff --git a/src/renderer/src/hooks/useFullscreen.ts b/src/renderer/src/hooks/useFullscreen.ts new file mode 100644 index 0000000000..4a5820ed8e --- /dev/null +++ b/src/renderer/src/hooks/useFullscreen.ts @@ -0,0 +1,18 @@ +import { IpcChannel } from '@shared/IpcChannel' +import { useEffect, useState } from 'react' + +export function useFullscreen() { + const [isFullscreen, setIsFullscreen] = useState(false) + + useEffect(() => { + const cleanup = window.electron.ipcRenderer.on(IpcChannel.FullscreenStatusChanged, (_, fullscreen) => { + setIsFullscreen(fullscreen) + }) + + return () => { + cleanup() + } + }, []) + + return isFullscreen +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 96dcf37c49..fb0c38ea51 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -131,7 +131,10 @@ "manage": "Manage", "select_model": "Select Model", "show.all": "Show All", - "update_available": "Update Available" + "update_available": "Update Available", + "includes_user_questions": "Include Your Questions", + "case_sensitive": "Case Sensitive", + "whole_word": "Whole Word" }, "chat": { "add.assistant.title": "Add Assistant", @@ -163,6 +166,7 @@ "input.estimated_tokens.tip": "Estimated tokens", "input.expand": "Expand", "input.file_not_supported": "Model does not support this file type", + "input.file_error": "Error processing file", "input.generate_image": "Generate image", "input.generate_image_not_supported": "The model does not support generating images.", "input.knowledge_base": "Knowledge Base", @@ -399,7 +403,8 @@ "pinyin": "Sort by Pinyin", "pinyin.asc": "Sort by Pinyin (A-Z)", "pinyin.desc": "Sort by Pinyin (Z-A)" - } + }, + "no_results": "No results" }, "docs": { "title": "Docs" @@ -962,6 +967,8 @@ "app_knowledge.remove_all_confirm": "Deleting knowledge base files will reduce the storage space occupied, but will not delete the knowledge base vector data, after deletion, the source file will no longer be able to be opened. Continue?", "app_knowledge.remove_all_success": "Files removed successfully", "app_logs": "App Logs", + "backup.skip_file_data_title": "Slim Backup", + "backup.skip_file_data_help": "Skip backing up data files such as pictures and knowledge bases during backup, and only back up chat records and settings. Reduce space occupancy and speed up the backup speed.", "clear_cache": { "button": "Clear Cache", "confirm": "Clearing the cache will delete application cache data, including minapp data. This action is irreversible, continue?", @@ -1558,6 +1565,7 @@ "reset_defaults_confirm": "Are you sure you want to reset all shortcuts?", "reset_to_default": "Reset to Default", "search_message": "Search Message", + "search_message_in_chat": "Search Message in Current Chat", "show_app": "Show/Hide App", "show_settings": "Open Settings", "title": "Keyboard Shortcuts", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 1e992a2f25..f5b19e9099 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -131,7 +131,10 @@ "manage": "管理", "select_model": "モデルを選択", "show.all": "すべて表示", - "update_available": "更新可能" + "update_available": "更新可能", + "includes_user_questions": "ユーザーからの質問を含む", + "case_sensitive": "大文字と小文字の区別", + "whole_word": "全語一致" }, "chat": { "add.assistant.title": "アシスタントを追加", @@ -163,6 +166,7 @@ "input.estimated_tokens.tip": "推定トークン数", "input.expand": "展開", "input.file_not_supported": "モデルはこのファイルタイプをサポートしません", + "input.file_error": "ファイル処理エラー", "input.generate_image": "画像を生成する", "input.generate_image_not_supported": "モデルは画像の生成をサポートしていません。", "input.knowledge_base": "ナレッジベース", @@ -399,7 +403,8 @@ "pinyin": "ピンインでソート", "pinyin.asc": "ピンインで昇順ソート", "pinyin.desc": "ピンインで降順ソート" - } + }, + "no_results": "検索結果なし" }, "docs": { "title": "ドキュメント" @@ -959,6 +964,8 @@ "app_knowledge.remove_all": "ナレッジベースファイルを削除", "app_knowledge.remove_all_confirm": "ナレッジベースファイルを削除すると、ナレッジベース自体は削除されません。これにより、ストレージ容量を節約できます。続行しますか?", "app_knowledge.remove_all_success": "ファイル削除成功", + "backup.skip_file_data_title": "精簡バックアップ", + "backup.skip_file_data_help": "バックアップ時に、画像や知識ベースなどのデータファイルをバックアップ対象から除外し、チャット履歴と設定のみをバックアップします。スペースの占有を減らし、バックアップ速度を向上させます。", "app_logs": "アプリログ", "clear_cache": { "button": "キャッシュをクリア", @@ -1554,6 +1561,7 @@ "reset_defaults_confirm": "すべてのショートカットをリセットしてもよろしいですか?", "reset_to_default": "デフォルトにリセット", "search_message": "メッセージを検索", + "search_message_in_chat": "現在のチャットでメッセージを検索", "show_app": "アプリを表示/非表示", "show_settings": "設定を開く", "title": "ショートカット", @@ -1648,7 +1656,9 @@ "title": "ページズーム", "reset": "リセット" }, - "input.show_translate_confirm": "翻訳確認ダイアログを表示" + "input.show_translate_confirm": "翻訳確認ダイアログを表示", + "about.debug.title": "デバッグ", + "about.debug.open": "開く" }, "translate": { "any.language": "任意の言語", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 32e1b3e48d..0a242631b5 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -131,7 +131,10 @@ "manage": "Редактировать", "select_model": "Выбрать модель", "show.all": "Показать все", - "update_available": "Доступно обновление" + "update_available": "Доступно обновление", + "includes_user_questions": "Включает вопросы пользователей", + "case_sensitive": "Чувствительность к регистру", + "whole_word": "Полное слово" }, "chat": { "add.assistant.title": "Добавить ассистента", @@ -163,6 +166,7 @@ "input.estimated_tokens.tip": "Затраты токенов", "input.expand": "Развернуть", "input.file_not_supported": "Модель не поддерживает этот тип файла", + "input.file_error": "Ошибка обработки файла", "input.generate_image": "Сгенерировать изображение", "input.generate_image_not_supported": "Модель не поддерживает генерацию изображений.", "input.knowledge_base": "База знаний", @@ -399,7 +403,8 @@ "pinyin": "Сортировать по пиньинь", "pinyin.asc": "Сортировать по пиньинь (А-Я)", "pinyin.desc": "Сортировать по пиньинь (Я-А)" - } + }, + "no_results": "Результатов не найдено" }, "docs": { "title": "Документация" @@ -910,6 +915,7 @@ "restore": { "confirm": "Вы уверены, что хотите восстановить данные?", "confirm.button": "Выбрать файл резервной копии", + "content": "Операция восстановления перезапишет все текущие данные приложения данными из резервной копии. Это может занять некоторое время.", "progress": { "completed": "Восстановление завершено", @@ -960,6 +966,8 @@ "app_knowledge.remove_all_confirm": "Удаление файлов базы знаний не удалит саму базу знаний, что позволит уменьшить занимаемый объем памяти, продолжить?", "app_knowledge.remove_all_success": "Файлы удалены успешно", "app_logs": "Логи приложения", + "backup.skip_file_data_title": "Упрощенная резервная копия", + "backup.skip_file_data_help": "Пропустить при резервном копировании такие данные, как изображения, базы знаний и другие файлы данных, и сделать резервную копию только переписки и настроек. Это уменьшает использование места на диске и ускоряет процесс резервного копирования.", "clear_cache": { "button": "Очистка кэша", "confirm": "Очистка кэша удалит данные приложения. Это действие необратимо, продолжить?", @@ -1554,6 +1562,7 @@ "reset_defaults_confirm": "Вы уверены, что хотите сбросить все горячие клавиши?", "reset_to_default": "Сбросить настройки по умолчанию", "search_message": "Поиск сообщения", + "search_message_in_chat": "Поиск сообщения в текущем диалоге", "show_app": "Показать/скрыть приложение", "show_settings": "Открыть настройки", "title": "Горячие клавиши", @@ -1648,7 +1657,9 @@ "title": "Масштаб страницы", "reset": "Сбросить" }, - "input.show_translate_confirm": "Показать диалоговое окно подтверждения перевода" + "input.show_translate_confirm": "Показать диалоговое окно подтверждения перевода", + "about.debug.title": "Отладка", + "about.debug.open": "Открыть" }, "translate": { "any.language": "Любой язык", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 6195173445..51976af48b 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -131,7 +131,10 @@ "manage": "管理", "select_model": "选择模型", "show.all": "显示全部", - "update_available": "有可用更新" + "update_available": "有可用更新", + "includes_user_questions": "包含用户提问", + "case_sensitive": "区分大小写", + "whole_word": "全字匹配" }, "chat": { "add.assistant.title": "添加助手", @@ -163,6 +166,7 @@ "input.estimated_tokens.tip": "预估 token 数", "input.expand": "展开", "input.file_not_supported": "模型不支持此文件类型", + "input.file_error": "文件处理出错", "input.generate_image": "生成图片", "input.generate_image_not_supported": "模型不支持生成图片", "input.knowledge_base": "知识库", @@ -399,7 +403,8 @@ "pinyin": "按拼音排序", "pinyin.asc": "按拼音升序", "pinyin.desc": "按拼音降序" - } + }, + "no_results": "无结果" }, "docs": { "title": "帮助文档" @@ -759,13 +764,13 @@ "type": { "embedding": "嵌入", "free": "免费", - "function_calling": "工具", "reasoning": "推理", "rerank": "重排", "select": "选择模型类型", "text": "文本", - "vision": "视觉", - "websearch": "联网" + "vision": "图像", + "function_calling": "函数调用", + "websearch": "[to be translated]:WebSearch" } }, "navbar": { @@ -962,6 +967,8 @@ "app_knowledge.remove_all_confirm": "删除知识库文件可以减少存储空间占用,但不会删除知识库向量化数据,删除之后将无法打开源文件,是否删除?", "app_knowledge.remove_all_success": "文件删除成功", "app_logs": "应用日志", + "backup.skip_file_data_title": "精简备份", + "backup.skip_file_data_help": "备份时跳过备份图片、知识库等数据文件,仅备份聊天记录和设置。减少空间占用, 加快备份速度。", "clear_cache": { "button": "清除缓存", "confirm": "清除缓存将删除应用缓存的数据,包括小程序数据。此操作不可恢复,是否继续?", @@ -1558,6 +1565,7 @@ "reset_defaults_confirm": "确定要重置所有快捷键吗?", "reset_to_default": "重置为默认", "search_message": "搜索消息", + "search_message_in_chat": "在当前对话中搜索消息", "show_app": "显示/隐藏应用", "show_settings": "打开设置", "title": "快捷方式", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index eba9038d70..bbfc3a7d3f 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -131,7 +131,10 @@ "manage": "管理", "select_model": "選擇模型", "show.all": "顯示全部", - "update_available": "有可用更新" + "update_available": "有可用更新", + "includes_user_questions": "包含使用者提問", + "case_sensitive": "區分大小寫", + "whole_word": "全字匹配" }, "chat": { "add.assistant.title": "新增助手", @@ -163,6 +166,7 @@ "input.estimated_tokens.tip": "預估 Token 數", "input.expand": "展開", "input.file_not_supported": "模型不支援此檔案類型", + "input.file_error": "檔案處理錯誤", "input.generate_image": "生成圖片", "input.generate_image_not_supported": "模型不支援生成圖片", "input.knowledge_base": "知識庫", @@ -399,7 +403,8 @@ "pinyin": "按拼音排序", "pinyin.asc": "按拼音升序", "pinyin.desc": "按拼音降序" - } + }, + "no_results": "沒有結果" }, "docs": { "title": "說明文件" @@ -962,6 +967,8 @@ "app_knowledge.remove_all_confirm": "刪除知識庫文件可以減少儲存空間佔用,但不會刪除知識庫向量化資料,刪除之後將無法開啟原始檔,是否刪除?", "app_knowledge.remove_all_success": "檔案刪除成功", "app_logs": "應用程式日誌", + "backup.skip_file_data_title": "精簡備份", + "backup.skip_file_data_help": "備份時跳過備份圖片、知識庫等數據文件,僅備份聊天記錄和設置。減少空間佔用, 加快備份速度。", "clear_cache": { "button": "清除快取", "confirm": "清除快取將刪除應用快取資料,包括小工具資料。此操作不可恢復,是否繼續?", @@ -1556,6 +1563,7 @@ "reset_defaults_confirm": "確定要重設所有快捷鍵嗎?", "reset_to_default": "重設為預設", "search_message": "搜尋訊息", + "search_message_in_chat": "在當前對話中搜尋訊息", "show_app": "顯示/隱藏應用程式", "show_settings": "開啟設定", "title": "快速方式", @@ -1564,7 +1572,8 @@ "toggle_show_topics": "切換話題顯示", "zoom_in": "放大介面", "zoom_out": "縮小介面", - "zoom_reset": "重設縮放" + "zoom_reset": "重設縮放", + "exit_fullscreen": "退出全螢幕" }, "theme.auto": "自動", "theme.dark": "深色", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 8f2cb2b295..394d271858 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -930,6 +930,8 @@ "app_knowledge.remove_all_confirm": "Η διαγραφή των αρχείων της βάσης γνώσεων μπορεί να μειώσει τη χρήση χώρου αποθήκευσης, αλλά δεν θα διαγράψει τα διανυσματωτικά δεδομένα της βάσης γνώσεων. Μετά τη διαγραφή, δεν θα μπορείτε να ανοίξετε τα αρχεία πηγή. Θέλετε να διαγράψετε;", "app_knowledge.remove_all_success": "Τα αρχεία διαγράφηκαν με επιτυχία", "app_logs": "Φάκελοι εφαρμογής", + "backup.skip_file_data_title": "Συμπυκνωμένο αντίγραφο ασφαλείας", + "backup.skip_file_data_help": "Κατά τη δημιουργία αντιγράφων ασφαλείας, παραλείψτε τις εικόνες, τις βάσεις γνώσεων και άλλα αρχεία δεδομένων. Δημιουργήστε αντίγραφα μόνο για το ιστορικό συνομιλιών και τις ρυθμίσεις. Αυτό θα μειώσει τη χρήση χώρου και θα επιταχύνει την ταχύτητα δημιουργίας αντιγράφων.", "clear_cache": { "button": "Καθαρισμός Μνήμης", "confirm": "Η διαγραφή της μνήμης θα διαγράψει τα στοιχεία καθαρισμού της εφαρμογής, συμπεριλαμβανομένων των στοιχείων πρόσθετων εφαρμογών. Αυτή η ενέργεια δεν είναι αναστρέψιμη. Θέλετε να συνεχίσετε;", @@ -1655,4 +1657,4 @@ "visualization": "προβολή" } } -} \ No newline at end of file +} diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 8dc1ad294a..f71f859ce2 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -107,6 +107,7 @@ "backup": { "confirm": "¿Está seguro de que desea realizar una copia de seguridad de los datos?", "confirm.button": "Seleccionar ubicación de copia de seguridad", + "confirm.file_checkbox": "El tamaño del archivo es {{size}}, ¿desea elegir el archivo de copia de seguridad?", "content": "Realizar una copia de seguridad de todos los datos, incluyendo registros de chat, configuraciones, bases de conocimiento y todos los demás datos. Tenga en cuenta que el proceso de copia de seguridad puede llevar algún tiempo, gracias por su paciencia.", "progress": { "completed": "Copia de seguridad completada", @@ -1655,4 +1656,4 @@ "visualization": "Visualización" } } -} \ No newline at end of file +} diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index d80f63c02b..725f89717c 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -930,6 +930,8 @@ "app_knowledge.remove_all_confirm": "La suppression des fichiers de la base de connaissances libérera de l'espace de stockage, mais ne supprimera pas les données vectorisées de la base de connaissances. Après la suppression, vous ne pourrez plus ouvrir les fichiers sources. Souhaitez-vous continuer ?", "app_knowledge.remove_all_success": "Fichiers supprimés avec succès", "app_logs": "Journaux de l'application", + "backup.skip_file_data_title": "Sauvegarde réduite", + "backup.skip_file_data_help": "Passer outre les fichiers de données tels que les images et les bases de connaissances lors de la sauvegarde, et ne sauvegarder que les conversations et les paramètres. Cela réduit l'occupation d'espace et accélère la vitesse de sauvegarde.", "clear_cache": { "button": "Effacer le cache", "confirm": "L'effacement du cache supprimera les données du cache de l'application, y compris les données des mini-programmes. Cette action ne peut pas être annulée, voulez-vous continuer ?", @@ -1655,4 +1657,4 @@ "visualization": "Visualisation" } } -} \ No newline at end of file +} diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 52ae447027..d4b5362404 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -107,6 +107,7 @@ "backup": { "confirm": "Tem certeza de que deseja fazer backup dos dados?", "confirm.button": "Escolher local de backup", + "confirm.file_checkbox": "Pule a cópia de segurança de arquivos de dados como imagens e banco de conhecimento e copie apenas as conversas e as configurações.", "content": "Fazer backup de todos os dados, incluindo registros de chat, configurações, base de conhecimento e todos os outros dados. Por favor, note que o processo de backup pode levar algum tempo. Agradecemos sua paciência.", "progress": { "completed": "Backup concluído", @@ -931,6 +932,8 @@ "app_knowledge.remove_all_confirm": "A exclusão dos arquivos da base de conhecimento reduzirá o uso do espaço de armazenamento, mas não excluirá os dados vetoriais da base de conhecimento. Após a exclusão, os arquivos originais não poderão ser abertos. Deseja excluir?", "app_knowledge.remove_all_success": "Arquivo excluído com sucesso", "app_logs": "Logs do aplicativo", + "backup.skip_file_data_title": "Backup simplificado", + "backup.skip_file_data_help": "Pule arquivos de dados como imagens e bancos de conhecimento durante o backup e realize apenas o backup das conversas e configurações. Diminua o consumo de espaço e aumente a velocidade do backup.", "clear_cache": { "button": "Limpar cache", "confirm": "Limpar cache removerá os dados armazenados em cache do aplicativo, incluindo dados de aplicativos minúsculos. Esta ação não pode ser desfeita, deseja continuar?", @@ -1656,4 +1659,4 @@ "visualization": "Visualização" } } -} \ No newline at end of file +} diff --git a/src/renderer/src/pages/apps/AppsPage.tsx b/src/renderer/src/pages/apps/AppsPage.tsx index 3ffecd9bcd..099649fade 100644 --- a/src/renderer/src/pages/apps/AppsPage.tsx +++ b/src/renderer/src/pages/apps/AppsPage.tsx @@ -1,7 +1,7 @@ import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' import { Center } from '@renderer/components/Layout' import { useMinapps } from '@renderer/hooks/useMinapps' -import { Empty, Input } from 'antd' +import { Input } from 'antd' import { isEmpty } from 'lodash' import { Search } from 'lucide-react' import React, { FC, useState } from 'react' @@ -53,7 +53,7 @@ const AppsPage: FC = () => { {isEmpty(filteredApps) ? (
- +
) : ( diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 9b3f0ff694..ee5262644d 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -1,7 +1,9 @@ import MultiSelectActionPopup from '@renderer/components/Popups/MultiSelectionPopup' +import { ContentSearch, ContentSearchRef } from '@renderer/components/ContentSearch' import { QuickPanelProvider } from '@renderer/components/QuickPanel' import { useAssistant } from '@renderer/hooks/useAssistant' import { useSettings } from '@renderer/hooks/useSettings' +import { useShortcut } from '@renderer/hooks/useShortcuts' import { useShowTopics } from '@renderer/hooks/useStore' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { RootState } from '@renderer/store' @@ -9,7 +11,9 @@ import { messageBlocksSelectors } from '@renderer/store/messageBlock' import { newMessagesActions, selectMessagesForTopic } from '@renderer/store/newMessage' import { Assistant, Topic } from '@renderer/types' import { Flex, Modal } from 'antd' -import { FC, useEffect, useState } from 'react' +import { debounce } from 'lodash' +import React, { FC, useMemo, useEffect, useState } from 'react' +import { useHotkeys } from 'react-hotkeys-hook' import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' import styled from 'styled-components' @@ -27,7 +31,7 @@ interface Props { const Chat: FC = (props) => { const { assistant } = useAssistant(props.assistant.id) - const { topicPosition, messageStyle } = useSettings() + const { topicPosition, messageStyle, showAssistants } = useSettings() const { showTopics } = useShowTopics() const { t } = useTranslation() const [isMultiSelectMode, setIsMultiSelectMode] = useState(false) @@ -40,6 +44,81 @@ const Chat: FC = (props) => { // 获取所有消息块 const messageBlocks = useSelector(messageBlocksSelectors.selectEntities) + const mainRef = React.useRef(null) + const contentSearchRef = React.useRef(null) + const [filterIncludeUser, setFilterIncludeUser] = useState(false) + + const maxWidth = useMemo(() => { + const showRightTopics = showTopics && topicPosition === 'right' + const minusAssistantsWidth = showAssistants ? '- var(--assistants-width)' : '' + const minusRightTopicsWidth = showRightTopics ? '- var(--assistants-width)' : '' + return `calc(100vw - var(--sidebar-width) ${minusAssistantsWidth} ${minusRightTopicsWidth} - 5px)` + }, [showAssistants, showTopics, topicPosition]) + + useHotkeys('esc', () => { + contentSearchRef.current?.disable() + }) + + useShortcut('search_message_in_chat', () => { + try { + const selectedText = window.getSelection()?.toString().trim() + contentSearchRef.current?.enable(selectedText) + } catch (error) { + console.error('Error enabling content search:', error) + } + }) + + const contentSearchFilter = (node: Node): boolean => { + if (node.parentNode) { + let parentNode: HTMLElement | null = node.parentNode as HTMLElement + while (parentNode?.parentNode) { + if (parentNode.classList.contains('MessageFooter')) { + return false + } + + if (filterIncludeUser) { + if (parentNode?.classList.contains('message-content-container')) { + return true + } + } else { + if (parentNode?.classList.contains('message-content-container-assistant')) { + return true + } + } + parentNode = parentNode.parentNode as HTMLElement + } + return false + } else { + return false + } + } + + const userOutlinedItemClickHandler = () => { + setFilterIncludeUser(!filterIncludeUser) + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setTimeout(() => { + contentSearchRef.current?.search() + contentSearchRef.current?.focus() + }, 0) + }) + }) + } + + let firstUpdateCompleted = false + const firstUpdateOrNoFirstUpdateHandler = debounce(() => { + contentSearchRef.current?.silentSearch() + }, 10) + const messagesComponentUpdateHandler = () => { + if (firstUpdateCompleted) { + firstUpdateOrNoFirstUpdateHandler() + } + } + const messagesComponentFirstUpdateHandler = () => { + setTimeout(() => (firstUpdateCompleted = true), 300) + firstUpdateOrNoFirstUpdateHandler() + } + useEffect(() => { const handleToggleMultiSelect = (value: boolean) => { setIsMultiSelectMode(value) @@ -142,13 +221,24 @@ const Chat: FC = (props) => { return ( -
- + } + filter={contentSearchFilter} + includeUser={filterIncludeUser} + onIncludeUserChange={userOutlinedItemClickHandler} /> + + + {isMultiSelectMode ? ( = (props) => { ) } +const MessagesContainer = styled.div` + display: flex; + flex-direction: column; + overflow: hidden; + flex: 1; +` + const Container = styled.div` display: flex; flex-direction: row; height: 100%; flex: 1; - justify-content: space-between; ` const Main = styled(Flex)` height: calc(100vh - var(--navbar-height)); transform: translateZ(0); + position: relative; ` export default Chat diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 39621cc85d..f5815be57e 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -117,6 +117,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState([]) const [mentionModels, setMentionModels] = useState([]) const [isDragging, setIsDragging] = useState(false) + const [isFileDragging, setIsFileDragging] = useState(false) const [textareaHeight, setTextareaHeight] = useState() const startDragY = useRef(0) const startHeight = useRef(0) @@ -584,27 +585,33 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = if (event.clipboardData?.files && event.clipboardData.files.length > 0) { event.preventDefault() for (const file of event.clipboardData.files) { - if (file.path === '') { - // 图像生成也支持图像编辑 - if (file.type.startsWith('image/') && (isVisionModel(model) || isGenerateImageModel(model))) { - const tempFilePath = await window.api.file.create(file.name) - const arrayBuffer = await file.arrayBuffer() - const uint8Array = new Uint8Array(arrayBuffer) - await window.api.file.write(tempFilePath, uint8Array) - const selectedFile = await window.api.file.get(tempFilePath) - selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) - break - } else { - window.message.info({ - key: 'file_not_supported', - content: t('chat.input.file_not_supported') - }) - } - } + try { + // 使用新的API获取文件路径 + const filePath = window.api.file.getPathForFile(file) - if (file.path) { - if (supportExts.includes(getFileExtension(file.path))) { - const selectedFile = await window.api.file.get(file.path) + // 如果没有路径,可能是剪贴板中的图像数据 + if (!filePath) { + // 图像生成也支持图像编辑 + if (file.type.startsWith('image/') && (isVisionModel(model) || isGenerateImageModel(model))) { + const tempFilePath = await window.api.file.create(file.name) + const arrayBuffer = await file.arrayBuffer() + const uint8Array = new Uint8Array(arrayBuffer) + await window.api.file.write(tempFilePath, uint8Array) + const selectedFile = await window.api.file.get(tempFilePath) + selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) + break + } else { + window.message.info({ + key: 'file_not_supported', + content: t('chat.input.file_not_supported') + }) + } + continue + } + + // 有路径的情况 + if (supportExts.includes(getFileExtension(filePath))) { + const selectedFile = await window.api.file.get(filePath) selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) } else { window.message.info({ @@ -612,6 +619,9 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = content: t('chat.input.file_not_supported') }) } + } catch (error) { + Logger.error('[src/renderer/src/pages/home/Inputbar/Inputbar.tsx] onPaste:', error) + window.message.error(t('chat.input.file_error')) } } return @@ -637,14 +647,44 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = [model, pasteLongTextAsFile, pasteLongTextThreshold, resizeTextArea, supportExts, t, text] ) + // 添加全局粘贴事件处理 + useEffect(() => { + const handleGlobalPaste = (event: ClipboardEvent) => { + if (document.activeElement === textareaRef.current?.resizableTextArea?.textArea) { + return + } + + onPaste(event) + } + + document.addEventListener('paste', handleGlobalPaste) + return () => { + document.removeEventListener('paste', handleGlobalPaste) + } + }, [onPaste]) + const handleDragOver = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() + setIsFileDragging(true) + } + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsFileDragging(true) + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsFileDragging(false) } const handleDrop = async (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() + setIsFileDragging(false) const files = await getFilesFromDropEvent(e).catch((err) => { Logger.error('[src/renderer/src/pages/home/Inputbar/Inputbar.tsx] handleDrop:', err) @@ -652,11 +692,22 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = }) if (files) { + let supportedFiles = 0 + files.forEach((file) => { if (supportExts.includes(getFileExtension(file.path))) { setFiles((prevFiles) => [...prevFiles, file]) + supportedFiles++ } }) + + // 如果有文件,但都不支持 + if (files.length > 0 && supportedFiles === 0) { + window.message.info({ + key: 'file_not_supported', + content: t('chat.input.file_not_supported') + }) + } } } @@ -872,12 +923,17 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const showThinkingButton = isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model) return ( - + {files.length > 0 && } {selectedKnowledgeBases.length > 0 && ( @@ -1058,6 +1114,23 @@ const InputBarContainer = styled.div` border-radius: 15px; padding-top: 6px; // 为拖动手柄留出空间 background-color: var(--color-background-opacity); + + &.file-dragging { + border: 2px dashed #2ecc71; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(46, 204, 113, 0.03); + border-radius: 14px; + z-index: 5; + pointer-events: none; + } + } ` const TextareaStyle: CSSProperties = { @@ -1070,7 +1143,6 @@ const Textarea = styled(TextArea)` border-radius: 0; display: flex; flex: 1; - font-family: Ubuntu; resize: none !important; overflow: auto; width: 100%; @@ -1097,7 +1169,7 @@ const ToolbarMenu = styled.div` gap: 6px; ` -const ToolbarButton = styled(Button)` +export const ToolbarButton = styled(Button)` width: 30px; height: 30px; font-size: 16px; diff --git a/src/renderer/src/pages/home/Inputbar/TokenCount.tsx b/src/renderer/src/pages/home/Inputbar/TokenCount.tsx index c2a0ee5068..a07071acb2 100644 --- a/src/renderer/src/pages/home/Inputbar/TokenCount.tsx +++ b/src/renderer/src/pages/home/Inputbar/TokenCount.tsx @@ -74,7 +74,6 @@ const Container = styled.div` z-index: 10; padding: 3px 10px; user-select: none; - font-family: Ubuntu; border: 0.5px solid var(--color-text-3); border-radius: 20px; display: flex; diff --git a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx index 212ee53ee9..476c67e57e 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx @@ -5,7 +5,7 @@ import { lightbulbVariants } from '@renderer/utils/motionVariants' import { Collapse, message as antdMessage, Tooltip } from 'antd' import { Lightbulb } from 'lucide-react' import { motion } from 'motion/react' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -20,8 +20,6 @@ const ThinkingBlock: React.FC = ({ block }) => { const { t } = useTranslation() const { messageFont, fontSize, thoughtAutoCollapse } = useSettings() const [activeKey, setActiveKey] = useState<'thought' | ''>(thoughtAutoCollapse ? '' : 'thought') - const [thinkingTime, setThinkingTime] = useState(block.thinking_millsec || 0) - const intervalId = useRef(null) const isThinking = useMemo(() => block.status === MessageBlockStatus.STREAMING, [block.status]) @@ -55,28 +53,6 @@ const ThinkingBlock: React.FC = ({ block }) => { } }, [block.content, t]) - // FIXME: 这里统计的和请求处统计的有一定误差 - useEffect(() => { - if (isThinking) { - intervalId.current = setInterval(() => { - setThinkingTime((prev) => prev + 100) - }, 100) - } else if (intervalId.current) { - // 立即清除计时器 - clearInterval(intervalId.current) - intervalId.current = null - } - - return () => { - if (intervalId.current) { - clearInterval(intervalId.current) - intervalId.current = null - } - } - }, [isThinking]) - - const thinkingTimeSeconds = useMemo(() => (thinkingTime / 1000).toFixed(1), [thinkingTime]) - if (!block.content) { return null } @@ -101,9 +77,7 @@ const ThinkingBlock: React.FC = ({ block }) => { - {t(isThinking ? 'chat.thinking' : 'chat.deeply_thought', { - seconds: thinkingTimeSeconds - })} + {/* {isThinking && } */} {!isThinking && ( @@ -134,6 +108,41 @@ const ThinkingBlock: React.FC = ({ block }) => { ) } +const ThinkingTimeSeconds = memo( + ({ blockThinkingTime, isThinking }: { blockThinkingTime?: number; isThinking: boolean }) => { + const { t } = useTranslation() + + const [thinkingTime, setThinkingTime] = useState(blockThinkingTime || 0) + + // FIXME: 这里统计的和请求处统计的有一定误差 + useEffect(() => { + let timer: NodeJS.Timeout | null = null + if (isThinking) { + timer = setInterval(() => { + setThinkingTime((prev) => prev + 100) + }, 100) + } else if (timer) { + // 立即清除计时器 + clearInterval(timer) + timer = null + } + + return () => { + if (timer) { + clearInterval(timer) + timer = null + } + } + }, [isThinking]) + + const thinkingTimeSeconds = useMemo(() => (thinkingTime / 1000).toFixed(1), [thinkingTime]) + + return t(isThinking ? 'chat.thinking' : 'chat.deeply_thought', { + seconds: thinkingTimeSeconds + }) + } +) + const CollapseContainer = styled(Collapse)` margin-bottom: 15px; ` diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index 9ae2da00b2..2e79850a91 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -1,5 +1,4 @@ import ContextMenu from '@renderer/components/ContextMenu' -import { FONT_FAMILY } from '@renderer/config/constant' import { useAssistant } from '@renderer/hooks/useAssistant' import { useModel } from '@renderer/hooks/useModel' import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings' @@ -10,7 +9,7 @@ import { Assistant, Topic } from '@renderer/types' import type { Message } from '@renderer/types/newMessage' import { classNames } from '@renderer/utils' import { Divider } from 'antd' -import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useMemo, useRef } from 'react' +import React, { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -54,10 +53,6 @@ const MessageItem: FC = ({ const isAssistantMessage = message.role === 'assistant' const showMenubar = !isStreaming && !message.status.includes('ing') - const fontFamily = useMemo(() => { - return messageFont === 'serif' ? FONT_FAMILY.replace('sans-serif', 'serif').replace('Ubuntu, ', '') : FONT_FAMILY - }, [messageFont]) - const messageBorder = showMessageDivider ? undefined : 'none' const messageBackground = getMessageBackground(isBubbleStyle, isAssistantMessage) @@ -106,13 +101,25 @@ const MessageItem: FC = ({ + className={ + message.role === 'user' + ? 'message-content-container message-content-container-user' + : message.role === 'assistant' + ? 'message-content-container message-content-container-assistant' + : 'message-content-container' + } + style={{ + fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)', + fontSize, + background: messageBackground, + overflowY: 'visible' + }}> {showMenubar && ( ` const MessageTime = styled.div` font-size: 10px; color: var(--color-text-3); - font-family: 'Ubuntu'; ` export default MessageHeader diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index c1b007bf18..f5c4048f8e 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -26,7 +26,7 @@ import { updateCodeBlock } from '@renderer/utils/markdown' import { getMainTextContent } from '@renderer/utils/messageUtils/find' import { isTextLikeBlock } from '@renderer/utils/messageUtils/is' import { last } from 'lodash' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import InfiniteScroll from 'react-infinite-scroll-component' import styled from 'styled-components' @@ -41,9 +41,11 @@ interface MessagesProps { assistant: Assistant topic: Topic setActiveTopic: (topic: Topic) => void + onComponentUpdate?(): void + onFirstUpdate?(): void } -const Messages: React.FC = ({ assistant, topic, setActiveTopic }) => { +const Messages: FC = ({ assistant, topic, setActiveTopic, onComponentUpdate, onFirstUpdate }) => { const { t } = useTranslation() const { showPrompt, showTopics, topicPosition, showAssistants, messageNavigation } = useSettings() const { updateTopic, addTopic } = useAssistant(assistant.id) @@ -267,8 +269,8 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) tokensCount: await estimateHistoryTokens(assistant, messages), contextCount: getContextCount(assistant, messages) }) - }) - }, [assistant, messages]) + }).then(() => onFirstUpdate?.()) + }, [assistant, messages, onFirstUpdate]) const loadMoreMessages = useCallback(() => { if (!hasMore || isLoadingMore) return @@ -292,6 +294,10 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) } }) + useEffect(() => { + requestAnimationFrame(() => onComponentUpdate?.()) + }, []) + const groupedMessages = useMemo(() => Object.entries(getGroupedMessages(displayMessages)), [displayMessages]) return ( { const { theme } = useTheme() const [menu, setMenu] = useState('data') + const _skipBackupFile = store.getState().settings.skipBackupFile + const [skipBackupFile, setSkipBackupFile] = useState(_skipBackupFile) + + const dispatch = useAppDispatch() + //joplin icon needs to be updated into iconfont const JoplinIcon = () => ( @@ -164,6 +179,11 @@ const DataSettings: FC = () => { }) } + const onSkipBackupFilesChange = (value: boolean) => { + setSkipBackupFile(value) + dispatch(_setSkipBackupFile(value)) + } + return ( @@ -208,6 +228,14 @@ const DataSettings: FC = () => { + + + {t('settings.data.backup.skip_file_data_title')} + + + + {t('settings.data.backup.skip_file_data_help')} + {t('settings.data.data.title')} diff --git a/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx b/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx index 8ef3432307..6b9784cea0 100644 --- a/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/NutstoreSettings.tsx @@ -17,25 +17,31 @@ import { useAppDispatch, useAppSelector } from '@renderer/store' import { setNutstoreAutoSync, setNutstorePath, + setNutstoreSkipBackupFile, 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 { Button, Input, Select, Switch, 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 '..' +import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..' const NutstoreSettings: FC = () => { const { theme } = useTheme() const { t } = useTranslation() - const { nutstoreToken, nutstorePath, nutstoreSyncInterval, nutstoreAutoSync, nutstoreSyncState } = useAppSelector( - (state) => state.nutstore - ) + const { + nutstoreToken, + nutstorePath, + nutstoreSyncInterval, + nutstoreAutoSync, + nutstoreSyncState, + nutstoreSkipBackupFile + } = useAppSelector((state) => state.nutstore) const dispatch = useAppDispatch() @@ -48,6 +54,8 @@ const NutstoreSettings: FC = () => { const [syncInterval, setSyncInterval] = useState(nutstoreSyncInterval) + const [nutSkipBackupFile, setNutSkipBackupFile] = useState(nutstoreSkipBackupFile) + const nutstoreSSOHandler = useNutstoreSSO() const [backupManagerVisible, setBackupManagerVisible] = useState(false) @@ -128,6 +136,11 @@ const NutstoreSettings: FC = () => { } } + const onSkipBackupFilesChange = (value: boolean) => { + setNutSkipBackupFile(value) + dispatch(setNutstoreSkipBackupFile(value)) + } + const handleClickPathChange = async () => { if (!nutstoreToken) { return @@ -287,6 +300,14 @@ const NutstoreSettings: FC = () => { )} + + + {t('settings.data.backup.skip_file_data_title')} + + + + {t('settings.data.backup.skip_file_data_help')} + )} <> diff --git a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx index 29221f9c05..104fa5cbd8 100644 --- a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx @@ -12,15 +12,16 @@ import { setWebdavMaxBackups as _setWebdavMaxBackups, setWebdavPass as _setWebdavPass, setWebdavPath as _setWebdavPath, + setWebdavSkipBackupFile as _setWebdavSkipBackupFile, setWebdavSyncInterval as _setWebdavSyncInterval, setWebdavUser as _setWebdavUser } from '@renderer/store/settings' -import { Button, Input, Select, Tooltip } from 'antd' +import { Button, Input, Select, Switch, Tooltip } from 'antd' import dayjs from 'dayjs' import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' -import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..' +import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..' const WebDavSettings: FC = () => { const { @@ -29,13 +30,15 @@ const WebDavSettings: FC = () => { webdavPass: webDAVPass, webdavPath: webDAVPath, webdavSyncInterval: webDAVSyncInterval, - webdavMaxBackups: webDAVMaxBackups + webdavMaxBackups: webDAVMaxBackups, + webdavSkipBackupFile: webdDAVSkipBackupFile } = useSettings() const [webdavHost, setWebdavHost] = useState(webDAVHost) const [webdavUser, setWebdavUser] = useState(webDAVUser) const [webdavPass, setWebdavPass] = useState(webDAVPass) const [webdavPath, setWebdavPath] = useState(webDAVPath) + const [webdavSkipBackupFile, setWebdavSkipBackupFile] = useState(webdDAVSkipBackupFile) const [backupManagerVisible, setBackupManagerVisible] = useState(false) const [syncInterval, setSyncInterval] = useState(webDAVSyncInterval) @@ -67,6 +70,11 @@ const WebDavSettings: FC = () => { dispatch(_setWebdavMaxBackups(value)) } + const onSkipBackupFilesChange = (value: boolean) => { + setWebdavSkipBackupFile(value) + dispatch(_setWebdavSkipBackupFile(value)) + } + const renderSyncStatus = () => { if (!webdavHost) return null @@ -194,6 +202,14 @@ const WebDavSettings: FC = () => { 50 + + + {t('settings.data.backup.skip_file_data_title')} + + + + {t('settings.data.backup.skip_file_data_help')} + {webdavSync && syncInterval > 0 && ( <> diff --git a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx index ad93b8fa38..d58cf0c01a 100644 --- a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx +++ b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx @@ -237,6 +237,7 @@ const DisplaySettings: FC = () => { minHeight: 200, fontFamily: 'monospace' }} + spellCheck={false} /> diff --git a/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx b/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx index 399e9c7678..3f6b6eb8ae 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx @@ -190,7 +190,6 @@ const ServerName = styled.div` const ServerNameText = styled.span` font-size: 15px; font-weight: 500; - font-family: Ubuntu; ` const StatusIndicator = styled.div` diff --git a/src/renderer/src/pages/settings/MCPSettings/McpSettingsNavbar.tsx b/src/renderer/src/pages/settings/MCPSettings/McpSettingsNavbar.tsx index 3cfdf8af63..7986ea5766 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpSettingsNavbar.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpSettingsNavbar.tsx @@ -1,6 +1,7 @@ import { NavbarRight } from '@renderer/components/app/Navbar' import { HStack } from '@renderer/components/Layout' import { isLinux, isWindows } from '@renderer/config/constant' +import { useFullscreen } from '@renderer/hooks/useFullscreen' import { Button, Dropdown, Menu, type MenuProps } from 'antd' import { ChevronDown, Search } from 'lucide-react' import { useTranslation } from 'react-i18next' @@ -73,7 +74,7 @@ export const McpSettingsNavbar = () => { })) return ( - +