Compare commits
85 Commits
feat/mcp-u
...
feat/sideb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4434eb7c8 | ||
|
|
762732af9d | ||
|
|
5d9b47198b | ||
|
|
db1c03f9fa | ||
|
|
bedea8aaaa | ||
|
|
27d959caed | ||
|
|
0609b93a14 | ||
|
|
c7a0b05841 | ||
|
|
5ca0ce682b | ||
|
|
7c3752a8e6 | ||
|
|
7ae10be387 | ||
|
|
7767dfaac7 | ||
|
|
c89ff17b36 | ||
|
|
0810a63fd8 | ||
|
|
ff261fb52b | ||
|
|
8f8deb9275 | ||
|
|
0a7e591f0e | ||
|
|
c1e8f1063a | ||
|
|
4317f4b672 | ||
|
|
202504fd17 | ||
|
|
05727c637f | ||
|
|
c7843ca288 | ||
|
|
facf29e02b | ||
|
|
66d280136c | ||
|
|
91892ea619 | ||
|
|
7d70425c75 | ||
|
|
20b3db0c01 | ||
|
|
f1804bc3a0 | ||
|
|
2ec0c29087 | ||
|
|
20a572aa46 | ||
|
|
142b624001 | ||
|
|
3775562956 | ||
|
|
3544c40d9a | ||
|
|
36fa3af9e9 | ||
|
|
eb832cc25a | ||
|
|
0378f9ceb1 | ||
|
|
99b23e1d5d | ||
|
|
8d23c810fe | ||
|
|
cdfa2ac13a | ||
|
|
58f3edb352 | ||
|
|
edeb9f84f9 | ||
|
|
2757fcf6b9 | ||
|
|
5339f4a9a3 | ||
|
|
e786feb165 | ||
|
|
5794c36d0d | ||
|
|
7d8b88c56e | ||
|
|
d0daeddf14 | ||
|
|
2fe2ae797b | ||
|
|
9bb66227b4 | ||
|
|
5f4736e8c1 | ||
|
|
7a44910847 | ||
|
|
509632030b | ||
|
|
09fe2aa67b | ||
|
|
793c641e1c | ||
|
|
38eb206a8a | ||
|
|
3d9236a09a | ||
|
|
e6fd9b5678 | ||
|
|
d41f175a05 | ||
|
|
6d6a554fd3 | ||
|
|
cb1fcf7d2d | ||
|
|
6ed30fd78a | ||
|
|
69acb2fccd | ||
|
|
d95a4e56f5 | ||
|
|
b15dac9ef4 | ||
|
|
73fced37b4 | ||
|
|
1124090d87 | ||
|
|
a92fa1b1ba | ||
|
|
13fdfc58b6 | ||
|
|
048a9135ac | ||
|
|
b5636646c9 | ||
|
|
d9def89ced | ||
|
|
b16d0069bf | ||
|
|
30823691f9 | ||
|
|
8be98ccbb3 | ||
|
|
922e85754a | ||
|
|
f041f9a231 | ||
|
|
0d9f1882b9 | ||
|
|
26d823e0a5 | ||
|
|
daa89df479 | ||
|
|
527740bf42 | ||
|
|
881e0b4713 | ||
|
|
88e251cee7 | ||
|
|
76387643f7 | ||
|
|
b41c89972b | ||
|
|
11a8154458 |
@@ -176,6 +176,7 @@
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"fast-diff": "^1.3.0",
|
||||
"fast-xml-parser": "^5.2.0",
|
||||
"framer-motion": "^12.17.3",
|
||||
"franc-min": "^6.2.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"google-auth-library": "^9.15.1",
|
||||
@@ -206,8 +207,8 @@
|
||||
"react-infinite-scroll-component": "^6.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-router": "6",
|
||||
"react-router-dom": "6",
|
||||
"react-router": "^7.6.2",
|
||||
"react-router-dom": "^7.6.2",
|
||||
"react-spinners": "^0.14.1",
|
||||
"react-window": "^1.8.11",
|
||||
"redux": "^5.0.1",
|
||||
|
||||
@@ -242,5 +242,12 @@ export enum IpcChannel {
|
||||
Selection_ActionWindowMinimize = 'selection:action-window-minimize',
|
||||
Selection_ActionWindowPin = 'selection:action-window-pin',
|
||||
Selection_ProcessAction = 'selection:process-action',
|
||||
Selection_UpdateActionData = 'selection:update-action-data'
|
||||
Selection_UpdateActionData = 'selection:update-action-data',
|
||||
|
||||
// Navigation
|
||||
Navigation_Url = 'navigation:url',
|
||||
Navigation_Close = 'navigation:close',
|
||||
|
||||
// Settings Window
|
||||
SettingsWindow_Show = 'settings-window:show'
|
||||
}
|
||||
|
||||
@@ -10,13 +10,13 @@ if (isDev) {
|
||||
export const DATA_PATH = getDataPath()
|
||||
|
||||
export const titleBarOverlayDark = {
|
||||
height: 40,
|
||||
height: 42,
|
||||
color: 'rgba(255,255,255,0)',
|
||||
symbolColor: '#fff'
|
||||
}
|
||||
|
||||
export const titleBarOverlayLight = {
|
||||
height: 40,
|
||||
height: 42,
|
||||
color: 'rgba(255,255,255,0)',
|
||||
symbolColor: '#000'
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Notification } from 'src/renderer/src/types/notification'
|
||||
import appService from './services/AppService'
|
||||
import AppUpdater from './services/AppUpdater'
|
||||
import BackupManager from './services/BackupManager'
|
||||
import { CacheService } from './services/CacheService'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import CopilotService from './services/CopilotService'
|
||||
import { ExportService } from './services/ExportService'
|
||||
@@ -30,6 +31,7 @@ import { pythonService } from './services/PythonService'
|
||||
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
||||
import { searchService } from './services/SearchService'
|
||||
import { SelectionService } from './services/SelectionService'
|
||||
import { SettingsWindowService } from './services/SettingsWindowService'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
import storeSyncService from './services/StoreSyncService'
|
||||
import { themeService } from './services/ThemeService'
|
||||
@@ -577,4 +579,12 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.App_SetDisableHardwareAcceleration, (_, isDisable: boolean) => {
|
||||
configManager.setDisableHardwareAcceleration(isDisable)
|
||||
})
|
||||
|
||||
// Navigation
|
||||
ipcMain.handle(IpcChannel.Navigation_Url, (_, url: string) => {
|
||||
CacheService.set('navigation-url', url)
|
||||
})
|
||||
|
||||
// Settings Window
|
||||
SettingsWindowService.registerIpcHandler()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
interface CacheItem<T> {
|
||||
data: T
|
||||
timestamp: number
|
||||
duration: number
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export class CacheService {
|
||||
@@ -11,9 +11,9 @@ export class CacheService {
|
||||
* Set cache
|
||||
* @param key Cache key
|
||||
* @param data Cache data
|
||||
* @param duration Cache duration (in milliseconds)
|
||||
* @param duration Cache duration (in milliseconds), if not set the cache will never expire
|
||||
*/
|
||||
static set<T>(key: string, data: T, duration: number): void {
|
||||
static set<T>(key: string, data: T, duration?: number): void {
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
@@ -30,6 +30,11 @@ export class CacheService {
|
||||
const item = this.cache.get(key)
|
||||
if (!item) return null
|
||||
|
||||
// If duration is undefined, cache never expires
|
||||
if (item.duration === undefined) {
|
||||
return item.data
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
if (now - item.timestamp > item.duration) {
|
||||
this.remove(key)
|
||||
@@ -63,6 +68,11 @@ export class CacheService {
|
||||
const item = this.cache.get(key)
|
||||
if (!item) return false
|
||||
|
||||
// If duration is undefined, cache never expires
|
||||
if (item.duration === undefined) {
|
||||
return true
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
if (now - item.timestamp > item.duration) {
|
||||
this.remove(key)
|
||||
|
||||
149
src/main/services/SettingsWindowService.ts
Normal file
149
src/main/services/SettingsWindowService.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { isLinux, isMac } from '@main/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { BrowserWindow, nativeTheme } from 'electron'
|
||||
import { join } from 'path'
|
||||
|
||||
import icon from '../../../build/icon.png?asset'
|
||||
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
|
||||
|
||||
export class SettingsWindowService {
|
||||
private static instance: SettingsWindowService | null = null
|
||||
private settingsWindow: BrowserWindow | null = null
|
||||
|
||||
public static getInstance(): SettingsWindowService {
|
||||
if (!SettingsWindowService.instance) {
|
||||
SettingsWindowService.instance = new SettingsWindowService()
|
||||
}
|
||||
return SettingsWindowService.instance
|
||||
}
|
||||
|
||||
public createSettingsWindow(defaultTab?: string): BrowserWindow {
|
||||
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
|
||||
this.settingsWindow.show()
|
||||
this.settingsWindow.focus()
|
||||
return this.settingsWindow
|
||||
}
|
||||
|
||||
this.settingsWindow = new BrowserWindow({
|
||||
width: 1000,
|
||||
height: 700,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
transparent: false,
|
||||
vibrancy: 'sidebar',
|
||||
visualEffectState: 'active',
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight,
|
||||
backgroundColor: isMac ? undefined : nativeTheme.shouldUseDarkColors ? '#181818' : '#FFFFFF',
|
||||
darkTheme: nativeTheme.shouldUseDarkColors,
|
||||
trafficLightPosition: { x: 12, y: 12 },
|
||||
...(isLinux ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
webviewTag: true,
|
||||
allowRunningInsecureContent: true,
|
||||
backgroundThrottling: false
|
||||
}
|
||||
})
|
||||
|
||||
this.setupSettingsWindow()
|
||||
this.loadSettingsWindowContent(defaultTab)
|
||||
|
||||
return this.settingsWindow
|
||||
}
|
||||
|
||||
private setupSettingsWindow() {
|
||||
if (!this.settingsWindow) return
|
||||
|
||||
this.settingsWindow.on('ready-to-show', () => {
|
||||
this.settingsWindow?.show()
|
||||
})
|
||||
|
||||
this.settingsWindow.on('closed', () => {
|
||||
this.settingsWindow = null
|
||||
})
|
||||
|
||||
this.settingsWindow.on('close', () => {
|
||||
// Clean up when window is closed
|
||||
})
|
||||
|
||||
// Handle theme changes
|
||||
nativeTheme.on('updated', () => {
|
||||
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
|
||||
this.settingsWindow.setTitleBarOverlay(
|
||||
nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private loadSettingsWindowContent(defaultTab?: string) {
|
||||
if (!this.settingsWindow) return
|
||||
|
||||
const queryParam = defaultTab ? `?tab=${defaultTab}` : ''
|
||||
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
this.settingsWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/settingsWindow.html' + queryParam)
|
||||
} else {
|
||||
this.settingsWindow.loadFile(join(__dirname, '../renderer/settingsWindow.html'))
|
||||
if (defaultTab) {
|
||||
this.settingsWindow.webContents.once('did-finish-load', () => {
|
||||
this.settingsWindow?.webContents.send(IpcChannel.SettingsWindow_Show, { defaultTab })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public showSettingsWindow(defaultTab?: string) {
|
||||
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
|
||||
if (this.settingsWindow.isMinimized()) {
|
||||
this.settingsWindow.restore()
|
||||
}
|
||||
|
||||
if (!isLinux) {
|
||||
this.settingsWindow.setVisibleOnAllWorkspaces(true)
|
||||
}
|
||||
|
||||
this.settingsWindow.show()
|
||||
this.settingsWindow.focus()
|
||||
|
||||
if (!isLinux) {
|
||||
this.settingsWindow.setVisibleOnAllWorkspaces(false)
|
||||
}
|
||||
} else {
|
||||
this.createSettingsWindow(defaultTab)
|
||||
}
|
||||
}
|
||||
|
||||
public hideSettingsWindow() {
|
||||
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
|
||||
this.settingsWindow.hide()
|
||||
}
|
||||
}
|
||||
|
||||
public closeSettingsWindow() {
|
||||
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
|
||||
this.settingsWindow.close()
|
||||
}
|
||||
}
|
||||
|
||||
public getSettingsWindow(): BrowserWindow | null {
|
||||
return this.settingsWindow
|
||||
}
|
||||
|
||||
public static registerIpcHandler() {
|
||||
const { ipcMain } = require('electron')
|
||||
const service = SettingsWindowService.getInstance()
|
||||
|
||||
ipcMain.handle(IpcChannel.SettingsWindow_Show, (_, options?: { defaultTab?: string }) => {
|
||||
service.showSettingsWindow(options?.defaultTab)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const settingsWindowService = SettingsWindowService.getInstance()
|
||||
@@ -5,10 +5,12 @@ import Logger from 'electron-log'
|
||||
|
||||
import { configManager } from './ConfigManager'
|
||||
import selectionService from './SelectionService'
|
||||
import { settingsWindowService } from './SettingsWindowService'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
let showAppAccelerator: string | null = null
|
||||
let showMiniWindowAccelerator: string | null = null
|
||||
let showSettingsAccelerator: string | null = null
|
||||
let selectionAssistantToggleAccelerator: string | null = null
|
||||
let selectionAssistantSelectTextAccelerator: string | null = null
|
||||
|
||||
@@ -26,6 +28,10 @@ function getShortcutHandler(shortcut: Shortcut) {
|
||||
return (window: BrowserWindow) => handleZoomFactor([window], -0.1)
|
||||
case 'zoom_reset':
|
||||
return (window: BrowserWindow) => handleZoomFactor([window], 0, true)
|
||||
case 'show_settings':
|
||||
return () => {
|
||||
settingsWindowService.showSettingsWindow()
|
||||
}
|
||||
case 'show_app':
|
||||
return () => {
|
||||
windowService.toggleMainWindow()
|
||||
@@ -146,9 +152,13 @@ export function registerShortcuts(window: BrowserWindow) {
|
||||
// only register universal shortcuts when needed
|
||||
if (
|
||||
onlyUniversalShortcuts &&
|
||||
!['show_app', 'mini_window', 'selection_assistant_toggle', 'selection_assistant_select_text'].includes(
|
||||
shortcut.key
|
||||
)
|
||||
![
|
||||
'show_app',
|
||||
'mini_window',
|
||||
'show_settings',
|
||||
'selection_assistant_toggle',
|
||||
'selection_assistant_select_text'
|
||||
].includes(shortcut.key)
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -171,6 +181,10 @@ export function registerShortcuts(window: BrowserWindow) {
|
||||
showMiniWindowAccelerator = formatShortcutKey(shortcut.shortcut)
|
||||
break
|
||||
|
||||
case 'show_settings':
|
||||
showSettingsAccelerator = formatShortcutKey(shortcut.shortcut)
|
||||
break
|
||||
|
||||
case 'selection_assistant_toggle':
|
||||
selectionAssistantToggleAccelerator = formatShortcutKey(shortcut.shortcut)
|
||||
break
|
||||
@@ -222,6 +236,12 @@ export function registerShortcuts(window: BrowserWindow) {
|
||||
handler && globalShortcut.register(accelerator, () => handler(window))
|
||||
}
|
||||
|
||||
if (showSettingsAccelerator) {
|
||||
const handler = getShortcutHandler({ key: 'show_settings' } as Shortcut)
|
||||
const accelerator = convertShortcutFormat(showSettingsAccelerator)
|
||||
handler && globalShortcut.register(accelerator, () => handler(window))
|
||||
}
|
||||
|
||||
if (selectionAssistantToggleAccelerator) {
|
||||
const handler = getShortcutHandler({ key: 'selection_assistant_toggle' } as Shortcut)
|
||||
const accelerator = convertShortcutFormat(selectionAssistantToggleAccelerator)
|
||||
@@ -258,6 +278,7 @@ export function unregisterAllShortcuts() {
|
||||
try {
|
||||
showAppAccelerator = null
|
||||
showMiniWindowAccelerator = null
|
||||
showSettingsAccelerator = null
|
||||
selectionAssistantToggleAccelerator = null
|
||||
selectionAssistantSelectTextAccelerator = null
|
||||
windowOnHandlers.forEach((handlers, window) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { join } from 'path'
|
||||
|
||||
import icon from '../../../build/icon.png?asset'
|
||||
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
|
||||
import { CacheService } from './CacheService'
|
||||
import { configManager } from './ConfigManager'
|
||||
import { contextMenu } from './ContextMenu'
|
||||
import { initSessionUserAgent } from './WebviewService'
|
||||
@@ -63,7 +64,7 @@ export class WindowService {
|
||||
titleBarOverlay: nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight,
|
||||
backgroundColor: isMac ? undefined : nativeTheme.shouldUseDarkColors ? '#181818' : '#FFFFFF',
|
||||
darkTheme: nativeTheme.shouldUseDarkColors,
|
||||
trafficLightPosition: { x: 8, y: 12 },
|
||||
trafficLightPosition: { x: 12, y: 12 },
|
||||
...(isLinux ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
@@ -313,6 +314,11 @@ export class WindowService {
|
||||
return app.quit()
|
||||
}
|
||||
|
||||
if (CacheService.get('navigation-url') !== '/') {
|
||||
event.preventDefault()
|
||||
return mainWindow.webContents.send(IpcChannel.Navigation_Close)
|
||||
}
|
||||
|
||||
// 托盘及关闭行为设置
|
||||
const isShowTray = configManager.getTray()
|
||||
const isTrayOnClose = configManager.getTrayOnClose()
|
||||
@@ -435,8 +441,9 @@ export class WindowService {
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
transparent: isMac,
|
||||
vibrancy: 'under-window',
|
||||
visualEffectState: 'followWindow',
|
||||
vibrancy: isMac ? 'under-window' : undefined,
|
||||
visualEffectState: isMac ? 'followWindow' : undefined,
|
||||
backgroundMaterial: isWin ? 'acrylic' : undefined,
|
||||
center: true,
|
||||
frame: false,
|
||||
alwaysOnTop: true,
|
||||
|
||||
@@ -318,8 +318,25 @@ const api = {
|
||||
pinActionWindow: (isPinned: boolean) => ipcRenderer.invoke(IpcChannel.Selection_ActionWindowPin, isPinned)
|
||||
},
|
||||
quoteToMainWindow: (text: string) => ipcRenderer.invoke(IpcChannel.App_QuoteToMain, text),
|
||||
navigation: {
|
||||
url: (url: string) => ipcRenderer.invoke(IpcChannel.Navigation_Url, url)
|
||||
},
|
||||
setDisableHardwareAcceleration: (isDisable: boolean) =>
|
||||
ipcRenderer.invoke(IpcChannel.App_SetDisableHardwareAcceleration, isDisable)
|
||||
ipcRenderer.invoke(IpcChannel.App_SetDisableHardwareAcceleration, isDisable),
|
||||
|
||||
// Settings Window
|
||||
showSettingsWindow: (options?: { defaultTab?: string }) =>
|
||||
ipcRenderer.invoke(IpcChannel.SettingsWindow_Show, options),
|
||||
|
||||
on: (channel: string, func: any) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, ...args: any[]) => {
|
||||
func(...args)
|
||||
}
|
||||
ipcRenderer.on(channel, listener)
|
||||
return () => {
|
||||
ipcRenderer.off(channel, listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
|
||||
23
src/renderer/settingsWindow.html
Normal file
23
src/renderer/settingsWindow.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
<title>Cherry Studio</title>
|
||||
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/windows/settings/entryPoint.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,10 +2,10 @@ import '@renderer/databases'
|
||||
|
||||
import store, { persistor } from '@renderer/store'
|
||||
import { Provider } from 'react-redux'
|
||||
import { HashRouter, Route, Routes } from 'react-router-dom'
|
||||
import { HashRouter } from 'react-router-dom'
|
||||
import { PersistGate } from 'redux-persist/integration/react'
|
||||
|
||||
import Sidebar from './components/app/Sidebar'
|
||||
import AppLayout from './components/Layout/AppLayout'
|
||||
import TopViewContainer from './components/TopView'
|
||||
import AntdProvider from './context/AntdProvider'
|
||||
import { CodeStyleProvider } from './context/CodeStyleProvider'
|
||||
@@ -13,14 +13,8 @@ import { NotificationProvider } from './context/NotificationProvider'
|
||||
import StyleSheetManager from './context/StyleSheetManager'
|
||||
import { ThemeProvider } from './context/ThemeProvider'
|
||||
import NavigationHandler from './handler/NavigationHandler'
|
||||
import AgentsPage from './pages/agents/AgentsPage'
|
||||
import AppsPage from './pages/apps/AppsPage'
|
||||
import FilesPage from './pages/files/FilesPage'
|
||||
import HomePage from './pages/home/HomePage'
|
||||
import KnowledgePage from './pages/knowledge/KnowledgePage'
|
||||
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
|
||||
import SettingsPage from './pages/settings/SettingsPage'
|
||||
import TranslatePage from './pages/translate/TranslatePage'
|
||||
import { ChatProvider } from './hooks/useChat'
|
||||
import Routes from './Routes'
|
||||
|
||||
function App(): React.ReactElement {
|
||||
return (
|
||||
@@ -31,22 +25,16 @@ function App(): React.ReactElement {
|
||||
<NotificationProvider>
|
||||
<CodeStyleProvider>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<TopViewContainer>
|
||||
<HashRouter>
|
||||
<HashRouter>
|
||||
<TopViewContainer>
|
||||
<NavigationHandler />
|
||||
<Sidebar />
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
|
||||
<Route path="/translate" element={<TranslatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/apps" element={<AppsPage />} />
|
||||
<Route path="/settings/*" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</TopViewContainer>
|
||||
<ChatProvider>
|
||||
<AppLayout>
|
||||
<Routes />
|
||||
</AppLayout>
|
||||
</ChatProvider>
|
||||
</TopViewContainer>
|
||||
</HashRouter>
|
||||
</PersistGate>
|
||||
</CodeStyleProvider>
|
||||
</NotificationProvider>
|
||||
|
||||
29
src/renderer/src/Routes.tsx
Normal file
29
src/renderer/src/Routes.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
|
||||
import AgentsPage from './pages/agents/AgentsPage'
|
||||
import AppsPage from './pages/apps/AppsPage'
|
||||
import FilesPage from './pages/files/FilesPage'
|
||||
import HomePage from './pages/home/HomePage'
|
||||
import KnowledgePage from './pages/knowledge/KnowledgePage'
|
||||
import McpServersPage from './pages/mcp-servers'
|
||||
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
|
||||
import TranslatePage from './pages/translate/TranslatePage'
|
||||
|
||||
const RouteContainer = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
|
||||
<Route path="/translate" element={<TranslatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/apps" element={<AppsPage />} />
|
||||
<Route path="/mcp-servers/*" element={<McpServersPage />} />
|
||||
{/* <Route path="/settings/*" element={<SettingsPage />} />
|
||||
<Route path="/launchpad" element={<LaunchpadPage />} /> */}
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default RouteContainer
|
||||
@@ -25,7 +25,6 @@
|
||||
}
|
||||
|
||||
.minapp-drawer {
|
||||
max-width: calc(100vw - var(--sidebar-width));
|
||||
.ant-drawer-content-wrapper {
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -33,7 +32,7 @@
|
||||
position: absolute;
|
||||
-webkit-app-region: drag;
|
||||
min-height: calc(var(--navbar-height) + 0.5px);
|
||||
width: calc(100vw - var(--sidebar-width));
|
||||
width: 100%;
|
||||
margin-top: -0.5px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
--color-text-secondary: rgba(235, 235, 245, 0.7);
|
||||
--color-icon: #ffffff99;
|
||||
--color-icon-white: #ffffff;
|
||||
--color-border: #ffffff19;
|
||||
--color-border: #383838;
|
||||
--color-border-soft: #ffffff10;
|
||||
--color-border-mute: #ffffff05;
|
||||
--color-error: #f44336;
|
||||
@@ -54,25 +54,14 @@
|
||||
--color-background-highlight-accent: rgba(255, 150, 50, 0.9);
|
||||
|
||||
--navbar-background-mac: rgba(20, 20, 20, 0.55);
|
||||
--navbar-background-win: rgba(20, 20, 20, 0.75);
|
||||
--navbar-background: #1f1f1f;
|
||||
|
||||
--navbar-height: 40px;
|
||||
--sidebar-width: 50px;
|
||||
--status-bar-height: 40px;
|
||||
--input-bar-height: 100px;
|
||||
|
||||
--assistants-width: 275px;
|
||||
--topic-list-width: 275px;
|
||||
--settings-width: 250px;
|
||||
--scrollbar-width: 5px;
|
||||
|
||||
--chat-background: transparent;
|
||||
--chat-background-user: rgba(255, 255, 255, 0.08);
|
||||
--chat-background-assistant: transparent;
|
||||
--chat-text-user: var(--color-black);
|
||||
|
||||
--list-item-border-radius: 20px;
|
||||
|
||||
--color-status-success: #52c41a;
|
||||
--color-status-error: #ff4d4f;
|
||||
--color-status-warning: #faad14;
|
||||
@@ -124,8 +113,8 @@
|
||||
--color-reference-text: #000000;
|
||||
--color-reference-background: #f1f7ff;
|
||||
|
||||
--color-list-item: #eee;
|
||||
--color-list-item-hover: #f5f5f5;
|
||||
--color-list-item: rgba(0, 0, 0, 0.05);
|
||||
--color-list-item-hover: rgba(0, 0, 0, 0.03);
|
||||
|
||||
--modal-background: var(--color-white);
|
||||
|
||||
@@ -134,6 +123,7 @@
|
||||
--color-background-highlight-accent: rgba(255, 150, 50, 0.5);
|
||||
|
||||
--navbar-background-mac: rgba(255, 255, 255, 0.55);
|
||||
--navbar-background-win: rgba(255, 255, 255, 0.75);
|
||||
--navbar-background: rgba(244, 244, 244);
|
||||
|
||||
--chat-background: transparent;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
#content-container {
|
||||
background-color: var(--color-background);
|
||||
border-top: 0.5px solid var(--color-border);
|
||||
border-top-left-radius: 10px;
|
||||
border-left: 0.5px solid var(--color-border);
|
||||
}
|
||||
|
||||
.group-container {
|
||||
.context-menu-container {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-container {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@use './variables.scss';
|
||||
@use './color.scss';
|
||||
@use './font.scss';
|
||||
@use './markdown.scss';
|
||||
|
||||
18
src/renderer/src/assets/styles/variables.scss
Normal file
18
src/renderer/src/assets/styles/variables.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
:root {
|
||||
--navbar-height: 42px;
|
||||
--sidebar-width: 50px;
|
||||
--status-bar-height: 40px;
|
||||
--input-bar-height: 100px;
|
||||
|
||||
--assistants-width: 275px;
|
||||
--topic-list-width: 275px;
|
||||
--settings-width: 250px;
|
||||
--scrollbar-width: 5px;
|
||||
|
||||
--list-item-border-radius: 15px;
|
||||
--border-width: 0.5px;
|
||||
|
||||
--main-height: 100vh;
|
||||
|
||||
--border-width: 0.5px;
|
||||
}
|
||||
@@ -292,6 +292,10 @@ const SplitViewWrapper = styled.div`
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&:not(:has(+ .html-artifacts)) {
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
&:not(:has(+ [class*='Container'])) {
|
||||
border-radius: 0 0 8px 8px;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
Text as UnWrapIcon,
|
||||
WrapText as WrapIcon
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useBlurHandler, useLanguageExtensions, useSaveKeymap } from './hooks'
|
||||
|
||||
@@ -348,20 +348,23 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
||||
<ToolBar>
|
||||
<Tooltip title={t('button.includes_user_questions')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<ToolbarButton type="text" onClick={userOutlinedButtonOnClick}>
|
||||
<User size={18} style={{ color: includeUser ? 'var(--color-link)' : 'var(--color-icon)' }} />
|
||||
<User size={18} style={{ color: includeUser ? 'var(--color-primary)' : 'var(--color-icon)' }} />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('button.case_sensitive')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<ToolbarButton type="text" onClick={caseSensitiveButtonOnClick}>
|
||||
<CaseSensitive
|
||||
size={18}
|
||||
style={{ color: isCaseSensitive ? 'var(--color-link)' : 'var(--color-icon)' }}
|
||||
style={{ color: isCaseSensitive ? 'var(--color-primary)' : 'var(--color-icon)' }}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('button.whole_word')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<ToolbarButton type="text" onClick={wholeWordButtonOnClick}>
|
||||
<WholeWord size={18} style={{ color: isWholeWord ? 'var(--color-link)' : 'var(--color-icon)' }} />
|
||||
<WholeWord
|
||||
size={18}
|
||||
style={{ color: isWholeWord ? 'var(--color-primary)' : 'var(--color-icon)' }}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
</ToolBar>
|
||||
@@ -406,7 +409,6 @@ const Container = styled.div`
|
||||
`
|
||||
|
||||
const SearchBarContainer = styled.div`
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s ease;
|
||||
position: fixed;
|
||||
@@ -420,6 +422,7 @@ const SearchBarContainer = styled.div`
|
||||
justify-content: center;
|
||||
background-color: var(--color-background);
|
||||
flex: 1 1 auto; /* Take up input's previous space */
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
`
|
||||
|
||||
const Placeholder = styled.div`
|
||||
|
||||
17
src/renderer/src/components/CustomSelect.tsx
Normal file
17
src/renderer/src/components/CustomSelect.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { includeKeywords } from '@renderer/utils/search'
|
||||
import { Select, SelectProps } from 'antd'
|
||||
|
||||
/**
|
||||
* 自定义 Select,使用增强的搜索 filter
|
||||
*/
|
||||
const CustomSelect = ({ ref, ...props }: SelectProps & { ref?: React.RefObject<any | null> }) => {
|
||||
return <Select ref={ref} filterOption={enhancedFilterOption} {...props} />
|
||||
}
|
||||
|
||||
CustomSelect.displayName = 'CustomSelect'
|
||||
|
||||
function enhancedFilterOption(input: string, option: any) {
|
||||
return includeKeywords(option.label, input)
|
||||
}
|
||||
|
||||
export default CustomSelect
|
||||
@@ -51,23 +51,27 @@ const DraggableList: FC<Props<any>> = ({
|
||||
<VirtualList data={list} itemKey="id">
|
||||
{(item, index) => {
|
||||
const id = item.id || item
|
||||
return (
|
||||
<Draggable key={`draggable_${id}_${index}`} draggableId={id} index={index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
style={{
|
||||
...listStyle,
|
||||
...provided.draggableProps.style,
|
||||
marginBottom: 8
|
||||
}}>
|
||||
{children(item, index)}
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
)
|
||||
if (!item.disabled) {
|
||||
return (
|
||||
<Draggable key={`draggable_${id}_${index}`} draggableId={id} index={index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
...listStyle,
|
||||
...provided.draggableProps.style
|
||||
}}>
|
||||
{children(item, index)}
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
)
|
||||
} else {
|
||||
return <div> {children(item, index)}</div>
|
||||
}
|
||||
}}
|
||||
</VirtualList>
|
||||
{provided.placeholder}
|
||||
|
||||
35
src/renderer/src/components/Icons/NarrowModeIcon.tsx
Normal file
35
src/renderer/src/components/Icons/NarrowModeIcon.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
isNarrowMode: boolean
|
||||
}
|
||||
|
||||
const NarrowModeIcon: FC<Props> = ({ isNarrowMode }) => {
|
||||
return (
|
||||
<Container $isNarrowMode={isNarrowMode}>
|
||||
<Line />
|
||||
<Line />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div<{ $isNarrowMode: boolean }>`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1.5px solid var(--color-text-2);
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: ${({ $isNarrowMode }) => ($isNarrowMode ? 'space-evenly' : 'space-between')};
|
||||
padding: 2px;
|
||||
`
|
||||
|
||||
const Line = styled.div`
|
||||
width: 2px;
|
||||
height: 10px;
|
||||
background-color: var(--color-text-2);
|
||||
border-radius: 5px;
|
||||
`
|
||||
|
||||
export default NarrowModeIcon
|
||||
42
src/renderer/src/components/Icons/PanelIcons.tsx
Normal file
42
src/renderer/src/components/Icons/PanelIcons.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { SVGProps } from 'react'
|
||||
|
||||
interface PanelIconProps extends Omit<SVGProps<SVGSVGElement>, 'width' | 'height'> {
|
||||
size?: number | string
|
||||
expanded?: boolean
|
||||
}
|
||||
|
||||
export const PanelLeftIcon = ({ size = 18, expanded = false, ...props }: PanelIconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
className="lucide lucide-panel-left-icon lucide-panel-left"
|
||||
{...props}>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
{expanded ? <path d="M10 7v10" strokeWidth={4} /> : <path d="M9 6v12" strokeWidth={2} />}
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const PanelRightIcon = ({ size = 18, expanded = false, ...props }: PanelIconProps) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
className="lucide lucide-panel-right-icon lucide-panel-right"
|
||||
{...props}>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
{expanded ? <path d="M14 7v10" strokeWidth={4} /> : <path d="M15 6v12" strokeWidth={2} />}
|
||||
</svg>
|
||||
)
|
||||
@@ -22,7 +22,7 @@ const Container = styled.div`
|
||||
`
|
||||
|
||||
const Icon = styled.i`
|
||||
color: var(--color-link);
|
||||
color: var(--color-primary);
|
||||
font-size: 16px;
|
||||
margin-right: 6px;
|
||||
`
|
||||
|
||||
@@ -77,3 +77,18 @@ export function MdiLightbulbOn90(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExpandWidth(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width="1em" height="1em" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<g>
|
||||
<path
|
||||
id="path"
|
||||
d="M0 25L0 175C0 179.41 3.58 183 8 183L12 183C16.41 183 20 179.41 20 175L20 25C20 20.58 16.41 17 12 17L8 17C3.58 17 0 20.58 0 25ZM60.41 58.43L29.42 94.81C26.87 97.8 26.87 102.19 29.42 105.18L60.38 141.53C65.2 147.19 74.47 143.78 74.47 136.34L74.47 121.83C74.47 117.41 78.06 113.83 82.47 113.83L117.5 113.83C121.91 113.83 125.5 117.41 125.5 121.83L125.5 136.35C125.5 143.78 134.76 147.2 139.58 141.54L170.57 105.18C173.12 102.19 173.12 97.8 170.57 94.81L139.59 58.43C134.76 52.77 125.5 56.18 125.5 63.62L125.5 78.16C125.5 82.58 121.91 86.16 117.5 86.16L82.5 86.16C78.08 86.16 74.5 82.58 74.5 78.16L74.5 63.62C74.5 56.18 65.23 52.77 60.41 58.43ZM188 17L192 17C196.41 17 200 20.58 200 25L200 175C200 179.41 196.41 183 192 183L188 183C183.58 183 180 179.41 180 175L180 25C180 20.58 183.58 17 188 17Z"
|
||||
fill="currentColor"
|
||||
fillRule="nonzero"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const Container = styled.div`
|
||||
`
|
||||
|
||||
const Icon = styled(GlobalOutlined)`
|
||||
color: var(--color-link);
|
||||
color: var(--color-primary);
|
||||
font-size: 15px;
|
||||
margin-right: 6px;
|
||||
`
|
||||
|
||||
27
src/renderer/src/components/Layout/AppLayout.tsx
Normal file
27
src/renderer/src/components/Layout/AppLayout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import MainSidebar from '@renderer/pages/home/MainSidebar/MainSidebar'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const AppLayout: FC<AppLayoutProps> = ({ children }) => {
|
||||
return (
|
||||
<HStack style={{ display: 'flex', flex: 1 }} id="app-layout">
|
||||
<MainSidebar />
|
||||
<ContentArea>{children}</ContentArea>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
const ContentArea = styled.div`
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
`
|
||||
|
||||
export default AppLayout
|
||||
@@ -395,10 +395,7 @@ const MinappPopupContainer: React.FC = () => {
|
||||
height={'100%'}
|
||||
maskClosable={false}
|
||||
closeIcon={null}
|
||||
style={{
|
||||
marginLeft: 'var(--sidebar-width)',
|
||||
backgroundColor: window.root.style.background
|
||||
}}>
|
||||
style={{ backgroundColor: window.root.style.background }}>
|
||||
{!isReady && (
|
||||
<EmptyView>
|
||||
<Avatar
|
||||
@@ -418,7 +415,7 @@ const TitleContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding-left: ${isMac ? '20px' : '10px'};
|
||||
padding-left: ${isMac ? '80px' : '10px'};
|
||||
padding-right: 10px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@@ -89,7 +89,7 @@ const WebviewContainer = memo(
|
||||
)
|
||||
|
||||
const WebviewStyle: React.CSSProperties = {
|
||||
width: 'calc(100vw - var(--sidebar-width))',
|
||||
width: '100vw',
|
||||
height: 'calc(100vh - var(--navbar-height))',
|
||||
backgroundColor: 'var(--color-background)',
|
||||
display: 'inline-flex'
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { useAgents } from '@renderer/hooks/useAgents'
|
||||
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useSystemAgents } from '@renderer/pages/agents'
|
||||
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
|
||||
@@ -24,7 +23,7 @@ interface Props {
|
||||
const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
const { agents: userAgents } = useAgents()
|
||||
const { assistants: userAgents } = useAssistants()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const { defaultAssistant } = useDefaultAssistant()
|
||||
const { assistants, addAssistant } = useAssistants()
|
||||
|
||||
@@ -14,14 +14,7 @@ interface Props {
|
||||
position: 'left' | 'right'
|
||||
}
|
||||
|
||||
const FloatingSidebar: FC<Props> = ({
|
||||
children,
|
||||
activeAssistant,
|
||||
setActiveAssistant,
|
||||
activeTopic,
|
||||
setActiveTopic,
|
||||
position = 'left'
|
||||
}) => {
|
||||
const FloatingSidebar: FC<Props> = ({ children, activeAssistant, setActiveAssistant, activeTopic, setActiveTopic }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useHotkeys('esc', () => {
|
||||
@@ -45,12 +38,11 @@ const FloatingSidebar: FC<Props> = ({
|
||||
const content = (
|
||||
<PopoverContent maxHeight={maxHeight}>
|
||||
<HomeTabs
|
||||
tab="assistants"
|
||||
activeAssistant={activeAssistant}
|
||||
activeTopic={activeTopic}
|
||||
setActiveAssistant={setActiveAssistant}
|
||||
setActiveTopic={setActiveTopic}
|
||||
position={position}
|
||||
forceToSeeAllTab={true}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
|
||||
375
src/renderer/src/components/Popups/MessageSettingsPopup.tsx
Normal file
375
src/renderer/src/components/Popups/MessageSettingsPopup.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { SettingDivider, SettingRow, SettingRowTitle } from '@renderer/pages/settings'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import {
|
||||
setCodeCollapsible,
|
||||
setCodeEditor,
|
||||
setCodeExecution,
|
||||
setCodePreview,
|
||||
setCodeShowLineNumbers,
|
||||
setCodeWrappable,
|
||||
setFontSize,
|
||||
setMathEngine,
|
||||
setMessageFont,
|
||||
setMessageNavigation,
|
||||
setMessageStyle,
|
||||
setMultiModelMessageStyle,
|
||||
setShowMessageDivider,
|
||||
setThoughtAutoCollapse
|
||||
} from '@renderer/store/settings'
|
||||
import { CodeStyleVarious, MathEngine, ThemeMode } from '@renderer/types'
|
||||
import { Col, InputNumber, Modal, Row, Slider, Switch, Tooltip } from 'antd'
|
||||
import { CircleHelp } from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Selector from '../Selector'
|
||||
import { TopView } from '../TopView'
|
||||
|
||||
interface ShowParams {
|
||||
title: string
|
||||
}
|
||||
|
||||
interface Props extends ShowParams {
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
|
||||
const { messageStyle, fontSize } = useSettings()
|
||||
const { theme } = useTheme()
|
||||
const { themeNames } = useCodeStyle()
|
||||
|
||||
const [fontSizeValue, setFontSizeValue] = useState(fontSize)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const {
|
||||
showMessageDivider,
|
||||
messageFont,
|
||||
codeShowLineNumbers,
|
||||
codeCollapsible,
|
||||
codeWrappable,
|
||||
codeEditor,
|
||||
codePreview,
|
||||
codeExecution,
|
||||
mathEngine,
|
||||
multiModelMessageStyle,
|
||||
thoughtAutoCollapse,
|
||||
messageNavigation
|
||||
} = useSettings()
|
||||
|
||||
const codeStyle = useMemo(() => {
|
||||
return codeEditor.enabled
|
||||
? theme === ThemeMode.light
|
||||
? codeEditor.themeLight
|
||||
: codeEditor.themeDark
|
||||
: theme === ThemeMode.light
|
||||
? codePreview.themeLight
|
||||
: codePreview.themeDark
|
||||
}, [
|
||||
codeEditor.enabled,
|
||||
codeEditor.themeLight,
|
||||
codeEditor.themeDark,
|
||||
theme,
|
||||
codePreview.themeLight,
|
||||
codePreview.themeDark
|
||||
])
|
||||
|
||||
const onCodeStyleChange = useCallback(
|
||||
(value: CodeStyleVarious) => {
|
||||
const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark'
|
||||
const action = codeEditor.enabled ? setCodeEditor : setCodePreview
|
||||
dispatch(action({ [field]: value }))
|
||||
},
|
||||
[dispatch, theme, codeEditor.enabled]
|
||||
)
|
||||
|
||||
const onOk = () => {
|
||||
resolve(true)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
resolve(false)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
|
||||
MessageSettingsPopup.hide = onCancel
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
transitionName="animation-move-down"
|
||||
footer={null}
|
||||
centered
|
||||
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}>
|
||||
<SettingGroup>
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('settings.messages.divider')}</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={showMessageDivider}
|
||||
onChange={(checked) => dispatch(setShowMessageDivider(checked))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('settings.messages.use_serif_font')}</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={messageFont === 'serif'}
|
||||
onChange={(checked) => dispatch(setMessageFont(checked ? 'serif' : 'system'))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>
|
||||
{t('chat.settings.thought_auto_collapse')}
|
||||
<Tooltip title={t('chat.settings.thought_auto_collapse.tip')}>
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={thoughtAutoCollapse}
|
||||
onChange={(checked) => dispatch(setThoughtAutoCollapse(checked))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('message.message.style')}</SettingRowTitleSmall>
|
||||
<Selector
|
||||
value={messageStyle}
|
||||
onChange={(value) => dispatch(setMessageStyle(value as 'plain' | 'bubble'))}
|
||||
options={[
|
||||
{ label: t('message.message.style.plain'), value: 'plain' },
|
||||
{ label: t('message.message.style.bubble'), value: 'bubble' }
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('message.message.multi_model_style')}</SettingRowTitleSmall>
|
||||
<Selector
|
||||
value={multiModelMessageStyle}
|
||||
onChange={(value) =>
|
||||
dispatch(setMultiModelMessageStyle(value as 'fold' | 'vertical' | 'horizontal' | 'grid'))
|
||||
}
|
||||
options={[
|
||||
{ label: t('message.message.multi_model_style.fold'), value: 'fold' },
|
||||
{ label: t('message.message.multi_model_style.vertical'), value: 'vertical' },
|
||||
{ label: t('message.message.multi_model_style.horizontal'), value: 'horizontal' },
|
||||
{ label: t('message.message.multi_model_style.grid'), value: 'grid' }
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('settings.messages.navigation')}</SettingRowTitleSmall>
|
||||
<Selector
|
||||
value={messageNavigation}
|
||||
onChange={(value) => dispatch(setMessageNavigation(value as 'none' | 'buttons' | 'anchor'))}
|
||||
options={[
|
||||
{ label: t('settings.messages.navigation.none'), value: 'none' },
|
||||
{ label: t('settings.messages.navigation.buttons'), value: 'buttons' },
|
||||
{ label: t('settings.messages.navigation.anchor'), value: 'anchor' }
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('settings.messages.math_engine')}</SettingRowTitleSmall>
|
||||
<Selector
|
||||
value={mathEngine}
|
||||
onChange={(value) => dispatch(setMathEngine(value as MathEngine))}
|
||||
options={[
|
||||
{ label: 'KaTeX', value: 'KaTeX' },
|
||||
{ label: 'MathJax', value: 'MathJax' },
|
||||
{ label: t('settings.messages.math_engine.none'), value: 'none' }
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('settings.font_size.title')}</SettingRowTitleSmall>
|
||||
</SettingRow>
|
||||
<Row align="middle" gutter={10}>
|
||||
<Col span={24}>
|
||||
<Slider
|
||||
value={fontSizeValue}
|
||||
onChange={(value) => setFontSizeValue(value)}
|
||||
onChangeComplete={(value) => dispatch(setFontSize(value))}
|
||||
min={12}
|
||||
max={22}
|
||||
step={1}
|
||||
marks={{
|
||||
12: <span style={{ fontSize: '12px' }}>A</span>,
|
||||
14: <span style={{ fontSize: '14px' }}>{t('common.default')}</span>,
|
||||
22: <span style={{ fontSize: '18px' }}>A</span>
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</SettingGroup>
|
||||
<SettingGroup>
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('message.message.code_style')}</SettingRowTitleSmall>
|
||||
<Selector
|
||||
value={codeStyle}
|
||||
onChange={(value) => onCodeStyleChange(value as CodeStyleVarious)}
|
||||
options={themeNames.map((theme) => ({ label: theme, value: theme }))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>
|
||||
{t('chat.settings.code_execution.title')}
|
||||
<Tooltip title={t('chat.settings.code_execution.tip')}>
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={codeExecution.enabled}
|
||||
onChange={(checked) => dispatch(setCodeExecution({ enabled: checked }))}
|
||||
/>
|
||||
</SettingRow>
|
||||
{codeExecution.enabled && (
|
||||
<>
|
||||
<SettingDivider />
|
||||
<SettingRow style={{ paddingLeft: 8 }}>
|
||||
<SettingRowTitleSmall>
|
||||
{t('chat.settings.code_execution.timeout_minutes')}
|
||||
<Tooltip title={t('chat.settings.code_execution.timeout_minutes.tip')}>
|
||||
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
|
||||
</Tooltip>
|
||||
</SettingRowTitleSmall>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={1}
|
||||
max={60}
|
||||
step={1}
|
||||
value={codeExecution.timeoutMinutes}
|
||||
onChange={(value) => dispatch(setCodeExecution({ timeoutMinutes: value ?? 1 }))}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('chat.settings.code_editor.title')}</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={codeEditor.enabled}
|
||||
onChange={(checked) => dispatch(setCodeEditor({ enabled: checked }))}
|
||||
/>
|
||||
</SettingRow>
|
||||
{codeEditor.enabled && (
|
||||
<>
|
||||
<SettingDivider />
|
||||
<SettingRow style={{ paddingLeft: 8 }}>
|
||||
<SettingRowTitleSmall>{t('chat.settings.code_editor.highlight_active_line')}</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={codeEditor.highlightActiveLine}
|
||||
onChange={(checked) => dispatch(setCodeEditor({ highlightActiveLine: checked }))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow style={{ paddingLeft: 8 }}>
|
||||
<SettingRowTitleSmall>{t('chat.settings.code_editor.fold_gutter')}</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={codeEditor.foldGutter}
|
||||
onChange={(checked) => dispatch(setCodeEditor({ foldGutter: checked }))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow style={{ paddingLeft: 8 }}>
|
||||
<SettingRowTitleSmall>{t('chat.settings.code_editor.autocompletion')}</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={codeEditor.autocompletion}
|
||||
onChange={(checked) => dispatch(setCodeEditor({ autocompletion: checked }))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow style={{ paddingLeft: 8 }}>
|
||||
<SettingRowTitleSmall>{t('chat.settings.code_editor.keymap')}</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={codeEditor.keymap}
|
||||
onChange={(checked) => dispatch(setCodeEditor({ keymap: checked }))}
|
||||
/>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('chat.settings.show_line_numbers')}</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={codeShowLineNumbers}
|
||||
onChange={(checked) => dispatch(setCodeShowLineNumbers(checked))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('chat.settings.code_collapsible')}</SettingRowTitleSmall>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={codeCollapsible}
|
||||
onChange={(checked) => dispatch(setCodeCollapsible(checked))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitleSmall>{t('chat.settings.code_wrappable')}</SettingRowTitleSmall>
|
||||
<Switch size="small" checked={codeWrappable} onChange={(checked) => dispatch(setCodeWrappable(checked))} />
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const SettingRowTitleSmall = styled(SettingRowTitle)`
|
||||
font-size: 13px;
|
||||
`
|
||||
|
||||
const SettingGroup = styled.div<{ theme?: ThemeMode }>`
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
margin-top: 0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 10px;
|
||||
`
|
||||
|
||||
const TopViewKey = 'MessageSettingsPopup'
|
||||
|
||||
export default class MessageSettingsPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
static show(props: ShowParams) {
|
||||
return new Promise<any>((resolve) => {
|
||||
TopView.show(<PopupContainer {...props} resolve={resolve} />, TopViewKey)
|
||||
})
|
||||
}
|
||||
}
|
||||
24
src/renderer/src/components/Popups/SettingsPopup.tsx
Normal file
24
src/renderer/src/components/Popups/SettingsPopup.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface SettingsPopupShowParams {
|
||||
defaultTab?:
|
||||
| 'provider'
|
||||
| 'model'
|
||||
| 'tool'
|
||||
| 'general'
|
||||
| 'display'
|
||||
| 'shortcut'
|
||||
| 'quickAssistant'
|
||||
| 'selectionAssistant'
|
||||
| 'data'
|
||||
| 'about'
|
||||
| 'quickPhrase'
|
||||
}
|
||||
|
||||
export default class SettingsPopup {
|
||||
static hide() {
|
||||
// Settings window is now independent, user can close it manually
|
||||
}
|
||||
|
||||
static show(props: SettingsPopupShowParams = {}) {
|
||||
return window.api.showSettingsWindow({ defaultTab: props.defaultTab })
|
||||
}
|
||||
}
|
||||
@@ -15,15 +15,17 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
|
||||
const onOk = () => {
|
||||
resolve(true)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
resolve(false)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
resolve({})
|
||||
TopView.hide(TopViewKey)
|
||||
}
|
||||
|
||||
TemplatePopup.hide = onCancel
|
||||
@@ -51,16 +53,7 @@ export default class TemplatePopup {
|
||||
}
|
||||
static show(props: ShowParams) {
|
||||
return new Promise<any>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
{...props}
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
TopView.hide(TopViewKey)
|
||||
}}
|
||||
/>,
|
||||
TopViewKey
|
||||
)
|
||||
TopView.show(<PopupContainer {...props} resolve={resolve} />, TopViewKey)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
237
src/renderer/src/components/Tabs/TabsContainer.tsx
Normal file
237
src/renderer/src/components/Tabs/TabsContainer.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import type { Tab } from '@renderer/store/tabs'
|
||||
import { addTab, removeTab, setActiveTab } from '@renderer/store/tabs'
|
||||
import {
|
||||
FileSearch,
|
||||
Folder,
|
||||
Home,
|
||||
Languages,
|
||||
LayoutGrid,
|
||||
Palette,
|
||||
Settings,
|
||||
Sparkle,
|
||||
SquareTerminal,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface TabsContainerProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const getTabIcon = (tabId: string): React.ReactNode | undefined => {
|
||||
switch (tabId) {
|
||||
case 'home':
|
||||
return <Home size={14} />
|
||||
case 'agents':
|
||||
return <Sparkle size={14} />
|
||||
case 'translate':
|
||||
return <Languages size={14} />
|
||||
case 'paintings':
|
||||
return <Palette size={14} />
|
||||
case 'apps':
|
||||
return <LayoutGrid size={14} />
|
||||
case 'knowledge':
|
||||
return <FileSearch size={14} />
|
||||
case 'mcp':
|
||||
return <SquareTerminal size={14} />
|
||||
case 'files':
|
||||
return <Folder size={14} />
|
||||
case 'settings':
|
||||
return <Settings size={14} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const dispatch = useAppDispatch()
|
||||
const tabs = useAppSelector((state) => state.tabs.tabs)
|
||||
const activeTabId = useAppSelector((state) => state.tabs.activeTabId)
|
||||
const isFullscreen = useFullscreen()
|
||||
|
||||
const getTabId = (path: string): string => {
|
||||
if (path === '/') return 'home'
|
||||
const segments = path.split('/')
|
||||
return segments[1] // 获取第一个路径段作为 id
|
||||
}
|
||||
|
||||
const shouldCreateTab = (path: string) => {
|
||||
if (path === '/') return false
|
||||
return !tabs.some((tab) => tab.id === getTabId(path))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const tabId = getTabId(location.pathname)
|
||||
const currentTab = tabs.find((tab) => tab.id === tabId)
|
||||
|
||||
if (!currentTab && shouldCreateTab(location.pathname)) {
|
||||
dispatch(
|
||||
addTab({
|
||||
id: tabId,
|
||||
path: location.pathname
|
||||
})
|
||||
)
|
||||
} else if (currentTab) {
|
||||
dispatch(setActiveTab(currentTab.id))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dispatch, location.pathname])
|
||||
|
||||
const closeTab = (tabId: string) => {
|
||||
const tabToClose = tabs.find((tab) => tab.id === tabId)
|
||||
if (!tabToClose) return
|
||||
|
||||
if (tabs.length === 1) return
|
||||
|
||||
if (tabId === activeTabId) {
|
||||
const remainingTabs = tabs.filter((tab) => tab.id !== tabId)
|
||||
const lastTab = remainingTabs[remainingTabs.length - 1]
|
||||
navigate(lastTab.path)
|
||||
}
|
||||
|
||||
dispatch(removeTab(tabId))
|
||||
}
|
||||
|
||||
const handleAddTab = () => {
|
||||
navigate('/launchpad')
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<TabsBar $isFullscreen={isFullscreen}>
|
||||
{tabs.map((tab) => (
|
||||
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => navigate(tab.path)}>
|
||||
<TabHeader>
|
||||
{tab.id && <TabIcon>{getTabIcon(tab.id)}</TabIcon>}
|
||||
<TabTitle>{t(`title.${tab.id}`)}</TabTitle>
|
||||
</TabHeader>
|
||||
{tab.id !== 'home' && (
|
||||
<CloseButton
|
||||
className="close-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
closeTab(tab.id)
|
||||
}}>
|
||||
<X size={12} />
|
||||
</CloseButton>
|
||||
)}
|
||||
</Tab>
|
||||
))}
|
||||
<AddTabButton onClick={handleAddTab}>
|
||||
<PlusOutlined />
|
||||
</AddTabButton>
|
||||
</TabsBar>
|
||||
<TabContent>{children}</TabContent>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
const TabsBar = styled.div<{ $isFullscreen: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? '80px' : '8px')};
|
||||
-webkit-app-region: drag;
|
||||
height: var(--navbar-height);
|
||||
`
|
||||
|
||||
const Tab = styled.div<{ active?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 10px;
|
||||
background: ${(props) => (props.active ? 'var(--color-background)' : 'transparent')};
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-app-region: none;
|
||||
min-width: 100px;
|
||||
transition: background 0.2s;
|
||||
.close-button {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
margin-right: -2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => (props.active ? 'var(--color-background)' : 'var(--color-background-soft)')};
|
||||
.close-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const TabHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
`
|
||||
|
||||
const TabIcon = styled.span`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 6px;
|
||||
color: var(--color-text-2);
|
||||
`
|
||||
|
||||
const TabTitle = styled.span`
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
margin-right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
const CloseButton = styled.span`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
`
|
||||
|
||||
const AddTabButton = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
-webkit-app-region: none;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-soft);
|
||||
border-radius: 8px;
|
||||
}
|
||||
`
|
||||
|
||||
const TabContent = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
width: calc(100vw - 12px);
|
||||
margin: 6px;
|
||||
margin-top: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
export default TabsContainer
|
||||
@@ -80,7 +80,7 @@ const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style, isLoa
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton onClick={handleTranslate} disabled={disabled || isTranslating} style={style} type="text">
|
||||
{isTranslating ? <LoadingOutlined spin /> : <Languages size={18} />}
|
||||
{isTranslating ? <LoadingOutlined spin /> : <Languages size={16} />}
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
import { isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import { useShowAssistants } from '@renderer/hooks/useStore'
|
||||
import type { FC, HTMLAttributes, PropsWithChildren } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
type Props = PropsWithChildren & HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export const Navbar: FC<Props> = ({ children, ...props }) => {
|
||||
const backgroundColor = useNavBackgroundColor()
|
||||
|
||||
return (
|
||||
<NavbarContainer {...props} style={{ backgroundColor }}>
|
||||
{children}
|
||||
</NavbarContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export const NavbarLeft: FC<Props> = ({ children, ...props }) => {
|
||||
return <NavbarLeftContainer {...props}>{children}</NavbarLeftContainer>
|
||||
return <NavbarContainer {...props}>{children}</NavbarContainer>
|
||||
}
|
||||
|
||||
export const NavbarCenter: FC<Props> = ({ children, ...props }) => {
|
||||
@@ -36,8 +25,10 @@ export const NavbarRight: FC<Props> = ({ children, ...props }) => {
|
||||
|
||||
export const NavbarMain: FC<Props> = ({ children, ...props }) => {
|
||||
const isFullscreen = useFullscreen()
|
||||
const { showAssistants } = useShowAssistants()
|
||||
|
||||
return (
|
||||
<NavbarMainContainer {...props} $isFullscreen={isFullscreen}>
|
||||
<NavbarMainContainer {...props} $isFullscreen={isFullscreen} $showAssistants={showAssistants}>
|
||||
{children}
|
||||
</NavbarMainContainer>
|
||||
)
|
||||
@@ -49,28 +40,8 @@ const NavbarContainer = styled.div`
|
||||
flex-direction: row;
|
||||
min-height: var(--navbar-height);
|
||||
max-height: var(--navbar-height);
|
||||
margin-left: ${isMac ? 'calc(var(--sidebar-width) * -1)' : 0};
|
||||
padding-left: ${isMac ? 'var(--sidebar-width)' : 0};
|
||||
-webkit-app-region: drag;
|
||||
`
|
||||
|
||||
const NavbarLeftContainer = styled.div`
|
||||
min-width: var(--assistants-width);
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
color: var(--color-text-1);
|
||||
`
|
||||
|
||||
const NavbarCenterContainer = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 ${isMac ? '20px' : 0};
|
||||
font-weight: bold;
|
||||
color: var(--color-text-1);
|
||||
background-color: var(--color-background);
|
||||
`
|
||||
|
||||
const NavbarRightContainer = styled.div<{ $isFullscreen: boolean }>`
|
||||
@@ -82,14 +53,45 @@ const NavbarRightContainer = styled.div<{ $isFullscreen: boolean }>`
|
||||
justify-content: flex-end;
|
||||
`
|
||||
|
||||
const NavbarMainContainer = styled.div<{ $isFullscreen: boolean }>`
|
||||
const NavbarMainContainer = styled.div<{ $isFullscreen: boolean; $showAssistants: boolean }>`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: var(--color-background);
|
||||
height: var(--navbar-height);
|
||||
max-height: var(--navbar-height);
|
||||
min-height: var(--navbar-height);
|
||||
justify-content: space-between;
|
||||
padding: 0 ${isMac ? '20px' : 0};
|
||||
font-weight: bold;
|
||||
color: var(--color-text-1);
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px')};
|
||||
-webkit-app-region: drag;
|
||||
padding: 0 12px;
|
||||
padding-left: ${({ $showAssistants }) => (isMac && !$showAssistants ? '70px' : '10px')};
|
||||
`
|
||||
|
||||
const NavbarCenterContainer = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--navbar-height);
|
||||
max-height: var(--navbar-height);
|
||||
min-height: var(--navbar-height);
|
||||
padding: 0 8px;
|
||||
font-weight: bold;
|
||||
justify-content: space-between;
|
||||
color: var(--color-text-1);
|
||||
`
|
||||
|
||||
// const rotateAnimation = keyframes`
|
||||
// from {
|
||||
// transform: rotate(-180deg);
|
||||
// }
|
||||
// to {
|
||||
// transform: rotate(0);
|
||||
// }
|
||||
// `
|
||||
|
||||
// const AnimatedButton = styled(Button)`
|
||||
// animation: ${rotateAnimation} 0.4s ease-out;
|
||||
// `
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import EmojiAvatar from '@renderer/components/Avatar/EmojiAvatar'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { AppLogo, UserAvatar } from '@renderer/config/env'
|
||||
import { AppLogo } from '@renderer/config/env'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import useAvatar from '@renderer/hooks/useAvatar'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
@@ -11,9 +9,8 @@ import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { isEmoji } from '@renderer/utils'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { Avatar, Dropdown, Tooltip } from 'antd'
|
||||
import { Dropdown, Tooltip } from 'antd'
|
||||
import {
|
||||
CircleHelp,
|
||||
FileSearch,
|
||||
@@ -35,7 +32,6 @@ import styled from 'styled-components'
|
||||
|
||||
import { DraggableList } from '../DraggableList'
|
||||
import MinAppIcon from '../Icons/MinAppIcon'
|
||||
import UserPopup from '../Popups/UserPopup'
|
||||
|
||||
const Sidebar: FC = () => {
|
||||
const { hideMinappPopup, openMinapp } = useMinappPopup()
|
||||
@@ -47,11 +43,8 @@ const Sidebar: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { theme, settedTheme, toggleTheme } = useTheme()
|
||||
const avatar = useAvatar()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onEditUser = () => UserPopup.show()
|
||||
|
||||
const backgroundColor = useNavBackgroundColor()
|
||||
|
||||
const showPinnedApps = pinned.length > 0 && sidebarIcons.visible.includes('minapp')
|
||||
@@ -79,13 +72,6 @@ const Sidebar: FC = () => {
|
||||
$isFullscreen={isFullscreen}
|
||||
id="app-sidebar"
|
||||
style={{ backgroundColor, zIndex: minappShow ? 10000 : 'initial' }}>
|
||||
{isEmoji(avatar) ? (
|
||||
<EmojiAvatar onClick={onEditUser} className="sidebar-avatar" size={31} fontSize={18}>
|
||||
{avatar}
|
||||
</EmojiAvatar>
|
||||
) : (
|
||||
<AvatarImg src={avatar || UserAvatar} draggable={false} className="nodrag" onClick={onEditUser} />
|
||||
)}
|
||||
<MainMenusContainer>
|
||||
<Menus onClick={hideMinappPopup}>
|
||||
<MainMenus />
|
||||
@@ -339,16 +325,6 @@ const Container = styled.div<{ $isFullscreen: boolean }>`
|
||||
}
|
||||
`
|
||||
|
||||
const AvatarImg = styled(Avatar)`
|
||||
width: 31px;
|
||||
height: 31px;
|
||||
background-color: var(--color-background-soft);
|
||||
margin-bottom: ${isMac ? '12px' : '12px'};
|
||||
margin-top: ${isMac ? '0px' : '2px'};
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
`
|
||||
|
||||
const MainMenusContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
@@ -47,7 +47,7 @@ As [role name], with [list skills], strictly adhering to [list constraints], usi
|
||||
`
|
||||
|
||||
export const SUMMARIZE_PROMPT =
|
||||
"You are an assistant skilled in conversation. You need to summarize the user's conversation into a title within 10 words. The language of the title should be consistent with the user's primary language. Do not use punctuation marks or other special symbols"
|
||||
"You are an assistant skilled in conversation. You need to summarize the user's conversation into a title within 10 words. The language of the title should be consistent with the user's primary language. Do not use punctuation marks, markdown language markers, or other special symbols"
|
||||
|
||||
// https://github.com/ItzCrazyKns/Perplexica/blob/master/src/lib/prompts/webSearch.ts
|
||||
export const SEARCH_SUMMARY_PROMPT = `
|
||||
|
||||
@@ -23,7 +23,7 @@ interface ThemeProviderProps extends PropsWithChildren {
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
// 用户设置的主题
|
||||
const { theme: settedTheme, setTheme: setSettedTheme } = useSettings()
|
||||
const { theme: settedTheme, setTheme: setSettedTheme, transparentWindow } = useSettings()
|
||||
const [actualTheme, setActualTheme] = useState<ThemeMode>(
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches ? ThemeMode.dark : ThemeMode.light
|
||||
)
|
||||
@@ -56,7 +56,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
document.body.setAttribute('theme-mode', actualTheme)
|
||||
setActualTheme(actualTheme)
|
||||
})
|
||||
}, [actualTheme, initUserTheme, setSettedTheme, settedTheme])
|
||||
}, [actualTheme, initUserTheme, setSettedTheme, settedTheme, transparentWindow])
|
||||
|
||||
useEffect(() => {
|
||||
window.api.setTheme(settedTheme)
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import SettingsPopup from '@renderer/components/Popups/SettingsPopup'
|
||||
import NavigationService from '@renderer/services/NavigationService'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { useEffect } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const NavigationHandler: React.FC = () => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const showSettingsShortcutEnabled = useAppSelector(
|
||||
(state) => state.shortcuts.shortcuts.find((s) => s.key === 'show_settings')?.enabled
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
NavigationService.setNavigate(navigate)
|
||||
}, [navigate])
|
||||
|
||||
useHotkeys(
|
||||
'meta+, ! ctrl+,',
|
||||
function () {
|
||||
if (location.pathname.startsWith('/settings')) {
|
||||
return
|
||||
}
|
||||
navigate('/settings/provider')
|
||||
SettingsPopup.show({ defaultTab: 'provider' })
|
||||
},
|
||||
{
|
||||
splitKey: '!',
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { addAgent, removeAgent, updateAgent, updateAgents, updateAgentSettings } from '@renderer/store/agents'
|
||||
import { Agent, AssistantSettings } from '@renderer/types'
|
||||
|
||||
export function useAgents() {
|
||||
const agents = useAppSelector((state) => state.agents.agents)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
agents,
|
||||
updateAgents: (agents: Agent[]) => dispatch(updateAgents(agents)),
|
||||
addAgent: (agent: Agent) => dispatch(addAgent(agent)),
|
||||
removeAgent: (id: string) => dispatch(removeAgent({ id }))
|
||||
}
|
||||
}
|
||||
|
||||
export function useAgent(id: string) {
|
||||
const agent = useAppSelector((state) => state.agents.agents.find((a) => a.id === id) as Agent)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
agent,
|
||||
updateAgent: (agent: Agent) => dispatch(updateAgent(agent)),
|
||||
updateAgentSettings: (settings: Partial<AssistantSettings>) => {
|
||||
dispatch(updateAgentSettings({ assistantId: agent.id, settings }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { isLocalAi } from '@renderer/config/env'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import db from '@renderer/databases'
|
||||
import i18n from '@renderer/i18n'
|
||||
import KnowledgeQueue from '@renderer/queue/KnowledgeQueue'
|
||||
@@ -13,17 +11,14 @@ import { useEffect } from 'react'
|
||||
|
||||
import { useDefaultModel } from './useAssistant'
|
||||
import useFullScreenNotice from './useFullScreenNotice'
|
||||
import { useRuntime } from './useRuntime'
|
||||
import { useSettings } from './useSettings'
|
||||
import useUpdateHandler from './useUpdateHandler'
|
||||
|
||||
export function useAppInit() {
|
||||
const dispatch = useAppDispatch()
|
||||
const { proxyUrl, language, windowStyle, autoCheckUpdate, proxyMode, customCss, enableDataCollection } = useSettings()
|
||||
const { minappShow } = useRuntime()
|
||||
const { proxyUrl, language, autoCheckUpdate, proxyMode, customCss, enableDataCollection } = useSettings()
|
||||
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
|
||||
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
|
||||
const { theme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
document.getElementById('spinner')?.remove()
|
||||
@@ -70,18 +65,6 @@ export function useAppInit() {
|
||||
i18n.changeLanguage(language || navigator.language || defaultLanguage)
|
||||
}, [language])
|
||||
|
||||
useEffect(() => {
|
||||
const transparentWindow = windowStyle === 'transparent' && isMac && !minappShow
|
||||
|
||||
if (minappShow) {
|
||||
window.root.style.background =
|
||||
windowStyle === 'transparent' && isMac ? 'var(--color-background)' : 'var(--navbar-background)'
|
||||
return
|
||||
}
|
||||
|
||||
window.root.style.background = transparentWindow ? 'var(--navbar-background-mac)' : 'var(--navbar-background)'
|
||||
}, [windowStyle, minappShow, theme])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLocalAi) {
|
||||
const model = JSON.parse(import.meta.env.VITE_RENDERER_INTEGRATED_MODEL)
|
||||
|
||||
@@ -1,45 +1,56 @@
|
||||
import { db } from '@renderer/databases'
|
||||
import { getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import {
|
||||
addAssistant,
|
||||
addTopic,
|
||||
removeAllTopics,
|
||||
createAssistantFromTemplate,
|
||||
removeAssistant,
|
||||
removeTopic,
|
||||
selectActiveAssistants,
|
||||
selectTemplates,
|
||||
setModel,
|
||||
updateAssistant,
|
||||
updateAssistants,
|
||||
updateAssistantSettings,
|
||||
updateDefaultAssistant,
|
||||
updateTopic,
|
||||
updateTopics
|
||||
updateDefaultAssistant
|
||||
} from '@renderer/store/assistants'
|
||||
import { setDefaultModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm'
|
||||
import { selectTopicsForAssistant, topicsActions } from '@renderer/store/topics'
|
||||
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
import { TopicManager } from './useTopic'
|
||||
|
||||
export function useAssistants() {
|
||||
const { assistants } = useAppSelector((state) => state.assistants)
|
||||
const assistants = useAppSelector(selectActiveAssistants)
|
||||
const templates = useAppSelector(selectTemplates)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const getAssistantById = useCallback((id: string) => assistants.find((a) => a.id === id), [assistants])
|
||||
|
||||
return {
|
||||
assistants,
|
||||
templates,
|
||||
getAssistantById,
|
||||
updateAssistants: (assistants: Assistant[]) => dispatch(updateAssistants(assistants)),
|
||||
addAssistant: (assistant: Assistant) => dispatch(addAssistant(assistant)),
|
||||
addAssistant: (assistant: Assistant) => {
|
||||
dispatch(addAssistant({ ...assistant, isTemplate: false }))
|
||||
dispatch(topicsActions.addDefaultTopic({ assistantId: assistant.id }))
|
||||
},
|
||||
addTemplate: (template: Assistant) => {
|
||||
dispatch(addAssistant({ ...template, isTemplate: true }))
|
||||
},
|
||||
removeAssistant: (id: string) => {
|
||||
dispatch(removeAssistant({ id }))
|
||||
const assistant = assistants.find((a) => a.id === id)
|
||||
const topics = assistant?.topics || []
|
||||
topics.forEach(({ id }) => TopicManager.removeTopic(id))
|
||||
// Remove all topics for this assistant
|
||||
dispatch(topicsActions.removeAllTopics({ assistantId: id }))
|
||||
},
|
||||
createAssistantFromTemplate: (templateId: string, assistantId: string) => {
|
||||
dispatch(createAssistantFromTemplate({ templateId, assistantId }))
|
||||
dispatch(topicsActions.addDefaultTopic({ assistantId }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useAssistant(id: string) {
|
||||
const assistant = useAppSelector((state) => state.assistants.assistants.find((a) => a.id === id) as Assistant)
|
||||
const topics = useTopicsForAssistant(id)
|
||||
const dispatch = useAppDispatch()
|
||||
const { defaultModel } = useDefaultModel()
|
||||
|
||||
@@ -48,19 +59,18 @@ export function useAssistant(id: string) {
|
||||
throw new Error(`Assistant model is not set for assistant with name: ${assistant?.name ?? 'unknown'}`)
|
||||
}
|
||||
|
||||
const assistantWithModel = useMemo(() => ({ ...assistant, model }), [assistant, model])
|
||||
const assistantWithModel = useMemo(() => ({ ...assistant, model, topics }), [assistant, model, topics])
|
||||
|
||||
return {
|
||||
assistant: assistantWithModel,
|
||||
model,
|
||||
addTopic: (topic: Topic) => dispatch(addTopic({ assistantId: assistant.id, topic })),
|
||||
topics,
|
||||
addTopic: (topic: Topic) => dispatch(topicsActions.addTopic({ assistantId: id, topic })),
|
||||
removeTopic: (topic: Topic) => {
|
||||
TopicManager.removeTopic(topic.id)
|
||||
dispatch(removeTopic({ assistantId: assistant.id, topic }))
|
||||
dispatch(topicsActions.removeTopic({ assistantId: id, topicId: topic.id }))
|
||||
},
|
||||
moveTopic: (topic: Topic, toAssistant: Assistant) => {
|
||||
dispatch(addTopic({ assistantId: toAssistant.id, topic: { ...topic, assistantId: toAssistant.id } }))
|
||||
dispatch(removeTopic({ assistantId: assistant.id, topic }))
|
||||
dispatch(topicsActions.moveTopic({ fromAssistantId: id, toAssistantId: toAssistant.id, topicId: topic.id }))
|
||||
// update topic messages in database
|
||||
db.topics
|
||||
.where('id')
|
||||
@@ -74,9 +84,9 @@ export function useAssistant(id: string) {
|
||||
}
|
||||
})
|
||||
},
|
||||
updateTopic: (topic: Topic) => dispatch(updateTopic({ assistantId: assistant.id, topic })),
|
||||
updateTopics: (topics: Topic[]) => dispatch(updateTopics({ assistantId: assistant.id, topics })),
|
||||
removeAllTopics: () => dispatch(removeAllTopics({ assistantId: assistant.id })),
|
||||
updateTopic: (topic: Topic) => dispatch(topicsActions.updateTopic({ assistantId: id, topic })),
|
||||
updateTopics: (topics: Topic[]) => dispatch(topicsActions.updateTopics({ assistantId: id, topics })),
|
||||
removeAllTopics: () => dispatch(topicsActions.removeAllTopics({ assistantId: id })),
|
||||
setModel: useCallback(
|
||||
(model: Model) => assistant && dispatch(setModel({ assistantId: assistant?.id, model })),
|
||||
[assistant, dispatch]
|
||||
@@ -88,16 +98,19 @@ export function useAssistant(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function useTopicsForAssistant(assistantId: string) {
|
||||
return useAppSelector((state) => selectTopicsForAssistant(state, assistantId))
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认助手模板
|
||||
*/
|
||||
export function useDefaultAssistant() {
|
||||
const defaultAssistant = useAppSelector((state) => state.assistants.defaultAssistant)
|
||||
const dispatch = useAppDispatch()
|
||||
const memoizedTopics = useMemo(() => [getDefaultTopic(defaultAssistant.id)], [defaultAssistant.id])
|
||||
|
||||
return {
|
||||
defaultAssistant: {
|
||||
...defaultAssistant,
|
||||
topics: memoizedTopics
|
||||
},
|
||||
defaultAssistant,
|
||||
updateDefaultAssistant: (assistant: Assistant) => dispatch(updateDefaultAssistant({ assistant }))
|
||||
}
|
||||
}
|
||||
|
||||
92
src/renderer/src/hooks/useChat.tsx
Normal file
92
src/renderer/src/hooks/useChat.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { createContext, use, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useTopicsForAssistant } from './useAssistant'
|
||||
import { useSettings } from './useSettings'
|
||||
|
||||
interface ChatContextType {
|
||||
activeAssistant: Assistant
|
||||
activeTopic: Topic
|
||||
setActiveAssistant: (assistant: Assistant) => void
|
||||
setActiveTopic: (topic: Topic) => void
|
||||
}
|
||||
|
||||
const ChatContext = createContext<ChatContextType | null>(null)
|
||||
|
||||
export const ChatProvider = ({ children }) => {
|
||||
const assistants = useAppSelector((state) => state.assistants.assistants)
|
||||
const [activeAssistant, setActiveAssistantBase] = useState<Assistant>(assistants[0])
|
||||
const topics = useTopicsForAssistant(activeAssistant.id)
|
||||
const [activeTopic, setActiveTopic] = useState<Topic>(topics[0])
|
||||
const { clickAssistantToShowTopic } = useSettings()
|
||||
const dispatch = useAppDispatch()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
// 包装setActiveAssistant以添加导航逻辑
|
||||
const setActiveAssistant = useCallback(
|
||||
(assistant: Assistant) => {
|
||||
setActiveAssistantBase(assistant)
|
||||
// 如果当前不在聊天页面,导航到聊天页面
|
||||
if (location.pathname !== '/') {
|
||||
navigate('/')
|
||||
}
|
||||
},
|
||||
[setActiveAssistantBase, location.pathname, navigate]
|
||||
)
|
||||
|
||||
// 当 topics 变化时,如果当前 activeTopic 不在 topics 中,设置第一个 topic
|
||||
useEffect(() => {
|
||||
if (!topics.find((topic) => topic.id === activeTopic?.id)) {
|
||||
const firstTopic = topics[0]
|
||||
firstTopic && setActiveTopic(firstTopic)
|
||||
}
|
||||
}, [topics, activeTopic?.id])
|
||||
|
||||
// 当 activeTopic 变化时加载消息
|
||||
useEffect(() => {
|
||||
if (activeTopic) {
|
||||
dispatch(loadTopicMessagesThunk(activeTopic.id))
|
||||
EventEmitter.emit(EVENT_NAMES.CHANGE_TOPIC, activeTopic)
|
||||
}
|
||||
}, [activeTopic, dispatch])
|
||||
|
||||
// 处理点击助手显示话题侧边栏
|
||||
useEffect(() => {
|
||||
if (clickAssistantToShowTopic) {
|
||||
EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR)
|
||||
}
|
||||
}, [clickAssistantToShowTopic, activeAssistant])
|
||||
|
||||
useEffect(() => {
|
||||
const subscriptions = [
|
||||
EventEmitter.on(EVENT_NAMES.SET_ASSISTANT, setActiveAssistant),
|
||||
EventEmitter.on(EVENT_NAMES.SET_TOPIC, setActiveTopic)
|
||||
]
|
||||
return () => subscriptions.forEach((subscription) => subscription())
|
||||
}, [setActiveAssistant])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
activeAssistant,
|
||||
activeTopic,
|
||||
setActiveAssistant,
|
||||
setActiveTopic
|
||||
}),
|
||||
[activeAssistant, activeTopic, setActiveAssistant]
|
||||
)
|
||||
|
||||
return <ChatContext value={value}>{children}</ChatContext>
|
||||
}
|
||||
|
||||
export const useChat = () => {
|
||||
const context = use(ChatContext)
|
||||
if (!context) {
|
||||
throw new Error('useChat must be used within ChatProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { RootState } from '@renderer/store'
|
||||
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
|
||||
import { selectMessagesForTopic } from '@renderer/store/newMessage'
|
||||
import { setActiveTopic, setSelectedMessageIds, toggleMultiSelectMode } from '@renderer/store/runtime'
|
||||
import { setSelectedMessageIds, toggleMultiSelectMode } from '@renderer/store/runtime'
|
||||
import { Topic } from '@renderer/types'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -27,10 +27,6 @@ export const useChatContext = (activeTopic: Topic) => {
|
||||
return () => unsubscribe()
|
||||
}, [dispatch])
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setActiveTopic(activeTopic))
|
||||
}, [dispatch, activeTopic])
|
||||
|
||||
const handleToggleMultiSelectMode = useCallback(
|
||||
(value: boolean) => {
|
||||
dispatch(toggleMultiSelectMode(value))
|
||||
|
||||
@@ -23,7 +23,6 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { useAgents } from './useAgents'
|
||||
import { useAssistants } from './useAssistant'
|
||||
|
||||
export const useKnowledge = (baseId: string) => {
|
||||
@@ -295,7 +294,6 @@ export const useKnowledgeBases = () => {
|
||||
const dispatch = useDispatch()
|
||||
const bases = useSelector((state: RootState) => state.knowledge.bases)
|
||||
const { assistants, updateAssistants } = useAssistants()
|
||||
const { agents, updateAgents } = useAgents()
|
||||
|
||||
const addKnowledgeBase = (base: KnowledgeBase) => {
|
||||
dispatch(addBase(base))
|
||||
@@ -319,19 +317,7 @@ export const useKnowledgeBases = () => {
|
||||
return assistant
|
||||
})
|
||||
|
||||
// remove agent knowledge_base
|
||||
const _agents = agents.map((agent) => {
|
||||
if (agent.knowledge_bases?.find((kb) => kb.id === baseId)) {
|
||||
return {
|
||||
...agent,
|
||||
knowledge_bases: agent.knowledge_bases.filter((kb) => kb.id !== baseId)
|
||||
}
|
||||
}
|
||||
return agent
|
||||
})
|
||||
|
||||
updateAssistants(_assistants)
|
||||
updateAgents(_agents)
|
||||
}
|
||||
|
||||
const updateKnowledgeBases = (bases: KnowledgeBase[]) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings' // 使用设置中的值
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import {
|
||||
setCurrentMinappId,
|
||||
@@ -33,6 +34,7 @@ export const useMinappPopup = () => {
|
||||
/** Open a minapp (popup shows and minapp loaded) */
|
||||
const openMinapp = useCallback(
|
||||
(app: MinAppType, keepAlive: boolean = false) => {
|
||||
EventEmitter.emit(EVENT_NAMES.OPEN_MINAPP, app)
|
||||
if (keepAlive) {
|
||||
// 如果小程序已经打开,只切换显示
|
||||
if (openedKeepAliveMinapps.some((item) => item.id === app.id)) {
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
|
||||
import { useSettings } from './useSettings'
|
||||
|
||||
function useNavBackgroundColor() {
|
||||
const { windowStyle } = useSettings()
|
||||
|
||||
const macTransparentWindow = isMac && windowStyle === 'transparent'
|
||||
|
||||
if (macTransparentWindow) {
|
||||
if (isMac) {
|
||||
return 'transparent'
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
setLaunchToTray,
|
||||
setPinTopicsToTop,
|
||||
setSendMessageShortcut as _setSendMessageShortcut,
|
||||
setShowTokens,
|
||||
setSidebarIcons,
|
||||
setTargetLanguage,
|
||||
setTestChannel as _setTestChannel,
|
||||
@@ -17,9 +16,9 @@ import {
|
||||
setTheme,
|
||||
SettingsState,
|
||||
setTopicPosition,
|
||||
setTransparentWindow,
|
||||
setTray as _setTray,
|
||||
setTrayOnClose,
|
||||
setWindowStyle
|
||||
setTrayOnClose
|
||||
} from '@renderer/store/settings'
|
||||
import { SidebarIcon, ThemeMode, TranslateLanguageVarious } from '@renderer/types'
|
||||
import { UpgradeChannel } from '@shared/config/constant'
|
||||
@@ -75,9 +74,6 @@ export function useSettings() {
|
||||
setTheme(theme: ThemeMode) {
|
||||
dispatch(setTheme(theme))
|
||||
},
|
||||
setWindowStyle(windowStyle: 'transparent' | 'opaque') {
|
||||
dispatch(setWindowStyle(windowStyle))
|
||||
},
|
||||
setTargetLanguage(targetLanguage: TranslateLanguageVarious) {
|
||||
dispatch(setTargetLanguage(targetLanguage))
|
||||
},
|
||||
@@ -99,8 +95,8 @@ export function useSettings() {
|
||||
setAssistantIconType(assistantIconType: AssistantIconType) {
|
||||
dispatch(setAssistantIconType(assistantIconType))
|
||||
},
|
||||
setShowTokens(showTokens: boolean) {
|
||||
dispatch(setShowTokens(showTokens))
|
||||
setTransparentWindow(transparentWindow: boolean) {
|
||||
dispatch(setTransparentWindow(transparentWindow))
|
||||
},
|
||||
setDisableHardwareAcceleration(disableHardwareAcceleration: boolean) {
|
||||
dispatch(setDisableHardwareAcceleration(disableHardwareAcceleration))
|
||||
|
||||
64
src/renderer/src/hooks/useTabs.ts
Normal file
64
src/renderer/src/hooks/useTabs.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import type { Tab } from '@renderer/store/tabs'
|
||||
import { addTab, removeTab, setActiveTab, updateTab } from '@renderer/store/tabs'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
export function useTabs() {
|
||||
const navigate = useNavigate()
|
||||
const dispatch = useAppDispatch()
|
||||
const tabs = useAppSelector((state) => state.tabs.tabs)
|
||||
const activeTabId = useAppSelector((state) => state.tabs.activeTabId)
|
||||
const activeTab = useAppSelector((state) => state.tabs.tabs.find((tab) => tab.id === activeTabId))
|
||||
|
||||
const getTabId = (path: string): string => {
|
||||
if (path === '/') return 'home'
|
||||
const segments = path.split('/')
|
||||
return segments[1]
|
||||
}
|
||||
|
||||
const shouldCreateTab = (path: string) => {
|
||||
if (path === '/') return false
|
||||
return !tabs.some((tab) => tab.id === getTabId(path))
|
||||
}
|
||||
|
||||
const addNewTab = (tab: Tab) => {
|
||||
dispatch(addTab(tab))
|
||||
navigate(tab.path)
|
||||
}
|
||||
|
||||
const closeTab = (tabId: string) => {
|
||||
if (tabs.length === 1) return
|
||||
|
||||
if (tabId === activeTabId) {
|
||||
const remainingTabs = tabs.filter((tab) => tab.id !== tabId)
|
||||
const lastTab = remainingTabs[remainingTabs.length - 1]
|
||||
navigate(lastTab.path)
|
||||
}
|
||||
|
||||
dispatch(removeTab(tabId))
|
||||
}
|
||||
|
||||
const switchTab = (tabId: string) => {
|
||||
const tab = tabs.find((tab) => tab.id === tabId)
|
||||
if (tab) {
|
||||
dispatch(setActiveTab(tabId))
|
||||
navigate(tab.path)
|
||||
}
|
||||
}
|
||||
|
||||
const updateCurrentTab = (updates: Partial<Tab>) => {
|
||||
dispatch(updateTab({ id: activeTabId, updates }))
|
||||
}
|
||||
|
||||
return {
|
||||
tabs,
|
||||
activeTab,
|
||||
activeTabId,
|
||||
addNewTab,
|
||||
closeTab,
|
||||
switchTab,
|
||||
getTabId,
|
||||
shouldCreateTab,
|
||||
updateCurrentTab
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export const useTags = () => {
|
||||
|
||||
// 计算所有标签
|
||||
const allTags = useMemo(() => {
|
||||
const tags = uniq(flatMap(assistants, (assistant) => assistant.tags || []))
|
||||
const tags = uniq(flatMap(assistants, (assistant) => assistant?.tags || []))
|
||||
if (savedTagsOrder.length > 0) {
|
||||
return [
|
||||
...savedTagsOrder.filter((tag) => tags.includes(tag)),
|
||||
@@ -50,6 +50,7 @@ export const useTags = () => {
|
||||
|
||||
// 按标签分组并构建结果
|
||||
const grouped = Object.entries(groupBy(assistantsByTags, 'tag')).map(([tag, group]) => ({
|
||||
id: tag,
|
||||
tag,
|
||||
assistants: group.map((g) => g.assistant)
|
||||
}))
|
||||
|
||||
@@ -1,59 +1,23 @@
|
||||
import db from '@renderer/databases'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { fetchMessagesSummary } from '@renderer/services/ApiService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { deleteMessageFiles } from '@renderer/services/MessagesService'
|
||||
import store from '@renderer/store'
|
||||
import { updateTopic } from '@renderer/store/assistants'
|
||||
import { setNewlyRenamedTopics, setRenamingTopics } from '@renderer/store/runtime'
|
||||
import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
|
||||
import { selectTopicById, topicsActions } from '@renderer/store/topics'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { findMainTextBlocks } from '@renderer/utils/messageUtils/find'
|
||||
import { find, isEmpty } from 'lodash'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isEmpty } from 'lodash'
|
||||
|
||||
import { useAssistant } from './useAssistant'
|
||||
import { getStoreSetting } from './useSettings'
|
||||
|
||||
let _activeTopic: Topic
|
||||
let _setActiveTopic: (topic: Topic) => void
|
||||
|
||||
export function useActiveTopic(_assistant: Assistant, topic?: Topic) {
|
||||
const { assistant } = useAssistant(_assistant.id)
|
||||
const [activeTopic, setActiveTopic] = useState(topic || _activeTopic || assistant?.topics[0])
|
||||
|
||||
_activeTopic = activeTopic
|
||||
_setActiveTopic = setActiveTopic
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTopic) {
|
||||
store.dispatch(loadTopicMessagesThunk(activeTopic.id))
|
||||
EventEmitter.emit(EVENT_NAMES.CHANGE_TOPIC, activeTopic)
|
||||
}
|
||||
}, [activeTopic])
|
||||
|
||||
useEffect(() => {
|
||||
// activeTopic not in assistant.topics
|
||||
if (assistant && !find(assistant.topics, { id: activeTopic?.id })) {
|
||||
setActiveTopic(assistant.topics[0])
|
||||
}
|
||||
}, [activeTopic?.id, assistant])
|
||||
|
||||
return { activeTopic, setActiveTopic }
|
||||
}
|
||||
|
||||
export function useTopic(assistant: Assistant, topicId?: string) {
|
||||
return assistant?.topics.find((topic) => topic.id === topicId)
|
||||
}
|
||||
|
||||
export function getTopic(assistant: Assistant, topicId: string) {
|
||||
return assistant?.topics.find((topic) => topic.id === topicId)
|
||||
export function getTopic(topicId: string) {
|
||||
return selectTopicById(store.getState(), topicId)
|
||||
}
|
||||
|
||||
export async function getTopicById(topicId: string) {
|
||||
const assistants = store.getState().assistants.assistants
|
||||
const topics = assistants.map((assistant) => assistant.topics).flat()
|
||||
const topic = topics.find((topic) => topic.id === topicId)
|
||||
const topic = selectTopicById(store.getState(), topicId)
|
||||
const messages = await TopicManager.getTopicMessages(topicId)
|
||||
return { ...topic, messages } as Topic
|
||||
}
|
||||
@@ -122,8 +86,7 @@ export const autoRenameTopic = async (assistant: Assistant, topicId: string) =>
|
||||
startTopicRenaming(topicId)
|
||||
|
||||
const data = { ...topic, name: topicName } as Topic
|
||||
topic.id === _activeTopic.id && _setActiveTopic(data)
|
||||
store.dispatch(updateTopic({ assistantId: assistant.id, topic: data }))
|
||||
store.dispatch(topicsActions.updateTopic({ assistantId: assistant.id, topic: data }))
|
||||
} finally {
|
||||
finishTopicRenaming(topicId)
|
||||
}
|
||||
@@ -137,8 +100,7 @@ export const autoRenameTopic = async (assistant: Assistant, topicId: string) =>
|
||||
const summaryText = await fetchMessagesSummary({ messages: topic.messages, assistant })
|
||||
if (summaryText) {
|
||||
const data = { ...topic, name: summaryText }
|
||||
topic.id === _activeTopic.id && _setActiveTopic(data)
|
||||
store.dispatch(updateTopic({ assistantId: assistant.id, topic: data }))
|
||||
store.dispatch(topicsActions.updateTopic({ assistantId: assistant.id, topic: data }))
|
||||
}
|
||||
} finally {
|
||||
finishTopicRenaming(topicId)
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
{
|
||||
"translation": {
|
||||
"title": {
|
||||
"home": "Home",
|
||||
"agents": "Agents",
|
||||
"paintings": "Paintings",
|
||||
"translate": "Translate",
|
||||
"files": "Files",
|
||||
"knowledge": "Knowledge Base",
|
||||
"apps": "Apps",
|
||||
"mcp-servers": "MCP Servers",
|
||||
"settings": "Settings",
|
||||
"launchpad": "Launchpad"
|
||||
},
|
||||
"agents": {
|
||||
"add.button": "Add to Assistant",
|
||||
"add.knowledge_base": "Knowledge Base",
|
||||
@@ -463,6 +475,7 @@
|
||||
"pinyin.asc": "Sort by Pinyin (A-Z)",
|
||||
"pinyin.desc": "Sort by Pinyin (Z-A)"
|
||||
},
|
||||
"apps": "Apps",
|
||||
"success": "Success",
|
||||
"swap": "Swap",
|
||||
"topics": "Topics",
|
||||
@@ -522,7 +535,7 @@
|
||||
"count": "files",
|
||||
"created_at": "Created At",
|
||||
"delete": "Delete",
|
||||
"delete.content": "Deleting a file will delete its reference from all messages. Are you sure you want to delete this file?",
|
||||
"delete.content": "Deleting a file will delete its reference from all messages. Are you sure you want to delete {{count}} files?",
|
||||
"delete.paintings.warning": "Image contains this file, deletion is not possible",
|
||||
"delete.title": "Delete File",
|
||||
"document": "Document",
|
||||
@@ -534,7 +547,9 @@
|
||||
"size": "Size",
|
||||
"text": "Text",
|
||||
"title": "Files",
|
||||
"type": "Type"
|
||||
"type": "Type",
|
||||
"batch_operation": "Batch Operation",
|
||||
"batch_delete": "Batch Delete"
|
||||
},
|
||||
"gpustack": {
|
||||
"keep_alive_time.description": "The time in minutes to keep the connection alive, default is 5 minutes.",
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
{
|
||||
"translation": {
|
||||
"title": {
|
||||
"home": "ホーム",
|
||||
"agents": "エージェント",
|
||||
"paintings": "ペインティング",
|
||||
"translate": "翻訳",
|
||||
"files": "ファイル",
|
||||
"knowledge": "ナレッジベース",
|
||||
"apps": "アプリ",
|
||||
"mcp-servers": "MCP サーバー",
|
||||
"settings": "設定",
|
||||
"launchpad": "ランチパッド"
|
||||
},
|
||||
"agents": {
|
||||
"add.button": "アシスタントに追加",
|
||||
"add.knowledge_base": "ナレッジベース",
|
||||
@@ -463,6 +475,7 @@
|
||||
"pinyin.asc": "ピンインで昇順ソート",
|
||||
"pinyin.desc": "ピンインで降順ソート"
|
||||
},
|
||||
"apps": "アプリ",
|
||||
"success": "成功",
|
||||
"swap": "交換",
|
||||
"topics": "トピック",
|
||||
@@ -522,7 +535,7 @@
|
||||
"count": "ファイル",
|
||||
"created_at": "作成日",
|
||||
"delete": "削除",
|
||||
"delete.content": "ファイルを削除すると、ファイルがすべてのメッセージで参照されることを削除します。このファイルを削除してもよろしいですか?",
|
||||
"delete.content": "ファイルを削除すると、ファイルがすべてのメッセージで参照されることを削除します。この{{count}}ファイルを削除してもよろしいですか?",
|
||||
"delete.paintings.warning": "画像に含まれているため、削除できません",
|
||||
"delete.title": "ファイルを削除",
|
||||
"document": "ドキュメント",
|
||||
@@ -534,7 +547,9 @@
|
||||
"size": "サイズ",
|
||||
"text": "テキスト",
|
||||
"title": "ファイル",
|
||||
"type": "タイプ"
|
||||
"type": "タイプ",
|
||||
"batch_operation": "一括操作",
|
||||
"batch_delete": "一括削除"
|
||||
},
|
||||
"gpustack": {
|
||||
"keep_alive_time.description": "モデルがメモリに保持される時間(デフォルト:5分)",
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
{
|
||||
"translation": {
|
||||
"title": {
|
||||
"home": "Главная",
|
||||
"agents": "Агенты",
|
||||
"paintings": "Рисунки",
|
||||
"translate": "Перевод",
|
||||
"files": "Файлы",
|
||||
"knowledge": "База знаний",
|
||||
"apps": "Приложения",
|
||||
"mcp-servers": "MCP серверы",
|
||||
"settings": "Настройки",
|
||||
"launchpad": "Запуск"
|
||||
},
|
||||
"agents": {
|
||||
"add.button": "Добавить в ассистента",
|
||||
"add.knowledge_base": "База знаний",
|
||||
@@ -467,7 +479,8 @@
|
||||
"swap": "Поменять местами",
|
||||
"topics": "Топики",
|
||||
"warning": "Предупреждение",
|
||||
"you": "Вы"
|
||||
"you": "Вы",
|
||||
"apps": "Приложения"
|
||||
},
|
||||
"docs": {
|
||||
"title": "Документация"
|
||||
@@ -522,7 +535,7 @@
|
||||
"count": "файлов",
|
||||
"created_at": "Дата создания",
|
||||
"delete": "Удалить",
|
||||
"delete.content": "Удаление файла удалит его из всех сообщений, вы уверены, что хотите удалить этот файл?",
|
||||
"delete.content": "Удаление файла удалит его из всех сообщений, вы уверены, что хотите удалить этот {{count}} файл",
|
||||
"delete.paintings.warning": "В изображениях содержится этот файл, удаление невозможно",
|
||||
"delete.title": "Удалить файл",
|
||||
"document": "Документ",
|
||||
@@ -534,7 +547,9 @@
|
||||
"size": "Размер",
|
||||
"text": "Текст",
|
||||
"title": "Файлы",
|
||||
"type": "Тип"
|
||||
"type": "Тип",
|
||||
"batch_operation": "Пакетная операция",
|
||||
"batch_delete": "Пакетное удаление"
|
||||
},
|
||||
"gpustack": {
|
||||
"keep_alive_time.description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.",
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
{
|
||||
"translation": {
|
||||
"title": {
|
||||
"home": "首页",
|
||||
"agents": "智能体",
|
||||
"paintings": "绘画",
|
||||
"translate": "翻译",
|
||||
"files": "文件",
|
||||
"knowledge": "知识库",
|
||||
"apps": "小程序",
|
||||
"mcp-servers": "MCP 服务器",
|
||||
"settings": "设置",
|
||||
"launchpad": "启动台"
|
||||
},
|
||||
"agents": {
|
||||
"add.button": "添加到助手",
|
||||
"add.knowledge_base": "知识库",
|
||||
@@ -467,7 +479,8 @@
|
||||
"swap": "交换",
|
||||
"topics": "话题",
|
||||
"warning": "警告",
|
||||
"you": "用户"
|
||||
"you": "用户",
|
||||
"apps": "应用"
|
||||
},
|
||||
"docs": {
|
||||
"title": "帮助文档"
|
||||
@@ -522,7 +535,7 @@
|
||||
"count": "个文件",
|
||||
"created_at": "创建时间",
|
||||
"delete": "删除",
|
||||
"delete.content": "删除文件会删除文件在所有消息中的引用,确定要删除此文件吗?",
|
||||
"delete.content": "删除文件会删除文件在所有消息中的引用,确定要删除这{{count}}个文件吗?",
|
||||
"delete.paintings.warning": "绘图中包含该图片,暂时无法删除",
|
||||
"delete.title": "删除文件",
|
||||
"document": "文档",
|
||||
@@ -534,7 +547,9 @@
|
||||
"size": "大小",
|
||||
"text": "文本",
|
||||
"title": "文件",
|
||||
"type": "类型"
|
||||
"type": "类型",
|
||||
"batch_operation": "批量操作",
|
||||
"batch_delete": "批量删除"
|
||||
},
|
||||
"gpustack": {
|
||||
"keep_alive_time.description": "模型在内存中保持的时间(默认:5 分钟)",
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
{
|
||||
"translation": {
|
||||
"title": {
|
||||
"home": "主頁",
|
||||
"agents": "智能體",
|
||||
"paintings": "繪畫",
|
||||
"translate": "翻譯",
|
||||
"files": "文件",
|
||||
"knowledge": "知識庫",
|
||||
"apps": "小程序",
|
||||
"mcp-servers": "MCP 伺服器",
|
||||
"settings": "設定",
|
||||
"launchpad": "啟動台"
|
||||
},
|
||||
"agents": {
|
||||
"add.button": "新增到助手",
|
||||
"add.knowledge_base": "知識庫",
|
||||
@@ -467,7 +479,8 @@
|
||||
"swap": "交換",
|
||||
"topics": "話題",
|
||||
"warning": "警告",
|
||||
"you": "您"
|
||||
"you": "您",
|
||||
"apps": "應用"
|
||||
},
|
||||
"docs": {
|
||||
"title": "說明文件"
|
||||
@@ -522,7 +535,7 @@
|
||||
"count": "個檔案",
|
||||
"created_at": "建立時間",
|
||||
"delete": "刪除",
|
||||
"delete.content": "刪除檔案會刪除檔案在所有訊息中的引用,確定要刪除此檔案嗎?",
|
||||
"delete.content": "刪除檔案會刪除檔案在所有訊息中的引用,確定要刪除這{{count}}個檔案嗎?",
|
||||
"delete.paintings.warning": "繪圖中包含該圖片,暫時無法刪除",
|
||||
"delete.title": "刪除檔案",
|
||||
"document": "文件",
|
||||
@@ -534,7 +547,9 @@
|
||||
"size": "大小",
|
||||
"text": "文字",
|
||||
"title": "檔案",
|
||||
"type": "類型"
|
||||
"type": "類型",
|
||||
"batch_operation": "批量操作",
|
||||
"batch_delete": "批量刪除"
|
||||
},
|
||||
"gpustack": {
|
||||
"keep_alive_time.description": "模型在記憶體中保持的時間(預設為 5 分鐘)",
|
||||
@@ -2448,4 +2463,4 @@
|
||||
"visualization": "視覺化"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1575,7 +1575,6 @@
|
||||
"theme.light": "Φωτεινό",
|
||||
"theme.system": "Σύστημα",
|
||||
"theme.title": "Θέμα",
|
||||
"theme.window.style.opaque": "Μη διαφανή παράθυρα",
|
||||
"theme.window.style.title": "Στυλ παραθύρων",
|
||||
"theme.window.style.transparent": "Διαφανή παράθυρα",
|
||||
"title": "Ρυθμίσεις",
|
||||
|
||||
@@ -1574,7 +1574,6 @@
|
||||
"theme.light": "Claro",
|
||||
"theme.system": "Sistema",
|
||||
"theme.title": "Tema",
|
||||
"theme.window.style.opaque": "Ventana opaca",
|
||||
"theme.window.style.title": "Estilo de ventana",
|
||||
"theme.window.style.transparent": "Ventana transparente",
|
||||
"title": "Configuración",
|
||||
|
||||
@@ -1575,7 +1575,6 @@
|
||||
"theme.light": "Clair",
|
||||
"theme.system": "Système",
|
||||
"theme.title": "Thème",
|
||||
"theme.window.style.opaque": "Fenêtre opaque",
|
||||
"theme.window.style.title": "Style de fenêtre",
|
||||
"theme.window.style.transparent": "Fenêtre transparente",
|
||||
"title": "Paramètres",
|
||||
|
||||
@@ -1576,7 +1576,6 @@
|
||||
"theme.light": "Claro",
|
||||
"theme.system": "Sistema",
|
||||
"theme.title": "Tema",
|
||||
"theme.window.style.opaque": "Janela opaca",
|
||||
"theme.window.style.title": "Estilo de janela",
|
||||
"theme.window.style.transparent": "Janela transparente",
|
||||
"title": "Configurações",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ImportOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import { NavbarCenter, NavbarMain } from '@renderer/components/app/Navbar'
|
||||
import CustomTag from '@renderer/components/CustomTag'
|
||||
import ListItem from '@renderer/components/ListItem'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useAgents } from '@renderer/hooks/useAgents'
|
||||
import { useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
|
||||
import { Agent } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
@@ -28,7 +28,7 @@ const AgentsPage: FC = () => {
|
||||
const [activeGroup, setActiveGroup] = useState('我的')
|
||||
const [agentGroups, setAgentGroups] = useState<Record<string, Agent[]>>({})
|
||||
const systemAgents = useSystemAgents()
|
||||
const { agents: userAgents } = useAgents()
|
||||
const { templates: userAgents } = useAssistants()
|
||||
|
||||
useEffect(() => {
|
||||
const systemAgentsGroupList = groupByCategories(systemAgents)
|
||||
@@ -152,7 +152,7 @@ const AgentsPage: FC = () => {
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Navbar>
|
||||
<NavbarMain>
|
||||
<NavbarCenter style={{ borderRight: 'none', justifyContent: 'space-between' }}>
|
||||
{t('agents.title')}
|
||||
<Input
|
||||
@@ -169,9 +169,9 @@ const AgentsPage: FC = () => {
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<div style={{ width: 80 }} />
|
||||
<div style={{ width: 1 }} />
|
||||
</NavbarCenter>
|
||||
</Navbar>
|
||||
</NavbarMain>
|
||||
|
||||
<Main id="content-container">
|
||||
<AgentsGroupList>
|
||||
@@ -321,7 +321,7 @@ const AgentDescription = styled.div`
|
||||
`
|
||||
|
||||
const AgentPrompt = styled.div`
|
||||
max-height: 60vh;
|
||||
max-height: 50vh;
|
||||
overflow-y: scroll;
|
||||
background-color: var(--color-background-soft);
|
||||
padding: 8px;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CheckOutlined, LoadingOutlined, RollbackOutlined, ThunderboltOutlined }
|
||||
import EmojiPicker from '@renderer/components/EmojiPicker'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { AGENT_PROMPT } from '@renderer/config/prompts'
|
||||
import { useAgents } from '@renderer/hooks/useAgents'
|
||||
import { useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
|
||||
import { fetchGenerate } from '@renderer/services/ApiService'
|
||||
import { getDefaultModel } from '@renderer/services/AssistantService'
|
||||
@@ -14,6 +14,7 @@ import { Agent, KnowledgeBase } from '@renderer/types'
|
||||
import { getLeadingEmoji, uuid } from '@renderer/utils'
|
||||
import { Button, Form, FormInstance, Input, Modal, Popover, Select, SelectProps } from 'antd'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import stringWidth from 'string-width'
|
||||
@@ -34,7 +35,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [form] = Form.useForm()
|
||||
const { t } = useTranslation()
|
||||
const { addAgent } = useAgents()
|
||||
const { addTemplate } = useAssistants()
|
||||
const formRef = useRef<FormInstance>(null)
|
||||
const [emoji, setEmoji] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -83,12 +84,12 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
emoji: _emoji,
|
||||
prompt: values.prompt,
|
||||
defaultModel: getDefaultModel(),
|
||||
type: 'agent',
|
||||
topics: [],
|
||||
messages: []
|
||||
messages: [],
|
||||
isTemplate: true
|
||||
}
|
||||
|
||||
addAgent(_agent)
|
||||
addTemplate(_agent)
|
||||
resolve(_agent)
|
||||
setOpen(false)
|
||||
}
|
||||
@@ -239,6 +240,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
suffixIcon={<ChevronDown size={16} color="var(--color-border)" />}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
SortAscendingOutlined
|
||||
} from '@ant-design/icons'
|
||||
import CustomTag from '@renderer/components/CustomTag'
|
||||
import { useAgents } from '@renderer/hooks/useAgents'
|
||||
import { useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings'
|
||||
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
|
||||
import type { Agent } from '@renderer/types'
|
||||
@@ -27,7 +27,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const AgentCard: FC<Props> = ({ agent, onClick, activegroup, getLocalizedGroupName }) => {
|
||||
const { removeAgent } = useAgents()
|
||||
const { removeAssistant: removeAgent } = useAssistants()
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { useAgents } from '@renderer/hooks/useAgents'
|
||||
import { useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import { getDefaultModel } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { Agent } from '@renderer/types'
|
||||
@@ -16,7 +16,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [form] = Form.useForm()
|
||||
const { t } = useTranslation()
|
||||
const { addAgent } = useAgents()
|
||||
const { addTemplate: addAgent } = useAssistants()
|
||||
const [importType, setImportType] = useState<'url' | 'file'>('url')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { MenuOutlined } from '@ant-design/icons'
|
||||
import { DraggableList } from '@renderer/components/DraggableList'
|
||||
import { Box, HStack } from '@renderer/components/Layout'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { useAgents } from '@renderer/hooks/useAgents'
|
||||
import { useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import { Empty, Modal } from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -11,7 +11,8 @@ import styled from 'styled-components'
|
||||
const PopupContainer: React.FC = () => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
const { agents, updateAgents } = useAgents()
|
||||
const { assistants, updateAssistants: updateAgents } = useAssistants()
|
||||
const agents = assistants.filter((a) => a.isTemplate)
|
||||
|
||||
const onOk = () => {
|
||||
setOpen(false)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Navbar, NavbarMain } from '@renderer/components/app/Navbar'
|
||||
import { NavbarCenter, NavbarMain } from '@renderer/components/app/Navbar'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import { Button, Input } from 'antd'
|
||||
import { Search, SettingsIcon, X } from 'lucide-react'
|
||||
import { Search, SettingsIcon } from 'lucide-react'
|
||||
import React, { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation } from 'react-router'
|
||||
@@ -41,8 +41,8 @@ const AppsPage: FC = () => {
|
||||
|
||||
return (
|
||||
<Container onContextMenu={handleContextMenu}>
|
||||
<Navbar>
|
||||
<NavbarMain>
|
||||
<NavbarMain>
|
||||
<NavbarCenter>
|
||||
{t('minapp.title')}
|
||||
<Input
|
||||
placeholder={t('common.search')}
|
||||
@@ -50,10 +50,7 @@ const AppsPage: FC = () => {
|
||||
style={{
|
||||
width: '30%',
|
||||
height: 28,
|
||||
borderRadius: 15,
|
||||
position: 'absolute',
|
||||
left: '50vw',
|
||||
transform: 'translateX(-50%)'
|
||||
borderRadius: 15
|
||||
}}
|
||||
size="small"
|
||||
variant="filled"
|
||||
@@ -65,11 +62,11 @@ const AppsPage: FC = () => {
|
||||
<Button
|
||||
type="text"
|
||||
className="nodrag"
|
||||
icon={isSettingsOpen ? <X size={18} /> : <SettingsIcon size={18} color="var(--color-text-2)" />}
|
||||
icon={<SettingsIcon size={18} color={isSettingsOpen ? 'var(--color-primary)' : 'var(--color-text-2)'} />}
|
||||
onClick={() => setIsSettingsOpen(!isSettingsOpen)}
|
||||
/>
|
||||
</NavbarMain>
|
||||
</Navbar>
|
||||
</NavbarCenter>
|
||||
</NavbarMain>
|
||||
<ContentContainer id="content-container">
|
||||
{isSettingsOpen && <MiniAppSettings />}
|
||||
{!isSettingsOpen && (
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
|
||||
import { handleDelete } from '@renderer/services/FileAction'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { FileMetadata, FileTypes } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Col, Image, Row, Spin } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import VirtualList from 'rc-virtual-list'
|
||||
import React, { memo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import FileItem from './FileItem'
|
||||
import ImageList from './ImageList'
|
||||
|
||||
interface FileItemProps {
|
||||
interface FileListProps {
|
||||
id: FileTypes | 'all' | string
|
||||
list: {
|
||||
key: FileTypes | 'all' | string
|
||||
@@ -26,55 +21,9 @@ interface FileItemProps {
|
||||
files?: FileMetadata[]
|
||||
}
|
||||
|
||||
const FileList: React.FC<FileItemProps> = ({ id, list, files }) => {
|
||||
const FileList: React.FC<FileListProps> = ({ id, list, files }) => {
|
||||
if (id === FileTypes.IMAGE && files?.length && files?.length > 0) {
|
||||
return (
|
||||
<div style={{ padding: 16, overflowY: 'auto' }}>
|
||||
<Image.PreviewGroup>
|
||||
<Row gutter={[16, 16]}>
|
||||
{files?.map((file) => (
|
||||
<Col key={file.id} xs={24} sm={12} md={8} lg={4} xl={3}>
|
||||
<ImageWrapper>
|
||||
<LoadingWrapper>
|
||||
<Spin />
|
||||
</LoadingWrapper>
|
||||
<Image
|
||||
src={FileManager.getFileUrl(file)}
|
||||
style={{ height: '100%', objectFit: 'cover', cursor: 'pointer' }}
|
||||
preview={{ mask: false }}
|
||||
onLoad={(e) => {
|
||||
const img = e.target as HTMLImageElement
|
||||
img.parentElement?.classList.add('loaded')
|
||||
}}
|
||||
/>
|
||||
<ImageInfo>
|
||||
<div>{formatFileSize(file.size)}</div>
|
||||
</ImageInfo>
|
||||
<DeleteButton
|
||||
title={t('files.delete.title')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.modal.confirm({
|
||||
title: t('files.delete.title'),
|
||||
content: t('files.delete.content'),
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
centered: true,
|
||||
onOk: () => {
|
||||
handleDelete(file.id, t)
|
||||
},
|
||||
icon: <ExclamationCircleOutlined style={{ color: 'red' }} />
|
||||
})
|
||||
}}>
|
||||
<DeleteOutlined />
|
||||
</DeleteButton>
|
||||
</ImageWrapper>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Image.PreviewGroup>
|
||||
</div>
|
||||
)
|
||||
return <ImageList files={files}></ImageList>
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -113,92 +62,4 @@ const FileList: React.FC<FileItemProps> = ({ id, list, files }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const ImageWrapper = styled.div`
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background-color: var(--color-background-soft);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0.5px solid var(--color-border);
|
||||
|
||||
.ant-image {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
|
||||
&.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.ant-image.loaded {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
div:last-child {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const LoadingWrapper = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-background-soft);
|
||||
`
|
||||
|
||||
const ImageInfo = styled.div`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
padding: 5px 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
font-size: 12px;
|
||||
|
||||
> div:first-child {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
`
|
||||
|
||||
const DeleteButton = styled.div`
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 0, 0, 0.8);
|
||||
}
|
||||
`
|
||||
|
||||
export default memo(FileList)
|
||||
|
||||
@@ -5,18 +5,19 @@ import {
|
||||
SortAscendingOutlined,
|
||||
SortDescendingOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import { NavbarCenter, NavbarMain } from '@renderer/components/app/Navbar'
|
||||
import ListItem from '@renderer/components/ListItem'
|
||||
import db from '@renderer/databases'
|
||||
import { handleDelete, handleRename, sortFiles, tempFilesSort } from '@renderer/services/FileAction'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import store from '@renderer/store'
|
||||
import { FileMetadata, FileTypes } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Button, Empty, Flex, Popconfirm } from 'antd'
|
||||
import { Button, Checkbox, Dropdown, Empty, Flex, Popconfirm } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { File as FileIcon, FileImage, FileText, FileType as FileTypeIcon } from 'lucide-react'
|
||||
import { FC, useState } from 'react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -30,6 +31,11 @@ const FilesPage: FC = () => {
|
||||
const [fileType, setFileType] = useState<string>('document')
|
||||
const [sortField, setSortField] = useState<SortField>('created_at')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedFileIds([])
|
||||
}, [fileType])
|
||||
|
||||
const files = useLiveQuery<FileMetadata[]>(() => {
|
||||
if (fileType === 'all') {
|
||||
@@ -40,6 +46,46 @@ const FilesPage: FC = () => {
|
||||
|
||||
const sortedFiles = files ? sortFiles(files, sortField, sortOrder) : []
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
const selectedFiles = await Promise.all(selectedFileIds.map((id) => FileManager.getFile(id)))
|
||||
const validFiles = selectedFiles.filter((file): file is FileType => file !== null && file !== undefined)
|
||||
|
||||
const paintings = await store.getState().paintings.paintings
|
||||
const paintingsFiles = paintings.flatMap((p) => p.files)
|
||||
|
||||
const filesInPaintings = validFiles.filter((file) => paintingsFiles.some((p) => p.id === file.id))
|
||||
|
||||
if (filesInPaintings.length > 0) {
|
||||
window.modal.warning({
|
||||
content: t('files.delete.paintings.warning', { count: filesInPaintings.length }),
|
||||
centered: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
for (const fileId of selectedFileIds) {
|
||||
await handleDelete(fileId, t, setSelectedFileIds)
|
||||
}
|
||||
|
||||
setSelectedFileIds([])
|
||||
}
|
||||
|
||||
const handleSelectFile = (fileId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedFileIds((prev) => [...prev, fileId])
|
||||
} else {
|
||||
setSelectedFileIds((prev) => prev.filter((id) => id !== fileId))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedFileIds(sortedFiles.map((file) => file.id))
|
||||
} else {
|
||||
setSelectedFileIds([])
|
||||
}
|
||||
}
|
||||
|
||||
const dataSource = sortedFiles?.map((file) => {
|
||||
return {
|
||||
key: file.id,
|
||||
@@ -56,13 +102,20 @@ const FilesPage: FC = () => {
|
||||
<Button type="text" icon={<EditOutlined />} onClick={() => handleRename(file.id)} />
|
||||
<Popconfirm
|
||||
title={t('files.delete.title')}
|
||||
description={t('files.delete.content')}
|
||||
description={t('files.delete.content', { count: 1 })}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={() => handleDelete(file.id, t)}
|
||||
onConfirm={() => handleDelete(file.id, t, setSelectedFileIds)}
|
||||
icon={<ExclamationCircleOutlined style={{ color: 'red' }} />}>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
{fileType !== 'image' && (
|
||||
<Checkbox
|
||||
checked={selectedFileIds.includes(file.id)}
|
||||
onChange={(e) => handleSelectFile(file.id, e.target.checked)}
|
||||
style={{ margin: '0 8px' }}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -77,10 +130,72 @@ const FilesPage: FC = () => {
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Navbar>
|
||||
<NavbarMain>
|
||||
<NavbarCenter style={{ borderRight: 'none' }}>{t('files.title')}</NavbarCenter>
|
||||
</Navbar>
|
||||
</NavbarMain>
|
||||
<ContentContainer id="content-container">
|
||||
<MainContent>
|
||||
<SortContainer>
|
||||
<Flex gap={8} align="center">
|
||||
{['created_at', 'size', 'name'].map((field) => (
|
||||
<Button
|
||||
color="default"
|
||||
key={field}
|
||||
variant={sortField === field ? 'filled' : 'text'}
|
||||
onClick={() => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortField(field as 'created_at' | 'size' | 'name')
|
||||
setSortOrder('desc')
|
||||
}
|
||||
}}>
|
||||
{t(`files.${field}`)}
|
||||
{sortField === field &&
|
||||
(sortOrder === 'desc' ? <SortDescendingOutlined /> : <SortAscendingOutlined />)}
|
||||
</Button>
|
||||
))}
|
||||
</Flex>
|
||||
{fileType !== 'image' && (
|
||||
<Dropdown.Button
|
||||
style={{ width: 'auto' }}
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'delete',
|
||||
disabled: selectedFileIds.length === 0,
|
||||
danger: true,
|
||||
label: (
|
||||
<Popconfirm
|
||||
disabled={selectedFileIds.length === 0}
|
||||
title={t('files.delete.title')}
|
||||
description={t('files.delete.content', { count: selectedFileIds.length })}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={handleBatchDelete}
|
||||
icon={<ExclamationCircleOutlined />}>
|
||||
{t('files.batch_delete')} ({selectedFileIds.length})
|
||||
</Popconfirm>
|
||||
)
|
||||
}
|
||||
]
|
||||
}}
|
||||
trigger={['click']}>
|
||||
<Checkbox
|
||||
indeterminate={selectedFileIds.length > 0 && selectedFileIds.length < sortedFiles.length}
|
||||
checked={selectedFileIds.length === sortedFiles.length}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}>
|
||||
{t('files.batch_operation')}
|
||||
</Checkbox>
|
||||
</Dropdown.Button>
|
||||
)}
|
||||
</SortContainer>
|
||||
{dataSource && dataSource?.length > 0 ? (
|
||||
<FileList id={fileType} list={dataSource} files={sortedFiles} />
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</MainContent>
|
||||
<SideNav>
|
||||
{menuItems.map((item) => (
|
||||
<ListItem
|
||||
@@ -92,31 +207,6 @@ const FilesPage: FC = () => {
|
||||
/>
|
||||
))}
|
||||
</SideNav>
|
||||
<MainContent>
|
||||
<SortContainer>
|
||||
{['created_at', 'size', 'name'].map((field) => (
|
||||
<SortButton
|
||||
key={field}
|
||||
active={sortField === field}
|
||||
onClick={() => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortField(field as 'created_at' | 'size' | 'name')
|
||||
setSortOrder('desc')
|
||||
}
|
||||
}}>
|
||||
{t(`files.${field}`)}
|
||||
{sortField === field && (sortOrder === 'desc' ? <SortDescendingOutlined /> : <SortAscendingOutlined />)}
|
||||
</SortButton>
|
||||
))}
|
||||
</SortContainer>
|
||||
{dataSource && dataSource?.length > 0 ? (
|
||||
<FileList id={fileType} list={dataSource} files={sortedFiles} />
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</MainContent>
|
||||
</ContentContainer>
|
||||
</Container>
|
||||
)
|
||||
@@ -138,6 +228,7 @@ const MainContent = styled.div`
|
||||
const SortContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 0.5px solid var(--color-border);
|
||||
@@ -184,25 +275,4 @@ const SideNav = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const SortButton = styled(Button)<{ active?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
height: 30px;
|
||||
border-radius: var(--list-item-border-radius);
|
||||
border: 0.5px solid ${(props) => (props.active ? 'var(--color-border)' : 'transparent')};
|
||||
background-color: ${(props) => (props.active ? 'var(--color-background-soft)' : 'transparent')};
|
||||
color: ${(props) => (props.active ? 'var(--color-text)' : 'var(--color-text-secondary)')};
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: 12px;
|
||||
}
|
||||
`
|
||||
|
||||
export default FilesPage
|
||||
|
||||
149
src/renderer/src/pages/files/ImageItem.tsx
Normal file
149
src/renderer/src/pages/files/ImageItem.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
|
||||
import { handleDelete } from '@renderer/services/FileAction'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { FileType } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Col, Image, Spin } from 'antd'
|
||||
import { memo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface ImageItemProps {
|
||||
file: FileType
|
||||
}
|
||||
|
||||
const ImageItem: React.FC<ImageItemProps> = ({ file }) => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Col xs={24} sm={12} md={8} lg={4} xl={3}>
|
||||
<ImageWrapper>
|
||||
{loading && (
|
||||
<LoadingWrapper>
|
||||
<Spin />
|
||||
</LoadingWrapper>
|
||||
)}
|
||||
<Image
|
||||
src={FileManager.getFileUrl(file)}
|
||||
style={{ height: '100%', objectFit: 'cover', cursor: 'pointer' }}
|
||||
preview={{ mask: false }}
|
||||
onLoad={(e) => {
|
||||
const img = e.target as HTMLImageElement
|
||||
img.parentElement?.classList.add('loaded')
|
||||
setLoading(false)
|
||||
}}
|
||||
/>
|
||||
<ImageInfo>
|
||||
<div>{formatFileSize(file.size)}</div>
|
||||
</ImageInfo>
|
||||
<DeleteButton
|
||||
title={t('files.delete.title')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.modal.confirm({
|
||||
title: t('files.delete.title'),
|
||||
content: t('files.delete.content'),
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
centered: true,
|
||||
onOk: () => {
|
||||
handleDelete(file.id, t)
|
||||
},
|
||||
icon: <ExclamationCircleOutlined style={{ color: 'red' }} />
|
||||
})
|
||||
}}>
|
||||
<DeleteOutlined />
|
||||
</DeleteButton>
|
||||
</ImageWrapper>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
const ImageWrapper = styled.div`
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background-color: var(--color-background-soft);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0.5px solid var(--color-border);
|
||||
|
||||
.ant-image {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
|
||||
&.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.ant-image.loaded {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
div:last-child {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const LoadingWrapper = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-background-soft);
|
||||
`
|
||||
|
||||
const ImageInfo = styled.div`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
padding: 5px 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
font-size: 12px;
|
||||
|
||||
> div:first-child {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
`
|
||||
|
||||
const DeleteButton = styled.div`
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 0, 0, 0.8);
|
||||
}
|
||||
`
|
||||
|
||||
export default memo(ImageItem)
|
||||
21
src/renderer/src/pages/files/ImageList.tsx
Normal file
21
src/renderer/src/pages/files/ImageList.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { FileType } from '@renderer/types'
|
||||
import { Image, Row } from 'antd'
|
||||
import { memo } from 'react'
|
||||
|
||||
import ImageItem from './ImageItem'
|
||||
|
||||
interface ImageListProps {
|
||||
files?: FileType[]
|
||||
}
|
||||
|
||||
const ImageList: React.FC<ImageListProps> = ({ files }) => {
|
||||
return (
|
||||
<div style={{ padding: 16, overflowY: 'auto' }}>
|
||||
<Image.PreviewGroup>
|
||||
<Row gutter={[16, 16]}>{files?.map((file) => <ImageItem key={file.id} file={file}></ImageItem>)}</Row>
|
||||
</Image.PreviewGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ImageList)
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { ChatProvider } from '@renderer/hooks/useChat'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
|
||||
import { Topic } from '@renderer/types'
|
||||
@@ -72,51 +73,53 @@ const TopicsPage: FC = () => {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<HStack style={{ padding: '0 12px', marginTop: 8 }}>
|
||||
<Input
|
||||
prefix={
|
||||
stack.length > 1 ? (
|
||||
<SearchIcon className="back-icon" onClick={goBack}>
|
||||
<ChevronLeft size={16} />
|
||||
</SearchIcon>
|
||||
) : (
|
||||
<SearchIcon>
|
||||
<Search size={15} />
|
||||
</SearchIcon>
|
||||
)
|
||||
}
|
||||
suffix={search.length >= 2 ? <CornerDownLeft size={16} /> : null}
|
||||
ref={inputRef}
|
||||
placeholder={t('history.search.placeholder')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value.trimStart())}
|
||||
allowClear
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
style={{ paddingLeft: 0 }}
|
||||
variant="borderless"
|
||||
size="middle"
|
||||
onPressEnter={onSearch}
|
||||
/>
|
||||
</HStack>
|
||||
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
|
||||
<ChatProvider>
|
||||
<Container>
|
||||
<HStack style={{ padding: '0 12px', marginTop: 8 }}>
|
||||
<Input
|
||||
prefix={
|
||||
stack.length > 1 ? (
|
||||
<SearchIcon className="back-icon" onClick={goBack}>
|
||||
<ChevronLeft size={16} />
|
||||
</SearchIcon>
|
||||
) : (
|
||||
<SearchIcon>
|
||||
<Search size={15} />
|
||||
</SearchIcon>
|
||||
)
|
||||
}
|
||||
suffix={search.length >= 2 ? <CornerDownLeft size={16} /> : null}
|
||||
ref={inputRef}
|
||||
placeholder={t('history.search.placeholder')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value.trimStart())}
|
||||
allowClear
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
style={{ paddingLeft: 0 }}
|
||||
variant="borderless"
|
||||
size="middle"
|
||||
onPressEnter={onSearch}
|
||||
/>
|
||||
</HStack>
|
||||
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
|
||||
|
||||
<TopicsHistory
|
||||
keywords={search}
|
||||
onClick={onTopicClick as any}
|
||||
onSearch={onSearch}
|
||||
style={{ display: isShow('topics') }}
|
||||
/>
|
||||
<TopicMessages topic={topic} style={{ display: isShow('topic') }} />
|
||||
<SearchResults
|
||||
keywords={isShow('search') ? searchKeywords : ''}
|
||||
onMessageClick={onMessageClick}
|
||||
onTopicClick={onTopicClick}
|
||||
style={{ display: isShow('search') }}
|
||||
/>
|
||||
<SearchMessage message={message} style={{ display: isShow('message') }} />
|
||||
</Container>
|
||||
<TopicsHistory
|
||||
keywords={search}
|
||||
onClick={onTopicClick as any}
|
||||
onSearch={onSearch}
|
||||
style={{ display: isShow('topics') }}
|
||||
/>
|
||||
<TopicMessages topic={topic} style={{ display: isShow('topic') }} />
|
||||
<SearchResults
|
||||
keywords={isShow('search') ? searchKeywords : ''}
|
||||
onMessageClick={onMessageClick}
|
||||
onTopicClick={onTopicClick}
|
||||
style={{ display: isShow('search') }}
|
||||
/>
|
||||
<SearchMessage message={message} style={{ display: isShow('message') }} />
|
||||
</Container>
|
||||
</ChatProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { MessageEditingProvider } from '@renderer/context/MessageEditingContext'
|
||||
import { getTopicById } from '@renderer/hooks/useTopic'
|
||||
import { default as MessageItem } from '@renderer/pages/home/Messages/Message'
|
||||
import { locateToMessage } from '@renderer/services/MessagesService'
|
||||
import NavigationService from '@renderer/services/NavigationService'
|
||||
import { Topic } from '@renderer/types'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import { runAsyncFunction } from '@renderer/utils'
|
||||
@@ -18,7 +17,6 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
}
|
||||
|
||||
const SearchMessage: FC<Props> = ({ message, ...props }) => {
|
||||
const navigate = NavigationService.navigate!
|
||||
const { t } = useTranslation()
|
||||
const [topic, setTopic] = useState<Topic | null>(null)
|
||||
|
||||
@@ -48,11 +46,11 @@ const SearchMessage: FC<Props> = ({ message, ...props }) => {
|
||||
type="text"
|
||||
size="middle"
|
||||
style={{ color: 'var(--color-text-3)', position: 'absolute', right: 16, top: 16 }}
|
||||
onClick={() => locateToMessage(navigate, message)}
|
||||
onClick={() => locateToMessage(message)}
|
||||
icon={<Forward size={16} />}
|
||||
/>
|
||||
<HStack mt="10px" justifyContent="center">
|
||||
<Button onClick={() => locateToMessage(navigate, message)} icon={<Forward size={16} />}>
|
||||
<Button onClick={() => locateToMessage(message)} icon={<Forward size={16} />}>
|
||||
{t('history.locate.message')}
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
@@ -2,11 +2,11 @@ import { MessageOutlined } from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import SearchPopup from '@renderer/components/Popups/SearchPopup'
|
||||
import { MessageEditingProvider } from '@renderer/context/MessageEditingContext'
|
||||
import { useChat } from '@renderer/hooks/useChat'
|
||||
import useScrollPosition from '@renderer/hooks/useScrollPosition'
|
||||
import { getAssistantById } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { isGenerating, locateToMessage } from '@renderer/services/MessagesService'
|
||||
import NavigationService from '@renderer/services/NavigationService'
|
||||
import { locateToMessage } from '@renderer/services/MessagesService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
|
||||
import { Topic } from '@renderer/types'
|
||||
@@ -23,9 +23,9 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
}
|
||||
|
||||
const TopicMessages: FC<Props> = ({ topic, ...props }) => {
|
||||
const navigate = NavigationService.navigate!
|
||||
const { handleScroll, containerRef } = useScrollPosition('TopicMessages')
|
||||
const dispatch = useAppDispatch()
|
||||
const { setActiveAssistant, setActiveTopic } = useChat()
|
||||
|
||||
useEffect(() => {
|
||||
topic && dispatch(loadTopicMessagesThunk(topic.id))
|
||||
@@ -38,11 +38,13 @@ const TopicMessages: FC<Props> = ({ topic, ...props }) => {
|
||||
}
|
||||
|
||||
const onContinueChat = async (topic: Topic) => {
|
||||
await isGenerating()
|
||||
SearchPopup.hide()
|
||||
const assistant = getAssistantById(topic.assistantId)
|
||||
navigate('/', { state: { assistant, topic } })
|
||||
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 100)
|
||||
if (assistant) {
|
||||
setActiveAssistant(assistant)
|
||||
setActiveTopic(topic)
|
||||
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 100)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -56,7 +58,7 @@ const TopicMessages: FC<Props> = ({ topic, ...props }) => {
|
||||
type="text"
|
||||
size="middle"
|
||||
style={{ color: 'var(--color-text-3)', position: 'absolute', right: 0, top: 5 }}
|
||||
onClick={() => locateToMessage(navigate, message)}
|
||||
onClick={() => locateToMessage(message)}
|
||||
icon={<Forward size={16} />}
|
||||
/>
|
||||
<Divider style={{ margin: '8px auto 15px' }} variant="dashed" />
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { SearchOutlined } from '@ant-design/icons'
|
||||
import { VStack } from '@renderer/components/Layout'
|
||||
import { useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import useScrollPosition from '@renderer/hooks/useScrollPosition'
|
||||
import { getTopicById } from '@renderer/hooks/useTopic'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { selectActiveAssistants } from '@renderer/store/assistants'
|
||||
import { Topic } from '@renderer/types'
|
||||
import { Button, Divider, Empty, Segmented } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -20,7 +21,7 @@ type Props = {
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props }) => {
|
||||
const { assistants } = useAssistants()
|
||||
const assistants = useAppSelector(selectActiveAssistants)
|
||||
const { t } = useTranslation()
|
||||
const { handleScroll, containerRef } = useScrollPosition('TopicsHistory')
|
||||
const [sortType, setSortType] = useState<SortType>('createdAt')
|
||||
@@ -35,6 +36,15 @@ const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props
|
||||
return dayjs(topic[sortType]).format('MM/DD')
|
||||
})
|
||||
|
||||
// 创建助手映射表
|
||||
const assistantMap = assistants.reduce(
|
||||
(map, assistant) => {
|
||||
map[assistant.id] = assistant
|
||||
return map
|
||||
},
|
||||
{} as Record<string, any>
|
||||
)
|
||||
|
||||
if (isEmpty(filteredTopics)) {
|
||||
return (
|
||||
<ListContainer {...props}>
|
||||
@@ -65,17 +75,27 @@ const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props
|
||||
<ListItem key={date}>
|
||||
<Date>{date}</Date>
|
||||
<Divider style={{ margin: '5px 0' }} />
|
||||
{items.map((topic) => (
|
||||
<TopicItem
|
||||
key={topic.id}
|
||||
onClick={async () => {
|
||||
const _topic = await getTopicById(topic.id)
|
||||
onClick(_topic)
|
||||
}}>
|
||||
<TopicName>{topic.name.substring(0, 50)}</TopicName>
|
||||
<TopicDate>{dayjs(topic[sortType]).format('HH:mm')}</TopicDate>
|
||||
</TopicItem>
|
||||
))}
|
||||
{items.map((topic) => {
|
||||
const assistant = assistantMap[topic.assistantId]
|
||||
return (
|
||||
<TopicItem
|
||||
key={topic.id}
|
||||
onClick={async () => {
|
||||
const _topic = await getTopicById(topic.id)
|
||||
onClick(_topic)
|
||||
}}>
|
||||
<TopicContent>
|
||||
<TopicName>{topic.name.substring(0, 50)}</TopicName>
|
||||
{assistant && (
|
||||
<AssistantTag>
|
||||
{assistant.emoji} {assistant.name}
|
||||
</AssistantTag>
|
||||
)}
|
||||
</TopicContent>
|
||||
<TopicDate>{dayjs(topic[sortType]).format('HH:mm')}</TopicDate>
|
||||
</TopicItem>
|
||||
)
|
||||
})}
|
||||
</ListItem>
|
||||
))}
|
||||
{keywords.length >= 2 && (
|
||||
@@ -127,7 +147,15 @@ const TopicItem = styled.div`
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 30px;
|
||||
min-height: 30px;
|
||||
padding: 4px 0;
|
||||
`
|
||||
|
||||
const TopicContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
const TopicName = styled.div`
|
||||
@@ -135,10 +163,21 @@ const TopicName = styled.div`
|
||||
color: var(--color-text);
|
||||
`
|
||||
|
||||
const AssistantTag = styled.div`
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
background: var(--color-fill-quaternary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
`
|
||||
|
||||
const TopicDate = styled.div`
|
||||
font-size: 14px;
|
||||
color: var(--color-text-3);
|
||||
margin-left: 10px;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
export default TopicsHistory
|
||||
|
||||
@@ -1,47 +1,29 @@
|
||||
import { ContentSearch, ContentSearchRef } from '@renderer/components/ContentSearch'
|
||||
import MultiSelectActionPopup from '@renderer/components/Popups/MultiSelectionPopup'
|
||||
import { QuickPanelProvider } from '@renderer/components/QuickPanel'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useChat } from '@renderer/hooks/useChat'
|
||||
import { useChatContext } from '@renderer/hooks/useChatContext'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShortcut } from '@renderer/hooks/useShortcuts'
|
||||
import { useShowTopics } from '@renderer/hooks/useStore'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Flex } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
import React, { FC, useMemo, useState } from 'react'
|
||||
import React, { FC, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Inputbar from './Inputbar/Inputbar'
|
||||
import Messages from './Messages/Messages'
|
||||
import Tabs from './Tabs'
|
||||
|
||||
interface Props {
|
||||
assistant: Assistant
|
||||
activeTopic: Topic
|
||||
setActiveTopic: (topic: Topic) => void
|
||||
setActiveAssistant: (assistant: Assistant) => void
|
||||
}
|
||||
|
||||
const Chat: FC<Props> = (props) => {
|
||||
const { assistant } = useAssistant(props.assistant.id)
|
||||
const { topicPosition, messageStyle, showAssistants } = useSettings()
|
||||
const { showTopics } = useShowTopics()
|
||||
const { isMultiSelectMode } = useChatContext(props.activeTopic)
|
||||
const Chat: FC = () => {
|
||||
const { activeAssistant, activeTopic, setActiveTopic } = useChat()
|
||||
const { messageStyle } = useSettings()
|
||||
const { isMultiSelectMode } = useChatContext(activeTopic)
|
||||
|
||||
const mainRef = React.useRef<HTMLDivElement>(null)
|
||||
const contentSearchRef = React.useRef<ContentSearchRef>(null)
|
||||
const [filterIncludeUser, setFilterIncludeUser] = useState(false)
|
||||
|
||||
const maxWidth = useMemo(() => {
|
||||
const showRightTopics = showTopics && topicPosition === 'right'
|
||||
const minusAssistantsWidth = showAssistants ? '- var(--assistants-width)' : ''
|
||||
const minusRightTopicsWidth = showRightTopics ? '- var(--assistants-width)' : ''
|
||||
return `calc(100vw - var(--sidebar-width) ${minusAssistantsWidth} ${minusRightTopicsWidth})`
|
||||
}, [showAssistants, showTopics, topicPosition])
|
||||
|
||||
useHotkeys('esc', () => {
|
||||
contentSearchRef.current?.disable()
|
||||
})
|
||||
@@ -100,48 +82,36 @@ const Chat: FC<Props> = (props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container id="chat" className={classNames([messageStyle, { 'multi-select-mode': isMultiSelectMode }])}>
|
||||
<Main ref={mainRef} id="chat-main" vertical flex={1} justify="space-between" style={{ maxWidth }}>
|
||||
<Messages
|
||||
key={props.activeTopic.id}
|
||||
assistant={assistant}
|
||||
topic={props.activeTopic}
|
||||
setActiveTopic={props.setActiveTopic}
|
||||
onComponentUpdate={messagesComponentUpdateHandler}
|
||||
onFirstUpdate={messagesComponentFirstUpdateHandler}
|
||||
/>
|
||||
<ContentSearch
|
||||
ref={contentSearchRef}
|
||||
searchTarget={mainRef as React.RefObject<HTMLElement>}
|
||||
filter={contentSearchFilter}
|
||||
includeUser={filterIncludeUser}
|
||||
onIncludeUserChange={userOutlinedItemClickHandler}
|
||||
/>
|
||||
<QuickPanelProvider>
|
||||
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
|
||||
{isMultiSelectMode && <MultiSelectActionPopup topic={props.activeTopic} />}
|
||||
</QuickPanelProvider>
|
||||
</Main>
|
||||
{topicPosition === 'right' && showTopics && (
|
||||
<Tabs
|
||||
activeAssistant={assistant}
|
||||
activeTopic={props.activeTopic}
|
||||
setActiveAssistant={props.setActiveAssistant}
|
||||
setActiveTopic={props.setActiveTopic}
|
||||
position="right"
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
<Main
|
||||
ref={mainRef}
|
||||
id="chat-main"
|
||||
className={classNames([messageStyle, { 'multi-select-mode': isMultiSelectMode }])}
|
||||
vertical
|
||||
flex={1}
|
||||
justify="space-between">
|
||||
<Messages
|
||||
key={activeTopic.id}
|
||||
assistant={activeAssistant}
|
||||
topic={activeTopic}
|
||||
setActiveTopic={setActiveTopic}
|
||||
onComponentUpdate={messagesComponentUpdateHandler}
|
||||
onFirstUpdate={messagesComponentFirstUpdateHandler}
|
||||
/>
|
||||
<ContentSearch
|
||||
ref={contentSearchRef}
|
||||
searchTarget={mainRef as React.RefObject<HTMLElement>}
|
||||
filter={contentSearchFilter}
|
||||
includeUser={filterIncludeUser}
|
||||
onIncludeUserChange={userOutlinedItemClickHandler}
|
||||
/>
|
||||
<QuickPanelProvider>
|
||||
<Inputbar />
|
||||
{isMultiSelectMode && <MultiSelectActionPopup topic={activeTopic} />}
|
||||
</QuickPanelProvider>
|
||||
</Main>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
const Main = styled(Flex)`
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
transform: translateZ(0);
|
||||
|
||||
91
src/renderer/src/pages/home/ChatNavbar.tsx
Normal file
91
src/renderer/src/pages/home/ChatNavbar.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { NavbarMain } from '@renderer/components/app/Navbar'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import SearchPopup from '@renderer/components/Popups/SearchPopup'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useChat } from '@renderer/hooks/useChat'
|
||||
import { useShortcut } from '@renderer/hooks/useShortcuts'
|
||||
import { useShowAssistants } from '@renderer/hooks/useStore'
|
||||
import { Tooltip } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { PanelLeft, PanelRight, Search } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import SelectModelButton from './components/SelectModelButton'
|
||||
import UpdateAppButton from './components/UpdateAppButton'
|
||||
|
||||
const ChatNavbar: FC = () => {
|
||||
const { activeAssistant } = useChat()
|
||||
const { assistant } = useAssistant(activeAssistant.id)
|
||||
const { showAssistants, toggleShowAssistants } = useShowAssistants()
|
||||
|
||||
useShortcut('search_message', SearchPopup.show)
|
||||
|
||||
return (
|
||||
<NavbarMain className="home-navbar" style={{ minHeight: 50 }}>
|
||||
<HStack alignItems="center" gap={8}>
|
||||
<NavbarIcon onClick={() => toggleShowAssistants()}>
|
||||
{showAssistants ? <PanelLeft size={18} /> : <PanelRight size={18} />}
|
||||
</NavbarIcon>
|
||||
<SelectModelButton assistant={assistant} />
|
||||
</HStack>
|
||||
<HStack alignItems="center" gap={8}>
|
||||
<UpdateAppButton />
|
||||
{isMac && (
|
||||
<Tooltip title={t('chat.assistant.search.placeholder')} mouseEnterDelay={0.8}>
|
||||
<NarrowIcon onClick={() => SearchPopup.show()}>
|
||||
<Search size={18} />
|
||||
</NarrowIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</HStack>
|
||||
</NavbarMain>
|
||||
)
|
||||
}
|
||||
|
||||
export const NavbarIcon = styled.div`
|
||||
-webkit-app-region: none;
|
||||
border-radius: 8px;
|
||||
height: 30px;
|
||||
padding: 0 7px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: all 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
.iconfont {
|
||||
font-size: 18px;
|
||||
color: var(--color-icon);
|
||||
&.icon-a-addchat {
|
||||
font-size: 20px;
|
||||
}
|
||||
&.icon-a-darkmode {
|
||||
font-size: 20px;
|
||||
}
|
||||
&.icon-appstore {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
.anticon {
|
||||
color: var(--color-icon);
|
||||
font-size: 16px;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--color-background-mute);
|
||||
color: var(--color-icon-white);
|
||||
}
|
||||
&.active {
|
||||
background-color: var(--color-background-mute);
|
||||
color: var(--color-icon-white);
|
||||
}
|
||||
`
|
||||
|
||||
const NarrowIcon = styled(NavbarIcon)`
|
||||
@media (max-width: 1000px) {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
|
||||
export default ChatNavbar
|
||||
@@ -1,58 +1,15 @@
|
||||
import { useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useActiveTopic } from '@renderer/hooks/useTopic'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import NavigationService from '@renderer/services/NavigationService'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { FC, useEffect } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Chat from './Chat'
|
||||
import Navbar from './Navbar'
|
||||
import HomeTabs from './Tabs'
|
||||
import ChatNavbar from './ChatNavbar'
|
||||
|
||||
let _activeAssistant: Assistant
|
||||
|
||||
const HomePage: FC = () => {
|
||||
const { assistants } = useAssistants()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const location = useLocation()
|
||||
const state = location.state
|
||||
|
||||
const [activeAssistant, setActiveAssistant] = useState(state?.assistant || _activeAssistant || assistants[0])
|
||||
const { activeTopic, setActiveTopic } = useActiveTopic(activeAssistant, state?.topic)
|
||||
const HomePage: FC<{ style?: React.CSSProperties }> = ({ style }) => {
|
||||
const { showAssistants, showTopics, topicPosition } = useSettings()
|
||||
|
||||
_activeAssistant = activeAssistant
|
||||
|
||||
useEffect(() => {
|
||||
NavigationService.setNavigate(navigate)
|
||||
}, [navigate])
|
||||
|
||||
useEffect(() => {
|
||||
state?.assistant && setActiveAssistant(state?.assistant)
|
||||
state?.topic && setActiveTopic(state?.topic)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = EventEmitter.on(EVENT_NAMES.SWITCH_ASSISTANT, (assistantId: string) => {
|
||||
const newAssistant = assistants.find((a) => a.id === assistantId)
|
||||
if (newAssistant) {
|
||||
setActiveAssistant(newAssistant)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
}
|
||||
}, [assistants, setActiveAssistant])
|
||||
|
||||
useEffect(() => {
|
||||
const canMinimize = topicPosition == 'left' ? !showAssistants : !showAssistants && !showTopics
|
||||
window.api.window.setMinimumSize(canMinimize ? 520 : 1080, 600)
|
||||
window.api.window.setMinimumSize(showAssistants ? 1080 : 520, 600)
|
||||
|
||||
return () => {
|
||||
window.api.window.resetMinimumSize()
|
||||
@@ -60,47 +17,22 @@ const HomePage: FC = () => {
|
||||
}, [showAssistants, showTopics, topicPosition])
|
||||
|
||||
return (
|
||||
<Container id="home-page">
|
||||
<Navbar
|
||||
activeAssistant={activeAssistant}
|
||||
activeTopic={activeTopic}
|
||||
setActiveTopic={setActiveTopic}
|
||||
setActiveAssistant={setActiveAssistant}
|
||||
position="left"
|
||||
/>
|
||||
<Container style={style}>
|
||||
<ChatNavbar />
|
||||
<ContentContainer id="content-container">
|
||||
{showAssistants && (
|
||||
<HomeTabs
|
||||
activeAssistant={activeAssistant}
|
||||
activeTopic={activeTopic}
|
||||
setActiveAssistant={setActiveAssistant}
|
||||
setActiveTopic={setActiveTopic}
|
||||
position="left"
|
||||
/>
|
||||
)}
|
||||
<Chat
|
||||
assistant={activeAssistant}
|
||||
activeTopic={activeTopic}
|
||||
setActiveTopic={setActiveTopic}
|
||||
setActiveAssistant={setActiveAssistant}
|
||||
/>
|
||||
<Chat />
|
||||
</ContentContainer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
max-width: calc(100vw - var(--sidebar-width));
|
||||
`
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
`
|
||||
const ContentContainer = styled.div``
|
||||
|
||||
export default HomePage
|
||||
|
||||
@@ -24,7 +24,7 @@ const GenerateImageButton: FC<Props> = ({ model, ToolbarButton, assistant, onEna
|
||||
mouseLeaveDelay={0}
|
||||
arrow>
|
||||
<ToolbarButton type="text" disabled={!isGenerateImageModel(model)} onClick={onEnableGenerateImage}>
|
||||
<Image size={18} color={assistant.enableGenerateImage ? 'var(--color-link)' : 'var(--color-icon)'} />
|
||||
<Image size={18} color={assistant.enableGenerateImage ? 'var(--color-primary)' : 'var(--color-icon)'} />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
@@ -14,9 +14,10 @@ import {
|
||||
} from '@renderer/config/models'
|
||||
import db from '@renderer/databases'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useChat } from '@renderer/hooks/useChat'
|
||||
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
||||
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
|
||||
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShortcut, useShortcutDisplay } from '@renderer/hooks/useShortcuts'
|
||||
import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
|
||||
@@ -32,7 +33,7 @@ import WebSearchService from '@renderer/services/WebSearchService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setSearching } from '@renderer/store/runtime'
|
||||
import { sendMessage as _sendMessage } from '@renderer/store/thunk/messageThunk'
|
||||
import { Assistant, FileType, FileTypes, KnowledgeBase, KnowledgeItem, Model, Topic } from '@renderer/types'
|
||||
import { FileType, FileTypes, KnowledgeBase, KnowledgeItem, Model } from '@renderer/types'
|
||||
import type { MessageInputBaseParams } from '@renderer/types/newMessage'
|
||||
import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils'
|
||||
import { formatQuotedText } from '@renderer/utils/formats'
|
||||
@@ -55,21 +56,17 @@ import InputbarTools, { InputbarToolsRef } from './InputbarTools'
|
||||
import KnowledgeBaseInput from './KnowledgeBaseInput'
|
||||
import MentionModelsInput from './MentionModelsInput'
|
||||
import SendMessageButton from './SendMessageButton'
|
||||
import SettingButton from './SettingButton'
|
||||
import TokenCount from './TokenCount'
|
||||
|
||||
interface Props {
|
||||
assistant: Assistant
|
||||
setActiveTopic: (topic: Topic) => void
|
||||
topic: Topic
|
||||
}
|
||||
|
||||
let _text = ''
|
||||
let _files: FileType[] = []
|
||||
|
||||
const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) => {
|
||||
const Inputbar: FC = () => {
|
||||
const { activeAssistant, activeTopic: topic, setActiveTopic } = useChat()
|
||||
const [text, setText] = useState(_text)
|
||||
const [inputFocus, setInputFocus] = useState(false)
|
||||
const { assistant, addTopic, model, setModel, updateAssistant } = useAssistant(_assistant.id)
|
||||
const { assistant, addTopic, model, setModel, updateAssistant } = useAssistant(activeAssistant.id)
|
||||
const {
|
||||
targetLanguage,
|
||||
sendMessageShortcut,
|
||||
@@ -432,8 +429,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
}
|
||||
|
||||
const addNewTopic = useCallback(async () => {
|
||||
await modelGenerating()
|
||||
|
||||
const topic = getDefaultTopic(assistant.id)
|
||||
|
||||
await db.topics.add({ id: topic.id, messages: [] })
|
||||
@@ -662,11 +657,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
useEffect(() => {
|
||||
const _setEstimateTokenCount = debounce(setEstimateTokenCount, 100, { leading: false, trailing: true })
|
||||
const unsubscribes = [
|
||||
// EventEmitter.on(EVENT_NAMES.EDIT_MESSAGE, (message: Message) => {
|
||||
// setText(message.content)
|
||||
// textareaRef.current?.focus()
|
||||
// setTimeout(() => resizeTextArea(), 0)
|
||||
// }),
|
||||
EventEmitter.on(EVENT_NAMES.ESTIMATED_TOKEN_COUNT, ({ tokensCount, contextCount }) => {
|
||||
_setEstimateTokenCount(tokensCount)
|
||||
setContextCount({ current: contextCount.current, max: contextCount.max }) // 现在contextCount是一个对象而不是单个数值
|
||||
@@ -791,12 +781,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
} else {
|
||||
textArea.style.height = 'auto'
|
||||
setTextareaHeight(undefined)
|
||||
requestAnimationFrame(() => {
|
||||
if (textArea) {
|
||||
const contentHeight = textArea.scrollHeight
|
||||
textArea.style.height = contentHeight > 400 ? '400px' : `${contentHeight}px`
|
||||
}
|
||||
})
|
||||
setTimeout(() => resizeTextArea(true), 0)
|
||||
}
|
||||
|
||||
textareaRef.current?.focus()
|
||||
@@ -907,11 +892,12 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
ToolbarButton={ToolbarButton}
|
||||
onClick={onNewContext}
|
||||
/>
|
||||
<SettingButton assistant={assistant} ToolbarButton={ToolbarButton} />
|
||||
<TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} />
|
||||
{loading && (
|
||||
<Tooltip placement="top" title={t('chat.input.pause')} mouseLeaveDelay={0} arrow>
|
||||
<ToolbarButton type="text" onClick={onPause} style={{ marginRight: -2, marginTop: 1 }}>
|
||||
<CirclePause style={{ color: 'var(--color-error)', fontSize: 20 }} />
|
||||
<Tooltip placement="top" title={t('chat.input.pause')} mouseEnterDelay={0} arrow>
|
||||
<ToolbarButton type="text" onClick={onPause} style={{ width: 30, height: 30, marginRight: 2 }}>
|
||||
<CirclePause style={{ color: 'var(--color-error)' }} size={28} />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -959,9 +945,10 @@ const Container = styled.div`
|
||||
`
|
||||
|
||||
const InputBarContainer = styled.div`
|
||||
border: 0.5px solid var(--color-border);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
margin: 16px 20px;
|
||||
border-radius: 20px;
|
||||
padding-top: 8px; // 为拖动手柄留出空间
|
||||
background-color: var(--color-background-opacity);
|
||||
@@ -1001,6 +988,9 @@ const Textarea = styled(TextArea)`
|
||||
&.ant-input {
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ant-input-textarea-show-count::after {
|
||||
transition: none !important;
|
||||
}
|
||||
&::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, Toolbar
|
||||
newList.push({
|
||||
label: t('settings.mcp.addServer') + '...',
|
||||
icon: <Plus />,
|
||||
action: () => navigate('/settings/mcp')
|
||||
action: () => navigate('/mcp-servers')
|
||||
})
|
||||
|
||||
newList.unshift({
|
||||
|
||||
@@ -13,7 +13,7 @@ const SendMessageButton: FC<Props> = ({ disabled, sendMessage }) => {
|
||||
style={{
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
color: disabled ? 'var(--color-text-3)' : 'var(--color-primary)',
|
||||
fontSize: 22,
|
||||
fontSize: 30,
|
||||
transition: 'all 0.2s',
|
||||
marginRight: 2
|
||||
}}
|
||||
|
||||
32
src/renderer/src/pages/home/Inputbar/SettingButton.tsx
Normal file
32
src/renderer/src/pages/home/Inputbar/SettingButton.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { Popover } from 'antd'
|
||||
import { SlidersHorizontal } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
|
||||
import SettingsTab from '../Tabs/SettingsTab'
|
||||
|
||||
interface Props {
|
||||
assistant: Assistant
|
||||
ToolbarButton: any
|
||||
}
|
||||
|
||||
const SettingButton: FC<Props> = ({ ToolbarButton }) => {
|
||||
return (
|
||||
<Popover
|
||||
arrow={false}
|
||||
placement="topLeft"
|
||||
content={<SettingsTab />}
|
||||
trigger="click"
|
||||
styles={{
|
||||
body: {
|
||||
padding: '4px 2px 4px 2px'
|
||||
}
|
||||
}}>
|
||||
<ToolbarButton type="text">
|
||||
<SlidersHorizontal size={16} />
|
||||
</ToolbarButton>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingButton
|
||||
@@ -107,7 +107,7 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
|
||||
}, [currentReasoningEffort, supportedOptions, updateAssistantSettings, model.id])
|
||||
|
||||
const createThinkingIcon = useCallback((option?: ThinkingOption, isActive: boolean = false) => {
|
||||
const iconColor = isActive ? 'var(--color-link)' : 'var(--color-icon)'
|
||||
const iconColor = isActive ? 'var(--color-primary)' : 'var(--color-icon)'
|
||||
|
||||
switch (true) {
|
||||
case option === 'low':
|
||||
|
||||
@@ -136,7 +136,7 @@ const WebSearchButton: FC<Props> = ({ ref, assistant, ToolbarButton }) => {
|
||||
<Globe
|
||||
size={18}
|
||||
style={{
|
||||
color: enableWebSearch ? 'var(--color-link)' : 'var(--color-icon)'
|
||||
color: enableWebSearch ? 'var(--color-primary)' : 'var(--color-icon)'
|
||||
}}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
|
||||
64
src/renderer/src/pages/home/MainSidebar/MainNavbar.tsx
Normal file
64
src/renderer/src/pages/home/MainSidebar/MainNavbar.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { PanelLeftIcon } from '@renderer/components/Icons/PanelIcons'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useShowAssistants } from '@renderer/hooks/useStore'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { Tooltip } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { MessageSquareDiff } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {}
|
||||
|
||||
const HeaderNavbar: FC<Props> = () => {
|
||||
const { showAssistants, toggleShowAssistants } = useShowAssistants()
|
||||
return (
|
||||
<Container>
|
||||
{showAssistants && (
|
||||
<NavbarIcon onClick={() => toggleShowAssistants()}>
|
||||
<PanelLeftIcon size={18} expanded={true} />
|
||||
</NavbarIcon>
|
||||
)}
|
||||
<Tooltip title={t('settings.shortcuts.new_topic')} mouseEnterDelay={0.8}>
|
||||
<NavbarIcon onClick={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}>
|
||||
<MessageSquareDiff size={18} />
|
||||
</NavbarIcon>
|
||||
</Tooltip>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
width: var(--assistant-width);
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
height: var(--navbar-height);
|
||||
min-height: var(--navbar-height);
|
||||
background-color: transparent;
|
||||
-webkit-app-region: drag;
|
||||
padding: 0 15px;
|
||||
padding-left: ${isMac ? '75px' : '15px'};
|
||||
`
|
||||
|
||||
export const NavbarIcon = styled.div`
|
||||
-webkit-app-region: none;
|
||||
border-radius: 8px;
|
||||
height: 30px;
|
||||
padding: 0 7px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: all 0.2s ease-in-out;
|
||||
-webkit-app-region: no-drag;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: var(--color-list-item);
|
||||
color: var(--color-icon-white);
|
||||
}
|
||||
`
|
||||
|
||||
export default HeaderNavbar
|
||||
405
src/renderer/src/pages/home/MainSidebar/MainSidebar.tsx
Normal file
405
src/renderer/src/pages/home/MainSidebar/MainSidebar.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
import EmojiAvatar from '@renderer/components/Avatar/EmojiAvatar'
|
||||
import UserPopup from '@renderer/components/Popups/UserPopup'
|
||||
import { AppLogo, UserAvatar } from '@renderer/config/env'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import useAvatar from '@renderer/hooks/useAvatar'
|
||||
import { useChat } from '@renderer/hooks/useChat'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShortcut } from '@renderer/hooks/useShortcuts'
|
||||
import { useShowAssistants } from '@renderer/hooks/useStore'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { getAssistantById } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { Assistant, ThemeMode } from '@renderer/types'
|
||||
import { isEmoji } from '@renderer/utils'
|
||||
import { Avatar, Dropdown } from 'antd'
|
||||
import {
|
||||
Blocks,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
EllipsisVertical,
|
||||
FileSearch,
|
||||
Folder,
|
||||
Languages,
|
||||
LayoutGrid,
|
||||
Moon,
|
||||
Palette,
|
||||
Settings,
|
||||
Sparkle,
|
||||
SquareTerminal,
|
||||
Sun,
|
||||
SunMoon
|
||||
} from 'lucide-react'
|
||||
import { FC, useDeferredValue, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import AssistantsTab from '../Tabs/AssistantsTab'
|
||||
import AssistantItem from '../Tabs/components/AssistantItem'
|
||||
import TopicsTab from '../Tabs/TopicsTab'
|
||||
import {
|
||||
Container,
|
||||
MainMenu,
|
||||
MainMenuItem,
|
||||
MainMenuItemIcon,
|
||||
MainMenuItemLeft,
|
||||
MainMenuItemRight,
|
||||
MainMenuItemText,
|
||||
SubMenu
|
||||
} from './MainSidebarStyles'
|
||||
import OpenedMinappTabs from './OpenedMinapps'
|
||||
import SidebarSearch from './SidebarSearch'
|
||||
|
||||
type Tab = 'assistants' | 'topic'
|
||||
|
||||
const MainSidebar: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const [tab, setTab] = useState<Tab>('assistants')
|
||||
const avatar = useAvatar()
|
||||
const { userName, defaultPaintingProvider, transparentWindow } = useSettings()
|
||||
const { t } = useTranslation()
|
||||
const { theme, settedTheme, toggleTheme } = useTheme()
|
||||
const [isAppMenuExpanded, setIsAppMenuExpanded] = useState(false)
|
||||
const { showAssistants, toggleShowAssistants } = useShowAssistants()
|
||||
|
||||
const location = useLocation()
|
||||
const { pathname } = location
|
||||
|
||||
const { activeAssistant, activeTopic, setActiveAssistant } = useChat()
|
||||
const { showTopics, clickAssistantToShowTopic } = useSettings()
|
||||
|
||||
const { openMinapp } = useMinappPopup()
|
||||
|
||||
const [_searchValue, setSearchValue] = useState('')
|
||||
const searchValue = useDeferredValue(_searchValue)
|
||||
|
||||
useShortcut('toggle_show_assistants', toggleShowAssistants)
|
||||
useShortcut('toggle_show_topics', () => EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR))
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = [
|
||||
EventEmitter.on(EVENT_NAMES.SHOW_TOPIC_SIDEBAR, (assistant: Assistant) => {
|
||||
if (clickAssistantToShowTopic) {
|
||||
setTab('topic')
|
||||
} else {
|
||||
if (activeAssistant.id === assistant.id) {
|
||||
setTab('topic')
|
||||
}
|
||||
}
|
||||
}),
|
||||
EventEmitter.on(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR, () => {
|
||||
setTab(tab === 'topic' ? 'assistants' : 'topic')
|
||||
!showAssistants && toggleShowAssistants()
|
||||
})
|
||||
]
|
||||
return () => unsubscribe.forEach((unsubscribe) => unsubscribe())
|
||||
}, [
|
||||
activeAssistant?.id,
|
||||
activeTopic?.assistantId,
|
||||
clickAssistantToShowTopic,
|
||||
isAppMenuExpanded,
|
||||
showAssistants,
|
||||
tab,
|
||||
toggleShowAssistants
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribes = [
|
||||
EventEmitter.on(EVENT_NAMES.SWITCH_ASSISTANT, (assistantId: string) => {
|
||||
const newAssistant = getAssistantById(assistantId)
|
||||
if (newAssistant) {
|
||||
setActiveAssistant(newAssistant)
|
||||
}
|
||||
}),
|
||||
EventEmitter.on(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR, () => setTab(tab === 'topic' ? 'assistants' : 'topic')),
|
||||
EventEmitter.on(EVENT_NAMES.OPEN_MINAPP, () => {
|
||||
setTimeout(() => setIsAppMenuExpanded(false), 1000)
|
||||
})
|
||||
]
|
||||
|
||||
return () => unsubscribes.forEach((unsubscribe) => unsubscribe())
|
||||
}, [setActiveAssistant, tab])
|
||||
|
||||
useEffect(() => {
|
||||
const canMinimize = !showAssistants && !showTopics
|
||||
window.api.window.setMinimumSize(canMinimize ? 520 : 1080, 600)
|
||||
|
||||
return () => {
|
||||
window.api.window.resetMinimumSize()
|
||||
}
|
||||
}, [showAssistants, showTopics])
|
||||
|
||||
useEffect(() => {
|
||||
setIsAppMenuExpanded(false)
|
||||
}, [activeAssistant.id, activeTopic.id])
|
||||
|
||||
const appMenuItems = [
|
||||
{ icon: <Sparkle size={18} className="icon" />, text: t('agents.title'), path: '/agents' },
|
||||
{ icon: <Languages size={18} className="icon" />, text: t('translate.title'), path: '/translate' },
|
||||
{
|
||||
icon: <Palette size={18} className="icon" />,
|
||||
text: t('paintings.title'),
|
||||
path: `/paintings/${defaultPaintingProvider}`
|
||||
},
|
||||
{ icon: <LayoutGrid size={18} className="icon" />, text: t('minapp.title'), path: '/apps' },
|
||||
{ icon: <FileSearch size={18} className="icon" />, text: t('knowledge.title'), path: '/knowledge' },
|
||||
{ icon: <SquareTerminal size={18} className="icon" />, text: t('settings.mcp.title'), path: '/mcp-servers' },
|
||||
{ icon: <Folder size={18} className="icon" />, text: t('files.title'), path: '/files' }
|
||||
]
|
||||
|
||||
const isRoutes = (path: string): boolean => pathname.startsWith(path)
|
||||
|
||||
const docsId = 'cherrystudio-docs'
|
||||
const onOpenDocs = () => {
|
||||
const isChinese = i18n.language.startsWith('zh')
|
||||
openMinapp({
|
||||
id: docsId,
|
||||
name: t('docs.title'),
|
||||
url: isChinese ? 'https://docs.cherry-ai.com/' : 'https://docs.cherry-ai.com/cherry-studio-wen-dang/en-us',
|
||||
logo: AppLogo
|
||||
})
|
||||
}
|
||||
|
||||
if (!showAssistants) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
id="main-sidebar"
|
||||
transparent={transparentWindow}
|
||||
style={{
|
||||
width: showAssistants ? 'var(--assistants-width)' : '0px',
|
||||
opacity: showAssistants ? 1 : 0,
|
||||
overflow: showAssistants ? 'initial' : 'hidden'
|
||||
}}>
|
||||
<MainMenu>
|
||||
<SidebarSearch onSearch={setSearchValue} />
|
||||
<MainMenuItem active={isAppMenuExpanded} onClick={() => setIsAppMenuExpanded(!isAppMenuExpanded)}>
|
||||
<MainMenuItemLeft>
|
||||
<MainMenuItemIcon>
|
||||
<Blocks size={19} className="icon" />
|
||||
</MainMenuItemIcon>
|
||||
<MainMenuItemText>{isAppMenuExpanded ? t('common.collapse') : t('common.apps')}</MainMenuItemText>
|
||||
</MainMenuItemLeft>
|
||||
<MainMenuItemRight>
|
||||
{isAppMenuExpanded ? (
|
||||
<ChevronDown size={18} color="var(--color-text-3)" />
|
||||
) : (
|
||||
<ChevronRight size={18} color="var(--color-text-3)" />
|
||||
)}
|
||||
</MainMenuItemRight>
|
||||
</MainMenuItem>
|
||||
{isAppMenuExpanded && (
|
||||
<SubMenu>
|
||||
{appMenuItems.map((item) => (
|
||||
<MainMenuItem key={item.path} active={isRoutes(item.path)} onClick={() => navigate(item.path)}>
|
||||
<MainMenuItemLeft>
|
||||
<MainMenuItemIcon>{item.icon}</MainMenuItemIcon>
|
||||
<MainMenuItemText>{item.text}</MainMenuItemText>
|
||||
</MainMenuItemLeft>
|
||||
</MainMenuItem>
|
||||
))}
|
||||
</SubMenu>
|
||||
)}
|
||||
<OpenedMinappTabs />
|
||||
</MainMenu>
|
||||
{tab === 'topic' && (
|
||||
<AssistantContainer onClick={() => setIsAppMenuExpanded(false)}>
|
||||
<AssistantItem
|
||||
key={activeAssistant.id}
|
||||
assistant={activeAssistant}
|
||||
isActive={false}
|
||||
sortBy="list"
|
||||
onSwitch={() => {}}
|
||||
onDelete={() => {}}
|
||||
addAssistant={() => {}}
|
||||
onCreateDefaultAssistant={() => {}}
|
||||
handleSortByChange={() => {}}
|
||||
singleLine
|
||||
/>
|
||||
</AssistantContainer>
|
||||
)}
|
||||
<MainContainer>
|
||||
{tab === 'assistants' && <AssistantsTab searchValue={searchValue} />}
|
||||
{tab === 'topic' && <TopicsTab searchValue={searchValue} style={{ paddingTop: 4 }} />}
|
||||
</MainContainer>
|
||||
<UserMenu>
|
||||
<UserMenuLeft onClick={() => UserPopup.show()}>
|
||||
{isEmoji(avatar) ? (
|
||||
<EmojiAvatar className="sidebar-avatar" size={31} fontSize={18}>
|
||||
{avatar}
|
||||
</EmojiAvatar>
|
||||
) : (
|
||||
<AvatarImg src={avatar || UserAvatar} draggable={false} className="nodrag" />
|
||||
)}
|
||||
{userName && <UserMenuText>{userName}</UserMenuText>}
|
||||
</UserMenuLeft>
|
||||
<Dropdown
|
||||
placement="topRight"
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'theme',
|
||||
label: (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleTheme()
|
||||
}}>
|
||||
{t('settings.theme.title')}: {t(`settings.theme.${settedTheme}`)}
|
||||
</span>
|
||||
),
|
||||
icon: ThemeIcon()
|
||||
},
|
||||
{
|
||||
key: 'about',
|
||||
label: t('docs.title'),
|
||||
icon: <CircleHelp size={16} className="icon" />,
|
||||
onClick: onOpenDocs
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: t('settings.title'),
|
||||
icon: <Settings size={16} className="icon" />,
|
||||
onClick: () => window.api.showSettingsWindow({ defaultTab: 'provider' })
|
||||
}
|
||||
]
|
||||
}}>
|
||||
<Icon theme={theme} className="settings-icon">
|
||||
<EllipsisVertical size={16} />
|
||||
</Icon>
|
||||
</Dropdown>
|
||||
</UserMenu>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const ThemeIcon = () => {
|
||||
const { settedTheme } = useTheme()
|
||||
|
||||
return settedTheme === ThemeMode.dark ? (
|
||||
<Moon size={16} className="icon" />
|
||||
) : settedTheme === ThemeMode.light ? (
|
||||
<Sun size={16} className="icon" />
|
||||
) : (
|
||||
<SunMoon size={16} className="icon" />
|
||||
)
|
||||
}
|
||||
|
||||
const MainContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: 0;
|
||||
min-height: 0;
|
||||
`
|
||||
|
||||
const AssistantContainer = styled.div`
|
||||
margin: 4px 10px;
|
||||
display: flex;
|
||||
margin-top: 0;
|
||||
`
|
||||
|
||||
const UserMenu = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 10px;
|
||||
margin-bottom: 10px;
|
||||
gap: 5px;
|
||||
border-radius: 8px;
|
||||
`
|
||||
|
||||
const UserMenuLeft = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-list-item);
|
||||
}
|
||||
`
|
||||
|
||||
const AvatarImg = styled(Avatar)`
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background-color: var(--color-background-soft);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
`
|
||||
|
||||
const UserMenuText = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-right: 3px;
|
||||
`
|
||||
|
||||
const Icon = styled.div<{ theme: string }>`
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
-webkit-app-region: none;
|
||||
border: 0.5px solid transparent;
|
||||
&.settings-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--color-list-item);
|
||||
opacity: 0.8;
|
||||
cursor: pointer;
|
||||
.icon {
|
||||
color: var(--color-icon-white);
|
||||
}
|
||||
}
|
||||
&.active {
|
||||
background-color: var(--color-list-item);
|
||||
border: 0.5px solid var(--color-border);
|
||||
.icon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes borderBreath {
|
||||
0% {
|
||||
opacity: 0.1;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
&.opened-minapp {
|
||||
position: relative;
|
||||
}
|
||||
&.opened-minapp::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-radius: inherit;
|
||||
opacity: 0.3;
|
||||
border: 0.5px solid var(--color-primary);
|
||||
}
|
||||
`
|
||||
|
||||
export default MainSidebar
|
||||
@@ -0,0 +1,99 @@
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const MainMenuItem = styled.div<{ active?: boolean }>`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background-color: ${({ active }) => (active ? 'var(--color-list-item)' : 'transparent')};
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
border-radius: 8px;
|
||||
opacity: ${({ active }) => (active ? 0.6 : 1)};
|
||||
&.active {
|
||||
background-color: var(--color-list-item);
|
||||
}
|
||||
&:hover {
|
||||
background-color: ${({ active }) => (active ? 'var(--color-list-item)' : 'var(--color-list-item-hover)')};
|
||||
}
|
||||
`
|
||||
|
||||
export const MainMenuItemLeft = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
`
|
||||
|
||||
export const MainMenuItemRight = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-right: -3px;
|
||||
`
|
||||
|
||||
export const MainMenuItemIcon = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
`
|
||||
|
||||
export const MainMenuItemText = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
`
|
||||
|
||||
export const Container = styled.div<{ transparent?: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
width: var(--assistants-width);
|
||||
max-width: var(--assistants-width);
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
height: calc(var(--main-height) - 50px);
|
||||
min-height: calc(var(--main-height) - 50px);
|
||||
background: var(--color-background);
|
||||
padding-top: 10px;
|
||||
margin-top: 50px;
|
||||
`
|
||||
|
||||
export const MainMenu = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 10px;
|
||||
`
|
||||
|
||||
export const SubMenu = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
overflow: hidden;
|
||||
padding: 5px 0;
|
||||
`
|
||||
|
||||
export const TabsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
-webkit-app-region: none;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin-top: 5px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
|
||||
export const TabsWrapper = styled(Scrollbar as any)`
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
`
|
||||
|
||||
export const Menus = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
`
|
||||
167
src/renderer/src/pages/home/MainSidebar/OpenedMinapps.tsx
Normal file
167
src/renderer/src/pages/home/MainSidebar/OpenedMinapps.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { DraggableList } from '@renderer/components/DraggableList'
|
||||
import MinAppIcon from '@renderer/components/Icons/MinAppIcon'
|
||||
import IndicatorLight from '@renderer/components/IndicatorLight'
|
||||
import { Center } from '@renderer/components/Layout'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { Empty } from 'antd'
|
||||
import { Dropdown } from 'antd'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { FC, useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import {
|
||||
MainMenuItem,
|
||||
MainMenuItemIcon,
|
||||
MainMenuItemLeft,
|
||||
MainMenuItemRight,
|
||||
MainMenuItemText,
|
||||
TabsContainer,
|
||||
TabsWrapper
|
||||
} from './MainSidebarStyles'
|
||||
|
||||
const OpenedMinapps: FC = () => {
|
||||
const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime()
|
||||
const { openMinappKeepAlive, hideMinappPopup, closeMinapp, closeAllMinapps } = useMinappPopup()
|
||||
const { showOpenedMinappsInSidebar } = useSettings()
|
||||
const { pinned, updatePinnedMinapps } = useMinapps()
|
||||
const { t } = useTranslation()
|
||||
|
||||
// 合并并排序应用列表
|
||||
const sortedApps = useMemo(() => {
|
||||
// 分离已打开但未固定的应用
|
||||
const openedNotPinned = openedKeepAliveMinapps.filter((app) => !pinned.find((p) => p.id === app.id))
|
||||
|
||||
// 获取固定应用列表(保持原有顺序)
|
||||
const pinnedApps = pinned.map((app) => {
|
||||
const openedApp = openedKeepAliveMinapps.find((o) => o.id === app.id)
|
||||
return openedApp || app
|
||||
})
|
||||
|
||||
// 把已启动但未固定的放到列表下面
|
||||
return [...pinnedApps, ...openedNotPinned]
|
||||
}, [openedKeepAliveMinapps, pinned])
|
||||
|
||||
const handleOnClick = (app) => {
|
||||
if (minappShow && currentMinappId === app.id) {
|
||||
hideMinappPopup()
|
||||
} else {
|
||||
openMinappKeepAlive(app)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const iconDefaultHeight = 40
|
||||
const iconDefaultOffset = 17
|
||||
const container = document.querySelector('.TabsContainer') as HTMLElement
|
||||
const activeIcon = document.querySelector('.TabsContainer .opened-active') as HTMLElement
|
||||
|
||||
let indicatorTop = 0,
|
||||
indicatorRight = 0
|
||||
if (minappShow && activeIcon && container) {
|
||||
indicatorTop = activeIcon.offsetTop + activeIcon.offsetHeight / 2 - 4
|
||||
indicatorRight = 0
|
||||
} else {
|
||||
indicatorTop =
|
||||
((openedKeepAliveMinapps.length > 0 ? openedKeepAliveMinapps.length : 1) / 2) * iconDefaultHeight +
|
||||
iconDefaultOffset -
|
||||
4
|
||||
indicatorRight = -50
|
||||
}
|
||||
container.style.setProperty('--indicator-top', `${indicatorTop}px`)
|
||||
container.style.setProperty('--indicator-right', `${indicatorRight}px`)
|
||||
}, [currentMinappId, openedKeepAliveMinapps, minappShow])
|
||||
|
||||
const isShowApps = showOpenedMinappsInSidebar && sortedApps.length > 0
|
||||
|
||||
if (!isShowApps) return <TabsContainer className="TabsContainer" />
|
||||
|
||||
return (
|
||||
<TabsContainer className="TabsContainer" style={{ marginBottom: 4 }}>
|
||||
<Divider />
|
||||
<TabsWrapper>
|
||||
<DraggableList
|
||||
list={sortedApps}
|
||||
onUpdate={(newList) => {
|
||||
// 只更新固定应用的顺序
|
||||
const newPinned = newList.filter((app) => pinned.find((p) => p.id === app.id))
|
||||
updatePinnedMinapps(newPinned)
|
||||
}}
|
||||
listStyle={{ margin: '4px 0' }}>
|
||||
{(app) => {
|
||||
const isPinned = pinned.find((p) => p.id === app.id)
|
||||
const isOpened = openedKeepAliveMinapps.find((o) => o.id === app.id)
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'togglePin',
|
||||
label: isPinned ? t('minapp.sidebar.remove.title') : t('minapp.sidebar.pin.title'),
|
||||
onClick: () => {
|
||||
if (isPinned) {
|
||||
const newPinned = pinned.filter((item) => item.id !== app.id)
|
||||
updatePinnedMinapps(newPinned)
|
||||
} else {
|
||||
updatePinnedMinapps([...pinned, app])
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
if (isOpened) {
|
||||
menuItems.push(
|
||||
{
|
||||
key: 'closeApp',
|
||||
label: t('minapp.sidebar.close.title'),
|
||||
onClick: () => closeMinapp(app.id)
|
||||
},
|
||||
{
|
||||
key: 'closeAllApp',
|
||||
label: t('minapp.sidebar.closeall.title'),
|
||||
onClick: () => closeAllMinapps()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']} overlayStyle={{ zIndex: 10000 }}>
|
||||
<MainMenuItem key={app.id} onClick={() => handleOnClick(app)}>
|
||||
<MainMenuItemLeft>
|
||||
<MainMenuItemIcon>
|
||||
<MinAppIcon size={22} app={app} style={{ borderRadius: 6 }} sidebar />
|
||||
</MainMenuItemIcon>
|
||||
<MainMenuItemText>{app.name}</MainMenuItemText>
|
||||
</MainMenuItemLeft>
|
||||
{isOpened && (
|
||||
<MainMenuItemRight style={{ marginRight: 4 }}>
|
||||
<IndicatorLight color="var(--color-primary)" shadow={false} animation={false} size={5} />
|
||||
</MainMenuItemRight>
|
||||
)}
|
||||
</MainMenuItem>
|
||||
</Dropdown>
|
||||
)
|
||||
}}
|
||||
</DraggableList>
|
||||
{isEmpty(sortedApps) && (
|
||||
<Center>
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</Center>
|
||||
)}
|
||||
</TabsWrapper>
|
||||
<Divider />
|
||||
</TabsContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const Divider = styled.div`
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--color-border);
|
||||
margin: 5px 0;
|
||||
opacity: 0.5;
|
||||
`
|
||||
|
||||
export default OpenedMinapps
|
||||
106
src/renderer/src/pages/home/MainSidebar/SidebarSearch.tsx
Normal file
106
src/renderer/src/pages/home/MainSidebar/SidebarSearch.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Input, InputRef } from 'antd'
|
||||
import { Search } from 'lucide-react'
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { MainMenuItem, MainMenuItemIcon, MainMenuItemLeft, MainMenuItemText } from './MainSidebarStyles'
|
||||
|
||||
interface SidebarSearchProps {
|
||||
onSearch: (text: string) => void
|
||||
}
|
||||
|
||||
const SidebarSearch: React.FC<SidebarSearchProps> = ({ onSearch }) => {
|
||||
const { t } = useTranslation()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const inputRef = useRef<InputRef>(null)
|
||||
|
||||
const handleTextChange = useCallback(
|
||||
(text: string) => {
|
||||
setSearchText(text)
|
||||
onSearch(text)
|
||||
},
|
||||
[onSearch]
|
||||
)
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
setIsExpanded(true)
|
||||
}, [])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setSearchText('')
|
||||
onSearch('')
|
||||
}, [onSearch])
|
||||
|
||||
const handleCollapse = useCallback(() => {
|
||||
setSearchText('')
|
||||
setIsExpanded(false)
|
||||
onSearch('')
|
||||
}, [onSearch])
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCollapse()
|
||||
}
|
||||
},
|
||||
[handleCollapse]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [isExpanded])
|
||||
|
||||
const renderInputBox = useMemo(() => {
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={searchText}
|
||||
placeholder={t('chat.assistant.search.placeholder')}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onBlur={(e) => {
|
||||
// 如果输入框失焦且没有搜索内容,则收起
|
||||
if (!e.target.value.trim()) {
|
||||
handleCollapse()
|
||||
}
|
||||
}}
|
||||
onClear={handleClear}
|
||||
allowClear
|
||||
style={{
|
||||
paddingTop: 4
|
||||
}}
|
||||
prefix={
|
||||
<MainMenuItemIcon style={{ margin: '0 6px 0 -2px' }}>
|
||||
<Search size={18} className="icon" />
|
||||
</MainMenuItemIcon>
|
||||
}
|
||||
spellCheck={false}
|
||||
/>
|
||||
)
|
||||
}, [handleClear, handleCollapse, handleInputKeyDown, handleTextChange, searchText, t])
|
||||
|
||||
const renderMenuItem = useMemo(() => {
|
||||
return (
|
||||
<MainMenuItem onClick={handleExpand} style={{ cursor: 'pointer' }}>
|
||||
<MainMenuItemLeft>
|
||||
<MainMenuItemIcon>
|
||||
<Search size={18} className="icon" />
|
||||
</MainMenuItemIcon>
|
||||
<MainMenuItemText>{t('chat.assistant.search.placeholder')}</MainMenuItemText>
|
||||
</MainMenuItemLeft>
|
||||
</MainMenuItem>
|
||||
)
|
||||
}, [handleExpand, t])
|
||||
|
||||
return <SearchBarWrapper>{isExpanded ? renderInputBox : renderMenuItem}</SearchBarWrapper>
|
||||
}
|
||||
|
||||
const SearchBarWrapper = styled.div`
|
||||
height: 2.2rem;
|
||||
`
|
||||
|
||||
export default memo(SidebarSearch)
|
||||
@@ -96,15 +96,19 @@ const Markdown: FC<Props> = ({ block }) => {
|
||||
} as Partial<Components>
|
||||
}, [onSaveCodeBlock, block.id])
|
||||
|
||||
if (messageContent.includes('<style>')) {
|
||||
components.style = MarkdownShadowDOMRenderer as any
|
||||
}
|
||||
|
||||
const urlTransform = useCallback((value: string) => {
|
||||
if (value.startsWith('data:image/png') || value.startsWith('data:image/jpeg')) return value
|
||||
return defaultUrlTransform(value)
|
||||
}, [])
|
||||
|
||||
// if (role === 'user' && !renderInputMessageAsMarkdown) {
|
||||
// return <p style={{ marginBottom: 5, whiteSpace: 'pre-wrap' }}>{messageContent}</p>
|
||||
// }
|
||||
|
||||
if (messageContent.includes('<style>')) {
|
||||
components.style = MarkdownShadowDOMRenderer as any
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="markdown">
|
||||
<ReactMarkdown
|
||||
|
||||
@@ -47,7 +47,7 @@ const MainTextBlock: React.FC<Props> = ({ block, citationBlockId, role, mentions
|
||||
</Flex>
|
||||
)}
|
||||
{role === 'user' && !renderInputMessageAsMarkdown ? (
|
||||
<p className="markdown" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
<p className="markdown" style={{ marginBottom: 5, whiteSpace: 'pre-wrap' }}>
|
||||
{block.content}
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
VerticalAlignBottomOutlined,
|
||||
VerticalAlignTopOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { RootState } from '@renderer/store'
|
||||
// import { selectCurrentTopicId } from '@renderer/store/newMessage'
|
||||
import { Button, Drawer, Tooltip } from 'antd'
|
||||
@@ -44,8 +43,6 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
const [manuallyClosedUntil, setManuallyClosedUntil] = useState<number | null>(null)
|
||||
const currentTopicId = useSelector((state: RootState) => state.messages.currentTopicId)
|
||||
const lastMoveTime = useRef(0)
|
||||
const { topicPosition, showTopics } = useSettings()
|
||||
const showRightTopics = topicPosition === 'right' && showTopics
|
||||
|
||||
// Reset hide timer and make buttons visible
|
||||
const resetHideTimer = useCallback(() => {
|
||||
@@ -274,14 +271,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
// Calculate if the mouse is in the trigger area
|
||||
const triggerWidth = 60 // Same as the width in styled component
|
||||
|
||||
// Safe way to calculate position when using calc expressions
|
||||
let rightOffset = RIGHT_GAP // Default right offset
|
||||
if (showRightTopics) {
|
||||
// When topics are shown on right, we need to account for topic list width
|
||||
rightOffset += 275 // --topic-list-width
|
||||
}
|
||||
|
||||
const rightPosition = window.innerWidth - rightOffset - triggerWidth
|
||||
const rightPosition = window.innerWidth - triggerWidth
|
||||
const topPosition = window.innerHeight * 0.35 // 35% from top
|
||||
const height = window.innerHeight * 0.3 // 30% of window height
|
||||
|
||||
@@ -326,16 +316,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
clearTimeout(hideTimer)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
containerId,
|
||||
hideTimer,
|
||||
resetHideTimer,
|
||||
isNearButtons,
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
showRightTopics,
|
||||
manuallyClosedUntil
|
||||
])
|
||||
}, [containerId, hideTimer, resetHideTimer, isNearButtons, handleMouseEnter, handleMouseLeave, manuallyClosedUntil])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -151,9 +151,7 @@ const WebSearchCitation: React.FC<{ citation: Citation }> = ({ citation }) => {
|
||||
<CitationLink className="text-nowrap" href={citation.url} onClick={(e) => handleLinkClick(citation.url, e)}>
|
||||
{citation.title || <span className="hostname">{citation.hostname}</span>}
|
||||
</CitationLink>
|
||||
|
||||
<CitationIndex>{citation.number}</CitationIndex>
|
||||
{fetchedContent && <CopyButton content={fetchedContent} />}
|
||||
<CitationIndex>{citation.number}</CitationIndex>s{fetchedContent && <CopyButton content={fetchedContent} />}
|
||||
</WebSearchCardHeader>
|
||||
{isLoading ? (
|
||||
<Skeleton active paragraph={{ rows: 1 }} title={false} />
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMessageEditing } from '@renderer/context/MessageEditingContext'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useMessageOperations } from '@renderer/hooks/useMessageOperations'
|
||||
import { useModel } from '@renderer/hooks/useModel'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { getMessageModelId } from '@renderer/services/MessagesService'
|
||||
import { getModelUniqId } from '@renderer/services/ModelService'
|
||||
@@ -21,6 +21,7 @@ import MessageEditor from './MessageEditor'
|
||||
import MessageErrorBoundary from './MessageErrorBoundary'
|
||||
import MessageHeader from './MessageHeader'
|
||||
import MessageMenubar from './MessageMenubar'
|
||||
import MessageTokens from './MessageTokens'
|
||||
|
||||
interface Props {
|
||||
message: Message
|
||||
@@ -42,12 +43,14 @@ const MessageItem: FC<Props> = ({
|
||||
index,
|
||||
hideMenuBar = false,
|
||||
isGrouped,
|
||||
isStreaming = false
|
||||
isStreaming = false,
|
||||
style
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { assistant, setModel } = useAssistant(message.assistantId)
|
||||
const model = useModel(getMessageModelId(message), message.model?.provider) || message.model
|
||||
const { messageFont, fontSize, messageStyle } = useSettings()
|
||||
const { isBubbleStyle } = useMessageStyle()
|
||||
const { showMessageDivider, messageFont, fontSize, messageStyle } = useSettings()
|
||||
const { editMessageBlocks, resendUserMessageWithEdit, editMessage } = useMessageOperations(topic)
|
||||
const messageContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { editingMessageId, stopEditing } = useMessageEditing()
|
||||
@@ -99,6 +102,8 @@ const MessageItem: FC<Props> = ({
|
||||
const isAssistantMessage = message.role === 'assistant'
|
||||
const showMenubar = !hideMenuBar && !isStreaming && !message.status.includes('ing') && !isEditing
|
||||
|
||||
const messageBackground = getMessageBackground(isBubbleStyle, isAssistantMessage)
|
||||
|
||||
const messageHighlightHandler = useCallback((highlight: boolean = true) => {
|
||||
if (messageContainerRef.current) {
|
||||
messageContainerRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
@@ -127,6 +132,29 @@ const MessageItem: FC<Props> = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<MessageContainer style={{ paddingTop: 15 }}>
|
||||
<MessageHeader
|
||||
message={message}
|
||||
assistant={assistant}
|
||||
model={model}
|
||||
key={getModelUniqId(model)}
|
||||
topic={topic}
|
||||
/>
|
||||
<div style={{ paddingLeft: messageStyle === 'plain' ? 46 : undefined }}>
|
||||
<MessageEditor
|
||||
message={message}
|
||||
topicId={topic.id}
|
||||
onSave={handleEditSave}
|
||||
onResend={handleEditResend}
|
||||
onCancel={handleEditCancel}
|
||||
/>
|
||||
</div>
|
||||
</MessageContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageContainer
|
||||
key={message.id}
|
||||
@@ -135,58 +163,55 @@ const MessageItem: FC<Props> = ({
|
||||
'message-assistant': isAssistantMessage,
|
||||
'message-user': !isAssistantMessage
|
||||
})}
|
||||
ref={messageContainerRef}>
|
||||
ref={messageContainerRef}
|
||||
style={style}>
|
||||
<MessageHeader message={message} assistant={assistant} model={model} key={getModelUniqId(model)} topic={topic} />
|
||||
{isEditing && (
|
||||
<MessageEditor
|
||||
message={message}
|
||||
topicId={topic.id}
|
||||
onSave={handleEditSave}
|
||||
onResend={handleEditResend}
|
||||
onCancel={handleEditCancel}
|
||||
/>
|
||||
)}
|
||||
{!isEditing && (
|
||||
<>
|
||||
<MessageContentContainer
|
||||
className="message-content-container"
|
||||
style={{
|
||||
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
|
||||
fontSize,
|
||||
overflowY: 'visible'
|
||||
}}>
|
||||
<MessageErrorBoundary>
|
||||
<MessageContent message={message} />
|
||||
</MessageErrorBoundary>
|
||||
</MessageContentContainer>
|
||||
{showMenubar && (
|
||||
<MessageFooter className="MessageFooter" $isLastMessage={isLastMessage} $messageStyle={messageStyle}>
|
||||
<MessageMenubar
|
||||
message={message}
|
||||
assistant={assistant}
|
||||
model={model}
|
||||
index={index}
|
||||
topic={topic}
|
||||
isLastMessage={isLastMessage}
|
||||
isAssistantMessage={isAssistantMessage}
|
||||
isGrouped={isGrouped}
|
||||
messageContainerRef={messageContainerRef as React.RefObject<HTMLDivElement>}
|
||||
setModel={setModel}
|
||||
/>
|
||||
</MessageFooter>
|
||||
)}
|
||||
</>
|
||||
<MessageContentContainer
|
||||
className="message-content-container"
|
||||
style={{
|
||||
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
|
||||
fontSize,
|
||||
background: messageBackground,
|
||||
overflowY: 'visible'
|
||||
}}>
|
||||
<MessageErrorBoundary>
|
||||
<MessageContent message={message} />
|
||||
</MessageErrorBoundary>
|
||||
</MessageContentContainer>
|
||||
{showMenubar && (
|
||||
<MessageFooter className="MessageFooter" $isLastMessage={isLastMessage} $messageStyle={messageStyle}>
|
||||
<MessageMenubar
|
||||
message={message}
|
||||
assistant={assistant}
|
||||
model={model}
|
||||
index={index}
|
||||
topic={topic}
|
||||
isLastMessage={isLastMessage}
|
||||
isAssistantMessage={isAssistantMessage}
|
||||
isGrouped={isGrouped}
|
||||
messageContainerRef={messageContainerRef as React.RefObject<HTMLDivElement>}
|
||||
setModel={setModel}
|
||||
/>
|
||||
</MessageFooter>
|
||||
)}
|
||||
</MessageContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolean) => {
|
||||
return isBubbleStyle
|
||||
? isAssistantMessage
|
||||
? 'var(--chat-background-assistant)'
|
||||
: 'var(--chat-background-user)'
|
||||
: undefined
|
||||
}
|
||||
|
||||
const MessageContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
transition: background-color 0.3s ease;
|
||||
padding: 0 24px;
|
||||
transform: translateZ(0);
|
||||
will-change: transform;
|
||||
padding: 10px;
|
||||
@@ -226,11 +251,11 @@ const MessageFooter = styled.div<{ $isLastMessage: boolean; $messageStyle: 'plai
|
||||
gap: 10px;
|
||||
margin-left: 46px;
|
||||
margin-top: 8px;
|
||||
border-top: 0.5px dotted var(--color-border);
|
||||
`
|
||||
|
||||
const NewContextMessage = styled.div`
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
export default memo(MessageItem)
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import {
|
||||
FileExcelFilled,
|
||||
FileImageFilled,
|
||||
FileMarkdownFilled,
|
||||
FilePdfFilled,
|
||||
FilePptFilled,
|
||||
FileTextFilled,
|
||||
FileUnknownFilled,
|
||||
FileWordFilled,
|
||||
FileZipFilled,
|
||||
FolderOpenFilled,
|
||||
GlobalOutlined,
|
||||
LinkOutlined
|
||||
} from '@ant-design/icons'
|
||||
import CustomTag from '@renderer/components/CustomTag'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { FileType } from '@renderer/types'
|
||||
import type { FileMessageBlock } from '@renderer/types/newMessage'
|
||||
import { Upload } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -8,17 +23,6 @@ interface Props {
|
||||
block: FileMessageBlock
|
||||
}
|
||||
|
||||
const StyledUpload = styled(Upload)`
|
||||
.ant-upload-list-item-name {
|
||||
max-width: 220px;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
`
|
||||
|
||||
const MessageAttachments: FC<Props> = ({ block }) => {
|
||||
// const handleCopyImage = async (image: FileMetadata) => {
|
||||
// const data = await FileManager.readFile(image)
|
||||
@@ -30,54 +34,84 @@ const MessageAttachments: FC<Props> = ({ block }) => {
|
||||
if (!block.file) {
|
||||
return null
|
||||
}
|
||||
// 由图片块代替
|
||||
// if (block.file.type === FileTypes.IMAGE) {
|
||||
// return (
|
||||
// <Container style={{ marginBottom: 8 }}>
|
||||
// <Image
|
||||
// src={FileManager.getFileUrl(block.file)}
|
||||
// key={block.file.id}
|
||||
// width="33%"
|
||||
// preview={{
|
||||
// toolbarRender: (
|
||||
// _,
|
||||
// {
|
||||
// transform: { scale },
|
||||
// actions: { onFlipY, onFlipX, onRotateLeft, onRotateRight, onZoomOut, onZoomIn, onReset }
|
||||
// }
|
||||
// ) => (
|
||||
// <ToobarWrapper size={12} className="toolbar-wrapper">
|
||||
// <SwapOutlined rotate={90} onClick={onFlipY} />
|
||||
// <SwapOutlined onClick={onFlipX} />
|
||||
// <RotateLeftOutlined onClick={onRotateLeft} />
|
||||
// <RotateRightOutlined onClick={onRotateRight} />
|
||||
// <ZoomOutOutlined disabled={scale === 1} onClick={onZoomOut} />
|
||||
// <ZoomInOutlined disabled={scale === 50} onClick={onZoomIn} />
|
||||
// <UndoOutlined onClick={onReset} />
|
||||
// <CopyOutlined onClick={() => handleCopyImage(block.file)} />
|
||||
// <DownloadOutlined onClick={() => download(FileManager.getFileUrl(block.file))} />
|
||||
// </ToobarWrapper>
|
||||
// )
|
||||
// }}
|
||||
// />
|
||||
// </Container>
|
||||
// )
|
||||
// }
|
||||
|
||||
const MAX_FILENAME_DISPLAY_LENGTH = 20
|
||||
function truncateFileName(name: string, maxLength: number = MAX_FILENAME_DISPLAY_LENGTH) {
|
||||
if (name.length <= maxLength) return name
|
||||
return name.slice(0, maxLength - 3) + '...'
|
||||
}
|
||||
|
||||
const getFileIcon = (type?: string) => {
|
||||
if (!type) return <FileUnknownFilled />
|
||||
|
||||
const ext = type.toLowerCase()
|
||||
|
||||
if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) {
|
||||
return <FileImageFilled />
|
||||
}
|
||||
|
||||
if (['.doc', '.docx'].includes(ext)) {
|
||||
return <FileWordFilled />
|
||||
}
|
||||
if (['.xls', '.xlsx'].includes(ext)) {
|
||||
return <FileExcelFilled />
|
||||
}
|
||||
if (['.ppt', '.pptx'].includes(ext)) {
|
||||
return <FilePptFilled />
|
||||
}
|
||||
if (ext === '.pdf') {
|
||||
return <FilePdfFilled />
|
||||
}
|
||||
if (['.md', '.markdown'].includes(ext)) {
|
||||
return <FileMarkdownFilled />
|
||||
}
|
||||
|
||||
if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) {
|
||||
return <FileZipFilled />
|
||||
}
|
||||
|
||||
if (['.txt', '.json', '.log', '.yml', '.yaml', '.xml', '.csv'].includes(ext)) {
|
||||
return <FileTextFilled />
|
||||
}
|
||||
|
||||
if (['.url'].includes(ext)) {
|
||||
return <LinkOutlined />
|
||||
}
|
||||
|
||||
if (['.sitemap'].includes(ext)) {
|
||||
return <GlobalOutlined />
|
||||
}
|
||||
|
||||
if (['.folder'].includes(ext)) {
|
||||
return <FolderOpenFilled />
|
||||
}
|
||||
|
||||
return <FileUnknownFilled />
|
||||
}
|
||||
|
||||
const FileNameRender: FC<{ file: FileType }> = ({ file }) => {
|
||||
const fullName = FileManager.formatFileName(file)
|
||||
const displayName = truncateFileName(fullName)
|
||||
|
||||
return (
|
||||
<FileName
|
||||
onClick={() => {
|
||||
const path = FileManager.getSafePath(file)
|
||||
if (path) {
|
||||
window.api.file.openPath(path)
|
||||
}
|
||||
}}
|
||||
title={fullName}>
|
||||
{displayName}
|
||||
</FileName>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container style={{ marginTop: 2, marginBottom: 8 }} className="message-attachments">
|
||||
<StyledUpload
|
||||
listType="text"
|
||||
disabled
|
||||
fileList={[
|
||||
{
|
||||
uid: block.file.id,
|
||||
url: 'file://' + FileManager.getSafePath(block.file),
|
||||
status: 'done' as const,
|
||||
name: FileManager.formatFileName(block.file)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
<CustomTag key={block.file.id} icon={getFileIcon(block.file.ext)} color="#37a5aa">
|
||||
<FileNameRender file={block.file} />
|
||||
</CustomTag>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -89,23 +123,11 @@ const Container = styled.div`
|
||||
margin-top: 8px;
|
||||
`
|
||||
|
||||
// const Image = styled(AntdImage)`
|
||||
// border-radius: 10px;
|
||||
// `
|
||||
|
||||
// const ToobarWrapper = styled(Space)`
|
||||
// padding: 0px 24px;
|
||||
// color: #fff;
|
||||
// font-size: 20px;
|
||||
// background-color: rgba(0, 0, 0, 0.1);
|
||||
// border-radius: 100px;
|
||||
// .anticon {
|
||||
// padding: 12px;
|
||||
// cursor: pointer;
|
||||
// }
|
||||
// .anticon:hover {
|
||||
// opacity: 0.3;
|
||||
// }
|
||||
// `
|
||||
const FileName = styled.span`
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`
|
||||
|
||||
export default MessageAttachments
|
||||
|
||||
@@ -14,7 +14,7 @@ const MessageContent: React.FC<Props> = ({ message }) => {
|
||||
return (
|
||||
<>
|
||||
{!isEmpty(message.mentions) && (
|
||||
<Flex gap="8px" wrap>
|
||||
<Flex gap="8px" wrap style={{ marginBottom: 10 }}>
|
||||
{message.mentions?.map((model) => <MentionTag key={getModelUniqId(model)}>{'@' + model.name}</MentionTag>)}
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
@@ -365,7 +365,7 @@ const EditorContainer = styled.div`
|
||||
padding-bottom: 5px;
|
||||
border: 0.5px solid var(--color-border);
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 15px;
|
||||
border-radius: var(--list-item-border-radius);
|
||||
margin-top: 18px;
|
||||
background-color: var(--color-background-opacity);
|
||||
width: 100%;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user