diff --git a/package.json b/package.json index 2ffd5b7b49..23d8c55fbc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.1.16", + "version": "1.1.17", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", @@ -63,7 +63,7 @@ "@cherrystudio/embedjs-openai": "^0.1.28", "@electron-toolkit/utils": "^3.0.0", "@electron/notarize": "^2.5.0", - "@google/generative-ai": "^0.21.0", + "@google/generative-ai": "^0.24.0", "@langchain/community": "^0.3.36", "@notionhq/client": "^2.2.15", "@tryfabric/martian": "^1.2.4", diff --git a/src/main/index.ts b/src/main/index.ts index 958def2ec1..de9c7f9e38 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,7 +1,7 @@ import { electronApp, optimizer } from '@electron-toolkit/utils' import { replaceDevtoolsFont } from '@main/utils/windowUtil' import { app, ipcMain } from 'electron' -import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer' +import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer' import { registerIpc } from './ipc' import { configManager } from './services/ConfigManager' @@ -48,7 +48,7 @@ if (!app.requestSingleInstanceLock()) { replaceDevtoolsFont(mainWindow) if (process.env.NODE_ENV === 'development') { - installExtension(REDUX_DEVTOOLS) + installExtension([REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS]) .then((name) => console.log(`Added Extension: ${name}`)) .catch((err) => console.log('An error occurred: ', err)) } diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index a775fddf10..a97b37e6a7 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -87,9 +87,16 @@ class BackupManager { await fs.ensureDir(this.tempDir) onProgress({ stage: 'preparing', progress: 0, total: 100 }) - // 将 data 写入临时文件 + // 使用流的方式写入 data.json const tempDataPath = path.join(this.tempDir, 'data.json') - await fs.writeFile(tempDataPath, data) + await new Promise((resolve, reject) => { + const writeStream = fs.createWriteStream(tempDataPath) + writeStream.write(data) + writeStream.end() + + writeStream.on('finish', () => resolve()) + writeStream.on('error', (error) => reject(error)) + }) onProgress({ stage: 'writing_data', progress: 20, total: 100 }) // 复制 Data 目录到临时目录 @@ -208,8 +215,15 @@ class BackupManager { fs.mkdirSync(this.backupDir, { recursive: true }) } - // sync为同步写,无须await - fs.writeFileSync(backupedFilePath, retrievedFile as Buffer) + // 使用流的方式写入文件 + await new Promise((resolve, reject) => { + const writeStream = fs.createWriteStream(backupedFilePath) + writeStream.write(retrievedFile as Buffer) + writeStream.end() + + writeStream.on('finish', () => resolve()) + writeStream.on('error', (error) => reject(error)) + }) return await this.restore(_, backupedFilePath) } catch (error: any) { diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index d44fc15111..e8a0807b4b 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -2,10 +2,11 @@ import os from 'node:os' import path from 'node:path' import { isLinux, isMac, isWin } from '@main/constant' +import { makeSureDirExists } from '@main/utils' import { getBinaryName, getBinaryPath } from '@main/utils/process' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { nanoid } from '@reduxjs/toolkit' import { MCPServer, MCPTool } from '@types' import { app } from 'electron' @@ -21,6 +22,7 @@ class McpService { baseUrl: server.baseUrl, command: server.command, args: server.args, + registryUrl: server.registryUrl, env: server.env, id: server.id }) @@ -68,13 +70,8 @@ class McpService { } else if (server.command) { let cmd = server.command - if (server.command === 'npx') { + if (server.command === 'npx' || server.command === 'bun' || server.command === 'bunx') { cmd = await getBinaryPath('bun') - - if (cmd === 'bun') { - cmd = 'npx' - } - Logger.info(`[MCP] Using command: ${cmd}`) // add -x to args if args exist @@ -82,22 +79,42 @@ class McpService { if (!args.includes('-y')) { !args.includes('-y') && args.unshift('-y') } - if (cmd.includes('bun') && !args.includes('x')) { + if (!args.includes('x')) { args.unshift('x') } } + if (server.registryUrl) { + server.env = { + ...server.env, + NPM_CONFIG_REGISTRY: server.registryUrl + } + + // if the server name is mcp-auto-install, use the mcp-registry.json file in the bin directory + if (server.name === 'mcp-auto-install') { + const binPath = await getBinaryPath() + makeSureDirExists(binPath) + server.env.MCP_REGISTRY_PATH = path.join(binPath, 'mcp-registry.json') + } + } + } else if (server.command === 'uvx' || server.command === 'uv') { + cmd = await getBinaryPath(server.command) + if (server.registryUrl) { + server.env = { + ...server.env, + UV_DEFAULT_INDEX: server.registryUrl, + PIP_INDEX_URL: server.registryUrl + } + } } - if (server.command === 'uvx') { - cmd = await getBinaryPath('uvx') - } - Logger.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`) + // Logger.info(`[MCP] Environment variables for server:`, server.env) transport = new StdioClientTransport({ command: cmd, args, env: { + ...getDefaultEnvironment(), PATH: this.getEnhancedPath(process.env.PATH || ''), ...server.env } @@ -233,6 +250,7 @@ class McpService { `${homeDir}/.npm-global/bin`, `${homeDir}/.yarn/bin`, `${homeDir}/.cargo/bin`, + `${homeDir}/.cherrystudio/bin`, '/opt/local/bin' ) } @@ -246,12 +264,18 @@ class McpService { `${homeDir}/.npm-global/bin`, `${homeDir}/.yarn/bin`, `${homeDir}/.cargo/bin`, + `${homeDir}/.cherrystudio/bin`, '/snap/bin' ) } if (isWin) { - newPaths.push(`${process.env.APPDATA}\\npm`, `${homeDir}\\AppData\\Local\\Yarn\\bin`, `${homeDir}\\.cargo\\bin`) + newPaths.push( + `${process.env.APPDATA}\\npm`, + `${homeDir}\\AppData\\Local\\Yarn\\bin`, + `${homeDir}\\.cargo\\bin`, + `${homeDir}\\.cherrystudio\\bin` + ) } // 只添加不存在的路径 diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index c4bad34de3..03caa02d24 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -23,17 +23,8 @@ function getShortcutHandler(shortcut: Shortcut) { configManager.setZoomFactor(1) } case 'show_app': - return (window: BrowserWindow) => { - if (window.isVisible()) { - if (window.isFocused()) { - window.hide() - } else { - window.focus() - } - } else { - window.show() - window.focus() - } + return () => { + windowService.toggleMainWindow() } case 'mini_window': return () => { diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 928bb8d379..f72c5b1b1c 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -16,6 +16,9 @@ export class WindowService { private mainWindow: BrowserWindow | null = null private miniWindow: BrowserWindow | null = null private wasFullScreen: boolean = false + //hacky-fix: store the focused status of mainWindow before miniWindow shows + //to restore the focus status when miniWindow hides + private wasMainWindowFocused: boolean = false private selectionMenuWindow: BrowserWindow | null = null private lastSelectedText: string = '' private contextMenu: Menu | null = null @@ -30,6 +33,7 @@ export class WindowService { public createMainWindow(): BrowserWindow { if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.mainWindow.show() + this.mainWindow.focus() return this.mainWindow } @@ -56,7 +60,7 @@ export class WindowService { titleBarOverlay: theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight, backgroundColor: isMac ? undefined : theme === 'dark' ? '#181818' : '#FFFFFF', trafficLightPosition: { x: 8, y: 12 }, - ...(process.platform === 'linux' ? { icon } : {}), + ...(isLinux ? { icon } : {}), webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: false, @@ -68,6 +72,12 @@ export class WindowService { this.setupMainWindow(this.mainWindow, mainWindowState) + //preload miniWindow to resolve series of issues about miniWindow in Mac + const enableQuickAssistant = configManager.getEnableQuickAssistant() + if (enableQuickAssistant && !this.miniWindow) { + this.miniWindow = this.createMiniWindow(true) + } + return this.mainWindow } @@ -148,6 +158,8 @@ export class WindowService { // show window only when laucn to tray not set const isLaunchToTray = configManager.getLaunchToTray() if (!isLaunchToTray) { + //[mac]hacky-fix: miniWindow set visibleOnFullScreen:true will cause dock icon disappeared + app.dock?.show() mainWindow.show() } }) @@ -163,6 +175,25 @@ export class WindowService { mainWindow.webContents.send('fullscreen-status-changed', false) }) + // set the zoom factor again when the window is going to resize + // + // this is a workaround for the known bug that + // the zoom factor is reset to cached value when window is resized after routing to other page + // see: https://github.com/electron/electron/issues/10572 + // + mainWindow.on('will-resize', () => { + mainWindow.webContents.setZoomFactor(configManager.getZoomFactor()) + }) + + // ARCH: as `will-resize` is only for Win & Mac, + // linux has the same problem, use `resize` listener instead + // but `resize` will fliker the ui + if (isLinux) { + mainWindow.on('resize', () => { + mainWindow.webContents.setZoomFactor(configManager.getZoomFactor()) + }) + } + // 添加Escape键退出全屏的支持 mainWindow.webContents.on('before-input-event', (event, input) => { // 当按下Escape键且窗口处于全屏状态时退出全屏 @@ -286,9 +317,8 @@ export class WindowService { event.preventDefault() mainWindow.hide() - if (isMac && isTrayOnClose) { - app.dock?.hide() //for mac to hide to tray - } + //for mac users, should hide dock icon if close to tray + app.dock?.hide() }) mainWindow.on('closed', () => { @@ -309,44 +339,48 @@ export class WindowService { if (this.mainWindow && !this.mainWindow.isDestroyed()) { if (this.mainWindow.isMinimized()) { - return this.mainWindow.restore() + this.mainWindow.restore() + return } + //[macOS] Known Issue + // setVisibleOnAllWorkspaces true/false will NOT bring window to current desktop in Mac (works fine with Windows) + // AppleScript may be a solution, but it's not worth + this.mainWindow.setVisibleOnAllWorkspaces(true) this.mainWindow.show() this.mainWindow.focus() + this.mainWindow.setVisibleOnAllWorkspaces(false) } else { this.mainWindow = this.createMainWindow() - this.mainWindow.focus() } - - //for mac users, when window is shown, should show dock icon (dock may be set to hide when launch) - app.dock?.show() } - public showMiniWindow() { - const enableQuickAssistant = configManager.getEnableQuickAssistant() - - if (!enableQuickAssistant) { + public toggleMainWindow() { + // should not toggle main window when in full screen + if (this.wasFullScreen) { return } - if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) { - this.selectionMenuWindow.hide() - } - - if (this.miniWindow && !this.miniWindow.isDestroyed()) { - if (this.miniWindow.isMinimized()) { - this.miniWindow.restore() + if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.isVisible()) { + if (this.mainWindow.isFocused()) { + // if tray is enabled, hide the main window, else do nothing + if (configManager.getTray()) { + this.mainWindow.hide() + app.dock?.hide() + } + } else { + this.mainWindow.focus() } - this.miniWindow.show() - this.miniWindow.center() - this.miniWindow.focus() return } + this.showMainWindow() + } + + public createMiniWindow(isPreload: boolean = false): BrowserWindow { this.miniWindow = new BrowserWindow({ width: 500, height: 520, - show: true, + show: false, autoHideMenuBar: true, transparent: isMac, vibrancy: 'under-window', @@ -356,6 +390,11 @@ export class WindowService { alwaysOnTop: true, resizable: false, useContentSize: true, + ...(isMac ? { type: 'panel' } : {}), + skipTaskbar: true, + minimizable: false, + maximizable: false, + fullscreenable: false, webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: false, @@ -364,8 +403,23 @@ export class WindowService { } }) + //miniWindow should show in current desktop + this.miniWindow?.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) + //make miniWindow always on top of fullscreen apps with level set + this.miniWindow.setAlwaysOnTop(true, 'screen-saver', 1) + + this.miniWindow.on('ready-to-show', () => { + if (isPreload) { + return + } + + this.wasMainWindowFocused = this.mainWindow?.isFocused() || false + this.miniWindow?.center() + this.miniWindow?.show() + }) + this.miniWindow.on('blur', () => { - this.miniWindow?.hide() + this.hideMiniWindow() }) this.miniWindow.on('closed', () => { @@ -391,9 +445,48 @@ export class WindowService { hash: '#/mini' }) } + + return this.miniWindow + } + + public showMiniWindow() { + const enableQuickAssistant = configManager.getEnableQuickAssistant() + + if (!enableQuickAssistant) { + return + } + + if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) { + this.selectionMenuWindow.hide() + } + + if (this.miniWindow && !this.miniWindow.isDestroyed()) { + this.wasMainWindowFocused = this.mainWindow?.isFocused() || false + + if (this.miniWindow.isMinimized()) { + this.miniWindow.restore() + } + this.miniWindow.show() + return + } + + this.miniWindow = this.createMiniWindow() } public hideMiniWindow() { + //hacky-fix:[mac/win] previous window(not self-app) should be focused again after miniWindow hide + if (isWin) { + this.miniWindow?.minimize() + this.miniWindow?.hide() + return + } else if (isMac) { + this.miniWindow?.hide() + if (!this.wasMainWindowFocused) { + app.hide() + } + return + } + this.miniWindow?.hide() } @@ -402,11 +495,12 @@ export class WindowService { } public toggleMiniWindow() { - if (this.miniWindow) { - this.miniWindow.isVisible() ? this.miniWindow.hide() : this.miniWindow.show() - } else { - this.showMiniWindow() + if (this.miniWindow && !this.miniWindow.isDestroyed() && this.miniWindow.isVisible()) { + this.hideMiniWindow() + return } + + this.showMiniWindow() } public showSelectionMenu(bounds: { x: number; y: number }) { diff --git a/src/main/utils/index.ts b/src/main/utils/index.ts index 2959562162..4a6fde670d 100644 --- a/src/main/utils/index.ts +++ b/src/main/utils/index.ts @@ -46,3 +46,9 @@ export function dumpPersistState() { export const runAsyncFunction = async (fn: () => void) => { await fn() } + +export function makeSureDirExists(dir: string) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } +} diff --git a/src/main/utils/process.ts b/src/main/utils/process.ts index e10cdc4be7..36a0d731bb 100644 --- a/src/main/utils/process.ts +++ b/src/main/utils/process.ts @@ -42,7 +42,11 @@ export async function getBinaryName(name: string): Promise { return name } -export async function getBinaryPath(name: string): Promise { +export async function getBinaryPath(name?: string): Promise { + if (!name) { + return path.join(os.homedir(), '.cherrystudio', 'bin') + } + const binaryName = await getBinaryName(name) const binariesDir = path.join(os.homedir(), '.cherrystudio', 'bin') const binariesDirExists = await fs.existsSync(binariesDir) diff --git a/src/renderer/src/assets/styles/animation.scss b/src/renderer/src/assets/styles/animation.scss index 5d02acfc80..eb6cb3592a 100644 --- a/src/renderer/src/assets/styles/animation.scss +++ b/src/renderer/src/assets/styles/animation.scss @@ -1,4 +1,4 @@ -@keyframes pulse { +@keyframes animation-pulse { 0% { box-shadow: 0 0 0 0 rgba(var(--pulse-color), 0.5); } @@ -14,5 +14,5 @@ .animation-pulse { --pulse-color: 59, 130, 246; --pulse-size: 8px; - animation: pulse 1.5s infinite; + animation: animation-pulse 1.5s infinite; } diff --git a/src/renderer/src/assets/styles/ant.scss b/src/renderer/src/assets/styles/ant.scss index bb4ac8955e..a598bb6004 100644 --- a/src/renderer/src/assets/styles/ant.scss +++ b/src/renderer/src/assets/styles/ant.scss @@ -192,3 +192,10 @@ } } } + +.ant-dropdown-menu .ant-dropdown-menu-sub { + max-height: 350px; + width: max-content; + overflow-y: auto; + overflow-x: hidden; +} diff --git a/src/renderer/src/components/CustomCollapse.tsx b/src/renderer/src/components/CustomCollapse.tsx new file mode 100644 index 0000000000..95db85268c --- /dev/null +++ b/src/renderer/src/components/CustomCollapse.tsx @@ -0,0 +1,43 @@ +import { Collapse } from 'antd' +import { FC, memo } from 'react' + +interface CustomCollapseProps { + label: React.ReactNode + extra: React.ReactNode + children: React.ReactNode +} + +const CustomCollapse: FC = ({ label, extra, children }) => { + const CollapseStyle = { + background: 'transparent', + border: '0.5px solid var(--color-border)' + } + const CollapseItemStyles = { + header: { + padding: '8px 16px', + alignItems: 'center', + justifyContent: 'space-between' + }, + body: { + borderTop: '0.5px solid var(--color-border)' + } + } + return ( + + ) +} + +export default memo(CustomCollapse) diff --git a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx index 6b668795d2..3e68943d70 100644 --- a/src/renderer/src/components/MinApp/MinappPopupContainer.tsx +++ b/src/renderer/src/components/MinApp/MinappPopupContainer.tsx @@ -1,6 +1,7 @@ import { CloseOutlined, CodeOutlined, + CopyOutlined, ExportOutlined, MinusOutlined, PushpinOutlined, @@ -42,6 +43,9 @@ const MinappPopupContainer: React.FC = () => { const [isPopupShow, setIsPopupShow] = useState(true) /** whether the current minapp is ready */ const [isReady, setIsReady] = useState(false) + /** the current REAL url of the minapp + * different from the app preset url, because user may navigate in minapp */ + const [currentUrl, setCurrentUrl] = useState(null) /** store the last minapp id and show status */ const lastMinappId = useRef(null) @@ -59,6 +63,11 @@ const MinappPopupContainer: React.FC = () => { /** set the popup display status */ useEffect(() => { if (minappShow) { + // init the current url + if (currentMinappId && currentAppInfo) { + setCurrentUrl(currentAppInfo.url) + } + setIsPopupShow(true) if (webviewLoadedRefs.current.get(currentMinappId)) { @@ -77,6 +86,7 @@ const MinappPopupContainer: React.FC = () => { lastMinappId.current = currentMinappId lastMinappShow.current = minappShow } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [minappShow, currentMinappId]) useEffect(() => { @@ -168,6 +178,13 @@ const MinappPopupContainer: React.FC = () => { } } + /** the callback function to handle the webview navigate to new url */ + const handleWebviewNavigate = (appid: string, url: string) => { + if (appid === currentMinappId) { + setCurrentUrl(url) + } + } + /** will open the devtools of the minapp */ const handleOpenDevTools = (appid: string) => { const webview = webviewRefs.current.get(appid) @@ -187,12 +204,9 @@ const MinappPopupContainer: React.FC = () => { } } - /** only open the current url */ - const handleOpenLink = (appid: string) => { - const webview = webviewRefs.current.get(appid) - if (webview) { - window.api.openWebsite(webview.getURL()) - } + /** open the giving url in browser */ + const handleOpenLink = (url: string) => { + window.api.openWebsite(url) } /** toggle the pin status of the minapp */ @@ -205,11 +219,41 @@ const MinappPopupContainer: React.FC = () => { } /** Title bar of the popup */ - const Title = ({ appInfo }: { appInfo: AppInfo | null }) => { + const Title = ({ appInfo, url }: { appInfo: AppInfo | null; url: string | null }) => { if (!appInfo) return null + + const handleCopyUrl = (event: any, url: string) => { + //don't show app-wide context menu + event.preventDefault() + navigator.clipboard + .writeText(url) + .then(() => { + window.message.success('URL ' + t('message.copy.success')) + }) + .catch(() => { + window.message.error('URL ' + t('message.copy.failed')) + }) + } + return ( - {appInfo.name} + + {url ?? appInfo.url}
+ + {t('minapp.popup.rightclick_copyurl')} + + } + mouseEnterDelay={0.8} + placement="rightBottom" + styles={{ + root: { + maxWidth: '400px' + } + }}> + handleCopyUrl(e, url ?? appInfo.url)}>{appInfo.name} +
@@ -266,6 +310,7 @@ const MinappPopupContainer: React.FC = () => { url={app.url} onSetRefCallback={handleWebviewSetRef} onLoadedCallback={handleWebviewLoaded} + onNavigateCallback={handleWebviewNavigate} /> )) @@ -275,7 +320,7 @@ const MinappPopupContainer: React.FC = () => { return ( } + title={} placement="bottom" onClose={handlePopupMinimize} open={isPopupShow} @@ -321,8 +366,18 @@ const TitleText = styled.div` font-size: 14px; color: var(--color-text-1); margin-right: 10px; - user-select: none; + -webkit-app-region: no-drag; ` + +const TitleTextTooltip = styled.span` + font-size: 0.8rem; + + .icon-copy { + font-size: 0.7rem; + padding-right: 5px; + } +` + const ButtonsGroup = styled.div` display: flex; flex-direction: row; diff --git a/src/renderer/src/components/MinApp/WebviewContainer.tsx b/src/renderer/src/components/MinApp/WebviewContainer.tsx index f56cdc08cd..203122e01c 100644 --- a/src/renderer/src/components/MinApp/WebviewContainer.tsx +++ b/src/renderer/src/components/MinApp/WebviewContainer.tsx @@ -11,12 +11,14 @@ const WebviewContainer = memo( appid, url, onSetRefCallback, - onLoadedCallback + onLoadedCallback, + onNavigateCallback }: { appid: string url: string onSetRefCallback: (appid: string, element: WebviewTag | null) => void onLoadedCallback: (appid: string) => void + onNavigateCallback: (appid: string, url: string) => void }) => { const webviewRef = useRef<WebviewTag | null>(null) @@ -47,8 +49,13 @@ const WebviewContainer = memo( onLoadedCallback(appid) } + const handleNavigate = (event: any) => { + onNavigateCallback(appid, event.url) + } + webviewRef.current.addEventListener('new-window', handleNewWindow) webviewRef.current.addEventListener('did-finish-load', handleLoaded) + webviewRef.current.addEventListener('did-navigate-in-page', handleNavigate) // we set the url when the webview is ready webviewRef.current.src = url @@ -56,6 +63,7 @@ const WebviewContainer = memo( return () => { webviewRef.current?.removeEventListener('new-window', handleNewWindow) webviewRef.current?.removeEventListener('did-finish-load', handleLoaded) + webviewRef.current?.removeEventListener('did-navigate-in-page', handleNavigate) } // because the appid and url are enough, no need to add onLoadedCallback // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/renderer/src/components/Popups/AddAssistantPopup.tsx b/src/renderer/src/components/Popups/AddAssistantPopup.tsx index d2479c0508..7d3ec6a04f 100644 --- a/src/renderer/src/components/Popups/AddAssistantPopup.tsx +++ b/src/renderer/src/components/Popups/AddAssistantPopup.tsx @@ -9,7 +9,7 @@ import { Agent, Assistant } from '@renderer/types' import { uuid } from '@renderer/utils' import { Divider, Input, InputRef, Modal, Tag } from 'antd' import { take } from 'lodash' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -30,6 +30,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => { const inputRef = useRef<InputRef>(null) const systemAgents = useSystemAgents() const loadingRef = useRef(false) + const [selectedIndex, setSelectedIndex] = useState(0) + const containerRef = useRef<HTMLDivElement>(null) const agents = useMemo(() => { const allAgents = [...userAgents, ...systemAgents] as Agent[] @@ -52,25 +54,80 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => { return filtered }, [assistants, defaultAssistant, searchText, systemAgents, userAgents]) - const onCreateAssistant = async (agent: Agent) => { - if (loadingRef.current) { - return + // 重置选中索引当搜索或列表内容变更时 + useEffect(() => { + setSelectedIndex(0) + }, [agents.length, searchText]) + + const onCreateAssistant = useCallback( + async (agent: Agent) => { + if (loadingRef.current) { + return + } + + loadingRef.current = true + let assistant: Assistant + + if (agent.id === 'default') { + assistant = { ...agent, id: uuid() } + addAssistant(assistant) + } else { + assistant = await createAssistantFromAgent(agent) + } + + setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0) + resolve(assistant) + setOpen(false) + }, + [resolve, addAssistant, setOpen] + ) // 添加函数内使用的依赖项 + // 键盘导航处理 + useEffect(() => { + if (!open) return + + const handleKeyDown = (e: KeyboardEvent) => { + const displayedAgents = take(agents, 100) + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setSelectedIndex((prev) => (prev >= displayedAgents.length - 1 ? 0 : prev + 1)) + break + case 'ArrowUp': + e.preventDefault() + setSelectedIndex((prev) => (prev <= 0 ? displayedAgents.length - 1 : prev - 1)) + break + case 'Enter': + // 如果焦点在输入框且有搜索内容,则默认选择第一项 + if (document.activeElement === inputRef.current?.input && searchText.trim()) { + e.preventDefault() + onCreateAssistant(displayedAgents[selectedIndex]) + } + // 否则选择当前选中项 + else if (selectedIndex >= 0 && selectedIndex < displayedAgents.length) { + e.preventDefault() + onCreateAssistant(displayedAgents[selectedIndex]) + } + break + } } - loadingRef.current = true - let assistant: Assistant + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [open, selectedIndex, agents, searchText, onCreateAssistant]) - if (agent.id === 'default') { - assistant = { ...agent, id: uuid() } - addAssistant(assistant) - } else { - assistant = await createAssistantFromAgent(agent) + // 确保选中项在可视区域 + useEffect(() => { + if (containerRef.current) { + const agentItems = containerRef.current.querySelectorAll('.agent-item') + if (agentItems[selectedIndex]) { + agentItems[selectedIndex].scrollIntoView({ + behavior: 'smooth', + block: 'nearest' + }) + } } - - setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0) - resolve(assistant) - setOpen(false) - } + }, [selectedIndex]) const onCancel = () => { setOpen(false) @@ -121,12 +178,13 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => { /> </HStack> <Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} /> - <Container> - {take(agents, 100).map((agent) => ( + <Container ref={containerRef}> + {take(agents, 100).map((agent, index) => ( <AgentItem key={agent.id} onClick={() => onCreateAssistant(agent)} - className={agent.id === 'default' ? 'default' : ''}> + className={`agent-item ${agent.id === 'default' ? 'default' : ''} ${index === selectedIndex ? 'keyboard-selected' : ''}`} + onMouseEnter={() => setSelectedIndex(index)}> <HStack alignItems="center" gap={5} @@ -161,9 +219,14 @@ const AgentItem = styled.div` margin-bottom: 8px; cursor: pointer; overflow: hidden; + border: 1px solid transparent; &.default { background-color: var(--color-background-mute); } + &.keyboard-selected { + background-color: var(--color-background-mute); + border: 1px solid var(--color-primary); + } .anticon { font-size: 16px; color: var(--color-icon); diff --git a/src/renderer/src/components/app/Sidebar.tsx b/src/renderer/src/components/app/Sidebar.tsx index 0854215665..47498d1a08 100644 --- a/src/renderer/src/components/app/Sidebar.tsx +++ b/src/renderer/src/components/app/Sidebar.tsx @@ -361,6 +361,7 @@ const Icon = styled.div<{ theme: string }>` justify-content: center; align-items: center; border-radius: 50%; + box-sizing: border-box; -webkit-app-region: none; border: 0.5px solid transparent; .iconfont, @@ -392,18 +393,34 @@ const Icon = styled.div<{ theme: string }>` @keyframes borderBreath { 0% { - border-color: var(--color-primary-mute); + opacity: 0.1; } 50% { - border-color: var(--color-primary); + opacity: 1; } 100% { - border-color: var(--color-primary-mute); + opacity: 0.1; } } &.opened-animation { + position: relative; + } + + &.opened-animation::after { + content: ''; + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + border-radius: inherit; + opacity: 0; + will-change: opacity; border: 0.5px solid var(--color-primary); + /* NOTICE: although we have optimized for the performance, + * the infinite animation will still consume a little GPU resources, + * it's a trade-off balance between performance and animation smoothness*/ animation: borderBreath 4s ease-in-out infinite; } ` diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index ca614580b2..ac20258ddf 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -205,7 +205,7 @@ export const FUNCTION_CALLING_MODELS = [ 'claude', 'qwen', 'hunyuan', - 'deepseek-ai/', + 'deepseek', 'glm-4(?:-[\\w-]+)?', 'learnlm(?:-[\\w-]+)?', 'gemini(?:-[\\w-]+)?' // 提前排除了gemini的嵌入模型 @@ -1958,6 +1958,17 @@ export const TEXT_TO_IMAGES_MODELS_SUPPORT_IMAGE_ENHANCEMENT = [ export const GENERATE_IMAGE_MODELS = ['gemini-2.0-flash-exp-image-generation', 'gemini-2.0-flash-exp'] +export const GEMINI_SEARCH_MODELS = [ + 'gemini-2.0-flash', + 'gemini-2.0-flash-lite', + 'gemini-2.0-flash-exp', + 'gemini-2.0-flash-001', + 'gemini-2.0-pro-exp-02-05', + 'gemini-2.0-pro-exp', + 'gemini-2.5-pro-exp', + 'gemini-2.5-pro-exp-03-25' +] + export function isTextToImageModel(model: Model): boolean { return TEXT_TO_IMAGE_REGEX.test(model.id) } @@ -2062,34 +2073,25 @@ export function isWebSearchModel(model: Model): boolean { return false } + if (provider.id === 'aihubmix') { + const models = ['gemini-2.0-flash-search', 'gemini-2.0-flash-exp-search', 'gemini-2.0-pro-exp-02-05-search'] + return models.includes(model?.id) + } + if (provider?.type === 'openai') { - if (model?.id?.includes('gemini-2.0-flash-exp')) { + if (GEMINI_SEARCH_MODELS.includes(model?.id)) { return true } } if (provider.id === 'gemini' || provider?.type === 'gemini') { - const models = [ - 'gemini-2.0-flash', - 'gemini-2.0-flash-exp', - 'gemini-2.0-flash-001', - 'gemini-2.0-pro-exp-02-05', - 'gemini-2.0-pro-exp', - 'gemini-2.5-pro-exp', - 'gemini-2.5-pro-exp-03-25' - ] - return models.includes(model?.id) + return GEMINI_SEARCH_MODELS.includes(model?.id) } if (provider.id === 'hunyuan') { return model?.id !== 'hunyuan-lite' } - if (provider.id === 'aihubmix') { - const models = ['gemini-2.0-flash-search', 'gemini-2.0-flash-exp-search', 'gemini-2.0-pro-exp-02-05-search'] - return models.includes(model?.id) - } - if (provider.id === 'zhipu') { return model?.id?.startsWith('glm-4-') } diff --git a/src/renderer/src/hooks/useAssistant.ts b/src/renderer/src/hooks/useAssistant.ts index c89c1dd30e..db34bda987 100644 --- a/src/renderer/src/hooks/useAssistant.ts +++ b/src/renderer/src/hooks/useAssistant.ts @@ -17,6 +17,7 @@ import { } from '@renderer/store/assistants' import { setDefaultModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm' import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types' +import { useCallback } from 'react' import { TopicManager } from './useTopic' @@ -69,7 +70,10 @@ export function useAssistant(id: string) { updateTopic: (topic: Topic) => dispatch(updateTopic({ assistantId: assistant.id, topic })), updateTopics: (topics: Topic[]) => dispatch(updateTopics({ assistantId: assistant.id, topics })), removeAllTopics: () => dispatch(removeAllTopics({ assistantId: assistant.id })), - setModel: (model: Model) => dispatch(setModel({ assistantId: assistant.id, model })), + setModel: useCallback( + (model: Model) => dispatch(setModel({ assistantId: assistant.id, model })), + [dispatch, assistant.id] + ), updateAssistant: (assistant: Assistant) => dispatch(updateAssistant(assistant)), updateAssistantSettings: (settings: Partial<AssistantSettings>) => { dispatch(updateAssistantSettings({ assistantId: assistant.id, settings })) diff --git a/src/renderer/src/hooks/useMCPServers.ts b/src/renderer/src/hooks/useMCPServers.ts index 553df561e0..65bc729d50 100644 --- a/src/renderer/src/hooks/useMCPServers.ts +++ b/src/renderer/src/hooks/useMCPServers.ts @@ -11,7 +11,7 @@ ipcRenderer.on('mcp:servers-changed', (_event, servers) => { export const useMCPServers = () => { const mcpServers = useAppSelector((state) => state.mcp.servers) - const activedMcpServers = useAppSelector((state) => state.mcp.servers?.filter((server) => server.isActive)) + const activedMcpServers = mcpServers.filter((server) => server.isActive) const dispatch = useAppDispatch() return { diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 9f65d5ee0b..a9f9f90681 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -46,6 +46,11 @@ "search": "Search assistants...", "settings.default_model": "Default Model", "settings.knowledge_base": "Knowledge Base Settings", + "settings.mcp": "MCP Servers", + "settings.mcp.enableFirst": "Enable this server in MCP settings first", + "settings.mcp.title": "MCP Settings", + "settings.mcp.noServersAvailable": "No MCP servers available. Add servers in settings", + "settings.mcp.description": "Default enabled MCP servers", "settings.model": "Model Settings", "settings.preset_messages": "Preset Messages", "settings.prompt": "Prompt Settings", @@ -145,7 +150,10 @@ "history": "Chat History", "last": "Already at the last message", "next": "Next Message", - "prev": "Previous Message" + "prev": "Previous Message", + "top": "Back to top", + "bottom": "Back to bottom", + "close": "Close" }, "resend": "Resend", "save": "Save", @@ -224,7 +232,10 @@ "topics.title": "Topics", "topics.unpinned": "Unpinned Topics", "translate": "Translate", - "topics.export.siyuan": "Export to Siyuan Note" + "topics.export.siyuan": "Export to Siyuan Note", + "topics.export.wait_for_title_naming": "Generating title...", + "topics.export.title_naming_success": "Title generated successfully", + "topics.export.title_naming_failed": "Failed to generate title, using default title" }, "code_block": { "collapse": "Collapse", @@ -553,7 +564,8 @@ "close": "Close MinApp", "minimize": "Minimize MinApp", "devtools": "Developer Tools", - "openExternal": "Open in Browser" + "openExternal": "Open in Browser", + "rightclick_copyurl": "Right-click to copy URL" }, "sidebar.add.title": "Add to sidebar", "sidebar.remove.title": "Remove from sidebar", @@ -919,7 +931,9 @@ "new_folder.button.confirm": "Confirm", "new_folder.button.cancel": "Cancel", "new_folder.button": "New Folder" - } + }, + "message_title.use_topic_naming.title": "Use topic naming model to create titles for exported messages", + "message_title.use_topic_naming.help": "When enabled, use topic naming model to create titles for exported messages. This will also affect all Markdown export methods." }, "display.assistant.title": "Assistant Settings", "display.custom.css": "Custom CSS", @@ -1045,7 +1059,10 @@ "noToolsAvailable": "No tools available" }, "deleteServer": "Delete Server", - "deleteServerConfirm": "Are you sure you want to delete this server?" + "deleteServerConfirm": "Are you sure you want to delete this server?", + "registry": "Package Registry", + "registryTooltip": "Choose the registry for package installation to resolve network issues with the default registry.", + "registryDefault": "Default" }, "messages.divider": "Show divider between messages", "messages.grid_columns": "Message grid display columns", @@ -1195,7 +1212,7 @@ "reset_defaults_confirm": "Are you sure you want to reset all shortcuts?", "reset_to_default": "Reset to Default", "search_message": "Search Message", - "show_app": "Show App", + "show_app": "Show/Hide App", "show_settings": "Open Settings", "title": "Keyboard Shortcuts", "toggle_new_context": "Clear Context", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 27856911b6..c07b448d5c 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -44,6 +44,11 @@ "save.success": "保存に成功しました", "save.title": "エージェントに保存", "search": "アシスタントを検索...", + "settings.mcp": "MCP サーバー", + "settings.mcp.enableFirst": "まず MCP 設定でこのサーバーを有効にしてください", + "settings.mcp.title": "MCP 設定", + "settings.mcp.noServersAvailable": "利用可能な MCP サーバーがありません。設定でサーバーを追加してください", + "settings.mcp.description": "デフォルトで有効な MCP サーバー", "settings.default_model": "デフォルトモデル", "settings.knowledge_base": "ナレッジベース設定", "settings.model": "モデル設定", @@ -145,7 +150,10 @@ "history": "チャット履歴", "last": "最後のメッセージです", "next": "次のメッセージ", - "prev": "前のメッセージ" + "prev": "前のメッセージ", + "top": "トップに戻る", + "bottom": "下部に戻る", + "close": "閉じる" }, "resend": "再送信", "save": "保存", @@ -224,7 +232,10 @@ "topics.title": "トピック", "topics.unpinned": "固定解除", "translate": "翻訳", - "topics.export.siyuan": "思源笔记にエクスポート" + "topics.export.siyuan": "思源笔记にエクスポート", + "topics.export.wait_for_title_naming": "タイトルを生成中...", + "topics.export.title_naming_success": "タイトルの生成に成功しました", + "topics.export.title_naming_failed": "タイトルの生成に失敗しました。デフォルトのタイトルを使用します" }, "code_block": { "collapse": "折りたたむ", @@ -553,7 +564,8 @@ "close": "ミニアプリを閉じる", "minimize": "ミニアプリを最小化", "devtools": "開発者ツール", - "openExternal": "ブラウザで開く" + "openExternal": "ブラウザで開く", + "rightclick_copyurl": "右クリックでURLをコピー" }, "sidebar.add.title": "サイドバーに追加", "sidebar.remove.title": "サイドバーから削除", @@ -919,7 +931,9 @@ "new_folder.button.confirm": "確認", "new_folder.button.cancel": "キャンセル", "new_folder.button": "新しいフォルダー" - } + }, + "message_title.use_topic_naming.title": "トピック命名モデルを使用してメッセージのタイトルを作成", + "message_title.use_topic_naming.help": "この設定は、すべてのMarkdownエクスポート方法に影響します。" }, "display.assistant.title": "アシスタント設定", "display.custom.css": "カスタムCSS", @@ -1044,7 +1058,10 @@ "noToolsAvailable": "利用可能なツールはありません" }, "deleteServer": "サーバーを削除", - "deleteServerConfirm": "このサーバーを削除してもよろしいですか?" + "deleteServerConfirm": "このサーバーを削除してもよろしいですか?", + "registry": "パッケージ管理レジストリ", + "registryTooltip": "デフォルトのレジストリでネットワークの問題が発生した場合、パッケージインストールに使用するレジストリを選択してください。", + "registryDefault": "デフォルト" }, "messages.divider": "メッセージ間に区切り線を表示", "messages.grid_columns": "メッセージグリッドの表示列数", @@ -1194,7 +1211,7 @@ "reset_defaults_confirm": "すべてのショートカットをリセットしてもよろしいですか?", "reset_to_default": "デフォルトにリセット", "search_message": "メッセージを検索", - "show_app": "アプリを表示", + "show_app": "アプリを表示/非表示", "show_settings": "設定を開く", "title": "ショートカット", "toggle_new_context": "コンテキストをクリア", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 2fb61539e8..81716e0e67 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -44,6 +44,11 @@ "save.success": "Успешно сохранено", "save.title": "Сохранить в агента", "search": "Поиск ассистентов...", + "settings.mcp": "Серверы MCP", + "settings.mcp.enableFirst": "Сначала включите этот сервер в настройках MCP", + "settings.mcp.title": "Настройки MCP", + "settings.mcp.noServersAvailable": "Нет доступных серверов MCP. Добавьте серверы в настройках", + "settings.mcp.description": "Серверы MCP, включенные по умолчанию", "settings.default_model": "Модель по умолчанию", "settings.knowledge_base": "Настройки базы знаний", "settings.model": "Настройки модели", @@ -145,7 +150,10 @@ "history": "История чата", "last": "Уже последнее сообщение", "next": "Следующее сообщение", - "prev": "Предыдущее сообщение" + "prev": "Предыдущее сообщение", + "top": "Вернуться наверх", + "bottom": "Вернуться вниз", + "close": "Закрыть" }, "resend": "Переотправить", "save": "Сохранить", @@ -224,7 +232,10 @@ "topics.title": "Топики", "topics.unpinned": "Открепленные темы", "translate": "Перевести", - "topics.export.siyuan": "Экспорт в Siyuan Note" + "topics.export.siyuan": "Экспорт в Siyuan Note", + "topics.export.wait_for_title_naming": "Создание заголовка...", + "topics.export.title_naming_success": "Заголовок успешно создан", + "topics.export.title_naming_failed": "Не удалось создать заголовок, используется заголовок по умолчанию" }, "code_block": { "collapse": "Свернуть", @@ -553,7 +564,8 @@ "close": "Закрыть встроенное приложение", "minimize": "Свернуть встроенное приложение", "devtools": "Инструменты разработчика", - "openExternal": "Открыть в браузере" + "openExternal": "Открыть в браузере", + "rightclick_copyurl": "ПКМ → Копировать URL" }, "sidebar.add.title": "Добавить в боковую панель", "sidebar.remove.title": "Удалить из боковой панели", @@ -919,7 +931,9 @@ "new_folder.button.confirm": "Подтвердить", "new_folder.button.cancel": "Отмена", "new_folder.button": "Новая папка" - } + }, + "message_title.use_topic_naming.title": "Использовать модель именования тем для создания заголовков сообщений", + "message_title.use_topic_naming.help": "Этот параметр влияет на все методы экспорта в Markdown, такие как Notion, Yuque и т.д." }, "display.assistant.title": "Настройки ассистентов", "display.custom.css": "Пользовательский CSS", @@ -1044,7 +1058,10 @@ "noToolsAvailable": "нет доступных инструментов" }, "deleteServer": "Удалить сервер", - "deleteServerConfirm": "Вы уверены, что хотите удалить этот сервер?" + "deleteServerConfirm": "Вы уверены, что хотите удалить этот сервер?", + "registry": "Реестр пакетов", + "registryTooltip": "Выберите реестр для установки пакетов, если возникают проблемы с сетью при использовании реестра по умолчанию.", + "registryDefault": "По умолчанию" }, "messages.divider": "Показывать разделитель между сообщениями", "messages.grid_columns": "Количество столбцов сетки сообщений", @@ -1194,7 +1211,7 @@ "reset_defaults_confirm": "Вы уверены, что хотите сбросить все горячие клавиши?", "reset_to_default": "Сбросить настройки по умолчанию", "search_message": "Поиск сообщения", - "show_app": "Показать приложение", + "show_app": "Показать/скрыть приложение", "show_settings": "Открыть настройки", "title": "Горячие клавиши", "toggle_new_context": "Очистить контекст", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 52ea34e882..9a5c432a3d 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -44,6 +44,11 @@ "save.success": "保存成功", "save.title": "保存到智能体", "search": "搜索助手", + "settings.mcp": "MCP 服务器", + "settings.mcp.enableFirst": "请先在 MCP 设置中启用此服务器", + "settings.mcp.title": "MCP 设置", + "settings.mcp.noServersAvailable": "无可用 MCP 服务器。请在设置中添加服务器", + "settings.mcp.description": "默认启用的 MCP 服务器", "settings.default_model": "默认模型", "settings.knowledge_base": "知识库设置", "settings.model": "模型设置", @@ -145,7 +150,10 @@ "history": "聊天历史", "last": "已经是最后一条消息", "next": "下一条消息", - "prev": "上一条消息" + "prev": "上一条消息", + "top": "回到顶部", + "bottom": "回到底部", + "close": "关闭" }, "resend": "重新发送", "save": "保存", @@ -224,7 +232,10 @@ "topics.title": "话题", "topics.unpinned": "取消固定", "translate": "翻译", - "topics.export.siyuan": "导出到思源笔记" + "topics.export.siyuan": "导出到思源笔记", + "topics.export.wait_for_title_naming": "正在生成标题...", + "topics.export.title_naming_success": "标题生成成功", + "topics.export.title_naming_failed": "标题生成失败,使用默认标题" }, "code_block": { "collapse": "收起", @@ -553,7 +564,8 @@ "close": "关闭小程序", "minimize": "最小化小程序", "devtools": "开发者工具", - "openExternal": "在浏览器中打开" + "openExternal": "在浏览器中打开", + "rightclick_copyurl": "右键复制URL" }, "sidebar.add.title": "添加到侧边栏", "sidebar.remove.title": "从侧边栏移除", @@ -800,6 +812,8 @@ "markdown_export.path_placeholder": "导出路径", "markdown_export.select": "选择", "markdown_export.title": "Markdown 导出", + "message_title.use_topic_naming.title": "使用话题命名模型为导出的消息创建标题", + "message_title.use_topic_naming.help": "开启后,使用话题命名模型为导出的消息创建标题。该项也会影响所有通过Markdown导出的方式。", "minute_interval_one": "{{count}} 分钟", "minute_interval_other": "{{count}} 分钟", "notion.api_key": "Notion 密钥", @@ -1045,7 +1059,10 @@ "noToolsAvailable": "没有可用工具" }, "deleteServer": "删除服务器", - "deleteServerConfirm": "确定要删除此服务器吗?" + "deleteServerConfirm": "确定要删除此服务器吗?", + "registry": "包管理源", + "registryTooltip": "选择用于安装包的源,以解决默认源的网络问题。", + "registryDefault": "默认" }, "messages.divider": "消息分割线", "messages.grid_columns": "消息网格展示列数", @@ -1195,7 +1212,7 @@ "reset_defaults_confirm": "确定要重置所有快捷键吗?", "reset_to_default": "重置为默认", "search_message": "搜索消息", - "show_app": "显示应用", + "show_app": "显示/隐藏应用", "show_settings": "打开设置", "title": "快捷方式", "toggle_new_context": "清除上下文", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 855045fd90..1ac2ac1b92 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -44,6 +44,11 @@ "save.success": "儲存成功", "save.title": "儲存到智慧代理人", "search": "搜尋助手...", + "settings.mcp": "MCP 伺服器", + "settings.mcp.enableFirst": "請先在 MCP 設定中啟用此伺服器", + "settings.mcp.title": "MCP 設定", + "settings.mcp.noServersAvailable": "無可用 MCP 伺服器。請在設定中新增伺服器", + "settings.mcp.description": "預設啟用的 MCP 伺服器", "settings.default_model": "預設模型", "settings.knowledge_base": "知識庫設定", "settings.model": "模型設定", @@ -145,7 +150,10 @@ "history": "聊天歷史", "last": "已經是最後一條訊息", "next": "下一條訊息", - "prev": "上一條訊息" + "prev": "上一條訊息", + "top": "回到頂部", + "bottom": "回到底部", + "close": "關閉" }, "resend": "重新傳送", "save": "儲存", @@ -224,7 +232,10 @@ "topics.title": "話題", "topics.unpinned": "取消固定", "translate": "翻譯", - "topics.export.siyuan": "匯出到思源筆記" + "topics.export.siyuan": "匯出到思源筆記", + "topics.export.wait_for_title_naming": "正在生成標題...", + "topics.export.title_naming_success": "標題生成成功", + "topics.export.title_naming_failed": "標題生成失敗,使用預設標題" }, "code_block": { "collapse": "折疊", @@ -553,7 +564,8 @@ "close": "關閉小工具", "minimize": "最小化小工具", "devtools": "開發者工具", - "openExternal": "在瀏覽器中開啟" + "openExternal": "在瀏覽器中開啟", + "rightclick_copyurl": "右鍵複製URL" }, "sidebar.add.title": "新增到側邊欄", "sidebar.remove.title": "從側邊欄移除", @@ -919,7 +931,9 @@ "new_folder.button.confirm": "確定", "new_folder.button.cancel": "取消", "new_folder.button": "新建文件夾" - } + }, + "message_title.use_topic_naming.title": "使用話題命名模型為導出的消息創建標題", + "message_title.use_topic_naming.help": "此設定會影響所有通過Markdown導出的方式,如Notion、語雀等。" }, "display.assistant.title": "助手設定", "display.custom.css": "自訂 CSS", @@ -1044,7 +1058,10 @@ "noToolsAvailable": "沒有可用工具" }, "deleteServer": "刪除伺服器", - "deleteServerConfirm": "確定要刪除此伺服器嗎?" + "deleteServerConfirm": "確定要刪除此伺服器嗎?", + "registry": "套件管理源", + "registryTooltip": "選擇用於安裝套件的源,以解決預設源的網路問題。", + "registryDefault": "預設" }, "messages.divider": "訊息間顯示分隔線", "messages.grid_columns": "訊息網格展示列數", @@ -1194,7 +1211,7 @@ "reset_defaults_confirm": "確定要重設所有快捷鍵嗎?", "reset_to_default": "重設為預設", "search_message": "搜尋訊息", - "show_app": "顯示應用程式", + "show_app": "顯示/隱藏應用程式", "show_settings": "開啟設定", "title": "快速方式", "toggle_new_context": "清除上下文", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 6b46257f2e..8fbe8799f8 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -130,7 +130,10 @@ "first": "Ήδη το πρώτο μήνυμα", "last": "Ήδη το τελευταίο μήνυμα", "next": "Επόμενο μήνυμα", - "prev": "Προηγούμενο μήνυμα" + "prev": "Προηγούμενο μήνυμα", + "top": "Επιστροφή στην κορυφή", + "bottom": "Επιστροφή στο κάτω μέρος", + "close": "Κλείσιμο" }, "resend": "Ξαναστείλε", "save": "Αποθήκευση", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index f97513058d..1dc6bdcd32 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -130,7 +130,10 @@ "first": "Ya es el primer mensaje", "last": "Ya es el último mensaje", "next": "Siguiente mensaje", - "prev": "Mensaje anterior" + "prev": "Mensaje anterior", + "top": "Volver arriba", + "bottom": "Volver abajo", + "close": "Cerrar" }, "resend": "Reenviar", "save": "Guardar", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 9a1c96002f..1128c72c77 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -130,7 +130,10 @@ "first": "Déjà premier message", "last": "Déjà dernier message", "next": "Prochain message", - "prev": "Précédent message" + "prev": "Précédent message", + "top": "Retour en haut", + "bottom": "Retour en bas", + "close": "Fermer" }, "resend": "Réenvoyer", "save": "Enregistrer", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 9dc210f94d..005423fee0 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -130,7 +130,10 @@ "first": "Esta é a primeira mensagem", "last": "Esta é a última mensagem", "next": "Próxima mensagem", - "prev": "Mensagem anterior" + "prev": "Mensagem anterior", + "top": "Voltar ao topo", + "bottom": "Voltar ao fundo", + "close": "Fechar" }, "resend": "Reenviar", "save": "Salvar", diff --git a/src/renderer/src/pages/files/FileItem.tsx b/src/renderer/src/pages/files/FileItem.tsx new file mode 100644 index 0000000000..5aa0f7c286 --- /dev/null +++ b/src/renderer/src/pages/files/FileItem.tsx @@ -0,0 +1,143 @@ +import { + FileExcelFilled, + FileImageFilled, + FileMarkdownFilled, + FilePdfFilled, + FilePptFilled, + FileTextFilled, + FileUnknownFilled, + FileWordFilled, + FileZipFilled, + FolderOpenFilled, + GlobalOutlined, + LinkOutlined +} from '@ant-design/icons' +import { Flex } from 'antd' +import React, { memo } from 'react' +import styled from 'styled-components' + +interface FileItemProps { + fileInfo: { + name: React.ReactNode | string + ext: string + extra?: React.ReactNode | string + actions: React.ReactNode + } +} + +const getFileIcon = (type?: string) => { + if (!type) return <FileUnknownFilled /> + + const ext = type.toLowerCase() + + if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) { + return <FileImageFilled /> + } + + if (['.doc', '.docx'].includes(ext)) { + return <FileWordFilled /> + } + if (['.xls', '.xlsx'].includes(ext)) { + return <FileExcelFilled /> + } + if (['.ppt', '.pptx'].includes(ext)) { + return <FilePptFilled /> + } + if (ext === '.pdf') { + return <FilePdfFilled /> + } + if (['.md', '.markdown'].includes(ext)) { + return <FileMarkdownFilled /> + } + + if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) { + return <FileZipFilled /> + } + + if (['.txt', '.json', '.log', '.yml', '.yaml', '.xml', '.csv'].includes(ext)) { + return <FileTextFilled /> + } + + if (['.url'].includes(ext)) { + return <LinkOutlined /> + } + + if (['.sitemap'].includes(ext)) { + return <GlobalOutlined /> + } + + if (['.folder'].includes(ext)) { + return <FolderOpenFilled /> + } + + return <FileUnknownFilled /> +} + +const FileItem: React.FC<FileItemProps> = ({ fileInfo }) => { + const { name, ext, extra, actions } = fileInfo + + return ( + <FileItemCard> + <CardContent> + <FileIcon>{getFileIcon(ext)}</FileIcon> + <Flex vertical gap={0} flex={1} style={{ width: '0px' }}> + <FileName>{name}</FileName> + {extra && <FileInfo>{extra}</FileInfo>} + </Flex> + {actions} + </CardContent> + </FileItemCard> + ) +} + +const FileItemCard = styled.div` + background: rgba(255, 255, 255, 0.04); + border-radius: 8px; + overflow: hidden; + border: 0.5px solid var(--color-border); + flex-shrink: 0; + transition: box-shadow 0.2s ease; + --shadow-color: rgba(0, 0, 0, 0.05); + &:hover { + box-shadow: + 0 10px 15px -3px var(--shadow-color), + 0 4px 6px -4px var(--shadow-color); + } + body[theme-mode='dark'] & { + --shadow-color: rgba(255, 255, 255, 0.02); + } +` + +const CardContent = styled.div` + padding: 8px 16px; + display: flex; + align-items: center; + gap: 16px; +` + +const FileIcon = styled.div` + color: var(--color-text-3); + font-size: 32px; +` + +const FileName = styled.div` + font-size: 15px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + transition: color 0.2s ease; + span { + font-size: 15px; + } + &:hover { + color: var(--color-primary); + } +` + +const FileInfo = styled.div` + font-size: 13px; + color: var(--color-text-2); +` + +export default memo(FileItem) diff --git a/src/renderer/src/pages/files/FileList.tsx b/src/renderer/src/pages/files/FileList.tsx new file mode 100644 index 0000000000..d81b5d50f3 --- /dev/null +++ b/src/renderer/src/pages/files/FileList.tsx @@ -0,0 +1,167 @@ +import FileManager from '@renderer/services/FileManager' +import { FileType, FileTypes } from '@renderer/types' +import { formatFileSize } from '@renderer/utils' +import { Col, Image, Row, Spin } from 'antd' +import { t } from 'i18next' +import VirtualList from 'rc-virtual-list' +import React, { memo } from 'react' +import styled from 'styled-components' + +import FileItem from './FileItem' +import GeminiFiles from './GeminiFiles' + +interface FileItemProps { + id: FileTypes | 'all' | string + list: { + key: FileTypes | 'all' | string + file: React.ReactNode + files?: FileType[] + count?: number + size: string + ext: string + created_at: string + actions: React.ReactNode + }[] + files?: FileType[] +} + +const FileList: React.FC<FileItemProps> = ({ id, list, files }) => { + if (id === FileTypes.IMAGE && files?.length && files?.length > 0) { + return ( + <div style={{ padding: 16 }}> + <Image.PreviewGroup> + <Row gutter={[16, 16]}> + {files?.map((file) => ( + <Col key={file.id} xs={24} sm={12} md={8} lg={4} xl={3}> + <ImageWrapper> + <LoadingWrapper> + <Spin /> + </LoadingWrapper> + <Image + src={FileManager.getFileUrl(file)} + style={{ height: '100%', objectFit: 'cover', cursor: 'pointer' }} + preview={{ mask: false }} + onLoad={(e) => { + const img = e.target as HTMLImageElement + img.parentElement?.classList.add('loaded') + }} + /> + <ImageInfo> + <div>{formatFileSize(file.size)}</div> + </ImageInfo> + </ImageWrapper> + </Col> + ))} + </Row> + </Image.PreviewGroup> + </div> + ) + } + + if (id.startsWith('gemini_')) { + return <GeminiFiles id={id.replace('gemini_', '') as string} /> + } + + return ( + <VirtualList + data={list} + height={window.innerHeight - 100} + itemHeight={80} + itemKey="key" + style={{ padding: '0 16px 16px 16px' }} + styles={{ + verticalScrollBar: { + width: 6 + }, + verticalScrollBarThumb: { + background: 'var(--color-scrollbar-thumb)' + } + }}> + {(item) => ( + <div + style={{ + height: '80px', + paddingTop: '12px' + }}> + <FileItem + key={item.key} + fileInfo={{ + name: item.file, + ext: item.ext, + extra: `${item.created_at} · ${t('files.count')} ${item.count} · ${item.size}`, + actions: item.actions + }} + /> + </div> + )} + </VirtualList> + ) +} + +const ImageWrapper = styled.div` + position: relative; + aspect-ratio: 1; + overflow: hidden; + border-radius: 8px; + background-color: var(--color-background-soft); + display: flex; + align-items: center; + justify-content: center; + border: 0.5px solid var(--color-border); + + .ant-image { + height: 100%; + width: 100%; + opacity: 0; + transition: + opacity 0.3s ease, + transform 0.3s ease; + + &.loaded { + opacity: 1; + } + } + + &:hover { + .ant-image.loaded { + transform: scale(1.05); + } + + div:last-child { + opacity: 1; + } + } +` + +const LoadingWrapper = styled.div` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--color-background-soft); +` + +const ImageInfo = styled.div` + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: rgba(0, 0, 0, 0.6); + color: white; + padding: 5px 8px; + opacity: 0; + transition: opacity 0.3s ease; + font-size: 12px; + + > div:first-child { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +` + +export default memo(FileList) diff --git a/src/renderer/src/pages/files/FilesPage.tsx b/src/renderer/src/pages/files/FilesPage.tsx index 4ad8f7314b..bbbb770a1c 100644 --- a/src/renderer/src/pages/files/FilesPage.tsx +++ b/src/renderer/src/pages/files/FilesPage.tsx @@ -1,14 +1,15 @@ import { DeleteOutlined, EditOutlined, - EllipsisOutlined, + ExclamationCircleOutlined, FileImageOutlined, FilePdfOutlined, - FileTextOutlined + FileTextOutlined, + SortAscendingOutlined, + SortDescendingOutlined } from '@ant-design/icons' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' import TextEditPopup from '@renderer/components/Popups/TextEditPopup' -import Scrollbar from '@renderer/components/Scrollbar' import db from '@renderer/databases' import { useProviders } from '@renderer/hooks/useProvider' import FileManager from '@renderer/services/FileManager' @@ -16,18 +17,23 @@ import store from '@renderer/store' import { FileType, FileTypes } from '@renderer/types' import { formatFileSize } from '@renderer/utils' import type { MenuProps } from 'antd' -import { Button, Dropdown, Menu } from 'antd' +import { Button, Empty, Flex, Menu, Popconfirm } from 'antd' import dayjs from 'dayjs' import { useLiveQuery } from 'dexie-react-hooks' -import { FC, useMemo, useState } from 'react' +import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import ContentView from './ContentView' +import FileList from './FileList' + +type SortField = 'created_at' | 'size' | 'name' +type SortOrder = 'asc' | 'desc' const FilesPage: FC = () => { const { t } = useTranslation() const [fileType, setFileType] = useState<string>('document') + const [sortField, setSortField] = useState<SortField>('created_at') + const [sortOrder, setSortOrder] = useState<SortOrder>('desc') const { providers } = useProviders() const geminiProviders = providers.filter((provider) => provider.type === 'gemini') @@ -42,6 +48,24 @@ const FilesPage: FC = () => { }) } + const sortFiles = (files: FileType[]) => { + return [...files].sort((a, b) => { + let comparison = 0 + switch (sortField) { + case 'created_at': + comparison = dayjs(a.created_at).unix() - dayjs(b.created_at).unix() + break + case 'size': + comparison = a.size - b.size + break + case 'name': + comparison = a.origin_name.localeCompare(b.origin_name) + break + } + return sortOrder === 'asc' ? comparison : -comparison + }) + } + const files = useLiveQuery<FileType[]>(() => { if (fileType === 'all') { return db.files.orderBy('count').toArray().then(tempFilesSort) @@ -49,6 +73,8 @@ const FilesPage: FC = () => { return db.files.where('type').equals(fileType).sortBy('count').then(tempFilesSort) }, [fileType]) + const sortedFiles = files ? sortFiles(files) : [] + const handleDelete = async (fileId: string) => { const file = await FileManager.getFile(fileId) @@ -89,95 +115,34 @@ const FilesPage: FC = () => { } } - const getActionMenu = (fileId: string): MenuProps['items'] => [ - { - key: 'rename', - icon: <EditOutlined />, - label: t('files.edit'), - onClick: () => handleRename(fileId) - }, - { - key: 'delete', - icon: <DeleteOutlined />, - label: t('files.delete'), - danger: true, - onClick: () => { - window.modal.confirm({ - title: t('files.delete.title'), - content: t('files.delete.content'), - centered: true, - okButtonProps: { danger: true }, - onOk: () => handleDelete(fileId) - }) - } - } - ] - - const dataSource = files?.map((file) => { + const dataSource = sortedFiles?.map((file) => { return { key: file.id, - file: ( - <FileNameText className="text-nowrap" onClick={() => window.api.file.openPath(file.path)}> - {FileManager.formatFileName(file)} - </FileNameText> - ), + file: <span onClick={() => window.api.file.openPath(file.path)}>{FileManager.formatFileName(file)}</span>, size: formatFileSize(file.size), size_bytes: file.size, count: file.count, + path: file.path, + ext: file.ext, created_at: dayjs(file.created_at).format('MM-DD HH:mm'), created_at_unix: dayjs(file.created_at).unix(), actions: ( - <Dropdown menu={{ items: getActionMenu(file.id) }} trigger={['click']} placement="bottom" arrow> - <Button type="text" size="small" icon={<EllipsisOutlined />} /> - </Dropdown> + <Flex align="center" gap={0} style={{ opacity: 0.7 }}> + <Button type="text" icon={<EditOutlined />} onClick={() => handleRename(file.id)} /> + <Popconfirm + title={t('files.delete.title')} + description={t('files.delete.content')} + okText={t('common.confirm')} + cancelText={t('common.cancel')} + onConfirm={() => handleDelete(file.id)} + icon={<ExclamationCircleOutlined style={{ color: 'red' }} />}> + <Button type="text" danger icon={<DeleteOutlined />} /> + </Popconfirm> + </Flex> ) } }) - const columns = useMemo( - () => [ - { - title: t('files.name'), - dataIndex: 'file', - key: 'file', - width: '300px' - }, - { - title: t('files.size'), - dataIndex: 'size', - key: 'size', - width: '80px', - sorter: (a: { size_bytes: number }, b: { size_bytes: number }) => b.size_bytes - a.size_bytes, - align: 'center' - }, - { - title: t('files.count'), - dataIndex: 'count', - key: 'count', - width: '60px', - sorter: (a: { count: number }, b: { count: number }) => b.count - a.count, - align: 'center' - }, - { - title: t('files.created_at'), - dataIndex: 'created_at', - key: 'created_at', - width: '120px', - align: 'center', - sorter: (a: { created_at_unix: number }, b: { created_at_unix: number }) => - b.created_at_unix - a.created_at_unix - }, - { - title: t('files.actions'), - dataIndex: 'actions', - key: 'actions', - width: '80px', - align: 'center' - } - ], - [t] - ) - const menuItems = [ { key: FileTypes.DOCUMENT, label: t('files.document'), icon: <FilePdfOutlined /> }, { key: FileTypes.IMAGE, label: t('files.image'), icon: <FileImageOutlined /> }, @@ -199,9 +164,31 @@ const FilesPage: FC = () => { <SideNav> <Menu selectedKeys={[fileType]} items={menuItems} onSelect={({ key }) => setFileType(key as FileTypes)} /> </SideNav> - <TableContainer right> - <ContentView id={fileType} files={files} dataSource={dataSource} columns={columns} /> - </TableContainer> + <MainContent> + <SortContainer> + {['created_at', 'size', 'name'].map((field) => ( + <SortButton + key={field} + active={sortField === field} + onClick={() => { + if (sortField === field) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc') + } else { + setSortField(field as 'created_at' | 'size' | 'name') + setSortOrder('desc') + } + }}> + {t(`files.${field}`)} + {sortField === field && (sortOrder === 'desc' ? <SortDescendingOutlined /> : <SortAscendingOutlined />)} + </SortButton> + ))} + </SortContainer> + {dataSource && dataSource?.length > 0 ? ( + <FileList id={fileType} list={dataSource} files={sortedFiles} /> + ) : ( + <Empty /> + )} + </MainContent> </ContentContainer> </Container> ) @@ -214,6 +201,20 @@ const Container = styled.div` height: calc(100vh - var(--navbar-height)); ` +const MainContent = styled.div` + display: flex; + flex: 1; + flex-direction: column; +` + +const SortContainer = styled.div` + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-bottom: 0.5px solid var(--color-border); +` + const ContentContainer = styled.div` display: flex; flex: 1; @@ -221,19 +222,6 @@ const ContentContainer = styled.div` min-height: 100%; ` -const TableContainer = styled(Scrollbar)` - padding: 15px; - display: flex; - width: 100%; - flex-direction: column; -` - -const FileNameText = styled.div` - font-size: 14px; - color: var(--color-text); - cursor: pointer; -` - const SideNav = styled.div` width: var(--assistants-width); border-right: 0.5px solid var(--color-border); @@ -266,4 +254,25 @@ const SideNav = styled.div` } ` +const SortButton = styled(Button)<{ active?: boolean }>` + display: flex; + align-items: center; + gap: 4px; + padding: 4px 12px; + height: 30px; + border-radius: var(--list-item-border-radius); + border: 0.5px solid ${(props) => (props.active ? 'var(--color-border)' : 'transparent')}; + background-color: ${(props) => (props.active ? 'var(--color-background-soft)' : 'transparent')}; + color: ${(props) => (props.active ? 'var(--color-text)' : 'var(--color-text-secondary)')}; + + &:hover { + background-color: var(--color-background-soft); + color: var(--color-text); + } + + .anticon { + font-size: 12px; + } +` + export default FilesPage diff --git a/src/renderer/src/pages/files/GeminiFiles.tsx b/src/renderer/src/pages/files/GeminiFiles.tsx index ff60c8751a..a664856bb6 100644 --- a/src/renderer/src/pages/files/GeminiFiles.tsx +++ b/src/renderer/src/pages/files/GeminiFiles.tsx @@ -2,12 +2,13 @@ import { DeleteOutlined } from '@ant-design/icons' import type { FileMetadataResponse } from '@google/generative-ai/server' import { useProvider } from '@renderer/hooks/useProvider' import { runAsyncFunction } from '@renderer/utils' -import { Table } from 'antd' -import type { ColumnsType } from 'antd/es/table' +import { Spin } from 'antd' +import dayjs from 'dayjs' import { FC, useCallback, useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' import styled from 'styled-components' +import FileItem from './FileItem' + interface GeminiFilesProps { id: string } @@ -15,7 +16,6 @@ interface GeminiFilesProps { const GeminiFiles: FC<GeminiFilesProps> = ({ id }) => { const { provider } = useProvider(id) const [files, setFiles] = useState<FileMetadataResponse[]>([]) - const { t } = useTranslation() const [loading, setLoading] = useState(false) const fetchFiles = useCallback(async () => { @@ -23,51 +23,6 @@ const GeminiFiles: FC<GeminiFilesProps> = ({ id }) => { files && setFiles(files.filter((file) => file.state === 'ACTIVE')) }, [provider]) - const columns: ColumnsType<FileMetadataResponse> = [ - { - title: t('files.name'), - dataIndex: 'displayName', - key: 'displayName' - }, - { - title: t('files.type'), - dataIndex: 'mimeType', - key: 'mimeType' - }, - { - title: t('files.size'), - dataIndex: 'sizeBytes', - key: 'sizeBytes', - render: (size: string) => `${(parseInt(size) / 1024 / 1024).toFixed(2)} MB` - }, - { - title: t('files.created_at'), - dataIndex: 'createTime', - key: 'createTime', - render: (time: string) => new Date(time).toLocaleString() - }, - { - title: t('files.actions'), - dataIndex: 'actions', - key: 'actions', - align: 'center', - render: (_, record) => { - return ( - <DeleteOutlined - style={{ cursor: 'pointer', color: 'var(--color-error)' }} - onClick={() => { - setFiles(files.filter((file) => file.name !== record.name)) - window.api.gemini.deleteFile(provider.apiKey, record.name).catch((error) => { - console.error('Failed to delete file:', error) - setFiles((prev) => [...prev, record]) - }) - }} - /> - ) - } - } - ] - useEffect(() => { runAsyncFunction(async () => { try { @@ -86,13 +41,61 @@ const GeminiFiles: FC<GeminiFilesProps> = ({ id }) => { setFiles([]) }, [id]) + if (loading) { + return ( + <Container> + <LoadingWrapper> + <Spin /> + </LoadingWrapper> + </Container> + ) + } + return ( <Container> - <Table columns={columns} dataSource={files} rowKey="name" loading={loading} /> + <FileListContainer> + {files.map((file) => ( + <FileItem + key={file.name} + fileInfo={{ + name: file.displayName, + ext: `.${file.name.split('.').pop()}`, + extra: `${dayjs(file.createTime).format('MM-DD HH:mm')} · ${(parseInt(file.sizeBytes) / 1024 / 1024).toFixed(2)} MB`, + actions: ( + <DeleteOutlined + style={{ cursor: 'pointer', color: 'var(--color-error)' }} + onClick={() => { + setFiles(files.filter((f) => f.name !== file.name)) + window.api.gemini.deleteFile(provider.apiKey, file.name).catch((error) => { + console.error('Failed to delete file:', error) + setFiles((prev) => [...prev, file]) + }) + }} + /> + ) + }} + /> + ))} + </FileListContainer> </Container> ) } -const Container = styled.div`` +const Container = styled.div` + width: 100%; +` + +const FileListContainer = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +` + +const LoadingWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; +` export default GeminiFiles diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index eb96e1384c..536a04f27c 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -13,6 +13,7 @@ import TranslateButton from '@renderer/components/TranslateButton' import { isFunctionCallingModel, isGenerateImageModel, isVisionModel, isWebSearchModel } from '@renderer/config/models' import db from '@renderer/databases' import { useAssistant } from '@renderer/hooks/useAssistant' +import { useMCPServers } from '@renderer/hooks/useMCPServers' import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations' import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime' import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings' @@ -91,7 +92,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) = const [isTranslating, setIsTranslating] = useState(false) const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<KnowledgeBase[]>([]) const [mentionModels, setMentionModels] = useState<Model[]>([]) - const [enabledMCPs, setEnabledMCPs] = useState<MCPServer[]>([]) + const [enabledMCPs, setEnabledMCPs] = useState<MCPServer[]>(assistant.mcpServers || []) const [isMentionPopupOpen, setIsMentionPopupOpen] = useState(false) const [isDragging, setIsDragging] = useState(false) const [textareaHeight, setTextareaHeight] = useState<number>() @@ -101,6 +102,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) = const isVision = useMemo(() => isVisionModel(model), [model]) const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision]) const navigate = useNavigate() + const { activedMcpServers } = useMCPServers() const showKnowledgeIcon = useSidebarIconShow('knowledge') const showMCPToolsIcon = isFunctionCallingModel(model) @@ -145,6 +147,11 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) = } }, [textareaHeight]) + // Reset to assistant knowledge mcp servers + useEffect(() => { + setEnabledMCPs(assistant.mcpServers || []) + }, [assistant.mcpServers]) + const sendMessage = useCallback(async () => { if (inputEmpty || loading) { return @@ -174,8 +181,12 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) = userMessage.mentions = mentionModels } - if (enabledMCPs) { - userMessage.enabledMCPs = enabledMCPs + if (isFunctionCallingModel(model)) { + if (!isEmpty(assistant.mcpServers) && !isEmpty(activedMcpServers)) { + userMessage.enabledMCPs = activedMcpServers.filter((server) => + assistant.mcpServers?.some((s) => s.id === server.id) + ) + } } userMessage.usage = await estimateMessageUsage(userMessage) @@ -197,13 +208,14 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) = console.error('Failed to send message:', error) } }, [ + activedMcpServers, assistant, dispatch, - enabledMCPs, files, inputEmpty, loading, mentionModels, + model, resizeTextArea, selectedKnowledgeBases, text, @@ -323,8 +335,11 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) = await db.topics.add({ id: topic.id, messages: [] }) await addAssistantMessagesToTopic({ assistant, topic }) + // Clear previous state // Reset to assistant default model assistant.defaultModel && setModel(assistant.defaultModel) + // Reset to assistant knowledge mcp servers + setEnabledMCPs(assistant.mcpServers || []) addTopic(topic) setActiveTopic(topic) diff --git a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx index 8930d0c622..09aadf394e 100644 --- a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx @@ -19,6 +19,10 @@ const MCPToolsButton: FC<Props> = ({ enabledMCPs, toggelEnableMCP, ToolbarButton const menuRef = useRef<HTMLDivElement>(null) const { t } = useTranslation() + const availableMCPs = activedMcpServers.filter((server) => enabledMCPs.some((s) => s.id === server.id)) + + const buttonEnabled = availableMCPs.length > 0 + const truncateText = (text: string, maxLength: number = 50) => { if (!text || text.length <= maxLength) return text return text.substring(0, maxLength) + '...' @@ -102,7 +106,7 @@ const MCPToolsButton: FC<Props> = ({ enabledMCPs, toggelEnableMCP, ToolbarButton overlayClassName="mention-models-dropdown"> <Tooltip placement="top" title={t('settings.mcp.title')} arrow> <ToolbarButton type="text" ref={dropdownRef}> - <CodeOutlined style={{ color: enabledMCPs.length > 0 ? 'var(--color-primary)' : 'var(--color-icon)' }} /> + <CodeOutlined style={{ color: buttonEnabled ? 'var(--color-primary)' : 'var(--color-icon)' }} /> </ToolbarButton> </Tooltip> </Dropdown> diff --git a/src/renderer/src/pages/home/Markdown/Artifacts.tsx b/src/renderer/src/pages/home/Markdown/Artifacts.tsx index 09b8243dcd..746eb170c5 100644 --- a/src/renderer/src/pages/home/Markdown/Artifacts.tsx +++ b/src/renderer/src/pages/home/Markdown/Artifacts.tsx @@ -75,6 +75,7 @@ const Container = styled.div` display: flex; flex-direction: row; gap: 8px; + padding-bottom: 10px; ` export default Artifacts diff --git a/src/renderer/src/pages/home/Markdown/CodeBlock.tsx b/src/renderer/src/pages/home/Markdown/CodeBlock.tsx index 75ea868c7c..f27975cf5c 100644 --- a/src/renderer/src/pages/home/Markdown/CodeBlock.tsx +++ b/src/renderer/src/pages/home/Markdown/CodeBlock.tsx @@ -37,12 +37,17 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => { const showDownloadButton = ['csv', 'json', 'txt', 'md'].includes(language) + const shouldShowExpandButtonRef = useRef(false) + useEffect(() => { const loadHighlightedCode = async () => { const highlightedHtml = await codeToHtml(children, language) if (codeContentRef.current) { codeContentRef.current.innerHTML = highlightedHtml - setShouldShowExpandButton(codeContentRef.current.scrollHeight > 350) + const isShowExpandButton = codeContentRef.current.scrollHeight > 350 + if (shouldShowExpandButtonRef.current === isShowExpandButton) return + shouldShowExpandButtonRef.current = isShowExpandButton + setShouldShowExpandButton(shouldShowExpandButtonRef.current) } } loadHighlightedCode() @@ -98,12 +103,18 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => { )} <CodeLanguage>{'<' + language.toUpperCase() + '>'}</CodeLanguage> </div> - <HStack gap={12} alignItems="center"> + </CodeHeader> + <StickyWrapper> + <HStack + position="absolute" + gap={12} + alignItems="center" + style={{ bottom: '0.2rem', right: '1rem', height: '27px' }}> {showDownloadButton && <DownloadButton language={language} data={children} />} {codeWrappable && <UnwrapButton unwrapped={isUnwrapped} onClick={() => setIsUnwrapped(!isUnwrapped)} />} <CopyButton text={children} /> </HStack> - </CodeHeader> + </StickyWrapper> <CodeContent ref={codeContentRef} isShowLineNumbers={codeShowLineNumbers} @@ -211,7 +222,9 @@ const DownloadButton = ({ language, data }: { language: string; data: string }) ) } -const CodeBlockWrapper = styled.div`` +const CodeBlockWrapper = styled.div` + position: relative; +` const CodeContent = styled.div<{ isShowLineNumbers: boolean; isUnwrapped: boolean; isCodeWrappable: boolean }>` .shiki { @@ -376,4 +389,10 @@ const DownloadWrapper = styled.div` } ` +const StickyWrapper = styled.div` + position: sticky; + top: 28px; + z-index: 10; +` + export default memo(CodeBlock) diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx index 257b52c1be..25faabbc24 100644 --- a/src/renderer/src/pages/home/Markdown/Markdown.tsx +++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx @@ -7,7 +7,7 @@ import { useSettings } from '@renderer/hooks/useSettings' import type { Message } from '@renderer/types' import { escapeBrackets, removeSvgEmptyLines, withGeminiGrounding } from '@renderer/utils/formats' import { isEmpty } from 'lodash' -import { type FC, useCallback, useMemo } from 'react' +import { type FC, useMemo } from 'react' import { useTranslation } from 'react-i18next' import ReactMarkdown, { type Components } from 'react-markdown' import rehypeKatex from 'rehype-katex' @@ -37,6 +37,8 @@ interface Props { > } +const remarkPlugins = [remarkMath, remarkGfm, remarkCjkFriendly] +const disallowedElements = ['iframe'] const Markdown: FC<Props> = ({ message, citationsData }) => { const { t } = useTranslation() const { renderInputMessageAsMarkdown, mathEngine } = useSettings() @@ -55,7 +57,7 @@ const Markdown: FC<Props> = ({ message, citationsData }) => { return hasElements ? [rehypeRaw, rehypeMath] : [rehypeMath] }, [messageContent, rehypeMath]) - const components = useCallback(() => { + const components = useMemo(() => { const baseComponents = { a: (props: any) => { if (props.href && citationsData?.has(props.href)) { @@ -64,15 +66,12 @@ const Markdown: FC<Props> = ({ message, citationsData }) => { return <Link {...props} /> }, code: CodeBlock, - img: ImagePreview + img: ImagePreview, + pre: (props: any) => <pre style={{ overflow: 'visible' }} {...props} />, + style: MarkdownShadowDOMRenderer as any } as Partial<Components> - - if (messageContent.includes('<style>')) { - baseComponents.style = MarkdownShadowDOMRenderer as any - } - return baseComponents - }, [messageContent, citationsData]) + }, [citationsData]) if (message.role === 'user' && !renderInputMessageAsMarkdown) { return <p style={{ marginBottom: 5, whiteSpace: 'pre-wrap' }}>{messageContent}</p> @@ -81,10 +80,10 @@ const Markdown: FC<Props> = ({ message, citationsData }) => { return ( <ReactMarkdown rehypePlugins={rehypePlugins} - remarkPlugins={[remarkMath, remarkGfm, remarkCjkFriendly]} + remarkPlugins={remarkPlugins} className="markdown" - components={components()} - disallowedElements={['iframe']} + components={components} + disallowedElements={disallowedElements} remarkRehypeOptions={{ footnoteLabel: t('common.footnotes'), footnoteLabelTagName: 'h4', diff --git a/src/renderer/src/pages/home/Messages/ChatFlowHistory.tsx b/src/renderer/src/pages/home/Messages/ChatFlowHistory.tsx index 8157ed1d3a..8a397642ed 100644 --- a/src/renderer/src/pages/home/Messages/ChatFlowHistory.tsx +++ b/src/renderer/src/pages/home/Messages/ChatFlowHistory.tsx @@ -3,6 +3,7 @@ import '@xyflow/react/dist/style.css' import { RobotOutlined, UserOutlined } from '@ant-design/icons' import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' import { getModelLogo } from '@renderer/config/models' +import { useTheme } from '@renderer/context/ThemeProvider' import { useSettings } from '@renderer/hooks/useSettings' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { RootState } from '@renderer/store' @@ -190,6 +191,7 @@ const ChatFlowHistory: FC<ChatFlowHistoryProps> = ({ conversationId }) => { const [edges, setEdges, onEdgesChange] = useEdgesState<any>([]) const [loading, setLoading] = useState(true) const { userName } = useSettings() + const { theme } = useTheme() const topicId = conversationId @@ -478,7 +480,8 @@ const ChatFlowHistory: FC<ChatFlowHistoryProps> = ({ conversationId }) => { maxZoom: 1 }} proOptions={{ hideAttribution: true }} - className="react-flow-container"> + className="react-flow-container" + colorMode={theme === 'auto' ? 'system' : theme}> <Controls showInteractive={false} /> <MiniMap nodeStrokeWidth={3} diff --git a/src/renderer/src/pages/home/Messages/ChatNavigation.tsx b/src/renderer/src/pages/home/Messages/ChatNavigation.tsx index 68f074026f..14ec82c88d 100644 --- a/src/renderer/src/pages/home/Messages/ChatNavigation.tsx +++ b/src/renderer/src/pages/home/Messages/ChatNavigation.tsx @@ -1,4 +1,11 @@ -import { DownOutlined, HistoryOutlined, UpOutlined } from '@ant-design/icons' +import { + ArrowDownOutlined, + ArrowUpOutlined, + CloseOutlined, + HistoryOutlined, + VerticalAlignBottomOutlined, + VerticalAlignTopOutlined +} from '@ant-design/icons' import { useSettings } from '@renderer/hooks/useSettings' import { RootState } from '@renderer/store' import { selectCurrentTopicId } from '@renderer/store/messages' @@ -20,6 +27,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => { const [isNearButtons, setIsNearButtons] = useState(false) const [hideTimer, setHideTimer] = useState<NodeJS.Timeout | null>(null) const [showChatHistory, setShowChatHistory] = useState(false) + const [manuallyClosedUntil, setManuallyClosedUntil] = useState<number | null>(null) const currentTopicId = useSelector((state: RootState) => selectCurrentTopicId(state)) const lastMoveTime = useRef(0) const { topicPosition, showTopics } = useSettings() @@ -44,6 +52,10 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => { // Handle mouse entering button area const handleMouseEnter = useCallback(() => { + if (manuallyClosedUntil && Date.now() < manuallyClosedUntil) { + return + } + setIsNearButtons(true) setIsVisible(true) @@ -52,7 +64,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => { clearTimeout(hideTimer) setHideTimer(null) } - }, [hideTimer]) + }, [hideTimer, manuallyClosedUntil]) // Handle mouse leaving button area const handleMouseLeave = useCallback(() => { @@ -97,7 +109,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => { const scrollToTop = () => { const container = document.getElementById(containerId) - container && container.scrollTo({ top: 0, behavior: 'smooth' }) + container && container.scrollTo({ top: -container.scrollHeight, behavior: 'smooth' }) } const scrollToBottom = () => { @@ -148,6 +160,23 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => { return -1 } + // 修改 handleCloseChatNavigation 函数 + const handleCloseChatNavigation = () => { + setIsVisible(false) + // 设置手动关闭状态,1分钟内不响应鼠标靠近事件 + setManuallyClosedUntil(Date.now() + 60000) // 60000毫秒 = 1分钟 + } + + const handleScrollToTop = () => { + resetHideTimer() + scrollToTop() + } + + const handleScrollToBottom = () => { + resetHideTimer() + scrollToBottom() + } + const handleNextMessage = () => { resetHideTimer() const userMessages = findUserMessages() @@ -216,6 +245,11 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => { // Throttled mouse move handler to improve performance const handleMouseMove = (e: MouseEvent) => { + // 如果在手动关闭期间,不响应鼠标移动事件 + if (manuallyClosedUntil && Date.now() < manuallyClosedUntil) { + return + } + // Throttle mouse move to every 50ms for performance const now = Date.now() if (now - lastMoveTime.current < 50) return @@ -262,16 +296,43 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => { clearTimeout(hideTimer) } } - }, [containerId, hideTimer, resetHideTimer, isNearButtons, handleMouseEnter, handleMouseLeave, showRightTopics]) + }, [ + containerId, + hideTimer, + resetHideTimer, + isNearButtons, + handleMouseEnter, + handleMouseLeave, + showRightTopics, + manuallyClosedUntil + ]) return ( <> <NavigationContainer $isVisible={isVisible} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}> <ButtonGroup> + <Tooltip title={t('chat.navigation.close')} placement="left"> + <NavigationButton + type="text" + icon={<CloseOutlined />} + onClick={handleCloseChatNavigation} + aria-label={t('chat.navigation.close')} + /> + </Tooltip> + <Divider /> + <Tooltip title={t('chat.navigation.top')} placement="left"> + <NavigationButton + type="text" + icon={<VerticalAlignTopOutlined />} + onClick={handleScrollToTop} + aria-label={t('chat.navigation.top')} + /> + </Tooltip> + <Divider /> <Tooltip title={t('chat.navigation.prev')} placement="left"> <NavigationButton type="text" - icon={<UpOutlined />} + icon={<ArrowUpOutlined />} onClick={handlePrevMessage} aria-label={t('chat.navigation.prev')} /> @@ -280,12 +341,21 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => { <Tooltip title={t('chat.navigation.next')} placement="left"> <NavigationButton type="text" - icon={<DownOutlined />} + icon={<ArrowDownOutlined />} onClick={handleNextMessage} aria-label={t('chat.navigation.next')} /> </Tooltip> <Divider /> + <Tooltip title={t('chat.navigation.bottom')} placement="left"> + <NavigationButton + type="text" + icon={<VerticalAlignBottomOutlined />} + onClick={handleScrollToBottom} + aria-label={t('chat.navigation.bottom')} + /> + </Tooltip> + <Divider /> <Tooltip title={t('chat.navigation.history')} placement="left"> <NavigationButton type="text" diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index 818511cb48..9ad8705b3b 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -143,7 +143,7 @@ const MessageItem: FC<Props> = ({ <MessageHeader message={message} assistant={assistant} model={model} key={getModelUniqId(model)} /> <MessageContentContainer className="message-content-container" - style={{ fontFamily, fontSize, background: messageBackground }}> + style={{ fontFamily, fontSize, background: messageBackground, overflowY: 'visible' }}> <MessageErrorBoundary> <MessageContent message={message} model={model} /> </MessageErrorBoundary> diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx index 6719ef286c..e43a7aabeb 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx @@ -286,6 +286,7 @@ const GridContainer = styled.div<{ $count: number; $layout: MultiModelMessageSty grid-template-rows: auto; gap: 16px; `} + overflow-y: visible; ` interface MessageWrapperProps { @@ -327,14 +328,14 @@ const MessageWrapper = styled(Scrollbar)<MessageWrapperProps>` return $layout === 'grid' && $isGrouped ? css` max-height: ${$isInPopover ? '50vh' : '300px'}; - overflow-y: ${$isInPopover ? 'auto' : 'hidden'}; + overflow-y: ${$isInPopover ? 'visible' : 'hidden'}; border: 0.5px solid ${$isInPopover ? 'transparent' : 'var(--color-border)'}; padding: 10px; border-radius: 6px; background-color: var(--color-background); ` : css` - overflow-y: auto; + overflow-y: visible; border-radius: 6px; ` }} diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index 220a5becb5..f5fa740352 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -198,7 +198,7 @@ const MessageMenubar: FC<Props> = (props) => { key: 'image', onClick: async () => { const imageData = await captureScrollableDivAsDataURL(messageContainerRef) - const title = getMessageTitle(message) + const title = await getMessageTitle(message) if (title && imageData) { window.api.file.saveImage(title, imageData) } @@ -211,14 +211,15 @@ const MessageMenubar: FC<Props> = (props) => { key: 'word', onClick: async () => { const markdown = messageToMarkdown(message) - window.api.export.toWord(markdown, getMessageTitle(message)) + const title = await getMessageTitle(message) + window.api.export.toWord(markdown, title) } }, { label: t('chat.topics.export.notion'), key: 'notion', onClick: async () => { - const title = getMessageTitle(message) + const title = await getMessageTitle(message) const markdown = messageToMarkdown(message) exportMarkdownToNotion(title, markdown) } @@ -227,7 +228,7 @@ const MessageMenubar: FC<Props> = (props) => { label: t('chat.topics.export.yuque'), key: 'yuque', onClick: async () => { - const title = getMessageTitle(message) + const title = await getMessageTitle(message) const markdown = messageToMarkdown(message) exportMarkdownToYuque(title, markdown) } @@ -245,7 +246,7 @@ const MessageMenubar: FC<Props> = (props) => { label: t('chat.topics.export.joplin'), key: 'joplin', onClick: async () => { - const title = getMessageTitle(message) + const title = await getMessageTitle(message) const markdown = messageToMarkdown(message) exportMarkdownToJoplin(title, markdown) } @@ -254,7 +255,7 @@ const MessageMenubar: FC<Props> = (props) => { label: t('chat.topics.export.siyuan'), key: 'siyuan', onClick: async () => { - const title = getMessageTitle(message) + const title = await getMessageTitle(message) const markdown = messageToMarkdown(message) exportMarkdownToSiyuan(title, markdown) } diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index d2d2f94ca0..1e921a18b9 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -256,7 +256,8 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic }) hasMore={hasMore} loader={null} scrollableTarget="messages" - inverse> + inverse + style={{ overflow: 'visible' }}> <ScrollContainer> <LoaderContainer $loading={isLoadingMore}> <BeatLoader size={8} color="var(--color-text-2)" /> diff --git a/src/renderer/src/pages/home/Tabs/AssistantItem.tsx b/src/renderer/src/pages/home/Tabs/AssistantItem.tsx index 6c3169e821..3aae32ec45 100644 --- a/src/renderer/src/pages/home/Tabs/AssistantItem.tsx +++ b/src/renderer/src/pages/home/Tabs/AssistantItem.tsx @@ -13,7 +13,7 @@ import { hasTopicPendingRequests } from '@renderer/utils/queue' import { Dropdown } from 'antd' import { ItemType } from 'antd/es/menu/interface' import { omit } from 'lodash' -import { FC, useCallback, useEffect, useState } from 'react' +import { FC, startTransition, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -117,11 +117,16 @@ const AssistantItem: FC<AssistantItemProps> = ({ assistant, isActive, onSwitch, const handleSwitch = useCallback(async () => { await modelGenerating() - if (topicPosition === 'left' && clickAssistantToShowTopic) { - EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR) + if (clickAssistantToShowTopic) { + if (topicPosition === 'left') { + EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR) + } + onSwitch(assistant) + } else { + startTransition(() => { + onSwitch(assistant) + }) } - - onSwitch(assistant) }, [clickAssistantToShowTopic, onSwitch, assistant, topicPosition]) const assistantName = assistant.name || t('chat.default.name') diff --git a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx index b638af15c2..d92b0d4509 100644 --- a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx @@ -37,7 +37,7 @@ import { hasTopicPendingRequests } from '@renderer/utils/queue' import { Dropdown, MenuProps, Tooltip } from 'antd' import dayjs from 'dayjs' import { findIndex } from 'lodash' -import { FC, useCallback, useMemo, useRef, useState } from 'react' +import { FC, startTransition, useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -146,7 +146,9 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic const onSwitchTopic = useCallback( async (topic: Topic) => { // await modelGenerating() - setActiveTopic(topic) + startTransition(() => { + setActiveTopic(topic) + }) }, [setActiveTopic] ) diff --git a/src/renderer/src/pages/knowledge/KnowledgeContent.tsx b/src/renderer/src/pages/knowledge/KnowledgeContent.tsx index ada10179b7..2b58243291 100644 --- a/src/renderer/src/pages/knowledge/KnowledgeContent.tsx +++ b/src/renderer/src/pages/knowledge/KnowledgeContent.tsx @@ -2,16 +2,13 @@ import { CopyOutlined, DeleteOutlined, EditOutlined, - FileTextOutlined, - FolderOutlined, - GlobalOutlined, - LinkOutlined, PlusOutlined, RedoOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons' import Ellipsis from '@renderer/components/Ellipsis' +import { HStack } from '@renderer/components/Layout' import PromptPopup from '@renderer/components/Popups/PromptPopup' import TextEditPopup from '@renderer/components/Popups/TextEditPopup' import Scrollbar from '@renderer/components/Scrollbar' @@ -19,24 +16,29 @@ import { useKnowledge } from '@renderer/hooks/useKnowledge' import FileManager from '@renderer/services/FileManager' import { getProviderName } from '@renderer/services/ProviderService' import { FileType, FileTypes, KnowledgeBase, KnowledgeItem } from '@renderer/types' +import { formatFileSize } from '@renderer/utils' import { bookExts, documentExts, textExts, thirdPartyApplicationExts } from '@shared/config/constant' -import { Alert, Button, Card, Divider, Dropdown, message, Tag, Tooltip, Typography, Upload } from 'antd' +import { Alert, Button, Dropdown, Empty, message, Tag, Tooltip, Upload } from 'antd' +import dayjs from 'dayjs' +import VirtualList from 'rc-virtual-list' import { FC } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' +import CustomCollapse from '../../components/CustomCollapse' +import FileItem from '../files/FileItem' import KnowledgeSearchPopup from './components/KnowledgeSearchPopup' import KnowledgeSettingsPopup from './components/KnowledgeSettingsPopup' import StatusIcon from './components/StatusIcon' const { Dragger } = Upload -const { Title } = Typography interface KnowledgeContentProps { selectedBase: KnowledgeBase } const fileTypes = [...bookExts, ...thirdPartyApplicationExts, ...documentExts, ...textExts] + const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => { const { t } = useTranslation() @@ -234,13 +236,21 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => { {!providerName && ( <Alert message={t('knowledge.no_provider')} type="error" style={{ marginBottom: 20 }} showIcon /> )} - <FileSection> - <TitleWrapper> - <Title level={5}>{t('files.title')} - - + }> handleDrop([file as File])} @@ -252,86 +262,137 @@ const KnowledgeContent: FC = ({ selectedBase }) => { {t('knowledge.file_hint', { file_types: 'TXT, MD, HTML, PDF, DOCX, PPTX, XLSX, EPUB...' })}

- - - {fileItems.reverse().map((item) => { - const file = item.content as FileType - return ( - - - - - window.api.file.openPath(file.path)}> - - {file.origin_name} - - - - - {item.uniqueId && - + }> + {directoryItems.length === 0 && } {directoryItems.reverse().map((item) => ( - - - - + window.api.file.openPath(item.content as string)}> {item.content as string} - - - {item.uniqueId && - + }> + {urlItems.length === 0 && } {urlItems.reverse().map((item) => ( - - - - + = ({ selectedBase }) => { - - - {item.uniqueId && - + }> + {sitemapItems.length === 0 && } {sitemapItems.reverse().map((item) => ( - - - - + @@ -399,53 +472,71 @@ const KnowledgeContent: FC = ({ selectedBase }) => { - - - {item.uniqueId && - + }> + {noteItems.length === 0 && } {noteItems.reverse().map((note) => ( - - - handleEditNote(note)} style={{ cursor: 'pointer' }}> - {(note.content as string).slice(0, 50)}... - - -