feat: add mini window

This commit is contained in:
kangfenmao 2025-01-02 09:21:34 +08:00
parent d9bb552f3f
commit a7d9700f06
51 changed files with 2367 additions and 42 deletions

View File

@ -50,7 +50,7 @@ export default defineConfig({
} }
}, },
optimizeDeps: { optimizeDeps: {
exclude: ['chunk-QH6N6I7P.js', 'chunk-PB73W2YU.js', 'chunk-AFE5XGNG.js', 'chunk-QIJABHCK.js'] exclude: ['chunk-RK3FTE5R.js']
} }
} }
}) })

View File

@ -85,6 +85,7 @@
"@kangfenmao/keyv-storage": "^0.1.0", "@kangfenmao/keyv-storage": "^0.1.0",
"@reduxjs/toolkit": "^2.2.5", "@reduxjs/toolkit": "^2.2.5",
"@types/adm-zip": "^0", "@types/adm-zip": "^0",
"@types/ffi-napi": "^4",
"@types/fs-extra": "^11", "@types/fs-extra": "^11",
"@types/lodash": "^4.17.5", "@types/lodash": "^4.17.5",
"@types/markdown-it": "^14", "@types/markdown-it": "^14",
@ -92,9 +93,11 @@
"@types/react": "^18.2.48", "@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",
"@types/react-infinite-scroll-component": "^5.0.0", "@types/react-infinite-scroll-component": "^5.0.0",
"@types/ref-napi": "^3",
"@types/tinycolor2": "^1", "@types/tinycolor2": "^1",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"antd": "^5.22.5", "antd": "^5.22.5",
"applescript": "^1.0.0",
"axios": "^1.7.3", "axios": "^1.7.3",
"browser-image-compression": "^2.0.2", "browser-image-compression": "^2.0.2",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",

117
resources/textMonitor.swift Normal file
View File

@ -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()

View File

@ -52,6 +52,14 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
configManager.setTray(isActive) 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 // theme
ipcMain.handle('app:set-theme', (_, theme: ThemeMode) => { ipcMain.handle('app:set-theme', (_, theme: ThemeMode) => {
configManager.setTheme(theme) configManager.setTheme(theme)

View File

@ -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)
}
}
}

View File

@ -30,7 +30,7 @@ export class ConfigManager {
this.store.set('theme', theme) this.store.set('theme', theme)
} }
isTray(): boolean { getTray(): boolean {
return !!this.store.get('tray', true) return !!this.store.get('tray', true)
} }
@ -83,6 +83,22 @@ export class ConfigManager {
) )
this.notifySubscribers('shortcuts', shortcuts) 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() export const configManager = new ConfigManager()

View File

@ -3,8 +3,10 @@ import { BrowserWindow, globalShortcut } from 'electron'
import Logger from 'electron-log' import Logger from 'electron-log'
import { configManager } from './ConfigManager' import { configManager } from './ConfigManager'
import { windowService } from './WindowService'
let showAppAccelerator: string | null = null let showAppAccelerator: string | null = null
let showMiniWindowAccelerator: string | null = null
function getShortcutHandler(shortcut: Shortcut) { function getShortcutHandler(shortcut: Shortcut) {
switch (shortcut.key) { switch (shortcut.key) {
@ -26,6 +28,10 @@ function getShortcutHandler(shortcut: Shortcut) {
window.focus() window.focus()
} }
} }
case 'mini_window':
return () => {
windowService.toggleMiniWindow()
}
default: default:
return null return null
} }
@ -73,6 +79,10 @@ export function registerShortcuts(window: BrowserWindow) {
showAppAccelerator = accelerator showAppAccelerator = accelerator
} }
if (shortcut.key === 'mini_window') {
showMiniWindowAccelerator = accelerator
}
if (shortcut.key.includes('zoom')) { if (shortcut.key.includes('zoom')) {
switch (shortcut.key) { switch (shortcut.key) {
case 'zoom_in': case 'zoom_in':
@ -90,7 +100,7 @@ export function registerShortcuts(window: BrowserWindow) {
} }
if (shortcut.enabled) { if (shortcut.enabled) {
globalShortcut.register(accelerator, () => handler(window)) globalShortcut.register(formatShortcutKey(shortcut.shortcut), () => handler(window))
} }
} catch (error) { } catch (error) {
Logger.error(`[ShortcutService] Failed to register shortcut ${shortcut.key}`) 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) const handler = getShortcutHandler({ key: 'show_app' } as Shortcut)
handler && globalShortcut.register(showAppAccelerator, () => handler(window)) handler && globalShortcut.register(showAppAccelerator, () => handler(window))
} }
if (showMiniWindowAccelerator) {
const handler = getShortcutHandler({ key: 'mini_window' } as Shortcut)
handler && globalShortcut.register(showMiniWindowAccelerator, () => handler(window))
}
} catch (error) { } catch (error) {
Logger.error('[ShortcutService] Failed to unregister shortcuts') Logger.error('[ShortcutService] Failed to unregister shortcuts')
} }
@ -124,6 +139,7 @@ export function registerShortcuts(window: BrowserWindow) {
export function unregisterAllShortcuts() { export function unregisterAllShortcuts() {
try { try {
showAppAccelerator = null showAppAccelerator = null
showMiniWindowAccelerator = null
globalShortcut.unregisterAll() globalShortcut.unregisterAll()
} catch (error) { } catch (error) {
Logger.error('[ShortcutService] Failed to unregister all shortcuts') Logger.error('[ShortcutService] Failed to unregister all shortcuts')

View File

@ -17,6 +17,8 @@ export class TrayService {
} }
private createTray() { private createTray() {
this.destroyTray()
const iconPath = isMac ? (nativeTheme.shouldUseDarkColors ? iconLight : iconDark) : icon const iconPath = isMac ? (nativeTheme.shouldUseDarkColors ? iconLight : iconDark) : icon
const tray = new Tray(iconPath) const tray = new Tray(iconPath)
@ -43,6 +45,10 @@ export class TrayService {
label: trayLocale.show_window, label: trayLocale.show_window,
click: () => windowService.showMainWindow() click: () => windowService.showMainWindow()
}, },
{
label: trayLocale.show_mini_window,
click: () => windowService.showMiniWindow()
},
{ type: 'separator' }, { type: 'separator' },
{ {
label: trayLocale.quit, label: trayLocale.quit,
@ -61,12 +67,17 @@ export class TrayService {
}) })
this.tray.on('click', () => { this.tray.on('click', () => {
if (configManager.getClickTrayToShowQuickAssistant()) {
windowService.showMiniWindow()
} else {
windowService.showMainWindow() windowService.showMainWindow()
}
}) })
} }
private updateTray() { private updateTray() {
if (configManager.isTray()) { const showTray = configManager.getTray()
if (showTray) {
this.createTray() this.createTray()
} else { } else {
this.destroyTray() this.destroyTray()

View File

@ -1,6 +1,6 @@
import { is } from '@electron-toolkit/utils' import { is } from '@electron-toolkit/utils'
import { isLinux, isWin } from '@main/constant' 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 Logger from 'electron-log'
import windowStateKeeper from 'electron-window-state' import windowStateKeeper from 'electron-window-state'
import path, { join } from 'path' import path, { join } from 'path'
@ -13,8 +13,11 @@ import { configManager } from './ConfigManager'
export class WindowService { export class WindowService {
private static instance: WindowService | null = null private static instance: WindowService | null = null
private mainWindow: BrowserWindow | null = null private mainWindow: BrowserWindow | null = null
private miniWindow: BrowserWindow | null = null
private isQuitting: boolean = false private isQuitting: boolean = false
private wasFullScreen: boolean = false private wasFullScreen: boolean = false
private selectionMenuWindow: BrowserWindow | null = null
private lastSelectedText: string = ''
public static getInstance(): WindowService { public static getInstance(): WindowService {
if (!WindowService.instance) { if (!WindowService.instance) {
@ -63,6 +66,9 @@ export class WindowService {
}) })
this.setupMainWindow(this.mainWindow, mainWindowState) this.setupMainWindow(this.mainWindow, mainWindowState)
setTimeout(() => this.showMiniWindow(), 5000)
return this.mainWindow return this.mainWindow
} }
@ -201,7 +207,7 @@ export class WindowService {
}) })
mainWindow.on('close', (event) => { mainWindow.on('close', (event) => {
const notInTray = !configManager.isTray() const notInTray = !configManager.getTray()
// Windows and Linux // Windows and Linux
if ((isWin || isLinux) && notInTray) { if ((isWin || isLinux) && notInTray) {
@ -233,6 +239,154 @@ export class WindowService {
this.createMainWindow() 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() export const windowService = WindowService.getInstance()

View File

@ -22,3 +22,15 @@ export function getInstanceName(baseURL: string) {
return '' 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)
}
}
}

View File

@ -89,6 +89,13 @@ declare global {
listFiles: (apiKey: string) => Promise<ListFilesResponse> listFiles: (apiKey: string) => Promise<ListFilesResponse>
deleteFile: (apiKey: string, fileId: string) => Promise<void> deleteFile: (apiKey: string, fileId: string) => Promise<void>
} }
selectionMenu: {
action: (action: string) => Promise<void>
}
config: {
set: (key: string, value: any) => Promise<void>
get: (key: string) => Promise<any>
}
} }
} }
} }

View File

@ -82,6 +82,13 @@ const api = {
retrieveFile: (file: FileType, apiKey: string) => ipcRenderer.invoke('gemini:retrieve-file', file, apiKey), retrieveFile: (file: FileType, apiKey: string) => ipcRenderer.invoke('gemini:retrieve-file', file, apiKey),
listFiles: (apiKey: string) => ipcRenderer.invoke('gemini:list-files', apiKey), listFiles: (apiKey: string) => ipcRenderer.invoke('gemini:list-files', apiKey),
deleteFile: (apiKey: string, fileId: string) => ipcRenderer.invoke('gemini:delete-file', apiKey, fileId) 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)
} }
} }

