From a7d9700f06fc68ff1ded2f79df75901e5264b069 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Thu, 2 Jan 2025 09:21:34 +0800 Subject: [PATCH] feat: add mini window --- electron.vite.config.ts | 2 +- package.json | 3 + resources/textMonitor.swift | 117 +++++ src/main/ipc.ts | 8 + src/main/services/ClipboardMonitor.ts | 118 +++++ src/main/services/ConfigManager.ts | 18 +- src/main/services/ShortcutService.ts | 18 +- src/main/services/TrayService.ts | 15 +- src/main/services/WindowService.ts | 158 ++++++- src/main/utils/index.ts | 12 + src/preload/index.d.ts | 7 + src/preload/index.ts | 7 + src/renderer/index.html | 3 +- src/renderer/src/assets/styles/index.scss | 2 + src/renderer/src/context/ThemeProvider.tsx | 10 +- src/renderer/src/hooks/useUpdateHandler.ts | 3 + src/renderer/src/i18n/locales/en-us.json | 36 +- src/renderer/src/i18n/locales/ja-jp.json | 36 +- src/renderer/src/i18n/locales/ru-ru.json | 36 +- src/renderer/src/i18n/locales/zh-cn.json | 36 +- src/renderer/src/i18n/locales/zh-tw.json | 36 +- src/renderer/src/init.ts | 8 + src/renderer/src/main.tsx | 9 +- .../src/pages/home/Messages/Message.tsx | 1 - .../settings/DataSettings/WebDavSettings.tsx | 2 +- .../ModalSettings/TopicNamingModalPopup.tsx | 4 +- .../pages/settings/QuickAssistantSettings.tsx | 54 +++ .../src/pages/settings/SettingsPage.tsx | 9 + .../src/providers/AnthropicProvider.ts | 2 +- src/renderer/src/providers/GeminiProvider.ts | 2 +- src/renderer/src/providers/OpenAIProvider.ts | 2 +- src/renderer/src/services/ApiService.ts | 3 +- src/renderer/src/services/BackupService.ts | 2 - src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/llm.ts | 1 + src/renderer/src/store/migrate.ts | 10 + src/renderer/src/store/settings.ts | 10 +- src/renderer/src/store/shortcuts.ts | 7 + src/renderer/src/windows/menu/menu.html | 146 ++++++ src/renderer/src/windows/mini/App.tsx | 29 ++ .../src/windows/mini/chat/ChatWindow.tsx | 34 ++ .../src/windows/mini/chat/Inputbar.tsx | 443 ++++++++++++++++++ .../src/windows/mini/chat/Message.tsx | 122 +++++ .../src/windows/mini/chat/Messages.tsx | 73 +++ .../src/windows/mini/home/HomeWindow.tsx | 203 ++++++++ .../src/windows/mini/home/Translate.tsx | 176 +++++++ .../mini/home/components/ClipboardPreview.tsx | 62 +++ .../mini/home/components/FeatureMenus.tsx | 108 +++++ .../windows/mini/home/components/Footer.tsx | 37 ++ .../windows/mini/home/components/InputBar.tsx | 47 ++ yarn.lock | 120 +++++ 51 files changed, 2367 insertions(+), 42 deletions(-) create mode 100644 resources/textMonitor.swift create mode 100644 src/main/services/ClipboardMonitor.ts create mode 100644 src/renderer/src/pages/settings/QuickAssistantSettings.tsx create mode 100644 src/renderer/src/windows/menu/menu.html create mode 100644 src/renderer/src/windows/mini/App.tsx create mode 100644 src/renderer/src/windows/mini/chat/ChatWindow.tsx create mode 100644 src/renderer/src/windows/mini/chat/Inputbar.tsx create mode 100644 src/renderer/src/windows/mini/chat/Message.tsx create mode 100644 src/renderer/src/windows/mini/chat/Messages.tsx create mode 100644 src/renderer/src/windows/mini/home/HomeWindow.tsx create mode 100644 src/renderer/src/windows/mini/home/Translate.tsx create mode 100644 src/renderer/src/windows/mini/home/components/ClipboardPreview.tsx create mode 100644 src/renderer/src/windows/mini/home/components/FeatureMenus.tsx create mode 100644 src/renderer/src/windows/mini/home/components/Footer.tsx create mode 100644 src/renderer/src/windows/mini/home/components/InputBar.tsx diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 21d8525c..4e0c3726 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -50,7 +50,7 @@ export default defineConfig({ } }, optimizeDeps: { - exclude: ['chunk-QH6N6I7P.js', 'chunk-PB73W2YU.js', 'chunk-AFE5XGNG.js', 'chunk-QIJABHCK.js'] + exclude: ['chunk-RK3FTE5R.js'] } } }) diff --git a/package.json b/package.json index 6d574f29..cc325569 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "@kangfenmao/keyv-storage": "^0.1.0", "@reduxjs/toolkit": "^2.2.5", "@types/adm-zip": "^0", + "@types/ffi-napi": "^4", "@types/fs-extra": "^11", "@types/lodash": "^4.17.5", "@types/markdown-it": "^14", @@ -92,9 +93,11 @@ "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", "@types/react-infinite-scroll-component": "^5.0.0", + "@types/ref-napi": "^3", "@types/tinycolor2": "^1", "@vitejs/plugin-react": "^4.2.1", "antd": "^5.22.5", + "applescript": "^1.0.0", "axios": "^1.7.3", "browser-image-compression": "^2.0.2", "dayjs": "^1.11.11", diff --git a/resources/textMonitor.swift b/resources/textMonitor.swift new file mode 100644 index 00000000..3c8b97bd --- /dev/null +++ b/resources/textMonitor.swift @@ -0,0 +1,117 @@ +import Cocoa +import Foundation + +class TextSelectionObserver: NSObject { + let workspace = NSWorkspace.shared + var lastSelectedText: String? + + override init() { + super.init() + + // 注册通知观察者 + let observer = NSWorkspace.shared.notificationCenter + observer.addObserver( + self, + selector: #selector(handleSelectionChange), + name: NSWorkspace.didActivateApplicationNotification, + object: nil + ) + + // 监听选择变化通知 + var axObserver: AXObserver? + let error = AXObserverCreate(getpid(), { observer, element, notification, userData in + let selfPointer = userData!.load(as: TextSelectionObserver.self) + selfPointer.checkSelectedText() + }, &axObserver) + + if error == .success, let axObserver = axObserver { + CFRunLoopAddSource( + RunLoop.main.getCFRunLoop(), + AXObserverGetRunLoopSource(axObserver), + .defaultMode + ) + + // 当前活动应用添加监听 + updateActiveAppObserver(axObserver) + } + } + + @objc func handleSelectionChange(_ notification: Notification) { + // 应用切换时更新监听 + var axObserver: AXObserver? + let error = AXObserverCreate(getpid(), { _, _, _, _ in }, &axObserver) + if error == .success, let axObserver = axObserver { + updateActiveAppObserver(axObserver) + } + } + + func updateActiveAppObserver(_ axObserver: AXObserver) { + guard let app = workspace.frontmostApplication else { return } + let pid = app.processIdentifier + let element = AXUIElementCreateApplication(pid) + + // 添加选择变化通知监听 + AXObserverAddNotification( + axObserver, + element, + kAXSelectedTextChangedNotification as CFString, + UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + ) + } + + func checkSelectedText() { + if let text = getSelectedText() { + if text.count > 0 && text != lastSelectedText { + print(text) + fflush(stdout) + lastSelectedText = text + } + } + } + + func getSelectedText() -> String? { + guard let app = NSWorkspace.shared.frontmostApplication else { return nil } + let pid = app.processIdentifier + + let axApp = AXUIElementCreateApplication(pid) + var focusedElement: AnyObject? + + // Get focused element + let result = AXUIElementCopyAttributeValue(axApp, kAXFocusedUIElementAttribute as CFString, &focusedElement) + guard result == .success else { return nil } + + // Try different approaches to get selected text + var selectedText: AnyObject? + + // First try: Direct selected text + var textResult = AXUIElementCopyAttributeValue(focusedElement as! AXUIElement, kAXSelectedTextAttribute as CFString, &selectedText) + + // Second try: Selected text in text area + if textResult != .success { + var selectedTextRange: AnyObject? + textResult = AXUIElementCopyAttributeValue(focusedElement as! AXUIElement, kAXSelectedTextRangeAttribute as CFString, &selectedTextRange) + if textResult == .success { + textResult = AXUIElementCopyAttributeValue(focusedElement as! AXUIElement, kAXValueAttribute as CFString, &selectedText) + } + } + + // Third try: Get selected text from parent element + if textResult != .success { + var parent: AnyObject? + if AXUIElementCopyAttributeValue(focusedElement as! AXUIElement, kAXParentAttribute as CFString, &parent) == .success { + textResult = AXUIElementCopyAttributeValue(parent as! AXUIElement, kAXSelectedTextAttribute as CFString, &selectedText) + } + } + + guard textResult == .success, let text = selectedText as? String else { return nil } + return text + } +} + +let observer = TextSelectionObserver() + +signal(SIGINT) { _ in + exit(0) +} + +RunLoop.main.run() \ No newline at end of file diff --git a/src/main/ipc.ts b/src/main/ipc.ts index a2089751..148ec97f 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -52,6 +52,14 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { configManager.setTray(isActive) }) + ipcMain.handle('config:set', (_, key: string, value: any) => { + configManager.set(key, value) + }) + + ipcMain.handle('config:get', (_, key: string) => { + return configManager.get(key) + }) + // theme ipcMain.handle('app:set-theme', (_, theme: ThemeMode) => { configManager.setTheme(theme) diff --git a/src/main/services/ClipboardMonitor.ts b/src/main/services/ClipboardMonitor.ts new file mode 100644 index 00000000..0de9e780 --- /dev/null +++ b/src/main/services/ClipboardMonitor.ts @@ -0,0 +1,118 @@ +import { debounce, getResourcePath } from '@main/utils' +import { exec } from 'child_process' +import { screen } from 'electron' +import path from 'path' + +import { windowService } from './WindowService' + +export default class ClipboardMonitor { + private platform: string + private lastText: string + private user32: any + private observer: any + public onTextSelected: (text: string) => void + + constructor() { + this.platform = process.platform + this.lastText = '' + this.onTextSelected = debounce((text: string) => this.handleTextSelected(text), 550) + + if (this.platform === 'win32') { + this.setupWindows() + } else if (this.platform === 'darwin') { + this.setupMacOS() + } + } + + setupMacOS() { + // 使用 Swift 脚本来监听文本选择 + const scriptPath = path.join(getResourcePath(), 'textMonitor.swift') + + // 启动 Swift 进程来监听文本选择 + const process = exec(`swift ${scriptPath}`) + + process?.stdout?.on('data', (data: string) => { + console.log('[ClipboardMonitor] MacOS data:', data) + const text = data.toString().trim() + if (text && text !== this.lastText) { + this.lastText = text + this.onTextSelected(text) + } + }) + + process.on('error', (error) => { + console.error('[ClipboardMonitor] MacOS error:', error) + }) + } + + setupWindows() { + // 使用 Windows API 监听文本选择事件 + const ffi = require('ffi-napi') + const ref = require('ref-napi') + + this.user32 = new ffi.Library('user32', { + SetWinEventHook: ['pointer', ['uint32', 'uint32', 'pointer', 'pointer', 'uint32', 'uint32', 'uint32']], + UnhookWinEvent: ['bool', ['pointer']] + }) + + // 定义事件常量 + const EVENT_OBJECT_SELECTION = 0x8006 + const WINEVENT_OUTOFCONTEXT = 0x0000 + const WINEVENT_SKIPOWNTHREAD = 0x0001 + const WINEVENT_SKIPOWNPROCESS = 0x0002 + + // 创建回调函数 + const callback = ffi.Callback('void', ['pointer', 'uint32', 'pointer', 'long', 'long', 'uint32', 'uint32'], () => { + this.getSelectedText() + }) + + // 设置事件钩子 + this.observer = this.user32.SetWinEventHook( + EVENT_OBJECT_SELECTION, + EVENT_OBJECT_SELECTION, + ref.NULL, + callback, + 0, + 0, + WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNTHREAD | WINEVENT_SKIPOWNPROCESS + ) + } + + getSelectedText() { + // Get selected text + if (this.platform === 'win32') { + const ref = require('ref-napi') + if (this.user32.OpenClipboard(ref.NULL)) { + // Get clipboard content + const text = this.user32.GetClipboardData(1) // CF_TEXT = 1 + this.user32.CloseClipboard() + + if (text && text !== this.lastText) { + this.lastText = text + this.onTextSelected(text) + } + } + } + } + + private handleTextSelected(text: string) { + if (!text) return + + console.debug('[ClipboardMonitor] handleTextSelected', text) + + windowService.setLastSelectedText(text) + + const mousePosition = screen.getCursorScreenPoint() + + windowService.showSelectionMenu({ + x: mousePosition.x, + y: mousePosition.y + 10 + }) + } + + dispose() { + if (this.platform === 'win32' && this.observer) { + this.user32.UnhookWinEvent(this.observer) + } + } +} diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index 51181e01..aa93f124 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -30,7 +30,7 @@ export class ConfigManager { this.store.set('theme', theme) } - isTray(): boolean { + getTray(): boolean { return !!this.store.get('tray', true) } @@ -83,6 +83,22 @@ export class ConfigManager { ) this.notifySubscribers('shortcuts', shortcuts) } + + getClickTrayToShowQuickAssistant(): boolean { + return this.store.get('clickTrayToShowQuickAssistant', false) as boolean + } + + setClickTrayToShowQuickAssistant(value: boolean) { + this.store.set('clickTrayToShowQuickAssistant', value) + } + + set(key: string, value: any) { + this.store.set(key, value) + } + + get(key: string) { + return this.store.get(key) + } } export const configManager = new ConfigManager() diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 984816a9..9a57254b 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -3,8 +3,10 @@ import { BrowserWindow, globalShortcut } from 'electron' import Logger from 'electron-log' import { configManager } from './ConfigManager' +import { windowService } from './WindowService' let showAppAccelerator: string | null = null +let showMiniWindowAccelerator: string | null = null function getShortcutHandler(shortcut: Shortcut) { switch (shortcut.key) { @@ -26,6 +28,10 @@ function getShortcutHandler(shortcut: Shortcut) { window.focus() } } + case 'mini_window': + return () => { + windowService.toggleMiniWindow() + } default: return null } @@ -73,6 +79,10 @@ export function registerShortcuts(window: BrowserWindow) { showAppAccelerator = accelerator } + if (shortcut.key === 'mini_window') { + showMiniWindowAccelerator = accelerator + } + if (shortcut.key.includes('zoom')) { switch (shortcut.key) { case 'zoom_in': @@ -90,7 +100,7 @@ export function registerShortcuts(window: BrowserWindow) { } if (shortcut.enabled) { - globalShortcut.register(accelerator, () => handler(window)) + globalShortcut.register(formatShortcutKey(shortcut.shortcut), () => handler(window)) } } catch (error) { Logger.error(`[ShortcutService] Failed to register shortcut ${shortcut.key}`) @@ -108,6 +118,11 @@ export function registerShortcuts(window: BrowserWindow) { const handler = getShortcutHandler({ key: 'show_app' } as Shortcut) handler && globalShortcut.register(showAppAccelerator, () => handler(window)) } + + if (showMiniWindowAccelerator) { + const handler = getShortcutHandler({ key: 'mini_window' } as Shortcut) + handler && globalShortcut.register(showMiniWindowAccelerator, () => handler(window)) + } } catch (error) { Logger.error('[ShortcutService] Failed to unregister shortcuts') } @@ -124,6 +139,7 @@ export function registerShortcuts(window: BrowserWindow) { export function unregisterAllShortcuts() { try { showAppAccelerator = null + showMiniWindowAccelerator = null globalShortcut.unregisterAll() } catch (error) { Logger.error('[ShortcutService] Failed to unregister all shortcuts') diff --git a/src/main/services/TrayService.ts b/src/main/services/TrayService.ts index 9ffcdea0..9c16f147 100644 --- a/src/main/services/TrayService.ts +++ b/src/main/services/TrayService.ts @@ -17,6 +17,8 @@ export class TrayService { } private createTray() { + this.destroyTray() + const iconPath = isMac ? (nativeTheme.shouldUseDarkColors ? iconLight : iconDark) : icon const tray = new Tray(iconPath) @@ -43,6 +45,10 @@ export class TrayService { label: trayLocale.show_window, click: () => windowService.showMainWindow() }, + { + label: trayLocale.show_mini_window, + click: () => windowService.showMiniWindow() + }, { type: 'separator' }, { label: trayLocale.quit, @@ -61,12 +67,17 @@ export class TrayService { }) this.tray.on('click', () => { - windowService.showMainWindow() + if (configManager.getClickTrayToShowQuickAssistant()) { + windowService.showMiniWindow() + } else { + windowService.showMainWindow() + } }) } private updateTray() { - if (configManager.isTray()) { + const showTray = configManager.getTray() + if (showTray) { this.createTray() } else { this.destroyTray() diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 7a427658..5b155214 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -1,6 +1,6 @@ import { is } from '@electron-toolkit/utils' import { isLinux, isWin } from '@main/constant' -import { app, BrowserWindow, Menu, MenuItem, shell } from 'electron' +import { app, BrowserWindow, ipcMain, Menu, MenuItem, shell } from 'electron' import Logger from 'electron-log' import windowStateKeeper from 'electron-window-state' import path, { join } from 'path' @@ -13,8 +13,11 @@ import { configManager } from './ConfigManager' export class WindowService { private static instance: WindowService | null = null private mainWindow: BrowserWindow | null = null + private miniWindow: BrowserWindow | null = null private isQuitting: boolean = false private wasFullScreen: boolean = false + private selectionMenuWindow: BrowserWindow | null = null + private lastSelectedText: string = '' public static getInstance(): WindowService { if (!WindowService.instance) { @@ -63,6 +66,9 @@ export class WindowService { }) this.setupMainWindow(this.mainWindow, mainWindowState) + + setTimeout(() => this.showMiniWindow(), 5000) + return this.mainWindow } @@ -201,7 +207,7 @@ export class WindowService { }) mainWindow.on('close', (event) => { - const notInTray = !configManager.isTray() + const notInTray = !configManager.getTray() // Windows and Linux if ((isWin || isLinux) && notInTray) { @@ -233,6 +239,154 @@ export class WindowService { this.createMainWindow() } } + + public showMiniWindow() { + if (this.selectionMenuWindow) { + this.selectionMenuWindow.hide() + } + + if (this.miniWindow && !this.miniWindow.isDestroyed()) { + if (this.miniWindow.isMinimized()) { + this.miniWindow.restore() + } + this.miniWindow.show() + this.miniWindow.center() + this.miniWindow.focus() + return + } + + const isMac = process.platform === 'darwin' + + this.miniWindow = new BrowserWindow({ + width: 500, + height: 520, + show: false, + autoHideMenuBar: true, + transparent: isMac, + vibrancy: 'under-window', + visualEffectState: 'followWindow', + center: true, + frame: false, + alwaysOnTop: true, + resizable: false, + useContentSize: true, + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + sandbox: false, + webSecurity: false, + webviewTag: true + } + }) + + this.miniWindow.on('blur', () => { + this.miniWindow?.hide() + }) + + this.miniWindow.on('close', (event) => { + if (this.isQuitting) { + return + } + event.preventDefault() + this.miniWindow?.hide() + }) + + this.miniWindow.on('closed', () => { + this.miniWindow = null + }) + + this.miniWindow.on('hide', () => { + this.miniWindow?.webContents.send('hide-mini-window') + }) + + this.miniWindow.on('show', () => { + this.miniWindow?.webContents.send('show-mini-window') + }) + + ipcMain.on('miniwindow-reload', () => { + this.miniWindow?.reload() + }) + + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { + this.miniWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '#/mini') + } else { + this.miniWindow.loadFile(join(__dirname, '../renderer/index.html') + '#/mini') + } + } + + public toggleMiniWindow() { + if (this.miniWindow) { + this.miniWindow.isVisible() ? this.miniWindow.hide() : this.miniWindow.show() + } + } + + public showSelectionMenu(bounds: { x: number; y: number }) { + if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) { + this.selectionMenuWindow.setPosition(bounds.x, bounds.y) + this.selectionMenuWindow.show() + return + } + + const theme = configManager.getTheme() + const isMac = process.platform === 'darwin' + + this.selectionMenuWindow = new BrowserWindow({ + width: 280, + height: 40, + x: bounds.x, + y: bounds.y, + show: true, + autoHideMenuBar: true, + transparent: true, + frame: false, + alwaysOnTop: false, + skipTaskbar: true, + backgroundColor: isMac ? undefined : theme === 'dark' ? '#181818' : '#FFFFFF', + resizable: false, + vibrancy: 'popover', + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + sandbox: false, + webSecurity: false + } + }) + + // 点击其他地方时隐藏窗口 + this.selectionMenuWindow.on('blur', () => { + this.selectionMenuWindow?.hide() + this.miniWindow?.webContents.send('selection-action', { + action: 'home', + selectedText: this.lastSelectedText + }) + }) + + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { + this.selectionMenuWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/src/windows/menu/menu.html') + } else { + this.selectionMenuWindow.loadFile(join(__dirname, '../renderer/src/windows/menu/menu.html')) + } + + this.setupSelectionMenuEvents() + } + + private setupSelectionMenuEvents() { + if (!this.selectionMenuWindow) return + + ipcMain.removeHandler('selection-menu:action') + ipcMain.handle('selection-menu:action', (_, action) => { + this.selectionMenuWindow?.hide() + this.showMiniWindow() + setTimeout(() => { + this.miniWindow?.webContents.send('selection-action', { + action, + selectedText: this.lastSelectedText + }) + }, 100) + }) + } + + public setLastSelectedText(text: string) { + this.lastSelectedText = text + } } export const windowService = WindowService.getInstance() diff --git a/src/main/utils/index.ts b/src/main/utils/index.ts index 5abef025..3fc44e2d 100644 --- a/src/main/utils/index.ts +++ b/src/main/utils/index.ts @@ -22,3 +22,15 @@ export function getInstanceName(baseURL: string) { return '' } } + +export function debounce(func: (...args: any[]) => void, wait: number, immediate: boolean = false) { + let timeout: NodeJS.Timeout | null = null + return function (...args: any[]) { + if (timeout) clearTimeout(timeout) + if (immediate) { + func(...args) + } else { + timeout = setTimeout(() => func(...args), wait) + } + } +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 274402a9..032f583d 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -89,6 +89,13 @@ declare global { listFiles: (apiKey: string) => Promise deleteFile: (apiKey: string, fileId: string) => Promise } + selectionMenu: { + action: (action: string) => Promise + } + config: { + set: (key: string, value: any) => Promise + get: (key: string) => Promise + } } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 25fa8c1f..593b21cb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -82,6 +82,13 @@ const api = { retrieveFile: (file: FileType, apiKey: string) => ipcRenderer.invoke('gemini:retrieve-file', file, apiKey), listFiles: (apiKey: string) => ipcRenderer.invoke('gemini:list-files', apiKey), deleteFile: (apiKey: string, fileId: string) => ipcRenderer.invoke('gemini:delete-file', apiKey, fileId) + }, + selectionMenu: { + action: (action: string) => ipcRenderer.invoke('selection-menu:action', action) + }, + config: { + set: (key: string, value: any) => ipcRenderer.invoke('config:set', key, value), + get: (key: string) => ipcRenderer.invoke('config:get', key) } } diff --git a/src/renderer/index.html b/src/renderer/index.html index c298b5d4..ed825050 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -17,10 +17,10 @@ position: fixed; width: 100vw; height: 100vh; - display: flex; flex-direction: row; justify-content: center; align-items: center; + display: none; } #spinner img { @@ -35,6 +35,7 @@
+ diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index 5acb9898..3e27beed 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -24,6 +24,7 @@ --color-background: var(--color-black); --color-background-soft: var(--color-black-soft); --color-background-mute: var(--color-black-mute); + --color-background-opacity: rgba(34, 34, 34, 0.7); --color-primary: #00b96b; --color-primary-soft: #00b96b99; @@ -87,6 +88,7 @@ body[theme-mode='light'] { --color-background: var(--color-white); --color-background-soft: var(--color-white-soft); --color-background-mute: var(--color-white-mute); + --color-background-opacity: rgba(255, 255, 255, 0.7); --color-primary: #00b96b; --color-primary-soft: #00b96b99; diff --git a/src/renderer/src/context/ThemeProvider.tsx b/src/renderer/src/context/ThemeProvider.tsx index 068fb13d..f551d04a 100644 --- a/src/renderer/src/context/ThemeProvider.tsx +++ b/src/renderer/src/context/ThemeProvider.tsx @@ -13,7 +13,11 @@ const ThemeContext = createContext({ toggleTheme: () => {} }) -export const ThemeProvider: React.FC = ({ children }) => { +interface ThemeProviderProps extends PropsWithChildren { + defaultTheme?: ThemeMode +} + +export const ThemeProvider: React.FC = ({ children, defaultTheme }) => { const { theme, setTheme } = useSettings() const [_theme, _setTheme] = useState(theme) @@ -22,7 +26,7 @@ export const ThemeProvider: React.FC = ({ children }) => { } useEffect((): any => { - if (theme === ThemeMode.auto) { + if (theme === ThemeMode.auto || defaultTheme === ThemeMode.auto) { const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') _setTheme(mediaQuery.matches ? ThemeMode.dark : ThemeMode.light) const handleChange = (e: MediaQueryListEvent) => _setTheme(e.matches ? ThemeMode.dark : ThemeMode.light) @@ -31,7 +35,7 @@ export const ThemeProvider: React.FC = ({ children }) => { } else { _setTheme(theme) } - }, [theme]) + }, [defaultTheme, theme]) useEffect(() => { document.body.setAttribute('theme-mode', _theme) diff --git a/src/renderer/src/hooks/useUpdateHandler.ts b/src/renderer/src/hooks/useUpdateHandler.ts index fd85b862..bc47001a 100644 --- a/src/renderer/src/hooks/useUpdateHandler.ts +++ b/src/renderer/src/hooks/useUpdateHandler.ts @@ -9,7 +9,10 @@ export default function useUpdateHandler() { const { t } = useTranslation() useEffect(() => { + if (!window.electron) return + const ipcRenderer = window.electron.ipcRenderer + const removers = [ ipcRenderer.on('update-not-available', () => { dispatch(setUpdateState({ checking: false })) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 8b7e31d8..fd8c9e0c 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -385,6 +385,10 @@ "webdav.syncError": "Backup Error", "webdav.lastSync": "Last Backup" }, + "quickAssistant": { + "title": "Quick Assistant", + "click_tray_to_show": "Click the system tray icon to open" + }, "display.title": "Display Settings", "font_size.title": "Message font size", "general": "General Settings", @@ -519,7 +523,8 @@ "toggle_show_assistants": "Toggle Assistants", "toggle_show_topics": "Toggle Topics", "copy_last_message": "Copy Last Message", - "search_message": "Search Message" + "search_message": "Search Message", + "mini_window": "Quick Assistant" }, "theme.auto": "Auto", "theme.dark": "Dark", @@ -552,7 +557,8 @@ }, "tray": { "quit": "Quit", - "show_window": "Show Window" + "show_window": "Show Window", + "show_mini_window": "Quick Assistant" }, "words": { "knowledgeGraph": "Knowledge Graph", @@ -634,7 +640,31 @@ } }, "prompts": { - "summarize": "You are an assistant who is good at conversation. You need to summarize the user's conversation into a title of 10 characters or less, ensuring it matches the user's primary language without using punctuation or other special symbols." + "title": "You are an assistant who is good at conversation. You need to summarize the user's conversation into a title of 10 characters or less, ensuring it matches the user's primary language without using punctuation or other special symbols.", + "explanation": "Explain this concept to me", + "summarize": "Summarize this text" + }, + "miniwindow": { + "feature": { + "chat": "Answer this question", + "translate": "Text translation", + "summary": "Content summary", + "explanation": "Explanation" + }, + "clipboard": { + "empty": "Clipboard is empty" + }, + "input": { + "placeholder": { + "title": "What do you want to do with this text?", + "empty": "Ask {{model}} for help..." + } + }, + "footer": { + "esc": "Press ESC {{action}}", + "esc_close": "close the window", + "esc_back": "back" + } } } } diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index d74ef218..9465dac3 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -383,6 +383,10 @@ "webdav.syncError": "バックアップエラー", "webdav.lastSync": "最終同期" }, + "quickAssistant": { + "title": "クイックアシスタント", + "click_tray_to_show": "システムトレイアイコンをクリックして開く" + }, "display.title": "表示設定", "font_size.title": "メッセージのフォントサイズ", "general": "一般設定", @@ -504,7 +508,8 @@ "toggle_show_assistants": "アシスタントの表示を切り替え", "toggle_show_topics": "トピックの表示を切り替え", "copy_last_message": "最後のメッセージをコピー", - "search_message": "メッセージを検索" + "search_message": "メッセージを検索", + "mini_window": "クイックアシスタント" }, "theme.auto": "自動", "theme.dark": "ダークテーマ", @@ -537,7 +542,8 @@ }, "tray": { "quit": "終了", - "show_window": "ウィンドウを表示" + "show_window": "ウィンドウを表示", + "show_mini_window": "クイックアシスタント" }, "words": { "knowledgeGraph": "ナレッジグラフ", @@ -619,7 +625,31 @@ } }, "prompts": { - "summarize": "あなたは会話を得意とするアシスタントです。ユーザーの会話を10文字以内のタイトルに要約し、ユーザーの主言語と一致していることを確認してください。句読点や特殊記号は使用しないでください。" + "title": "あなたは会話を得意とするアシスタントです。ユーザーの会話を10文字以内のタイトルに要約し、ユーザーの主言語と一致していることを確認してください。句読点や特殊記号は使用しないでください。", + "explanation": "この概念を説明してください", + "summarize": "このテキストを要約してください" + }, + "miniwindow": { + "feature": { + "chat": "この質問に回答", + "translate": "テキスト翻訳", + "summary": "内容要約", + "explanation": "説明" + }, + "clipboard": { + "empty": "クリップボードが空です" + }, + "input": { + "placeholder": { + "title": "下のテキストに対して何をしますか?", + "empty": "{{model}} に質問してください..." + } + }, + "footer": { + "esc": "ESC キーを押して{{action}}", + "esc_close": "ウィンドウを閉じる", + "esc_back": "戻る" + } } } } diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 1b271787..3e3bde58 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -385,6 +385,10 @@ "webdav.syncError": "Ошибка резервного копирования", "webdav.lastSync": "Последняя синхронизация" }, + "quickAssistant": { + "title": "Быстрый помощник", + "click_tray_to_show": "Нажмите на иконку системного трея для открытия" + }, "display.title": "Настройки отображения", "font_size.title": "Размер шрифта сообщений", "general": "Общие настройки", @@ -518,7 +522,8 @@ "toggle_show_assistants": "Переключить отображение ассистентов", "toggle_show_topics": "Переключить отображение топиков", "copy_last_message": "Копировать последнее сообщение", - "search_message": "Поиск сообщения" + "search_message": "Поиск сообщения", + "mini_window": "Быстрый помощник" }, "theme.auto": "Автоматически", "theme.dark": "Темная", @@ -551,7 +556,8 @@ }, "tray": { "quit": "Выйти", - "show_window": "Показать окно" + "show_window": "Показать окно", + "show_mini_window": "Быстрый помощник" }, "words": { "knowledgeGraph": "Граф знаний", @@ -633,7 +639,31 @@ } }, "prompts": { - "summarize": "Вы - эксперт в общении, который суммирует разговоры пользователя в 10-символьном заголовке, совпадающем с языком пользователя, без использования знаков препинания и других специальных символов" + "title": "Вы - эксперт в общении, который суммирует разговоры пользователя в 10-символьном заголовке, совпадающем с языком пользователя, без использования знаков препинания и других специальных символов", + "explanation": "Объясните мне этот концепт", + "summarize": "Суммируйте этот текст" + }, + "miniwindow": { + "feature": { + "chat": "Ответить на этот вопрос", + "translate": "Текст перевод", + "summary": "Содержание", + "explanation": "Объяснение" + }, + "clipboard": { + "empty": "Буфер обмена пуст" + }, + "input": { + "placeholder": { + "title": "Что вы хотите сделать с этим текстом?", + "empty": "Задайте вопрос {{model}}..." + } + }, + "footer": { + "esc": "Нажмите ESC {{action}}", + "esc_close": "закрытия окна", + "esc_back": "возвращения" + } } } } diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 8b3e3fc4..46acc537 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -386,6 +386,10 @@ "webdav.syncError": "备份错误", "webdav.lastSync": "上次备份时间" }, + "quickAssistant": { + "title": "快捷助手", + "click_tray_to_show": "点击系统托盘图标打开" + }, "display.title": "显示设置", "font_size.title": "消息字体大小", "general": "常规设置", @@ -507,7 +511,8 @@ "toggle_show_assistants": "切换助手显示", "toggle_show_topics": "切换话题显示", "copy_last_message": "复制上一条消息", - "search_message": "搜索消息" + "search_message": "搜索消息", + "mini_window": "快捷助手" }, "theme.auto": "跟随系统", "theme.dark": "深色主题", @@ -540,7 +545,8 @@ }, "tray": { "quit": "退出", - "show_window": "显示窗口" + "show_window": "显示窗口", + "show_mini_window": "快捷助手" }, "words": { "knowledgeGraph": "知识图谱", @@ -622,7 +628,31 @@ } }, "prompts": { - "summarize": "你是一名擅长会话的助理,你需要将用户的会话总结为 10 个字以内的标题,标题语言与用户的首要语言一致,不要使用标点符号和其他特殊符号" + "title": "你是一名擅长会话的助理,你需要将用户的会话总结为 10 个字以内的标题,标题语言与用户的首要语言一致,不要使用标点符号和其他特殊符号", + "explanation": "帮我解释一下这个概念", + "summarize": "帮我总结一下这段话" + }, + "miniwindow": { + "feature": { + "chat": "回答此问题", + "translate": "文本翻译", + "summary": "内容总结", + "explanation": "解释说明" + }, + "clipboard": { + "empty": "剪贴板为空" + }, + "input": { + "placeholder": { + "title": "你想对下方文字做什么", + "empty": "询问 {{model}} 获取帮助..." + } + }, + "footer": { + "esc": "按 ESC {{action}}", + "esc_close": "关闭窗口", + "esc_back": "返回" + } } } } diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index a3eb21ea..c851c6ca 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -385,6 +385,10 @@ "webdav.syncError": "備份錯誤", "webdav.lastSync": "上次同步時間" }, + "quickAssistant": { + "title": "快捷助手", + "click_tray_to_show": "點擊系統托盤圖標打開" + }, "display.title": "顯示設定", "font_size.title": "訊息字體大小", "general": "一般設定", @@ -506,7 +510,8 @@ "toggle_show_assistants": "切換助手顯示", "toggle_show_topics": "切換話題顯示", "copy_last_message": "複製上一条消息", - "search_message": "搜索消息" + "search_message": "搜索消息", + "mini_window": "快捷助手" }, "theme.auto": "自動", "theme.dark": "深色主題", @@ -539,7 +544,8 @@ }, "tray": { "quit": "退出", - "show_window": "顯示視窗" + "show_window": "顯示視窗", + "show_mini_window": "快捷助手" }, "words": { "knowledgeGraph": "知識圖譜", @@ -621,7 +627,31 @@ } }, "prompts": { - "summarize": "你是一名擅長會話的助理,你需要將用戶的會話總結為 10 個字以內的標題,標題語言與用戶的首要語言一致,不要使用標點符號和其他特殊符號" + "title": "你是一名擅長會話的助理,你需要將用戶的會話總結為 10 個字以內的標題,標題語言與用戶的首要語言一致,不要使用標點符號和其他特殊符號", + "explanation": "幫我解釋一下這個概念", + "summarize": "幫我總結一下這段話" + }, + "miniwindow": { + "feature": { + "chat": "回答此問題", + "translate": "文本翻譯", + "summary": "內容總結", + "explanation": "解釋說明" + }, + "clipboard": { + "empty": "剪貼板為空" + }, + "input": { + "placeholder": { + "title": "你想對下方文字做什麼", + "empty": "詢問 {{model}} 獲取幫助..." + } + }, + "footer": { + "esc": "按 ESC {{action}}", + "esc_close": "關閉窗口", + "esc_back": "返回" + } } } } diff --git a/src/renderer/src/init.ts b/src/renderer/src/init.ts index 85bb1ea5..8962da5a 100644 --- a/src/renderer/src/init.ts +++ b/src/renderer/src/init.ts @@ -3,6 +3,13 @@ import KeyvStorage from '@kangfenmao/keyv-storage' import { startAutoSync } from './services/BackupService' import store from './store' +function initSpinner() { + const spinner = document.getElementById('spinner') + if (spinner && window.location.hash !== '#/mini') { + spinner.style.display = 'flex' + } +} + function initKeyv() { window.keyv = new KeyvStorage() window.keyv.init() @@ -17,5 +24,6 @@ function initAutoSync() { }, 2000) } +initSpinner() initKeyv() initAutoSync() diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 5a1e4629..4885d60b 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -1,8 +1,13 @@ import './assets/styles/index.scss' -import './init' import ReactDOM from 'react-dom/client' import App from './App' +import MiniApp from './windows/mini/App' -ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render() +if (location.hash === '#/mini') { + document.getElementById('spinner')?.remove() + ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render() +} else { + ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render() +} diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index 53ba8a23..146effa9 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -120,7 +120,6 @@ const MessageItem: FC = ({ messages.findIndex((m) => m.id === message.id) ), assistant: assistantWithModel, - topic, onResponse: (msg) => { setMessage(msg) if (msg.status !== 'pending') { diff --git a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx index 094ea934..0f0b5ca9 100644 --- a/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/WebDavSettings.tsx @@ -52,7 +52,7 @@ const WebDavSettings: FC = () => { return } setBackuping(true) - await backupToWebdav() + await backupToWebdav({ showMessage: true }) setBackuping(false) } diff --git a/src/renderer/src/pages/settings/ModalSettings/TopicNamingModalPopup.tsx b/src/renderer/src/pages/settings/ModalSettings/TopicNamingModalPopup.tsx index 424174bb..052da669 100644 --- a/src/renderer/src/pages/settings/ModalSettings/TopicNamingModalPopup.tsx +++ b/src/renderer/src/pages/settings/ModalSettings/TopicNamingModalPopup.tsx @@ -56,9 +56,9 @@ const PopupContainer: React.FC = ({ resolve }) => {
{t('settings.models.topic_naming_prompt')}
dispatch(setTopicNamingPrompt(e.target.value.trim()))} - placeholder={t('prompts.summarize')} + placeholder={t('prompts.title')} /> {topicNamingPrompt && ( + + + + + + + + + \ No newline at end of file diff --git a/src/renderer/src/windows/mini/App.tsx b/src/renderer/src/windows/mini/App.tsx new file mode 100644 index 00000000..100c01a5 --- /dev/null +++ b/src/renderer/src/windows/mini/App.tsx @@ -0,0 +1,29 @@ +import '@renderer/databases' + +import store, { persistor } from '@renderer/store' +import { Provider } from 'react-redux' +import { PersistGate } from 'redux-persist/integration/react' + +import AntdProvider from '../../context/AntdProvider' +import { SyntaxHighlighterProvider } from '../../context/SyntaxHighlighterProvider' +import { ThemeProvider } from '../../context/ThemeProvider' +import { ThemeMode } from '../../types' +import HomeWindow from './home/HomeWindow' + +function MiniWindow(): JSX.Element { + return ( + + + + + + + + + + + + ) +} + +export default MiniWindow diff --git a/src/renderer/src/windows/mini/chat/ChatWindow.tsx b/src/renderer/src/windows/mini/chat/ChatWindow.tsx new file mode 100644 index 00000000..f410b832 --- /dev/null +++ b/src/renderer/src/windows/mini/chat/ChatWindow.tsx @@ -0,0 +1,34 @@ +import Scrollbar from '@renderer/components/Scrollbar' +import { useDefaultAssistant } from '@renderer/hooks/useAssistant' +import { getDefaultModel } from '@renderer/services/AssistantService' +import { FC } from 'react' +import styled from 'styled-components' + +import Messages from './Messages' + +interface Props { + route: string +} + +const ChatWindow: FC = ({ route }) => { + const { defaultAssistant } = useDefaultAssistant() + + return ( +
+ +
+ ) +} + +const Main = styled(Scrollbar)` + width: 100%; + display: flex; + flex-direction: row; + justify-content: flex-start; + margin-bottom: auto; + -webkit-app-region: none; + background-color: transparent !important; + max-height: 100%; +` + +export default ChatWindow diff --git a/src/renderer/src/windows/mini/chat/Inputbar.tsx b/src/renderer/src/windows/mini/chat/Inputbar.tsx new file mode 100644 index 00000000..08123b3b --- /dev/null +++ b/src/renderer/src/windows/mini/chat/Inputbar.tsx @@ -0,0 +1,443 @@ +import { ClearOutlined, PauseCircleOutlined, QuestionCircleOutlined } from '@ant-design/icons' +import TranslateButton from '@renderer/components/TranslateButton' +import { isVisionModel } from '@renderer/config/models' +import { useDefaultAssistant, useDefaultModel } from '@renderer/hooks/useAssistant' +import { useRuntime } from '@renderer/hooks/useRuntime' +import { useSettings } from '@renderer/hooks/useSettings' +import AttachmentButton from '@renderer/pages/home/Inputbar/AttachmentButton' +import AttachmentPreview from '@renderer/pages/home/Inputbar/AttachmentPreview' +import KnowledgeBaseButton from '@renderer/pages/home/Inputbar/KnowledgeBaseButton' +import SendMessageButton from '@renderer/pages/home/Inputbar/SendMessageButton' +import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' +import FileManager from '@renderer/services/FileManager' +import { translateText } from '@renderer/services/TranslateService' +import store, { useAppDispatch, useAppSelector } from '@renderer/store' +import { setGenerating, setSearching } from '@renderer/store/runtime' +import { FileType, KnowledgeBase, Message } from '@renderer/types' +import { delay, getFileExtension, uuid } from '@renderer/utils' +import { documentExts, imageExts, textExts } from '@shared/config/constant' +import { Button, Popconfirm, Tooltip } from 'antd' +import TextArea, { TextAreaRef } from 'antd/es/input/TextArea' +import dayjs from 'dayjs' +import { isEmpty } from 'lodash' +import { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +const Inputbar: FC = () => { + const [text, setText] = useState('') + const [inputFocus, setInputFocus] = useState(false) + const { defaultAssistant } = useDefaultAssistant() + const { defaultModel } = useDefaultModel() + const assistant = defaultAssistant + const model = defaultModel + const { + sendMessageShortcut, + fontSize, + pasteLongTextAsFile, + pasteLongTextThreshold, + language, + autoTranslateWithSpace + } = useSettings() + const [expended, setExpend] = useState(false) + const generating = useAppSelector((state) => state.runtime.generating) + const textareaRef = useRef(null) + const [files, setFiles] = useState([]) + const { t } = useTranslation() + const containerRef = useRef(null) + const { searching } = useRuntime() + const dispatch = useAppDispatch() + const [spaceClickCount, setSpaceClickCount] = useState(0) + const spaceClickTimer = useRef() + const [isTranslating, setIsTranslating] = useState(false) + const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState() + + const isVision = useMemo(() => isVisionModel(model), [model]) + const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision]) + + const inputEmpty = isEmpty(text.trim()) && files.length === 0 + + const sendMessage = useCallback(async () => { + if (generating) { + return + } + + if (inputEmpty) { + return + } + + const message: Message = { + id: uuid(), + role: 'user', + content: text, + assistantId: assistant.id, + topicId: assistant.topics[0].id || uuid(), + createdAt: dayjs().format('YYYY-MM-DD HH:mm:ss'), + type: 'text', + status: 'success' + } + + if (selectedKnowledgeBase) { + message.knowledgeBaseIds = [selectedKnowledgeBase.id] + } + + if (files.length > 0) { + message.files = await FileManager.uploadFiles(files) + } + + EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, message) + + setText('') + setFiles([]) + setTimeout(() => setText(''), 500) + setTimeout(() => resizeTextArea(), 0) + + setExpend(false) + }, [generating, inputEmpty, text, assistant.id, assistant.topics, selectedKnowledgeBase, files]) + + const translate = async () => { + if (isTranslating) { + return + } + + try { + setIsTranslating(true) + const translatedText = await translateText(text, 'english') + translatedText && setText(translatedText) + setTimeout(() => resizeTextArea(), 0) + } catch (error) { + console.error('Translation failed:', error) + } finally { + setIsTranslating(false) + } + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + const isEnterPressed = event.keyCode == 13 + + if (autoTranslateWithSpace) { + if (event.key === ' ') { + setSpaceClickCount((prev) => prev + 1) + + if (spaceClickTimer.current) { + clearTimeout(spaceClickTimer.current) + } + + spaceClickTimer.current = setTimeout(() => { + setSpaceClickCount(0) + }, 200) + + if (spaceClickCount === 2) { + console.log('Triple space detected - trigger translation') + setSpaceClickCount(0) + setIsTranslating(true) + translate() + return + } + } + } + + if (expended) { + if (event.key === 'Escape') { + return setExpend(false) + } + } + + if (sendMessageShortcut === 'Enter' && isEnterPressed) { + if (event.shiftKey) { + return + } + sendMessage() + return event.preventDefault() + } + + if (sendMessageShortcut === 'Shift+Enter' && isEnterPressed && event.shiftKey) { + sendMessage() + return event.preventDefault() + } + + if (sendMessageShortcut === 'Ctrl+Enter' && isEnterPressed && event.ctrlKey) { + sendMessage() + return event.preventDefault() + } + + if (sendMessageShortcut === 'Command+Enter' && isEnterPressed && event.metaKey) { + sendMessage() + return event.preventDefault() + } + } + + const clearTopic = async () => { + if (generating) { + onPause() + await delay(1) + } + EventEmitter.emit(EVENT_NAMES.CLEAR_MESSAGES) + } + + const onPause = () => { + window.keyv.set(EVENT_NAMES.CHAT_COMPLETION_PAUSED, true) + store.dispatch(setGenerating(false)) + } + + const resizeTextArea = () => { + const textArea = textareaRef.current?.resizableTextArea?.textArea + if (textArea) { + textArea.style.height = 'auto' + textArea.style.height = textArea?.scrollHeight > 400 ? '400px' : `${textArea?.scrollHeight}px` + } + } + + const onInput = () => !expended && resizeTextArea() + + const onPaste = useCallback( + async (event: ClipboardEvent) => { + for (const file of event.clipboardData?.files || []) { + event.preventDefault() + + if (file.path === '') { + if (file.type.startsWith('image/')) { + const tempFilePath = await window.api.file.create(file.name) + const arrayBuffer = await file.arrayBuffer() + const uint8Array = new Uint8Array(arrayBuffer) + await window.api.file.write(tempFilePath, uint8Array) + const selectedFile = await window.api.file.get(tempFilePath) + selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) + break + } + } + + if (file.path) { + if (supportExts.includes(getFileExtension(file.path))) { + const selectedFile = await window.api.file.get(file.path) + selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) + } + } + } + + if (pasteLongTextAsFile) { + const item = event.clipboardData?.items[0] + if (item && item.kind === 'string' && item.type === 'text/plain') { + item.getAsString(async (pasteText) => { + if (pasteText.length > pasteLongTextThreshold) { + const tempFilePath = await window.api.file.create('pasted_text.txt') + await window.api.file.write(tempFilePath, pasteText) + const selectedFile = await window.api.file.get(tempFilePath) + selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) + setText(text) + setTimeout(() => resizeTextArea(), 0) + } + }) + } + } + }, + [pasteLongTextAsFile, pasteLongTextThreshold, supportExts, text] + ) + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + + const files = Array.from(e.dataTransfer.files) + + files.forEach(async (file) => { + if (supportExts.includes(getFileExtension(file.path))) { + const selectedFile = await window.api.file.get(file.path) + selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) + } + }) + } + + const onTranslated = (translatedText: string) => { + setText(translatedText) + setTimeout(() => resizeTextArea(), 0) + } + + useEffect(() => { + textareaRef.current?.focus() + }, [assistant]) + + useEffect(() => { + setTimeout(() => resizeTextArea(), 0) + }, []) + + useEffect(() => { + return () => { + if (spaceClickTimer.current) { + clearTimeout(spaceClickTimer.current) + } + } + }, []) + + const handleKnowledgeBaseSelect = (base?: KnowledgeBase) => { + setSelectedKnowledgeBase(base) + } + + return ( + + + +