Compare commits

...

54 Commits

Author SHA1 Message Date
kangfenmao
a6833d5994 chore(version): 0.9.13 2025-01-20 13:11:26 +08:00
kangfenmao
d850fd315a feat: add onclick event to login icon in footer component 2025-01-20 12:57:26 +08:00
kangfenmao
c04fd62bec feat: extended safety threshold check to include 'thinking-exp' model ids 2025-01-20 12:55:24 +08:00
kangfenmao
f86a274cd3 feat: update contact email address 2025-01-20 12:20:46 +08:00
kangfenmao
798a6e8c3e chore(version): 0.9.12 2025-01-20 11:52:26 +08:00
kangfenmao
749353f460 feat: added copy last message feature and translations 2025-01-20 11:09:57 +08:00
kangfenmao
c510f5dcce feat: added utility function, sorting, and new shortcut 2025-01-20 10:29:44 +08:00
kangfenmao
46b314303c feat: enable pinned functionality for minapps and update 'flowith' configuration 2025-01-20 09:58:47 +08:00
kangfenmao
b01aca9066 fix: prevent unnecessary route changes and trim input field on change 2025-01-20 09:52:58 +08:00
ousugo
725f81c165 fix: conditionally render pin button based on app ID 2025-01-20 09:32:13 +08:00
ousugo
c0e25879e5 feat: add Flowith minapp, resolve #780 2025-01-20 09:31:34 +08:00
MrChen
4c22c404ca feat: add the shortcuts for 'clear' and 'new context' and fix (#786)
* Fix: ESC key to exit the expanded editor

* Add the shortcuts for 'clear' and 'new context' to the input bar
Clear Messages: Ctrl+L
New Context: Ctrl+R
https://github.com/CherryHQ/cherry-studio/issues/740
https://github.com/CherryHQ/cherry-studio/issues/766

* Fix: the paste issue when copying from an email (content was pasted as an image; ensure it is pasted as text). Prioritize the text in the clipboard during pasting.
2025-01-20 09:31:09 +08:00
kangfenmao
63673ec39f chore(version): 0.9.11 2025-01-19 20:50:33 +08:00
kangfenmao
88cc783a95 fix: quick assistant bugs 2025-01-19 20:03:45 +08:00
kangfenmao
9c55b4516c feat: add a startup switch for quick assistant 2025-01-19 19:22:25 +08:00
kangfenmao
aecc5fefcf feat: translate support stream output 2025-01-19 16:56:35 +08:00
kangfenmao
afc2e2f595 feat: auto-scroll to selected menu item on model open 2025-01-19 15:47:19 +08:00
kangfenmao
67b63ee07a refactor: add qwenlm provider 2025-01-19 15:39:48 +08:00
kangfenmao
fd7132cd3a fix: store minapp url use base64 data image 2025-01-19 15:35:17 +08:00
kangfenmao
a7d9700f06 feat: add mini window 2025-01-19 13:59:32 +08:00
ousugo
d9bb552f3f feat: add pinning functionality for MinApp component 2025-01-19 13:59:06 +08:00
ousugo
ad2713c0be fix: fix wrong NVIDIA official website link, fix #771 2025-01-19 13:59:06 +08:00
牡丹凤凰
1e756614f9 Delete .github/workflows/update-lmarena.yml 2025-01-19 13:59:06 +08:00
牡丹凤凰
d457dfa3d3 Update update-lmarena.yml 2025-01-19 13:59:06 +08:00
牡丹凤凰
b24d88dfe3 Create update-lmarena.yml 2025-01-19 13:59:06 +08:00
kangfenmao
b6d598c52e fix: remove default message for webdav backup initiation 2025-01-19 13:59:06 +08:00
kangfenmao
67e1dd56e9 style: increased padding at the bottom of the sidebar component 2025-01-19 13:59:06 +08:00
kangfenmao
8b5dd427d0 fix: WebDAV not automatic backup on app reopened #752 2025-01-19 13:59:06 +08:00
kangfenmao
4f44afeec4 feat: auto focs input textarea #759
close #759
2025-01-19 13:59:06 +08:00
kangfenmao
c46219cd6c feat: improved 'my agents' list rendering 2025-01-19 13:59:06 +08:00
magicdmer
999bd802c4 perf: 优化智能体页面性能和体验 (#756)
* feat: improved model validation and error handling

* refactor: 优化智能体页面下拉流畅度和分类切换效果,让其更加顺畅自然

---------

Co-authored-by: kangfenmao <kangfenmao@qq.com>
Co-authored-by: magicdmer <magicdmer@163.com>
2025-01-19 13:59:06 +08:00
kangfenmao
2300cca070 refactor: improved code organization and reusability 2025-01-19 13:59:06 +08:00
kangfenmao
b4de6292c3 feat: improved model safety settings for geminiprovider class 2025-01-19 13:59:06 +08:00
magicdmer
42908e8834 refactor: (GeminiProvider) optimize safety settings handling
- Extract safety threshold logic into getModelSafetySetting method
- gemini-exp-* models not support 'OFF', must use 'BLOCK_NONE'
2025-01-19 13:59:06 +08:00
kangfenmao
57718dda6f feat: update harmblockthreshold for harm_category_civic_integrity 2025-01-19 13:59:06 +08:00
kangfenmao
c87e88a53a feat: add civic integrity category to harm block settings in GeminiProvider 2025-01-19 13:59:06 +08:00
kangfenmao
5b00c21f15 feat: update safety settings for specific categories #696
Gemini安全设置是否没有完全关闭
2025-01-19 13:59:06 +08:00
kangfenmao
6276890e5b feat: replaced visionicon with modeltags 2025-01-19 13:59:06 +08:00
kangfenmao
a7337ed4b0 feat: add 思维链(CoT) agent 2025-01-19 13:59:06 +08:00
kangfenmao
fe0f6318c9 fix: improved openai provider model id validation logic 2025-01-19 13:59:06 +08:00
magicdmer
75742323ea fix: 修正o1模型无法使用的问题 2025-01-19 13:59:06 +08:00
kangfenmao
f7f8c6f0c6 fix: remove specific unicode characters from removespecialcharacters function 2025-01-19 13:59:06 +08:00
Linjun
e4f4c6cd86 fix issue#762: upon clicking to resend, the conversation content is cleared.
If there is no subsequent message or if the next message is from the user, this message should be resent. delete the old message after processing is complete.
2025-01-19 12:26:55 +08:00
kangfenmao
8eac836e05 feat: improved model validation and error handling 2025-01-16 10:14:32 +08:00
Nanami
a6795289da fix: qwenlm context error 2025-01-15 09:09:01 +08:00
kangfenmao
eff639ddf9 chore(version): 0.9.10 2025-01-15 08:57:05 +08:00
kangfenmao
a046cf32ba fix: artifacts cannot preview 2025-01-14 23:27:54 +08:00
kangfenmao
66bc9cb3f9 refactor: improved type safety and consistency for file handling 2025-01-14 21:02:55 +08:00
kangfenmao
247d1a1846 chore(version): 0.9.9 2025-01-14 20:57:16 +08:00
kangfenmao
0e7fb2b19c refactor: update model group names and sync interval 2025-01-14 20:53:52 +08:00
kangfenmao
8a94bb05ea fix: fix model type logic based on provider properties 2025-01-14 20:32:04 +08:00
Nanami
bc454d4dec feat: add support for qwenlm and image upload (#726)
* feat: add support for qwenlm and image upload

* fix: qwenlm return

* feat: add provider config
2025-01-14 18:59:19 +08:00
Teo
d388aeecfb feat: 添加模型提及功能,支持多个模型一起回答 2025-01-14 17:46:55 +08:00
kangfenmao
3e33ee6cc5 feat: add release workflow behavior control option 2025-01-14 14:55:32 +08:00
92 changed files with 3442 additions and 345 deletions

View File

@@ -82,5 +82,6 @@ jobs:
with: with:
draft: true draft: true
allowUpdates: true allowUpdates: true
makeLatest: false
artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/*.blockmap' artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/*.blockmap'
token: ${{ secrets.GH_TOKEN }} token: ${{ secrets.GH_TOKEN }}

View File

@@ -80,9 +80,10 @@ afterPack: scripts/after-pack.js
afterSign: scripts/notarize.js afterSign: scripts/notarize.js
releaseInfo: releaseInfo:
releaseNotes: | releaseNotes: |
支持将小程序固定到侧边栏 @hxp0618 新增快捷助手弹窗
增加 Grok 和 QwenLM 小程序 @ruiwarn 翻译默认使用流输出
支持下载模型生成的 CSV 文件 小程序弹窗顶部增加固定按钮 @ousugo
知识库增加刷新按钮 新增清除消息、清除上下文快捷键 @cljnnn
Gemini 搜索增加引用来源 Gemini 安全设置更新 @magicdmer
修复模型设置参数无法保存的问题 智能体页面性能优化 @magicdmer
修复 WebDAV 不能自动备份问题

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "CherryStudio", "name": "CherryStudio",
"version": "0.9.8", "version": "0.9.13",
"private": true, "private": true,
"description": "A powerful AI assistant for producer.", "description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js", "main": "./out/main/index.js",
@@ -95,6 +95,7 @@
"@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

@@ -14,6 +14,7 @@ import FileStorage from './services/FileStorage'
import { GeminiService } from './services/GeminiService' import { GeminiService } from './services/GeminiService'
import KnowledgeService from './services/KnowledgeService' import KnowledgeService from './services/KnowledgeService'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService' import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService' import { windowService } from './services/WindowService'
import { compress, decompress } from './utils/zip' import { compress, decompress } from './utils/zip'
@@ -52,6 +53,16 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
configManager.setTray(isActive) configManager.setTray(isActive)
}) })
ipcMain.handle('app:restart-tray', () => TrayService.getInstance().restartTray())
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)
@@ -117,6 +128,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('file:base64Image', fileManager.base64Image) ipcMain.handle('file:base64Image', fileManager.base64Image)
ipcMain.handle('file:download', fileManager.downloadFile) ipcMain.handle('file:download', fileManager.downloadFile)
ipcMain.handle('file:copy', fileManager.copyFile) ipcMain.handle('file:copy', fileManager.copyFile)
ipcMain.handle('file:binaryFile', fileManager.binaryFile)
// minapp // minapp
ipcMain.handle('minapp', (_, args) => { ipcMain.handle('minapp', (_, args) => {
@@ -175,4 +187,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('gemini:retrieve-file', GeminiService.retrieveFile) ipcMain.handle('gemini:retrieve-file', GeminiService.retrieveFile)
ipcMain.handle('gemini:list-files', GeminiService.listFiles) ipcMain.handle('gemini:list-files', GeminiService.listFiles)
ipcMain.handle('gemini:delete-file', GeminiService.deleteFile) ipcMain.handle('gemini:delete-file', GeminiService.deleteFile)
// mini window
ipcMain.handle('miniwindow:show', () => windowService.showMiniWindow())
ipcMain.handle('miniwindow:hide', () => windowService.hideMiniWindow())
ipcMain.handle('miniwindow:close', () => windowService.closeMiniWindow())
ipcMain.handle('miniwindow:toggle', () => windowService.toggleMiniWindow())
} }

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,30 @@ 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)
}
getEnableQuickAssistant(): boolean {
return this.store.get('enableQuickAssistant', false) as boolean
}
setEnableQuickAssistant(value: boolean) {
this.store.set('enableQuickAssistant', 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

@@ -263,6 +263,13 @@ class FileStorage {
} }
} }
public binaryFile = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<{ data: Buffer; mime: string }> => {
const filePath = path.join(this.storageDir, id)
const data = await fs.promises.readFile(filePath)
const mime = `image/${path.extname(filePath).slice(1)}`
return { data, mime }
}
public clear = async (): Promise<void> => { public clear = async (): Promise<void> => {
await fs.promises.rmdir(this.storageDir, { recursive: true }) await fs.promises.rmdir(this.storageDir, { recursive: true })
await this.initStorageDir() await this.initStorageDir()

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

@@ -1,6 +1,6 @@
import { isMac } from '@main/constant' import { isMac } from '@main/constant'
import { locales } from '@main/utils/locales' import { locales } from '@main/utils/locales'
import { app, Menu, nativeImage, nativeTheme, Tray } from 'electron' import { app, Menu, MenuItemConstructorOptions, nativeImage, nativeTheme, Tray } from 'electron'
import icon from '../../../build/tray_icon.png?asset' import icon from '../../../build/tray_icon.png?asset'
import iconDark from '../../../build/tray_icon_dark.png?asset' import iconDark from '../../../build/tray_icon_dark.png?asset'
@@ -9,14 +9,22 @@ import { configManager } from './ConfigManager'
import { windowService } from './WindowService' import { windowService } from './WindowService'
export class TrayService { export class TrayService {
private static instance: TrayService
private tray: Tray | null = null private tray: Tray | null = null
constructor() { constructor() {
this.updateTray() this.updateTray()
this.watchTrayChanges() this.watchTrayChanges()
TrayService.instance = this
}
public static getInstance() {
return TrayService.instance
} }
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)
@@ -38,17 +46,25 @@ export class TrayService {
const locale = locales[configManager.getLanguage()] const locale = locales[configManager.getLanguage()]
const { tray: trayLocale } = locale.translation const { tray: trayLocale } = locale.translation
const contextMenu = Menu.buildFromTemplate([ const enableQuickAssistant = configManager.getEnableQuickAssistant()
const template = [
{ {
label: trayLocale.show_window, label: trayLocale.show_window,
click: () => windowService.showMainWindow() click: () => windowService.showMainWindow()
}, },
enableQuickAssistant && {
label: trayLocale.show_mini_window,
click: () => windowService.showMiniWindow()
},
{ type: 'separator' }, { type: 'separator' },
{ {
label: trayLocale.quit, label: trayLocale.quit,
click: () => this.quit() click: () => this.quit()
} }
]) ].filter(Boolean) as MenuItemConstructorOptions[]
const contextMenu = Menu.buildFromTemplate(template)
if (process.platform === 'linux') { if (process.platform === 'linux') {
this.tray.setContextMenu(contextMenu) this.tray.setContextMenu(contextMenu)
@@ -61,18 +77,30 @@ export class TrayService {
}) })
this.tray.on('click', () => { this.tray.on('click', () => {
windowService.showMainWindow() if (enableQuickAssistant && configManager.getClickTrayToShowQuickAssistant()) {
windowService.showMiniWindow()
} else {
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()
} }
} }
public restartTray() {
if (configManager.getTray()) {
this.destroyTray()
this.createTray()
}
}
private destroyTray() { private destroyTray() {
if (this.tray) { if (this.tray) {
this.tray.destroy() this.tray.destroy()

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,7 @@ export class WindowService {
}) })
this.setupMainWindow(this.mainWindow, mainWindowState) this.setupMainWindow(this.mainWindow, mainWindowState)
return this.mainWindow return this.mainWindow
} }
@@ -201,7 +205,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 +237,164 @@ export class WindowService {
this.createMainWindow() this.createMainWindow()
} }
} }
public showMiniWindow() {
const enableQuickAssistant = configManager.getEnableQuickAssistant()
if (!enableQuickAssistant) {
return
}
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: true,
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('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'), {
hash: '#/mini'
})
}
}
public hideMiniWindow() {
this.miniWindow?.hide()
}
public closeMiniWindow() {
this.miniWindow?.close()
}
public toggleMiniWindow() {
if (this.miniWindow) {
this.miniWindow.isVisible() ? this.miniWindow.hide() : this.miniWindow.show()
} else {
this.showMiniWindow()
}
}
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,23 @@ 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)
}
}
}
export function dumpPersistState() {
const persistState = JSON.parse(localStorage.getItem('persist:cherry-studio') || '{}')
for (const key in persistState) {
persistState[key] = JSON.parse(persistState[key])
}
return JSON.stringify(persistState)
}

View File

@@ -18,6 +18,7 @@ declare global {
setProxy: (proxy: string | undefined) => void setProxy: (proxy: string | undefined) => void
setLanguage: (theme: LanguageVarious) => void setLanguage: (theme: LanguageVarious) => void
setTray: (isActive: boolean) => void setTray: (isActive: boolean) => void
restartTray: () => void
setTheme: (theme: 'light' | 'dark') => void setTheme: (theme: 'light' | 'dark') => void
minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void
reload: () => void reload: () => void
@@ -53,6 +54,7 @@ declare global {
base64Image: (fileId: string) => Promise<{ mime: string; base64: string; data: string }> base64Image: (fileId: string) => Promise<{ mime: string; base64: string; data: string }>
download: (url: string) => Promise<FileType | null> download: (url: string) => Promise<FileType | null>
copy: (fileId: string, destPath: string) => Promise<void> copy: (fileId: string, destPath: string) => Promise<void>
binaryFile: (fileId: string) => Promise<{ data: Buffer; mime: string }>
} }
export: { export: {
toWord: (markdown: string, fileName: string) => Promise<void> toWord: (markdown: string, fileName: string) => Promise<void>
@@ -88,6 +90,19 @@ 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>
}
miniWindow: {
show: () => Promise<void>
hide: () => Promise<void>
close: () => Promise<void>
toggle: () => Promise<void>
}
} }
} }
} }

View File

@@ -10,6 +10,7 @@ const api = {
checkForUpdate: () => ipcRenderer.invoke('app:check-for-update'), checkForUpdate: () => ipcRenderer.invoke('app:check-for-update'),
setLanguage: (lang: string) => ipcRenderer.invoke('app:set-language', lang), setLanguage: (lang: string) => ipcRenderer.invoke('app:set-language', lang),
setTray: (isActive: boolean) => ipcRenderer.invoke('app:set-tray', isActive), setTray: (isActive: boolean) => ipcRenderer.invoke('app:set-tray', isActive),
restartTray: () => ipcRenderer.invoke('app:restart-tray'),
setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('app:set-theme', theme), setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('app:set-theme', theme),
openWebsite: (url: string) => ipcRenderer.invoke('open:website', url), openWebsite: (url: string) => ipcRenderer.invoke('open:website', url),
minApp: (url: string) => ipcRenderer.invoke('minapp', url), minApp: (url: string) => ipcRenderer.invoke('minapp', url),
@@ -43,7 +44,8 @@ const api = {
saveImage: (name: string, data: string) => ipcRenderer.invoke('file:saveImage', name, data), saveImage: (name: string, data: string) => ipcRenderer.invoke('file:saveImage', name, data),
base64Image: (fileId: string) => ipcRenderer.invoke('file:base64Image', fileId), base64Image: (fileId: string) => ipcRenderer.invoke('file:base64Image', fileId),
download: (url: string) => ipcRenderer.invoke('file:download', url), download: (url: string) => ipcRenderer.invoke('file:download', url),
copy: (fileId: string, destPath: string) => ipcRenderer.invoke('file:copy', fileId, destPath) copy: (fileId: string, destPath: string) => ipcRenderer.invoke('file:copy', fileId, destPath),
binaryFile: (fileId: string) => ipcRenderer.invoke('file:binaryFile', fileId)
}, },
export: { export: {
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke('export:word', markdown, fileName) toWord: (markdown: string, fileName: string) => ipcRenderer.invoke('export:word', markdown, fileName)
@@ -81,6 +83,19 @@ 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)
},
miniWindow: {
show: () => ipcRenderer.invoke('miniwindow:show'),
hide: () => ipcRenderer.invoke('miniwindow:hide'),
close: () => ipcRenderer.invoke('miniwindow:close'),
toggle: () => ipcRenderer.invoke('miniwindow:toggle')
} }
} }

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

@@ -0,0 +1,4 @@
<svg width="464" height="464" viewBox="0 0 464 464" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="464" height="464" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M243 127C235.268 127 229 133.268 229 141V322C229 329.732 235.268 336 243 336H283C290.732 336 297 329.732 297 322V141C297 133.268 290.732 127 283 127H243ZM167.562 128C163.762 128 160.317 129.518 157.805 131.978C157.787 131.995 157.759 131.977 157.767 131.954C157.775 131.93 157.743 131.913 157.727 131.933L157.311 132.486C156.679 133.171 156.115 133.92 155.629 134.722C154.303 136.486 153.139 138.365 152.152 140.338L88.8745 266.857L85.2894 274.899C85.2249 275.037 85.1626 275.177 85.1027 275.318L84.7141 276.189C84.7086 276.201 84.7223 276.213 84.7339 276.206C84.745 276.2 84.7583 276.211 84.7541 276.223C84.2654 277.639 84 279.16 84 280.742L84 322.399C84 330.067 90.2354 336.284 97.9271 336.284H139.708C147.4 336.284 153.635 330.067 153.635 322.399V266.857L153.636 252.97C153.636 222.295 178.577 197.428 209.344 197.428C217.035 197.428 223.271 191.211 223.271 183.542V141.886C223.271 134.217 217.035 128 209.344 128H167.562ZM304.5 301.57C304.5 282.398 320.088 266.856 339.318 266.856C358.547 266.856 374.135 282.398 374.135 301.57C374.135 320.742 358.547 336.284 339.318 336.284C320.088 336.284 304.5 320.742 304.5 301.57Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

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

@@ -1,7 +1,9 @@
/* eslint-disable react/no-unknown-property */ /* eslint-disable react/no-unknown-property */
import { CloseOutlined, ExportOutlined, ReloadOutlined } from '@ant-design/icons' import { CloseOutlined, ExportOutlined, PushpinOutlined, ReloadOutlined } from '@ant-design/icons'
import { isMac, isWindows } from '@renderer/config/constant' import { isMac, isWindows } from '@renderer/config/constant'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { useBridge } from '@renderer/hooks/useBridge' import { useBridge } from '@renderer/hooks/useBridge'
import { useMinapps } from '@renderer/hooks/useMinapps'
import store from '@renderer/store' import store from '@renderer/store'
import { setMinappShow } from '@renderer/store/runtime' import { setMinappShow } from '@renderer/store/runtime'
import { MinAppType } from '@renderer/types' import { MinAppType } from '@renderer/types'
@@ -20,6 +22,8 @@ interface Props {
} }
const PopupContainer: React.FC<Props> = ({ app, resolve }) => { const PopupContainer: React.FC<Props> = ({ app, resolve }) => {
const { pinned, updatePinnedMinapps } = useMinapps()
const isPinned = pinned.some((p) => p.id === app.id)
const [open, setOpen] = useState(true) const [open, setOpen] = useState(true)
const [opened, setOpened] = useState(false) const [opened, setOpened] = useState(false)
const [isReady, setIsReady] = useState(false) const [isReady, setIsReady] = useState(false)
@@ -28,6 +32,7 @@ const PopupContainer: React.FC<Props> = ({ app, resolve }) => {
useBridge() useBridge()
const canOpenExternalLink = app.url.startsWith('http://') || app.url.startsWith('https://') const canOpenExternalLink = app.url.startsWith('http://') || app.url.startsWith('https://')
const canPinned = DEFAULT_MIN_APPS.some((i) => i.id === app?.id)
const onClose = async (_delay = 0.3) => { const onClose = async (_delay = 0.3) => {
setOpen(false) setOpen(false)
@@ -47,6 +52,11 @@ const PopupContainer: React.FC<Props> = ({ app, resolve }) => {
window.api.openWebsite(app.url) window.api.openWebsite(app.url)
} }
const onTogglePin = () => {
const newPinned = isPinned ? pinned.filter((item) => item.id !== app.id) : [...pinned, app]
updatePinnedMinapps(newPinned)
}
const Title = () => { const Title = () => {
return ( return (
<TitleContainer style={{ justifyContent: 'space-between' }}> <TitleContainer style={{ justifyContent: 'space-between' }}>
@@ -55,6 +65,11 @@ const PopupContainer: React.FC<Props> = ({ app, resolve }) => {
<Button onClick={onReload}> <Button onClick={onReload}>
<ReloadOutlined /> <ReloadOutlined />
</Button> </Button>
{canPinned && (
<Button onClick={onTogglePin} className={isPinned ? 'pinned' : ''}>
<PushpinOutlined style={{ fontSize: 16 }} />
</Button>
)}
{canOpenExternalLink && ( {canOpenExternalLink && (
<Button onClick={onOpenLink}> <Button onClick={onOpenLink}>
<ExportOutlined /> <ExportOutlined />
@@ -140,7 +155,7 @@ const TitleContainer = styled.div`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
padding-left: ${isMac ? '20px' : '15px'}; padding-left: ${isMac ? '20px' : '10px'};
padding-right: 10px; padding-right: 10px;
position: absolute; position: absolute;
top: 0; top: 0;
@@ -188,6 +203,10 @@ const Button = styled.div`
color: var(--color-text-1); color: var(--color-text-1);
background-color: var(--color-background-mute); background-color: var(--color-background-mute);
} }
&.pinned {
color: var(--color-primary);
background-color: var(--color-primary-bg);
}
` `
const EmptyView = styled.div` const EmptyView = styled.div`
@@ -207,7 +226,7 @@ export default class MinApp {
static app: MinAppType | null = null static app: MinAppType | null = null
static async start(app: MinAppType) { static async start(app: MinAppType) {
if (MinApp.app?.id === app.id) { if (app?.id && MinApp.app?.id === app?.id) {
return return
} }

View File

@@ -1,7 +1,6 @@
import { PushpinOutlined, SearchOutlined } from '@ant-design/icons' import { PushpinOutlined, SearchOutlined } from '@ant-design/icons'
import VisionIcon from '@renderer/components/Icons/VisionIcon'
import { TopView } from '@renderer/components/TopView' import { TopView } from '@renderer/components/TopView'
import { getModelLogo, isEmbeddingModel, isVisionModel } from '@renderer/config/models' import { getModelLogo, isEmbeddingModel } from '@renderer/config/models'
import db from '@renderer/databases' import db from '@renderer/databases'
import { useProviders } from '@renderer/hooks/useProvider' import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService' import { getModelUniqId } from '@renderer/services/ModelService'
@@ -33,6 +32,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
const inputRef = useRef<InputRef>(null) const inputRef = useRef<InputRef>(null)
const { providers } = useProviders() const { providers } = useProviders()
const [pinnedModels, setPinnedModels] = useState<string[]>([]) const [pinnedModels, setPinnedModels] = useState<string[]>([])
const scrollContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
const loadPinnedModels = async () => { const loadPinnedModels = async () => {
@@ -118,7 +118,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
key: getModelUniqId(m) + '_pinned', key: getModelUniqId(m) + '_pinned',
label: ( label: (
<ModelItem> <ModelItem>
{m?.name} {isVisionModel(m) && <VisionIcon />} {m?.name} <ModelTags model={m} />
<PinIcon <PinIcon
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
@@ -163,6 +163,17 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
open && setTimeout(() => inputRef.current?.focus(), 0) open && setTimeout(() => inputRef.current?.focus(), 0)
}, [open]) }, [open])
useEffect(() => {
if (open && model) {
setTimeout(() => {
const selectedElement = document.querySelector('.ant-menu-item-selected')
if (selectedElement && scrollContainerRef.current) {
selectedElement.scrollIntoView({ block: 'center', behavior: 'auto' })
}
}, 100) // Small delay to ensure menu is rendered
}
}, [open, model])
return ( return (
<Modal <Modal
centered centered
@@ -200,7 +211,7 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
/> />
</HStack> </HStack>
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} /> <Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} />
<Scrollbar style={{ height: '50vh' }}> <Scrollbar style={{ height: '50vh' }} ref={scrollContainerRef}>
<Container> <Container>
{filteredItems.length > 0 ? ( {filteredItems.length > 0 ? (
<StyledMenu <StyledMenu

View File

@@ -166,6 +166,7 @@ const Container = styled.div`
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 8px 0; padding: 8px 0;
padding-bottom: 12px;
width: var(--sidebar-width); width: var(--sidebar-width);
min-width: var(--sidebar-width); min-width: var(--sidebar-width);
height: ${isMac ? 'calc(100vh - var(--navbar-height))' : '100vh'}; height: ${isMac ? 'calc(100vh - var(--navbar-height))' : '100vh'};

File diff suppressed because one or more lines are too long

View File

@@ -1,38 +1,39 @@
import BaiduAiAppLogo from '@renderer/assets/images/apps/baidu-ai.png' import BaiduAiAppLogo from '@renderer/assets/images/apps/baidu-ai.png?url'
import BaicuanAppLogo from '@renderer/assets/images/apps/baixiaoying.webp' import BaicuanAppLogo from '@renderer/assets/images/apps/baixiaoying.webp?url'
import BoltAppLogo from '@renderer/assets/images/apps/bolt.svg' import BoltAppLogo from '@renderer/assets/images/apps/bolt.svg?url'
import DevvAppLogo from '@renderer/assets/images/apps/devv.png' import DevvAppLogo from '@renderer/assets/images/apps/devv.png?url'
import DoubaoAppLogo from '@renderer/assets/images/apps/doubao.png' import DoubaoAppLogo from '@renderer/assets/images/apps/doubao.png?url'
import DuckDuckGoAppLogo from '@renderer/assets/images/apps/duckduckgo.webp' import DuckDuckGoAppLogo from '@renderer/assets/images/apps/duckduckgo.webp?url'
import FeloAppLogo from '@renderer/assets/images/apps/felo.png' import FeloAppLogo from '@renderer/assets/images/apps/felo.png?url'
import GeminiAppLogo from '@renderer/assets/images/apps/gemini.png' import FlowithAppLogo from '@renderer/assets/images/apps/flowith.svg?url'
import GensparkLogo from '@renderer/assets/images/apps/genspark.jpg' import GeminiAppLogo from '@renderer/assets/images/apps/gemini.png?url'
import GithubCopilotLogo from '@renderer/assets/images/apps/github-copilot.webp' import GensparkLogo from '@renderer/assets/images/apps/genspark.jpg?url'
import GrokAppLogo from '@renderer/assets/images/apps/grok.png' import GithubCopilotLogo from '@renderer/assets/images/apps/github-copilot.webp?url'
import HikaLogo from '@renderer/assets/images/apps/hika.webp' import GrokAppLogo from '@renderer/assets/images/apps/grok.png?url'
import HuggingChatLogo from '@renderer/assets/images/apps/huggingchat.svg' import HikaLogo from '@renderer/assets/images/apps/hika.webp?url'
import KimiAppLogo from '@renderer/assets/images/apps/kimi.jpg' import HuggingChatLogo from '@renderer/assets/images/apps/huggingchat.svg?url'
import MetasoAppLogo from '@renderer/assets/images/apps/metaso.webp' import KimiAppLogo from '@renderer/assets/images/apps/kimi.jpg?url'
import NamiAiSearchLogo from '@renderer/assets/images/apps/nm.webp' import MetasoAppLogo from '@renderer/assets/images/apps/metaso.webp?url'
import PerplexityAppLogo from '@renderer/assets/images/apps/perplexity.webp' import NamiAiSearchLogo from '@renderer/assets/images/apps/nm.webp?url'
import PoeAppLogo from '@renderer/assets/images/apps/poe.webp' import PerplexityAppLogo from '@renderer/assets/images/apps/perplexity.webp?url'
import ZhipuProviderLogo from '@renderer/assets/images/apps/qingyan.png' import PoeAppLogo from '@renderer/assets/images/apps/poe.webp?url'
import QwenlmAppLogo from '@renderer/assets/images/apps/qwenlm.webp' import ZhipuProviderLogo from '@renderer/assets/images/apps/qingyan.png?url'
import SensetimeAppLogo from '@renderer/assets/images/apps/sensetime.png' import QwenlmAppLogo from '@renderer/assets/images/apps/qwenlm.webp?url'
import SparkDeskAppLogo from '@renderer/assets/images/apps/sparkdesk.png' import SensetimeAppLogo from '@renderer/assets/images/apps/sensetime.png?url'
import ThinkAnyLogo from '@renderer/assets/images/apps/thinkany.webp' import SparkDeskAppLogo from '@renderer/assets/images/apps/sparkdesk.png?url'
import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.png' import ThinkAnyLogo from '@renderer/assets/images/apps/thinkany.webp?url'
import WanZhiAppLogo from '@renderer/assets/images/apps/wanzhi.jpg' import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.png?url'
import TencentYuanbaoAppLogo from '@renderer/assets/images/apps/yuanbao.png' import WanZhiAppLogo from '@renderer/assets/images/apps/wanzhi.jpg?url'
import YuewenAppLogo from '@renderer/assets/images/apps/yuewen.png' import TencentYuanbaoAppLogo from '@renderer/assets/images/apps/yuanbao.png?url'
import ZhihuAppLogo from '@renderer/assets/images/apps/zhihu.png' import YuewenAppLogo from '@renderer/assets/images/apps/yuewen.png?url'
import ClaudeAppLogo from '@renderer/assets/images/models/claude.png' import ZhihuAppLogo from '@renderer/assets/images/apps/zhihu.png?url'
import HailuoModelLogo from '@renderer/assets/images/models/hailuo.png' import ClaudeAppLogo from '@renderer/assets/images/models/claude.png?url'
import QwenModelLogo from '@renderer/assets/images/models/qwen.png' import HailuoModelLogo from '@renderer/assets/images/models/hailuo.png?url'
import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png' import QwenModelLogo from '@renderer/assets/images/models/qwen.png?url'
import GroqProviderLogo from '@renderer/assets/images/providers/groq.png' import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png?url'
import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png' import GroqProviderLogo from '@renderer/assets/images/providers/groq.png?url'
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png' import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png?url'
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png?url'
import MinApp from '@renderer/components/MinApp' import MinApp from '@renderer/components/MinApp'
import { MinAppType } from '@renderer/types' import { MinAppType } from '@renderer/types'
@@ -260,6 +261,13 @@ export const DEFAULT_MIN_APPS: MinAppType[] = [
name: 'QwenLM', name: 'QwenLM',
logo: QwenlmAppLogo, logo: QwenlmAppLogo,
url: 'https://qwenlm.ai/' url: 'https://qwenlm.ai/'
},
{
id: 'flowith',
name: 'Flowith',
logo: FlowithAppLogo,
url: 'https://www.flowith.io/',
bodered: true
} }
] ]

View File

@@ -264,6 +264,56 @@ export function getModelLogo(modelId: string) {
} }
export const SYSTEM_MODELS: Record<string, Model[]> = { export const SYSTEM_MODELS: Record<string, Model[]> = {
qwenlm: [
{
id: 'qwen-plus-latest',
provider: 'qwenlm',
name: 'Qwen2.5-Plus',
group: 'Qwen 2.5'
},
{
id: 'qvq-72b-preview',
provider: 'qwenlm',
name: 'QVQ-72B-Preview',
group: 'QVQ'
},
{
id: 'qwq-32b-preview',
provider: 'qwenlm',
name: 'QwQ-32B-Preview',
group: 'QVQ'
},
{
id: 'qwen2.5-coder-32b-instruct',
provider: 'qwenlm',
name: 'Qwen2.5-Coder-32B-Instruct',
group: 'Qwen 2.5'
},
{
id: 'qwen-vl-max-latest',
provider: 'qwenlm',
name: 'Qwen2-VL-Max',
group: 'Qwen 2'
},
{
id: 'qwen-turbo-latest',
provider: 'qwenlm',
name: 'Qwen2.5-Turbo',
group: 'Qwen 2.5'
},
{
id: 'qwen2.5-72b-instruct',
provider: 'qwenlm',
name: 'Qwen2.5-72B-Instruct',
group: 'Qwen 2.5'
},
{
id: 'qwen2.5-32b-instruct',
provider: 'qwenlm',
name: 'Qwen2.5-32B-Instruct',
group: 'Qwen 2.5'
}
],
aihubmix: [ aihubmix: [
{ {
id: 'gpt-4o', id: 'gpt-4o',
@@ -1055,16 +1105,16 @@ export function isWebSearchModel(model: Model): boolean {
const provider = getProviderByModel(model) const provider = getProviderByModel(model)
if (provider.type === 'openai') { if (!provider) {
return false
}
if (provider?.type === 'openai') {
if (model?.id?.includes('gemini-2.0-flash-exp')) { if (model?.id?.includes('gemini-2.0-flash-exp')) {
return true return true
} }
} }
if (!provider) {
return false
}
if (provider.id === 'gemini' || provider?.type === 'gemini') { if (provider.id === 'gemini' || provider?.type === 'gemini') {
return model?.id === 'gemini-2.0-flash-exp' return model?.id === 'gemini-2.0-flash-exp'
} }

View File

@@ -23,6 +23,7 @@ import OcoolAiProviderLogo from '@renderer/assets/images/providers/ocoolai.png'
import OllamaProviderLogo from '@renderer/assets/images/providers/ollama.png' import OllamaProviderLogo from '@renderer/assets/images/providers/ollama.png'
import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png' import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png'
import OpenRouterProviderLogo from '@renderer/assets/images/providers/openrouter.png' import OpenRouterProviderLogo from '@renderer/assets/images/providers/openrouter.png'
import QwenLMProviderLogo from '@renderer/assets/images/providers/qwenlm.png'
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png' import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png'
import StepProviderLogo from '@renderer/assets/images/providers/step.png' import StepProviderLogo from '@renderer/assets/images/providers/step.png'
import TogetherProviderLogo from '@renderer/assets/images/providers/together.png' import TogetherProviderLogo from '@renderer/assets/images/providers/together.png'
@@ -91,6 +92,8 @@ export function getProviderLogo(providerId: string) {
return MistralProviderLogo return MistralProviderLogo
case 'jina': case 'jina':
return JinaProviderLogo return JinaProviderLogo
case 'qwenlm':
return QwenLMProviderLogo
default: default:
return undefined return undefined
} }
@@ -402,7 +405,7 @@ export const PROVIDER_CONFIG = {
url: 'https://integrate.api.nvidia.com' url: 'https://integrate.api.nvidia.com'
}, },
websites: { websites: {
official: 'https://ai.360.com/', official: 'https://build.nvidia.com/explore/discover',
apiKey: 'https://build.nvidia.com/meta/llama-3_1-405b-instruct', apiKey: 'https://build.nvidia.com/meta/llama-3_1-405b-instruct',
docs: 'https://docs.api.nvidia.com/nim/reference/llm-apis', docs: 'https://docs.api.nvidia.com/nim/reference/llm-apis',
models: 'https://build.nvidia.com/nim' models: 'https://build.nvidia.com/nim'
@@ -418,5 +421,16 @@ export const PROVIDER_CONFIG = {
docs: 'https://learn.microsoft.com/en-us/azure/ai-services/openai/', docs: 'https://learn.microsoft.com/en-us/azure/ai-services/openai/',
models: 'https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models' models: 'https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models'
} }
},
qwenlm: {
api: {
url: 'https://chat.qwenlm.ai/api/'
},
websites: {
official: 'https://chat.qwenlm.ai',
apiKey: 'https://chat.qwenlm.ai',
docs: 'https://chat.qwenlm.ai',
models: 'https://chat.qwenlm.ai'
}
} }
} }

View File

@@ -0,0 +1,59 @@
import i18n from '@renderer/i18n'
export const TranslateLanguageOptions = [
{
value: 'english',
label: i18n.t('languages.english'),
emoji: '🇬🇧'
},
{
value: 'chinese',
label: i18n.t('languages.chinese'),
emoji: '🇨🇳'
},
{
value: 'chinese-traditional',
label: i18n.t('languages.chinese-traditional'),
emoji: '🇭🇰'
},
{
value: 'japanese',
label: i18n.t('languages.japanese'),
emoji: '🇯🇵'
},
{
value: 'korean',
label: i18n.t('languages.korean'),
emoji: '🇰🇷'
},
{
value: 'russian',
label: i18n.t('languages.russian'),
emoji: '🇷🇺'
},
{
value: 'spanish',
label: i18n.t('languages.spanish'),
emoji: '🇪🇸'
},
{
value: 'french',
label: i18n.t('languages.french'),
emoji: '🇫🇷'
},
{
value: 'italian',
label: i18n.t('languages.italian'),
emoji: '🇮🇹'
},
{
value: 'portuguese',
label: i18n.t('languages.portuguese'),
emoji: '🇵🇹'
},
{
value: 'arabic',
label: i18n.t('languages.arabic'),
emoji: '🇸🇦'
}
]

View File

@@ -1,6 +1,7 @@
import { isMac } from '@renderer/config/constant' import { isMac } from '@renderer/config/constant'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { ThemeMode } from '@renderer/types' import { ThemeMode } from '@renderer/types'
import { isMiniWindow } from '@renderer/utils'
import React, { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' import React, { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react'
interface ThemeContextType { interface ThemeContextType {
@@ -13,7 +14,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 +27,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,11 +36,13 @@ 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)
window.api?.setTheme(_theme === ThemeMode.dark ? 'dark' : 'light') if (!isMiniWindow()) {
window.api?.setTheme(_theme === ThemeMode.dark ? 'dark' : 'light')
}
}, [_theme]) }, [_theme])
useEffect(() => { useEffect(() => {

View File

@@ -1,3 +1,4 @@
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { RootState, useAppDispatch, useAppSelector } from '@renderer/store' import { RootState, useAppDispatch, useAppSelector } from '@renderer/store'
import { setDisabledMinApps, setMinApps, setPinnedMinApps } from '@renderer/store/minapps' import { setDisabledMinApps, setMinApps, setPinnedMinApps } from '@renderer/store/minapps'
import { MinAppType } from '@renderer/types' import { MinAppType } from '@renderer/types'
@@ -7,9 +8,9 @@ export const useMinapps = () => {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
return { return {
minapps: enabled, minapps: enabled.map((app) => DEFAULT_MIN_APPS.find((item) => item.id === app.id) || app),
disabled, disabled: disabled.map((app) => DEFAULT_MIN_APPS.find((item) => item.id === app.id) || app),
pinned, pinned: pinned.map((app) => DEFAULT_MIN_APPS.find((item) => item.id === app.id) || app),
updateMinapps: (minapps: MinAppType[]) => { updateMinapps: (minapps: MinAppType[]) => {
dispatch(setMinApps(minapps)) dispatch(setMinApps(minapps))
}, },

View File

@@ -22,6 +22,7 @@ export function useSettings() {
}, },
setTray(isActive: boolean) { setTray(isActive: boolean) {
dispatch(setTray(isActive)) dispatch(setTray(isActive))
window.api.setTray(isActive)
}, },
setTheme(theme: ThemeMode) { setTheme(theme: ThemeMode) {
dispatch(setTheme(theme)) dispatch(setTheme(theme))

View File

@@ -1,5 +1,6 @@
import { isMac, isWindows } from '@renderer/config/constant' import { isMac, isWindows } from '@renderer/config/constant'
import { useAppSelector } from '@renderer/store' import { useAppSelector } from '@renderer/store'
import { orderBy } from 'lodash'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
@@ -58,7 +59,7 @@ export const useShortcut = (
export function useShortcuts() { export function useShortcuts() {
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts) const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
return { shortcuts } return { shortcuts: orderBy(shortcuts, 'system', 'desc') }
} }
export function useShortcutDisplay(key: string) { export function useShortcutDisplay(key: string) {

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

@@ -64,14 +64,14 @@
"default.description": "Hello, I'm Default Assistant. You can start chatting with me right away", "default.description": "Hello, I'm Default Assistant. You can start chatting with me right away",
"default.name": "⭐️ Default Assistant", "default.name": "⭐️ Default Assistant",
"default.topic.name": "Default Topic", "default.topic.name": "Default Topic",
"input.clear": "Clear", "input.clear": "Clear {{Command}}",
"input.clear.content": "Do you want to clear all messages of the current topic?", "input.clear.content": "Do you want to clear all messages of the current topic?",
"input.clear.title": "Clear all messages?", "input.clear.title": "Clear all messages?",
"input.collapse": "Collapse", "input.collapse": "Collapse",
"input.context_count.tip": "Context Count", "input.context_count.tip": "Context Count",
"input.estimated_tokens.tip": "Estimated tokens", "input.estimated_tokens.tip": "Estimated tokens",
"input.expand": "Expand", "input.expand": "Expand",
"input.new.context": "Clear Context", "input.new.context": "Clear Context {{Command}}",
"input.new_topic": "New Topic {{Command}}", "input.new_topic": "New Topic {{Command}}",
"input.pause": "Pause", "input.pause": "Pause",
"input.placeholder": "Type your message here...", "input.placeholder": "Type your message here...",
@@ -326,7 +326,8 @@
"together": "Together", "together": "Together",
"yi": "Yi", "yi": "Yi",
"zhinao": "360AI", "zhinao": "360AI",
"zhipu": "ZHIPU AI" "zhipu": "ZHIPU AI",
"qwenlm": "QwenLM"
}, },
"settings": { "settings": {
"about": "About & Feedback", "about": "About & Feedback",
@@ -384,6 +385,12 @@
"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 tray icon to start",
"enable_quick_assistant": "Enable Quick Assistant",
"use_shortcut_to_show": "Right-click the tray icon or use shortcuts to start"
},
"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",
@@ -518,7 +525,10 @@
"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",
"clear_topic": "Clear Messages",
"toggle_new_context": "Clear Context"
}, },
"theme.auto": "Auto", "theme.auto": "Auto",
"theme.dark": "Dark", "theme.dark": "Dark",
@@ -551,7 +561,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",
@@ -633,7 +644,32 @@
} }
}, },
"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",
"copy_last_message": "Press C to copy"
}
} }
} }
} }

View File

@@ -64,14 +64,14 @@
"default.description": "こんにちは、私はデフォルトのアシスタントです。すぐにチャットを始められます。", "default.description": "こんにちは、私はデフォルトのアシスタントです。すぐにチャットを始められます。",
"default.name": "⭐️ デフォルトアシスタント", "default.name": "⭐️ デフォルトアシスタント",
"default.topic.name": "デフォルトトピック", "default.topic.name": "デフォルトトピック",
"input.clear": "クリア", "input.clear": "クリア {{Command}}",
"input.clear.content": "現在のトピックのすべてのメッセージをクリアしますか?", "input.clear.content": "現在のトピックのすべてのメッセージをクリアしますか?",
"input.clear.title": "すべてのメッセージをクリアしますか?", "input.clear.title": "すべてのメッセージをクリアしますか?",
"input.collapse": "折りたたむ", "input.collapse": "折りたたむ",
"input.context_count.tip": "コンテキスト数", "input.context_count.tip": "コンテキスト数",
"input.estimated_tokens.tip": "推定トークン数", "input.estimated_tokens.tip": "推定トークン数",
"input.expand": "展開", "input.expand": "展開",
"input.new.context": "コンテキストをクリア", "input.new.context": "コンテキストをクリア {{Command}}",
"input.new_topic": "新しいトピック {{Command}}", "input.new_topic": "新しいトピック {{Command}}",
"input.pause": "一時停止", "input.pause": "一時停止",
"input.placeholder": "ここにメッセージを入力...", "input.placeholder": "ここにメッセージを入力...",
@@ -324,7 +324,8 @@
"together": "Together", "together": "Together",
"yi": "零一万物", "yi": "零一万物",
"zhinao": "360智脳", "zhinao": "360智脳",
"zhipu": "智譜AI" "zhipu": "智譜AI",
"qwenlm": "QwenLM"
}, },
"settings": { "settings": {
"about": "について", "about": "について",
@@ -382,6 +383,12 @@
"webdav.syncError": "バックアップエラー", "webdav.syncError": "バックアップエラー",
"webdav.lastSync": "最終同期" "webdav.lastSync": "最終同期"
}, },
"quickAssistant": {
"title": "クイックアシスタント",
"click_tray_to_show": "トレイアイコンをクリックして起動",
"enable_quick_assistant": "クイックアシスタントを有効にする",
"use_shortcut_to_show": "トレイアイコンを右クリックするか、ショートカットキーで起動できます"
},
"display.title": "表示設定", "display.title": "表示設定",
"font_size.title": "メッセージのフォントサイズ", "font_size.title": "メッセージのフォントサイズ",
"general": "一般設定", "general": "一般設定",
@@ -503,7 +510,10 @@
"toggle_show_assistants": "アシスタントの表示を切り替え", "toggle_show_assistants": "アシスタントの表示を切り替え",
"toggle_show_topics": "トピックの表示を切り替え", "toggle_show_topics": "トピックの表示を切り替え",
"copy_last_message": "最後のメッセージをコピー", "copy_last_message": "最後のメッセージをコピー",
"search_message": "メッセージを検索" "search_message": "メッセージを検索",
"mini_window": "クイックアシスタント",
"clear_topic": "メッセージを消去",
"toggle_new_context": "コンテキストをクリア"
}, },
"theme.auto": "自動", "theme.auto": "自動",
"theme.dark": "ダークテーマ", "theme.dark": "ダークテーマ",
@@ -536,7 +546,8 @@
}, },
"tray": { "tray": {
"quit": "終了", "quit": "終了",
"show_window": "ウィンドウを表示" "show_window": "ウィンドウを表示",
"show_mini_window": "クイックアシスタント"
}, },
"words": { "words": {
"knowledgeGraph": "ナレッジグラフ", "knowledgeGraph": "ナレッジグラフ",
@@ -618,7 +629,32 @@
} }
}, },
"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": "戻る",
"copy_last_message": "C キーを押してコピー"
}
} }
} }
} }

View File

@@ -64,14 +64,14 @@
"default.description": "Привет, я Ассистент по умолчанию. Вы можете начать общаться со мной прямо сейчас", "default.description": "Привет, я Ассистент по умолчанию. Вы можете начать общаться со мной прямо сейчас",
"default.name": "⭐️ Ассистент по умолчанию", "default.name": "⭐️ Ассистент по умолчанию",
"default.topic.name": "Топик по умолчанию", "default.topic.name": "Топик по умолчанию",
"input.clear": "Очистить", "input.clear": "Очистить {{Command}}",
"input.clear.content": "Хотите очистить все сообщения текущего топика?", "input.clear.content": "Хотите очистить все сообщения текущего топика?",
"input.clear.title": "Очистить все сообщения?", "input.clear.title": "Очистить все сообщения?",
"input.collapse": "Свернуть", "input.collapse": "Свернуть",
"input.context_count.tip": "Количество контекстов", "input.context_count.tip": "Количество контекстов",
"input.estimated_tokens.tip": "Затраты токенов", "input.estimated_tokens.tip": "Затраты токенов",
"input.expand": "Развернуть", "input.expand": "Развернуть",
"input.new.context": "Очистить контекст", "input.new.context": "Очистить контекст {{Command}}",
"input.new_topic": "Новый топик {{Command}}", "input.new_topic": "Новый топик {{Command}}",
"input.pause": "Остановить", "input.pause": "Остановить",
"input.placeholder": "Введите ваше сообщение здесь...", "input.placeholder": "Введите ваше сообщение здесь...",
@@ -326,7 +326,8 @@
"together": "Together", "together": "Together",
"yi": "Yi", "yi": "Yi",
"zhinao": "360AI", "zhinao": "360AI",
"zhipu": "ZHIPU AI" "zhipu": "ZHIPU AI",
"qwenlm": "QwenLM"
}, },
"settings": { "settings": {
"about": "О программе и обратная связь", "about": "О программе и обратная связь",
@@ -384,6 +385,12 @@
"webdav.syncError": "Ошибка резервного копирования", "webdav.syncError": "Ошибка резервного копирования",
"webdav.lastSync": "Последняя синхронизация" "webdav.lastSync": "Последняя синхронизация"
}, },
"quickAssistant": {
"title": "Быстрый помощник",
"click_tray_to_show": "Нажмите на иконку трея для запуска",
"enable_quick_assistant": "Включить быстрый помощник",
"use_shortcut_to_show": "Нажмите на иконку трея или используйте горячие клавиши для запуска"
},
"display.title": "Настройки отображения", "display.title": "Настройки отображения",
"font_size.title": "Размер шрифта сообщений", "font_size.title": "Размер шрифта сообщений",
"general": "Общие настройки", "general": "Общие настройки",
@@ -517,7 +524,10 @@
"toggle_show_assistants": "Переключить отображение ассистентов", "toggle_show_assistants": "Переключить отображение ассистентов",
"toggle_show_topics": "Переключить отображение топиков", "toggle_show_topics": "Переключить отображение топиков",
"copy_last_message": "Копировать последнее сообщение", "copy_last_message": "Копировать последнее сообщение",
"search_message": "Поиск сообщения" "search_message": "Поиск сообщения",
"mini_window": "Быстрый помощник",
"clear_topic": "Очистить все сообщения",
"toggle_new_context": "Очистить контекст"
}, },
"theme.auto": "Автоматически", "theme.auto": "Автоматически",
"theme.dark": "Темная", "theme.dark": "Темная",
@@ -550,7 +560,8 @@
}, },
"tray": { "tray": {
"quit": "Выйти", "quit": "Выйти",
"show_window": "Показать окно" "show_window": "Показать окно",
"show_mini_window": "Быстрый помощник"
}, },
"words": { "words": {
"knowledgeGraph": "Граф знаний", "knowledgeGraph": "Граф знаний",
@@ -632,7 +643,32 @@
} }
}, },
"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": "возвращения",
"copy_last_message": "Нажмите C для копирования"
}
} }
} }
} }

View File

@@ -64,14 +64,14 @@
"default.description": "你好,我是默认助手。你可以立刻开始跟我聊天。", "default.description": "你好,我是默认助手。你可以立刻开始跟我聊天。",
"default.name": "⭐️ 默认助手", "default.name": "⭐️ 默认助手",
"default.topic.name": "默认话题", "default.topic.name": "默认话题",
"input.clear": "清空消息", "input.clear": "清空消息 {{Command}}",
"input.clear.content": "确定要清除当前会话所有消息吗?", "input.clear.content": "确定要清除当前会话所有消息吗?",
"input.clear.title": "清空消息", "input.clear.title": "清空消息",
"input.collapse": "收起", "input.collapse": "收起",
"input.context_count.tip": "上下文数", "input.context_count.tip": "上下文数",
"input.estimated_tokens.tip": "预估 token 数", "input.estimated_tokens.tip": "预估 token 数",
"input.expand": "展开", "input.expand": "展开",
"input.new.context": "清除上下文", "input.new.context": "清除上下文 {{Command}}",
"input.new_topic": "新话题 {{Command}}", "input.new_topic": "新话题 {{Command}}",
"input.pause": "暂停", "input.pause": "暂停",
"input.placeholder": "在这里输入消息...", "input.placeholder": "在这里输入消息...",
@@ -327,7 +327,8 @@
"together": "Together", "together": "Together",
"yi": "零一万物", "yi": "零一万物",
"zhinao": "360智脑", "zhinao": "360智脑",
"zhipu": "智谱AI" "zhipu": "智谱AI",
"qwenlm": "QwenLM"
}, },
"settings": { "settings": {
"about": "关于我们", "about": "关于我们",
@@ -385,6 +386,12 @@
"webdav.syncError": "备份错误", "webdav.syncError": "备份错误",
"webdav.lastSync": "上次备份时间" "webdav.lastSync": "上次备份时间"
}, },
"quickAssistant": {
"title": "快捷助手",
"click_tray_to_show": "点击托盘图标启动",
"enable_quick_assistant": "启用快捷助手",
"use_shortcut_to_show": "右键点击托盘图标或使用快捷键启动"
},
"display.title": "显示设置", "display.title": "显示设置",
"font_size.title": "消息字体大小", "font_size.title": "消息字体大小",
"general": "常规设置", "general": "常规设置",
@@ -506,7 +513,10 @@
"toggle_show_assistants": "切换助手显示", "toggle_show_assistants": "切换助手显示",
"toggle_show_topics": "切换话题显示", "toggle_show_topics": "切换话题显示",
"copy_last_message": "复制上一条消息", "copy_last_message": "复制上一条消息",
"search_message": "搜索消息" "search_message": "搜索消息",
"mini_window": "快捷助手",
"clear_topic": "清空消息",
"toggle_new_context": "清除上下文"
}, },
"theme.auto": "跟随系统", "theme.auto": "跟随系统",
"theme.dark": "深色主题", "theme.dark": "深色主题",
@@ -539,7 +549,8 @@
}, },
"tray": { "tray": {
"quit": "退出", "quit": "退出",
"show_window": "显示窗口" "show_window": "显示窗口",
"show_mini_window": "快捷助手"
}, },
"words": { "words": {
"knowledgeGraph": "知识图谱", "knowledgeGraph": "知识图谱",
@@ -621,7 +632,32 @@
} }
}, },
"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": "返回",
"copy_last_message": "按 C 键复制"
}
} }
} }
} }

View File

@@ -64,14 +64,14 @@
"default.description": "你好,我是預設助手。你可以立即開始與我聊天。", "default.description": "你好,我是預設助手。你可以立即開始與我聊天。",
"default.name": "⭐️ 預設助手", "default.name": "⭐️ 預設助手",
"default.topic.name": "預設話題", "default.topic.name": "預設話題",
"input.clear": "清除", "input.clear": "清除 {{Command}}",
"input.clear.content": "您想要清除當前話題的所有訊息嗎?", "input.clear.content": "您想要清除當前話題的所有訊息嗎?",
"input.clear.title": "清除所有訊息?", "input.clear.title": "清除所有訊息?",
"input.collapse": "收起", "input.collapse": "收起",
"input.context_count.tip": "上下文數量", "input.context_count.tip": "上下文數量",
"input.estimated_tokens.tip": "預估 Token 數", "input.estimated_tokens.tip": "預估 Token 數",
"input.expand": "展開", "input.expand": "展開",
"input.new.context": "清除上下文", "input.new.context": "清除上下文 {{Command}}",
"input.new_topic": "新話題 {{Command}}", "input.new_topic": "新話題 {{Command}}",
"input.pause": "暫停", "input.pause": "暫停",
"input.placeholder": "在此輸入您的訊息...", "input.placeholder": "在此輸入您的訊息...",
@@ -326,7 +326,8 @@
"together": "Together", "together": "Together",
"yi": "零一萬物", "yi": "零一萬物",
"zhinao": "360智腦", "zhinao": "360智腦",
"zhipu": "智譜AI" "zhipu": "智譜AI",
"qwenlm": "QwenLM"
}, },
"settings": { "settings": {
"about": "關於與回饋", "about": "關於與回饋",
@@ -384,6 +385,12 @@
"webdav.syncError": "備份錯誤", "webdav.syncError": "備份錯誤",
"webdav.lastSync": "上次同步時間" "webdav.lastSync": "上次同步時間"
}, },
"quickAssistant": {
"title": "快捷助手",
"click_tray_to_show": "點擊托盤圖標啟動",
"enable_quick_assistant": "啟用快捷助手",
"use_shortcut_to_show": "右鍵點擊托盤圖標或使用快捷鍵啟動"
},
"display.title": "顯示設定", "display.title": "顯示設定",
"font_size.title": "訊息字體大小", "font_size.title": "訊息字體大小",
"general": "一般設定", "general": "一般設定",
@@ -505,7 +512,10 @@
"toggle_show_assistants": "切換助手顯示", "toggle_show_assistants": "切換助手顯示",
"toggle_show_topics": "切換話題顯示", "toggle_show_topics": "切換話題顯示",
"copy_last_message": "複製上一条消息", "copy_last_message": "複製上一条消息",
"search_message": "搜索消息" "search_message": "搜索消息",
"mini_window": "快捷助手",
"clear_topic": "清除所有訊息",
"toggle_new_context": "清除上下文"
}, },
"theme.auto": "自動", "theme.auto": "自動",
"theme.dark": "深色主題", "theme.dark": "深色主題",
@@ -538,7 +548,8 @@
}, },
"tray": { "tray": {
"quit": "退出", "quit": "退出",
"show_window": "顯示視窗" "show_window": "顯示視窗",
"show_mini_window": "快捷助手"
}, },
"words": { "words": {
"knowledgeGraph": "知識圖譜", "knowledgeGraph": "知識圖譜",
@@ -620,7 +631,32 @@
} }
}, },
"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": "返回",
"copy_last_message": "按 C 鍵複製"
}
} }
} }
} }

View File

@@ -3,17 +3,27 @@ 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()
} }
function initAutoSync() { function initAutoSync() {
const { webdavAutoSync } = store.getState().settings setTimeout(() => {
if (webdavAutoSync) { const { webdavAutoSync } = store.getState().settings
startAutoSync() if (webdavAutoSync) {
} startAutoSync()
}
}, 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'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(<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 />)
}

View File

@@ -120,12 +120,31 @@ const AgentsPage: FC = () => {
[i18n.language] [i18n.language]
) )
const renderAgentList = useCallback(
(agents: Agent[]) => {
return (
<Row gutter={[20, 20]}>
{agents.map((agent, index) => (
<Col span={6} key={agent.id || index}>
<AgentCard
onClick={() => onAddAgentConfirm(getAgentFromSystemAgent(agent as any))}
agent={agent as any}
/>
</Col>
))}
</Row>
)
},
[onAddAgentConfirm]
)
const tabItems = useMemo(() => { const tabItems = useMemo(() => {
const groups = Object.keys(filteredAgentGroups) const groups = Object.keys(filteredAgentGroups)
return groups.map((group, i) => { return groups.map((group, i) => {
const id = String(i + 1) const id = String(i + 1)
const localizedGroupName = getLocalizedGroupName(group) const localizedGroupName = getLocalizedGroupName(group)
const agents = filteredAgentGroups[group] || []
return { return {
label: localizedGroupName, label: localizedGroupName,
@@ -135,25 +154,12 @@ const AgentsPage: FC = () => {
<Title level={5} key={group} style={{ marginBottom: 10 }}> <Title level={5} key={group} style={{ marginBottom: 10 }}>
{localizedGroupName} {localizedGroupName}
</Title> </Title>
<Row gutter={[20, 20]}> {group === '我的' ? <MyAgents onClick={onAddAgentConfirm} search={search} /> : renderAgentList(agents)}
{group === '我的' ? (
<MyAgents onClick={onAddAgentConfirm} search={search} />
) : (
filteredAgentGroups[group]?.map((agent, index) => (
<Col span={6} key={group + index}>
<AgentCard
onClick={() => onAddAgentConfirm(getAgentFromSystemAgent(agent as any))}
agent={agent as any}
/>
</Col>
))
)}
</Row>
</TabContent> </TabContent>
) )
} }
}) })
}, [filteredAgentGroups, getLocalizedGroupName, onAddAgentConfirm, search]) }, [filteredAgentGroups, getLocalizedGroupName, onAddAgentConfirm, search, renderAgentList])
const handleSearch = () => { const handleSearch = () => {
if (searchInput.trim() === '') { if (searchInput.trim() === '') {
@@ -189,22 +195,9 @@ const AgentsPage: FC = () => {
<AssistantsContainer> <AssistantsContainer>
{Object.values(filteredAgentGroups).flat().length > 0 ? ( {Object.values(filteredAgentGroups).flat().length > 0 ? (
search.trim() ? ( search.trim() ? (
<TabContent> <TabContent>{renderAgentList(Object.values(filteredAgentGroups).flat())}</TabContent>
<Row gutter={[20, 20]}>
{Object.values(filteredAgentGroups)
.flat()
.map((agent, index, array) => (
<Col span={array.length === 1 ? 12 : 6} key={index}>
<AgentCard
onClick={() => onAddAgentConfirm(getAgentFromSystemAgent(agent as any))}
agent={agent as any}
/>
</Col>
))}
</Row>
</TabContent>
) : ( ) : (
<Tabs tabPosition="right" animated items={tabItems} $language={i18n.language} /> <Tabs tabPosition="right" animated={false} items={tabItems} $language={i18n.language} />
) )
) : ( ) : (
<EmptyView> <EmptyView>
@@ -232,6 +225,7 @@ const ContentContainer = styled.div`
height: 100%; height: 100%;
padding: 0 10px; padding: 0 10px;
padding-left: 0; padding-left: 0;
border-top: 0.5px solid var(--color-border);
` `
const AssistantsContainer = styled.div` const AssistantsContainer = styled.div`
@@ -247,6 +241,9 @@ const TabContent = styled(Scrollbar)`
margin-right: -4px; margin-right: -4px;
padding-bottom: 20px !important; padding-bottom: 20px !important;
overflow-x: hidden; overflow-x: hidden;
transform: translateZ(0);
will-change: transform;
-webkit-font-smoothing: antialiased;
` `
const AgentPrompt = styled.div` const AgentPrompt = styled.div`
@@ -268,12 +265,15 @@ const Tabs = styled(TabsAntd)<{ $language: string }>`
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: row-reverse; flex-direction: row-reverse;
.ant-tabs-tabpane { .ant-tabs-tabpane {
padding-right: 0 !important; padding-right: 0 !important;
} }
.ant-tabs-nav { .ant-tabs-nav {
min-width: ${({ $language }) => ($language.startsWith('zh') ? '120px' : '140px')}; min-width: ${({ $language }) => ($language.startsWith('zh') ? '120px' : '140px')};
max-width: ${({ $language }) => ($language.startsWith('zh') ? '120px' : '140px')}; max-width: ${({ $language }) => ($language.startsWith('zh') ? '120px' : '140px')};
position: relative;
overflow: hidden;
} }
.ant-tabs-nav-list { .ant-tabs-nav-list {
padding: 10px 8px; padding: 10px 8px;
@@ -291,11 +291,14 @@ const Tabs = styled(TabsAntd)<{ $language: string }>`
border: 0.5px solid transparent; border: 0.5px solid transparent;
justify-content: ${({ $language }) => ($language.startsWith('zh') ? 'center' : 'flex-start')}; justify-content: ${({ $language }) => ($language.startsWith('zh') ? 'center' : 'flex-start')};
user-select: none; user-select: none;
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
.ant-tabs-tab-btn { .ant-tabs-tab-btn {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: 100px; max-width: 100px;
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
} }
&:hover { &:hover {
color: var(--color-text) !important; color: var(--color-text) !important;
@@ -304,8 +307,8 @@ const Tabs = styled(TabsAntd)<{ $language: string }>`
} }
.ant-tabs-tab-active { .ant-tabs-tab-active {
background-color: var(--color-background-soft); background-color: var(--color-background-soft);
border-right: none;
border: 0.5px solid var(--color-border); border: 0.5px solid var(--color-border);
transform: scale(1.02);
} }
.ant-tabs-content-holder { .ant-tabs-content-holder {
border-left: 0.5px solid var(--color-border); border-left: 0.5px solid var(--color-border);
@@ -322,6 +325,9 @@ const Tabs = styled(TabsAntd)<{ $language: string }>`
color: var(--color-text) !important; color: var(--color-text) !important;
} }
} }
.ant-tabs-content {
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
}
` `
export default AgentsPage export default AgentsPage

View File

@@ -2,11 +2,12 @@ import { EllipsisOutlined } from '@ant-design/icons'
import { Agent } from '@renderer/types' import { Agent } from '@renderer/types'
import { getLeadingEmoji } from '@renderer/utils' import { getLeadingEmoji } from '@renderer/utils'
import { Dropdown } from 'antd' import { Dropdown } from 'antd'
import { FC, memo } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
interface Props { interface Props {
agent: Agent agent: Agent
onClick?: () => void onClick: () => void
contextMenu?: { label: string; onClick: () => void }[] contextMenu?: { label: string; onClick: () => void }[]
menuItems?: { menuItems?: {
key: string key: string
@@ -17,7 +18,7 @@ interface Props {
}[] }[]
} }
const AgentCard: React.FC<Props> = ({ agent, onClick, contextMenu, menuItems }) => { const AgentCard: FC<Props> = ({ agent, onClick, contextMenu, menuItems }) => {
const emoji = agent.emoji || getLeadingEmoji(agent.name) const emoji = agent.emoji || getLeadingEmoji(agent.name)
const prompt = (agent.description || agent.prompt).substring(0, 100).replace(/\\n/g, '') const prompt = (agent.description || agent.prompt).substring(0, 100).replace(/\\n/g, '')
const content = ( const content = (
@@ -205,4 +206,4 @@ const MenuContainer = styled.div`
} }
` `
export default AgentCard export default memo(AgentCard)

View File

@@ -3,7 +3,7 @@ import { useAgents } from '@renderer/hooks/useAgents'
import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings' import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings'
import { createAssistantFromAgent } from '@renderer/services/AssistantService' import { createAssistantFromAgent } from '@renderer/services/AssistantService'
import { Agent } from '@renderer/types' import { Agent } from '@renderer/types'
import { Col } from 'antd' import { Col, Row } from 'antd'
import { useCallback, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -43,7 +43,7 @@ const MyAgents: React.FC<Props> = ({ onClick, search }) => {
) )
return ( return (
<> <Row gutter={[20, 20]}>
{filteredAgents.map((agent) => { {filteredAgents.map((agent) => {
const dropdownMenuItems = [ const dropdownMenuItems = [
{ {
@@ -102,7 +102,7 @@ const MyAgents: React.FC<Props> = ({ onClick, search }) => {
<Col span={6}> <Col span={6}>
<AddAgentCard onClick={() => AddAgentPopup.show()} /> <AddAgentCard onClick={() => AddAgentPopup.show()} />
</Col> </Col>
</> </Row>
) )
} }

View File

@@ -30,6 +30,7 @@ const App: FC<Props> = ({ app, onClick, size = 60 }) => {
key: 'togglePin', key: 'togglePin',
label: isPinned ? t('minapp.sidebar.remove.title') : t('minapp.sidebar.add.title'), label: isPinned ? t('minapp.sidebar.remove.title') : t('minapp.sidebar.add.title'),
onClick: () => { onClick: () => {
console.debug('togglePin', app)
const newPinned = isPinned ? pinned.filter((item) => item.id !== app.id) : [...(pinned || []), app] const newPinned = isPinned ? pinned.filter((item) => item.id !== app.id) : [...(pinned || []), app]
updatePinnedMinapps(newPinned) updatePinnedMinapps(newPinned)
} }

View File

@@ -15,8 +15,6 @@ const AppsPage: FC = () => {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const { minapps } = useMinapps() const { minapps } = useMinapps()
console.debug('minapps', minapps)
const filteredApps = search const filteredApps = search
? minapps.filter( ? minapps.filter(
(app) => app.name.toLowerCase().includes(search.toLowerCase()) || app.url.includes(search.toLowerCase()) (app) => app.name.toLowerCase().includes(search.toLowerCase()) || app.url.includes(search.toLowerCase())

View File

@@ -16,37 +16,26 @@ const AttachmentPreview: FC<Props> = ({ files, setFiles }) => {
} }
return ( return (
<Container> <ContentContainer>
<ContentContainer> <Upload
<Upload listType={files.length > 20 ? 'text' : 'picture-card'}
listType={files.length > 20 ? 'text' : 'picture-card'} fileList={files.map((file) => ({
fileList={files.map((file) => ({ uid: file.id,
uid: file.id, url: 'file://' + FileManager.getSafePath(file),
url: 'file://' + FileManager.getSafePath(file), status: 'done',
status: 'done', name: file.name
name: file.name }))}
}))} onRemove={(item) => setFiles(files.filter((file) => item.uid !== file.id))}
onRemove={(item) => setFiles(files.filter((file) => item.uid !== file.id))} />
/> </ContentContainer>
</ContentContainer>
</Container>
) )
} }
const Container = styled.div`
display: flex;
flex-direction: row;
gap: 10px;
padding: 10px 0;
background: var(--color-background);
border-top: 1px solid var(--color-border-mute);
`
const ContentContainer = styled.div` const ContentContainer = styled.div`
max-height: 40vh; max-height: 40vh;
width: 100%;
overflow-y: auto; overflow-y: auto;
padding: 0 20px; width: 100%;
padding: 10px 15px 0;
` `
export default AttachmentPreview export default AttachmentPreview

View File

@@ -24,7 +24,7 @@ import { estimateTextTokens as estimateTxtTokens } from '@renderer/services/Toke
import { translateText } from '@renderer/services/TranslateService' import { translateText } from '@renderer/services/TranslateService'
import store, { useAppDispatch, useAppSelector } from '@renderer/store' import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { setGenerating, setSearching } from '@renderer/store/runtime' import { setGenerating, setSearching } from '@renderer/store/runtime'
import { Assistant, FileType, KnowledgeBase, Message, Topic } from '@renderer/types' import { Assistant, FileType, KnowledgeBase, Message, Model, Topic } from '@renderer/types'
import { classNames, delay, getFileExtension, uuid } from '@renderer/utils' import { classNames, delay, getFileExtension, uuid } from '@renderer/utils'
import { documentExts, imageExts, textExts } from '@shared/config/constant' import { documentExts, imageExts, textExts } from '@shared/config/constant'
import { Button, Popconfirm, Tooltip } from 'antd' import { Button, Popconfirm, Tooltip } from 'antd'
@@ -39,6 +39,8 @@ import NarrowLayout from '../Messages/NarrowLayout'
import AttachmentButton from './AttachmentButton' import AttachmentButton from './AttachmentButton'
import AttachmentPreview from './AttachmentPreview' import AttachmentPreview from './AttachmentPreview'
import KnowledgeBaseButton from './KnowledgeBaseButton' import KnowledgeBaseButton from './KnowledgeBaseButton'
import MentionModelsButton from './MentionModelsButton'
import MentionModelsInput from './MentionModelsInput'
import SendMessageButton from './SendMessageButton' import SendMessageButton from './SendMessageButton'
import TokenCount from './TokenCount' import TokenCount from './TokenCount'
@@ -82,6 +84,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
const spaceClickTimer = useRef<NodeJS.Timeout>() const spaceClickTimer = useRef<NodeJS.Timeout>()
const [isTranslating, setIsTranslating] = useState(false) const [isTranslating, setIsTranslating] = useState(false)
const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState<KnowledgeBase | undefined>(_base) const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState<KnowledgeBase | undefined>(_base)
const [mentionModels, setMentionModels] = useState<Model[]>([])
const isVision = useMemo(() => isVisionModel(model), [model]) const isVision = useMemo(() => isVisionModel(model), [model])
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision]) const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
@@ -94,6 +97,8 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
[estimateTextTokens, showInputEstimatedTokens, text] [estimateTextTokens, showInputEstimatedTokens, text]
) )
const newTopicShortcut = useShortcutDisplay('new_topic') const newTopicShortcut = useShortcutDisplay('new_topic')
const newContextShortcut = useShortcutDisplay('toggle_new_context')
const cleanTopicShortcut = useShortcutDisplay('clear_topic')
const inputEmpty = isEmpty(text.trim()) && files.length === 0 const inputEmpty = isEmpty(text.trim()) && files.length === 0
_text = text _text = text
@@ -126,15 +131,20 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
message.files = await FileManager.uploadFiles(files) message.files = await FileManager.uploadFiles(files)
} }
if (mentionModels.length > 0) {
message.mentions = mentionModels
}
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, message) EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, message)
setText('') setText('')
setFiles([]) setFiles([])
setMentionModels([])
setTimeout(() => setText(''), 500) setTimeout(() => setText(''), 500)
setTimeout(() => resizeTextArea(), 0) setTimeout(() => resizeTextArea(), 0)
setExpend(false) setExpend(false)
}, [inputEmpty, text, assistant.id, assistant.topics, selectedKnowledgeBase, files]) }, [inputEmpty, text, assistant.id, assistant.topics, selectedKnowledgeBase, files, mentionModels])
const translate = async () => { const translate = async () => {
if (isTranslating) { if (isTranslating) {
@@ -180,7 +190,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
if (expended) { if (expended) {
if (event.key === 'Escape') { if (event.key === 'Escape') {
return setExpend(false) return onToggleExpended()
} }
} }
@@ -273,25 +283,31 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
const onPaste = useCallback( const onPaste = useCallback(
async (event: ClipboardEvent) => { async (event: ClipboardEvent) => {
for (const file of event.clipboardData?.files || []) { const clipboardText = event.clipboardData?.getData('text')
event.preventDefault() if (clipboardText) {
// Prioritize the text when pasting.
// handled by the default event
} else {
for (const file of event.clipboardData?.files || []) {
event.preventDefault()
if (file.path === '') { if (file.path === '') {
if (file.type.startsWith('image/')) { if (file.type.startsWith('image/')) {
const tempFilePath = await window.api.file.create(file.name) const tempFilePath = await window.api.file.create(file.name)
const arrayBuffer = await file.arrayBuffer() const arrayBuffer = await file.arrayBuffer()
const uint8Array = new Uint8Array(arrayBuffer) const uint8Array = new Uint8Array(arrayBuffer)
await window.api.file.write(tempFilePath, uint8Array) await window.api.file.write(tempFilePath, uint8Array)
const selectedFile = await window.api.file.get(tempFilePath) const selectedFile = await window.api.file.get(tempFilePath)
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
break break
}
} }
}
if (file.path) { if (file.path) {
if (supportExts.includes(getFileExtension(file.path))) { if (supportExts.includes(getFileExtension(file.path))) {
const selectedFile = await window.api.file.get(file.path) const selectedFile = await window.api.file.get(file.path)
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile]) selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
}
} }
} }
} }
@@ -347,6 +363,14 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
} }
}) })
useShortcut('clear_topic', () => {
clearTopic()
})
useShortcut('toggle_new_context', () => {
onNewContext()
})
useEffect(() => { useEffect(() => {
const _setEstimateTokenCount = debounce(setEstimateTokenCount, 100, { leading: false, trailing: true }) const _setEstimateTokenCount = debounce(setEstimateTokenCount, 100, { leading: false, trailing: true })
const unsubscribes = [ const unsubscribes = [
@@ -380,20 +404,43 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
} }
}, []) }, [])
useEffect(() => {
window.addEventListener('focus', () => {
textareaRef.current?.focus()
})
}, [])
const textareaRows = window.innerHeight >= 1000 || isBubbleStyle ? 2 : 1 const textareaRows = window.innerHeight >= 1000 || isBubbleStyle ? 2 : 1
const handleKnowledgeBaseSelect = (base?: KnowledgeBase) => { const handleKnowledgeBaseSelect = (base?: KnowledgeBase) => {
setSelectedKnowledgeBase(base) setSelectedKnowledgeBase(base)
} }
const onMentionModel = useCallback(
(model: Model) => {
const isSelected = mentionModels.some((m) => m.id === model.id)
if (isSelected) {
setMentionModels(mentionModels.filter((m) => m.id !== model.id))
} else {
setMentionModels([...mentionModels, model])
}
},
[mentionModels]
)
const handleRemoveModel = (model: Model) => {
setMentionModels(mentionModels.filter((m) => m.id !== model.id))
}
return ( return (
<Container onDragOver={handleDragOver} onDrop={handleDrop} className="inputbar"> <Container onDragOver={handleDragOver} onDrop={handleDrop} className="inputbar">
<NarrowLayout style={{ width: '100%' }}> <NarrowLayout style={{ width: '100%' }}>
<AttachmentPreview files={files} setFiles={setFiles} />
<InputBarContainer <InputBarContainer
id="inputbar" id="inputbar"
className={classNames('inputbar-container', inputFocus && 'focus')} className={classNames('inputbar-container', inputFocus && 'focus')}
ref={containerRef}> ref={containerRef}>
<AttachmentPreview files={files} setFiles={setFiles} />
<MentionModelsInput selectedModels={mentionModels} onRemoveModel={handleRemoveModel} />
<Textarea <Textarea
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
@@ -421,6 +468,11 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
<FormOutlined /> <FormOutlined />
</ToolbarButton> </ToolbarButton>
</Tooltip> </Tooltip>
<MentionModelsButton
mentionModels={mentionModels}
onMentionModel={onMentionModel}
ToolbarButton={ToolbarButton}
/>
{isWebSearchModel(model) && ( {isWebSearchModel(model) && (
<Tooltip placement="top" title={t('chat.input.web_search')} arrow> <Tooltip placement="top" title={t('chat.input.web_search')} arrow>
<ToolbarButton <ToolbarButton
@@ -432,14 +484,14 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
</ToolbarButton> </ToolbarButton>
</Tooltip> </Tooltip>
)} )}
<Tooltip placement="top" title={t('chat.input.clear')} arrow> <Tooltip placement="top" title={t('chat.input.clear', { Command: cleanTopicShortcut })} arrow>
<Popconfirm <Popconfirm
title={t('chat.input.clear.content')} title={t('chat.input.clear.content')}
placement="top" placement="top"
onConfirm={clearTopic} onConfirm={clearTopic}
okButtonProps={{ danger: true }} okButtonProps={{ danger: true }}
icon={<QuestionCircleOutlined style={{ color: 'red' }} />} icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
okText={t('chat.input.clear')}> okText={t('chat.input.clear.title')}>
<ToolbarButton type="text"> <ToolbarButton type="text">
<ClearOutlined /> <ClearOutlined />
</ToolbarButton> </ToolbarButton>
@@ -464,11 +516,11 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic }) => {
/> />
)} )}
<AttachmentButton model={model} files={files} setFiles={setFiles} ToolbarButton={ToolbarButton} /> <AttachmentButton model={model} files={files} setFiles={setFiles} ToolbarButton={ToolbarButton} />
<ToolbarButton type="text" onClick={onNewContext}> <Tooltip placement="top" title={t('chat.input.new.context', { Command: newContextShortcut })} arrow>
<Tooltip placement="top" title={t('chat.input.new.context')}> <ToolbarButton type="text" onClick={onNewContext}>
<PicCenterOutlined /> <PicCenterOutlined />
</Tooltip> </ToolbarButton>
</ToolbarButton> </Tooltip>
<Tooltip placement="top" title={expended ? t('chat.input.collapse') : t('chat.input.expand')} arrow> <Tooltip placement="top" title={expended ? t('chat.input.collapse') : t('chat.input.expand')} arrow>
<ToolbarButton type="text" onClick={onToggleExpended}> <ToolbarButton type="text" onClick={onToggleExpended}>
{expended ? <FullscreenExitOutlined /> : <FullscreenOutlined />} {expended ? <FullscreenExitOutlined /> : <FullscreenOutlined />}

View File

@@ -0,0 +1,155 @@
import { PushpinOutlined } from '@ant-design/icons'
import ModelTags from '@renderer/components/ModelTags'
import { getModelLogo, isEmbeddingModel } from '@renderer/config/models'
import db from '@renderer/databases'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
import { Model } from '@renderer/types'
import { Avatar, Dropdown, Tooltip } from 'antd'
import { first, sortBy } from 'lodash'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled, { createGlobalStyle } from 'styled-components'
interface Props {
mentionModels: Model[]
onMentionModel: (model: Model) => void
ToolbarButton: any
}
const MentionModelsButton: FC<Props> = ({ onMentionModel: onSelect, ToolbarButton }) => {
const { providers } = useProviders()
const [pinnedModels, setPinnedModels] = useState<string[]>([])
const { t } = useTranslation()
useEffect(() => {
const loadPinnedModels = async () => {
const setting = await db.settings.get('pinned:models')
setPinnedModels(setting?.value || [])
}
loadPinnedModels()
}, [])
const togglePin = async (modelId: string) => {
const newPinnedModels = pinnedModels.includes(modelId)
? pinnedModels.filter((id) => id !== modelId)
: [...pinnedModels, modelId]
await db.settings.put({ id: 'pinned:models', value: newPinnedModels })
setPinnedModels(newPinnedModels)
}
const modelMenuItems = providers
.filter((p) => p.models && p.models.length > 0)
.map((p) => {
const filteredModels = sortBy(p.models, ['group', 'name'])
.filter((m) => !isEmbeddingModel(m))
.map((m) => ({
key: getModelUniqId(m),
label: (
<ModelItem>
<span>
{m?.name} <ModelTags model={m} />
</span>
{/* <Checkbox checked={selectedModels.some((sm) => sm.id === m.id)} /> */}
<PinIcon
onClick={(e) => {
e.stopPropagation()
togglePin(getModelUniqId(m))
}}
$isPinned={pinnedModels.includes(getModelUniqId(m))}>
<PushpinOutlined />
</PinIcon>
</ModelItem>
),
icon: (
<Avatar src={getModelLogo(m.id)} size={24}>
{first(m.name)}
</Avatar>
),
onClick: () => {
onSelect(m)
}
}))
return filteredModels.length > 0
? {
key: p.id,
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
type: 'group' as const,
children: filteredModels
}
: null
})
.filter(Boolean)
if (pinnedModels.length > 0) {
const pinnedItems = modelMenuItems
.flatMap((p) => p?.children || [])
.filter((m) => pinnedModels.includes(m.key))
.map((m) => ({ ...m, key: m.key + 'pinned' }))
if (pinnedItems.length > 0) {
modelMenuItems.unshift({
key: 'pinned',
label: t('models.pinned'),
type: 'group' as const,
children: pinnedItems
})
}
}
return (
<>
<DropdownMenuStyle />
<Dropdown menu={{ items: modelMenuItems }} trigger={['click']} overlayClassName="mention-models-dropdown">
<Tooltip placement="top" title={t('agents.edit.model.select.title')} arrow>
<ToolbarButton type="text">
<i className="iconfont icon-at" style={{ fontSize: 18 }}></i>
</ToolbarButton>
</Tooltip>
</Dropdown>
</>
)
}
const DropdownMenuStyle = createGlobalStyle`
.mention-models-dropdown {
.ant-dropdown-menu {
max-height: 400px;
}
}
`
const ModelItem = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
width: 100%;
gap: 16px;
&:hover {
.pin-icon {
opacity: 0.3;
}
}
`
const PinIcon = styled.span.attrs({ className: 'pin-icon' })<{ $isPinned: boolean }>`
margin-left: auto;
padding: 0 8px;
opacity: ${(props) => (props.$isPinned ? 1 : 'inherit')};
transition: opacity 0.2s;
right: 0;
color: ${(props) => (props.$isPinned ? 'var(--color-primary)' : 'inherit')};
transform: ${(props) => (props.$isPinned ? 'rotate(-45deg)' : 'none')};
opacity: 0;
&:hover {
opacity: 1 !important;
color: ${(props) => (props.$isPinned ? 'var(--color-primary)' : 'inherit')};
}
`
export default MentionModelsButton

View File

@@ -0,0 +1,26 @@
import { Model } from '@renderer/types'
import { Flex, Tag } from 'antd'
import { FC } from 'react'
import styled from 'styled-components'
const MentionModelsInput: FC<{
selectedModels: Model[]
onRemoveModel: (model: Model) => void
}> = ({ selectedModels, onRemoveModel }) => {
return (
<Container gap="4px 0" wrap>
{selectedModels.map((model) => (
<Tag bordered={false} color="processing" key={model.id} closable onClose={() => onRemoveModel(model)}>
@{model.name}
</Tag>
))}
</Container>
)
}
const Container = styled(Flex)`
width: 100%;
padding: 10px 15px 0;
`
export default MentionModelsInput

View File

@@ -109,6 +109,8 @@ const MessageItem: FC<Props> = ({
if (topic && onGetMessages && onSetMessages) { if (topic && onGetMessages && onSetMessages) {
if (message.status === 'sending') { if (message.status === 'sending') {
const messages = onGetMessages() const messages = onGetMessages()
const assistantWithModel = message.model ? { ...assistant, model: message.model } : assistant
fetchChatCompletion({ fetchChatCompletion({
message, message,
messages: messages messages: messages
@@ -117,8 +119,7 @@ const MessageItem: FC<Props> = ({
0, 0,
messages.findIndex((m) => m.id === message.id) messages.findIndex((m) => m.id === message.id)
), ),
assistant, assistant: assistantWithModel,
topic,
onResponse: (msg) => { onResponse: (msg) => {
setMessage(msg) setMessage(msg)
if (msg.status !== 'pending') { if (msg.status !== 'pending') {

View File

@@ -1,7 +1,7 @@
import { SyncOutlined, TranslationOutlined } from '@ant-design/icons' import { SyncOutlined, TranslationOutlined } from '@ant-design/icons'
import { Message, Model } from '@renderer/types' import { Message, Model } from '@renderer/types'
import { getBriefInfo } from '@renderer/utils' import { getBriefInfo } from '@renderer/utils'
import { Divider } from 'antd' import { Divider, Flex } from 'antd'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import BeatLoader from 'react-spinners/BeatLoader' import BeatLoader from 'react-spinners/BeatLoader'
@@ -37,6 +37,9 @@ const MessageContent: React.FC<{
return ( return (
<> <>
<Flex gap="8px" wrap>
{message.mentions?.map((model) => <MentionTag key={model.id}>{'@' + model.name}</MentionTag>)}
</Flex>
<Markdown message={message} /> <Markdown message={message} />
{message.translatedContent && ( {message.translatedContent && (
<> <>
@@ -65,4 +68,8 @@ const MessageContentLoading = styled.div`
margin-bottom: 5px; margin-bottom: 5px;
` `
const MentionTag = styled.span`
color: var(--color-link);
`
export default React.memo(MessageContent) export default React.memo(MessageContent)

View File

@@ -96,9 +96,9 @@ const MessageMenubar: FC<Props> = (props) => {
}) })
} }
if (!nextMessage) { if (!nextMessage || nextMessage.role === 'user') {
onDeleteMessage?.(message)
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, { ...message, id: uuid() }) EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, { ...message, id: uuid() })
onDeleteMessage?.(message)
} }
}, [assistantModel?.id, message, model?.id, onDeleteMessage, onGetMessages]) }, [assistantModel?.id, message, model?.id, onDeleteMessage, onGetMessages])

View File

@@ -97,10 +97,19 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
const onSendMessage = useCallback( const onSendMessage = useCallback(
async (message: Message) => { async (message: Message) => {
const assistantMessage = getAssistantMessage({ assistant, topic }) const assistantMessages: Message[] = []
if (message.mentions?.length) {
message.mentions.forEach((m) => {
const assistantMessage = getAssistantMessage({ assistant: { ...assistant, model: m }, topic })
assistantMessage.model = m
assistantMessages.push(assistantMessage)
})
} else {
assistantMessages.push(getAssistantMessage({ assistant, topic }))
}
setMessages((prev) => { setMessages((prev) => {
const messages = prev.concat([message, assistantMessage]) const messages = prev.concat([message, ...assistantMessages])
db.topics.put({ id: topic.id, messages }) db.topics.put({ id: topic.id, messages })
return messages return messages
}) })
@@ -156,7 +165,8 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
}), }),
EventEmitter.on(EVENT_NAMES.REGENERATE_MESSAGE, async (model: Model) => { EventEmitter.on(EVENT_NAMES.REGENERATE_MESSAGE, async (model: Model) => {
const lastUserMessage = last(filterMessages(messages).filter((m) => m.role === 'user')) const lastUserMessage = last(filterMessages(messages).filter((m) => m.role === 'user'))
lastUserMessage && onSendMessage({ ...lastUserMessage, id: uuid(), type: '@', modelId: model.id }) lastUserMessage &&
onSendMessage({ ...lastUserMessage, id: uuid(), modelId: model.id, model: model, mentions: [model] })
}), }),
EventEmitter.on(EVENT_NAMES.AI_AUTO_RENAME, autoRenameTopic), EventEmitter.on(EVENT_NAMES.AI_AUTO_RENAME, autoRenameTopic),
EventEmitter.on(EVENT_NAMES.CLEAR_MESSAGES, () => { EventEmitter.on(EVENT_NAMES.CLEAR_MESSAGES, () => {

View File

@@ -7,7 +7,7 @@ import Scrollbar from '@renderer/components/Scrollbar'
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge' import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
import { KnowledgeBase } from '@renderer/types' import { KnowledgeBase } from '@renderer/types'
import { Dropdown, Empty, MenuProps } from 'antd' import { Dropdown, Empty, MenuProps } from 'antd'
import { FC, useCallback, useEffect, useRef, useState } from 'react' import { FC, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@@ -17,33 +17,18 @@ import KnowledgeContent from './KnowledgeContent'
const KnowledgePage: FC = () => { const KnowledgePage: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { bases, renameKnowledgeBase, deleteKnowledgeBase, updateKnowledgeBases } = useKnowledgeBases() const { bases, renameKnowledgeBase, deleteKnowledgeBase, updateKnowledgeBases } = useKnowledgeBases()
const [selectedBase, setSelectedBase] = useState<KnowledgeBase>() const [selectedBase, setSelectedBase] = useState<KnowledgeBase | undefined>(bases[0])
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const prevLength = useRef(0)
const handleAddKnowledge = async () => { const handleAddKnowledge = async () => {
await AddKnowledgePopup.show({ title: t('knowledge.add.title') }) await AddKnowledgePopup.show({ title: t('knowledge.add.title') })
} }
useEffect(() => { useEffect(() => {
if (bases.length > 0) { const hasSelectedBase = bases.find((base) => base.id === selectedBase?.id)
if (!selectedBase) { !hasSelectedBase && setSelectedBase(bases[0])
return setSelectedBase(bases[0])
}
if (selectedBase && !bases.find((base) => base.id === selectedBase.id)) {
return setSelectedBase(bases[0])
}
}
}, [bases, selectedBase]) }, [bases, selectedBase])
useEffect(() => {
const currentLength = bases.length
if (currentLength > 0 && currentLength > prevLength.current) {
setSelectedBase(bases[currentLength - 1])
}
prevLength.current = currentLength
}, [bases])
const getMenuItems = useCallback( const getMenuItems = useCallback(
(base: KnowledgeBase) => { (base: KnowledgeBase) => {
const menus: MenuProps['items'] = [ const menus: MenuProps['items'] = [

View File

@@ -55,7 +55,7 @@ const AboutSettings: FC = () => {
} }
const mailto = async () => { const mailto = async () => {
const email = 'kangfenmao@qq.com' const email = 'support@cherry-ai.com'
const subject = `${APP_NAME} Feedback` const subject = `${APP_NAME} Feedback`
const version = (await window.api.getAppInfo()).version const version = (await window.api.getAppInfo()).version
const platform = window.electron.process.platform const platform = window.electron.process.platform

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

@@ -68,6 +68,9 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
} }
const onAddModel = (model: Model) => { const onAddModel = (model: Model) => {
if (isEmpty(model.name)) {
return
}
addModel(model) addModel(model)
} }
@@ -92,7 +95,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
description: model?.description, description: model?.description,
owned_by: model?.owned_by owned_by: model?.owned_by
})) }))
.filter((model) => !isEmpty(model.id)) .filter((model) => !isEmpty(model.name))
) )
setLoading(false) setLoading(false)
} catch (error) { } catch (error) {
@@ -151,7 +154,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
<ListItem key={model.id}> <ListItem key={model.id}>
<ListItemHeader> <ListItemHeader>
<Avatar src={getModelLogo(model.id)} size={24}> <Avatar src={getModelLogo(model.id)} size={24}>
{model.name[0].toUpperCase()} {model?.name?.[0]?.toUpperCase()}
</Avatar> </Avatar>
<ListItemName> <ListItemName>
<Tooltip title={model.id} placement="top"> <Tooltip title={model.id} placement="top">

View File

@@ -279,9 +279,9 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
<ModelListItem key={model.id}> <ModelListItem key={model.id}>
<ModelListHeader> <ModelListHeader>
<Avatar src={getModelLogo(model.id)} size={22} style={{ marginRight: '8px' }}> <Avatar src={getModelLogo(model.id)} size={22} style={{ marginRight: '8px' }}>
{model.name[0].toUpperCase()} {model?.name?.[0]?.toUpperCase()}
</Avatar> </Avatar>
{model.name} {model?.name}
<ModelTags model={model} /> <ModelTags model={model} />
<Popover content={modelTypeContent(model)} title={t('models.type.select')} trigger="click"> <Popover content={modelTypeContent(model)} title={t('models.type.select')} trigger="click">
<SettingIcon /> <SettingIcon />

View File

@@ -0,0 +1,90 @@
import { InfoCircleOutlined } from '@ant-design/icons'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useSettings } from '@renderer/hooks/useSettings'
import { useAppDispatch } from '@renderer/store'
import { setClickTrayToShowQuickAssistant, setEnableQuickAssistant } from '@renderer/store/settings'
import HomeWindow from '@renderer/windows/mini/home/HomeWindow'
import { Switch, Tooltip } 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 { enableQuickAssistant, clickTrayToShowQuickAssistant, setTray } = useSettings()
const dispatch = useAppDispatch()
const handleEnableQuickAssistant = async (enable: boolean) => {
dispatch(setEnableQuickAssistant(enable))
await window.api.config.set('enableQuickAssistant', enable)
window.api.restartTray()
const disable = !enable
disable && window.api.miniWindow.close()
if (enable && !clickTrayToShowQuickAssistant) {
window.message.info({
content: t('settings.quickAssistant.use_shortcut_to_show'),
duration: 4,
icon: <InfoCircleOutlined />,
key: 'quick-assistant-info'
})
}
if (enable && clickTrayToShowQuickAssistant) {
setTray(true)
}
}
const handleClickTrayToShowQuickAssistant = async (checked: boolean) => {
dispatch(setClickTrayToShowQuickAssistant(checked))
await window.api.config.set('clickTrayToShowQuickAssistant', checked)
checked && setTray(true)
}
return (
<SettingContainer theme={theme}>
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.quickAssistant.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span>{t('settings.quickAssistant.enable_quick_assistant')}</span>
<Tooltip title={t('settings.quickAssistant.use_shortcut_to_show')} placement="right">
<InfoCircleOutlined style={{ cursor: 'pointer' }} />
</Tooltip>
</SettingRowTitle>
<Switch checked={enableQuickAssistant} onChange={handleEnableQuickAssistant} />
</SettingRow>
{enableQuickAssistant && (
<>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.quickAssistant.click_tray_to_show')}</SettingRowTitle>
<Switch checked={clickTrayToShowQuickAssistant} onChange={handleClickTrayToShowQuickAssistant} />
</SettingRow>
</>
)}
</SettingGroup>
{enableQuickAssistant && (
<AssistantContainer>
<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;
overflow: hidden;
`
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

@@ -2,7 +2,8 @@ import { ClearOutlined, UndoOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout' import { HStack } from '@renderer/components/Layout'
import { isMac, isWindows } from '@renderer/config/constant' import { isMac, isWindows } from '@renderer/config/constant'
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
import { useAppDispatch, useAppSelector } from '@renderer/store' import { useShortcuts } from '@renderer/hooks/useShortcuts'
import { useAppDispatch } from '@renderer/store'
import { initialState, resetShortcuts, toggleShortcut, updateShortcut } from '@renderer/store/shortcuts' import { initialState, resetShortcuts, toggleShortcut, updateShortcut } from '@renderer/store/shortcuts'
import { Shortcut } from '@renderer/types' import { Shortcut } from '@renderer/types'
import { Button, Input, InputRef, Switch, Table as AntTable, Tooltip } from 'antd' import { Button, Input, InputRef, Switch, Table as AntTable, Tooltip } from 'antd'
@@ -17,7 +18,7 @@ const ShortcutSettings: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { theme } = useTheme() const { theme } = useTheme()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts) const { shortcuts } = useShortcuts()
const inputRefs = useRef<Record<string, InputRef>>({}) const inputRefs = useRef<Record<string, InputRef>>({})
const [editingKey, setEditingKey] = useState<string | null>(null) const [editingKey, setEditingKey] = useState<string | null>(null)

View File

@@ -2,6 +2,7 @@ import { CheckOutlined, SendOutlined, SettingOutlined, SwapOutlined, WarningOutl
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import CopyIcon from '@renderer/components/Icons/CopyIcon' import CopyIcon from '@renderer/components/Icons/CopyIcon'
import { isLocalAi } from '@renderer/config/env' import { isLocalAi } from '@renderer/config/env'
import { TranslateLanguageOptions } from '@renderer/config/translate'
import db from '@renderer/databases' import db from '@renderer/databases'
import { useDefaultModel } from '@renderer/hooks/useAssistant' import { useDefaultModel } from '@renderer/hooks/useAssistant'
import { fetchTranslate } from '@renderer/services/ApiService' import { fetchTranslate } from '@renderer/services/ApiService'
@@ -33,64 +34,6 @@ const TranslatePage: FC = () => {
_result = result _result = result
_targetLanguage = targetLanguage _targetLanguage = targetLanguage
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 onTranslate = async () => { const onTranslate = async () => {
if (!text.trim()) { if (!text.trim()) {
return return
@@ -119,8 +62,7 @@ const TranslatePage: FC = () => {
} }
setLoading(true) setLoading(true)
const translateText = await fetchTranslate({ message, assistant }) await fetchTranslate({ message, assistant, onResponse: (text) => setResult(text) })
setResult(translateText)
setLoading(false) setLoading(false)
} }
@@ -187,7 +129,7 @@ const TranslatePage: FC = () => {
value={targetLanguage} value={targetLanguage}
style={{ width: 180 }} style={{ width: 180 }}
optionFilterProp="label" optionFilterProp="label"
options={languageOptions} options={TranslateLanguageOptions}
onChange={(value) => { onChange={(value) => {
setTargetLanguage(value) setTargetLanguage(value)
db.settings.put({ id: 'translate:target:language', value }) db.settings.put({ id: 'translate:target:language', value })

View File

@@ -20,8 +20,8 @@ export default class AiProvider {
return this.sdk.completions({ messages, assistant, onChunk, onFilterMessages }) return this.sdk.completions({ messages, assistant, onChunk, onFilterMessages })
} }
public async translate(message: Message, assistant: Assistant): Promise<string> { public async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void): Promise<string> {
return this.sdk.translate(message, assistant) return this.sdk.translate(message, assistant, onResponse)
} }
public async summaries(messages: Message[], assistant: Assistant): Promise<string> { public async summaries(messages: Message[], assistant: Assistant): Promise<string> {

View File

@@ -149,7 +149,7 @@ export default class AnthropicProvider extends BaseProvider {
}) })
} }
public async translate(message: Message, assistant: Assistant) { public async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void) {
const defaultModel = getDefaultModel() const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel const model = assistant.model || defaultModel
const messages = [ const messages = [
@@ -157,16 +157,33 @@ export default class AnthropicProvider extends BaseProvider {
{ role: 'user', content: message.content } { role: 'user', content: message.content }
] ]
const response = await this.sdk.messages.create({ const stream = onResponse ? true : false
const body: MessageCreateParamsNonStreaming = {
model: model.id, model: model.id,
messages: messages.filter((m) => m.role === 'user') as MessageParam[], messages: messages.filter((m) => m.role === 'user') as MessageParam[],
max_tokens: 4096, max_tokens: 4096,
temperature: assistant?.settings?.temperature, temperature: assistant?.settings?.temperature,
system: assistant.prompt, system: assistant.prompt
stream: false }
})
return response.content[0].type === 'text' ? response.content[0].text : '' if (!stream) {
const response = await this.sdk.messages.create({ ...body, stream: false })
return response.content[0].type === 'text' ? response.content[0].text : ''
}
let text = ''
return new Promise<string>((resolve, reject) => {
this.sdk.messages
.stream({ ...body, stream: true })
.on('text', (_text) => {
text += _text
onResponse?.(text)
})
.on('finalMessage', () => resolve(text))
.on('error', (error) => reject(error))
})
} }
public async summaries(messages: Message[], assistant: Assistant): Promise<string> { public async summaries(messages: Message[], assistant: Assistant): Promise<string> {
@@ -190,7 +207,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

@@ -20,7 +20,7 @@ export default abstract class BaseProvider {
} }
abstract completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void> abstract completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void>
abstract translate(message: Message, assistant: Assistant): Promise<string> abstract translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void): Promise<string>
abstract summaries(messages: Message[], assistant: Assistant): Promise<string> abstract summaries(messages: Message[], assistant: Assistant): Promise<string>
abstract suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]> abstract suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]>
abstract generateText({ prompt, content }: { prompt: string; content: string }): Promise<string> abstract generateText({ prompt, content }: { prompt: string; content: string }): Promise<string>

View File

@@ -7,6 +7,7 @@ import {
InlineDataPart, InlineDataPart,
Part, Part,
RequestOptions, RequestOptions,
SafetySetting,
TextPart TextPart
} from '@google/generative-ai' } from '@google/generative-ai'
import { isWebSearchModel } from '@renderer/config/models' import { isWebSearchModel } from '@renderer/config/models'
@@ -112,6 +113,35 @@ export default class GeminiProvider extends BaseProvider {
} }
} }
private getSafetySettings(modelId: string): SafetySetting[] {
const safetyThreshold = modelId.includes('gemini-2.0-flash-exp')
? ('OFF' as HarmBlockThreshold)
: HarmBlockThreshold.BLOCK_NONE
return [
{
category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold: safetyThreshold
},
{
category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold: safetyThreshold
},
{
category: HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold: safetyThreshold
},
{
category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold: safetyThreshold
},
{
category: 'HARM_CATEGORY_CIVIC_INTEGRITY' as HarmCategory,
threshold: safetyThreshold
}
]
}
public async completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams) { public async completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams) {
const defaultModel = getDefaultModel() const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel const model = assistant.model || defaultModel
@@ -138,21 +168,13 @@ export default class GeminiProvider extends BaseProvider {
systemInstruction: assistant.prompt, systemInstruction: assistant.prompt,
// @ts-ignore googleSearch is not a valid tool for Gemini // @ts-ignore googleSearch is not a valid tool for Gemini
tools: assistant.enableWebSearch && isWebSearchModel(model) ? [{ googleSearch: {} }] : undefined, tools: assistant.enableWebSearch && isWebSearchModel(model) ? [{ googleSearch: {} }] : undefined,
safetySettings: this.getSafetySettings(model.id),
generationConfig: { generationConfig: {
maxOutputTokens: maxTokens, maxOutputTokens: maxTokens,
temperature: assistant?.settings?.temperature, temperature: assistant?.settings?.temperature,
topP: assistant?.settings?.topP, topP: assistant?.settings?.topP,
...this.getCustomParameters(assistant) ...this.getCustomParameters(assistant)
}, }
safetySettings: [
{ category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_NONE },
{
category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold: HarmBlockThreshold.BLOCK_NONE
},
{ category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_NONE }
]
}, },
this.requestOptions this.requestOptions
) )
@@ -208,7 +230,7 @@ export default class GeminiProvider extends BaseProvider {
} }
} }
async translate(message: Message, assistant: Assistant) { async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void) {
const defaultModel = getDefaultModel() const defaultModel = getDefaultModel()
const { maxTokens } = getAssistantSettings(assistant) const { maxTokens } = getAssistantSettings(assistant)
const model = assistant.model || defaultModel const model = assistant.model || defaultModel
@@ -225,9 +247,21 @@ export default class GeminiProvider extends BaseProvider {
this.requestOptions this.requestOptions
) )
const { response } = await geminiModel.generateContent(message.content) if (!onResponse) {
const { response } = await geminiModel.generateContent(message.content)
return response.text()
}
return response.text() const response = await geminiModel.generateContentStream(message.content)
let text = ''
for await (const chunk of response.stream) {
text += chunk.text()
onResponse(text)
}
return text
} }
public async summaries(messages: Message[], assistant: Assistant): Promise<string> { public async summaries(messages: Message[], assistant: Assistant): Promise<string> {
@@ -247,7 +281,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

@@ -132,7 +132,7 @@ export default class OpenAIProvider extends BaseProvider {
userMessages.push(await this.getMessageParam(message, model)) userMessages.push(await this.getMessageParam(message, model))
} }
const isOpenAIo1 = model.id.includes('o1-') const isOpenAIo1 = model.id.startsWith('o1')
const isSupportStreamOutput = () => { const isSupportStreamOutput = () => {
if (this.provider.id === 'github' && isOpenAIo1) { if (this.provider.id === 'github' && isOpenAIo1) {
@@ -192,7 +192,7 @@ export default class OpenAIProvider extends BaseProvider {
} }
} }
async translate(message: Message, assistant: Assistant) { async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void) {
const defaultModel = getDefaultModel() const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel const model = assistant.model || defaultModel
const messages = [ const messages = [
@@ -200,16 +200,41 @@ export default class OpenAIProvider extends BaseProvider {
{ role: 'user', content: message.content } { role: 'user', content: message.content }
] ]
const isOpenAIo1 = model.id.startsWith('o1')
const isSupportedStreamOutput = () => {
if (!onResponse) {
return false
}
if (this.provider.id === 'github' && isOpenAIo1) {
return false
}
return true
}
const stream = isSupportedStreamOutput()
// @ts-ignore key is not typed // @ts-ignore key is not typed
const response = await this.sdk.chat.completions.create({ const response = await this.sdk.chat.completions.create({
model: model.id, model: model.id,
messages: messages as ChatCompletionMessageParam[], messages: messages as ChatCompletionMessageParam[],
stream: false, stream,
keep_alive: this.keepAliveTime, keep_alive: this.keepAliveTime,
temperature: assistant?.settings?.temperature temperature: assistant?.settings?.temperature
}) })
return response.choices[0].message?.content || '' if (!stream) {
return response.choices[0].message?.content || ''
}
let text = ''
for await (const chunk of response) {
text += chunk.choices[0]?.delta?.content || ''
onResponse?.(text)
}
return text
} }
public async summaries(messages: Message[], assistant: Assistant): Promise<string> { public async summaries(messages: Message[], assistant: Assistant): Promise<string> {
@@ -229,7 +254,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

@@ -4,6 +4,7 @@ import AnthropicProvider from './AnthropicProvider'
import BaseProvider from './BaseProvider' import BaseProvider from './BaseProvider'
import GeminiProvider from './GeminiProvider' import GeminiProvider from './GeminiProvider'
import OpenAIProvider from './OpenAIProvider' import OpenAIProvider from './OpenAIProvider'
import QwenLMProvider from './QwenLMProvider'
export default class ProviderFactory { export default class ProviderFactory {
static create(provider: Provider): BaseProvider { static create(provider: Provider): BaseProvider {
@@ -12,6 +13,8 @@ export default class ProviderFactory {
return new AnthropicProvider(provider) return new AnthropicProvider(provider)
case 'gemini': case 'gemini':
return new GeminiProvider(provider) return new GeminiProvider(provider)
case 'qwenlm':
return new QwenLMProvider(provider)
default: default:
return new OpenAIProvider(provider) return new OpenAIProvider(provider)
} }

View File

@@ -0,0 +1,160 @@
import { getOpenAIWebSearchParams, isVisionModel } from '@renderer/config/models'
import { getAssistantSettings, getDefaultModel } from '@renderer/services/AssistantService'
import { EVENT_NAMES } from '@renderer/services/EventService'
import { filterContextMessages } from '@renderer/services/MessagesService'
import { FileTypes, Message, Model, Provider } from '@renderer/types'
import { takeRight } from 'lodash'
import OpenAI from 'openai'
import { ChatCompletionContentPart, ChatCompletionMessageParam } from 'openai/resources'
import { CompletionsParams } from '.'
import OpenAIProvider from './OpenAIProvider'
class QwenLMProvider extends OpenAIProvider {
constructor(provider: Provider) {
super(provider)
}
private async getMessageParams(
message: Message,
model: Model
): Promise<OpenAI.Chat.Completions.ChatCompletionMessageParam> {
const isVision = isVisionModel(model)
const content = await this.getMessageContent(message)
if (!message.files) {
return {
role: message.role,
content
}
}
const parts: ChatCompletionContentPart[] = [
{
type: 'text',
text: content
}
]
const qwenlm_image_url: { type: string; image: string }[] = []
for (const file of message.files || []) {
if (file.type === FileTypes.IMAGE && isVision) {
const image = await window.api.file.binaryFile(file.id + file.ext)
const imageId = await this.uploadImageToQwenLM(image.data, file.origin_name, image.mime)
qwenlm_image_url.push({
type: 'image',
image: imageId
})
}
if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) {
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
parts.push({
type: 'text',
text: file.origin_name + '\n' + fileContent
})
}
}
return {
role: message.role,
content: [...parts, ...qwenlm_image_url]
} as ChatCompletionMessageParam
}
private async uploadImageToQwenLM(image_file: Buffer, file_name: string, mime: string): Promise<string> {
try {
// 创建 FormData
const formData = new FormData()
formData.append('file', new Blob([image_file], { type: mime }), file_name)
// 发送上传请求
const response = await fetch(`${this.provider.apiHost}v1/files/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.apiKey}`
},
body: formData
})
if (!response.ok) {
throw new Error('Failed to upload image to QwenLM')
}
const data = await response.json()
return data.id
} catch (error) {
console.error('Error uploading image to QwenLM:', error)
throw error
}
}
async completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void> {
const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel
const { contextCount, maxTokens } = getAssistantSettings(assistant)
const systemMessage = assistant.prompt ? { role: 'system', content: assistant.prompt } : undefined
const userMessages: ChatCompletionMessageParam[] = []
const _messages = filterContextMessages(takeRight(messages, contextCount + 1))
onFilterMessages(_messages)
if (_messages[0]?.role !== 'user') {
userMessages.push({ role: 'user', content: '' })
}
for (const message of _messages) {
userMessages.push(await this.getMessageParams(message, model))
}
let time_first_token_millsec = 0
const start_time_millsec = new Date().getTime()
// @ts-ignore key is not typed
const stream = await this.sdk.chat.completions.create({
model: model.id,
messages: [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[],
temperature: assistant?.settings?.temperature,
top_p: assistant?.settings?.topP,
max_tokens: maxTokens,
stream: true,
...(assistant.enableWebSearch ? getOpenAIWebSearchParams(model) : {}),
...this.getCustomParameters(assistant)
})
let accumulatedText = ''
for await (const chunk of stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
break
}
if (time_first_token_millsec == 0) {
time_first_token_millsec = new Date().getTime() - start_time_millsec
}
// 获取当前块的完整内容
const currentContent = chunk.choices[0]?.delta?.content || ''
// 如果内容与累积的内容不同,则只发送增量部分
if (currentContent !== accumulatedText) {
const deltaText = currentContent.slice(accumulatedText.length)
accumulatedText = currentContent // 更新累积的文本
const time_completion_millsec = new Date().getTime() - start_time_millsec
onChunk({
text: deltaText,
usage: chunk.usage,
metrics: {
completion_tokens: chunk.usage?.completion_tokens,
time_completion_millsec,
time_first_token_millsec
}
})
}
}
}
}
export default QwenLMProvider

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
}) { }) {
@@ -102,7 +101,13 @@ export async function fetchChatCompletion({
return message return message
} }
export async function fetchTranslate({ message, assistant }: { message: Message; assistant: Assistant }) { interface FetchTranslateProps {
message: Message
assistant: Assistant
onResponse?: (text: string) => void
}
export async function fetchTranslate({ message, assistant, onResponse }: FetchTranslateProps) {
const model = getTranslateModel() const model = getTranslateModel()
if (!model) { if (!model) {
@@ -118,7 +123,7 @@ export async function fetchTranslate({ message, assistant }: { message: Message;
const AI = new AiProvider(provider) const AI = new AiProvider(provider)
try { try {
return await AI.translate(message, assistant) return await AI.translate(message, assistant, onResponse)
} catch (error: any) { } catch (error: any) {
return '' return ''
} }

View File

@@ -59,7 +59,7 @@ export async function reset() {
} }
// 备份到 webdav // 备份到 webdav
export async function backupToWebdav({ showMessage = true }: { showMessage?: boolean } = {}) { export async function backupToWebdav({ showMessage = false }: { showMessage?: boolean } = {}) {
if (isManualBackupRunning) { if (isManualBackupRunning) {
console.log('[Backup] Manual backup already in progress') console.log('[Backup] Manual backup already in progress')
return return
@@ -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: 55, version: 59,
blacklist: ['runtime'], blacklist: ['runtime'],
migrate migrate
}, },

View File

@@ -323,6 +323,16 @@ const initialState: LlmState = {
models: SYSTEM_MODELS.jina, models: SYSTEM_MODELS.jina,
isSystem: true, isSystem: true,
enabled: false enabled: false
},
{
id: 'qwenlm',
name: 'QwenLM',
type: 'openai',
apiKey: '',
apiHost: 'https://chat.qwenlm.ai/api/',
models: SYSTEM_MODELS.qwenlm,
isSystem: true,
enabled: false
} }
], ],
settings: { settings: {
@@ -397,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

@@ -1,4 +1,5 @@
import { isMac } from '@renderer/config/constant' import { isMac } from '@renderer/config/constant'
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
import { SYSTEM_MODELS } from '@renderer/config/models' import { SYSTEM_MODELS } from '@renderer/config/models'
import { TRANSLATE_PROMPT } from '@renderer/config/prompts' import { TRANSLATE_PROMPT } from '@renderer/config/prompts'
import db from '@renderer/databases' import db from '@renderer/databases'
@@ -11,6 +12,15 @@ import { createMigrate } from 'redux-persist'
import { RootState } from '.' import { RootState } from '.'
import { DEFAULT_SIDEBAR_ICONS } from './settings' import { DEFAULT_SIDEBAR_ICONS } from './settings'
// remove logo base64 data to reduce the size of the state
function removeMiniAppIconsFromState(state: RootState) {
if (state.minapps) {
state.minapps.enabled = state.minapps.enabled.map((app) => ({ ...app, logo: undefined }))
state.minapps.disabled = state.minapps.disabled.map((app) => ({ ...app, logo: undefined }))
state.minapps.pinned = state.minapps.pinned.map((app) => ({ ...app, logo: undefined }))
}
}
const migrateConfig = { const migrateConfig = {
'2': (state: RootState) => { '2': (state: RootState) => {
return { return {
@@ -799,6 +809,74 @@ const migrateConfig = {
} }
} }
return state return state
},
'56': (state: RootState) => {
state.llm.providers.push({
id: 'qwenlm',
name: 'QwenLM',
type: 'openai',
apiKey: '',
apiHost: 'https://chat.qwenlm.ai/api/',
models: SYSTEM_MODELS.qwenlm,
isSystem: true,
enabled: false
})
return state
},
'57': (state: RootState) => {
if (state.shortcuts) {
state.shortcuts.shortcuts.push({
key: 'mini_window',
shortcut: [isMac ? 'Command' : 'Ctrl', 'E'],
editable: true,
enabled: false,
system: true
})
}
removeMiniAppIconsFromState(state)
state.llm.providers.forEach((provider) => {
if (provider.id === 'qwenlm') {
provider.type = 'qwenlm'
}
})
state.settings.enableQuickAssistant = false
state.settings.clickTrayToShowQuickAssistant = true
return state
},
'58': (state: RootState) => {
if (state.shortcuts) {
state.shortcuts.shortcuts.push(
{
key: 'clear_topic',
shortcut: [isMac ? 'Command' : 'Ctrl', 'L'],
editable: true,
enabled: true,
system: false
},
{
key: 'toggle_new_context',
shortcut: [isMac ? 'Command' : 'Ctrl', 'R'],
editable: true,
enabled: true,
system: false
}
)
}
return state
},
'59': (state: RootState) => {
if (state.minapps) {
const flowith = DEFAULT_MIN_APPS.find((app) => app.id === 'flowith')
if (flowith) {
state.minapps.enabled.push(flowith)
}
}
removeMiniAppIconsFromState(state)
return state
} }
} }

View File

@@ -29,16 +29,16 @@ const minAppsSlice = createSlice({
initialState, initialState,
reducers: { reducers: {
setMinApps: (state, action: PayloadAction<MinAppType[]>) => { setMinApps: (state, action: PayloadAction<MinAppType[]>) => {
state.enabled = action.payload state.enabled = action.payload.map((app) => ({ ...app, logo: undefined }))
}, },
addMinApp: (state, action: PayloadAction<MinAppType>) => { addMinApp: (state, action: PayloadAction<MinAppType>) => {
state.enabled.push(action.payload) state.enabled.push(action.payload)
}, },
setDisabledMinApps: (state, action: PayloadAction<MinAppType[]>) => { setDisabledMinApps: (state, action: PayloadAction<MinAppType[]>) => {
state.disabled = action.payload state.disabled = action.payload.map((app) => ({ ...app, logo: undefined }))
}, },
setPinnedMinApps: (state, action: PayloadAction<MinAppType[]>) => { setPinnedMinApps: (state, action: PayloadAction<MinAppType[]>) => {
state.pinned = action.payload state.pinned = action.payload.map((app) => ({ ...app, logo: undefined }))
} }
} }
}) })

View File

@@ -61,6 +61,8 @@ export interface SettingsState {
disabled: SidebarIcon[] disabled: SidebarIcon[]
} }
narrowMode: boolean narrowMode: boolean
enableQuickAssistant: boolean
clickTrayToShowQuickAssistant: boolean
} }
const initialState: SettingsState = { const initialState: SettingsState = {
@@ -95,7 +97,7 @@ const initialState: SettingsState = {
webdavPass: '', webdavPass: '',
webdavPath: '/cherry-studio', webdavPath: '/cherry-studio',
webdavAutoSync: false, webdavAutoSync: false,
webdavSyncInterval: 5, webdavSyncInterval: 0,
translateModelPrompt: TRANSLATE_PROMPT, translateModelPrompt: TRANSLATE_PROMPT,
autoTranslateWithSpace: false, autoTranslateWithSpace: false,
enableTopicNaming: true, enableTopicNaming: true,
@@ -105,7 +107,9 @@ const initialState: SettingsState = {
visible: DEFAULT_SIDEBAR_ICONS, visible: DEFAULT_SIDEBAR_ICONS,
disabled: [] disabled: []
}, },
narrowMode: false narrowMode: false,
enableQuickAssistant: false,
clickTrayToShowQuickAssistant: false
} }
const settingsSlice = createSlice({ const settingsSlice = createSlice({
@@ -129,6 +133,7 @@ const settingsSlice = createSlice({
}, },
setLanguage: (state, action: PayloadAction<LanguageVarious>) => { setLanguage: (state, action: PayloadAction<LanguageVarious>) => {
state.language = action.payload state.language = action.payload
window.electron.ipcRenderer.send('miniwindow-reload')
}, },
setProxyMode: (state, action: PayloadAction<'system' | 'custom' | 'none'>) => { setProxyMode: (state, action: PayloadAction<'system' | 'custom' | 'none'>) => {
state.proxyMode = action.payload state.proxyMode = action.payload
@@ -240,6 +245,12 @@ 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
},
setEnableQuickAssistant: (state, action: PayloadAction<boolean>) => {
state.enableQuickAssistant = action.payload
} }
} }
}) })
@@ -285,7 +296,9 @@ export const {
setCustomCss, setCustomCss,
setTopicNamingPrompt, setTopicNamingPrompt,
setSidebarIcons, setSidebarIcons,
setNarrowMode setNarrowMode,
setClickTrayToShowQuickAssistant,
setEnableQuickAssistant
} = settingsSlice.actions } = settingsSlice.actions
export default settingsSlice.reducer export default settingsSlice.reducer

View File

@@ -17,6 +17,13 @@ const initialState: ShortcutsState = {
enabled: true, enabled: true,
system: true system: true
}, },
{
key: 'mini_window',
shortcut: [isMac ? 'Command' : 'Ctrl', 'E'],
editable: true,
enabled: false,
system: true
},
{ {
key: 'new_topic', key: 'new_topic',
shortcut: [isMac ? 'Command' : 'Ctrl', 'N'], shortcut: [isMac ? 'Command' : 'Ctrl', 'N'],
@@ -51,6 +58,20 @@ const initialState: ShortcutsState = {
editable: true, editable: true,
enabled: true, enabled: true,
system: false system: false
},
{
key: 'clear_topic',
shortcut: [isMac ? 'Command' : 'Ctrl', 'L'],
editable: true,
enabled: true,
system: false
},
{
key: 'toggle_new_context',
shortcut: [isMac ? 'Command' : 'Ctrl', 'K'],
editable: true,
enabled: true,
system: false
} }
] ]
} }

View File

@@ -59,6 +59,8 @@ export type Message = {
knowledgeBaseIds?: string[] knowledgeBaseIds?: string[]
type: 'text' | '@' | 'clear' type: 'text' | '@' | 'clear'
isPreset?: boolean isPreset?: boolean
mentions?: Model[]
model?: Model
metadata?: { metadata?: {
// Gemini // Gemini
groundingMetadata?: any groundingMetadata?: any
@@ -99,7 +101,7 @@ export type Provider = {
isSystem?: boolean isSystem?: boolean
} }
export type ProviderType = 'openai' | 'anthropic' | 'gemini' export type ProviderType = 'openai' | 'anthropic' | 'gemini' | 'qwenlm'
export type ModelType = 'text' | 'vision' | 'embedding' export type ModelType = 'text' | 'vision' | 'embedding'
@@ -135,7 +137,7 @@ export interface Painting {
export type MinAppType = { export type MinAppType = {
id?: string | number id?: string | number
name: string name: string
logo: string logo?: string
url: string url: string
bodered?: boolean bodered?: boolean
background?: string background?: string

View File

@@ -154,7 +154,7 @@ export function removeQuotes(str) {
export function removeSpecialCharacters(str: string) { export function removeSpecialCharacters(str: string) {
// First remove newlines and quotes, then remove other special characters // First remove newlines and quotes, then remove other special characters
return str.replace(/[\n"]/g, '').replace(/[^\p{L}\p{M}\p{N}\p{P}\p{S}]/gu, '') return str.replace(/[\n"]/g, '').replace(/[\p{M}\p{N}\p{P}\p{S}]/gu, '')
} }
export function generateColorFromChar(char: string) { export function generateColorFromChar(char: string) {
@@ -381,4 +381,8 @@ export const compareVersions = (v1: string, v2: string): number => {
return 0 return 0
} }
export function isMiniWindow() {
return window.location.hash === '#/mini'
}
export { classNames } export { classNames }

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 './components/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,125 @@
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 { isMiniWindow } from '@renderer/utils'
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)
const maxWidth = isMiniWindow() ? '480px' : '100%'
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' } : {}), maxWidth }}>
<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,86 @@
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 { last } from 'lodash'
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 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)
const { t } = useTranslation()
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])
useHotkeys('c', () => {
const lastMessage = last(messages)
if (lastMessage) {
navigator.clipboard.writeText(lastMessage.content)
window.message.success(t('message.copy.success'))
}
})
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,208 @@
import { isMac } from '@renderer/config/constant'
import { useDefaultAssistant, useDefaultModel } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n'
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 { isEmpty } from 'lodash'
import { FC, useCallback, useEffect, 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 TranslateWindow from '../translate/TranslateWindow'
import ClipboardPreview from './components/ClipboardPreview'
import FeatureMenus from './components/FeatureMenus'
import Footer from './components/Footer'
import InputBar from './components/InputBar'
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 { language } = useSettings()
const { t } = useTranslation()
const referenceText = selectedText || clipboardText || text
const content = (referenceText === text ? text : `${referenceText}\n\n${text}`).trim()
const onReadClipboard = useCallback(async () => {
const text = await navigator.clipboard.readText()
setClipboardText(text.trim())
}, [])
useEffect(() => {
onReadClipboard()
}, [onReadClipboard])
useEffect(() => {
i18n.changeLanguage(language || navigator.language || 'en-US')
}, [language])
const onCloseWindow = () => window.api.miniWindow.hide()
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
setText('')
setRoute('home')
route === 'home' && onCloseWindow()
return
}
if (e.key === 'Enter') {
e.preventDefault()
if (content) {
setRoute('chat')
onSendMessage()
setTimeout(() => setText(''), 100)
}
}
}
const onSendMessage = useCallback(
async (prompt?: string) => {
if (isEmpty(content)) {
return
}
setTimeout(() => {
const message = {
id: uuid(),
role: 'user',
content: prompt ? `${prompt}\n\n${content}` : content,
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)
},
[content, defaultAssistant.id, defaultAssistant.topics]
)
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 }) => {
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>
<TranslateWindow 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} text={content} />
</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,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,115 @@
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 {
text: string
setRoute: Dispatch<SetStateAction<'translate' | 'summary' | 'chat' | 'explanation' | 'home'>>
onSendMessage: (prompt?: string) => void
}
const FeatureMenus: FC<FeatureMenusProps> = ({ text, setRoute, onSendMessage }) => {
const { t } = useTranslation()
const features = [
{
icon: <MessageOutlined style={{ fontSize: '16px', color: 'var(--color-text)' }} />,
title: t('miniwindow.feature.chat'),
active: true,
onClick: () => {
if (text) {
setRoute('chat')
onSendMessage()
}
}
},
{
icon: <TranslationOutlined style={{ fontSize: '16px', color: 'var(--color-text)' }} />,
title: t('miniwindow.feature.translate'),
onClick: () => text && setRoute('translate')
},
{
icon: <FileTextOutlined style={{ fontSize: '16px', color: 'var(--color-text)' }} />,
title: t('miniwindow.feature.summary'),
onClick: () => {
if (text) {
setRoute('summary')
onSendMessage(t('prompts.summarize'))
}
}
},
{
icon: <BulbOutlined style={{ fontSize: '16px', color: 'var(--color-text)' }} />,
title: t('miniwindow.feature.explanation'),
onClick: () => {
if (text) {
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,50 @@
import { CopyOutlined, LoginOutlined } from '@ant-design/icons'
import { Tag } from 'antd'
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>
<FooterText className="nodrag">
<Tag bordered={false} icon={<LoginOutlined />} onClick={() => onExit()}>
{t('miniwindow.footer.esc', {
action: route === 'home' ? t('miniwindow.footer.esc_close') : t('miniwindow.footer.esc_back')
})}
</Tag>
{route !== 'home' && (
<Tag bordered={false} icon={<CopyOutlined />}>
{t('miniwindow.footer.copy_last_message')}
</Tag>
)}
</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.div`
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
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

View File

@@ -0,0 +1,167 @@
import { SwapOutlined } from '@ant-design/icons'
import Scrollbar from '@renderer/components/Scrollbar'
import { TranslateLanguageOptions } from '@renderer/config/translate'
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 { runAsyncFunction, uuid } from '@renderer/utils'
import { Select, Space } from 'antd'
import { isEmpty } from 'lodash'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface Props {
text: string
}
let _targetLanguage = 'chinese'
const Translate: FC<Props> = ({ text }) => {
const [result, setResult] = useState('')
const [targetLanguage, setTargetLanguage] = useState(_targetLanguage)
const { translateModel } = useDefaultModel()
const { t } = useTranslation()
const translatingRef = useRef(false)
_targetLanguage = targetLanguage
const translate = useCallback(async () => {
if (!text.trim() || !translateModel) return
if (translatingRef.current) return
try {
translatingRef.current = true
const targetLang = await db.settings.get({ id: 'translate:target:language' })
const assistant: Assistant = getDefaultTranslateAssistant(targetLang?.value || 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'
}
await fetchTranslate({ message, assistant, onResponse: setResult })
translatingRef.current = false
} catch (error) {
console.error(error)
} finally {
translatingRef.current = false
}
}, [text, targetLanguage, translateModel])
useEffect(() => {
runAsyncFunction(async () => {
const targetLang = await db.settings.get({ id: 'translate:target:language' })
targetLang && setTargetLanguage(targetLang.value)
})
}, [])
useEffect(() => {
translate()
}, [translate])
useHotkeys('c', () => {
navigator.clipboard.writeText(result)
})
return (
<Container>
<MenuContainer>
<Select
showSearch
value="any"
style={{ width: 200 }}
optionFilterProp="label"
disabled
options={[{ label: t('translate.any.language'), value: 'any' }]}
/>
<SwapOutlined />
<Select
showSearch
value={targetLanguage}
style={{ width: 200 }}
optionFilterProp="label"
options={TranslateLanguageOptions}
onChange={async (value) => {
await db.settings.put({ id: 'translate:target:language', value })
setTargetLanguage(value)
}}
optionRender={(option) => (
<Space>
<span role="img" aria-label={option.data.label}>
{option.data.emoji}
</span>
{option.label}
</Space>
)}
/>
</MenuContainer>
<Main>
{isEmpty(result) ? (
<LoadingText>{t('translate.output.placeholder')}...</LoadingText>
) : (
<OutputContainer>
<ResultText>{result}</ResultText>
</OutputContainer>
)}
</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.div`
white-space: pre-wrap;
word-break: break-word;
width: 100%;
`
const LoadingText = styled.div`
color: var(--color-text-2);
font-style: italic;
`
const MenuContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 15px;
gap: 20px;
`
const OutputContainer = styled(Scrollbar)`
display: flex;
flex-direction: column;
flex: 1;
gap: 10px;
`
export default Translate

View File

@@ -3011,6 +3011,7 @@ __metadata:
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"
@@ -3390,6 +3391,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"