View File

@ -17,10 +17,10 @@
position: fixed; position: fixed;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
display: flex;
flex-direction: row; flex-direction: row;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
display: none;
} }
#spinner img { #spinner img {
@ -35,6 +35,7 @@
<div id="spinner"> <div id="spinner">
<img src="/src/assets/images/logo.png" /> <img src="/src/assets/images/logo.png" />
</div> </div>
<script type="module" src="/src/init.ts"></script>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>

View File

@ -24,6 +24,7 @@
--color-background: var(--color-black); --color-background: var(--color-black);
--color-background-soft: var(--color-black-soft); --color-background-soft: var(--color-black-soft);
--color-background-mute: var(--color-black-mute); --color-background-mute: var(--color-black-mute);
--color-background-opacity: rgba(34, 34, 34, 0.7);
--color-primary: #00b96b; --color-primary: #00b96b;
--color-primary-soft: #00b96b99; --color-primary-soft: #00b96b99;
@ -87,6 +88,7 @@ body[theme-mode='light'] {
--color-background: var(--color-white); --color-background: var(--color-white);
--color-background-soft: var(--color-white-soft); --color-background-soft: var(--color-white-soft);
--color-background-mute: var(--color-white-mute); --color-background-mute: var(--color-white-mute);
--color-background-opacity: rgba(255, 255, 255, 0.7);
--color-primary: #00b96b; --color-primary: #00b96b;
--color-primary-soft: #00b96b99; --color-primary-soft: #00b96b99;

View File

@ -13,7 +13,11 @@ const ThemeContext = createContext<ThemeContextType>({
toggleTheme: () => {} toggleTheme: () => {}
}) })
export const ThemeProvider: React.FC<PropsWithChildren> = ({ children }) => { interface ThemeProviderProps extends PropsWithChildren {
defaultTheme?: ThemeMode
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, defaultTheme }) => {
const { theme, setTheme } = useSettings() const { theme, setTheme } = useSettings()
const [_theme, _setTheme] = useState(theme) const [_theme, _setTheme] = useState(theme)
@ -22,7 +26,7 @@ export const ThemeProvider: React.FC<PropsWithChildren> = ({ children }) => {
} }
useEffect((): any => { useEffect((): any => {
if (theme === ThemeMode.auto) { if (theme === ThemeMode.auto || defaultTheme === ThemeMode.auto) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
_setTheme(mediaQuery.matches ? ThemeMode.dark : ThemeMode.light) _setTheme(mediaQuery.matches ? ThemeMode.dark : ThemeMode.light)
const handleChange = (e: MediaQueryListEvent) => _setTheme(e.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<PropsWithChildren> = ({ children }) => {
} else { } else {
_setTheme(theme) _setTheme(theme)
} }
}, [theme]) }, [defaultTheme, theme])
useEffect(() => { useEffect(() => {
document.body.setAttribute('theme-mode', _theme) document.body.setAttribute('theme-mode', _theme)

View File

@ -9,7 +9,10 @@ export default function useUpdateHandler() {
const { t } = useTranslation() const { t } = useTranslation()
useEffect(() => { useEffect(() => {
if (!window.electron) return
const ipcRenderer = window.electron.ipcRenderer const ipcRenderer = window.electron.ipcRenderer
const removers = [ const removers = [
ipcRenderer.on('update-not-available', () => { ipcRenderer.on('update-not-available', () => {
dispatch(setUpdateState({ checking: false })) dispatch(setUpdateState({ checking: false }))

View File

@ -385,6 +385,10 @@
"webdav.syncError": "Backup Error", "webdav.syncError": "Backup Error",
"webdav.lastSync": "Last Backup" "webdav.lastSync": "Last Backup"
}, },
"quickAssistant": {
"title": "Quick Assistant",
"click_tray_to_show": "Click the system tray icon to open"
},
"display.title": "Display Settings", "display.title": "Display Settings",
"font_size.title": "Message font size", "font_size.title": "Message font size",
"general": "General Settings", "general": "General Settings",
@ -519,7 +523,8 @@
"toggle_show_assistants": "Toggle Assistants", "toggle_show_assistants": "Toggle Assistants",
"toggle_show_topics": "Toggle Topics", "toggle_show_topics": "Toggle Topics",
"copy_last_message": "Copy Last Message", "copy_last_message": "Copy Last Message",
"search_message": "Search Message" "search_message": "Search Message",
"mini_window": "Quick Assistant"
}, },
"theme.auto": "Auto", "theme.auto": "Auto",
"theme.dark": "Dark", "theme.dark": "Dark",
@ -552,7 +557,8 @@
}, },
"tray": { "tray": {
"quit": "Quit", "quit": "Quit",
"show_window": "Show Window" "show_window": "Show Window",
"show_mini_window": "Quick Assistant"
}, },
"words": { "words": {
"knowledgeGraph": "Knowledge Graph", "knowledgeGraph": "Knowledge Graph",
@ -634,7 +640,31 @@
} }
}, },
"prompts": { "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"
}
} }
} }
} }

View File

@ -383,6 +383,10 @@
"webdav.syncError": "バックアップエラー", "webdav.syncError": "バックアップエラー",
"webdav.lastSync": "最終同期" "webdav.lastSync": "最終同期"
}, },
"quickAssistant": {
"title": "クイックアシスタント",
"click_tray_to_show": "システムトレイアイコンをクリックして開く"
},
"display.title": "表示設定", "display.title": "表示設定",
"font_size.title": "メッセージのフォントサイズ", "font_size.title": "メッセージのフォントサイズ",
"general": "一般設定", "general": "一般設定",
@ -504,7 +508,8 @@
"toggle_show_assistants": "アシスタントの表示を切り替え", "toggle_show_assistants": "アシスタントの表示を切り替え",
"toggle_show_topics": "トピックの表示を切り替え", "toggle_show_topics": "トピックの表示を切り替え",
"copy_last_message": "最後のメッセージをコピー", "copy_last_message": "最後のメッセージをコピー",
"search_message": "メッセージを検索" "search_message": "メッセージを検索",
"mini_window": "クイックアシスタント"
}, },
"theme.auto": "自動", "theme.auto": "自動",
"theme.dark": "ダークテーマ", "theme.dark": "ダークテーマ",
@ -537,7 +542,8 @@
}, },
"tray": { "tray": {
"quit": "終了", "quit": "終了",
"show_window": "ウィンドウを表示" "show_window": "ウィンドウを表示",
"show_mini_window": "クイックアシスタント"
}, },
"words": { "words": {
"knowledgeGraph": "ナレッジグラフ", "knowledgeGraph": "ナレッジグラフ",
@ -619,7 +625,31 @@
} }
}, },
"prompts": { "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": "戻る"
}
} }
} }
} }

View File

@ -385,6 +385,10 @@
"webdav.syncError": "Ошибка резервного копирования", "webdav.syncError": "Ошибка резервного копирования",
"webdav.lastSync": "Последняя синхронизация" "webdav.lastSync": "Последняя синхронизация"
}, },
"quickAssistant": {
"title": "Быстрый помощник",
"click_tray_to_show": "Нажмите на иконку системного трея для открытия"
},
"display.title": "Настройки отображения", "display.title": "Настройки отображения",
"font_size.title": "Размер шрифта сообщений", "font_size.title": "Размер шрифта сообщений",
"general": "Общие настройки", "general": "Общие настройки",
@ -518,7 +522,8 @@
"toggle_show_assistants": "Переключить отображение ассистентов", "toggle_show_assistants": "Переключить отображение ассистентов",
"toggle_show_topics": "Переключить отображение топиков", "toggle_show_topics": "Переключить отображение топиков",
"copy_last_message": "Копировать последнее сообщение", "copy_last_message": "Копировать последнее сообщение",
"search_message": "Поиск сообщения" "search_message": "Поиск сообщения",
"mini_window": "Быстрый помощник"
}, },
"theme.auto": "Автоматически", "theme.auto": "Автоматически",
"theme.dark": "Темная", "theme.dark": "Темная",
@ -551,7 +556,8 @@
}, },
"tray": { "tray": {
"quit": "Выйти", "quit": "Выйти",
"show_window": "Показать окно" "show_window": "Показать окно",
"show_mini_window": "Быстрый помощник"
}, },
"words": { "words": {
"knowledgeGraph": "Граф знаний", "knowledgeGraph": "Граф знаний",
@ -633,7 +639,31 @@
} }
}, },
"prompts": { "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": "возвращения"
}
} }
} }
} }

