mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-29 05:51:26 +08:00
Merge branch 'main' into feat/variable_replace_prompt
This commit is contained in:
commit
d213bc1024
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.17",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@ -63,7 +63,7 @@
|
||||
"@cherrystudio/embedjs-openai": "^0.1.28",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@electron/notarize": "^2.5.0",
|
||||
"@google/generative-ai": "^0.21.0",
|
||||
"@google/generative-ai": "^0.24.0",
|
||||
"@langchain/community": "^0.3.36",
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"@tryfabric/martian": "^1.2.4",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
|
||||
import { app, ipcMain } from 'electron'
|
||||
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||
|
||||
import { registerIpc } from './ipc'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
@ -48,7 +48,7 @@ if (!app.requestSingleInstanceLock()) {
|
||||
replaceDevtoolsFont(mainWindow)
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
installExtension(REDUX_DEVTOOLS)
|
||||
installExtension([REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS])
|
||||
.then((name) => console.log(`Added Extension: ${name}`))
|
||||
.catch((err) => console.log('An error occurred: ', err))
|
||||
}
|
||||
|
||||
@ -87,9 +87,16 @@ class BackupManager {
|
||||
await fs.ensureDir(this.tempDir)
|
||||
onProgress({ stage: 'preparing', progress: 0, total: 100 })
|
||||
|
||||
// 将 data 写入临时文件
|
||||
// 使用流的方式写入 data.json
|
||||
const tempDataPath = path.join(this.tempDir, 'data.json')
|
||||
await fs.writeFile(tempDataPath, data)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const writeStream = fs.createWriteStream(tempDataPath)
|
||||
writeStream.write(data)
|
||||
writeStream.end()
|
||||
|
||||
writeStream.on('finish', () => resolve())
|
||||
writeStream.on('error', (error) => reject(error))
|
||||
})
|
||||
onProgress({ stage: 'writing_data', progress: 20, total: 100 })
|
||||
|
||||
// 复制 Data 目录到临时目录
|
||||
@ -208,8 +215,15 @@ class BackupManager {
|
||||
fs.mkdirSync(this.backupDir, { recursive: true })
|
||||
}
|
||||
|
||||
// sync为同步写,无须await
|
||||
fs.writeFileSync(backupedFilePath, retrievedFile as Buffer)
|
||||
// 使用流的方式写入文件
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const writeStream = fs.createWriteStream(backupedFilePath)
|
||||
writeStream.write(retrievedFile as Buffer)
|
||||
writeStream.end()
|
||||
|
||||
writeStream.on('finish', () => resolve())
|
||||
writeStream.on('error', (error) => reject(error))
|
||||
})
|
||||
|
||||
return await this.restore(_, backupedFilePath)
|
||||
} catch (error: any) {
|
||||
|
||||
@ -2,10 +2,11 @@ import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { isLinux, isMac, isWin } from '@main/constant'
|
||||
import { makeSureDirExists } from '@main/utils'
|
||||
import { getBinaryName, getBinaryPath } from '@main/utils/process'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import { MCPServer, MCPTool } from '@types'
|
||||
import { app } from 'electron'
|
||||
@ -21,6 +22,7 @@ class McpService {
|
||||
baseUrl: server.baseUrl,
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
registryUrl: server.registryUrl,
|
||||
env: server.env,
|
||||
id: server.id
|
||||
})
|
||||
@ -68,13 +70,8 @@ class McpService {
|
||||
} else if (server.command) {
|
||||
let cmd = server.command
|
||||
|
||||
if (server.command === 'npx') {
|
||||
if (server.command === 'npx' || server.command === 'bun' || server.command === 'bunx') {
|
||||
cmd = await getBinaryPath('bun')
|
||||
|
||||
if (cmd === 'bun') {
|
||||
cmd = 'npx'
|
||||
}
|
||||
|
||||
Logger.info(`[MCP] Using command: ${cmd}`)
|
||||
|
||||
// add -x to args if args exist
|
||||
@ -82,22 +79,42 @@ class McpService {
|
||||
if (!args.includes('-y')) {
|
||||
!args.includes('-y') && args.unshift('-y')
|
||||
}
|
||||
if (cmd.includes('bun') && !args.includes('x')) {
|
||||
if (!args.includes('x')) {
|
||||
args.unshift('x')
|
||||
}
|
||||
}
|
||||
if (server.registryUrl) {
|
||||
server.env = {
|
||||
...server.env,
|
||||
NPM_CONFIG_REGISTRY: server.registryUrl
|
||||
}
|
||||
|
||||
// if the server name is mcp-auto-install, use the mcp-registry.json file in the bin directory
|
||||
if (server.name === 'mcp-auto-install') {
|
||||
const binPath = await getBinaryPath()
|
||||
makeSureDirExists(binPath)
|
||||
server.env.MCP_REGISTRY_PATH = path.join(binPath, 'mcp-registry.json')
|
||||
}
|
||||
}
|
||||
} else if (server.command === 'uvx' || server.command === 'uv') {
|
||||
cmd = await getBinaryPath(server.command)
|
||||
if (server.registryUrl) {
|
||||
server.env = {
|
||||
...server.env,
|
||||
UV_DEFAULT_INDEX: server.registryUrl,
|
||||
PIP_INDEX_URL: server.registryUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (server.command === 'uvx') {
|
||||
cmd = await getBinaryPath('uvx')
|
||||
}
|
||||
|
||||
Logger.info(`[MCP] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
|
||||
// Logger.info(`[MCP] Environment variables for server:`, server.env)
|
||||
|
||||
transport = new StdioClientTransport({
|
||||
command: cmd,
|
||||
args,
|
||||
env: {
|
||||
...getDefaultEnvironment(),
|
||||
PATH: this.getEnhancedPath(process.env.PATH || ''),
|
||||
...server.env
|
||||
}
|
||||
@ -233,6 +250,7 @@ class McpService {
|
||||
`${homeDir}/.npm-global/bin`,
|
||||
`${homeDir}/.yarn/bin`,
|
||||
`${homeDir}/.cargo/bin`,
|
||||
`${homeDir}/.cherrystudio/bin`,
|
||||
'/opt/local/bin'
|
||||
)
|
||||
}
|
||||
@ -246,12 +264,18 @@ class McpService {
|
||||
`${homeDir}/.npm-global/bin`,
|
||||
`${homeDir}/.yarn/bin`,
|
||||
`${homeDir}/.cargo/bin`,
|
||||
`${homeDir}/.cherrystudio/bin`,
|
||||
'/snap/bin'
|
||||
)
|
||||
}
|
||||
|
||||
if (isWin) {
|
||||
newPaths.push(`${process.env.APPDATA}\\npm`, `${homeDir}\\AppData\\Local\\Yarn\\bin`, `${homeDir}\\.cargo\\bin`)
|
||||
newPaths.push(
|
||||
`${process.env.APPDATA}\\npm`,
|
||||
`${homeDir}\\AppData\\Local\\Yarn\\bin`,
|
||||
`${homeDir}\\.cargo\\bin`,
|
||||
`${homeDir}\\.cherrystudio\\bin`
|
||||
)
|
||||
}
|
||||
|
||||
// 只添加不存在的路径
|
||||
|
||||
@ -23,17 +23,8 @@ function getShortcutHandler(shortcut: Shortcut) {
|
||||
configManager.setZoomFactor(1)
|
||||
}
|
||||
case 'show_app':
|
||||
return (window: BrowserWindow) => {
|
||||
if (window.isVisible()) {
|
||||
if (window.isFocused()) {
|
||||
window.hide()
|
||||
} else {
|
||||
window.focus()
|
||||
}
|
||||
} else {
|
||||
window.show()
|
||||
window.focus()
|
||||
}
|
||||
return () => {
|
||||
windowService.toggleMainWindow()
|
||||
}
|
||||
case 'mini_window':
|
||||
return () => {
|
||||
|
||||
@ -16,6 +16,9 @@ export class WindowService {
|
||||
private mainWindow: BrowserWindow | null = null
|
||||
private miniWindow: BrowserWindow | null = null
|
||||
private wasFullScreen: boolean = false
|
||||
//hacky-fix: store the focused status of mainWindow before miniWindow shows
|
||||
//to restore the focus status when miniWindow hides
|
||||
private wasMainWindowFocused: boolean = false
|
||||
private selectionMenuWindow: BrowserWindow | null = null
|
||||
private lastSelectedText: string = ''
|
||||
private contextMenu: Menu | null = null
|
||||
@ -30,6 +33,7 @@ export class WindowService {
|
||||
public createMainWindow(): BrowserWindow {
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.show()
|
||||
this.mainWindow.focus()
|
||||
return this.mainWindow
|
||||
}
|
||||
|
||||
@ -56,7 +60,7 @@ export class WindowService {
|
||||
titleBarOverlay: theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight,
|
||||
backgroundColor: isMac ? undefined : theme === 'dark' ? '#181818' : '#FFFFFF',
|
||||
trafficLightPosition: { x: 8, y: 12 },
|
||||
...(process.platform === 'linux' ? { icon } : {}),
|
||||
...(isLinux ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
@ -68,6 +72,12 @@ export class WindowService {
|
||||
|
||||
this.setupMainWindow(this.mainWindow, mainWindowState)
|
||||
|
||||
//preload miniWindow to resolve series of issues about miniWindow in Mac
|
||||
const enableQuickAssistant = configManager.getEnableQuickAssistant()
|
||||
if (enableQuickAssistant && !this.miniWindow) {
|
||||
this.miniWindow = this.createMiniWindow(true)
|
||||
}
|
||||
|
||||
return this.mainWindow
|
||||
}
|
||||
|
||||
@ -148,6 +158,8 @@ export class WindowService {
|
||||
// show window only when laucn to tray not set
|
||||
const isLaunchToTray = configManager.getLaunchToTray()
|
||||
if (!isLaunchToTray) {
|
||||
//[mac]hacky-fix: miniWindow set visibleOnFullScreen:true will cause dock icon disappeared
|
||||
app.dock?.show()
|
||||
mainWindow.show()
|
||||
}
|
||||
})
|
||||
@ -163,6 +175,25 @@ export class WindowService {
|
||||
mainWindow.webContents.send('fullscreen-status-changed', false)
|
||||
})
|
||||
|
||||
// set the zoom factor again when the window is going to resize
|
||||
//
|
||||
// this is a workaround for the known bug that
|
||||
// the zoom factor is reset to cached value when window is resized after routing to other page
|
||||
// see: https://github.com/electron/electron/issues/10572
|
||||
//
|
||||
mainWindow.on('will-resize', () => {
|
||||
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
|
||||
})
|
||||
|
||||
// ARCH: as `will-resize` is only for Win & Mac,
|
||||
// linux has the same problem, use `resize` listener instead
|
||||
// but `resize` will fliker the ui
|
||||
if (isLinux) {
|
||||
mainWindow.on('resize', () => {
|
||||
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
|
||||
})
|
||||
}
|
||||
|
||||
// 添加Escape键退出全屏的支持
|
||||
mainWindow.webContents.on('before-input-event', (event, input) => {
|
||||
// 当按下Escape键且窗口处于全屏状态时退出全屏
|
||||
@ -286,9 +317,8 @@ export class WindowService {
|
||||
event.preventDefault()
|
||||
mainWindow.hide()
|
||||
|
||||
if (isMac && isTrayOnClose) {
|
||||
app.dock?.hide() //for mac to hide to tray
|
||||
}
|
||||
//for mac users, should hide dock icon if close to tray
|
||||
app.dock?.hide()
|
||||
})
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
@ -309,44 +339,48 @@ export class WindowService {
|
||||
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
if (this.mainWindow.isMinimized()) {
|
||||
return this.mainWindow.restore()
|
||||
this.mainWindow.restore()
|
||||
return
|
||||
}
|
||||
//[macOS] Known Issue
|
||||
// setVisibleOnAllWorkspaces true/false will NOT bring window to current desktop in Mac (works fine with Windows)
|
||||
// AppleScript may be a solution, but it's not worth
|
||||
this.mainWindow.setVisibleOnAllWorkspaces(true)
|
||||
this.mainWindow.show()
|
||||
this.mainWindow.focus()
|
||||
this.mainWindow.setVisibleOnAllWorkspaces(false)
|
||||
} else {
|
||||
this.mainWindow = this.createMainWindow()
|
||||
this.mainWindow.focus()
|
||||
}
|
||||
|
||||
//for mac users, when window is shown, should show dock icon (dock may be set to hide when launch)
|
||||
app.dock?.show()
|
||||
}
|
||||
|
||||
public showMiniWindow() {
|
||||
const enableQuickAssistant = configManager.getEnableQuickAssistant()
|
||||
|
||||
if (!enableQuickAssistant) {
|
||||
public toggleMainWindow() {
|
||||
// should not toggle main window when in full screen
|
||||
if (this.wasFullScreen) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) {
|
||||
this.selectionMenuWindow.hide()
|
||||
}
|
||||
|
||||
if (this.miniWindow && !this.miniWindow.isDestroyed()) {
|
||||
if (this.miniWindow.isMinimized()) {
|
||||
this.miniWindow.restore()
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.isVisible()) {
|
||||
if (this.mainWindow.isFocused()) {
|
||||
// if tray is enabled, hide the main window, else do nothing
|
||||
if (configManager.getTray()) {
|
||||
this.mainWindow.hide()
|
||||
app.dock?.hide()
|
||||
}
|
||||
} else {
|
||||
this.mainWindow.focus()
|
||||
}
|
||||
this.miniWindow.show()
|
||||
this.miniWindow.center()
|
||||
this.miniWindow.focus()
|
||||
return
|
||||
}
|
||||
|
||||
this.showMainWindow()
|
||||
}
|
||||
|
||||
public createMiniWindow(isPreload: boolean = false): BrowserWindow {
|
||||
this.miniWindow = new BrowserWindow({
|
||||
width: 500,
|
||||
height: 520,
|
||||
show: true,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
transparent: isMac,
|
||||
vibrancy: 'under-window',
|
||||
@ -356,6 +390,11 @@ export class WindowService {
|
||||
alwaysOnTop: true,
|
||||
resizable: false,
|
||||
useContentSize: true,
|
||||
...(isMac ? { type: 'panel' } : {}),
|
||||
skipTaskbar: true,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
@ -364,8 +403,23 @@ export class WindowService {
|
||||
}
|
||||
})
|
||||
|
||||
//miniWindow should show in current desktop
|
||||
this.miniWindow?.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
|
||||
//make miniWindow always on top of fullscreen apps with level set
|
||||
this.miniWindow.setAlwaysOnTop(true, 'screen-saver', 1)
|
||||
|
||||
this.miniWindow.on('ready-to-show', () => {
|
||||
if (isPreload) {
|
||||
return
|
||||
}
|
||||
|
||||
this.wasMainWindowFocused = this.mainWindow?.isFocused() || false
|
||||
this.miniWindow?.center()
|
||||
this.miniWindow?.show()
|
||||
})
|
||||
|
||||
this.miniWindow.on('blur', () => {
|
||||
this.miniWindow?.hide()
|
||||
this.hideMiniWindow()
|
||||
})
|
||||
|
||||
this.miniWindow.on('closed', () => {
|
||||
@ -391,9 +445,48 @@ export class WindowService {
|
||||
hash: '#/mini'
|
||||
})
|
||||
}
|
||||
|
||||
return this.miniWindow
|
||||
}
|
||||
|
||||
public showMiniWindow() {
|
||||
const enableQuickAssistant = configManager.getEnableQuickAssistant()
|
||||
|
||||
if (!enableQuickAssistant) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) {
|
||||
this.selectionMenuWindow.hide()
|
||||
}
|
||||
|
||||
if (this.miniWindow && !this.miniWindow.isDestroyed()) {
|
||||
this.wasMainWindowFocused = this.mainWindow?.isFocused() || false
|
||||
|
||||
if (this.miniWindow.isMinimized()) {
|
||||
this.miniWindow.restore()
|
||||
}
|
||||
this.miniWindow.show()
|
||||
return
|
||||
}
|
||||
|
||||
this.miniWindow = this.createMiniWindow()
|
||||
}
|
||||
|
||||
public hideMiniWindow() {
|
||||
//hacky-fix:[mac/win] previous window(not self-app) should be focused again after miniWindow hide
|
||||
if (isWin) {
|
||||
this.miniWindow?.minimize()
|
||||
this.miniWindow?.hide()
|
||||
return
|
||||
} else if (isMac) {
|
||||
this.miniWindow?.hide()
|
||||
if (!this.wasMainWindowFocused) {
|
||||
app.hide()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.miniWindow?.hide()
|
||||
}
|
||||
|
||||
@ -402,11 +495,12 @@ export class WindowService {
|
||||
}
|
||||
|
||||
public toggleMiniWindow() {
|
||||
if (this.miniWindow) {
|
||||
this.miniWindow.isVisible() ? this.miniWindow.hide() : this.miniWindow.show()
|
||||
} else {
|
||||
this.showMiniWindow()
|
||||
if (this.miniWindow && !this.miniWindow.isDestroyed() && this.miniWindow.isVisible()) {
|
||||
this.hideMiniWindow()
|
||||
return
|
||||
}
|
||||
|
||||
this.showMiniWindow()
|
||||
}
|
||||
|
||||
public showSelectionMenu(bounds: { x: number; y: number }) {
|
||||
|
||||
@ -46,3 +46,9 @@ export function dumpPersistState() {
|
||||
export const runAsyncFunction = async (fn: () => void) => {
|
||||
await fn()
|
||||
}
|
||||
|
||||
export function makeSureDirExists(dir: string) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,7 +42,11 @@ export async function getBinaryName(name: string): Promise<string> {
|
||||
return name
|
||||
}
|
||||
|
||||
export async function getBinaryPath(name: string): Promise<string> {
|
||||
export async function getBinaryPath(name?: string): Promise<string> {
|
||||
if (!name) {
|
||||
return path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||
}
|
||||
|
||||
const binaryName = await getBinaryName(name)
|
||||
const binariesDir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||
const binariesDirExists = await fs.existsSync(binariesDir)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
@keyframes pulse {
|
||||
@keyframes animation-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--pulse-color), 0.5);
|
||||
}
|
||||
@ -14,5 +14,5 @@
|
||||
.animation-pulse {
|
||||
--pulse-color: 59, 130, 246;
|
||||
--pulse-size: 8px;
|
||||
animation: pulse 1.5s infinite;
|
||||
animation: animation-pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@ -192,3 +192,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-dropdown-menu .ant-dropdown-menu-sub {
|
||||
max-height: 350px;
|
||||
width: max-content;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
43
src/renderer/src/components/CustomCollapse.tsx
Normal file
43
src/renderer/src/components/CustomCollapse.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { Collapse } from 'antd'
|
||||
import { FC, memo } from 'react'
|
||||
|
||||
interface CustomCollapseProps {
|
||||
label: React.ReactNode
|
||||
extra: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const CustomCollapse: FC<CustomCollapseProps> = ({ label, extra, children }) => {
|
||||
const CollapseStyle = {
|
||||
background: 'transparent',
|
||||
border: '0.5px solid var(--color-border)'
|
||||
}
|
||||
const CollapseItemStyles = {
|
||||
header: {
|
||||
padding: '8px 16px',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
body: {
|
||||
borderTop: '0.5px solid var(--color-border)'
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Collapse
|
||||
bordered={false}
|
||||
style={CollapseStyle}
|
||||
defaultActiveKey={['1']}
|
||||
items={[
|
||||
{
|
||||
styles: CollapseItemStyles,
|
||||
key: '1',
|
||||
label,
|
||||
extra,
|
||||
children
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CustomCollapse)
|
||||
@ -1,6 +1,7 @@
|
||||
import {
|
||||
CloseOutlined,
|
||||
CodeOutlined,
|
||||
CopyOutlined,
|
||||
ExportOutlined,
|
||||
MinusOutlined,
|
||||
PushpinOutlined,
|
||||
@ -42,6 +43,9 @@ const MinappPopupContainer: React.FC = () => {
|
||||
const [isPopupShow, setIsPopupShow] = useState(true)
|
||||
/** whether the current minapp is ready */
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
/** the current REAL url of the minapp
|
||||
* different from the app preset url, because user may navigate in minapp */
|
||||
const [currentUrl, setCurrentUrl] = useState<string | null>(null)
|
||||
|
||||
/** store the last minapp id and show status */
|
||||
const lastMinappId = useRef<string | null>(null)
|
||||
@ -59,6 +63,11 @@ const MinappPopupContainer: React.FC = () => {
|
||||
/** set the popup display status */
|
||||
useEffect(() => {
|
||||
if (minappShow) {
|
||||
// init the current url
|
||||
if (currentMinappId && currentAppInfo) {
|
||||
setCurrentUrl(currentAppInfo.url)
|
||||
}
|
||||
|
||||
setIsPopupShow(true)
|
||||
|
||||
if (webviewLoadedRefs.current.get(currentMinappId)) {
|
||||
@ -77,6 +86,7 @@ const MinappPopupContainer: React.FC = () => {
|
||||
lastMinappId.current = currentMinappId
|
||||
lastMinappShow.current = minappShow
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [minappShow, currentMinappId])
|
||||
|
||||
useEffect(() => {
|
||||
@ -168,6 +178,13 @@ const MinappPopupContainer: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** the callback function to handle the webview navigate to new url */
|
||||
const handleWebviewNavigate = (appid: string, url: string) => {
|
||||
if (appid === currentMinappId) {
|
||||
setCurrentUrl(url)
|
||||
}
|
||||
}
|
||||
|
||||
/** will open the devtools of the minapp */
|
||||
const handleOpenDevTools = (appid: string) => {
|
||||
const webview = webviewRefs.current.get(appid)
|
||||
@ -187,12 +204,9 @@ const MinappPopupContainer: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** only open the current url */
|
||||
const handleOpenLink = (appid: string) => {
|
||||
const webview = webviewRefs.current.get(appid)
|
||||
if (webview) {
|
||||
window.api.openWebsite(webview.getURL())
|
||||
}
|
||||
/** open the giving url in browser */
|
||||
const handleOpenLink = (url: string) => {
|
||||
window.api.openWebsite(url)
|
||||
}
|
||||
|
||||
/** toggle the pin status of the minapp */
|
||||
@ -205,11 +219,41 @@ const MinappPopupContainer: React.FC = () => {
|
||||
}
|
||||
|
||||
/** Title bar of the popup */
|
||||
const Title = ({ appInfo }: { appInfo: AppInfo | null }) => {
|
||||
const Title = ({ appInfo, url }: { appInfo: AppInfo | null; url: string | null }) => {
|
||||
if (!appInfo) return null
|
||||
|
||||
const handleCopyUrl = (event: any, url: string) => {
|
||||
//don't show app-wide context menu
|
||||
event.preventDefault()
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => {
|
||||
window.message.success('URL ' + t('message.copy.success'))
|
||||
})
|
||||
.catch(() => {
|
||||
window.message.error('URL ' + t('message.copy.failed'))
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<TitleContainer style={{ justifyContent: 'space-between' }}>
|
||||
<TitleText>{appInfo.name}</TitleText>
|
||||
<Tooltip
|
||||
title={
|
||||
<TitleTextTooltip>
|
||||
{url ?? appInfo.url} <br />
|
||||
<CopyOutlined className="icon-copy" />
|
||||
{t('minapp.popup.rightclick_copyurl')}
|
||||
</TitleTextTooltip>
|
||||
}
|
||||
mouseEnterDelay={0.8}
|
||||
placement="rightBottom"
|
||||
styles={{
|
||||
root: {
|
||||
maxWidth: '400px'
|
||||
}
|
||||
}}>
|
||||
<TitleText onContextMenu={(e) => handleCopyUrl(e, url ?? appInfo.url)}>{appInfo.name}</TitleText>
|
||||
</Tooltip>
|
||||
<ButtonsGroup className={isWindows ? 'windows' : ''}>
|
||||
<Tooltip title={t('minapp.popup.refresh')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Button onClick={() => handleReload(appInfo.id)}>
|
||||
@ -228,7 +272,7 @@ const MinappPopupContainer: React.FC = () => {
|
||||
)}
|
||||
{appInfo.canOpenExternalLink && (
|
||||
<Tooltip title={t('minapp.popup.openExternal')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Button onClick={() => handleOpenLink(appInfo.id)}>
|
||||
<Button onClick={() => handleOpenLink(url ?? appInfo.url)}>
|
||||
<ExportOutlined />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@ -266,6 +310,7 @@ const MinappPopupContainer: React.FC = () => {
|
||||
url={app.url}
|
||||
onSetRefCallback={handleWebviewSetRef}
|
||||
onLoadedCallback={handleWebviewLoaded}
|
||||
onNavigateCallback={handleWebviewNavigate}
|
||||
/>
|
||||
))
|
||||
|
||||
@ -275,7 +320,7 @@ const MinappPopupContainer: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={<Title appInfo={currentAppInfo} />}
|
||||
title={<Title appInfo={currentAppInfo} url={currentUrl} />}
|
||||
placement="bottom"
|
||||
onClose={handlePopupMinimize}
|
||||
open={isPopupShow}
|
||||
@ -321,8 +366,18 @@ const TitleText = styled.div`
|
||||
font-size: 14px;
|
||||
color: var(--color-text-1);
|
||||
margin-right: 10px;
|
||||
user-select: none;
|
||||
-webkit-app-region: no-drag;
|
||||
`
|
||||
|
||||
const TitleTextTooltip = styled.span`
|
||||
font-size: 0.8rem;
|
||||
|
||||
.icon-copy {
|
||||
font-size: 0.7rem;
|
||||
padding-right: 5px;
|
||||
}
|
||||
`
|
||||
|
||||
const ButtonsGroup = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@ -11,12 +11,14 @@ const WebviewContainer = memo(
|
||||
appid,
|
||||
url,
|
||||
onSetRefCallback,
|
||||
onLoadedCallback
|
||||
onLoadedCallback,
|
||||
onNavigateCallback
|
||||
}: {
|
||||
appid: string
|
||||
url: string
|
||||
onSetRefCallback: (appid: string, element: WebviewTag | null) => void
|
||||
onLoadedCallback: (appid: string) => void
|
||||
onNavigateCallback: (appid: string, url: string) => void
|
||||
}) => {
|
||||
const webviewRef = useRef<WebviewTag | null>(null)
|
||||
|
||||
@ -47,8 +49,13 @@ const WebviewContainer = memo(
|
||||
onLoadedCallback(appid)
|
||||
}
|
||||
|
||||
const handleNavigate = (event: any) => {
|
||||
onNavigateCallback(appid, event.url)
|
||||
}
|
||||
|
||||
webviewRef.current.addEventListener('new-window', handleNewWindow)
|
||||
webviewRef.current.addEventListener('did-finish-load', handleLoaded)
|
||||
webviewRef.current.addEventListener('did-navigate-in-page', handleNavigate)
|
||||
|
||||
// we set the url when the webview is ready
|
||||
webviewRef.current.src = url
|
||||
@ -56,6 +63,7 @@ const WebviewContainer = memo(
|
||||
return () => {
|
||||
webviewRef.current?.removeEventListener('new-window', handleNewWindow)
|
||||
webviewRef.current?.removeEventListener('did-finish-load', handleLoaded)
|
||||
webviewRef.current?.removeEventListener('did-navigate-in-page', handleNavigate)
|
||||
}
|
||||
// because the appid and url are enough, no need to add onLoadedCallback
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@ -9,7 +9,7 @@ import { Agent, Assistant } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { Divider, Input, InputRef, Modal, Tag } from 'antd'
|
||||
import { take } from 'lodash'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -30,6 +30,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const inputRef = useRef<InputRef>(null)
|
||||
const systemAgents = useSystemAgents()
|
||||
const loadingRef = useRef(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const agents = useMemo(() => {
|
||||
const allAgents = [...userAgents, ...systemAgents] as Agent[]
|
||||
@ -52,25 +54,80 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
return filtered
|
||||
}, [assistants, defaultAssistant, searchText, systemAgents, userAgents])
|
||||
|
||||
const onCreateAssistant = async (agent: Agent) => {
|
||||
if (loadingRef.current) {
|
||||
return
|
||||
// 重置选中索引当搜索或列表内容变更时
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [agents.length, searchText])
|
||||
|
||||
const onCreateAssistant = useCallback(
|
||||
async (agent: Agent) => {
|
||||
if (loadingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
loadingRef.current = true
|
||||
let assistant: Assistant
|
||||
|
||||
if (agent.id === 'default') {
|
||||
assistant = { ...agent, id: uuid() }
|
||||
addAssistant(assistant)
|
||||
} else {
|
||||
assistant = await createAssistantFromAgent(agent)
|
||||
}
|
||||
|
||||
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
|
||||
resolve(assistant)
|
||||
setOpen(false)
|
||||
},
|
||||
[resolve, addAssistant, setOpen]
|
||||
) // 添加函数内使用的依赖项
|
||||
// 键盘导航处理
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const displayedAgents = take(agents, 100)
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => (prev >= displayedAgents.length - 1 ? 0 : prev + 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => (prev <= 0 ? displayedAgents.length - 1 : prev - 1))
|
||||
break
|
||||
case 'Enter':
|
||||
// 如果焦点在输入框且有搜索内容,则默认选择第一项
|
||||
if (document.activeElement === inputRef.current?.input && searchText.trim()) {
|
||||
e.preventDefault()
|
||||
onCreateAssistant(displayedAgents[selectedIndex])
|
||||
}
|
||||
// 否则选择当前选中项
|
||||
else if (selectedIndex >= 0 && selectedIndex < displayedAgents.length) {
|
||||
e.preventDefault()
|
||||
onCreateAssistant(displayedAgents[selectedIndex])
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
loadingRef.current = true
|
||||
let assistant: Assistant
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, selectedIndex, agents, searchText, onCreateAssistant])
|
||||
|
||||
if (agent.id === 'default') {
|
||||
assistant = { ...agent, id: uuid() }
|
||||
addAssistant(assistant)
|
||||
} else {
|
||||
assistant = await createAssistantFromAgent(agent)
|
||||
// 确保选中项在可视区域
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
const agentItems = containerRef.current.querySelectorAll('.agent-item')
|
||||
if (agentItems[selectedIndex]) {
|
||||
agentItems[selectedIndex].scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
|
||||
resolve(assistant)
|
||||
setOpen(false)
|
||||
}
|
||||
}, [selectedIndex])
|
||||
|
||||
const onCancel = () => {
|
||||
setOpen(false)
|
||||
@ -121,12 +178,13 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
/>
|
||||
</HStack>
|
||||
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} />
|
||||
<Container>
|
||||
{take(agents, 100).map((agent) => (
|
||||
<Container ref={containerRef}>
|
||||
{take(agents, 100).map((agent, index) => (
|
||||
<AgentItem
|
||||
key={agent.id}
|
||||
onClick={() => onCreateAssistant(agent)}
|
||||
className={agent.id === 'default' ? 'default' : ''}>
|
||||
className={`agent-item ${agent.id === 'default' ? 'default' : ''} ${index === selectedIndex ? 'keyboard-selected' : ''}`}
|
||||
onMouseEnter={() => setSelectedIndex(index)}>
|
||||
<HStack
|
||||
alignItems="center"
|
||||
gap={5}
|
||||
@ -161,9 +219,14 @@ const AgentItem = styled.div`
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
border: 1px solid transparent;
|
||||
&.default {
|
||||
background-color: var(--color-background-mute);
|
||||
}
|
||||
&.keyboard-selected {
|
||||
background-color: var(--color-background-mute);
|
||||
border: 1px solid var(--color-primary);
|
||||
}
|
||||
.anticon {
|
||||
font-size: 16px;
|
||||
color: var(--color-icon);
|
||||
|
||||
@ -361,6 +361,7 @@ const Icon = styled.div<{ theme: string }>`
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
-webkit-app-region: none;
|
||||
border: 0.5px solid transparent;
|
||||
.iconfont,
|
||||
@ -392,18 +393,34 @@ const Icon = styled.div<{ theme: string }>`
|
||||
|
||||
@keyframes borderBreath {
|
||||
0% {
|
||||
border-color: var(--color-primary-mute);
|
||||
opacity: 0.1;
|
||||
}
|
||||
50% {
|
||||
border-color: var(--color-primary);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
border-color: var(--color-primary-mute);
|
||||
opacity: 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
&.opened-animation {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&.opened-animation::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-radius: inherit;
|
||||
opacity: 0;
|
||||
will-change: opacity;
|
||||
border: 0.5px solid var(--color-primary);
|
||||
/* NOTICE: although we have optimized for the performance,
|
||||
* the infinite animation will still consume a little GPU resources,
|
||||
* it's a trade-off balance between performance and animation smoothness*/
|
||||
animation: borderBreath 4s ease-in-out infinite;
|
||||
}
|
||||
`
|
||||
|
||||
@ -205,7 +205,7 @@ export const FUNCTION_CALLING_MODELS = [
|
||||
'claude',
|
||||
'qwen',
|
||||
'hunyuan',
|
||||
'deepseek-ai/',
|
||||
'deepseek',
|
||||
'glm-4(?:-[\\w-]+)?',
|
||||
'learnlm(?:-[\\w-]+)?',
|
||||
'gemini(?:-[\\w-]+)?' // 提前排除了gemini的嵌入模型
|
||||
@ -1958,6 +1958,17 @@ export const TEXT_TO_IMAGES_MODELS_SUPPORT_IMAGE_ENHANCEMENT = [
|
||||
|
||||
export const GENERATE_IMAGE_MODELS = ['gemini-2.0-flash-exp-image-generation', 'gemini-2.0-flash-exp']
|
||||
|
||||
export const GEMINI_SEARCH_MODELS = [
|
||||
'gemini-2.0-flash',
|
||||
'gemini-2.0-flash-lite',
|
||||
'gemini-2.0-flash-exp',
|
||||
'gemini-2.0-flash-001',
|
||||
'gemini-2.0-pro-exp-02-05',
|
||||
'gemini-2.0-pro-exp',
|
||||
'gemini-2.5-pro-exp',
|
||||
'gemini-2.5-pro-exp-03-25'
|
||||
]
|
||||
|
||||
export function isTextToImageModel(model: Model): boolean {
|
||||
return TEXT_TO_IMAGE_REGEX.test(model.id)
|
||||
}
|
||||
@ -2062,34 +2073,25 @@ export function isWebSearchModel(model: Model): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
if (provider.id === 'aihubmix') {
|
||||
const models = ['gemini-2.0-flash-search', 'gemini-2.0-flash-exp-search', 'gemini-2.0-pro-exp-02-05-search']
|
||||
return models.includes(model?.id)
|
||||
}
|
||||
|
||||
if (provider?.type === 'openai') {
|
||||
if (model?.id?.includes('gemini-2.0-flash-exp')) {
|
||||
if (GEMINI_SEARCH_MODELS.includes(model?.id)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (provider.id === 'gemini' || provider?.type === 'gemini') {
|
||||
const models = [
|
||||
'gemini-2.0-flash',
|
||||
'gemini-2.0-flash-exp',
|
||||
'gemini-2.0-flash-001',
|
||||
'gemini-2.0-pro-exp-02-05',
|
||||
'gemini-2.0-pro-exp',
|
||||
'gemini-2.5-pro-exp',
|
||||
'gemini-2.5-pro-exp-03-25'
|
||||
]
|
||||
return models.includes(model?.id)
|
||||
return GEMINI_SEARCH_MODELS.includes(model?.id)
|
||||
}
|
||||
|
||||
if (provider.id === 'hunyuan') {
|
||||
return model?.id !== 'hunyuan-lite'
|
||||
}
|
||||
|
||||
if (provider.id === 'aihubmix') {
|
||||
const models = ['gemini-2.0-flash-search', 'gemini-2.0-flash-exp-search', 'gemini-2.0-pro-exp-02-05-search']
|
||||
return models.includes(model?.id)
|
||||
}
|
||||
|
||||
if (provider.id === 'zhipu') {
|
||||
return model?.id?.startsWith('glm-4-')
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
} from '@renderer/store/assistants'
|
||||
import { setDefaultModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm'
|
||||
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { TopicManager } from './useTopic'
|
||||
|
||||
@ -69,7 +70,10 @@ export function useAssistant(id: string) {
|
||||
updateTopic: (topic: Topic) => dispatch(updateTopic({ assistantId: assistant.id, topic })),
|
||||
updateTopics: (topics: Topic[]) => dispatch(updateTopics({ assistantId: assistant.id, topics })),
|
||||
removeAllTopics: () => dispatch(removeAllTopics({ assistantId: assistant.id })),
|
||||
setModel: (model: Model) => dispatch(setModel({ assistantId: assistant.id, model })),
|
||||
setModel: useCallback(
|
||||
(model: Model) => dispatch(setModel({ assistantId: assistant.id, model })),
|
||||
[dispatch, assistant.id]
|
||||
),
|
||||
updateAssistant: (assistant: Assistant) => dispatch(updateAssistant(assistant)),
|
||||
updateAssistantSettings: (settings: Partial<AssistantSettings>) => {
|
||||
dispatch(updateAssistantSettings({ assistantId: assistant.id, settings }))
|
||||
|
||||
@ -11,7 +11,7 @@ ipcRenderer.on('mcp:servers-changed', (_event, servers) => {
|
||||
|
||||
export const useMCPServers = () => {
|
||||
const mcpServers = useAppSelector((state) => state.mcp.servers)
|
||||
const activedMcpServers = useAppSelector((state) => state.mcp.servers?.filter((server) => server.isActive))
|
||||
const activedMcpServers = mcpServers.filter((server) => server.isActive)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
return {
|
||||
|
||||
@ -46,6 +46,11 @@
|
||||
"search": "Search assistants...",
|
||||
"settings.default_model": "Default Model",
|
||||
"settings.knowledge_base": "Knowledge Base Settings",
|
||||
"settings.mcp": "MCP Servers",
|
||||
"settings.mcp.enableFirst": "Enable this server in MCP settings first",
|
||||
"settings.mcp.title": "MCP Settings",
|
||||
"settings.mcp.noServersAvailable": "No MCP servers available. Add servers in settings",
|
||||
"settings.mcp.description": "Default enabled MCP servers",
|
||||
"settings.model": "Model Settings",
|
||||
"settings.preset_messages": "Preset Messages",
|
||||
"settings.prompt": "Prompt Settings",
|
||||
@ -145,7 +150,10 @@
|
||||
"history": "Chat History",
|
||||
"last": "Already at the last message",
|
||||
"next": "Next Message",
|
||||
"prev": "Previous Message"
|
||||
"prev": "Previous Message",
|
||||
"top": "Back to top",
|
||||
"bottom": "Back to bottom",
|
||||
"close": "Close"
|
||||
},
|
||||
"resend": "Resend",
|
||||
"save": "Save",
|
||||
@ -224,7 +232,10 @@
|
||||
"topics.title": "Topics",
|
||||
"topics.unpinned": "Unpinned Topics",
|
||||
"translate": "Translate",
|
||||
"topics.export.siyuan": "Export to Siyuan Note"
|
||||
"topics.export.siyuan": "Export to Siyuan Note",
|
||||
"topics.export.wait_for_title_naming": "Generating title...",
|
||||
"topics.export.title_naming_success": "Title generated successfully",
|
||||
"topics.export.title_naming_failed": "Failed to generate title, using default title"
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "Collapse",
|
||||
@ -553,7 +564,8 @@
|
||||
"close": "Close MinApp",
|
||||
"minimize": "Minimize MinApp",
|
||||
"devtools": "Developer Tools",
|
||||
"openExternal": "Open in Browser"
|
||||
"openExternal": "Open in Browser",
|
||||
"rightclick_copyurl": "Right-click to copy URL"
|
||||
},
|
||||
"sidebar.add.title": "Add to sidebar",
|
||||
"sidebar.remove.title": "Remove from sidebar",
|
||||
@ -919,7 +931,9 @@
|
||||
"new_folder.button.confirm": "Confirm",
|
||||
"new_folder.button.cancel": "Cancel",
|
||||
"new_folder.button": "New Folder"
|
||||
}
|
||||
},
|
||||
"message_title.use_topic_naming.title": "Use topic naming model to create titles for exported messages",
|
||||
"message_title.use_topic_naming.help": "When enabled, use topic naming model to create titles for exported messages. This will also affect all Markdown export methods."
|
||||
},
|
||||
"display.assistant.title": "Assistant Settings",
|
||||
"display.custom.css": "Custom CSS",
|
||||
@ -1045,7 +1059,10 @@
|
||||
"noToolsAvailable": "No tools available"
|
||||
},
|
||||
"deleteServer": "Delete Server",
|
||||
"deleteServerConfirm": "Are you sure you want to delete this server?"
|
||||
"deleteServerConfirm": "Are you sure you want to delete this server?",
|
||||
"registry": "Package Registry",
|
||||
"registryTooltip": "Choose the registry for package installation to resolve network issues with the default registry.",
|
||||
"registryDefault": "Default"
|
||||
},
|
||||
"messages.divider": "Show divider between messages",
|
||||
"messages.grid_columns": "Message grid display columns",
|
||||
@ -1195,7 +1212,7 @@
|
||||
"reset_defaults_confirm": "Are you sure you want to reset all shortcuts?",
|
||||
"reset_to_default": "Reset to Default",
|
||||
"search_message": "Search Message",
|
||||
"show_app": "Show App",
|
||||
"show_app": "Show/Hide App",
|
||||
"show_settings": "Open Settings",
|
||||
"title": "Keyboard Shortcuts",
|
||||
"toggle_new_context": "Clear Context",
|
||||
|
||||
@ -44,6 +44,11 @@
|
||||
"save.success": "保存に成功しました",
|
||||
"save.title": "エージェントに保存",
|
||||
"search": "アシスタントを検索...",
|
||||
"settings.mcp": "MCP サーバー",
|
||||
"settings.mcp.enableFirst": "まず MCP 設定でこのサーバーを有効にしてください",
|
||||
"settings.mcp.title": "MCP 設定",
|
||||
"settings.mcp.noServersAvailable": "利用可能な MCP サーバーがありません。設定でサーバーを追加してください",
|
||||
"settings.mcp.description": "デフォルトで有効な MCP サーバー",
|
||||
"settings.default_model": "デフォルトモデル",
|
||||
"settings.knowledge_base": "ナレッジベース設定",
|
||||
"settings.model": "モデル設定",
|
||||
@ -145,7 +150,10 @@
|
||||
"history": "チャット履歴",
|
||||
"last": "最後のメッセージです",
|
||||
"next": "次のメッセージ",
|
||||
"prev": "前のメッセージ"
|
||||
"prev": "前のメッセージ",
|
||||
"top": "トップに戻る",
|
||||
"bottom": "下部に戻る",
|
||||
"close": "閉じる"
|
||||
},
|
||||
"resend": "再送信",
|
||||
"save": "保存",
|
||||
@ -224,7 +232,10 @@
|
||||
"topics.title": "トピック",
|
||||
"topics.unpinned": "固定解除",
|
||||
"translate": "翻訳",
|
||||
"topics.export.siyuan": "思源笔记にエクスポート"
|
||||
"topics.export.siyuan": "思源笔记にエクスポート",
|
||||
"topics.export.wait_for_title_naming": "タイトルを生成中...",
|
||||
"topics.export.title_naming_success": "タイトルの生成に成功しました",
|
||||
"topics.export.title_naming_failed": "タイトルの生成に失敗しました。デフォルトのタイトルを使用します"
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "折りたたむ",
|
||||
@ -553,7 +564,8 @@
|
||||
"close": "ミニアプリを閉じる",
|
||||
"minimize": "ミニアプリを最小化",
|
||||
"devtools": "開発者ツール",
|
||||
"openExternal": "ブラウザで開く"
|
||||
"openExternal": "ブラウザで開く",
|
||||
"rightclick_copyurl": "右クリックでURLをコピー"
|
||||
},
|
||||
"sidebar.add.title": "サイドバーに追加",
|
||||
"sidebar.remove.title": "サイドバーから削除",
|
||||
@ -919,7 +931,9 @@
|
||||
"new_folder.button.confirm": "確認",
|
||||
"new_folder.button.cancel": "キャンセル",
|
||||
"new_folder.button": "新しいフォルダー"
|
||||
}
|
||||
},
|
||||
"message_title.use_topic_naming.title": "トピック命名モデルを使用してメッセージのタイトルを作成",
|
||||
"message_title.use_topic_naming.help": "この設定は、すべてのMarkdownエクスポート方法に影響します。"
|
||||
},
|
||||
"display.assistant.title": "アシスタント設定",
|
||||
"display.custom.css": "カスタムCSS",
|
||||
@ -1044,7 +1058,10 @@
|
||||
"noToolsAvailable": "利用可能なツールはありません"
|
||||
},
|
||||
"deleteServer": "サーバーを削除",
|
||||
"deleteServerConfirm": "このサーバーを削除してもよろしいですか?"
|
||||
"deleteServerConfirm": "このサーバーを削除してもよろしいですか?",
|
||||
"registry": "パッケージ管理レジストリ",
|
||||
"registryTooltip": "デフォルトのレジストリでネットワークの問題が発生した場合、パッケージインストールに使用するレジストリを選択してください。",
|
||||
"registryDefault": "デフォルト"
|
||||
},
|
||||
"messages.divider": "メッセージ間に区切り線を表示",
|
||||
"messages.grid_columns": "メッセージグリッドの表示列数",
|
||||
@ -1194,7 +1211,7 @@
|
||||
"reset_defaults_confirm": "すべてのショートカットをリセットしてもよろしいですか?",
|
||||
"reset_to_default": "デフォルトにリセット",
|
||||
"search_message": "メッセージを検索",
|
||||
"show_app": "アプリを表示",
|
||||
"show_app": "アプリを表示/非表示",
|
||||
"show_settings": "設定を開く",
|
||||
"title": "ショートカット",
|
||||
"toggle_new_context": "コンテキストをクリア",
|
||||
|
||||
@ -44,6 +44,11 @@
|
||||
"save.success": "Успешно сохранено",
|
||||
"save.title": "Сохранить в агента",
|
||||
"search": "Поиск ассистентов...",
|
||||
"settings.mcp": "Серверы MCP",
|
||||
"settings.mcp.enableFirst": "Сначала включите этот сервер в настройках MCP",
|
||||
"settings.mcp.title": "Настройки MCP",
|
||||
"settings.mcp.noServersAvailable": "Нет доступных серверов MCP. Добавьте серверы в настройках",
|
||||
"settings.mcp.description": "Серверы MCP, включенные по умолчанию",
|
||||
"settings.default_model": "Модель по умолчанию",
|
||||
"settings.knowledge_base": "Настройки базы знаний",
|
||||
"settings.model": "Настройки модели",
|
||||
@ -145,7 +150,10 @@
|
||||
"history": "История чата",
|
||||
"last": "Уже последнее сообщение",
|
||||
"next": "Следующее сообщение",
|
||||
"prev": "Предыдущее сообщение"
|
||||
"prev": "Предыдущее сообщение",
|
||||
"top": "Вернуться наверх",
|
||||
"bottom": "Вернуться вниз",
|
||||
"close": "Закрыть"
|
||||
},
|
||||
"resend": "Переотправить",
|
||||
"save": "Сохранить",
|
||||
@ -224,7 +232,10 @@
|
||||
"topics.title": "Топики",
|
||||
"topics.unpinned": "Открепленные темы",
|
||||
"translate": "Перевести",
|
||||
"topics.export.siyuan": "Экспорт в Siyuan Note"
|
||||
"topics.export.siyuan": "Экспорт в Siyuan Note",
|
||||
"topics.export.wait_for_title_naming": "Создание заголовка...",
|
||||
"topics.export.title_naming_success": "Заголовок успешно создан",
|
||||
"topics.export.title_naming_failed": "Не удалось создать заголовок, используется заголовок по умолчанию"
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "Свернуть",
|
||||
@ -553,7 +564,8 @@
|
||||
"close": "Закрыть встроенное приложение",
|
||||
"minimize": "Свернуть встроенное приложение",
|
||||
"devtools": "Инструменты разработчика",
|
||||
"openExternal": "Открыть в браузере"
|
||||
"openExternal": "Открыть в браузере",
|
||||
"rightclick_copyurl": "ПКМ → Копировать URL"
|
||||
},
|
||||
"sidebar.add.title": "Добавить в боковую панель",
|
||||
"sidebar.remove.title": "Удалить из боковой панели",
|
||||
@ -919,7 +931,9 @@
|
||||
"new_folder.button.confirm": "Подтвердить",
|
||||
"new_folder.button.cancel": "Отмена",
|
||||
"new_folder.button": "Новая папка"
|
||||
}
|
||||
},
|
||||
"message_title.use_topic_naming.title": "Использовать модель именования тем для создания заголовков сообщений",
|
||||
"message_title.use_topic_naming.help": "Этот параметр влияет на все методы экспорта в Markdown, такие как Notion, Yuque и т.д."
|
||||
},
|
||||
"display.assistant.title": "Настройки ассистентов",
|
||||
"display.custom.css": "Пользовательский CSS",
|
||||
@ -1044,7 +1058,10 @@
|
||||
"noToolsAvailable": "нет доступных инструментов"
|
||||
},
|
||||
"deleteServer": "Удалить сервер",
|
||||
"deleteServerConfirm": "Вы уверены, что хотите удалить этот сервер?"
|
||||
"deleteServerConfirm": "Вы уверены, что хотите удалить этот сервер?",
|
||||
"registry": "Реестр пакетов",
|
||||
"registryTooltip": "Выберите реестр для установки пакетов, если возникают проблемы с сетью при использовании реестра по умолчанию.",
|
||||
"registryDefault": "По умолчанию"
|
||||
},
|
||||
"messages.divider": "Показывать разделитель между сообщениями",
|
||||
"messages.grid_columns": "Количество столбцов сетки сообщений",
|
||||
@ -1194,7 +1211,7 @@
|
||||
"reset_defaults_confirm": "Вы уверены, что хотите сбросить все горячие клавиши?",
|
||||
"reset_to_default": "Сбросить настройки по умолчанию",
|
||||
"search_message": "Поиск сообщения",
|
||||
"show_app": "Показать приложение",
|
||||
"show_app": "Показать/скрыть приложение",
|
||||
"show_settings": "Открыть настройки",
|
||||
"title": "Горячие клавиши",
|
||||
"toggle_new_context": "Очистить контекст",
|
||||
|
||||
@ -44,6 +44,11 @@
|
||||
"save.success": "保存成功",
|
||||
"save.title": "保存到智能体",
|
||||
"search": "搜索助手",
|
||||
"settings.mcp": "MCP 服务器",
|
||||
"settings.mcp.enableFirst": "请先在 MCP 设置中启用此服务器",
|
||||
"settings.mcp.title": "MCP 设置",
|
||||
"settings.mcp.noServersAvailable": "无可用 MCP 服务器。请在设置中添加服务器",
|
||||
"settings.mcp.description": "默认启用的 MCP 服务器",
|
||||
"settings.default_model": "默认模型",
|
||||
"settings.knowledge_base": "知识库设置",
|
||||
"settings.model": "模型设置",
|
||||
@ -145,7 +150,10 @@
|
||||
"history": "聊天历史",
|
||||
"last": "已经是最后一条消息",
|
||||
"next": "下一条消息",
|
||||
"prev": "上一条消息"
|
||||
"prev": "上一条消息",
|
||||
"top": "回到顶部",
|
||||
"bottom": "回到底部",
|
||||
"close": "关闭"
|
||||
},
|
||||
"resend": "重新发送",
|
||||
"save": "保存",
|
||||
@ -224,7 +232,10 @@
|
||||
"topics.title": "话题",
|
||||
"topics.unpinned": "取消固定",
|
||||
"translate": "翻译",
|
||||
"topics.export.siyuan": "导出到思源笔记"
|
||||
"topics.export.siyuan": "导出到思源笔记",
|
||||
"topics.export.wait_for_title_naming": "正在生成标题...",
|
||||
"topics.export.title_naming_success": "标题生成成功",
|
||||
"topics.export.title_naming_failed": "标题生成失败,使用默认标题"
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "收起",
|
||||
@ -553,7 +564,8 @@
|
||||
"close": "关闭小程序",
|
||||
"minimize": "最小化小程序",
|
||||
"devtools": "开发者工具",
|
||||
"openExternal": "在浏览器中打开"
|
||||
"openExternal": "在浏览器中打开",
|
||||
"rightclick_copyurl": "右键复制URL"
|
||||
},
|
||||
"sidebar.add.title": "添加到侧边栏",
|
||||
"sidebar.remove.title": "从侧边栏移除",
|
||||
@ -800,6 +812,8 @@
|
||||
"markdown_export.path_placeholder": "导出路径",
|
||||
"markdown_export.select": "选择",
|
||||
"markdown_export.title": "Markdown 导出",
|
||||
"message_title.use_topic_naming.title": "使用话题命名模型为导出的消息创建标题",
|
||||
"message_title.use_topic_naming.help": "开启后,使用话题命名模型为导出的消息创建标题。该项也会影响所有通过Markdown导出的方式。",
|
||||
"minute_interval_one": "{{count}} 分钟",
|
||||
"minute_interval_other": "{{count}} 分钟",
|
||||
"notion.api_key": "Notion 密钥",
|
||||
@ -1045,7 +1059,10 @@
|
||||
"noToolsAvailable": "没有可用工具"
|
||||
},
|
||||
"deleteServer": "删除服务器",
|
||||
"deleteServerConfirm": "确定要删除此服务器吗?"
|
||||
"deleteServerConfirm": "确定要删除此服务器吗?",
|
||||
"registry": "包管理源",
|
||||
"registryTooltip": "选择用于安装包的源,以解决默认源的网络问题。",
|
||||
"registryDefault": "默认"
|
||||
},
|
||||
"messages.divider": "消息分割线",
|
||||
"messages.grid_columns": "消息网格展示列数",
|
||||
@ -1195,7 +1212,7 @@
|
||||
"reset_defaults_confirm": "确定要重置所有快捷键吗?",
|
||||
"reset_to_default": "重置为默认",
|
||||
"search_message": "搜索消息",
|
||||
"show_app": "显示应用",
|
||||
"show_app": "显示/隐藏应用",
|
||||
"show_settings": "打开设置",
|
||||
"title": "快捷方式",
|
||||
"toggle_new_context": "清除上下文",
|
||||
|
||||
@ -44,6 +44,11 @@
|
||||
"save.success": "儲存成功",
|
||||
"save.title": "儲存到智慧代理人",
|
||||
"search": "搜尋助手...",
|
||||
"settings.mcp": "MCP 伺服器",
|
||||
"settings.mcp.enableFirst": "請先在 MCP 設定中啟用此伺服器",
|
||||
"settings.mcp.title": "MCP 設定",
|
||||
"settings.mcp.noServersAvailable": "無可用 MCP 伺服器。請在設定中新增伺服器",
|
||||
"settings.mcp.description": "預設啟用的 MCP 伺服器",
|
||||
"settings.default_model": "預設模型",
|
||||
"settings.knowledge_base": "知識庫設定",
|
||||
"settings.model": "模型設定",
|
||||
@ -145,7 +150,10 @@
|
||||
"history": "聊天歷史",
|
||||
"last": "已經是最後一條訊息",
|
||||
"next": "下一條訊息",
|
||||
"prev": "上一條訊息"
|
||||
"prev": "上一條訊息",
|
||||
"top": "回到頂部",
|
||||
"bottom": "回到底部",
|
||||
"close": "關閉"
|
||||
},
|
||||
"resend": "重新傳送",
|
||||
"save": "儲存",
|
||||
@ -224,7 +232,10 @@
|
||||
"topics.title": "話題",
|
||||
"topics.unpinned": "取消固定",
|
||||
"translate": "翻譯",
|
||||
"topics.export.siyuan": "匯出到思源筆記"
|
||||
"topics.export.siyuan": "匯出到思源筆記",
|
||||
"topics.export.wait_for_title_naming": "正在生成標題...",
|
||||
"topics.export.title_naming_success": "標題生成成功",
|
||||
"topics.export.title_naming_failed": "標題生成失敗,使用預設標題"
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "折疊",
|
||||
@ -553,7 +564,8 @@
|
||||
"close": "關閉小工具",
|
||||
"minimize": "最小化小工具",
|
||||
"devtools": "開發者工具",
|
||||
"openExternal": "在瀏覽器中開啟"
|
||||
"openExternal": "在瀏覽器中開啟",
|
||||
"rightclick_copyurl": "右鍵複製URL"
|
||||
},
|
||||
"sidebar.add.title": "新增到側邊欄",
|
||||
"sidebar.remove.title": "從側邊欄移除",
|
||||
@ -919,7 +931,9 @@
|
||||
"new_folder.button.confirm": "確定",
|
||||
"new_folder.button.cancel": "取消",
|
||||
"new_folder.button": "新建文件夾"
|
||||
}
|
||||
},
|
||||
"message_title.use_topic_naming.title": "使用話題命名模型為導出的消息創建標題",
|
||||
"message_title.use_topic_naming.help": "此設定會影響所有通過Markdown導出的方式,如Notion、語雀等。"
|
||||
},
|
||||
"display.assistant.title": "助手設定",
|
||||
"display.custom.css": "自訂 CSS",
|
||||
@ -1044,7 +1058,10 @@
|
||||
"noToolsAvailable": "沒有可用工具"
|
||||
},
|
||||
"deleteServer": "刪除伺服器",
|
||||
"deleteServerConfirm": "確定要刪除此伺服器嗎?"
|
||||
"deleteServerConfirm": "確定要刪除此伺服器嗎?",
|
||||
"registry": "套件管理源",
|
||||
"registryTooltip": "選擇用於安裝套件的源,以解決預設源的網路問題。",
|
||||
"registryDefault": "預設"
|
||||
},
|
||||
"messages.divider": "訊息間顯示分隔線",
|
||||
"messages.grid_columns": "訊息網格展示列數",
|
||||
@ -1194,7 +1211,7 @@
|
||||
"reset_defaults_confirm": "確定要重設所有快捷鍵嗎?",
|
||||
"reset_to_default": "重設為預設",
|
||||
"search_message": "搜尋訊息",
|
||||
"show_app": "顯示應用程式",
|
||||
"show_app": "顯示/隱藏應用程式",
|
||||
"show_settings": "開啟設定",
|
||||
"title": "快速方式",
|
||||
"toggle_new_context": "清除上下文",
|
||||
|
||||
@ -130,7 +130,10 @@
|
||||
"first": "Ήδη το πρώτο μήνυμα",
|
||||
"last": "Ήδη το τελευταίο μήνυμα",
|
||||
"next": "Επόμενο μήνυμα",
|
||||
"prev": "Προηγούμενο μήνυμα"
|
||||
"prev": "Προηγούμενο μήνυμα",
|
||||
"top": "Επιστροφή στην κορυφή",
|
||||
"bottom": "Επιστροφή στο κάτω μέρος",
|
||||
"close": "Κλείσιμο"
|
||||
},
|
||||
"resend": "Ξαναστείλε",
|
||||
"save": "Αποθήκευση",
|
||||
|
||||
@ -130,7 +130,10 @@
|
||||
"first": "Ya es el primer mensaje",
|
||||
"last": "Ya es el último mensaje",
|
||||
"next": "Siguiente mensaje",
|
||||
"prev": "Mensaje anterior"
|
||||
"prev": "Mensaje anterior",
|
||||
"top": "Volver arriba",
|
||||
"bottom": "Volver abajo",
|
||||
"close": "Cerrar"
|
||||
},
|
||||
"resend": "Reenviar",
|
||||
"save": "Guardar",
|
||||
|
||||
@ -130,7 +130,10 @@
|
||||
"first": "Déjà premier message",
|
||||
"last": "Déjà dernier message",
|
||||
"next": "Prochain message",
|
||||
"prev": "Précédent message"
|
||||
"prev": "Précédent message",
|
||||
"top": "Retour en haut",
|
||||
"bottom": "Retour en bas",
|
||||
"close": "Fermer"
|
||||
},
|
||||
"resend": "Réenvoyer",
|
||||
"save": "Enregistrer",
|
||||
|
||||
@ -130,7 +130,10 @@
|
||||
"first": "Esta é a primeira mensagem",
|
||||
"last": "Esta é a última mensagem",
|
||||
"next": "Próxima mensagem",
|
||||
"prev": "Mensagem anterior"
|
||||
"prev": "Mensagem anterior",
|
||||
"top": "Voltar ao topo",
|
||||
"bottom": "Voltar ao fundo",
|
||||
"close": "Fechar"
|
||||
},
|
||||
"resend": "Reenviar",
|
||||
"save": "Salvar",
|
||||
|
||||
143
src/renderer/src/pages/files/FileItem.tsx
Normal file
143
src/renderer/src/pages/files/FileItem.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import {
|
||||
FileExcelFilled,
|
||||
FileImageFilled,
|
||||
FileMarkdownFilled,
|
||||
FilePdfFilled,
|
||||
FilePptFilled,
|
||||
FileTextFilled,
|
||||
FileUnknownFilled,
|
||||
FileWordFilled,
|
||||
FileZipFilled,
|
||||
FolderOpenFilled,
|
||||
GlobalOutlined,
|
||||
LinkOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { Flex } from 'antd'
|
||||
import React, { memo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface FileItemProps {
|
||||
fileInfo: {
|
||||
name: React.ReactNode | string
|
||||
ext: string
|
||||
extra?: React.ReactNode | string
|
||||
actions: React.ReactNode
|
||||
}
|
||||
}
|
||||
|
||||
const getFileIcon = (type?: string) => {
|
||||
if (!type) return <FileUnknownFilled />
|
||||
|
||||
const ext = type.toLowerCase()
|
||||
|
||||
if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) {
|
||||
return <FileImageFilled />
|
||||
}
|
||||
|
||||
if (['.doc', '.docx'].includes(ext)) {
|
||||
return <FileWordFilled />
|
||||
}
|
||||
if (['.xls', '.xlsx'].includes(ext)) {
|
||||
return <FileExcelFilled />
|
||||
}
|
||||
if (['.ppt', '.pptx'].includes(ext)) {
|
||||
return <FilePptFilled />
|
||||
}
|
||||
if (ext === '.pdf') {
|
||||
return <FilePdfFilled />
|
||||
}
|
||||
if (['.md', '.markdown'].includes(ext)) {
|
||||
return <FileMarkdownFilled />
|
||||
}
|
||||
|
||||
if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) {
|
||||
return <FileZipFilled />
|
||||
}
|
||||
|
||||
if (['.txt', '.json', '.log', '.yml', '.yaml', '.xml', '.csv'].includes(ext)) {
|
||||
return <FileTextFilled />
|
||||
}
|
||||
|
||||
if (['.url'].includes(ext)) {
|
||||
return <LinkOutlined />
|
||||
}
|
||||
|
||||
if (['.sitemap'].includes(ext)) {
|
||||
return <GlobalOutlined />
|
||||
}
|
||||
|
||||
if (['.folder'].includes(ext)) {
|
||||
return <FolderOpenFilled />
|
||||
}
|
||||
|
||||
return <FileUnknownFilled />
|
||||
}
|
||||
|
||||
const FileItem: React.FC<FileItemProps> = ({ fileInfo }) => {
|
||||
const { name, ext, extra, actions } = fileInfo
|
||||
|
||||
return (
|
||||
<FileItemCard>
|
||||
<CardContent>
|
||||
<FileIcon>{getFileIcon(ext)}</FileIcon>
|
||||
<Flex vertical gap={0} flex={1} style={{ width: '0px' }}>
|
||||
<FileName>{name}</FileName>
|
||||
{extra && <FileInfo>{extra}</FileInfo>}
|
||||
</Flex>
|
||||
{actions}
|
||||
</CardContent>
|
||||
</FileItemCard>
|
||||
)
|
||||
}
|
||||
|
||||
const FileItemCard = styled.div`
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 0.5px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
transition: box-shadow 0.2s ease;
|
||||
--shadow-color: rgba(0, 0, 0, 0.05);
|
||||
&:hover {
|
||||
box-shadow:
|
||||
0 10px 15px -3px var(--shadow-color),
|
||||
0 4px 6px -4px var(--shadow-color);
|
||||
}
|
||||
body[theme-mode='dark'] & {
|
||||
--shadow-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
`
|
||||
|
||||
const CardContent = styled.div`
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
`
|
||||
|
||||
const FileIcon = styled.div`
|
||||
color: var(--color-text-3);
|
||||
font-size: 32px;
|
||||
`
|
||||
|
||||
const FileName = styled.div`
|
||||
font-size: 15px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
span {
|
||||
font-size: 15px;
|
||||
}
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
`
|
||||
|
||||
const FileInfo = styled.div`
|
||||
font-size: 13px;
|
||||
color: var(--color-text-2);
|
||||
`
|
||||
|
||||
export default memo(FileItem)
|
||||
167
src/renderer/src/pages/files/FileList.tsx
Normal file
167
src/renderer/src/pages/files/FileList.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { FileType, FileTypes } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Col, Image, Row, Spin } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import VirtualList from 'rc-virtual-list'
|
||||
import React, { memo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import FileItem from './FileItem'
|
||||
import GeminiFiles from './GeminiFiles'
|
||||
|
||||
interface FileItemProps {
|
||||
id: FileTypes | 'all' | string
|
||||
list: {
|
||||
key: FileTypes | 'all' | string
|
||||
file: React.ReactNode
|
||||
files?: FileType[]
|
||||
count?: number
|
||||
size: string
|
||||
ext: string
|
||||
created_at: string
|
||||
actions: React.ReactNode
|
||||
}[]
|
||||
files?: FileType[]
|
||||
}
|
||||
|
||||
const FileList: React.FC<FileItemProps> = ({ id, list, files }) => {
|
||||
if (id === FileTypes.IMAGE && files?.length && files?.length > 0) {
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<Image.PreviewGroup>
|
||||
<Row gutter={[16, 16]}>
|
||||
{files?.map((file) => (
|
||||
<Col key={file.id} xs={24} sm={12} md={8} lg={4} xl={3}>
|
||||
<ImageWrapper>
|
||||
<LoadingWrapper>
|
||||
<Spin />
|
||||
</LoadingWrapper>
|
||||
<Image
|
||||
src={FileManager.getFileUrl(file)}
|
||||
style={{ height: '100%', objectFit: 'cover', cursor: 'pointer' }}
|
||||
preview={{ mask: false }}
|
||||
onLoad={(e) => {
|
||||
const img = e.target as HTMLImageElement
|
||||
img.parentElement?.classList.add('loaded')
|
||||
}}
|
||||
/>
|
||||
<ImageInfo>
|
||||
<div>{formatFileSize(file.size)}</div>
|
||||
</ImageInfo>
|
||||
</ImageWrapper>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Image.PreviewGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (id.startsWith('gemini_')) {
|
||||
return <GeminiFiles id={id.replace('gemini_', '') as string} />
|
||||
}
|
||||
|
||||
return (
|
||||
<VirtualList
|
||||
data={list}
|
||||
height={window.innerHeight - 100}
|
||||
itemHeight={80}
|
||||
itemKey="key"
|
||||
style={{ padding: '0 16px 16px 16px' }}
|
||||
styles={{
|
||||
verticalScrollBar: {
|
||||
width: 6
|
||||
},
|
||||
verticalScrollBarThumb: {
|
||||
background: 'var(--color-scrollbar-thumb)'
|
||||
}
|
||||
}}>
|
||||
{(item) => (
|
||||
<div
|
||||
style={{
|
||||
height: '80px',
|
||||
paddingTop: '12px'
|
||||
}}>
|
||||
<FileItem
|
||||
key={item.key}
|
||||
fileInfo={{
|
||||
name: item.file,
|
||||
ext: item.ext,
|
||||
extra: `${item.created_at} · ${t('files.count')} ${item.count} · ${item.size}`,
|
||||
actions: item.actions
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</VirtualList>
|
||||
)
|
||||
}
|
||||
|
||||
const ImageWrapper = styled.div`
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background-color: var(--color-background-soft);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0.5px solid var(--color-border);
|
||||
|
||||
.ant-image {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
|
||||
&.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.ant-image.loaded {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
div:last-child {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const LoadingWrapper = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-background-soft);
|
||||
`
|
||||
|
||||
const ImageInfo = styled.div`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
padding: 5px 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
font-size: 12px;
|
||||
|
||||
> div:first-child {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
`
|
||||
|
||||
export default memo(FileList)
|
||||
@ -1,14 +1,15 @@
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
EllipsisOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
FileImageOutlined,
|
||||
FilePdfOutlined,
|
||||
FileTextOutlined
|
||||
FileTextOutlined,
|
||||
SortAscendingOutlined,
|
||||
SortDescendingOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import db from '@renderer/databases'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
@ -16,18 +17,23 @@ import store from '@renderer/store'
|
||||
import { FileType, FileTypes } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { Button, Dropdown, Menu } from 'antd'
|
||||
import { Button, Empty, Flex, Menu, Popconfirm } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { FC, useMemo, useState } from 'react'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import ContentView from './ContentView'
|
||||
import FileList from './FileList'
|
||||
|
||||
type SortField = 'created_at' | 'size' | 'name'
|
||||
type SortOrder = 'asc' | 'desc'
|
||||
|
||||
const FilesPage: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [fileType, setFileType] = useState<string>('document')
|
||||
const [sortField, setSortField] = useState<SortField>('created_at')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
|
||||
const { providers } = useProviders()
|
||||
|
||||
const geminiProviders = providers.filter((provider) => provider.type === 'gemini')
|
||||
@ -42,6 +48,24 @@ const FilesPage: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const sortFiles = (files: FileType[]) => {
|
||||
return [...files].sort((a, b) => {
|
||||
let comparison = 0
|
||||
switch (sortField) {
|
||||
case 'created_at':
|
||||
comparison = dayjs(a.created_at).unix() - dayjs(b.created_at).unix()
|
||||
break
|
||||
case 'size':
|
||||
comparison = a.size - b.size
|
||||
break
|
||||
case 'name':
|
||||
comparison = a.origin_name.localeCompare(b.origin_name)
|
||||
break
|
||||
}
|
||||
return sortOrder === 'asc' ? comparison : -comparison
|
||||
})
|
||||
}
|
||||
|
||||
const files = useLiveQuery<FileType[]>(() => {
|
||||
if (fileType === 'all') {
|
||||
return db.files.orderBy('count').toArray().then(tempFilesSort)
|
||||
@ -49,6 +73,8 @@ const FilesPage: FC = () => {
|
||||
return db.files.where('type').equals(fileType).sortBy('count').then(tempFilesSort)
|
||||
}, [fileType])
|
||||
|
||||
const sortedFiles = files ? sortFiles(files) : []
|
||||
|
||||
const handleDelete = async (fileId: string) => {
|
||||
const file = await FileManager.getFile(fileId)
|
||||
|
||||
@ -89,95 +115,34 @@ const FilesPage: FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const getActionMenu = (fileId: string): MenuProps['items'] => [
|
||||
{
|
||||
key: 'rename',
|
||||
icon: <EditOutlined />,
|
||||
label: t('files.edit'),
|
||||
onClick: () => handleRename(fileId)
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
icon: <DeleteOutlined />,
|
||||
label: t('files.delete'),
|
||||
danger: true,
|
||||
onClick: () => {
|
||||
window.modal.confirm({
|
||||
title: t('files.delete.title'),
|
||||
content: t('files.delete.content'),
|
||||
centered: true,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => handleDelete(fileId)
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const dataSource = files?.map((file) => {
|
||||
const dataSource = sortedFiles?.map((file) => {
|
||||
return {
|
||||
key: file.id,
|
||||
file: (
|
||||
<FileNameText className="text-nowrap" onClick={() => window.api.file.openPath(file.path)}>
|
||||
{FileManager.formatFileName(file)}
|
||||
</FileNameText>
|
||||
),
|
||||
file: <span onClick={() => window.api.file.openPath(file.path)}>{FileManager.formatFileName(file)}</span>,
|
||||
size: formatFileSize(file.size),
|
||||
size_bytes: file.size,
|
||||
count: file.count,
|
||||
path: file.path,
|
||||
ext: file.ext,
|
||||
created_at: dayjs(file.created_at).format('MM-DD HH:mm'),
|
||||
created_at_unix: dayjs(file.created_at).unix(),
|
||||
actions: (
|
||||
<Dropdown menu={{ items: getActionMenu(file.id) }} trigger={['click']} placement="bottom" arrow>
|
||||
<Button type="text" size="small" icon={<EllipsisOutlined />} />
|
||||
</Dropdown>
|
||||
<Flex align="center" gap={0} style={{ opacity: 0.7 }}>
|
||||
<Button type="text" icon={<EditOutlined />} onClick={() => handleRename(file.id)} />
|
||||
<Popconfirm
|
||||
title={t('files.delete.title')}
|
||||
description={t('files.delete.content')}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={() => handleDelete(file.id)}
|
||||
icon={<ExclamationCircleOutlined style={{ color: 'red' }} />}>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t('files.name'),
|
||||
dataIndex: 'file',
|
||||
key: 'file',
|
||||
width: '300px'
|
||||
},
|
||||
{
|
||||
title: t('files.size'),
|
||||
dataIndex: 'size',
|
||||
key: 'size',
|
||||
width: '80px',
|
||||
sorter: (a: { size_bytes: number }, b: { size_bytes: number }) => b.size_bytes - a.size_bytes,
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: t('files.count'),
|
||||
dataIndex: 'count',
|
||||
key: 'count',
|
||||
width: '60px',
|
||||
sorter: (a: { count: number }, b: { count: number }) => b.count - a.count,
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: t('files.created_at'),
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: '120px',
|
||||
align: 'center',
|
||||
sorter: (a: { created_at_unix: number }, b: { created_at_unix: number }) =>
|
||||
b.created_at_unix - a.created_at_unix
|
||||
},
|
||||
{
|
||||
title: t('files.actions'),
|
||||
dataIndex: 'actions',
|
||||
key: 'actions',
|
||||
width: '80px',
|
||||
align: 'center'
|
||||
}
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
const menuItems = [
|
||||
{ key: FileTypes.DOCUMENT, label: t('files.document'), icon: <FilePdfOutlined /> },
|
||||
{ key: FileTypes.IMAGE, label: t('files.image'), icon: <FileImageOutlined /> },
|
||||
@ -199,9 +164,31 @@ const FilesPage: FC = () => {
|
||||
<SideNav>
|
||||
<Menu selectedKeys={[fileType]} items={menuItems} onSelect={({ key }) => setFileType(key as FileTypes)} />
|
||||
</SideNav>
|
||||
<TableContainer right>
|
||||
<ContentView id={fileType} files={files} dataSource={dataSource} columns={columns} />
|
||||
</TableContainer>
|
||||
<MainContent>
|
||||
<SortContainer>
|
||||
{['created_at', 'size', 'name'].map((field) => (
|
||||
<SortButton
|
||||
key={field}
|
||||
active={sortField === field}
|
||||
onClick={() => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortField(field as 'created_at' | 'size' | 'name')
|
||||
setSortOrder('desc')
|
||||
}
|
||||
}}>
|
||||
{t(`files.${field}`)}
|
||||
{sortField === field && (sortOrder === 'desc' ? <SortDescendingOutlined /> : <SortAscendingOutlined />)}
|
||||
</SortButton>
|
||||
))}
|
||||
</SortContainer>
|
||||
{dataSource && dataSource?.length > 0 ? (
|
||||
<FileList id={fileType} list={dataSource} files={sortedFiles} />
|
||||
) : (
|
||||
<Empty />
|
||||
)}
|
||||
</MainContent>
|
||||
</ContentContainer>
|
||||
</Container>
|
||||
)
|
||||
@ -214,6 +201,20 @@ const Container = styled.div`
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
`
|
||||
|
||||
const MainContent = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const SortContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 0.5px solid var(--color-border);
|
||||
`
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@ -221,19 +222,6 @@ const ContentContainer = styled.div`
|
||||
min-height: 100%;
|
||||
`
|
||||
|
||||
const TableContainer = styled(Scrollbar)`
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const FileNameText = styled.div`
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
`
|
||||
|
||||
const SideNav = styled.div`
|
||||
width: var(--assistants-width);
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
@ -266,4 +254,25 @@ const SideNav = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const SortButton = styled(Button)<{ active?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
height: 30px;
|
||||
border-radius: var(--list-item-border-radius);
|
||||
border: 0.5px solid ${(props) => (props.active ? 'var(--color-border)' : 'transparent')};
|
||||
background-color: ${(props) => (props.active ? 'var(--color-background-soft)' : 'transparent')};
|
||||
color: ${(props) => (props.active ? 'var(--color-text)' : 'var(--color-text-secondary)')};
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: 12px;
|
||||
}
|
||||
`
|
||||
|
||||
export default FilesPage
|
||||
|
||||
@ -2,12 +2,13 @@ import { DeleteOutlined } from '@ant-design/icons'
|
||||
import type { FileMetadataResponse } from '@google/generative-ai/server'
|
||||
import { useProvider } from '@renderer/hooks/useProvider'
|
||||
import { runAsyncFunction } from '@renderer/utils'
|
||||
import { Table } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { Spin } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { FC, useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import FileItem from './FileItem'
|
||||
|
||||
interface GeminiFilesProps {
|
||||
id: string
|
||||
}
|
||||
@ -15,7 +16,6 @@ interface GeminiFilesProps {
|
||||
const GeminiFiles: FC<GeminiFilesProps> = ({ id }) => {
|
||||
const { provider } = useProvider(id)
|
||||
const [files, setFiles] = useState<FileMetadataResponse[]>([])
|
||||
const { t } = useTranslation()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetchFiles = useCallback(async () => {
|
||||
@ -23,51 +23,6 @@ const GeminiFiles: FC<GeminiFilesProps> = ({ id }) => {
|
||||
files && setFiles(files.filter((file) => file.state === 'ACTIVE'))
|
||||
}, [provider])
|
||||
|
||||
const columns: ColumnsType<FileMetadataResponse> = [
|
||||
{
|
||||
title: t('files.name'),
|
||||
dataIndex: 'displayName',
|
||||
key: 'displayName'
|
||||
},
|
||||
{
|
||||
title: t('files.type'),
|
||||
dataIndex: 'mimeType',
|
||||
key: 'mimeType'
|
||||
},
|
||||
{
|
||||
title: t('files.size'),
|
||||
dataIndex: 'sizeBytes',
|
||||
key: 'sizeBytes',
|
||||
render: (size: string) => `${(parseInt(size) / 1024 / 1024).toFixed(2)} MB`
|
||||
},
|
||||
{
|
||||
title: t('files.created_at'),
|
||||
dataIndex: 'createTime',
|
||||
key: 'createTime',
|
||||
render: (time: string) => new Date(time).toLocaleString()
|
||||
},
|
||||
{
|
||||
title: t('files.actions'),
|
||||
dataIndex: 'actions',
|
||||
key: 'actions',
|
||||
align: 'center',
|
||||
render: (_, record) => {
|
||||
return (
|
||||
<DeleteOutlined
|
||||
style={{ cursor: 'pointer', color: 'var(--color-error)' }}
|
||||
onClick={() => {
|
||||
setFiles(files.filter((file) => file.name !== record.name))
|
||||
window.api.gemini.deleteFile(provider.apiKey, record.name).catch((error) => {
|
||||
console.error('Failed to delete file:', error)
|
||||
setFiles((prev) => [...prev, record])
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
runAsyncFunction(async () => {
|
||||
try {
|
||||
@ -86,13 +41,61 @@ const GeminiFiles: FC<GeminiFilesProps> = ({ id }) => {
|
||||
setFiles([])
|
||||
}, [id])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container>
|
||||
<LoadingWrapper>
|
||||
<Spin />
|
||||
</LoadingWrapper>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Table columns={columns} dataSource={files} rowKey="name" loading={loading} />
|
||||
<FileListContainer>
|
||||
{files.map((file) => (
|
||||
<FileItem
|
||||
key={file.name}
|
||||
fileInfo={{
|
||||
name: file.displayName,
|
||||
ext: `.${file.name.split('.').pop()}`,
|
||||
extra: `${dayjs(file.createTime).format('MM-DD HH:mm')} · ${(parseInt(file.sizeBytes) / 1024 / 1024).toFixed(2)} MB`,
|
||||
actions: (
|
||||
<DeleteOutlined
|
||||
style={{ cursor: 'pointer', color: 'var(--color-error)' }}
|
||||
onClick={() => {
|
||||
setFiles(files.filter((f) => f.name !== file.name))
|
||||
window.api.gemini.deleteFile(provider.apiKey, file.name).catch((error) => {
|
||||
console.error('Failed to delete file:', error)
|
||||
setFiles((prev) => [...prev, file])
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</FileListContainer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div``
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const FileListContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const LoadingWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
`
|
||||
|
||||
export default GeminiFiles
|
||||
|
||||
@ -13,6 +13,7 @@ import TranslateButton from '@renderer/components/TranslateButton'
|
||||
import { isFunctionCallingModel, isGenerateImageModel, isVisionModel, isWebSearchModel } from '@renderer/config/models'
|
||||
import db from '@renderer/databases'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
|
||||
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
|
||||
@ -91,7 +92,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
const [isTranslating, setIsTranslating] = useState(false)
|
||||
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<KnowledgeBase[]>([])
|
||||
const [mentionModels, setMentionModels] = useState<Model[]>([])
|
||||
const [enabledMCPs, setEnabledMCPs] = useState<MCPServer[]>([])
|
||||
const [enabledMCPs, setEnabledMCPs] = useState<MCPServer[]>(assistant.mcpServers || [])
|
||||
const [isMentionPopupOpen, setIsMentionPopupOpen] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [textareaHeight, setTextareaHeight] = useState<number>()
|
||||
@ -101,6 +102,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
const isVision = useMemo(() => isVisionModel(model), [model])
|
||||
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
|
||||
const navigate = useNavigate()
|
||||
const { activedMcpServers } = useMCPServers()
|
||||
|
||||
const showKnowledgeIcon = useSidebarIconShow('knowledge')
|
||||
const showMCPToolsIcon = isFunctionCallingModel(model)
|
||||
@ -145,6 +147,11 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
}
|
||||
}, [textareaHeight])
|
||||
|
||||
// Reset to assistant knowledge mcp servers
|
||||
useEffect(() => {
|
||||
setEnabledMCPs(assistant.mcpServers || [])
|
||||
}, [assistant.mcpServers])
|
||||
|
||||
const sendMessage = useCallback(async () => {
|
||||
if (inputEmpty || loading) {
|
||||
return
|
||||
@ -174,8 +181,12 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
userMessage.mentions = mentionModels
|
||||
}
|
||||
|
||||
if (enabledMCPs) {
|
||||
userMessage.enabledMCPs = enabledMCPs
|
||||
if (isFunctionCallingModel(model)) {
|
||||
if (!isEmpty(assistant.mcpServers) && !isEmpty(activedMcpServers)) {
|
||||
userMessage.enabledMCPs = activedMcpServers.filter((server) =>
|
||||
assistant.mcpServers?.some((s) => s.id === server.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
userMessage.usage = await estimateMessageUsage(userMessage)
|
||||
@ -197,13 +208,14 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
console.error('Failed to send message:', error)
|
||||
}
|
||||
}, [
|
||||
activedMcpServers,
|
||||
assistant,
|
||||
dispatch,
|
||||
enabledMCPs,
|
||||
files,
|
||||
inputEmpty,
|
||||
loading,
|
||||
mentionModels,
|
||||
model,
|
||||
resizeTextArea,
|
||||
selectedKnowledgeBases,
|
||||
text,
|
||||
@ -323,8 +335,11 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
await db.topics.add({ id: topic.id, messages: [] })
|
||||
await addAssistantMessagesToTopic({ assistant, topic })
|
||||
|
||||
// Clear previous state
|
||||
// Reset to assistant default model
|
||||
assistant.defaultModel && setModel(assistant.defaultModel)
|
||||
// Reset to assistant knowledge mcp servers
|
||||
setEnabledMCPs(assistant.mcpServers || [])
|
||||
|
||||
addTopic(topic)
|
||||
setActiveTopic(topic)
|
||||
|
||||
@ -19,6 +19,10 @@ const MCPToolsButton: FC<Props> = ({ enabledMCPs, toggelEnableMCP, ToolbarButton
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const availableMCPs = activedMcpServers.filter((server) => enabledMCPs.some((s) => s.id === server.id))
|
||||
|
||||
const buttonEnabled = availableMCPs.length > 0
|
||||
|
||||
const truncateText = (text: string, maxLength: number = 50) => {
|
||||
if (!text || text.length <= maxLength) return text
|
||||
return text.substring(0, maxLength) + '...'
|
||||
@ -102,7 +106,7 @@ const MCPToolsButton: FC<Props> = ({ enabledMCPs, toggelEnableMCP, ToolbarButton
|
||||
overlayClassName="mention-models-dropdown">
|
||||
<Tooltip placement="top" title={t('settings.mcp.title')} arrow>
|
||||
<ToolbarButton type="text" ref={dropdownRef}>
|
||||
<CodeOutlined style={{ color: enabledMCPs.length > 0 ? 'var(--color-primary)' : 'var(--color-icon)' }} />
|
||||
<CodeOutlined style={{ color: buttonEnabled ? 'var(--color-primary)' : 'var(--color-icon)' }} />
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
|
||||
@ -75,6 +75,7 @@ const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
padding-bottom: 10px;
|
||||
`
|
||||
|
||||
export default Artifacts
|
||||
|
||||
@ -37,12 +37,17 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
|
||||
|
||||
const showDownloadButton = ['csv', 'json', 'txt', 'md'].includes(language)
|
||||
|
||||
const shouldShowExpandButtonRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const loadHighlightedCode = async () => {
|
||||
const highlightedHtml = await codeToHtml(children, language)
|
||||
if (codeContentRef.current) {
|
||||
codeContentRef.current.innerHTML = highlightedHtml
|
||||
setShouldShowExpandButton(codeContentRef.current.scrollHeight > 350)
|
||||
const isShowExpandButton = codeContentRef.current.scrollHeight > 350
|
||||
if (shouldShowExpandButtonRef.current === isShowExpandButton) return
|
||||
shouldShowExpandButtonRef.current = isShowExpandButton
|
||||
setShouldShowExpandButton(shouldShowExpandButtonRef.current)
|
||||
}
|
||||
}
|
||||
loadHighlightedCode()
|
||||
@ -98,12 +103,18 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
|
||||
)}
|
||||
<CodeLanguage>{'<' + language.toUpperCase() + '>'}</CodeLanguage>
|
||||
</div>
|
||||
<HStack gap={12} alignItems="center">
|
||||
</CodeHeader>
|
||||
<StickyWrapper>
|
||||
<HStack
|
||||
position="absolute"
|
||||
gap={12}
|
||||
alignItems="center"
|
||||
style={{ bottom: '0.2rem', right: '1rem', height: '27px' }}>
|
||||
{showDownloadButton && <DownloadButton language={language} data={children} />}
|
||||
{codeWrappable && <UnwrapButton unwrapped={isUnwrapped} onClick={() => setIsUnwrapped(!isUnwrapped)} />}
|
||||
<CopyButton text={children} />
|
||||
</HStack>
|
||||
</CodeHeader>
|
||||
</StickyWrapper>
|
||||
<CodeContent
|
||||
ref={codeContentRef}
|
||||
isShowLineNumbers={codeShowLineNumbers}
|
||||
@ -211,7 +222,9 @@ const DownloadButton = ({ language, data }: { language: string; data: string })
|
||||
)
|
||||
}
|
||||
|
||||
const CodeBlockWrapper = styled.div``
|
||||
const CodeBlockWrapper = styled.div`
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const CodeContent = styled.div<{ isShowLineNumbers: boolean; isUnwrapped: boolean; isCodeWrappable: boolean }>`
|
||||
.shiki {
|
||||
@ -376,4 +389,10 @@ const DownloadWrapper = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const StickyWrapper = styled.div`
|
||||
position: sticky;
|
||||
top: 28px;
|
||||
z-index: 10;
|
||||
`
|
||||
|
||||
export default memo(CodeBlock)
|
||||
|
||||
@ -7,7 +7,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import type { Message } from '@renderer/types'
|
||||
import { escapeBrackets, removeSvgEmptyLines, withGeminiGrounding } from '@renderer/utils/formats'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { type FC, useCallback, useMemo } from 'react'
|
||||
import { type FC, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
@ -37,6 +37,8 @@ interface Props {
|
||||
>
|
||||
}
|
||||
|
||||
const remarkPlugins = [remarkMath, remarkGfm, remarkCjkFriendly]
|
||||
const disallowedElements = ['iframe']
|
||||
const Markdown: FC<Props> = ({ message, citationsData }) => {
|
||||
const { t } = useTranslation()
|
||||
const { renderInputMessageAsMarkdown, mathEngine } = useSettings()
|
||||
@ -55,7 +57,7 @@ const Markdown: FC<Props> = ({ message, citationsData }) => {
|
||||
return hasElements ? [rehypeRaw, rehypeMath] : [rehypeMath]
|
||||
}, [messageContent, rehypeMath])
|
||||
|
||||
const components = useCallback(() => {
|
||||
const components = useMemo(() => {
|
||||
const baseComponents = {
|
||||
a: (props: any) => {
|
||||
if (props.href && citationsData?.has(props.href)) {
|
||||
@ -64,15 +66,12 @@ const Markdown: FC<Props> = ({ message, citationsData }) => {
|
||||
return <Link {...props} />
|
||||
},
|
||||
code: CodeBlock,
|
||||
img: ImagePreview
|
||||
img: ImagePreview,
|
||||
pre: (props: any) => <pre style={{ overflow: 'visible' }} {...props} />,
|
||||
style: MarkdownShadowDOMRenderer as any
|
||||
} as Partial<Components>
|
||||
|
||||
if (messageContent.includes('<style>')) {
|
||||
baseComponents.style = MarkdownShadowDOMRenderer as any
|
||||
}
|
||||
|
||||
return baseComponents
|
||||
}, [messageContent, citationsData])
|
||||
}, [citationsData])
|
||||
|
||||
if (message.role === 'user' && !renderInputMessageAsMarkdown) {
|
||||
return <p style={{ marginBottom: 5, whiteSpace: 'pre-wrap' }}>{messageContent}</p>
|
||||
@ -81,10 +80,10 @@ const Markdown: FC<Props> = ({ message, citationsData }) => {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
rehypePlugins={rehypePlugins}
|
||||
remarkPlugins={[remarkMath, remarkGfm, remarkCjkFriendly]}
|
||||
remarkPlugins={remarkPlugins}
|
||||
className="markdown"
|
||||
components={components()}
|
||||
disallowedElements={['iframe']}
|
||||
components={components}
|
||||
disallowedElements={disallowedElements}
|
||||
remarkRehypeOptions={{
|
||||
footnoteLabel: t('common.footnotes'),
|
||||
footnoteLabelTagName: 'h4',
|
||||
|
||||
@ -3,6 +3,7 @@ import '@xyflow/react/dist/style.css'
|
||||
import { RobotOutlined, UserOutlined } from '@ant-design/icons'
|
||||
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
||||
import { getModelLogo } from '@renderer/config/models'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import { RootState } from '@renderer/store'
|
||||
@ -190,6 +191,7 @@ const ChatFlowHistory: FC<ChatFlowHistoryProps> = ({ conversationId }) => {
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<any>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { userName } = useSettings()
|
||||
const { theme } = useTheme()
|
||||
|
||||
const topicId = conversationId
|
||||
|
||||
@ -478,7 +480,8 @@ const ChatFlowHistory: FC<ChatFlowHistoryProps> = ({ conversationId }) => {
|
||||
maxZoom: 1
|
||||
}}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
className="react-flow-container">
|
||||
className="react-flow-container"
|
||||
colorMode={theme === 'auto' ? 'system' : theme}>
|
||||
<Controls showInteractive={false} />
|
||||
<MiniMap
|
||||
nodeStrokeWidth={3}
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
import { DownOutlined, HistoryOutlined, UpOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
ArrowDownOutlined,
|
||||
ArrowUpOutlined,
|
||||
CloseOutlined,
|
||||
HistoryOutlined,
|
||||
VerticalAlignBottomOutlined,
|
||||
VerticalAlignTopOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { RootState } from '@renderer/store'
|
||||
import { selectCurrentTopicId } from '@renderer/store/messages'
|
||||
@ -20,6 +27,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
const [isNearButtons, setIsNearButtons] = useState(false)
|
||||
const [hideTimer, setHideTimer] = useState<NodeJS.Timeout | null>(null)
|
||||
const [showChatHistory, setShowChatHistory] = useState(false)
|
||||
const [manuallyClosedUntil, setManuallyClosedUntil] = useState<number | null>(null)
|
||||
const currentTopicId = useSelector((state: RootState) => selectCurrentTopicId(state))
|
||||
const lastMoveTime = useRef(0)
|
||||
const { topicPosition, showTopics } = useSettings()
|
||||
@ -44,6 +52,10 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
|
||||
// Handle mouse entering button area
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (manuallyClosedUntil && Date.now() < manuallyClosedUntil) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsNearButtons(true)
|
||||
setIsVisible(true)
|
||||
|
||||
@ -52,7 +64,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
clearTimeout(hideTimer)
|
||||
setHideTimer(null)
|
||||
}
|
||||
}, [hideTimer])
|
||||
}, [hideTimer, manuallyClosedUntil])
|
||||
|
||||
// Handle mouse leaving button area
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
@ -97,7 +109,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
|
||||
const scrollToTop = () => {
|
||||
const container = document.getElementById(containerId)
|
||||
container && container.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
container && container.scrollTo({ top: -container.scrollHeight, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
@ -148,6 +160,23 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
return -1
|
||||
}
|
||||
|
||||
// 修改 handleCloseChatNavigation 函数
|
||||
const handleCloseChatNavigation = () => {
|
||||
setIsVisible(false)
|
||||
// 设置手动关闭状态,1分钟内不响应鼠标靠近事件
|
||||
setManuallyClosedUntil(Date.now() + 60000) // 60000毫秒 = 1分钟
|
||||
}
|
||||
|
||||
const handleScrollToTop = () => {
|
||||
resetHideTimer()
|
||||
scrollToTop()
|
||||
}
|
||||
|
||||
const handleScrollToBottom = () => {
|
||||
resetHideTimer()
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
const handleNextMessage = () => {
|
||||
resetHideTimer()
|
||||
const userMessages = findUserMessages()
|
||||
@ -216,6 +245,11 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
|
||||
// Throttled mouse move handler to improve performance
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
// 如果在手动关闭期间,不响应鼠标移动事件
|
||||
if (manuallyClosedUntil && Date.now() < manuallyClosedUntil) {
|
||||
return
|
||||
}
|
||||
|
||||
// Throttle mouse move to every 50ms for performance
|
||||
const now = Date.now()
|
||||
if (now - lastMoveTime.current < 50) return
|
||||
@ -262,16 +296,43 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
clearTimeout(hideTimer)
|
||||
}
|
||||
}
|
||||
}, [containerId, hideTimer, resetHideTimer, isNearButtons, handleMouseEnter, handleMouseLeave, showRightTopics])
|
||||
}, [
|
||||
containerId,
|
||||
hideTimer,
|
||||
resetHideTimer,
|
||||
isNearButtons,
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
showRightTopics,
|
||||
manuallyClosedUntil
|
||||
])
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavigationContainer $isVisible={isVisible} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||
<ButtonGroup>
|
||||
<Tooltip title={t('chat.navigation.close')} placement="left">
|
||||
<NavigationButton
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={handleCloseChatNavigation}
|
||||
aria-label={t('chat.navigation.close')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Divider />
|
||||
<Tooltip title={t('chat.navigation.top')} placement="left">
|
||||
<NavigationButton
|
||||
type="text"
|
||||
icon={<VerticalAlignTopOutlined />}
|
||||
onClick={handleScrollToTop}
|
||||
aria-label={t('chat.navigation.top')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Divider />
|
||||
<Tooltip title={t('chat.navigation.prev')} placement="left">
|
||||
<NavigationButton
|
||||
type="text"
|
||||
icon={<UpOutlined />}
|
||||
icon={<ArrowUpOutlined />}
|
||||
onClick={handlePrevMessage}
|
||||
aria-label={t('chat.navigation.prev')}
|
||||
/>
|
||||
@ -280,12 +341,21 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
<Tooltip title={t('chat.navigation.next')} placement="left">
|
||||
<NavigationButton
|
||||
type="text"
|
||||
icon={<DownOutlined />}
|
||||
icon={<ArrowDownOutlined />}
|
||||
onClick={handleNextMessage}
|
||||
aria-label={t('chat.navigation.next')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Divider />
|
||||
<Tooltip title={t('chat.navigation.bottom')} placement="left">
|
||||
<NavigationButton
|
||||
type="text"
|
||||
icon={<VerticalAlignBottomOutlined />}
|
||||
onClick={handleScrollToBottom}
|
||||
aria-label={t('chat.navigation.bottom')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Divider />
|
||||
<Tooltip title={t('chat.navigation.history')} placement="left">
|
||||
<NavigationButton
|
||||
type="text"
|
||||
|
||||
@ -143,7 +143,7 @@ const MessageItem: FC<Props> = ({
|
||||
<MessageHeader message={message} assistant={assistant} model={model} key={getModelUniqId(model)} />
|
||||
<MessageContentContainer
|
||||
className="message-content-container"
|
||||
style={{ fontFamily, fontSize, background: messageBackground }}>
|
||||
style={{ fontFamily, fontSize, background: messageBackground, overflowY: 'visible' }}>
|
||||
<MessageErrorBoundary>
|
||||
<MessageContent message={message} model={model} />
|
||||
</MessageErrorBoundary>
|
||||
|
||||
@ -286,6 +286,7 @@ const GridContainer = styled.div<{ $count: number; $layout: MultiModelMessageSty
|
||||
grid-template-rows: auto;
|
||||
gap: 16px;
|
||||
`}
|
||||
overflow-y: visible;
|
||||
`
|
||||
|
||||
interface MessageWrapperProps {
|
||||
@ -327,14 +328,14 @@ const MessageWrapper = styled(Scrollbar)<MessageWrapperProps>`
|
||||
return $layout === 'grid' && $isGrouped
|
||||
? css`
|
||||
max-height: ${$isInPopover ? '50vh' : '300px'};
|
||||
overflow-y: ${$isInPopover ? 'auto' : 'hidden'};
|
||||
overflow-y: ${$isInPopover ? 'visible' : 'hidden'};
|
||||
border: 0.5px solid ${$isInPopover ? 'transparent' : 'var(--color-border)'};
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--color-background);
|
||||
`
|
||||
: css`
|
||||
overflow-y: auto;
|
||||
overflow-y: visible;
|
||||
border-radius: 6px;
|
||||
`
|
||||
}}
|
||||
|
||||
@ -198,7 +198,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
key: 'image',
|
||||
onClick: async () => {
|
||||
const imageData = await captureScrollableDivAsDataURL(messageContainerRef)
|
||||
const title = getMessageTitle(message)
|
||||
const title = await getMessageTitle(message)
|
||||
if (title && imageData) {
|
||||
window.api.file.saveImage(title, imageData)
|
||||
}
|
||||
@ -211,14 +211,15 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
key: 'word',
|
||||
onClick: async () => {
|
||||
const markdown = messageToMarkdown(message)
|
||||
window.api.export.toWord(markdown, getMessageTitle(message))
|
||||
const title = await getMessageTitle(message)
|
||||
window.api.export.toWord(markdown, title)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('chat.topics.export.notion'),
|
||||
key: 'notion',
|
||||
onClick: async () => {
|
||||
const title = getMessageTitle(message)
|
||||
const title = await getMessageTitle(message)
|
||||
const markdown = messageToMarkdown(message)
|
||||
exportMarkdownToNotion(title, markdown)
|
||||
}
|
||||
@ -227,7 +228,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
label: t('chat.topics.export.yuque'),
|
||||
key: 'yuque',
|
||||
onClick: async () => {
|
||||
const title = getMessageTitle(message)
|
||||
const title = await getMessageTitle(message)
|
||||
const markdown = messageToMarkdown(message)
|
||||
exportMarkdownToYuque(title, markdown)
|
||||
}
|
||||
@ -245,7 +246,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
label: t('chat.topics.export.joplin'),
|
||||
key: 'joplin',
|
||||
onClick: async () => {
|
||||
const title = getMessageTitle(message)
|
||||
const title = await getMessageTitle(message)
|
||||
const markdown = messageToMarkdown(message)
|
||||
exportMarkdownToJoplin(title, markdown)
|
||||
}
|
||||
@ -254,7 +255,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
label: t('chat.topics.export.siyuan'),
|
||||
key: 'siyuan',
|
||||
onClick: async () => {
|
||||
const title = getMessageTitle(message)
|
||||
const title = await getMessageTitle(message)
|
||||
const markdown = messageToMarkdown(message)
|
||||
exportMarkdownToSiyuan(title, markdown)
|
||||
}
|
||||
|
||||
@ -256,7 +256,8 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
|
||||
hasMore={hasMore}
|
||||
loader={null}
|
||||
scrollableTarget="messages"
|
||||
inverse>
|
||||
inverse
|
||||
style={{ overflow: 'visible' }}>
|
||||
<ScrollContainer>
|
||||
<LoaderContainer $loading={isLoadingMore}>
|
||||
<BeatLoader size={8} color="var(--color-text-2)" />
|
||||
|
||||
@ -13,7 +13,7 @@ import { hasTopicPendingRequests } from '@renderer/utils/queue'
|
||||
import { Dropdown } from 'antd'
|
||||
import { ItemType } from 'antd/es/menu/interface'
|
||||
import { omit } from 'lodash'
|
||||
import { FC, useCallback, useEffect, useState } from 'react'
|
||||
import { FC, startTransition, useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -117,11 +117,16 @@ const AssistantItem: FC<AssistantItemProps> = ({ assistant, isActive, onSwitch,
|
||||
const handleSwitch = useCallback(async () => {
|
||||
await modelGenerating()
|
||||
|
||||
if (topicPosition === 'left' && clickAssistantToShowTopic) {
|
||||
EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)
|
||||
if (clickAssistantToShowTopic) {
|
||||
if (topicPosition === 'left') {
|
||||
EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)
|
||||
}
|
||||
onSwitch(assistant)
|
||||
} else {
|
||||
startTransition(() => {
|
||||
onSwitch(assistant)
|
||||
})
|
||||
}
|
||||
|
||||
onSwitch(assistant)
|
||||
}, [clickAssistantToShowTopic, onSwitch, assistant, topicPosition])
|
||||
|
||||
const assistantName = assistant.name || t('chat.default.name')
|
||||
|
||||
@ -37,7 +37,7 @@ import { hasTopicPendingRequests } from '@renderer/utils/queue'
|
||||
import { Dropdown, MenuProps, Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { findIndex } from 'lodash'
|
||||
import { FC, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { FC, startTransition, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -146,7 +146,9 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
const onSwitchTopic = useCallback(
|
||||
async (topic: Topic) => {
|
||||
// await modelGenerating()
|
||||
setActiveTopic(topic)
|
||||
startTransition(() => {
|
||||
setActiveTopic(topic)
|
||||
})
|
||||
},
|
||||
[setActiveTopic]
|
||||
)
|
||||
|
||||
@ -2,16 +2,13 @@ import {
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
FileTextOutlined,
|
||||
FolderOutlined,
|
||||
GlobalOutlined,
|
||||
LinkOutlined,
|
||||
PlusOutlined,
|
||||
RedoOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined
|
||||
} from '@ant-design/icons'
|
||||
import Ellipsis from '@renderer/components/Ellipsis'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
||||
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
@ -19,24 +16,29 @@ import { useKnowledge } from '@renderer/hooks/useKnowledge'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { getProviderName } from '@renderer/services/ProviderService'
|
||||
import { FileType, FileTypes, KnowledgeBase, KnowledgeItem } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { bookExts, documentExts, textExts, thirdPartyApplicationExts } from '@shared/config/constant'
|
||||
import { Alert, Button, Card, Divider, Dropdown, message, Tag, Tooltip, Typography, Upload } from 'antd'
|
||||
import { Alert, Button, Dropdown, Empty, message, Tag, Tooltip, Upload } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import VirtualList from 'rc-virtual-list'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import CustomCollapse from '../../components/CustomCollapse'
|
||||
import FileItem from '../files/FileItem'
|
||||
import KnowledgeSearchPopup from './components/KnowledgeSearchPopup'
|
||||
import KnowledgeSettingsPopup from './components/KnowledgeSettingsPopup'
|
||||
import StatusIcon from './components/StatusIcon'
|
||||
|
||||
const { Dragger } = Upload
|
||||
const { Title } = Typography
|
||||
|
||||
interface KnowledgeContentProps {
|
||||
selectedBase: KnowledgeBase
|
||||
}
|
||||
|
||||
const fileTypes = [...bookExts, ...thirdPartyApplicationExts, ...documentExts, ...textExts]
|
||||
|
||||
const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@ -234,13 +236,21 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
{!providerName && (
|
||||
<Alert message={t('knowledge.no_provider')} type="error" style={{ marginBottom: 20 }} showIcon />
|
||||
)}
|
||||
<FileSection>
|
||||
<TitleWrapper>
|
||||
<Title level={5}>{t('files.title')}</Title>
|
||||
<Button icon={<PlusOutlined />} onClick={handleAddFile} disabled={disabled}>
|
||||
|
||||
<CustomCollapse
|
||||
label={<CollapseLabel label={t('files.title')} count={fileItems.length} />}
|
||||
extra={
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddFile()
|
||||
}}
|
||||
disabled={disabled}>
|
||||
{t('knowledge.add_file')}
|
||||
</Button>
|
||||
</TitleWrapper>
|
||||
}>
|
||||
<Dragger
|
||||
showUploadList={false}
|
||||
customRequest={({ file }) => handleDrop([file as File])}
|
||||
@ -252,86 +262,137 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
{t('knowledge.file_hint', { file_types: 'TXT, MD, HTML, PDF, DOCX, PPTX, XLSX, EPUB...' })}
|
||||
</p>
|
||||
</Dragger>
|
||||
</FileSection>
|
||||
|
||||
<FileListSection>
|
||||
{fileItems.reverse().map((item) => {
|
||||
const file = item.content as FileType
|
||||
return (
|
||||
<ItemCard key={item.id}>
|
||||
<ItemContent>
|
||||
<ItemInfo>
|
||||
<FileIcon />
|
||||
<ClickableSpan onClick={() => window.api.file.openPath(file.path)}>
|
||||
<Ellipsis>
|
||||
<Tooltip title={file.origin_name}>{file.origin_name}</Tooltip>
|
||||
</Ellipsis>
|
||||
</ClickableSpan>
|
||||
</ItemInfo>
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} type="file" />
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
</ItemContent>
|
||||
</ItemCard>
|
||||
)
|
||||
})}
|
||||
</FileListSection>
|
||||
<FlexColumn>
|
||||
{fileItems.length === 0 ? (
|
||||
<EmptyView />
|
||||
) : (
|
||||
<VirtualList
|
||||
data={fileItems.reverse()}
|
||||
height={fileItems.length > 5 ? 400 : fileItems.length * 80}
|
||||
itemHeight={80}
|
||||
itemKey="id"
|
||||
styles={{
|
||||
verticalScrollBar: {
|
||||
width: 6
|
||||
},
|
||||
verticalScrollBarThumb: {
|
||||
background: 'var(--color-scrollbar-thumb)'
|
||||
}
|
||||
}}>
|
||||
{(item) => {
|
||||
const file = item.content as FileType
|
||||
return (
|
||||
<div style={{ height: '80px', paddingTop: '12px' }}>
|
||||
<FileItem
|
||||
key={item.id}
|
||||
fileInfo={{
|
||||
name: (
|
||||
<ClickableSpan onClick={() => window.api.file.openPath(file.path)}>
|
||||
<Ellipsis>
|
||||
<Tooltip title={file.origin_name}>{file.origin_name}</Tooltip>
|
||||
</Ellipsis>
|
||||
</ClickableSpan>
|
||||
),
|
||||
ext: file.ext,
|
||||
extra: `${dayjs(file.created_at).format('MM-DD HH:mm')} · ${formatFileSize(file.size)}`,
|
||||
actions: (
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && (
|
||||
<Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />
|
||||
)}
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon
|
||||
sourceId={item.id}
|
||||
base={base}
|
||||
getProcessingStatus={getProcessingStatus}
|
||||
type="file"
|
||||
/>
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</VirtualList>
|
||||
)}
|
||||
</FlexColumn>
|
||||
</CustomCollapse>
|
||||
|
||||
<ContentSection>
|
||||
<TitleWrapper>
|
||||
<Title level={5}>{t('knowledge.directories')}</Title>
|
||||
<Button icon={<PlusOutlined />} onClick={handleAddDirectory} disabled={disabled}>
|
||||
<CustomCollapse
|
||||
label={<CollapseLabel label={t('knowledge.directories')} count={directoryItems.length} />}
|
||||
extra={
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddDirectory()
|
||||
}}
|
||||
disabled={disabled}>
|
||||
{t('knowledge.add_directory')}
|
||||
</Button>
|
||||
</TitleWrapper>
|
||||
}>
|
||||
<FlexColumn>
|
||||
{directoryItems.length === 0 && <EmptyView />}
|
||||
{directoryItems.reverse().map((item) => (
|
||||
<ItemCard key={item.id}>
|
||||
<ItemContent>
|
||||
<ItemInfo>
|
||||
<FolderOutlined />
|
||||
<FileItem
|
||||
key={item.id}
|
||||
fileInfo={{
|
||||
name: (
|
||||
<ClickableSpan onClick={() => window.api.file.openPath(item.content as string)}>
|
||||
<Ellipsis>
|
||||
<Tooltip title={item.content as string}>{item.content as string}</Tooltip>
|
||||
</Ellipsis>
|
||||
</ClickableSpan>
|
||||
</ItemInfo>
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon
|
||||
sourceId={item.id}
|
||||
base={base}
|
||||
getProcessingStatus={getProcessingStatus}
|
||||
getProcessingPercent={getProgressingPercentForItem}
|
||||
type="directory"
|
||||
/>
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
</ItemContent>
|
||||
</ItemCard>
|
||||
),
|
||||
ext: '.folder',
|
||||
extra: `${dayjs(item.created_at).format('MM-DD HH:mm')}`,
|
||||
actions: (
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon
|
||||
sourceId={item.id}
|
||||
base={base}
|
||||
getProcessingStatus={getProcessingStatus}
|
||||
getProcessingPercent={getProgressingPercentForItem}
|
||||
type="directory"
|
||||
/>
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</FlexColumn>
|
||||
</ContentSection>
|
||||
</CustomCollapse>
|
||||
|
||||
<ContentSection>
|
||||
<TitleWrapper>
|
||||
<Title level={5}>{t('knowledge.urls')}</Title>
|
||||
<Button icon={<PlusOutlined />} onClick={handleAddUrl} disabled={disabled}>
|
||||
<CustomCollapse
|
||||
label={<CollapseLabel label={t('knowledge.urls')} count={urlItems.length} />}
|
||||
extra={
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddUrl()
|
||||
}}
|
||||
disabled={disabled}>
|
||||
{t('knowledge.add_url')}
|
||||
</Button>
|
||||
</TitleWrapper>
|
||||
}>
|
||||
<FlexColumn>
|
||||
{urlItems.length === 0 && <EmptyView />}
|
||||
{urlItems.reverse().map((item) => (
|
||||
<ItemCard key={item.id}>
|
||||
<ItemContent>
|
||||
<ItemInfo>
|
||||
<LinkOutlined />
|
||||
<FileItem
|
||||
key={item.id}
|
||||
fileInfo={{
|
||||
name: (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
@ -363,33 +424,45 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
</Tooltip>
|
||||
</ClickableSpan>
|
||||
</Dropdown>
|
||||
</ItemInfo>
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} type="url" />
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
</ItemContent>
|
||||
</ItemCard>
|
||||
),
|
||||
ext: '.url',
|
||||
extra: `${dayjs(item.created_at).format('MM-DD HH:mm')}`,
|
||||
actions: (
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} type="url" />
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</FlexColumn>
|
||||
</ContentSection>
|
||||
</CustomCollapse>
|
||||
|
||||
<ContentSection>
|
||||
<TitleWrapper>
|
||||
<Title level={5}>{t('knowledge.sitemaps')}</Title>
|
||||
<Button icon={<PlusOutlined />} onClick={handleAddSitemap} disabled={disabled}>
|
||||
<CustomCollapse
|
||||
label={<CollapseLabel label={t('knowledge.sitemaps')} count={sitemapItems.length} />}
|
||||
extra={
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddSitemap()
|
||||
}}
|
||||
disabled={disabled}>
|
||||
{t('knowledge.add_sitemap')}
|
||||
</Button>
|
||||
</TitleWrapper>
|
||||
}>
|
||||
<FlexColumn>
|
||||
{sitemapItems.length === 0 && <EmptyView />}
|
||||
{sitemapItems.reverse().map((item) => (
|
||||
<ItemCard key={item.id}>
|
||||
<ItemContent>
|
||||
<ItemInfo>
|
||||
<GlobalOutlined />
|
||||
<FileItem
|
||||
key={item.id}
|
||||
fileInfo={{
|
||||
name: (
|
||||
<ClickableSpan>
|
||||
<Tooltip title={item.content as string}>
|
||||
<Ellipsis>
|
||||
@ -399,53 +472,71 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
</Ellipsis>
|
||||
</Tooltip>
|
||||
</ClickableSpan>
|
||||
</ItemInfo>
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon
|
||||
sourceId={item.id}
|
||||
base={base}
|
||||
getProcessingStatus={getProcessingStatus}
|
||||
type="sitemap"
|
||||
/>
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
</ItemContent>
|
||||
</ItemCard>
|
||||
),
|
||||
ext: '.sitemap',
|
||||
extra: `${dayjs(item.created_at).format('MM-DD HH:mm')}`,
|
||||
actions: (
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon
|
||||
sourceId={item.id}
|
||||
base={base}
|
||||
getProcessingStatus={getProcessingStatus}
|
||||
type="sitemap"
|
||||
/>
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</FlexColumn>
|
||||
</ContentSection>
|
||||
</CustomCollapse>
|
||||
|
||||
<ContentSection>
|
||||
<TitleWrapper>
|
||||
<Title level={5}>{t('knowledge.notes')}</Title>
|
||||
<Button icon={<PlusOutlined />} onClick={handleAddNote} disabled={disabled}>
|
||||
<CustomCollapse
|
||||
label={<CollapseLabel label={t('knowledge.notes')} count={noteItems.length} />}
|
||||
extra={
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddNote()
|
||||
}}
|
||||
disabled={disabled}>
|
||||
{t('knowledge.add_note')}
|
||||
</Button>
|
||||
</TitleWrapper>
|
||||
}>
|
||||
<FlexColumn>
|
||||
{noteItems.length === 0 && <EmptyView />}
|
||||
{noteItems.reverse().map((note) => (
|
||||
<ItemCard key={note.id}>
|
||||
<ItemContent>
|
||||
<ItemInfo onClick={() => handleEditNote(note)} style={{ cursor: 'pointer' }}>
|
||||
<span>{(note.content as string).slice(0, 50)}...</span>
|
||||
</ItemInfo>
|
||||
<FlexAlignCenter>
|
||||
<Button type="text" onClick={() => handleEditNote(note)} icon={<EditOutlined />} />
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon sourceId={note.id} base={base} getProcessingStatus={getProcessingStatus} type="note" />
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(note)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
</ItemContent>
|
||||
</ItemCard>
|
||||
<FileItem
|
||||
key={note.id}
|
||||
fileInfo={{
|
||||
name: <span onClick={() => handleEditNote(note)}>{(note.content as string).slice(0, 50)}...</span>,
|
||||
ext: '.txt',
|
||||
extra: `${dayjs(note.created_at).format('MM-DD HH:mm')}`,
|
||||
actions: (
|
||||
<FlexAlignCenter>
|
||||
<Button type="text" onClick={() => handleEditNote(note)} icon={<EditOutlined />} />
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon
|
||||
sourceId={note.id}
|
||||
base={base}
|
||||
getProcessingStatus={getProcessingStatus}
|
||||
type="note"
|
||||
/>
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(note)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</FlexColumn>
|
||||
</ContentSection>
|
||||
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
</CustomCollapse>
|
||||
<ModelInfo>
|
||||
<div className="model-header">
|
||||
<label>{t('knowledge.model_info')}</label>
|
||||
@ -491,6 +582,19 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const EmptyView = () => <Empty style={{ margin: 0 }} styles={{ image: { display: 'none' } }} />
|
||||
|
||||
const CollapseLabel = ({ label, count }: { label: string; count: number }) => {
|
||||
return (
|
||||
<HStack alignItems="center" gap={10}>
|
||||
<label>{label}</label>
|
||||
<Tag style={{ borderRadius: 100, padding: '0 10px' }} color={count ? 'green' : 'default'}>
|
||||
{count}
|
||||
</Tag>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
const MainContent = styled(Scrollbar)`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
@ -498,69 +602,9 @@ const MainContent = styled(Scrollbar)`
|
||||
padding-bottom: 50px;
|
||||
padding: 15px;
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const FileSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const ContentSection = styled.div`
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
.ant-input-textarea {
|
||||
background: var(--color-background-soft);
|
||||
border-radius: 8px;
|
||||
}
|
||||
`
|
||||
|
||||
const TitleWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
background-color: var(--color-background-soft);
|
||||
padding: 5px 20px;
|
||||
min-height: 45px;
|
||||
border-radius: 6px;
|
||||
.ant-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`
|
||||
|
||||
const FileListSection = styled.div`
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const ItemCard = styled(Card)`
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
.ant-card-body {
|
||||
padding: 0 20px;
|
||||
}
|
||||
`
|
||||
|
||||
const ItemContent = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
`
|
||||
|
||||
const ItemInfo = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
const IndexSection = styled.div`
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
@ -602,10 +646,12 @@ const ModelInfo = styled.div`
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
`
|
||||
|
||||
const FlexColumn = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
`
|
||||
|
||||
const FlexAlignCenter = styled.div`
|
||||
@ -620,10 +666,6 @@ const ClickableSpan = styled.span`
|
||||
width: 0;
|
||||
`
|
||||
|
||||
const FileIcon = styled(FileTextOutlined)`
|
||||
font-size: 16px;
|
||||
`
|
||||
|
||||
const BottomSpacer = styled.div`
|
||||
min-height: 20px;
|
||||
`
|
||||
|
||||
@ -126,7 +126,7 @@ const PopupContainer: React.FC<Props> = ({ base, resolve }) => {
|
||||
<List.Item>
|
||||
<ResultItem>
|
||||
<ScoreTag>Score: {(item.score * 100).toFixed(1)}%</ScoreTag>
|
||||
<Paragraph>{highlightText(item.pageContent)}</Paragraph>
|
||||
<Paragraph style={{ userSelect: 'text' }}>{highlightText(item.pageContent)}</Paragraph>
|
||||
<MetadataContainer>
|
||||
<Text type="secondary">
|
||||
{t('knowledge.source')}:{' '}
|
||||
@ -191,6 +191,7 @@ const MetadataContainer = styled.div`
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
user-select: text;
|
||||
`
|
||||
|
||||
const TopViewKey = 'KnowledgeSearchPopup'
|
||||
|
||||
@ -0,0 +1,188 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
||||
import { Box } from '@renderer/components/Layout'
|
||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||
import { Assistant, AssistantSettings } from '@renderer/types'
|
||||
import { Empty, Switch, Tooltip } from 'antd'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
export interface MCPServer {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
baseUrl?: string
|
||||
command?: string
|
||||
args?: string[]
|
||||
env?: Record<string, string>
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
assistant: Assistant
|
||||
updateAssistant: (assistant: Assistant) => void
|
||||
updateAssistantSettings: (settings: AssistantSettings) => void
|
||||
}
|
||||
|
||||
const AssistantMCPSettings: React.FC<Props> = ({ assistant, updateAssistant }) => {
|
||||
const { t } = useTranslation()
|
||||
const { mcpServers: allMcpServers } = useMCPServers()
|
||||
|
||||
const onUpdate = (ids: string[]) => {
|
||||
const mcpServers = ids
|
||||
.map((id) => allMcpServers.find((server) => server.id === id))
|
||||
.filter((server): server is MCPServer => server !== undefined && server.isActive)
|
||||
|
||||
updateAssistant({ ...assistant, mcpServers })
|
||||
}
|
||||
|
||||
const handleServerToggle = (serverId: string) => {
|
||||
const currentServerIds = assistant.mcpServers?.map((server) => server.id) || []
|
||||
|
||||
if (currentServerIds.includes(serverId)) {
|
||||
// Remove server if it's already enabled
|
||||
onUpdate(currentServerIds.filter((id) => id !== serverId))
|
||||
} else {
|
||||
// Add server if it's not enabled
|
||||
onUpdate([...currentServerIds, serverId])
|
||||
}
|
||||
}
|
||||
|
||||
const enabledCount = assistant.mcpServers?.length || 0
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<HeaderContainer>
|
||||
<Box style={{ fontWeight: 'bold', fontSize: '14px' }}>
|
||||
{t('assistants.settings.mcp.title')}
|
||||
<Tooltip title={t('assistants.settings.mcp.description', 'Select MCP servers to use with this assistant')}>
|
||||
<InfoIcon />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
{allMcpServers.length > 0 && (
|
||||
<EnabledCount>
|
||||
{enabledCount} / {allMcpServers.length} {t('settings.mcp.active')}
|
||||
</EnabledCount>
|
||||
)}
|
||||
</HeaderContainer>
|
||||
|
||||
{allMcpServers.length > 0 ? (
|
||||
<ServerList>
|
||||
{allMcpServers.map((server) => {
|
||||
const isEnabled = assistant.mcpServers?.some((s) => s.id === server.id) || false
|
||||
|
||||
return (
|
||||
<ServerItem key={server.id} isEnabled={isEnabled}>
|
||||
<ServerInfo>
|
||||
<ServerName>{server.name}</ServerName>
|
||||
{server.description && <ServerDescription>{server.description}</ServerDescription>}
|
||||
{server.baseUrl && <ServerUrl>{server.baseUrl}</ServerUrl>}
|
||||
</ServerInfo>
|
||||
<Tooltip
|
||||
title={
|
||||
!server.isActive
|
||||
? t('assistants.settings.mcp.enableFirst', 'Enable this server in MCP settings first')
|
||||
: undefined
|
||||
}>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
disabled={!server.isActive}
|
||||
onChange={() => handleServerToggle(server.id)}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
</ServerItem>
|
||||
)
|
||||
})}
|
||||
</ServerList>
|
||||
) : (
|
||||
<EmptyContainer>
|
||||
<Empty
|
||||
description={t('assistants.settings.mcp.noAvaliable', 'No MCP servers available')}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</EmptyContainer>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
const HeaderContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
`
|
||||
|
||||
const InfoIcon = styled(InfoCircleOutlined)`
|
||||
margin-left: 6px;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-2);
|
||||
cursor: help;
|
||||
`
|
||||
|
||||
const EnabledCount = styled.span`
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
`
|
||||
|
||||
const EmptyContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 40px 0;
|
||||
`
|
||||
|
||||
const ServerList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow-y: auto;
|
||||
`
|
||||
|
||||
const ServerItem = styled.div<{ isEnabled: boolean }>`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--color-background-mute);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: all 0.2s ease;
|
||||
opacity: ${(props) => (props.isEnabled ? 1 : 0.7)};
|
||||
`
|
||||
|
||||
const ServerInfo = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
const ServerName = styled.div`
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
`
|
||||
|
||||
const ServerDescription = styled.div`
|
||||
font-size: 0.85rem;
|
||||
color: ${(props) => props.theme.colors?.textSecondary || '#8c8c8c'};
|
||||
margin-bottom: 3px;
|
||||
`
|
||||
|
||||
const ServerUrl = styled.div`
|
||||
font-size: 0.8rem;
|
||||
color: ${(props) => props.theme.colors?.textTertiary || '#bfbfbf'};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`
|
||||
|
||||
export default AssistantMCPSettings
|
||||
@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import AssistantKnowledgeBaseSettings from './AssistantKnowledgeBaseSettings'
|
||||
import AssistantMCPSettings from './AssistantMCPSettings'
|
||||
import AssistantMessagesSettings from './AssistantMessagesSettings'
|
||||
import AssistantModelSettings from './AssistantModelSettings'
|
||||
import AssistantPromptSettings from './AssistantPromptSettings'
|
||||
@ -19,7 +20,7 @@ interface AssistantSettingPopupShowParams {
|
||||
tab?: AssistantSettingPopupTab
|
||||
}
|
||||
|
||||
type AssistantSettingPopupTab = 'prompt' | 'model' | 'messages' | 'knowledge_base'
|
||||
type AssistantSettingPopupTab = 'prompt' | 'model' | 'messages' | 'knowledge_base' | 'mcp'
|
||||
|
||||
interface Props extends AssistantSettingPopupShowParams {
|
||||
resolve: (assistant: Assistant) => void
|
||||
@ -68,6 +69,10 @@ const AssistantSettingPopupContainer: React.FC<Props> = ({ resolve, tab, ...prop
|
||||
showKnowledgeIcon && {
|
||||
key: 'knowledge_base',
|
||||
label: t('assistants.settings.knowledge_base')
|
||||
},
|
||||
{
|
||||
key: 'mcp',
|
||||
label: t('assistants.settings.mcp')
|
||||
}
|
||||
].filter(Boolean) as { key: string; label: string }[]
|
||||
|
||||
@ -133,6 +138,13 @@ const AssistantSettingPopupContainer: React.FC<Props> = ({ resolve, tab, ...prop
|
||||
updateAssistantSettings={updateAssistantSettings}
|
||||
/>
|
||||
)}
|
||||
{menu === 'mcp' && (
|
||||
<AssistantMCPSettings
|
||||
assistant={assistant}
|
||||
updateAssistant={updateAssistant}
|
||||
updateAssistantSettings={updateAssistantSettings}
|
||||
/>
|
||||
)}
|
||||
</Settings>
|
||||
</HStack>
|
||||
</StyledModal>
|
||||
|
||||
@ -2,7 +2,11 @@ import { DeleteOutlined, FolderOpenOutlined } from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { RootState, useAppDispatch } from '@renderer/store'
|
||||
import { setForceDollarMathInMarkdown, setmarkdownExportPath } from '@renderer/store/settings'
|
||||
import {
|
||||
setForceDollarMathInMarkdown,
|
||||
setmarkdownExportPath,
|
||||
setUseTopicNamingForMessageTitle
|
||||
} from '@renderer/store/settings'
|
||||
import { Button, Switch } from 'antd'
|
||||
import Input from 'antd/es/input/Input'
|
||||
import { FC } from 'react'
|
||||
@ -18,6 +22,7 @@ const MarkdownExportSettings: FC = () => {
|
||||
|
||||
const markdownExportPath = useSelector((state: RootState) => state.settings.markdownExportPath)
|
||||
const forceDollarMathInMarkdown = useSelector((state: RootState) => state.settings.forceDollarMathInMarkdown)
|
||||
const useTopicNamingForMessageTitle = useSelector((state: RootState) => state.settings.useTopicNamingForMessageTitle)
|
||||
|
||||
const handleSelectFolder = async () => {
|
||||
const path = await window.api.file.selectFolder()
|
||||
@ -34,6 +39,10 @@ const MarkdownExportSettings: FC = () => {
|
||||
dispatch(setForceDollarMathInMarkdown(checked))
|
||||
}
|
||||
|
||||
const handleToggleTopicNaming = (checked: boolean) => {
|
||||
dispatch(setUseTopicNamingForMessageTitle(checked))
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.data.markdown_export.title')}</SettingTitle>
|
||||
@ -69,6 +78,14 @@ const MarkdownExportSettings: FC = () => {
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.markdown_export.force_dollar_math.help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.message_title.use_topic_naming.title')}</SettingRowTitle>
|
||||
<Switch checked={useTopicNamingForMessageTitle} onChange={handleToggleTopicNaming} />
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.message_title.use_topic_naming.help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@ -175,7 +175,7 @@ const GeneralSettings: FC = () => {
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.tray.onclose')}</SettingRowTitle>
|
||||
<Switch checked={trayOnClose} onChange={(checked) => updateTrayOnClose(checked)} disabled={!tray} />
|
||||
<Switch checked={trayOnClose} onChange={(checked) => updateTrayOnClose(checked)} />
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
</SettingContainer>
|
||||
|
||||
@ -20,11 +20,26 @@ interface MCPFormValues {
|
||||
serverType: 'sse' | 'stdio'
|
||||
baseUrl?: string
|
||||
command?: string
|
||||
registryUrl?: string
|
||||
args?: string
|
||||
env?: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
interface Registry {
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
const NpmRegistry: Registry[] = [{ name: '淘宝 NPM Mirror', url: 'https://registry.npmmirror.com' }]
|
||||
const PipRegistry: Registry[] = [
|
||||
{ name: '清华大学', url: 'https://pypi.tuna.tsinghua.edu.cn/simple' },
|
||||
{ name: '阿里云', url: 'http://mirrors.aliyun.com/pypi/simple/' },
|
||||
{ name: '中国科学技术大学', url: 'https://mirrors.ustc.edu.cn/pypi/simple/' },
|
||||
{ name: '华为云', url: 'https://repo.huaweicloud.com/repository/pypi/simple/' },
|
||||
{ name: '腾讯云', url: 'https://mirrors.cloud.tencent.com/pypi/simple/' }
|
||||
]
|
||||
|
||||
const McpSettings: React.FC<Props> = ({ server }) => {
|
||||
const { t } = useTranslation()
|
||||
const { deleteMCPServer } = useMCPServers()
|
||||
@ -35,36 +50,42 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
||||
const [loadingServer, setLoadingServer] = useState<string | null>(null)
|
||||
const { updateMCPServer } = useMCPServers()
|
||||
const [tools, setTools] = useState<MCPTool[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (server) {
|
||||
form.setFieldsValue({
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
serverType: server.baseUrl ? 'sse' : 'stdio',
|
||||
baseUrl: server.baseUrl || '',
|
||||
command: server.command || '',
|
||||
args: server.args ? server.args.join('\n') : '',
|
||||
env: server.env
|
||||
? Object.entries(server.env)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')
|
||||
: '',
|
||||
isActive: server.isActive
|
||||
})
|
||||
}
|
||||
}, [form, server])
|
||||
const [isShowRegistry, setIsShowRegistry] = useState(false)
|
||||
const [registry, setRegistry] = useState<Registry[]>()
|
||||
|
||||
useEffect(() => {
|
||||
const serverType = server.baseUrl ? 'sse' : 'stdio'
|
||||
setServerType(serverType)
|
||||
|
||||
// Set registry UI state based on command and registryUrl
|
||||
if (server.command) {
|
||||
handleCommandChange(server.command)
|
||||
|
||||
// If there's a registryUrl, ensure registry UI is shown
|
||||
if (server.registryUrl) {
|
||||
setIsShowRegistry(true)
|
||||
|
||||
// Determine registry type based on command
|
||||
if (server.command.includes('uv') || server.command.includes('uvx')) {
|
||||
setRegistry(PipRegistry)
|
||||
} else if (
|
||||
server.command.includes('npx') ||
|
||||
server.command.includes('bun') ||
|
||||
server.command.includes('bunx')
|
||||
) {
|
||||
setRegistry(NpmRegistry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
form.setFieldsValue({
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
serverType: serverType,
|
||||
baseUrl: server.baseUrl || '',
|
||||
command: server.command || '',
|
||||
registryUrl: server.registryUrl || '',
|
||||
isActive: server.isActive,
|
||||
args: server.args ? server.args.join('\n') : '',
|
||||
env: server.env
|
||||
? Object.entries(server.env)
|
||||
@ -72,7 +93,8 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
||||
.join('\n')
|
||||
: ''
|
||||
})
|
||||
}, [form, server])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [server])
|
||||
|
||||
// Watch the serverType field to update the form layout dynamically
|
||||
useEffect(() => {
|
||||
@ -110,32 +132,36 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
|
||||
// set basic fields
|
||||
const mcpServer: MCPServer = {
|
||||
id: server.id,
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
isActive: values.isActive
|
||||
isActive: values.isActive,
|
||||
registryUrl: values.registryUrl
|
||||
}
|
||||
|
||||
// set stdio or sse server
|
||||
if (values.serverType === 'sse') {
|
||||
mcpServer.baseUrl = values.baseUrl
|
||||
} else {
|
||||
mcpServer.command = values.command
|
||||
mcpServer.args = values.args ? values.args.split('\n').filter((arg) => arg.trim() !== '') : []
|
||||
}
|
||||
|
||||
// set env variables
|
||||
if (values.env) {
|
||||
const env: Record<string, string> = {}
|
||||
if (values.env) {
|
||||
values.env.split('\n').forEach((line) => {
|
||||
if (line.trim()) {
|
||||
const [key, ...chunks] = line.split('=')
|
||||
const value = chunks.join('=')
|
||||
if (key && value) {
|
||||
env[key.trim()] = value.trim()
|
||||
}
|
||||
values.env.split('\n').forEach((line) => {
|
||||
if (line.trim()) {
|
||||
const [key, ...chunks] = line.split('=')
|
||||
const value = chunks.join('=')
|
||||
if (key && value) {
|
||||
env[key.trim()] = value.trim()
|
||||
}
|
||||
})
|
||||
}
|
||||
mcpServer.env = Object.keys(env).length > 0 ? env : undefined
|
||||
}
|
||||
})
|
||||
mcpServer.env = env
|
||||
}
|
||||
|
||||
try {
|
||||
@ -156,9 +182,41 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
||||
}
|
||||
} catch (error: any) {
|
||||
setLoading(false)
|
||||
console.error('Failed to save MCP server settings:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for command field changes
|
||||
const handleCommandChange = (command: string) => {
|
||||
if (command.includes('uv') || command.includes('uvx')) {
|
||||
setIsShowRegistry(true)
|
||||
setRegistry(PipRegistry)
|
||||
} else if (command.includes('npx') || command.includes('bun') || command.includes('bunx')) {
|
||||
setIsShowRegistry(true)
|
||||
setRegistry(NpmRegistry)
|
||||
} else {
|
||||
setIsShowRegistry(false)
|
||||
setRegistry(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const onSelectRegistry = (url: string) => {
|
||||
const command = form.getFieldValue('command') || ''
|
||||
|
||||
// Add new registry env variables
|
||||
if (command.includes('uv') || command.includes('uvx')) {
|
||||
// envs['PIP_INDEX_URL'] = url
|
||||
// envs['UV_DEFAULT_INDEX'] = url
|
||||
form.setFieldsValue({ registryUrl: url })
|
||||
} else if (command.includes('npx') || command.includes('bun') || command.includes('bunx')) {
|
||||
// envs['NPM_CONFIG_REGISTRY'] = url
|
||||
form.setFieldsValue({ registryUrl: url })
|
||||
}
|
||||
|
||||
// Mark form as changed
|
||||
setIsFormChanged(true)
|
||||
}
|
||||
|
||||
const onDeleteMcpServer = useCallback(
|
||||
async (server: MCPServer) => {
|
||||
try {
|
||||
@ -281,14 +339,38 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
||||
name="command"
|
||||
label={t('settings.mcp.command')}
|
||||
rules={[{ required: serverType === 'stdio', message: '' }]}>
|
||||
<Input placeholder="uvx or npx" />
|
||||
<Input placeholder="uvx or npx" onChange={(e) => handleCommandChange(e.target.value)} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="args"
|
||||
label={t('settings.mcp.args')}
|
||||
tooltip={t('settings.mcp.argsTooltip')}
|
||||
rules={[{ required: serverType === 'stdio', message: '' }]}>
|
||||
{isShowRegistry && registry && (
|
||||
<Form.Item
|
||||
name="registryUrl"
|
||||
label={t('settings.mcp.registry')}
|
||||
tooltip={t('settings.mcp.registryTooltip')}>
|
||||
<Radio.Group>
|
||||
<Radio
|
||||
key="no-proxy"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
onSelectRegistry(e.target.value)
|
||||
}}>
|
||||
{t('settings.mcp.registryDefault')}
|
||||
</Radio>
|
||||
{registry.map((reg) => (
|
||||
<Radio
|
||||
key={reg.url}
|
||||
value={reg.url}
|
||||
onChange={(e) => {
|
||||
onSelectRegistry(e.target.value)
|
||||
}}>
|
||||
{reg.name}
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item name="args" label={t('settings.mcp.args')} tooltip={t('settings.mcp.argsTooltip')}>
|
||||
<TextArea rows={3} placeholder={`arg1\narg2`} style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { EditOutlined, ExportOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import { NavbarRight } from '@renderer/components/app/Navbar'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { isWindows } from '@renderer/config/constant'
|
||||
import { EventEmitter } from '@renderer/services/EventService'
|
||||
import { Button } from 'antd'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -13,7 +14,7 @@ export const McpSettingsNavbar = () => {
|
||||
const onClick = () => window.open('https://mcp.so/', '_blank')
|
||||
|
||||
return (
|
||||
<NavbarRight>
|
||||
<NavbarRight style={{ paddingRight: isWindows ? 150 : 12 }}>
|
||||
<HStack alignItems="center" gap={5}>
|
||||
<Button
|
||||
size="small"
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { DownOutlined, UpOutlined } from '@ant-design/icons'
|
||||
import CopyIcon from '@renderer/components/Icons/CopyIcon'
|
||||
import { isEmbeddingModel, isFunctionCallingModel, isReasoningModel, isVisionModel } from '@renderer/config/models'
|
||||
import { Model, ModelType } from '@renderer/types'
|
||||
import { getDefaultGroupName } from '@renderer/utils'
|
||||
import { Button, Checkbox, Divider, Flex, Form, Input, Modal } from 'antd'
|
||||
import { Button, Checkbox, Divider, Flex, Form, Input, message, Modal } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface ModelEditContentProps {
|
||||
model: Model
|
||||
onUpdateModel: (model: Model) => void
|
||||
@ -65,17 +65,29 @@ const ModelEditContent: FC<ModelEditContentProps> = ({ model, onUpdateModel, ope
|
||||
label={t('settings.models.add.model_id')}
|
||||
tooltip={t('settings.models.add.model_id.tooltip')}
|
||||
rules={[{ required: true }]}>
|
||||
<Input
|
||||
placeholder={t('settings.models.add.model_id.placeholder')}
|
||||
spellCheck={false}
|
||||
maxLength={200}
|
||||
disabled={true}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
form.setFieldValue('name', value)
|
||||
form.setFieldValue('group', getDefaultGroupName(value))
|
||||
}}
|
||||
/>
|
||||
<Flex justify="space-between" gap={5}>
|
||||
<Input
|
||||
placeholder={t('settings.models.add.model_id.placeholder')}
|
||||
spellCheck={false}
|
||||
maxLength={200}
|
||||
disabled={true}
|
||||
value={model.id}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
form.setFieldValue('name', value)
|
||||
form.setFieldValue('group', getDefaultGroupName(value))
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
//copy model id
|
||||
const val = form.getFieldValue('name')
|
||||
navigator.clipboard.writeText((val.id || model.id) as string)
|
||||
message.success(t('message.copied'))
|
||||
}}>
|
||||
<CopyIcon /> {t('chat.topics.copy.title')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="name"
|
||||
|
||||
@ -16,11 +16,11 @@ import { useProvider } from '@renderer/hooks/useProvider'
|
||||
import { ModelCheckStatus } from '@renderer/services/HealthCheckService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setModel } from '@renderer/store/assistants'
|
||||
import { Model, Provider } from '@renderer/types'
|
||||
import { Model } from '@renderer/types'
|
||||
import { maskApiKey } from '@renderer/utils/api'
|
||||
import { Avatar, Button, Card, Flex, Space, Tooltip, Typography } from 'antd'
|
||||
import { groupBy, sortBy, toPairs } from 'lodash'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -36,7 +36,7 @@ const STATUS_COLORS = {
|
||||
}
|
||||
|
||||
interface ModelListProps {
|
||||
provider: Provider
|
||||
providerId: string
|
||||
modelStatuses?: ModelStatus[]
|
||||
searchText?: string
|
||||
}
|
||||
@ -166,10 +166,9 @@ function useModelStatusRendering() {
|
||||
return { renderStatusIndicator, renderLatencyText }
|
||||
}
|
||||
|
||||
const ModelList: React.FC<ModelListProps> = ({ provider: _provider, modelStatuses = [], searchText = '' }) => {
|
||||
const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], searchText = '' }) => {
|
||||
const { t } = useTranslation()
|
||||
const { provider } = useProvider(_provider.id)
|
||||
const { updateProvider, models, removeModel } = useProvider(_provider.id)
|
||||
const { provider, updateProvider, models, removeModel } = useProvider(providerId)
|
||||
const { assistants } = useAssistants()
|
||||
const dispatch = useAppDispatch()
|
||||
const { defaultModel, setDefaultModel } = useDefaultModel()
|
||||
@ -180,59 +179,64 @@ const ModelList: React.FC<ModelListProps> = ({ provider: _provider, modelStatuse
|
||||
const modelsWebsite = providerConfig?.websites?.models
|
||||
|
||||
const [editingModel, setEditingModel] = useState<Model | null>(null)
|
||||
const [debouncedSearchText, setDebouncedSearchText] = useState(searchText)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearchText(searchText)
|
||||
}, 50)
|
||||
const modelGroups = useMemo(() => {
|
||||
const filteredModels = searchText
|
||||
? models.filter((model) => model.name.toLowerCase().includes(searchText.toLowerCase()))
|
||||
: models
|
||||
return groupBy(filteredModels, 'group')
|
||||
}, [searchText, models])
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchText])
|
||||
const sortedModelGroups = useMemo(() => {
|
||||
return sortBy(toPairs(modelGroups), [0]).reduce((acc, [key, value]) => {
|
||||
acc[key] = value
|
||||
return acc
|
||||
}, {})
|
||||
}, [modelGroups])
|
||||
|
||||
const filteredModels = debouncedSearchText
|
||||
? models.filter((model) => model.name.toLowerCase().includes(debouncedSearchText.toLowerCase()))
|
||||
: models
|
||||
const onManageModel = useCallback(() => {
|
||||
EditModelsPopup.show({ provider })
|
||||
}, [provider])
|
||||
|
||||
const modelGroups = groupBy(filteredModels, 'group')
|
||||
const sortedModelGroups = sortBy(toPairs(modelGroups), [0]).reduce((acc, [key, value]) => {
|
||||
acc[key] = value
|
||||
return acc
|
||||
}, {})
|
||||
const onAddModel = useCallback(
|
||||
() => AddModelPopup.show({ title: t('settings.models.add.add_model'), provider }),
|
||||
[provider, t]
|
||||
)
|
||||
|
||||
const onManageModel = () => EditModelsPopup.show({ provider })
|
||||
const onAddModel = () => AddModelPopup.show({ title: t('settings.models.add.add_model'), provider })
|
||||
const onEditModel = (model: Model) => {
|
||||
const onEditModel = useCallback((model: Model) => {
|
||||
setEditingModel(model)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onUpdateModel = (updatedModel: Model) => {
|
||||
const updatedModels = models.map((m) => {
|
||||
if (m.id === updatedModel.id) {
|
||||
return updatedModel
|
||||
const onUpdateModel = useCallback(
|
||||
(updatedModel: Model) => {
|
||||
const updatedModels = models.map((m) => {
|
||||
if (m.id === updatedModel.id) {
|
||||
return updatedModel
|
||||
}
|
||||
return m
|
||||
})
|
||||
|
||||
updateProvider({ ...provider, models: updatedModels })
|
||||
|
||||
// Update assistants using this model
|
||||
assistants.forEach((assistant) => {
|
||||
if (assistant?.model?.id === updatedModel.id && assistant.model.provider === provider.id) {
|
||||
dispatch(
|
||||
setModel({
|
||||
assistantId: assistant.id,
|
||||
model: updatedModel
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Update default model if needed
|
||||
if (defaultModel?.id === updatedModel.id && defaultModel?.provider === provider.id) {
|
||||
setDefaultModel(updatedModel)
|
||||
}
|
||||
return m
|
||||
})
|
||||
|
||||
updateProvider({ ...provider, models: updatedModels })
|
||||
|
||||
// Update assistants using this model
|
||||
assistants.forEach((assistant) => {
|
||||
if (assistant?.model?.id === updatedModel.id && assistant.model.provider === provider.id) {
|
||||
dispatch(
|
||||
setModel({
|
||||
assistantId: assistant.id,
|
||||
model: updatedModel
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Update default model if needed
|
||||
if (defaultModel?.id === updatedModel.id && defaultModel?.provider === provider.id) {
|
||||
setDefaultModel(updatedModel)
|
||||
}
|
||||
}
|
||||
},
|
||||
[models, updateProvider, provider, assistants, defaultModel?.id, defaultModel?.provider, dispatch, setDefaultModel]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -396,4 +400,4 @@ const ModelLatencyText = styled(Typography.Text)`
|
||||
color: var(--color-text-secondary);
|
||||
`
|
||||
|
||||
export default ModelList
|
||||
export default memo(ModelList)
|
||||
|
||||
@ -6,7 +6,7 @@ import { PROVIDER_CONFIG } from '@renderer/config/providers'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useProvider } from '@renderer/hooks/useProvider'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { isOpenAIProvider } from '@renderer/providers/ProviderFactory'
|
||||
import { isOpenAIProvider } from '@renderer/providers/AiProvider/ProviderFactory'
|
||||
import { checkApi, formatApiKeys } from '@renderer/services/ApiService'
|
||||
import { checkModelsHealth, ModelCheckStatus } from '@renderer/services/HealthCheckService'
|
||||
import { isProviderSupportAuth, isProviderSupportCharge } from '@renderer/services/ProviderService'
|
||||
@ -16,7 +16,7 @@ import { providerCharge } from '@renderer/utils/oauth'
|
||||
import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd'
|
||||
import Link from 'antd/es/typography/Link'
|
||||
import { debounce, isEmpty } from 'lodash'
|
||||
import { FC, useCallback, useEffect, useState } from 'react'
|
||||
import { FC, useCallback, useDeferredValue, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -51,7 +51,8 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
const [apiVersion, setApiVersion] = useState(provider.apiVersion)
|
||||
const [apiValid, setApiValid] = useState(false)
|
||||
const [apiChecking, setApiChecking] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [modelSearchText, setModelSearchText] = useState('')
|
||||
const deferredModelSearchText = useDeferredValue(modelSearchText)
|
||||
const { updateProvider, models } = useProvider(provider.id)
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
@ -387,7 +388,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<span>{t('common.models')}</span>
|
||||
{!isEmpty(models) && <ModelListSearchBar onSearch={setSearchText} />}
|
||||
{!isEmpty(models) && <ModelListSearchBar onSearch={setModelSearchText} />}
|
||||
</Space>
|
||||
{!isEmpty(models) && (
|
||||
<Tooltip title={t('settings.models.check.button_caption')} mouseEnterDelay={0.5}>
|
||||
@ -402,7 +403,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
)}
|
||||
</Space>
|
||||
</SettingSubtitle>
|
||||
<ModelList provider={provider} modelStatuses={modelStatuses} searchText={searchText} />
|
||||
<ModelList providerId={provider.id} modelStatuses={modelStatuses} searchText={deferredModelSearchText} />
|
||||
</SettingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
@ -221,7 +221,7 @@ export default class AnthropicProvider extends BaseProvider {
|
||||
return onChunk({
|
||||
text,
|
||||
reasoning_content,
|
||||
usage: message.usage,
|
||||
usage: message.usage as any,
|
||||
metrics: {
|
||||
completion_tokens: message.usage.output_tokens,
|
||||
time_completion_millsec,
|
||||
@ -1,9 +1,47 @@
|
||||
import BaseProvider from '@renderer/providers/BaseProvider'
|
||||
import ProviderFactory from '@renderer/providers/ProviderFactory'
|
||||
import { Assistant, GenerateImageParams, Message, Model, Provider, Suggestion } from '@renderer/types'
|
||||
import type { GroundingMetadata } from '@google/generative-ai'
|
||||
import BaseProvider from '@renderer/providers/AiProvider/BaseProvider'
|
||||
import ProviderFactory from '@renderer/providers/AiProvider/ProviderFactory'
|
||||
import type {
|
||||
Assistant,
|
||||
GenerateImageParams,
|
||||
GenerateImageResponse,
|
||||
MCPTool,
|
||||
MCPToolResponse,
|
||||
Message,
|
||||
Metrics,
|
||||
Model,
|
||||
Provider,
|
||||
Suggestion
|
||||
} from '@renderer/types'
|
||||
import OpenAI from 'openai'
|
||||
|
||||
import { CompletionsParams } from '.'
|
||||
export interface ChunkCallbackData {
|
||||
text?: string
|
||||
reasoning_content?: string
|
||||
usage?: OpenAI.Completions.CompletionUsage
|
||||
metrics?: Metrics
|
||||
search?: GroundingMetadata
|
||||
citations?: string[]
|
||||
mcpToolResponse?: MCPToolResponse[]
|
||||
generateImage?: GenerateImageResponse
|
||||
}
|
||||
|
||||
export interface CompletionsParams {
|
||||
messages: Message[]
|
||||
assistant: Assistant
|
||||
onChunk: ({
|
||||
text,
|
||||
reasoning_content,
|
||||
usage,
|
||||
metrics,
|
||||
search,
|
||||
citations,
|
||||
mcpToolResponse,
|
||||
generateImage
|
||||
}: ChunkCallbackData) => void
|
||||
onFilterMessages: (messages: Message[]) => void
|
||||
mcpTools?: MCPTool[]
|
||||
}
|
||||
|
||||
export default class AiProvider {
|
||||
private sdk: BaseProvider
|
||||
30
src/renderer/src/providers/index.d.ts
vendored
30
src/renderer/src/providers/index.d.ts
vendored
@ -1,30 +0,0 @@
|
||||
import type { GroundingMetadata } from '@google/generative-ai'
|
||||
import type { Assistant, MCPToolResponse, Message, Metrics } from '@renderer/types'
|
||||
|
||||
interface ChunkCallbackData {
|
||||
text?: string
|
||||
reasoning_content?: string
|
||||
usage?: OpenAI.Completions.CompletionUsage
|
||||
metrics?: Metrics
|
||||
search?: GroundingMetadata
|
||||
citations?: string[]
|
||||
mcpToolResponse?: MCPToolResponse[]
|
||||
generateImage?: GenerateImageResponse
|
||||
}
|
||||
|
||||
interface CompletionsParams {
|
||||
messages: Message[]
|
||||
assistant: Assistant
|
||||
onChunk: ({
|
||||
text,
|
||||
reasoning_content,
|
||||
usage,
|
||||
metrics,
|
||||
search,
|
||||
citations,
|
||||
mcpToolResponse,
|
||||
generateImage
|
||||
}: ChunkCallbackData) => void
|
||||
onFilterMessages: (messages: Message[]) => void
|
||||
mcpTools?: MCPTool[]
|
||||
}
|
||||
@ -2,6 +2,7 @@ import SearchPopup from '@renderer/components/Popups/SearchPopup'
|
||||
import { DEFAULT_CONTEXTCOUNT } from '@renderer/config/constant'
|
||||
import { getTopicById } from '@renderer/hooks/useTopic'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { fetchMessagesSummary } from '@renderer/services/ApiService'
|
||||
import store from '@renderer/store'
|
||||
import { Assistant, Message, Model, Topic } from '@renderer/types'
|
||||
import { getTitleFromString, uuid } from '@renderer/utils'
|
||||
@ -214,7 +215,22 @@ export function resetAssistantMessage(message: Message, model?: Model): Message
|
||||
}
|
||||
}
|
||||
|
||||
export function getMessageTitle(message: Message, length = 30) {
|
||||
export async function getMessageTitle(message: Message, length = 30): Promise<string> {
|
||||
// 检查 Redux 设置,若开启话题命名则调用 summaries 方法
|
||||
if ((store.getState().settings as any).useTopicNamingForMessageTitle) {
|
||||
try {
|
||||
window.message.loading({ content: t('chat.topics.export.wait_for_title_naming'), key: 'message-title-naming' })
|
||||
const title = await fetchMessagesSummary({ messages: [message], assistant: {} as Assistant })
|
||||
if (title) {
|
||||
window.message.success({ content: t('chat.topics.export.title_naming_success'), key: 'message-title-naming' })
|
||||
return title
|
||||
}
|
||||
} catch (e) {
|
||||
window.message.error({ content: t('chat.topics.export.title_naming_failed'), key: 'message-title-naming' })
|
||||
console.error('Failed to generate title using topic naming, downgraded to default logic', e)
|
||||
}
|
||||
}
|
||||
|
||||
let title = getTitleFromString(message.content, length)
|
||||
|
||||
if (!title) {
|
||||
@ -223,6 +239,7 @@ export function getMessageTitle(message: Message, length = 30) {
|
||||
|
||||
return title
|
||||
}
|
||||
|
||||
export function checkRateLimit(assistant: Assistant): boolean {
|
||||
const provider = getAssistantProvider(assistant)
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import WebSearchEngineProvider from '@renderer/providers/WebSearchProvider'
|
||||
import store from '@renderer/store'
|
||||
import { setDefaultProvider, WebSearchState } from '@renderer/store/websearch'
|
||||
import { WebSearchProvider, WebSearchResponse } from '@renderer/types'
|
||||
import { hasObjectKey } from '@renderer/utils'
|
||||
import WebSearchEngineProvider from '@renderer/webSearchProvider/WebSearchEngineProvider'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
/**
|
||||
|
||||
@ -42,7 +42,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 86,
|
||||
version: 88,
|
||||
blacklist: ['runtime', 'messages'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@ -1,8 +1,20 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import { MCPConfig, MCPServer } from '@renderer/types'
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import type { MCPConfig, MCPServer } from '@renderer/types'
|
||||
|
||||
const initialState: MCPConfig = {
|
||||
servers: []
|
||||
servers: [
|
||||
{
|
||||
id: nanoid(),
|
||||
name: 'mcp-auto-install',
|
||||
description: 'Automatically install MCP services (Beta version)',
|
||||
baseUrl: '',
|
||||
command: 'npx',
|
||||
args: ['-y', '@mcpmarket/mcp-auto-install', 'connect', '--json'],
|
||||
env: {},
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const mcpSlice = createSlice({
|
||||
@ -47,5 +59,6 @@ export const { getActiveServers, getAllServers } = mcpSlice.selectors
|
||||
// Type-safe selector for accessing this slice from the root state
|
||||
export const selectMCP = (state: { mcp: MCPConfig }) => state.mcp
|
||||
|
||||
export { mcpSlice }
|
||||
// Export the reducer as default export
|
||||
export default mcpSlice.reducer
|
||||
|
||||
@ -12,6 +12,7 @@ import { createMigrate } from 'redux-persist'
|
||||
|
||||
import { RootState } from '.'
|
||||
import { INITIAL_PROVIDERS, moveProvider } from './llm'
|
||||
import { mcpSlice } from './mcp'
|
||||
import { DEFAULT_SIDEBAR_ICONS } from './settings'
|
||||
|
||||
// remove logo base64 data to reduce the size of the state
|
||||
@ -35,420 +36,609 @@ function addProvider(state: RootState, id: string) {
|
||||
|
||||
const migrateConfig = {
|
||||
'2': (state: RootState) => {
|
||||
addProvider(state, 'yi')
|
||||
return state
|
||||
try {
|
||||
addProvider(state, 'yi')
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'3': (state: RootState) => {
|
||||
addProvider(state, 'zhipu')
|
||||
return state
|
||||
try {
|
||||
addProvider(state, 'zhipu')
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'4': (state: RootState) => {
|
||||
addProvider(state, 'ollama')
|
||||
return state
|
||||
try {
|
||||
addProvider(state, 'ollama')
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'5': (state: RootState) => {
|
||||
addProvider(state, 'moonshot')
|
||||
return state
|
||||
try {
|
||||
addProvider(state, 'moonshot')
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'6': (state: RootState) => {
|
||||
addProvider(state, 'openrouter')
|
||||
return state
|
||||
try {
|
||||
addProvider(state, 'openrouter')
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'7': (state: RootState) => {
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
language: navigator.language
|
||||
try {
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
language: navigator.language
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'8': (state: RootState) => {
|
||||
const fixAssistantName = (assistant: Assistant) => {
|
||||
if (isEmpty(assistant.name)) {
|
||||
assistant.name = i18n.t(`assistant.${assistant.id}.name`)
|
||||
}
|
||||
|
||||
assistant.topics = assistant.topics.map((topic) => {
|
||||
if (isEmpty(topic.name)) {
|
||||
topic.name = i18n.t(`assistant.${assistant.id}.topic.name`)
|
||||
try {
|
||||
const fixAssistantName = (assistant: Assistant) => {
|
||||
if (isEmpty(assistant.name)) {
|
||||
assistant.name = i18n.t(`assistant.${assistant.id}.name`)
|
||||
}
|
||||
return topic
|
||||
})
|
||||
|
||||
return assistant
|
||||
}
|
||||
assistant.topics = assistant.topics.map((topic) => {
|
||||
if (isEmpty(topic.name)) {
|
||||
topic.name = i18n.t(`assistant.${assistant.id}.topic.name`)
|
||||
}
|
||||
return topic
|
||||
})
|
||||
|
||||
return {
|
||||
...state,
|
||||
assistants: {
|
||||
...state.assistants,
|
||||
defaultAssistant: fixAssistantName(state.assistants.defaultAssistant),
|
||||
assistants: state.assistants.assistants.map((assistant) => fixAssistantName(assistant))
|
||||
return assistant
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
assistants: {
|
||||
...state.assistants,
|
||||
defaultAssistant: fixAssistantName(state.assistants.defaultAssistant),
|
||||
assistants: state.assistants.assistants.map((assistant) => fixAssistantName(assistant))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'9': (state: RootState) => {
|
||||
return {
|
||||
...state,
|
||||
llm: {
|
||||
...state.llm,
|
||||
providers: state.llm.providers.map((provider) => {
|
||||
if (provider.id === 'zhipu' && provider.models[0] && provider.models[0].id === 'llama3-70b-8192') {
|
||||
provider.models = SYSTEM_MODELS.zhipu
|
||||
}
|
||||
return provider
|
||||
})
|
||||
try {
|
||||
return {
|
||||
...state,
|
||||
llm: {
|
||||
...state.llm,
|
||||
providers: state.llm.providers.map((provider) => {
|
||||
if (provider.id === 'zhipu' && provider.models[0] && provider.models[0].id === 'llama3-70b-8192') {
|
||||
provider.models = SYSTEM_MODELS.zhipu
|
||||
}
|
||||
return provider
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'10': (state: RootState) => {
|
||||
addProvider(state, 'baichuan')
|
||||
return state
|
||||
try {
|
||||
addProvider(state, 'baichuan')
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'11': (state: RootState) => {
|
||||
addProvider(state, 'dashscope')
|
||||
addProvider(state, 'anthropic')
|
||||
return state
|
||||
try {
|
||||
addProvider(state, 'dashscope')
|
||||
addProvider(state, 'anthropic')
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'12': (state: RootState) => {
|
||||
addProvider(state, 'aihubmix')
|
||||
return state
|
||||
try {
|
||||
addProvider(state, 'aihubmix')
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'13': (state: RootState) => {
|
||||
return {
|
||||
...state,
|
||||
assistants: {
|
||||
...state.assistants,
|
||||
defaultAssistant: {
|
||||
...state.assistants.defaultAssistant,
|
||||
name: ['Default Assistant', '默认助手'].includes(state.assistants.defaultAssistant.name)
|
||||
? i18n.t(`assistant.default.name`)
|
||||
: state.assistants.defaultAssistant.name
|
||||
try {
|
||||
return {
|
||||
...state,
|
||||
assistants: {
|
||||
...state.assistants,
|
||||
defaultAssistant: {
|
||||
...state.assistants.defaultAssistant,
|
||||
name: ['Default Assistant', '默认助手'].includes(state.assistants.defaultAssistant.name)
|
||||
? i18n.t(`assistant.default.name`)
|
||||
: state.assistants.defaultAssistant.name
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'14': (state: RootState) => {
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
showAssistants: true,
|
||||
proxyUrl: undefined
|
||||
try {
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
showAssistants: true,
|
||||
proxyUrl: undefined
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'15': (state: RootState) => {
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
userName: '',
|
||||
showMessageDivider: true
|
||||
try {
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
userName: '',
|
||||
showMessageDivider: true
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'16': (state: RootState) => {
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
messageFont: 'system',
|
||||
showInputEstimatedTokens: false
|
||||
try {
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
messageFont: 'system',
|
||||
showInputEstimatedTokens: false
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'17': (state: RootState) => {
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
theme: 'auto'
|
||||
try {
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
theme: 'auto'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'19': (state: RootState) => {
|
||||
return {
|
||||
...state,
|
||||
agents: {
|
||||
agents: []
|
||||
},
|
||||
llm: {
|
||||
...state.llm,
|
||||
settings: {
|
||||
ollama: {
|
||||
keepAliveTime: 5
|
||||
try {
|
||||
return {
|
||||
...state,
|
||||
agents: {
|
||||
agents: []
|
||||
},
|
||||
llm: {
|
||||
...state.llm,
|
||||
settings: {
|
||||
ollama: {
|
||||
keepAliveTime: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'20': (state: RootState) => {
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
fontSize: 14
|
||||
try {
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
fontSize: 14
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'21': (state: RootState) => {
|
||||
addProvider(state, 'gemini')
|
||||
addProvider(state, 'stepfun')
|
||||
addProvider(state, 'doubao')
|
||||
return state
|
||||
try {
|
||||
addProvider(state, 'gemini')
|
||||
addProvider(state, 'stepfun')
|
||||
addProvider(state, 'doubao')
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'22': (state: RootState) => {
|
||||
addProvider(state, 'minimax')
|
||||
return state
|
||||
try {
|
||||
addProvider(state, 'minimax')
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'23': (state: RootState) => {
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
showTopics: true,
|
||||
windowStyle: 'transparent'
|
||||
try {
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
showTopics: true,
|
||||
windowStyle: 'transparent'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'24': (state: RootState) => {
|
||||
return {
|
||||
...state,
|
||||
assistants: {
|
||||
...state.assistants,
|
||||
assistants: state.assistants.assistants.map((assistant) => ({
|
||||
...assistant,
|
||||
topics: assistant.topics.map((topic) => ({
|
||||
...topic,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
try {
|
||||
return {
|
||||
...state,
|
||||
assistants: {
|
||||
...state.assistants,
|
||||
assistants: state.assistants.assistants.map((assistant) => ({
|
||||
...assistant,
|
||||
topics: assistant.topics.map((topic) => ({
|
||||
...topic,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
},
|
||||
settings: {
|
||||
...state.settings,
|
||||
topicPosition: 'right'
|
||||
},
|
||||
settings: {
|
||||
...state.settings,
|
||||
topicPosition: 'right'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'25': (state: RootState) => {
|
||||
addProvider(state, 'github')
|
||||
return state
|
||||
try {
|
||||
addProvider(state, 'github')
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'26': (state: RootState) => {
|
||||
addProvider(state, 'ocoolai')
|
||||
return state
|
||||
try {
|
||||
addProvider(state, 'ocoolai')
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'27': (state: RootState) => {
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
renderInputMessageAsMarkdown: true
|
||||
try {
|
||||
return {
|
||||
...state,
|
||||
settings: {
|
||||
...state.settings,
|
||||
renderInputMessageAsMarkdown: true
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'28': (state: RootState) => {
|
||||
addProvider(state, 'together')
|
||||
addProvider(state, 'fireworks')
|
||||
addProvider(state, 'zhinao')
|
||||
addProvider(state, 'hunyuan')
|
||||
addProvider(state, 'nvidia')
|
||||
try {
|
||||
addProvider(state, 'together')
|
||||
addProvider(state, 'fireworks')
|
||||
addProvider(state, 'zhinao')
|
||||
addProvider(state, 'hunyuan')
|
||||
addProvider(state, 'nvidia')
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'29': (state: RootState) => {
|
||||
return {
|
||||
...state,
|
||||
assistants: {
|
||||
...state.assistants,
|
||||
assistants: state.assistants.assistants.map((assistant) => {
|
||||
assistant.topics = assistant.topics.map((topic) => ({
|
||||
...topic,
|
||||
assistantId: assistant.id
|
||||
}))
|
||||
return assistant
|
||||
})
|
||||
try {
|
||||
return {
|
||||
...state,
|
||||
assistants: {
|
||||
...state.assistants,
|
||||
assistants: state.assistants.assistants.map((assistant) => {
|
||||
assistant.topics = assistant.topics.map((topic) => ({
|
||||
...topic,
|
||||
assistantId: assistant.id
|
||||
}))
|
||||
return assistant
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'30': (state: RootState) => {
|
||||
addProvider(state, 'azure-openai')
|
||||
return state
|
||||
try {
|
||||
addProvider(state, 'azure-openai')
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'31': (state: RootState) => {
|
||||
return {
|
||||
...state,
|
||||
llm: {
|
||||
...state.llm,
|
||||
providers: state.llm.providers.map((provider) => {
|
||||
if (provider.id === 'azure-openai') {
|
||||
provider.models = provider.models.map((model) => ({ ...model, provider: 'azure-openai' }))
|
||||
}
|
||||
return provider
|
||||
})
|
||||
try {
|
||||
return {
|
||||
...state,
|
||||
llm: {
|
||||
...state.llm,
|
||||
providers: state.llm.providers.map((provider) => {
|
||||
if (provider.id === 'azure-openai') {
|
||||
provider.models = provider.models.map((model) => ({ ...model, provider: 'azure-openai' }))
|
||||
}
|
||||
return provider
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'32': (state: RootState) => {
|
||||
addProvider(state, 'hunyuan')
|
||||
return state
|
||||
try {
|
||||
addProvider(state, 'hunyuan')
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'33': (state: RootState) => {
|
||||
state.assistants.defaultAssistant.type = 'assistant'
|
||||
try {
|
||||
state.assistants.defaultAssistant.type = 'assistant'
|
||||
|
||||
state.agents.agents.forEach((agent) => {
|
||||
agent.type = 'agent'
|
||||
// @ts-ignore eslint-disable-next-line
|
||||
delete agent.group
|
||||
})
|
||||
state.agents.agents.forEach((agent) => {
|
||||
agent.type = 'agent'
|
||||
// @ts-ignore eslint-disable-next-line
|
||||
delete agent.group
|
||||
})
|
||||
|
||||
return {
|
||||
...state,
|
||||
assistants: {
|
||||
...state.assistants,
|
||||
assistants: [...state.assistants.assistants].map((assistant) => {
|
||||
// @ts-ignore eslint-disable-next-line
|
||||
delete assistant.group
|
||||
return {
|
||||
...assistant,
|
||||
id: assistant.id.length === 36 ? assistant.id : uuid(),
|
||||
type: assistant.type === 'system' ? assistant.type : 'assistant'
|
||||
}
|
||||
})
|
||||
return {
|
||||
...state,
|
||||
assistants: {
|
||||
...state.assistants,
|
||||
assistants: [...state.assistants.assistants].map((assistant) => {
|
||||
// @ts-ignore eslint-disable-next-line
|
||||
delete assistant.group
|
||||
return {
|
||||
...assistant,
|
||||
id: assistant.id.length === 36 ? assistant.id : uuid(),
|
||||
type: assistant.type === 'system' ? assistant.type : 'assistant'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'34': (state: RootState) => {
|
||||
state.assistants.assistants.forEach((assistant) => {
|
||||
assistant.topics.forEach((topic) => {
|
||||
topic.assistantId = assistant.id
|
||||
runAsyncFunction(async () => {
|
||||
const _topic = await db.topics.get(topic.id)
|
||||
if (_topic) {
|
||||
const messages = (_topic?.messages || []).map((message) => ({ ...message, assistantId: assistant.id }))
|
||||
db.topics.put({ ..._topic, messages }, topic.id)
|
||||
}
|
||||
try {
|
||||
state.assistants.assistants.forEach((assistant) => {
|
||||
assistant.topics.forEach((topic) => {
|
||||
topic.assistantId = assistant.id
|
||||
runAsyncFunction(async () => {
|
||||
const _topic = await db.topics.get(topic.id)
|
||||
if (_topic) {
|
||||
const messages = (_topic?.messages || []).map((message) => ({ ...message, assistantId: assistant.id }))
|
||||
db.topics.put({ ..._topic, messages }, topic.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
return state
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'35': (state: RootState) => {
|
||||
state.settings.mathEngine = 'KaTeX'
|
||||
return state
|
||||
try {
|
||||
state.settings.mathEngine = 'KaTeX'
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'36': (state: RootState) => {
|
||||
state.settings.topicPosition = 'left'
|
||||
return state
|
||||
try {
|
||||
state.settings.topicPosition = 'left'
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'37': (state: RootState) => {
|
||||
state.settings.messageStyle = 'plain'
|
||||
return state
|
||||
try {
|
||||
state.settings.messageStyle = 'plain'
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'38': (state: RootState) => {
|
||||
addProvider(state, 'grok')
|
||||
addProvider(state, 'hyperbolic')
|
||||
addProvider(state, 'mistral')
|
||||
return state
|
||||
try {
|
||||
addProvider(state, 'grok')
|
||||
addProvider(state, 'hyperbolic')
|
||||
addProvider(state, 'mistral')
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'39': (state: RootState) => {
|
||||
state.settings.codeStyle = 'auto'
|
||||
return state
|
||||
try {
|
||||
state.settings.codeStyle = 'auto'
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'40': (state: RootState) => {
|
||||
state.settings.tray = true
|
||||
return state
|
||||
try {
|
||||
state.settings.tray = true
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'41': (state: RootState) => {
|
||||
state.llm.providers.forEach((provider) => {
|
||||
if (provider.id === 'gemini') {
|
||||
provider.type = 'gemini'
|
||||
} else if (provider.id === 'anthropic') {
|
||||
provider.type = 'anthropic'
|
||||
} else {
|
||||
provider.type = 'openai'
|
||||
}
|
||||
})
|
||||
return state
|
||||
try {
|
||||
state.llm.providers.forEach((provider) => {
|
||||
if (provider.id === 'gemini') {
|
||||
provider.type = 'gemini'
|
||||
} else if (provider.id === 'anthropic') {
|
||||
provider.type = 'anthropic'
|
||||
} else {
|
||||
provider.type = 'openai'
|
||||
}
|
||||
})
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'42': (state: RootState) => {
|
||||
state.settings.proxyMode = state.settings.proxyUrl ? 'custom' : 'none'
|
||||
return state
|
||||
try {
|
||||
state.settings.proxyMode = state.settings.proxyUrl ? 'custom' : 'none'
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'43': (state: RootState) => {
|
||||
if (state.settings.proxyMode === 'none') {
|
||||
state.settings.proxyMode = 'system'
|
||||
try {
|
||||
if (state.settings.proxyMode === 'none') {
|
||||
state.settings.proxyMode = 'system'
|
||||
}
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
return state
|
||||
},
|
||||
'44': (state: RootState) => {
|
||||
state.settings.translateModelPrompt = TRANSLATE_PROMPT
|
||||
return state
|
||||
try {
|
||||
state.settings.translateModelPrompt = TRANSLATE_PROMPT
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'45': (state: RootState) => {
|
||||
state.settings.enableTopicNaming = true
|
||||
return state
|
||||
},
|
||||
'46': (state: RootState) => {
|
||||
if (
|
||||
state.settings?.translateModelPrompt?.includes(
|
||||
'If the target language is the same as the source language, do not translate'
|
||||
)
|
||||
) {
|
||||
state.settings.translateModelPrompt = TRANSLATE_PROMPT
|
||||
try {
|
||||
if (
|
||||
state.settings?.translateModelPrompt?.includes(
|
||||
'If the target language is the same as the source language, do not translate'
|
||||
)
|
||||
) {
|
||||
state.settings.translateModelPrompt = TRANSLATE_PROMPT
|
||||
}
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
return state
|
||||
},
|
||||
'47': (state: RootState) => {
|
||||
state.llm.providers.forEach((provider) => {
|
||||
provider.models.forEach((model) => {
|
||||
model.group = getDefaultGroupName(model.id)
|
||||
try {
|
||||
state.llm.providers.forEach((provider) => {
|
||||
provider.models.forEach((model) => {
|
||||
model.group = getDefaultGroupName(model.id)
|
||||
})
|
||||
})
|
||||
})
|
||||
return state
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'48': (state: RootState) => {
|
||||
if (state.shortcuts) {
|
||||
state.shortcuts.shortcuts.forEach((shortcut) => {
|
||||
shortcut.system = shortcut.key !== 'new_topic'
|
||||
})
|
||||
state.shortcuts.shortcuts.push({
|
||||
key: 'toggle_show_assistants',
|
||||
shortcut: [isMac ? 'Command' : 'Ctrl', '['],
|
||||
editable: true,
|
||||
enabled: true,
|
||||
system: false
|
||||
})
|
||||
state.shortcuts.shortcuts.push({
|
||||
key: 'toggle_show_topics',
|
||||
shortcut: [isMac ? 'Command' : 'Ctrl', ']'],
|
||||
editable: true,
|
||||
enabled: true,
|
||||
system: false
|
||||
})
|
||||
try {
|
||||
if (state.shortcuts) {
|
||||
state.shortcuts.shortcuts.forEach((shortcut) => {
|
||||
shortcut.system = shortcut.key !== 'new_topic'
|
||||
})
|
||||
state.shortcuts.shortcuts.push({
|
||||
key: 'toggle_show_assistants',
|
||||
shortcut: [isMac ? 'Command' : 'Ctrl', '['],
|
||||
editable: true,
|
||||
enabled: true,
|
||||
system: false
|
||||
})
|
||||
state.shortcuts.shortcuts.push({
|
||||
key: 'toggle_show_topics',
|
||||
shortcut: [isMac ? 'Command' : 'Ctrl', ']'],
|
||||
editable: true,
|
||||
enabled: true,
|
||||
system: false
|
||||
})
|
||||
}
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
return state
|
||||
},
|
||||
'49': (state: RootState) => {
|
||||
state.settings.pasteLongTextThreshold = 1500
|
||||
if (state.shortcuts) {
|
||||
state.shortcuts.shortcuts = [
|
||||
...state.shortcuts.shortcuts,
|
||||
{
|
||||
key: 'copy_last_message',
|
||||
shortcut: [isMac ? 'Command' : 'Ctrl', 'Shift', 'C'],
|
||||
editable: true,
|
||||
enabled: false,
|
||||
system: false
|
||||
}
|
||||
]
|
||||
try {
|
||||
state.settings.pasteLongTextThreshold = 1500
|
||||
if (state.shortcuts) {
|
||||
state.shortcuts.shortcuts = [
|
||||
...state.shortcuts.shortcuts,
|
||||
{
|
||||
key: 'copy_last_message',
|
||||
shortcut: [isMac ? 'Command' : 'Ctrl', 'Shift', 'C'],
|
||||
editable: true,
|
||||
enabled: false,
|
||||
system: false
|
||||
}
|
||||
]
|
||||
}
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
return state
|
||||
},
|
||||
'50': (state: RootState) => {
|
||||
addProvider(state, 'jina')
|
||||
return state
|
||||
try {
|
||||
addProvider(state, 'jina')
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'51': (state: RootState) => {
|
||||
state.settings.topicNamingPrompt = ''
|
||||
@ -634,24 +824,28 @@ const migrateConfig = {
|
||||
}
|
||||
},
|
||||
'67': (state: RootState) => {
|
||||
if (state.minapps) {
|
||||
const xiaoyi = DEFAULT_MIN_APPS.find((app) => app.id === 'xiaoyi')
|
||||
if (xiaoyi) {
|
||||
state.minapps.enabled.push(xiaoyi)
|
||||
try {
|
||||
if (state.minapps) {
|
||||
const xiaoyi = DEFAULT_MIN_APPS.find((app) => app.id === 'xiaoyi')
|
||||
if (xiaoyi) {
|
||||
state.minapps.enabled.push(xiaoyi)
|
||||
}
|
||||
}
|
||||
|
||||
addProvider(state, 'modelscope')
|
||||
addProvider(state, 'lmstudio')
|
||||
addProvider(state, 'perplexity')
|
||||
addProvider(state, 'infini')
|
||||
addProvider(state, 'dmxapi')
|
||||
|
||||
state.llm.settings.lmstudio = {
|
||||
keepAliveTime: 5
|
||||
}
|
||||
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
|
||||
addProvider(state, 'modelscope')
|
||||
addProvider(state, 'lmstudio')
|
||||
addProvider(state, 'perplexity')
|
||||
addProvider(state, 'infini')
|
||||
addProvider(state, 'dmxapi')
|
||||
|
||||
state.llm.settings.lmstudio = {
|
||||
keepAliveTime: 5
|
||||
}
|
||||
|
||||
return state
|
||||
},
|
||||
'68': (state: RootState) => {
|
||||
try {
|
||||
@ -945,6 +1139,31 @@ const migrateConfig = {
|
||||
return state
|
||||
}
|
||||
|
||||
return state
|
||||
},
|
||||
'87': (state: RootState) => {
|
||||
try {
|
||||
state.settings.maxKeepAliveMinapps = 3
|
||||
state.settings.showOpenedMinappsInSidebar = true
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
}
|
||||
},
|
||||
'88': (state: RootState) => {
|
||||
try {
|
||||
if (state?.mcp?.servers) {
|
||||
const hasAutoInstall = state.mcp.servers.some((server) => server.name === 'mcp-auto-install')
|
||||
if (!hasAutoInstall) {
|
||||
const defaultServer = mcpSlice.getInitialState().servers[0]
|
||||
state.mcp.servers = [{ ...defaultServer, id: nanoid() }, ...state.mcp.servers]
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return state
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
@ -85,6 +85,7 @@ export interface SettingsState {
|
||||
notionPageNameKey: string | null
|
||||
markdownExportPath: string | null
|
||||
forceDollarMathInMarkdown: boolean
|
||||
useTopicNamingForMessageTitle: boolean
|
||||
thoughtAutoCollapse: boolean
|
||||
notionAutoSplit: boolean
|
||||
notionSplitSize: number
|
||||
@ -129,7 +130,7 @@ const initialState: SettingsState = {
|
||||
showAssistantIcon: false,
|
||||
pasteLongTextAsFile: false,
|
||||
pasteLongTextThreshold: 1500,
|
||||
clickAssistantToShowTopic: false,
|
||||
clickAssistantToShowTopic: true,
|
||||
autoCheckUpdate: true,
|
||||
renderInputMessageAsMarkdown: false,
|
||||
codeShowLineNumbers: false,
|
||||
@ -167,6 +168,7 @@ const initialState: SettingsState = {
|
||||
notionPageNameKey: 'Name',
|
||||
markdownExportPath: null,
|
||||
forceDollarMathInMarkdown: false,
|
||||
useTopicNamingForMessageTitle: false,
|
||||
thoughtAutoCollapse: true,
|
||||
notionAutoSplit: false,
|
||||
notionSplitSize: 90,
|
||||
@ -372,6 +374,9 @@ const settingsSlice = createSlice({
|
||||
setForceDollarMathInMarkdown: (state, action: PayloadAction<boolean>) => {
|
||||
state.forceDollarMathInMarkdown = action.payload
|
||||
},
|
||||
setUseTopicNamingForMessageTitle: (state, action: PayloadAction<boolean>) => {
|
||||
state.useTopicNamingForMessageTitle = action.payload
|
||||
},
|
||||
setThoughtAutoCollapse: (state, action: PayloadAction<boolean>) => {
|
||||
state.thoughtAutoCollapse = action.payload
|
||||
},
|
||||
@ -483,6 +488,7 @@ export const {
|
||||
setNotionPageNameKey,
|
||||
setmarkdownExportPath,
|
||||
setForceDollarMathInMarkdown,
|
||||
setUseTopicNamingForMessageTitle,
|
||||
setThoughtAutoCollapse,
|
||||
setNotionAutoSplit,
|
||||
setNotionSplitSize,
|
||||
|
||||
@ -18,6 +18,7 @@ export type Assistant = {
|
||||
enableWebSearch?: boolean
|
||||
enableGenerateImage?: boolean
|
||||
promptVariables?: Variable[]
|
||||
mcpServers?: MCPServer[]
|
||||
}
|
||||
|
||||
export type AssistantMessage = {
|
||||
@ -374,6 +375,7 @@ export interface MCPServer {
|
||||
description?: string
|
||||
baseUrl?: string
|
||||
command?: string
|
||||
registryUrl?: string
|
||||
args?: string[]
|
||||
env?: Record<string, string>
|
||||
isActive: boolean
|
||||
|
||||
@ -66,7 +66,8 @@ export const exportMessageAsMarkdown = async (message: Message) => {
|
||||
const { markdownExportPath } = store.getState().settings
|
||||
if (!markdownExportPath) {
|
||||
try {
|
||||
const fileName = removeSpecialCharactersForFileName(getMessageTitle(message)) + '.md'
|
||||
const title = await getMessageTitle(message)
|
||||
const fileName = removeSpecialCharactersForFileName(title) + '.md'
|
||||
const markdown = messageToMarkdown(message)
|
||||
const result = await window.api.file.save(fileName, markdown)
|
||||
if (result) {
|
||||
@ -81,7 +82,8 @@ export const exportMessageAsMarkdown = async (message: Message) => {
|
||||
} else {
|
||||
try {
|
||||
const timestamp = dayjs().format('YYYY-MM-DD-HH-mm-ss')
|
||||
const fileName = removeSpecialCharactersForFileName(getMessageTitle(message)) + ` ${timestamp}.md`
|
||||
const title = await getMessageTitle(message)
|
||||
const fileName = removeSpecialCharactersForFileName(title) + ` ${timestamp}.md`
|
||||
const markdown = messageToMarkdown(message)
|
||||
await window.api.file.write(markdownExportPath + '/' + fileName, markdown)
|
||||
window.message.success({ content: i18n.t('message.success.markdown.export.preconf'), key: 'markdown-success' })
|
||||
|
||||
@ -1,59 +1,162 @@
|
||||
import { Tool, ToolUnion, ToolUseBlock } from '@anthropic-ai/sdk/resources'
|
||||
import { FunctionCall, FunctionDeclaration, SchemaType, Tool as geminiToool } from '@google/generative-ai'
|
||||
import { FunctionCall, FunctionDeclaration, SchemaType, Tool as geminiTool } from '@google/generative-ai'
|
||||
import {
|
||||
ArraySchema,
|
||||
BaseSchema,
|
||||
BooleanSchema,
|
||||
EnumStringSchema,
|
||||
FunctionDeclarationSchema,
|
||||
FunctionDeclarationSchemaProperty,
|
||||
IntegerSchema,
|
||||
NumberSchema,
|
||||
ObjectSchema,
|
||||
SimpleStringSchema
|
||||
} from '@google/generative-ai'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import store from '@renderer/store'
|
||||
import { addMCPServer } from '@renderer/store/mcp'
|
||||
import { MCPServer, MCPTool, MCPToolResponse } from '@renderer/types'
|
||||
import { ChatCompletionMessageToolCall, ChatCompletionTool } from 'openai/resources'
|
||||
|
||||
import { ChunkCallbackData } from '../providers'
|
||||
import { ChunkCallbackData } from '../providers/AiProvider'
|
||||
|
||||
const supportedAttributes = [
|
||||
'type',
|
||||
'nullable',
|
||||
'required',
|
||||
// 'format',
|
||||
'description',
|
||||
'properties',
|
||||
'items',
|
||||
'enum',
|
||||
'anyOf'
|
||||
]
|
||||
const ensureValidSchema = (obj: Record<string, any>): FunctionDeclarationSchemaProperty => {
|
||||
// Filter out unsupported keys for Gemini
|
||||
const filteredObj = filterUnsupportedKeys(obj)
|
||||
|
||||
function filterPropertieAttributes(tool: MCPTool, filterNestedObj = false) {
|
||||
// Handle base schema properties
|
||||
const baseSchema = {
|
||||
description: filteredObj.description,
|
||||
nullable: filteredObj.nullable
|
||||
} as BaseSchema
|
||||
|
||||
// Handle string type
|
||||
if (filteredObj.type?.toLowerCase() === SchemaType.STRING) {
|
||||
if (filteredObj.enum && Array.isArray(filteredObj.enum)) {
|
||||
return {
|
||||
...baseSchema,
|
||||
type: SchemaType.STRING,
|
||||
format: 'enum',
|
||||
enum: filteredObj.enum as string[]
|
||||
} as EnumStringSchema
|
||||
}
|
||||
return {
|
||||
...baseSchema,
|
||||
type: SchemaType.STRING,
|
||||
format: filteredObj.format === 'date-time' ? 'date-time' : undefined
|
||||
} as SimpleStringSchema
|
||||
}
|
||||
|
||||
// Handle number type
|
||||
if (filteredObj.type?.toLowerCase() === SchemaType.NUMBER) {
|
||||
return {
|
||||
...baseSchema,
|
||||
type: SchemaType.NUMBER,
|
||||
format: ['float', 'double'].includes(filteredObj.format) ? (filteredObj.format as 'float' | 'double') : undefined
|
||||
} as NumberSchema
|
||||
}
|
||||
|
||||
// Handle integer type
|
||||
if (filteredObj.type?.toLowerCase() === SchemaType.INTEGER) {
|
||||
return {
|
||||
...baseSchema,
|
||||
type: SchemaType.INTEGER,
|
||||
format: ['int32', 'int64'].includes(filteredObj.format) ? (filteredObj.format as 'int32' | 'int64') : undefined
|
||||
} as IntegerSchema
|
||||
}
|
||||
|
||||
// Handle boolean type
|
||||
if (filteredObj.type?.toLowerCase() === SchemaType.BOOLEAN) {
|
||||
return {
|
||||
...baseSchema,
|
||||
type: SchemaType.BOOLEAN
|
||||
} as BooleanSchema
|
||||
}
|
||||
|
||||
// Handle array type
|
||||
if (filteredObj.type?.toLowerCase() === SchemaType.ARRAY) {
|
||||
return {
|
||||
...baseSchema,
|
||||
type: SchemaType.ARRAY,
|
||||
items: filteredObj.items
|
||||
? ensureValidSchema(filteredObj.items as Record<string, any>)
|
||||
: ({ type: SchemaType.STRING } as SimpleStringSchema),
|
||||
minItems: filteredObj.minItems,
|
||||
maxItems: filteredObj.maxItems
|
||||
} as ArraySchema
|
||||
}
|
||||
|
||||
// Handle object type (default)
|
||||
const properties = filteredObj.properties
|
||||
? Object.fromEntries(
|
||||
Object.entries(filteredObj.properties).map(([key, value]) => [
|
||||
key,
|
||||
ensureValidSchema(value as Record<string, any>)
|
||||
])
|
||||
)
|
||||
: { _empty: { type: SchemaType.STRING } as SimpleStringSchema } // Ensure properties is never empty
|
||||
|
||||
return {
|
||||
...baseSchema,
|
||||
type: SchemaType.OBJECT,
|
||||
properties,
|
||||
required: Array.isArray(filteredObj.required) ? filteredObj.required : undefined
|
||||
} as ObjectSchema
|
||||
}
|
||||
|
||||
function filterUnsupportedKeys(obj: Record<string, any>): Record<string, any> {
|
||||
const supportedBaseKeys = ['description', 'nullable']
|
||||
const supportedStringKeys = [...supportedBaseKeys, 'type', 'format', 'enum']
|
||||
const supportedNumberKeys = [...supportedBaseKeys, 'type', 'format']
|
||||
const supportedBooleanKeys = [...supportedBaseKeys, 'type']
|
||||
const supportedArrayKeys = [...supportedBaseKeys, 'type', 'items', 'minItems', 'maxItems']
|
||||
const supportedObjectKeys = [...supportedBaseKeys, 'type', 'properties', 'required']
|
||||
|
||||
const filtered: Record<string, any> = {}
|
||||
|
||||
let keysToKeep: string[]
|
||||
|
||||
if (obj.type?.toLowerCase() === SchemaType.STRING) {
|
||||
keysToKeep = supportedStringKeys
|
||||
} else if (obj.type?.toLowerCase() === SchemaType.NUMBER) {
|
||||
keysToKeep = supportedNumberKeys
|
||||
} else if (obj.type?.toLowerCase() === SchemaType.INTEGER) {
|
||||
keysToKeep = supportedNumberKeys
|
||||
} else if (obj.type?.toLowerCase() === SchemaType.BOOLEAN) {
|
||||
keysToKeep = supportedBooleanKeys
|
||||
} else if (obj.type?.toLowerCase() === SchemaType.ARRAY) {
|
||||
keysToKeep = supportedArrayKeys
|
||||
} else {
|
||||
// Default to object type
|
||||
keysToKeep = supportedObjectKeys
|
||||
}
|
||||
|
||||
// copy supported keys
|
||||
for (const key of keysToKeep) {
|
||||
if (obj[key] !== undefined) {
|
||||
filtered[key] = obj[key]
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
function filterPropertieAttributes(tool: MCPTool, filterNestedObj: boolean = false): Record<string, object> {
|
||||
const properties = tool.inputSchema.properties
|
||||
if (!properties) {
|
||||
return {}
|
||||
}
|
||||
const getSubMap = (obj: Record<string, any>, keys: string[]) => {
|
||||
const filtered = Object.fromEntries(Object.entries(obj).filter(([key]) => keys.includes(key)))
|
||||
|
||||
if (filterNestedObj) {
|
||||
return {
|
||||
...filtered,
|
||||
...(obj.type === 'object' && obj.properties
|
||||
? {
|
||||
properties: Object.fromEntries(
|
||||
Object.entries(obj.properties).map(([k, v]) => [
|
||||
k,
|
||||
(v as any).type === 'object' ? getSubMap(v as Record<string, any>, keys) : v
|
||||
])
|
||||
)
|
||||
}
|
||||
: {}),
|
||||
...(obj.type === 'array' && obj.items?.type === 'object'
|
||||
? {
|
||||
items: getSubMap(obj.items, keys)
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
// For OpenAI, we don't need to validate as strictly
|
||||
if (!filterNestedObj) {
|
||||
return properties
|
||||
}
|
||||
|
||||
for (const [key, val] of Object.entries(properties)) {
|
||||
properties[key] = getSubMap(val, supportedAttributes)
|
||||
}
|
||||
return properties
|
||||
const processedProperties = Object.fromEntries(
|
||||
Object.entries(properties).map(([key, value]) => [key, ensureValidSchema(value as Record<string, any>)])
|
||||
)
|
||||
|
||||
return processedProperties
|
||||
}
|
||||
|
||||
export function mcpToolsToOpenAITools(mcpTools: MCPTool[]): Array<ChatCompletionTool> {
|
||||
@ -126,6 +229,23 @@ export async function callMCPTool(tool: MCPTool): Promise<any> {
|
||||
})
|
||||
|
||||
console.log(`[MCP] Tool called: ${tool.serverName} ${tool.name}`, resp)
|
||||
|
||||
if (tool.serverName === 'mcp-auto-install') {
|
||||
if (resp.data) {
|
||||
const mcpServer: MCPServer = {
|
||||
id: `f${nanoid()}`,
|
||||
name: resp.data.name,
|
||||
description: resp.data.description,
|
||||
baseUrl: resp.data.baseUrl,
|
||||
command: resp.data.command,
|
||||
args: resp.data.args,
|
||||
env: resp.data.env,
|
||||
isActive: false
|
||||
}
|
||||
store.dispatch(addMCPServer(mcpServer))
|
||||
}
|
||||
}
|
||||
|
||||
return resp
|
||||
} catch (e) {
|
||||
console.error(`[MCP] Error calling Tool: ${tool.serverName} ${tool.name}`, e)
|
||||
@ -164,7 +284,7 @@ export function anthropicToolUseToMcpTool(mcpTools: MCPTool[] | undefined, toolU
|
||||
return tool
|
||||
}
|
||||
|
||||
export function mcpToolsToGeminiTools(mcpTools: MCPTool[] | undefined): geminiToool[] {
|
||||
export function mcpToolsToGeminiTools(mcpTools: MCPTool[] | undefined): geminiTool[] {
|
||||
if (!mcpTools || mcpTools.length === 0) {
|
||||
// No tools available
|
||||
return []
|
||||
@ -176,18 +296,19 @@ export function mcpToolsToGeminiTools(mcpTools: MCPTool[] | undefined): geminiTo
|
||||
const functionDeclaration: FunctionDeclaration = {
|
||||
name: tool.id,
|
||||
description: tool.description,
|
||||
...(Object.keys(properties).length > 0
|
||||
? {
|
||||
parameters: {
|
||||
type: SchemaType.OBJECT,
|
||||
properties
|
||||
}
|
||||
}
|
||||
: {})
|
||||
parameters: {
|
||||
type: SchemaType.OBJECT,
|
||||
properties:
|
||||
Object.keys(properties).length > 0
|
||||
? Object.fromEntries(
|
||||
Object.entries(properties).map(([key, value]) => [key, ensureValidSchema(value as Record<string, any>)])
|
||||
)
|
||||
: { _empty: { type: SchemaType.STRING } as SimpleStringSchema }
|
||||
} as FunctionDeclarationSchema
|
||||
}
|
||||
functions.push(functionDeclaration)
|
||||
}
|
||||
const tool: geminiToool = {
|
||||
const tool: geminiTool = {
|
||||
functionDeclarations: functions
|
||||
}
|
||||
return [tool]
|
||||
|
||||
10
yarn.lock
10
yarn.lock
@ -1212,10 +1212,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@google/generative-ai@npm:^0.21.0":
|
||||
version: 0.21.0
|
||||
resolution: "@google/generative-ai@npm:0.21.0"
|
||||
checksum: 10c0/cff5946c5964f2380e5097d82bd563d79be27a1a5ac604aaaad3f9ba3382992e4f0a371bd255baabfba4e5bdf296d8ce1410cbd65424afa98e64b2590fe49f3b
|
||||
"@google/generative-ai@npm:^0.24.0":
|
||||
version: 0.24.0
|
||||
resolution: "@google/generative-ai@npm:0.24.0"
|
||||
checksum: 10c0/31452bf2653cdee7fd61eb209f16ac0ef82c94c4175909ba40e1088e938e3e19e01f628dfb80d429dae3338fc8487e9a0fd8a6ff0164189f2722211175690b0b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -3767,7 +3767,7 @@ __metadata:
|
||||
"@eslint-react/eslint-plugin": "npm:^1.36.1"
|
||||
"@eslint/js": "npm:^9.22.0"
|
||||
"@google/genai": "npm:^0.4.0"
|
||||
"@google/generative-ai": "npm:^0.21.0"
|
||||
"@google/generative-ai": "npm:^0.24.0"
|
||||
"@hello-pangea/dnd": "npm:^16.6.0"
|
||||
"@kangfenmao/keyv-storage": "npm:^0.1.0"
|
||||
"@langchain/community": "npm:^0.3.36"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user