View File

@ -386,6 +386,10 @@
"webdav.syncError": "备份错误", "webdav.syncError": "备份错误",
"webdav.lastSync": "上次备份时间" "webdav.lastSync": "上次备份时间"
}, },
"quickAssistant": {
"title": "快捷助手",
"click_tray_to_show": "点击系统托盘图标打开"
},
"display.title": "显示设置", "display.title": "显示设置",
"font_size.title": "消息字体大小", "font_size.title": "消息字体大小",
"general": "常规设置", "general": "常规设置",
@ -507,7 +511,8 @@
"toggle_show_assistants": "切换助手显示", "toggle_show_assistants": "切换助手显示",
"toggle_show_topics": "切换话题显示", "toggle_show_topics": "切换话题显示",
"copy_last_message": "复制上一条消息", "copy_last_message": "复制上一条消息",
"search_message": "搜索消息" "search_message": "搜索消息",
"mini_window": "快捷助手"
}, },
"theme.auto": "跟随系统", "theme.auto": "跟随系统",
"theme.dark": "深色主题", "theme.dark": "深色主题",
@ -540,7 +545,8 @@
}, },
"tray": { "tray": {
"quit": "退出", "quit": "退出",
"show_window": "显示窗口" "show_window": "显示窗口",
"show_mini_window": "快捷助手"
}, },
"words": { "words": {
"knowledgeGraph": "知识图谱", "knowledgeGraph": "知识图谱",
@ -622,7 +628,31 @@
} }
}, },
"prompts": { "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": "返回"
}
} }
} }
} }

View File

@ -385,6 +385,10 @@
"webdav.syncError": "備份錯誤", "webdav.syncError": "備份錯誤",
"webdav.lastSync": "上次同步時間" "webdav.lastSync": "上次同步時間"
}, },
"quickAssistant": {
"title": "快捷助手",
"click_tray_to_show": "點擊系統托盤圖標打開"
},
"display.title": "顯示設定", "display.title": "顯示設定",
"font_size.title": "訊息字體大小", "font_size.title": "訊息字體大小",
"general": "一般設定", "general": "一般設定",
@ -506,7 +510,8 @@
"toggle_show_assistants": "切換助手顯示", "toggle_show_assistants": "切換助手顯示",
"toggle_show_topics": "切換話題顯示", "toggle_show_topics": "切換話題顯示",
"copy_last_message": "複製上一条消息", "copy_last_message": "複製上一条消息",
"search_message": "搜索消息" "search_message": "搜索消息",
"mini_window": "快捷助手"
}, },
"theme.auto": "自動", "theme.auto": "自動",
"theme.dark": "深色主題", "theme.dark": "深色主題",
@ -539,7 +544,8 @@
}, },
"tray": { "tray": {
"quit": "退出", "quit": "退出",
"show_window": "顯示視窗" "show_window": "顯示視窗",
"show_mini_window": "快捷助手"
}, },
"words": { "words": {
"knowledgeGraph": "知識圖譜", "knowledgeGraph": "知識圖譜",
@ -621,7 +627,31 @@
} }
}, },
"prompts": { "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": "返回"
}
} }
} }
} }

View File

@ -3,6 +3,13 @@ import KeyvStorage from '@kangfenmao/keyv-storage'
import { startAutoSync } from './services/BackupService' import { startAutoSync } from './services/BackupService'
import store from './store' import store from './store'
function initSpinner() {
const spinner = document.getElementById('spinner')
if (spinner && window.location.hash !== '#/mini') {
spinner.style.display = 'flex'
}
}
function initKeyv() { function initKeyv() {
window.keyv = new KeyvStorage() window.keyv = new KeyvStorage()
window.keyv.init() window.keyv.init()
@ -17,5 +24,6 @@ function initAutoSync() {
}, 2000) }, 2000)
} }
initSpinner()
initKeyv() initKeyv()
initAutoSync() initAutoSync()

View File

@ -1,8 +1,13 @@
import './assets/styles/index.scss' import './assets/styles/index.scss'
import './init'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App' import App from './App'
import MiniApp from './windows/mini/App'
if (location.hash === '#/mini') {
document.getElementById('spinner')?.remove()
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(<MiniApp />)
} else {
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(<App />) ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(<App />)
}

View File

@ -120,7 +120,6 @@ const MessageItem: FC<Props> = ({
messages.findIndex((m) => m.id === message.id) messages.findIndex((m) => m.id === message.id)
), ),
assistant: assistantWithModel, assistant: assistantWithModel,
topic,
onResponse: (msg) => { onResponse: (msg) => {
setMessage(msg) setMessage(msg)
if (msg.status !== 'pending') { if (msg.status !== 'pending') {

View File

@ -52,7 +52,7 @@ const WebDavSettings: FC = () => {
return return
} }
setBackuping(true) setBackuping(true)
await backupToWebdav() await backupToWebdav({ showMessage: true })
setBackuping(false) setBackuping(false)
} }

View File

@ -56,9 +56,9 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
<div style={{ marginBottom: 10 }}>{t('settings.models.topic_naming_prompt')}</div> <div style={{ marginBottom: 10 }}>{t('settings.models.topic_naming_prompt')}</div>
<Input.TextArea <Input.TextArea
rows={4} rows={4}
value={topicNamingPrompt || t('prompts.summarize')} value={topicNamingPrompt || t('prompts.title')}
onChange={(e) => dispatch(setTopicNamingPrompt(e.target.value.trim()))} onChange={(e) => dispatch(setTopicNamingPrompt(e.target.value.trim()))}
placeholder={t('prompts.summarize')} placeholder={t('prompts.title')}
/> />
{topicNamingPrompt && ( {topicNamingPrompt && (
<Button style={{ marginTop: 10 }} onClick={handleReset}> <Button style={{ marginTop: 10 }} onClick={handleReset}>

View File

@ -0,0 +1,54 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { useSettings } from '@renderer/hooks/useSettings'
import { useAppDispatch } from '@renderer/store'
import { setClickTrayToShowQuickAssistant } from '@renderer/store/settings'
import HomeWindow from '@renderer/windows/mini/home/HomeWindow'
import { Switch } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '.'
const QuickAssistantSettings: FC = () => {
const { t } = useTranslation()
const { theme } = useTheme()
const { clickTrayToShowQuickAssistant, setTray } = useSettings()
const dispatch = useAppDispatch()
const handleClickTrayToShowQuickAssistant = async (checked: boolean) => {
dispatch(setClickTrayToShowQuickAssistant(checked))
await window.api.config.set('clickTrayToShowQuickAssistant', checked)
if (checked) {
setTray(true)
window.api.setTray(true)
}
}
return (
<SettingContainer theme={theme}>
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.quickAssistant.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.quickAssistant.click_tray_to_show')}</SettingRowTitle>
<Switch checked={clickTrayToShowQuickAssistant} onChange={handleClickTrayToShowQuickAssistant} />
</SettingRow>
</SettingGroup>
<AssistantContainer onClick={() => {}}>
<HomeWindow />
</AssistantContainer>
</SettingContainer>
)
}
const AssistantContainer = styled.div`
width: 100%;
height: 460px;
background-color: var(--color-background);
border-radius: 10px;
border: 0.5px solid var(--color-border);
margin: 0 auto;
`
export default QuickAssistantSettings

View File

@ -3,6 +3,7 @@ import {
InfoCircleOutlined, InfoCircleOutlined,
LayoutOutlined, LayoutOutlined,
MacCommandOutlined, MacCommandOutlined,
RocketOutlined,
SaveOutlined, SaveOutlined,
SettingOutlined SettingOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
@ -19,6 +20,7 @@ import DisplaySettings from './DisplaySettings/DisplaySettings'
import GeneralSettings from './GeneralSettings' import GeneralSettings from './GeneralSettings'
import ModelSettings from './ModalSettings/ModelSettings' import ModelSettings from './ModalSettings/ModelSettings'
import ProvidersList from './ProviderSettings' import ProvidersList from './ProviderSettings'
import QuickAssistantSettings from './QuickAssistantSettings'
import ShortcutSettings from './ShortcutSettings' import ShortcutSettings from './ShortcutSettings'
const SettingsPage: FC = () => { const SettingsPage: FC = () => {
@ -68,6 +70,12 @@ const SettingsPage: FC = () => {
{t('settings.shortcuts.title')} {t('settings.shortcuts.title')}
</MenuItem> </MenuItem>
</MenuItemLink> </MenuItemLink>
<MenuItemLink to="/settings/quickAssistant">
<MenuItem className={isRoute('/settings/quickAssistant')}>
<RocketOutlined />
{t('settings.quickAssistant.title')}
</MenuItem>
</MenuItemLink>
<MenuItemLink to="/settings/data"> <MenuItemLink to="/settings/data">
<MenuItem className={isRoute('/settings/data')}> <MenuItem className={isRoute('/settings/data')}>
<SaveOutlined /> <SaveOutlined />
@ -88,6 +96,7 @@ const SettingsPage: FC = () => {
<Route path="general/*" element={<GeneralSettings />} /> <Route path="general/*" element={<GeneralSettings />} />
<Route path="display" element={<DisplaySettings />} /> <Route path="display" element={<DisplaySettings />} />
<Route path="data/*" element={<DataSettings />} /> <Route path="data/*" element={<DataSettings />} />
<Route path="quickAssistant" element={<QuickAssistantSettings />} />
<Route path="shortcut" element={<ShortcutSettings />} /> <Route path="shortcut" element={<ShortcutSettings />} />
<Route path="about" element={<AboutSettings />} /> <Route path="about" element={<AboutSettings />} />
</Routes> </Routes>

View File

@ -190,7 +190,7 @@ export default class AnthropicProvider extends BaseProvider {
const systemMessage = { const systemMessage = {
role: 'system', role: 'system',
content: (getStoreSetting('topicNamingPrompt') as string) || i18n.t('prompts.summarize') content: (getStoreSetting('topicNamingPrompt') as string) || i18n.t('prompts.title')
} }
const userMessage = { const userMessage = {

View File

@ -269,7 +269,7 @@ export default class GeminiProvider extends BaseProvider {
const systemMessage = { const systemMessage = {
role: 'system', role: 'system',
content: (getStoreSetting('topicNamingPrompt') as string) || i18n.t('prompts.summarize') content: (getStoreSetting('topicNamingPrompt') as string) || i18n.t('prompts.title')
} }
const userMessage = { const userMessage = {

View File

@ -322,7 +322,7 @@ export default class OpenAIProvider extends BaseProvider {
const systemMessage = { const systemMessage = {
role: 'system', role: 'system',
content: getStoreSetting('topicNamingPrompt') || i18n.t('prompts.summarize') content: getStoreSetting('topicNamingPrompt') || i18n.t('prompts.title')
} }
const userMessage = { const userMessage = {

View File

@ -1,7 +1,7 @@
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import store from '@renderer/store' import store from '@renderer/store'
import { setGenerating } from '@renderer/store/runtime' import { setGenerating } from '@renderer/store/runtime'
import { Assistant, Message, Model, Provider, Suggestion, Topic } from '@renderer/types' import { Assistant, Message, Model, Provider, Suggestion } from '@renderer/types'
import { isEmpty } from 'lodash' import { isEmpty } from 'lodash'
import AiProvider from '../providers/AiProvider' import AiProvider from '../providers/AiProvider'
@ -24,7 +24,6 @@ export async function fetchChatCompletion({
}: { }: {
message: Message message: Message
messages: Message[] messages: Message[]
topic: Topic
assistant: Assistant assistant: Assistant
onResponse: (message: Message) => void onResponse: (message: Message) => void
}) { }) {

View File

@ -181,10 +181,8 @@ export function startAutoSync() {
try { try {
console.log('[AutoSync] Performing auto backup...') console.log('[AutoSync] Performing auto backup...')
await backupToWebdav({ showMessage: false }) await backupToWebdav({ showMessage: false })
window.message.success({ content: i18n.t('message.backup.success'), key: 'webdav-auto-sync' })
} catch (error) { } catch (error) {
console.error('[AutoSync] Auto backup failed:', error) console.error('[AutoSync] Auto backup failed:', error)
window.message.error({ content: i18n.t('message.backup.failed'), key: 'webdav-auto-sync' })
} finally { } finally {
isAutoBackupRunning = false isAutoBackupRunning = false
scheduleNextBackup() scheduleNextBackup()

View File

@ -30,7 +30,7 @@ const persistedReducer = persistReducer(
{ {
key: 'cherry-studio', key: 'cherry-studio',
storage, storage,
version: 56, version: 57,
blacklist: ['runtime'], blacklist: ['runtime'],
migrate migrate
}, },

View File

@ -407,6 +407,7 @@ const settingsSlice = createSlice({
}, },
setDefaultModel: (state, action: PayloadAction<{ model: Model }>) => { setDefaultModel: (state, action: PayloadAction<{ model: Model }>) => {
state.defaultModel = action.payload.model state.defaultModel = action.payload.model
window.electron.ipcRenderer.send('miniwindow-reload')
}, },
setTopicNamingModel: (state, action: PayloadAction<{ model: Model }>) => { setTopicNamingModel: (state, action: PayloadAction<{ model: Model }>) => {
state.topicNamingModel = action.payload.model state.topicNamingModel = action.payload.model

View File

@ -812,6 +812,16 @@ const migrateConfig = {
enabled: false enabled: false
}) })
return state return state
},
'57': (state: RootState) => {
state.shortcuts.shortcuts.push({
key: 'mini_window',
shortcut: [isMac ? 'Command' : 'Ctrl', 'E'],
editable: true,
enabled: false,
system: true
})
return state
} }
} }

View File

@ -61,6 +61,7 @@ export interface SettingsState {
disabled: SidebarIcon[] disabled: SidebarIcon[]
} }
narrowMode: boolean narrowMode: boolean
clickTrayToShowQuickAssistant: boolean
} }
const initialState: SettingsState = { const initialState: SettingsState = {
@ -105,7 +106,8 @@ const initialState: SettingsState = {
visible: DEFAULT_SIDEBAR_ICONS, visible: DEFAULT_SIDEBAR_ICONS,
disabled: [] disabled: []
}, },
narrowMode: false narrowMode: false,
clickTrayToShowQuickAssistant: false
} }
const settingsSlice = createSlice({ const settingsSlice = createSlice({
@ -240,6 +242,9 @@ const settingsSlice = createSlice({
}, },
setNarrowMode: (state, action: PayloadAction<boolean>) => { setNarrowMode: (state, action: PayloadAction<boolean>) => {
state.narrowMode = action.payload state.narrowMode = action.payload
},
setClickTrayToShowQuickAssistant: (state, action: PayloadAction<boolean>) => {
state.clickTrayToShowQuickAssistant = action.payload
} }
} }
}) })
@ -285,7 +290,8 @@ export const {
setCustomCss, setCustomCss,
setTopicNamingPrompt, setTopicNamingPrompt,
setSidebarIcons, setSidebarIcons,
setNarrowMode setNarrowMode,
setClickTrayToShowQuickAssistant
} = settingsSlice.actions } = settingsSlice.actions
export default settingsSlice.reducer export default settingsSlice.reducer

View File

@ -51,6 +51,13 @@ const initialState: ShortcutsState = {
editable: true, editable: true,
enabled: true, enabled: true,
system: false system: false
},
{
key: 'mini_window',
shortcut: [isMac ? 'Command' : 'Ctrl', 'E'],
editable: true,
enabled: false,
system: true
} }
] ]
} }

View File

@ -0,0 +1,146 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Selection Menu</title>
<style>
:root {
--bg-color: rgba(255, 255, 255, 0.95);
--button-bg: #f5f5f5;
--button-hover: #e8e8e8;
--text-color: #333;
--border-color: rgba(0, 0, 0, 0.06);
}
@media (prefers-color-scheme: dark) {
:root {
--bg-color: rgba(80, 80, 80, 0.95);
--button-bg: #2c2c2c;
--button-hover: #383838;
--text-color: #e0e0e0;
--border-color: rgba(255, 255, 255, 0.08);
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
user-select: none;
}
body {
width: 280px;
height: 40px;
background: var(--bg-color);
overflow: hidden;
display: flex;
align-items: center;
}
.drag-handle {
width: 20px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
-webkit-app-region: drag;
}
.drag-handle::before,
.drag-handle::after {
content: '';
width: 2px;
height: 16px;
background-color: var(--border-color);
border-radius: 1px;
}
menu {
display: flex;
align-items: center;
height: 40px;
flex: 1;
margin-right: 10px;
gap: 5px;
}
button {
flex: 1;
min-width: 0;
height: 32px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: var(--text-color);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
outline: none;
}
button:hover {
background: var(--button-hover);
}
button:active {
transform: scale(0.95);
}
svg {
width: 16px;
height: 16px;
fill: currentColor;
}
</style>
</head>
<body>
<div class="drag-handle"></div>
<menu>
<button data-action="chat">
<svg viewBox="0 0 24 24">
<path d="M20,2H4C2.9,2,2,2.9,2,4v18l4-4h14c1.1,0,2-0.9,2-2V4C22,2.9,21.1,2,20,2z M20,16H6l-2,2V4h16V16z" />
</svg>
提问
</button>
<button data-action="explanation">
<svg viewBox="0 0 24 24">
<path
d="M12,2C6.48,2,2,6.48,2,12s4.48,10,10,10s10-4.48,10-10S17.52,2,12,2z M13,17h-2v-6h2V17z M13,9h-2V7h2V9z" />
</svg>
释义
</button>
<button data-action="translate">
<svg viewBox="0 0 24 24">
<path d="M6 4h12v2H6zM6 10h12v2H6zM6 16h8v2H6z" />
</svg>
翻译
</button>
<button data-action="summary">
<svg viewBox="0 0 24 24">
<path d="M14,17H4v2h10V17z M20,9H4v2h16V9z M4,15h16v-2H4V15z M4,5v2h16V5H4z" />
</svg>
总结
</button>
</menu>
<script>
document.querySelectorAll('button').forEach(button => {
button.addEventListener('click', () => {
const action = button.getAttribute('data-action')
window.api.selectionMenu.action(action)
})
})
</script>
</body>
</html>

View File

@ -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 (
<Provider store={store}>
<ThemeProvider defaultTheme={ThemeMode.auto}>
<AntdProvider>
<SyntaxHighlighterProvider>
<PersistGate loading={null} persistor={persistor}>
<HomeWindow />
</PersistGate>
</SyntaxHighlighterProvider>
</AntdProvider>
</ThemeProvider>
</Provider>
)
}
export default MiniWindow

View File

@ -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<Props> = ({ route }) => {
const { defaultAssistant } = useDefaultAssistant()
return (
<Main className="bubble">
<Messages assistant={{ ...defaultAssistant, model: getDefaultModel() }} route={route} />
</Main>
)
}
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

View File

@ -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<TextAreaRef>(null)
const [files, setFiles] = useState<FileType[]>([])
const { t } = useTranslation()
const containerRef = useRef(null)
const { searching } = useRuntime()
const dispatch = useAppDispatch()
const [spaceClickCount, setSpaceClickCount] = useState(0)
const spaceClickTimer = useRef<NodeJS.Timeout>()
const [isTranslating, setIsTranslating] = useState(false)
const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState<KnowledgeBase>()
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<HTMLTextAreaElement>) => {
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<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
}
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
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 (
<Container onDragOver={handleDragOver} onDrop={handleDrop}>
<AttachmentPreview files={files} setFiles={setFiles} />
<InputBarContainer id="inputbar" className={inputFocus ? 'focus' : ''} ref={containerRef}>
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={isTranslating ? t('chat.input.translating') : t('chat.input.placeholder')}
autoFocus
contextMenu="true"
variant="borderless"
rows={1}
ref={textareaRef}
style={{ fontSize }}
styles={{ textarea: TextareaStyle }}
onFocus={() => setInputFocus(true)}
onBlur={() => setInputFocus(false)}
onInput={onInput}
disabled={searching}
onPaste={(e) => onPaste(e.nativeEvent)}
onClick={() => searching && dispatch(setSearching(false))}
/>
<Toolbar>
<ToolbarMenu>
<Tooltip placement="top" title={t('chat.input.clear')} arrow>
<Popconfirm
title={t('chat.input.clear.content')}
placement="top"
onConfirm={clearTopic}
okButtonProps={{ danger: true }}
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
okText={t('chat.input.clear')}>
<ToolbarButton type="text">
<ClearOutlined />
</ToolbarButton>
</Popconfirm>
</Tooltip>
<KnowledgeBaseButton
selectedBase={selectedKnowledgeBase}
onSelect={handleKnowledgeBaseSelect}
ToolbarButton={ToolbarButton}
disabled={files.length > 0}
/>
<AttachmentButton
model={model}
files={files}
setFiles={setFiles}
ToolbarButton={ToolbarButton}
disabled={!!selectedKnowledgeBase}
/>
</ToolbarMenu>
<ToolbarMenu>
{!language.startsWith('en') && (
<TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} />
)}
{generating && (
<Tooltip placement="top" title={t('chat.input.pause')} arrow>
<ToolbarButton type="text" onClick={onPause} style={{ marginRight: -2, marginTop: 1 }}>
<PauseCircleOutlined style={{ color: 'var(--color-error)', fontSize: 20 }} />
</ToolbarButton>
</Tooltip>
)}
{!generating && <SendMessageButton sendMessage={sendMessage} disabled={generating || inputEmpty} />}
</ToolbarMenu>
</Toolbar>
</InputBarContainer>
</Container>
)
}
const Container = styled.div`
display: flex;
flex-direction: column;
-webkit-app-region: none;
`
const InputBarContainer = styled.div`
border: 1px solid var(--color-border);
transition: all 0.3s ease;
position: relative;
margin: 10px;
border-radius: 10px;
`
const TextareaStyle: CSSProperties = {
paddingLeft: 0,
padding: '10px 15px 8px'
}
const Textarea = styled(TextArea)`
padding: 0;
border-radius: 0;
display: flex;
flex: 1;
font-family: Ubuntu;
resize: none !important;
overflow: auto;
width: 100%;
box-sizing: border-box;
&.ant-input {
line-height: 1.4;
}
`
const Toolbar = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 0 8px;
padding-bottom: 0;
margin-bottom: 4px;
height: 36px;
`
const ToolbarMenu = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
`
const ToolbarButton = styled(Button)`
width: 30px;
height: 30px;
font-size: 17px;
border-radius: 50%;
transition: all 0.3s ease;
color: var(--color-icon);
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 0;
&.anticon,
&.iconfont {
transition: all 0.3s ease;
color: var(--color-icon);
}
.icon-a-addchat {
font-size: 19px;
margin-bottom: -2px;
}
&:hover {
background-color: var(--color-background-soft);
.anticon,
.iconfont {
color: var(--color-text-1);
}
}
&.active {
background-color: var(--color-primary) !important;
.anticon,
.iconfont {
color: var(--color-white-soft);
}
&:hover {
background-color: var(--color-primary);
}
}
`
export default Inputbar

View File

@ -0,0 +1,122 @@
import { FONT_FAMILY } from '@renderer/config/constant'
import { useModel } from '@renderer/hooks/useModel'
import { useSettings } from '@renderer/hooks/useSettings'
import MessageContent from '@renderer/pages/home/Messages/MessageContent'
import MessageErrorBoundary from '@renderer/pages/home/Messages/MessageErrorBoundary'
import { fetchChatCompletion } from '@renderer/services/ApiService'
import { getDefaultAssistant, getDefaultModel } from '@renderer/services/AssistantService'
import { Message } from '@renderer/types'
import { Dispatch, FC, memo, SetStateAction, useEffect, useMemo, useRef, useState } from 'react'
import styled from 'styled-components'
interface Props {
message: Message
index?: number
total?: number
route: string
onGetMessages?: () => Message[]
onSetMessages?: Dispatch<SetStateAction<Message[]>>
}
const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolean) =>
isBubbleStyle ? (isAssistantMessage ? 'transparent' : 'var(--chat-background-user)') : undefined
const MessageItem: FC<Props> = ({ message: _message, index, total, route, onSetMessages, onGetMessages }) => {
const [message, setMessage] = useState(_message)
const model = useModel(message.modelId)
const isBubbleStyle = true
const { messageFont, fontSize } = useSettings()
const messageContainerRef = useRef<HTMLDivElement>(null)
const isAssistantMessage = message.role === 'assistant'
const fontFamily = useMemo(() => {
return messageFont === 'serif' ? FONT_FAMILY.replace('sans-serif', 'serif').replace('Ubuntu, ', '') : FONT_FAMILY
}, [messageFont])
const messageBackground = getMessageBackground(true, isAssistantMessage)
useEffect(() => {
if (onGetMessages && onSetMessages) {
if (message.status === 'sending') {
const messages = onGetMessages()
fetchChatCompletion({
message,
messages: messages
.filter((m) => !m.status.includes('ing'))
.slice(
0,
messages.findIndex((m) => m.id === message.id)
),
assistant: { ...getDefaultAssistant(), model: getDefaultModel() },
onResponse: (msg) => {
setMessage(msg)
if (msg.status !== 'pending') {
const _messages = messages.map((m) => (m.id === msg.id ? msg : m))
onSetMessages(_messages)
}
}
})
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [message.status])
if (['summary', 'explanation'].includes(route) && index === total - 1) {
return null
}
return (
<MessageContainer
key={message.id}
ref={messageContainerRef}
style={{ ...(isBubbleStyle ? { alignItems: isAssistantMessage ? 'start' : 'end' } : {}) }}>
<MessageContentContainer
className="message-content-container"
style={{
fontFamily,
fontSize,
background: messageBackground,
...(isAssistantMessage ? { paddingLeft: 5, paddingRight: 5 } : {})
}}>
<MessageErrorBoundary>
<MessageContent message={message} model={model} />
</MessageErrorBoundary>
</MessageContentContainer>
</MessageContainer>
)
}
const MessageContainer = styled.div`
display: flex;
flex-direction: column;
position: relative;
transition: background-color 0.3s ease;
&.message-highlight {
background-color: var(--color-primary-mute);
}
.menubar {
opacity: 0;
transition: opacity 0.2s ease;
&.show {
opacity: 1;
}
}
&:hover {
.menubar {
opacity: 1;
}
}
`
const MessageContentContainer = styled.div`
max-width: 100%;
display: flex;
flex: 1;
flex-direction: column;
justify-content: space-between;
margin-left: 46px;
margin-top: 5px;
`
export default memo(MessageItem)

View File

@ -0,0 +1,73 @@
import Scrollbar from '@renderer/components/Scrollbar'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { getAssistantMessage } from '@renderer/services/MessagesService'
import { Assistant, Message } from '@renderer/types'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
import MessageItem from './Message'
interface Props {
assistant: Assistant
route: string
}
interface ContainerProps {
right?: boolean
}
const Messages: FC<Props> = ({ assistant, route }) => {
const [messages, setMessages] = useState<Message[]>([])
const containerRef = useRef<HTMLDivElement>(null)
const messagesRef = useRef(messages)
messagesRef.current = messages
const onSendMessage = useCallback(
async (message: Message) => {
setMessages((prev) => {
const assistantMessage = getAssistantMessage({ assistant, topic: assistant.topics[0] })
const messages = prev.concat([message, assistantMessage])
return messages
})
},
[assistant]
)
const onGetMessages = useCallback(() => {
return messagesRef.current
}, [])
useEffect(() => {
const unsubscribes = [EventEmitter.on(EVENT_NAMES.SEND_MESSAGE, onSendMessage)]
return () => unsubscribes.forEach((unsub) => unsub())
}, [assistant.id, onSendMessage])
return (
<Container id="messages" key={assistant.id} ref={containerRef}>
{[...messages].reverse().map((message, index) => (
<MessageItem
key={message.id}
message={message}
index={index}
total={messages.length}
onSetMessages={setMessages}
onGetMessages={onGetMessages}
route={route}
/>
))}
</Container>
)
}
const Container = styled(Scrollbar)<ContainerProps>`
display: flex;
flex-direction: column-reverse;
padding-bottom: 20px;
overflow-x: hidden;
min-width: 100%;
background-color: transparent !important;
`
export default Messages

View File

@ -0,0 +1,203 @@
import { isMac } from '@renderer/config/constant'
import { useDefaultAssistant, useDefaultModel } from '@renderer/hooks/useAssistant'
import { EVENT_NAMES } from '@renderer/services/EventService'
import { EventEmitter } from '@renderer/services/EventService'
import { uuid } from '@renderer/utils'
import { Divider } from 'antd'
import dayjs from 'dayjs'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import ChatWindow from '../chat/ChatWindow'
import ClipboardPreview from './components/ClipboardPreview'
import FeatureMenus from './components/FeatureMenus'
import Footer from './components/Footer'
import InputBar from './components/InputBar'
import Translate from './Translate'
const HomeWindow: FC = () => {
const [route, setRoute] = useState<'home' | 'chat' | 'translate' | 'summary' | 'explanation'>('home')
const [clipboardText, setClipboardText] = useState('')
const [selectedText, setSelectedText] = useState('')
const [text, setText] = useState('')
const { defaultAssistant } = useDefaultAssistant()
const { defaultModel: model } = useDefaultModel()
const { t } = useTranslation()
const textRef = useRef(text)
const referenceText = selectedText || clipboardText
textRef.current = `${referenceText}\n\n${text}`
const isMiniWindow = window.location.hash === '#/mini'
const onReadClipboard = useCallback(async () => {
const text = await navigator.clipboard.readText()
setClipboardText(text.trim())
}, [])
useEffect(() => {
onReadClipboard()
}, [onReadClipboard])
const onCloseWindow = () => isMiniWindow && window.close()
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
setText('')
setRoute('home')
route === 'home' && onCloseWindow()
return
}
if (e.key === 'Enter') {
if (text.trim() === '') {
return
}
setRoute('chat')
onSendMessage()
setTimeout(() => setText(''), 100)
}
}
const onSendMessage = useCallback(
async (prompt?: string) => {
setTimeout(() => {
const message = {
id: uuid(),
role: 'user',
content: prompt ? `${prompt}\n\n${textRef.current}` : textRef.current,
assistantId: defaultAssistant.id,
topicId: defaultAssistant.topics[0].id || uuid(),
createdAt: dayjs().format('YYYY-MM-DD HH:mm:ss'),
type: 'text',
status: 'success'
}
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, message)
}, 0)
},
[defaultAssistant]
)
const clearClipboard = () => {
setClipboardText('')
setSelectedText('')
navigator.clipboard.writeText('')
}
useHotkeys('esc', () => {
if (route === 'home') {
onCloseWindow()
} else {
setRoute('home')
setText('')
}
})
useEffect(() => {
window.electron.ipcRenderer.on('show-mini-window', () => {
onReadClipboard()
})
window.electron.ipcRenderer.on('selection-action', (_, { action, selectedText }) => {
console.debug('[HomeWindow] selection-action', action, selectedText)
selectedText && setSelectedText(selectedText)
action && setRoute(action)
action === 'chat' && onSendMessage()
})
return () => {
window.electron.ipcRenderer.removeAllListeners('show-mini-window')
window.electron.ipcRenderer.removeAllListeners('selection-action')
}
}, [onReadClipboard, onSendMessage, setRoute])
if (['chat', 'summary', 'explanation'].includes(route)) {
return (
<Container>
{route === 'chat' && (
<>
<InputBar
text={text}
model={model}
referenceText={referenceText}
placeholder={t('miniwindow.input.placeholder.empty', { model: model.name })}
handleKeyDown={handleKeyDown}
setText={setText}
/>
<Divider style={{ margin: '10px 0' }} />
</>
)}
{['summary', 'explanation'].includes(route) && (
<div style={{ marginTop: 10 }}>
<ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} />
</div>
)}
<ChatWindow route={route} />
<Divider style={{ margin: '10px 0' }} />
<Footer route={route} onExit={() => setRoute('home')} />
</Container>
)
}
if (route === 'translate') {
return (
<Container>
<Translate text={referenceText} />
<Divider style={{ margin: '10px 0' }} />
<Footer route={route} onExit={() => setRoute('home')} />
</Container>
)
}
return (
<Container>
<InputBar
text={text}
model={model}
referenceText={referenceText}
placeholder={
referenceText && route === 'home'
? t('miniwindow.input.placeholder.title')
: t('miniwindow.input.placeholder.empty', { model: model.name })
}
handleKeyDown={handleKeyDown}
setText={setText}
/>
<Divider style={{ margin: '10px 0' }} />
<ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} />
<Main>
<FeatureMenus setRoute={setRoute} onSendMessage={onSendMessage} />
</Main>
<Divider style={{ margin: '10px 0' }} />
<Footer
route={route}
onExit={() => {
setRoute('home')
setText('')
onCloseWindow()
}}
/>
</Container>
)
}
const Container = styled.div`
display: flex;
flex: 1;
height: 100%;
flex-direction: column;
-webkit-app-region: drag;
padding: 8px 10px;
background-color: ${isMac ? 'transparent' : 'var(--color-background)'};
`
const Main = styled.main`
display: flex;
flex: 1;
overflow: hidden;
`
export default HomeWindow

View File

@ -0,0 +1,176 @@
import Scrollbar from '@renderer/components/Scrollbar'
import db from '@renderer/databases'
import { useDefaultModel } from '@renderer/hooks/useAssistant'
import { fetchTranslate } from '@renderer/services/ApiService'
import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
import { Assistant, Message } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { Select, Space } from 'antd'
import { FC, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface Props {
text: string
}
const Translate: FC<Props> = ({ text }) => {
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const [result, setResult] = useState('')
const [targetLanguage, setTargetLanguage] = useState('chinese')
const { translateModel } = useDefaultModel()
const languageOptions = [
{
value: 'english',
label: t('languages.english'),
emoji: '🇬🇧'
},
{
value: 'chinese',
label: t('languages.chinese'),
emoji: '🇨🇳'
},
{
value: 'chinese-traditional',
label: t('languages.chinese-traditional'),
emoji: '🇭🇰'
},
{
value: 'japanese',
label: t('languages.japanese'),
emoji: '🇯🇵'
},
{
value: 'korean',
label: t('languages.korean'),
emoji: '🇰🇷'
},
{
value: 'russian',
label: t('languages.russian'),
emoji: '🇷🇺'
},
{
value: 'spanish',
label: t('languages.spanish'),
emoji: '🇪🇸'
},
{
value: 'french',
label: t('languages.french'),
emoji: '🇫🇷'
},
{
value: 'italian',
label: t('languages.italian'),
emoji: '🇮🇹'
},
{
value: 'portuguese',
label: t('languages.portuguese'),
emoji: '🇵🇹'
},
{
value: 'arabic',
label: t('languages.arabic'),
emoji: '🇸🇦'
}
]
const translate = useCallback(async () => {
if (!text.trim() || !translateModel) return
const assistant: Assistant = getDefaultTranslateAssistant(targetLanguage, text)
const message: Message = {
id: uuid(),
role: 'user',
content: text,
assistantId: assistant.id,
topicId: uuid(),
modelId: translateModel.id,
createdAt: new Date().toISOString(),
type: 'text',
status: 'sending'
}
setLoading(true)
const translateText = await fetchTranslate({ message, assistant })
setResult(translateText)
setLoading(false)
}, [text, targetLanguage, translateModel])
useEffect(() => {
// 获取默认目标语言
db.settings.get({ id: 'translate:target:language' }).then((targetLang) => {
if (targetLang) {
setTargetLanguage(targetLang.value)
}
})
}, [])
useEffect(() => {
translate()
}, [])
return (
<Container>
<LanguageSelect>
<Select
value={targetLanguage}
style={{ width: 140 }}
optionFilterProp="label"
options={languageOptions}
onChange={(value) => {
setTargetLanguage(value)
translate()
}}
optionRender={(option) => (
<Space>
<span role="img" aria-label={option.data.label}>
{option.data.emoji}
</span>
{option.label}
</Space>
)}
/>
</LanguageSelect>
<Main>{loading ? <LoadingText>...</LoadingText> : <ResultText>{result || text}</ResultText>}</Main>
</Container>
)
}
const Container = styled.div`
display: flex;
flex-direction: column;
flex: 1;
padding: 12px;
padding-right: 0;
overflow: hidden;
-webkit-app-region: none;
`
const Main = styled.div`
display: flex;
flex: 1;
width: 100%;
overflow: hidden;
`
const ResultText = styled(Scrollbar)`
white-space: pre-wrap;
word-break: break-word;
width: 100%;
`
const LoadingText = styled.div`
color: var(--color-text-2);
font-style: italic;
`
const LanguageSelect = styled.div`
margin-bottom: 8px;
`
export default Translate

View File

@ -0,0 +1,62 @@
import { CloseOutlined } from '@ant-design/icons'
import CopyIcon from '@renderer/components/Icons/CopyIcon'
import { Typography } from 'antd'
import { FC } from 'react'
import styled from 'styled-components'
interface ClipboardPreviewProps {
referenceText: string
clearClipboard: () => void
t: (key: string) => string
}
const { Paragraph } = Typography
const ClipboardPreview: FC<ClipboardPreviewProps> = ({ referenceText, clearClipboard, t }) => {
if (!referenceText) return null
return (
<Container>
<ClipboardContent>
<CopyIcon style={{ fontSize: '14px', flexShrink: 0, cursor: 'pointer' }} className="nodrag" />
<Paragraph ellipsis={{ rows: 2 }} style={{ margin: '0 12px', fontSize: 12, flex: 1, minWidth: 0 }}>
{referenceText || t('miniwindow.clipboard.empty')}
</Paragraph>
<CloseButton onClick={clearClipboard} className="nodrag">
<CloseOutlined style={{ fontSize: '14px' }} />
</CloseButton>
</ClipboardContent>
</Container>
)
}
const Container = styled.div`
padding: 12px;
background-color: var(--color-background-opacity);
border-radius: 8px;
margin-bottom: 10px;
`
const ClipboardContent = styled.div`
display: flex;
align-items: center;
width: 100%;
color: var(--color-text-secondary);
`
const CloseButton = styled.button`
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&:hover {
color: var(--color-text);
}
`
export default ClipboardPreview

View File

@ -0,0 +1,108 @@
import { BulbOutlined, FileTextOutlined, MessageOutlined, TranslationOutlined } from '@ant-design/icons'
import Scrollbar from '@renderer/components/Scrollbar'
import { Col } from 'antd'
import { Dispatch, FC, SetStateAction } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface FeatureMenusProps {
setRoute: Dispatch<SetStateAction<'translate' | 'summary' | 'chat' | 'explanation' | 'home'>>
onSendMessage: (prompt?: string) => void
}
const FeatureMenus: FC<FeatureMenusProps> = ({ setRoute, onSendMessage }) => {
const { t } = useTranslation()
const features = [
{
icon: <MessageOutlined style={{ fontSize: '16px', color: 'var(--color-text)' }} />,
title: t('miniwindow.feature.chat'),
active: true,
onClick: () => {
setRoute('chat')
onSendMessage()
}
},
{
icon: <TranslationOutlined style={{ fontSize: '16px', color: 'var(--color-text)' }} />,
title: t('miniwindow.feature.translate'),
onClick: () => setRoute('translate')
},
{
icon: <FileTextOutlined style={{ fontSize: '16px', color: 'var(--color-text)' }} />,
title: t('miniwindow.feature.summary'),
onClick: () => {
setRoute('summary')
onSendMessage(t('prompts.summarize'))
}
},
{
icon: <BulbOutlined style={{ fontSize: '16px', color: 'var(--color-text)' }} />,
title: t('miniwindow.feature.explanation'),
onClick: () => {
setRoute('explanation')
onSendMessage(t('prompts.explanation'))
}
}
]
return (
<FeatureList>
<FeatureListWrapper>
{features.map((feature, index) => (
<Col span={24} key={index}>
<FeatureItem onClick={feature.onClick} className={feature.active ? 'active' : ''}>
<FeatureIcon>{feature.icon}</FeatureIcon>
<FeatureTitle>{feature.title}</FeatureTitle>
</FeatureItem>
</Col>
))}
</FeatureListWrapper>
</FeatureList>
)
}
const FeatureList = styled(Scrollbar)`
flex: 1;
-webkit-app-region: none;
`
const FeatureListWrapper = styled.div`
display: flex;
flex-direction: column;
gap: 5px;
cursor: pointer;
`
const FeatureItem = styled.div`
cursor: pointer;
transition: all 0.3s;
background: transparent;
border: none;
padding: 8px 16px;
display: flex;
align-items: center;
gap: 12px;
-webkit-app-region: none;
border-radius: 8px;
user-select: none;
&:hover {
background: var(--color-background-opacity);
}
&.active {
background: var(--color-background-opacity);
}
`
const FeatureIcon = styled.div`
color: #fff;
`
const FeatureTitle = styled.h3`
margin: 0;
font-size: 14px;
`
export default FeatureMenus

View File

@ -0,0 +1,37 @@
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface FooterProps {
route: string
onExit: () => void
}
const Footer: FC<FooterProps> = ({ route, onExit }) => {
const { t } = useTranslation()
return (
<WindowFooter onClick={() => onExit()}>
<FooterText className="nodrag">
{t('miniwindow.footer.esc', {
action: route === 'home' ? t('miniwindow.footer.esc_close') : t('miniwindow.footer.esc_back')
})}
</FooterText>
</WindowFooter>
)
}
const WindowFooter = styled.div`
text-align: center;
padding: 5px 0;
color: var(--color-text-secondary);
font-size: 12px;
cursor: pointer;
`
const FooterText = styled.span`
color: var(--color-text-secondary);
font-size: 12px;
`
export default Footer

View File

@ -0,0 +1,47 @@
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { Input as AntdInput } from 'antd'
import { FC } from 'react'
import styled from 'styled-components'
interface InputBarProps {
text: string
model: any
referenceText: string
placeholder: string
handleKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
setText: (text: string) => void
}
const InputBar: FC<InputBarProps> = ({ text, model, placeholder, handleKeyDown, setText }) => {
const { generating } = useRuntime()
return (
<InputWrapper>
<ModelAvatar model={model} size={30} />
<Input
value={text}
placeholder={placeholder}
bordered={false}
autoFocus
onKeyDown={handleKeyDown}
onChange={(e) => setText(e.target.value)}
disabled={generating}
/>
</InputWrapper>
)
}
const InputWrapper = styled.div`
display: flex;
align-items: center;
margin-top: 10px;
`
const Input = styled(AntdInput)`
background: none;
border: none;
-webkit-app-region: none;
font-size: 18px;
`
export default InputBar

120
yarn.lock
View File

@ -2495,6 +2495,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/ffi-napi@npm:^4":
version: 4.0.10
resolution: "@types/ffi-napi@npm:4.0.10"
dependencies:
"@types/node": "npm:*"
"@types/ref-napi": "npm:*"
"@types/ref-struct-di": "npm:*"
checksum: 10c0/309e4659676ea978c76ebd336935f7d7481343e982c0a6e93e5cae09ffd45530171ea1bc5832cf21a3358828e7d96d32ccb436785abb34dcbba6ecfbcc0d3f30
languageName: node
linkType: hard
"@types/fs-extra@npm:9.0.13, @types/fs-extra@npm:^9.0.11": "@types/fs-extra@npm:9.0.13, @types/fs-extra@npm:^9.0.11":
version: 9.0.13 version: 9.0.13
resolution: "@types/fs-extra@npm:9.0.13" resolution: "@types/fs-extra@npm:9.0.13"
@ -2724,6 +2735,24 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/ref-napi@npm:*, @types/ref-napi@npm:^3":
version: 3.0.12
resolution: "@types/ref-napi@npm:3.0.12"
dependencies:
"@types/node": "npm:*"
checksum: 10c0/d60c3e07aa908c97fdf3ea958264ca0f88d080467c045f423d7dda43d475b35e7a5798b108ddfbce7ced04a70d9787acf99991e251c6f8c0556be6e71c0e02b8
languageName: node
linkType: hard
"@types/ref-struct-di@npm:*":
version: 1.1.12
resolution: "@types/ref-struct-di@npm:1.1.12"
dependencies:
"@types/ref-napi": "npm:*"
checksum: 10c0/c52043a067fd419b30ebbfe794cad9e44606a100a31aa4d094effc121a7c30ce654dd287809f57b289f0622fb642b916a9d90b559f7f721e21c322075ce149c1
languageName: node
linkType: hard
"@types/responselike@npm:^1.0.0": "@types/responselike@npm:^1.0.0":
version: 1.0.3 version: 1.0.3
resolution: "@types/responselike@npm:1.0.3" resolution: "@types/responselike@npm:1.0.3"
@ -2999,6 +3028,7 @@ __metadata:
"@llm-tools/embedjs-openai": "npm:^0.1.25" "@llm-tools/embedjs-openai": "npm:^0.1.25"
"@reduxjs/toolkit": "npm:^2.2.5" "@reduxjs/toolkit": "npm:^2.2.5"
"@types/adm-zip": "npm:^0" "@types/adm-zip": "npm:^0"
"@types/ffi-napi": "npm:^4"
"@types/fs-extra": "npm:^11" "@types/fs-extra": "npm:^11"
"@types/lodash": "npm:^4.17.5" "@types/lodash": "npm:^4.17.5"
"@types/markdown-it": "npm:^14" "@types/markdown-it": "npm:^14"
@ -3006,11 +3036,13 @@ __metadata:
"@types/react": "npm:^18.2.48" "@types/react": "npm:^18.2.48"
"@types/react-dom": "npm:^18.2.18" "@types/react-dom": "npm:^18.2.18"
"@types/react-infinite-scroll-component": "npm:^5.0.0" "@types/react-infinite-scroll-component": "npm:^5.0.0"
"@types/ref-napi": "npm:^3"
"@types/tinycolor2": "npm:^1" "@types/tinycolor2": "npm:^1"
"@vitejs/plugin-react": "npm:^4.2.1" "@vitejs/plugin-react": "npm:^4.2.1"
adm-zip: "npm:^0.5.16" adm-zip: "npm:^0.5.16"
antd: "npm:^5.22.5" antd: "npm:^5.22.5"
apache-arrow: "npm:^18.1.0" apache-arrow: "npm:^18.1.0"
applescript: "npm:^1.0.0"
axios: "npm:^1.7.3" axios: "npm:^1.7.3"
browser-image-compression: "npm:^2.0.2" browser-image-compression: "npm:^2.0.2"
dayjs: "npm:^1.11.11" dayjs: "npm:^1.11.11"
@ -3034,6 +3066,7 @@ __metadata:
eslint-plugin-react-hooks: "npm:^4.6.2" eslint-plugin-react-hooks: "npm:^4.6.2"
eslint-plugin-simple-import-sort: "npm:^12.1.1" eslint-plugin-simple-import-sort: "npm:^12.1.1"
eslint-plugin-unused-imports: "npm:^4.0.0" eslint-plugin-unused-imports: "npm:^4.0.0"
ffi-napi: "npm:^4.0.3"
fs-extra: "npm:^11.2.0" fs-extra: "npm:^11.2.0"
html2canvas: "npm:^1.4.1" html2canvas: "npm:^1.4.1"
i18next: "npm:^23.11.5" i18next: "npm:^23.11.5"
@ -3055,6 +3088,7 @@ __metadata:
react-spinners: "npm:^0.14.1" react-spinners: "npm:^0.14.1"
redux: "npm:^5.0.1" redux: "npm:^5.0.1"
redux-persist: "npm:^6.0.0" redux-persist: "npm:^6.0.0"
ref-napi: "npm:^3.0.3"
rehype-katex: "npm:^7.0.1" rehype-katex: "npm:^7.0.1"
rehype-mathjax: "npm:^6.0.0" rehype-mathjax: "npm:^6.0.0"
rehype-raw: "npm:^7.0.0" rehype-raw: "npm:^7.0.0"
@ -3073,6 +3107,11 @@ __metadata:
peerDependencies: peerDependencies:
react: ^17.0.0 || ^18.0.0 react: ^17.0.0 || ^18.0.0
react-dom: ^17.0.0 || ^18.0.0 react-dom: ^17.0.0 || ^18.0.0
dependenciesMeta:
ffi-napi:
optional: true
ref-napi:
optional: true
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@ -3390,6 +3429,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"applescript@npm:^1.0.0":
version: 1.0.0
resolution: "applescript@npm:1.0.0"
checksum: 10c0/b535e7df97a3e1272d1b8e8c832494ba3933fbad879847cb83c8990c08aed5bcb097d2af200ba2e0754c3467c2367441706b7864173e1aa9ee4132f5189287f0
languageName: node
linkType: hard
"aproba@npm:^1.0.3 || ^2.0.0": "aproba@npm:^1.0.3 || ^2.0.0":
version: 2.0.0 version: 2.0.0
resolution: "aproba@npm:2.0.0" resolution: "aproba@npm:2.0.0"
@ -6098,6 +6144,21 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"ffi-napi@npm:^4.0.3":
version: 4.0.3
resolution: "ffi-napi@npm:4.0.3"
dependencies:
debug: "npm:^4.1.1"
get-uv-event-loop-napi-h: "npm:^1.0.5"
node-addon-api: "npm:^3.0.0"
node-gyp: "npm:latest"
node-gyp-build: "npm:^4.2.1"
ref-napi: "npm:^2.0.1 || ^3.0.2"
ref-struct-di: "npm:^1.1.0"
checksum: 10c0/03a81bc228da1fa4f65431a3a54cf8e0367f9be45173ffd3085f13ec44b7bb9124b703535aa0b49955207f083daca86795759d6513db1605d1ed868c76d199de
languageName: node
linkType: hard
"fflate@npm:0.8.1": "fflate@npm:0.8.1":
version: 0.8.1 version: 0.8.1
resolution: "fflate@npm:0.8.1" resolution: "fflate@npm:0.8.1"
@ -6591,6 +6652,22 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"get-symbol-from-current-process-h@npm:^1.0.1, get-symbol-from-current-process-h@npm:^1.0.2":
version: 1.0.2
resolution: "get-symbol-from-current-process-h@npm:1.0.2"
checksum: 10c0/f33109e08aef7029b16f18032dce669e92efb21992946d7b575e494f32907c3d2c353685e00398b9202c332aab140e444e9333c93f2b81aa662bde3f59e3a25a
languageName: node
linkType: hard
"get-uv-event-loop-napi-h@npm:^1.0.5":
version: 1.0.6
resolution: "get-uv-event-loop-napi-h@npm:1.0.6"
dependencies:
get-symbol-from-current-process-h: "npm:^1.0.1"
checksum: 10c0/beb601c7e4b74fec51fb33df452a214943a1453bac9e3ca66b98fabb9ac9371f07b53d43b163a20b69228003eaf61a84b9b3faf692ed896ef66d84970411d5e0
languageName: node
linkType: hard
"getpass@npm:^0.1.1": "getpass@npm:^0.1.1":
version: 0.1.7 version: 0.1.7
resolution: "getpass@npm:0.1.7" resolution: "getpass@npm:0.1.7"
@ -9800,6 +9877,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"node-addon-api@npm:^3.0.0":
version: 3.2.1
resolution: "node-addon-api@npm:3.2.1"
dependencies:
node-gyp: "npm:latest"
checksum: 10c0/41f21c9d12318875a2c429befd06070ce367065a3ef02952cfd4ea17ef69fa14012732f510b82b226e99c254da8d671847ea018cad785f839a5366e02dd56302
languageName: node
linkType: hard
"node-addon-api@npm:^7.0.0": "node-addon-api@npm:^7.0.0":
version: 7.1.1 version: 7.1.1
resolution: "node-addon-api@npm:7.1.1" resolution: "node-addon-api@npm:7.1.1"
@ -9848,6 +9934,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"node-gyp-build@npm:^4.2.1":
version: 4.8.4
resolution: "node-gyp-build@npm:4.8.4"
bin:
node-gyp-build: bin.js
node-gyp-build-optional: optional.js
node-gyp-build-test: build-test.js
checksum: 10c0/444e189907ece2081fe60e75368784f7782cfddb554b60123743dfb89509df89f1f29c03bbfa16b3a3e0be3f48799a4783f487da6203245fa5bed239ba7407e1
languageName: node
linkType: hard
"node-gyp@npm:8.x": "node-gyp@npm:8.x":
version: 8.4.1 version: 8.4.1
resolution: "node-gyp@npm:8.4.1" resolution: "node-gyp@npm:8.4.1"
@ -11790,6 +11887,29 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"ref-napi@npm:^2.0.1 || ^3.0.2, ref-napi@npm:^3.0.3":
version: 3.0.3
resolution: "ref-napi@npm:3.0.3"
dependencies:
debug: "npm:^4.1.1"
get-symbol-from-current-process-h: "npm:^1.0.2"
node-addon-api: "npm:^3.0.0"
node-gyp: "npm:latest"
node-gyp-build: "npm:^4.2.1"
checksum: 10c0/03768cfe6134061c4afe8c5da6cc03ced9763ad1b8401895df2c5f6b10211dfd2bbcf31cca256e2abe9f2d4ddf08333590a6589b2ec5e68efacf70de2c0e2d7b
languageName: node
linkType: hard
"ref-struct-di@npm:^1.1.0":
version: 1.1.1
resolution: "ref-struct-di@npm:1.1.1"
dependencies:
debug: "npm:^3.1.0"
node-gyp: "npm:latest"
checksum: 10c0/e456f4b228647d06af1c911bd3f90fd85f7b9d88aa9707e10fa2879fee92d2e7a3035ca819cb30b0bdc1b32751a628308229204b00a0fb3b0e93b002dcef9835
languageName: node
linkType: hard
"reflect.getprototypeof@npm:^1.0.6, reflect.getprototypeof@npm:^1.0.9": "reflect.getprototypeof@npm:^1.0.6, reflect.getprototypeof@npm:^1.0.9":
version: 1.0.10 version: 1.0.10
resolution: "reflect.getprototypeof@npm:1.0.10" resolution: "reflect.getprototypeof@npm:1.0.10"