Compare commits
77 Commits
v1.1.12
...
feat/varia
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
370cfd6e9f | ||
|
|
5cdf4eff77 | ||
|
|
b53dbcbb30 | ||
|
|
a42283e789 | ||
|
|
d2ed9972bd | ||
|
|
0fd9b6e56c | ||
|
|
d213bc1024 | ||
|
|
91b9a48c48 | ||
|
|
e572b3801b | ||
|
|
4bf15aed25 | ||
|
|
6d568688ed | ||
|
|
f20cbf31a8 | ||
|
|
bfbfba13fe | ||
|
|
8b9929cc7b | ||
|
|
a90be7e83f | ||
|
|
efa68c8519 | ||
|
|
d7bd240a9a | ||
|
|
95df69ff82 | ||
|
|
e41df917b4 | ||
|
|
0a33649b3c | ||
|
|
d1cb7258d2 | ||
|
|
8fbedb2bd0 | ||
|
|
750247aef8 | ||
|
|
32e1f428e7 | ||
|
|
aee6219a75 | ||
|
|
5329fa7ede | ||
|
|
ba640d4070 | ||
|
|
8c5f61d407 | ||
|
|
b43ecb75f5 | ||
|
|
3dc4947e26 | ||
|
|
a5b0480418 | ||
|
|
8a7db19e73 | ||
|
|
2da8a73124 | ||
|
|
5223a3c5a6 | ||
|
|
72c5de3b81 | ||
|
|
9f11e7c22b | ||
|
|
1ce86c11ca | ||
|
|
57c1b59a51 | ||
|
|
a2f9067908 | ||
|
|
2a4c512e49 | ||
|
|
94eb7f3a34 | ||
|
|
b363cb06a4 | ||
|
|
9e977f4b35 | ||
|
|
00de616958 | ||
|
|
1187a47698 | ||
|
|
83d0eb07aa | ||
|
|
8f6bf11320 | ||
|
|
22b0bd54b4 | ||
|
|
be39c5f40c | ||
|
|
8b00ff4b93 | ||
|
|
f5b675b356 | ||
|
|
de8dbb2646 | ||
|
|
7e67005e70 | ||
|
|
d6e66f3a4d | ||
|
|
e5aaec2129 | ||
|
|
464634d051 | ||
|
|
3698238e9e | ||
|
|
ae2a661201 | ||
|
|
d6dbe357fb | ||
|
|
e9dd795f9a | ||
|
|
03a18c1f3b | ||
|
|
e3ba44fc2c | ||
|
|
9976ad9ed0 | ||
|
|
3bb294e698 | ||
|
|
990b1651a9 | ||
|
|
11c070a1d7 | ||
|
|
57ba91072d | ||
|
|
433d562599 | ||
|
|
194ba1baa0 | ||
|
|
53ae427f2f | ||
|
|
3f40cc28ac | ||
|
|
d3584d2d39 | ||
|
|
da0db73916 | ||
|
|
21f1b8b373 | ||
|
|
f1a03916e7 | ||
|
|
45f0bfa0f9 | ||
|
|
f2102daf00 |
@@ -83,8 +83,7 @@ afterPack: scripts/after-pack.js
|
|||||||
afterSign: scripts/notarize.js
|
afterSign: scripts/notarize.js
|
||||||
releaseInfo:
|
releaseInfo:
|
||||||
releaseNotes: |
|
releaseNotes: |
|
||||||
知识库设置增加重排模型,提升知识库的准确性
|
小程序支持多开
|
||||||
自定义服务商增加兼容模式
|
支持 GPT-4o 图像生成
|
||||||
增加 Github Copilot 服务商
|
修复 MCP 服务器无法使用问题
|
||||||
PlantUML 预览支持放大和缩小
|
修复升级导致旧版本数据丢失问题
|
||||||
联网模式支持增强模式
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "CherryStudio",
|
"name": "CherryStudio",
|
||||||
"version": "1.1.12",
|
"version": "1.1.17",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "A powerful AI assistant for producer.",
|
"description": "A powerful AI assistant for producer.",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
"@cherrystudio/embedjs-openai": "^0.1.28",
|
"@cherrystudio/embedjs-openai": "^0.1.28",
|
||||||
"@electron-toolkit/utils": "^3.0.0",
|
"@electron-toolkit/utils": "^3.0.0",
|
||||||
"@electron/notarize": "^2.5.0",
|
"@electron/notarize": "^2.5.0",
|
||||||
"@google/generative-ai": "^0.21.0",
|
"@google/generative-ai": "^0.24.0",
|
||||||
"@langchain/community": "^0.3.36",
|
"@langchain/community": "^0.3.36",
|
||||||
"@notionhq/client": "^2.2.15",
|
"@notionhq/client": "^2.2.15",
|
||||||
"@tryfabric/martian": "^1.2.4",
|
"@tryfabric/martian": "^1.2.4",
|
||||||
@@ -166,6 +166,7 @@
|
|||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
"rehype-mathjax": "^7.0.0",
|
"rehype-mathjax": "^7.0.0",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
|
"remark-cjk-friendly": "^1.1.0",
|
||||||
"remark-gfm": "^4.0.0",
|
"remark-gfm": "^4.0.0",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"rollup-plugin-visualizer": "^5.12.0",
|
"rollup-plugin-visualizer": "^5.12.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||||
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
|
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
|
||||||
import { app, ipcMain } from 'electron'
|
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 { registerIpc } from './ipc'
|
||||||
import { configManager } from './services/ConfigManager'
|
import { configManager } from './services/ConfigManager'
|
||||||
@@ -48,7 +48,7 @@ if (!app.requestSingleInstanceLock()) {
|
|||||||
replaceDevtoolsFont(mainWindow)
|
replaceDevtoolsFont(mainWindow)
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
installExtension(REDUX_DEVTOOLS)
|
installExtension([REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS])
|
||||||
.then((name) => console.log(`Added Extension: ${name}`))
|
.then((name) => console.log(`Added Extension: ${name}`))
|
||||||
.catch((err) => console.log('An error occurred: ', err))
|
.catch((err) => console.log('An error occurred: ', err))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
ipcMain.handle('miniwindow:hide', () => windowService.hideMiniWindow())
|
ipcMain.handle('miniwindow:hide', () => windowService.hideMiniWindow())
|
||||||
ipcMain.handle('miniwindow:close', () => windowService.closeMiniWindow())
|
ipcMain.handle('miniwindow:close', () => windowService.closeMiniWindow())
|
||||||
ipcMain.handle('miniwindow:toggle', () => windowService.toggleMiniWindow())
|
ipcMain.handle('miniwindow:toggle', () => windowService.toggleMiniWindow())
|
||||||
|
ipcMain.handle('miniwindow:set-pin', (_, isPinned) => windowService.setPinMiniWindow(isPinned))
|
||||||
|
|
||||||
// aes
|
// aes
|
||||||
ipcMain.handle('aes:encrypt', (_, text: string, secretKey: string, iv: string) => encrypt(text, secretKey, iv))
|
ipcMain.handle('aes:encrypt', (_, text: string, secretKey: string, iv: string) => encrypt(text, secretKey, iv))
|
||||||
@@ -264,6 +265,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
|
|
||||||
// Register MCP handlers
|
// Register MCP handlers
|
||||||
ipcMain.handle('mcp:remove-server', mcpService.removeServer)
|
ipcMain.handle('mcp:remove-server', mcpService.removeServer)
|
||||||
|
ipcMain.handle('mcp:restart-server', mcpService.restartServer)
|
||||||
|
ipcMain.handle('mcp:stop-server', mcpService.stopServer)
|
||||||
ipcMain.handle('mcp:list-tools', mcpService.listTools)
|
ipcMain.handle('mcp:list-tools', mcpService.listTools)
|
||||||
ipcMain.handle('mcp:call-tool', mcpService.callTool)
|
ipcMain.handle('mcp:call-tool', mcpService.callTool)
|
||||||
ipcMain.handle('mcp:get-install-info', mcpService.getInstallInfo)
|
ipcMain.handle('mcp:get-install-info', mcpService.getInstallInfo)
|
||||||
|
|||||||
@@ -87,9 +87,16 @@ class BackupManager {
|
|||||||
await fs.ensureDir(this.tempDir)
|
await fs.ensureDir(this.tempDir)
|
||||||
onProgress({ stage: 'preparing', progress: 0, total: 100 })
|
onProgress({ stage: 'preparing', progress: 0, total: 100 })
|
||||||
|
|
||||||
// 将 data 写入临时文件
|
// 使用流的方式写入 data.json
|
||||||
const tempDataPath = path.join(this.tempDir, '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 })
|
onProgress({ stage: 'writing_data', progress: 20, total: 100 })
|
||||||
|
|
||||||
// 复制 Data 目录到临时目录
|
// 复制 Data 目录到临时目录
|
||||||
@@ -208,8 +215,15 @@ class BackupManager {
|
|||||||
fs.mkdirSync(this.backupDir, { recursive: true })
|
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)
|
return await this.restore(_, backupedFilePath)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import path from 'node:path'
|
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 { getBinaryName, getBinaryPath } from '@main/utils/process'
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.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 { MCPServer } from '@types'
|
import { nanoid } from '@reduxjs/toolkit'
|
||||||
|
import { MCPServer, MCPTool } from '@types'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import Logger from 'electron-log'
|
import Logger from 'electron-log'
|
||||||
|
|
||||||
|
import { CacheService } from './CacheService'
|
||||||
|
|
||||||
class McpService {
|
class McpService {
|
||||||
private client: Client | null = null
|
|
||||||
private clients: Map<string, Client> = new Map()
|
private clients: Map<string, Client> = new Map()
|
||||||
|
|
||||||
private getServerKey(server: MCPServer): string {
|
private getServerKey(server: MCPServer): string {
|
||||||
@@ -18,6 +22,7 @@ class McpService {
|
|||||||
baseUrl: server.baseUrl,
|
baseUrl: server.baseUrl,
|
||||||
command: server.command,
|
command: server.command,
|
||||||
args: server.args,
|
args: server.args,
|
||||||
|
registryUrl: server.registryUrl,
|
||||||
env: server.env,
|
env: server.env,
|
||||||
id: server.id
|
id: server.id
|
||||||
})
|
})
|
||||||
@@ -29,25 +34,30 @@ class McpService {
|
|||||||
this.callTool = this.callTool.bind(this)
|
this.callTool = this.callTool.bind(this)
|
||||||
this.closeClient = this.closeClient.bind(this)
|
this.closeClient = this.closeClient.bind(this)
|
||||||
this.removeServer = this.removeServer.bind(this)
|
this.removeServer = this.removeServer.bind(this)
|
||||||
|
this.restartServer = this.restartServer.bind(this)
|
||||||
|
this.stopServer = this.stopServer.bind(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
async initClient(server: MCPServer) {
|
async initClient(server: MCPServer): Promise<Client> {
|
||||||
const serverKey = this.getServerKey(server)
|
const serverKey = this.getServerKey(server)
|
||||||
|
|
||||||
// Check if we already have a client for this server configuration
|
// Check if we already have a client for this server configuration
|
||||||
const existingClient = this.clients.get(serverKey)
|
const existingClient = this.clients.get(serverKey)
|
||||||
if (existingClient) {
|
if (existingClient) {
|
||||||
this.client = existingClient
|
// Check if the existing client is still connected
|
||||||
return
|
const pingResult = await existingClient.ping()
|
||||||
}
|
Logger.info(`[MCP] Ping result for ${server.name}:`, pingResult)
|
||||||
|
// If the ping fails, remove the client from the cache
|
||||||
// If there's an existing client for a different server, close it
|
// and create a new one
|
||||||
if (this.client) {
|
if (!pingResult) {
|
||||||
await this.closeClient()
|
this.clients.delete(serverKey)
|
||||||
|
} else {
|
||||||
|
return existingClient
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new client instance for each connection
|
// Create new client instance for each connection
|
||||||
this.client = new Client({ name: 'Cherry Studio', version: app.getVersion() }, { capabilities: {} })
|
const client = new Client({ name: 'Cherry Studio', version: app.getVersion() }, { capabilities: {} })
|
||||||
|
|
||||||
const args = [...(server.args || [])]
|
const args = [...(server.args || [])]
|
||||||
|
|
||||||
@@ -60,13 +70,8 @@ class McpService {
|
|||||||
} else if (server.command) {
|
} else if (server.command) {
|
||||||
let cmd = server.command
|
let cmd = server.command
|
||||||
|
|
||||||
if (server.command === 'npx') {
|
if (server.command === 'npx' || server.command === 'bun' || server.command === 'bunx') {
|
||||||
cmd = await getBinaryPath('bun')
|
cmd = await getBinaryPath('bun')
|
||||||
|
|
||||||
if (cmd === 'bun') {
|
|
||||||
cmd = 'npx'
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.info(`[MCP] Using command: ${cmd}`)
|
Logger.info(`[MCP] Using command: ${cmd}`)
|
||||||
|
|
||||||
// add -x to args if args exist
|
// add -x to args if args exist
|
||||||
@@ -74,67 +79,123 @@ class McpService {
|
|||||||
if (!args.includes('-y')) {
|
if (!args.includes('-y')) {
|
||||||
!args.includes('-y') && args.unshift('-y')
|
!args.includes('-y') && args.unshift('-y')
|
||||||
}
|
}
|
||||||
if (cmd.includes('bun') && !args.includes('x')) {
|
if (!args.includes('x')) {
|
||||||
args.unshift('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] Starting server with command: ${cmd} ${args ? args.join(' ') : ''}`)
|
||||||
|
// Logger.info(`[MCP] Environment variables for server:`, server.env)
|
||||||
|
|
||||||
transport = new StdioClientTransport({
|
transport = new StdioClientTransport({
|
||||||
command: cmd,
|
command: cmd,
|
||||||
args,
|
args,
|
||||||
env: server.env
|
env: {
|
||||||
|
...getDefaultEnvironment(),
|
||||||
|
PATH: this.getEnhancedPath(process.env.PATH || ''),
|
||||||
|
...server.env
|
||||||
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Either baseUrl or command must be provided')
|
throw new Error('Either baseUrl or command must be provided')
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.client.connect(transport)
|
await client.connect(transport)
|
||||||
|
|
||||||
// Store the new client in the cache
|
// Store the new client in the cache
|
||||||
this.clients.set(serverKey, this.client)
|
this.clients.set(serverKey, client)
|
||||||
|
|
||||||
Logger.info(`[MCP] Activated server: ${server.name}`)
|
Logger.info(`[MCP] Activated server: ${server.name}`)
|
||||||
|
return client
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
Logger.error(`[MCP] Error activating server ${server.name}:`, error)
|
Logger.error(`[MCP] Error activating server ${server.name}:`, error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async closeClient() {
|
async closeClient(serverKey: string) {
|
||||||
if (this.client) {
|
const client = this.clients.get(serverKey)
|
||||||
|
if (client) {
|
||||||
// Remove the client from the cache
|
// Remove the client from the cache
|
||||||
for (const [key, client] of this.clients.entries()) {
|
await client.close()
|
||||||
if (client === this.client) {
|
Logger.info(`[MCP] Closed server: ${serverKey}`)
|
||||||
this.clients.delete(key)
|
this.clients.delete(serverKey)
|
||||||
break
|
CacheService.remove(`mcp:list_tool:${serverKey}`)
|
||||||
}
|
Logger.info(`[MCP] Cleared cache for server: ${serverKey}`)
|
||||||
}
|
} else {
|
||||||
|
Logger.warn(`[MCP] No client found for server: ${serverKey}`)
|
||||||
await this.client.close()
|
|
||||||
this.client = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async stopServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
|
||||||
|
const serverKey = this.getServerKey(server)
|
||||||
|
Logger.info(`[MCP] Stopping server: ${server.name}`)
|
||||||
|
await this.closeClient(serverKey)
|
||||||
|
}
|
||||||
|
|
||||||
async removeServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
|
async removeServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
|
||||||
await this.closeClient()
|
const serverKey = this.getServerKey(server)
|
||||||
this.clients.delete(this.getServerKey(server))
|
const existingClient = this.clients.get(serverKey)
|
||||||
|
if (existingClient) {
|
||||||
|
await this.closeClient(serverKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async restartServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
|
||||||
|
Logger.info(`[MCP] Restarting server: ${server.name}`)
|
||||||
|
const serverKey = this.getServerKey(server)
|
||||||
|
await this.closeClient(serverKey)
|
||||||
|
await this.initClient(server)
|
||||||
}
|
}
|
||||||
|
|
||||||
async listTools(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
|
async listTools(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
|
||||||
await this.initClient(server)
|
const client = await this.initClient(server)
|
||||||
const { tools } = await this.client!.listTools()
|
const serverKey = this.getServerKey(server)
|
||||||
return tools.map((tool) => ({
|
const cacheKey = `mcp:list_tool:${serverKey}`
|
||||||
...tool,
|
if (CacheService.has(cacheKey)) {
|
||||||
serverId: server.id,
|
Logger.info(`[MCP] Tools from ${server.name} loaded from cache`)
|
||||||
serverName: server.name
|
const cachedTools = CacheService.get<MCPTool[]>(cacheKey)
|
||||||
}))
|
if (cachedTools && cachedTools.length > 0) {
|
||||||
|
return cachedTools
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Logger.info(`[MCP] Listing tools for server: ${server.name}`)
|
||||||
|
const { tools } = await client.listTools()
|
||||||
|
const serverTools: MCPTool[] = []
|
||||||
|
tools.map((tool: any) => {
|
||||||
|
const serverTool: MCPTool = {
|
||||||
|
...tool,
|
||||||
|
id: `f${nanoid()}`,
|
||||||
|
serverId: server.id,
|
||||||
|
serverName: server.name
|
||||||
|
}
|
||||||
|
serverTools.push(serverTool)
|
||||||
|
})
|
||||||
|
CacheService.set(cacheKey, serverTools, 5 * 60 * 1000)
|
||||||
|
return serverTools
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,11 +205,10 @@ class McpService {
|
|||||||
_: Electron.IpcMainInvokeEvent,
|
_: Electron.IpcMainInvokeEvent,
|
||||||
{ server, name, args }: { server: MCPServer; name: string; args: any }
|
{ server, name, args }: { server: MCPServer; name: string; args: any }
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
await this.initClient(server)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Logger.info('[MCP] Calling:', server.name, name, args)
|
Logger.info('[MCP] Calling:', server.name, name, args)
|
||||||
const result = await this.client!.callTool({ name, arguments: args })
|
const client = await this.initClient(server)
|
||||||
|
const result = await client.callTool({ name, arguments: args })
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(`[MCP] Error calling tool ${name} on ${server.name}:`, error)
|
Logger.error(`[MCP] Error calling tool ${name} on ${server.name}:`, error)
|
||||||
@@ -164,6 +224,70 @@ class McpService {
|
|||||||
const bunPath = path.join(dir, bunName)
|
const bunPath = path.join(dir, bunName)
|
||||||
return { dir, uvPath, bunPath }
|
return { dir, uvPath, bunPath }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get enhanced PATH including common tool locations
|
||||||
|
*/
|
||||||
|
private getEnhancedPath(originalPath: string): string {
|
||||||
|
// 将原始 PATH 按分隔符分割成数组
|
||||||
|
const pathSeparator = process.platform === 'win32' ? ';' : ':'
|
||||||
|
const existingPaths = new Set(originalPath.split(pathSeparator).filter(Boolean))
|
||||||
|
const homeDir = process.env.HOME || process.env.USERPROFILE || ''
|
||||||
|
|
||||||
|
// 定义要添加的新路径
|
||||||
|
const newPaths: string[] = []
|
||||||
|
|
||||||
|
if (isMac) {
|
||||||
|
newPaths.push(
|
||||||
|
'/bin',
|
||||||
|
'/usr/bin',
|
||||||
|
'/usr/local/bin',
|
||||||
|
'/usr/local/sbin',
|
||||||
|
'/opt/homebrew/bin',
|
||||||
|
'/opt/homebrew/sbin',
|
||||||
|
'/usr/local/opt/node/bin',
|
||||||
|
`${homeDir}/.nvm/current/bin`,
|
||||||
|
`${homeDir}/.npm-global/bin`,
|
||||||
|
`${homeDir}/.yarn/bin`,
|
||||||
|
`${homeDir}/.cargo/bin`,
|
||||||
|
`${homeDir}/.cherrystudio/bin`,
|
||||||
|
'/opt/local/bin'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLinux) {
|
||||||
|
newPaths.push(
|
||||||
|
'/bin',
|
||||||
|
'/usr/bin',
|
||||||
|
'/usr/local/bin',
|
||||||
|
`${homeDir}/.nvm/current/bin`,
|
||||||
|
`${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`,
|
||||||
|
`${homeDir}\\.cherrystudio\\bin`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只添加不存在的路径
|
||||||
|
newPaths.forEach((path) => {
|
||||||
|
if (path && !existingPaths.has(path)) {
|
||||||
|
existingPaths.add(path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 转换回字符串
|
||||||
|
return Array.from(existingPaths).join(pathSeparator)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new McpService()
|
export default new McpService()
|
||||||
|
|||||||
@@ -112,10 +112,10 @@ function convertToFileStat(serverBase: string, item: WebDAVResponse['multistatus
|
|||||||
const props = item.propstat.prop
|
const props = item.propstat.prop
|
||||||
const isDir = !isNil(props.resourcetype?.collection)
|
const isDir = !isNil(props.resourcetype?.collection)
|
||||||
const href = decodeURIComponent(item.href)
|
const href = decodeURIComponent(item.href)
|
||||||
const filename = serverBase === '/' ? href : path.join('/', href.replace(serverBase, ''))
|
const filename = serverBase === '/' ? href : path.posix.join('/', href.replace(serverBase, ''))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filename,
|
filename: filename.endsWith('/') ? filename.slice(0, -1) : filename,
|
||||||
basename: path.basename(filename),
|
basename: path.basename(filename),
|
||||||
lastmod: props.getlastmodified || '',
|
lastmod: props.getlastmodified || '',
|
||||||
size: props.getcontentlength ? parseInt(props.getcontentlength, 10) : 0,
|
size: props.getcontentlength ? parseInt(props.getcontentlength, 10) : 0,
|
||||||
|
|||||||
@@ -23,17 +23,8 @@ function getShortcutHandler(shortcut: Shortcut) {
|
|||||||
configManager.setZoomFactor(1)
|
configManager.setZoomFactor(1)
|
||||||
}
|
}
|
||||||
case 'show_app':
|
case 'show_app':
|
||||||
return (window: BrowserWindow) => {
|
return () => {
|
||||||
if (window.isVisible()) {
|
windowService.toggleMainWindow()
|
||||||
if (window.isFocused()) {
|
|
||||||
window.hide()
|
|
||||||
} else {
|
|
||||||
window.focus()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
window.show()
|
|
||||||
window.focus()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
case 'mini_window':
|
case 'mini_window':
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ export class WindowService {
|
|||||||
private static instance: WindowService | null = null
|
private static instance: WindowService | null = null
|
||||||
private mainWindow: BrowserWindow | null = null
|
private mainWindow: BrowserWindow | null = null
|
||||||
private miniWindow: BrowserWindow | null = null
|
private miniWindow: BrowserWindow | null = null
|
||||||
|
private isPinnedMiniWindow: boolean = false
|
||||||
private wasFullScreen: boolean = false
|
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 selectionMenuWindow: BrowserWindow | null = null
|
||||||
private lastSelectedText: string = ''
|
private lastSelectedText: string = ''
|
||||||
private contextMenu: Menu | null = null
|
private contextMenu: Menu | null = null
|
||||||
@@ -30,6 +34,7 @@ export class WindowService {
|
|||||||
public createMainWindow(): BrowserWindow {
|
public createMainWindow(): BrowserWindow {
|
||||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||||
this.mainWindow.show()
|
this.mainWindow.show()
|
||||||
|
this.mainWindow.focus()
|
||||||
return this.mainWindow
|
return this.mainWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +61,7 @@ export class WindowService {
|
|||||||
titleBarOverlay: theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight,
|
titleBarOverlay: theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight,
|
||||||
backgroundColor: isMac ? undefined : theme === 'dark' ? '#181818' : '#FFFFFF',
|
backgroundColor: isMac ? undefined : theme === 'dark' ? '#181818' : '#FFFFFF',
|
||||||
trafficLightPosition: { x: 8, y: 12 },
|
trafficLightPosition: { x: 8, y: 12 },
|
||||||
...(process.platform === 'linux' ? { icon } : {}),
|
...(isLinux ? { icon } : {}),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, '../preload/index.js'),
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
sandbox: false,
|
sandbox: false,
|
||||||
@@ -68,6 +73,12 @@ export class WindowService {
|
|||||||
|
|
||||||
this.setupMainWindow(this.mainWindow, mainWindowState)
|
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
|
return this.mainWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +159,8 @@ export class WindowService {
|
|||||||
// show window only when laucn to tray not set
|
// show window only when laucn to tray not set
|
||||||
const isLaunchToTray = configManager.getLaunchToTray()
|
const isLaunchToTray = configManager.getLaunchToTray()
|
||||||
if (!isLaunchToTray) {
|
if (!isLaunchToTray) {
|
||||||
|
//[mac]hacky-fix: miniWindow set visibleOnFullScreen:true will cause dock icon disappeared
|
||||||
|
app.dock?.show()
|
||||||
mainWindow.show()
|
mainWindow.show()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -163,6 +176,25 @@ export class WindowService {
|
|||||||
mainWindow.webContents.send('fullscreen-status-changed', false)
|
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键退出全屏的支持
|
// 添加Escape键退出全屏的支持
|
||||||
mainWindow.webContents.on('before-input-event', (event, input) => {
|
mainWindow.webContents.on('before-input-event', (event, input) => {
|
||||||
// 当按下Escape键且窗口处于全屏状态时退出全屏
|
// 当按下Escape键且窗口处于全屏状态时退出全屏
|
||||||
@@ -286,9 +318,8 @@ export class WindowService {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
mainWindow.hide()
|
mainWindow.hide()
|
||||||
|
|
||||||
if (isMac && isTrayOnClose) {
|
//for mac users, should hide dock icon if close to tray
|
||||||
app.dock?.hide() //for mac to hide to tray
|
app.dock?.hide()
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
mainWindow.on('closed', () => {
|
mainWindow.on('closed', () => {
|
||||||
@@ -309,44 +340,52 @@ export class WindowService {
|
|||||||
|
|
||||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||||
if (this.mainWindow.isMinimized()) {
|
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.show()
|
||||||
this.mainWindow.focus()
|
this.mainWindow.focus()
|
||||||
|
this.mainWindow.setVisibleOnAllWorkspaces(false)
|
||||||
} else {
|
} else {
|
||||||
this.mainWindow = this.createMainWindow()
|
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() {
|
public toggleMainWindow() {
|
||||||
const enableQuickAssistant = configManager.getEnableQuickAssistant()
|
// should not toggle main window when in full screen
|
||||||
|
if (this.wasFullScreen) {
|
||||||
if (!enableQuickAssistant) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) {
|
if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.isVisible()) {
|
||||||
this.selectionMenuWindow.hide()
|
if (this.mainWindow.isFocused()) {
|
||||||
}
|
// if tray is enabled, hide the main window, else do nothing
|
||||||
|
if (configManager.getTray()) {
|
||||||
if (this.miniWindow && !this.miniWindow.isDestroyed()) {
|
this.mainWindow.hide()
|
||||||
if (this.miniWindow.isMinimized()) {
|
app.dock?.hide()
|
||||||
this.miniWindow.restore()
|
}
|
||||||
|
} else {
|
||||||
|
this.mainWindow.focus()
|
||||||
}
|
}
|
||||||
this.miniWindow.show()
|
|
||||||
this.miniWindow.center()
|
|
||||||
this.miniWindow.focus()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.showMainWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
public createMiniWindow(isPreload: boolean = false): BrowserWindow {
|
||||||
this.miniWindow = new BrowserWindow({
|
this.miniWindow = new BrowserWindow({
|
||||||
width: 500,
|
width: 550,
|
||||||
height: 520,
|
height: 400,
|
||||||
show: true,
|
minWidth: 350,
|
||||||
|
minHeight: 380,
|
||||||
|
maxWidth: 1024,
|
||||||
|
maxHeight: 768,
|
||||||
|
show: false,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
transparent: isMac,
|
transparent: isMac,
|
||||||
vibrancy: 'under-window',
|
vibrancy: 'under-window',
|
||||||
@@ -354,8 +393,13 @@ export class WindowService {
|
|||||||
center: true,
|
center: true,
|
||||||
frame: false,
|
frame: false,
|
||||||
alwaysOnTop: true,
|
alwaysOnTop: true,
|
||||||
resizable: false,
|
resizable: true,
|
||||||
useContentSize: true,
|
useContentSize: true,
|
||||||
|
...(isMac ? { type: 'panel' } : {}),
|
||||||
|
skipTaskbar: true,
|
||||||
|
minimizable: false,
|
||||||
|
maximizable: false,
|
||||||
|
fullscreenable: false,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, '../preload/index.js'),
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
sandbox: false,
|
sandbox: false,
|
||||||
@@ -364,8 +408,25 @@ 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.on('blur', () => {
|
||||||
this.miniWindow?.hide()
|
if (!this.isPinnedMiniWindow) {
|
||||||
|
this.hideMiniWindow()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.miniWindow.on('closed', () => {
|
this.miniWindow.on('closed', () => {
|
||||||
@@ -391,9 +452,48 @@ export class WindowService {
|
|||||||
hash: '#/mini'
|
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() {
|
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()
|
this.miniWindow?.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,11 +502,16 @@ export class WindowService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public toggleMiniWindow() {
|
public toggleMiniWindow() {
|
||||||
if (this.miniWindow) {
|
if (this.miniWindow && !this.miniWindow.isDestroyed() && this.miniWindow.isVisible()) {
|
||||||
this.miniWindow.isVisible() ? this.miniWindow.hide() : this.miniWindow.show()
|
this.hideMiniWindow()
|
||||||
} else {
|
return
|
||||||
this.showMiniWindow()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.showMiniWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
public setPinMiniWindow(isPinned) {
|
||||||
|
this.isPinnedMiniWindow = isPinned
|
||||||
}
|
}
|
||||||
|
|
||||||
public showSelectionMenu(bounds: { x: number; y: number }) {
|
public showSelectionMenu(bounds: { x: number; y: number }) {
|
||||||
|
|||||||
@@ -46,3 +46,9 @@ export function dumpPersistState() {
|
|||||||
export const runAsyncFunction = async (fn: () => void) => {
|
export const runAsyncFunction = async (fn: () => void) => {
|
||||||
await fn()
|
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
|
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 binaryName = await getBinaryName(name)
|
||||||
const binariesDir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
const binariesDir = path.join(os.homedir(), '.cherrystudio', 'bin')
|
||||||
const binariesDirExists = await fs.existsSync(binariesDir)
|
const binariesDirExists = await fs.existsSync(binariesDir)
|
||||||
|
|||||||
3
src/preload/index.d.ts
vendored
3
src/preload/index.d.ts
vendored
@@ -137,6 +137,7 @@ declare global {
|
|||||||
hide: () => Promise<void>
|
hide: () => Promise<void>
|
||||||
close: () => Promise<void>
|
close: () => Promise<void>
|
||||||
toggle: () => Promise<void>
|
toggle: () => Promise<void>
|
||||||
|
setPin: (isPinned: boolean) => Promise<void>
|
||||||
}
|
}
|
||||||
aes: {
|
aes: {
|
||||||
encrypt: (text: string, secretKey: string, iv: string) => Promise<{ iv: string; encryptedData: string }>
|
encrypt: (text: string, secretKey: string, iv: string) => Promise<{ iv: string; encryptedData: string }>
|
||||||
@@ -147,6 +148,8 @@ declare global {
|
|||||||
}
|
}
|
||||||
mcp: {
|
mcp: {
|
||||||
removeServer: (server: MCPServer) => Promise<void>
|
removeServer: (server: MCPServer) => Promise<void>
|
||||||
|
restartServer: (server: MCPServer) => Promise<void>
|
||||||
|
stopServer: (server: MCPServer) => Promise<void>
|
||||||
listTools: (server: MCPServer) => Promise<MCPTool[]>
|
listTools: (server: MCPServer) => Promise<MCPTool[]>
|
||||||
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => Promise<any>
|
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => Promise<any>
|
||||||
getInstallInfo: () => Promise<{ dir: string; uvPath: string; bunPath: string }>
|
getInstallInfo: () => Promise<{ dir: string; uvPath: string; bunPath: string }>
|
||||||
|
|||||||
@@ -112,7 +112,8 @@ const api = {
|
|||||||
show: () => ipcRenderer.invoke('miniwindow:show'),
|
show: () => ipcRenderer.invoke('miniwindow:show'),
|
||||||
hide: () => ipcRenderer.invoke('miniwindow:hide'),
|
hide: () => ipcRenderer.invoke('miniwindow:hide'),
|
||||||
close: () => ipcRenderer.invoke('miniwindow:close'),
|
close: () => ipcRenderer.invoke('miniwindow:close'),
|
||||||
toggle: () => ipcRenderer.invoke('miniwindow:toggle')
|
toggle: () => ipcRenderer.invoke('miniwindow:toggle'),
|
||||||
|
setPin: (isPinned: boolean) => ipcRenderer.invoke('miniwindow:set-pin', isPinned)
|
||||||
},
|
},
|
||||||
aes: {
|
aes: {
|
||||||
encrypt: (text: string, secretKey: string, iv: string) => ipcRenderer.invoke('aes:encrypt', text, secretKey, iv),
|
encrypt: (text: string, secretKey: string, iv: string) => ipcRenderer.invoke('aes:encrypt', text, secretKey, iv),
|
||||||
@@ -121,6 +122,8 @@ const api = {
|
|||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
removeServer: (server: MCPServer) => ipcRenderer.invoke('mcp:remove-server', server),
|
removeServer: (server: MCPServer) => ipcRenderer.invoke('mcp:remove-server', server),
|
||||||
|
restartServer: (server: MCPServer) => ipcRenderer.invoke('mcp:restart-server', server),
|
||||||
|
stopServer: (server: MCPServer) => ipcRenderer.invoke('mcp:stop-server', server),
|
||||||
listTools: (server: MCPServer) => ipcRenderer.invoke('mcp:list-tools', server),
|
listTools: (server: MCPServer) => ipcRenderer.invoke('mcp:list-tools', server),
|
||||||
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) =>
|
callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) =>
|
||||||
ipcRenderer.invoke('mcp:call-tool', { server, name, args }),
|
ipcRenderer.invoke('mcp:call-tool', { server, name, args }),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@keyframes pulse {
|
@keyframes animation-pulse {
|
||||||
0% {
|
0% {
|
||||||
box-shadow: 0 0 0 0 rgba(var(--pulse-color), 0.5);
|
box-shadow: 0 0 0 0 rgba(var(--pulse-color), 0.5);
|
||||||
}
|
}
|
||||||
@@ -14,5 +14,5 @@
|
|||||||
.animation-pulse {
|
.animation-pulse {
|
||||||
--pulse-color: 59, 130, 246;
|
--pulse-color: 59, 130, 246;
|
||||||
--pulse-size: 8px;
|
--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)
|
||||||
429
src/renderer/src/components/MinApp/MinappPopupContainer.tsx
Normal file
429
src/renderer/src/components/MinApp/MinappPopupContainer.tsx
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
import {
|
||||||
|
CloseOutlined,
|
||||||
|
CodeOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
ExportOutlined,
|
||||||
|
MinusOutlined,
|
||||||
|
PushpinOutlined,
|
||||||
|
ReloadOutlined
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { isMac, isWindows } from '@renderer/config/constant'
|
||||||
|
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||||
|
import { useBridge } from '@renderer/hooks/useBridge'
|
||||||
|
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||||
|
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||||
|
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
|
import { MinAppType } from '@renderer/types'
|
||||||
|
import { delay } from '@renderer/utils'
|
||||||
|
import { Avatar, Drawer, Tooltip } from 'antd'
|
||||||
|
import { WebviewTag } from 'electron'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import BeatLoader from 'react-spinners/BeatLoader'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
import WebviewContainer from './WebviewContainer'
|
||||||
|
|
||||||
|
interface AppExtraInfo {
|
||||||
|
canPinned: boolean
|
||||||
|
isPinned: boolean
|
||||||
|
canOpenExternalLink: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppInfo = MinAppType & AppExtraInfo
|
||||||
|
|
||||||
|
/** The main container for MinApp popup */
|
||||||
|
const MinappPopupContainer: React.FC = () => {
|
||||||
|
const { openedKeepAliveMinapps, openedOneOffMinapp, currentMinappId, minappShow } = useRuntime()
|
||||||
|
const { closeMinapp, hideMinappPopup } = useMinappPopup()
|
||||||
|
const { pinned, updatePinnedMinapps } = useMinapps()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
/** control the drawer open or close */
|
||||||
|
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)
|
||||||
|
const lastMinappShow = useRef<boolean>(false)
|
||||||
|
|
||||||
|
/** store the webview refs, one of the key to make them keepalive */
|
||||||
|
const webviewRefs = useRef<Map<string, WebviewTag | null>>(new Map())
|
||||||
|
/** indicate whether the webview has loaded */
|
||||||
|
const webviewLoadedRefs = useRef<Map<string, boolean>>(new Map())
|
||||||
|
|
||||||
|
const isInDevelopment = process.env.NODE_ENV === 'development'
|
||||||
|
|
||||||
|
useBridge()
|
||||||
|
|
||||||
|
/** 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)) {
|
||||||
|
setIsReady(true)
|
||||||
|
/** the case that open the minapp from sidebar */
|
||||||
|
} else if (lastMinappId.current !== currentMinappId && lastMinappShow.current === minappShow) {
|
||||||
|
setIsReady(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsPopupShow(false)
|
||||||
|
setIsReady(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
/** renew the last minapp id and show status */
|
||||||
|
lastMinappId.current = currentMinappId
|
||||||
|
lastMinappShow.current = minappShow
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [minappShow, currentMinappId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!webviewRefs.current) return
|
||||||
|
|
||||||
|
/** set the webview display status
|
||||||
|
* DO NOT use the state to set the display status,
|
||||||
|
* to AVOID the re-render of the webview container
|
||||||
|
*/
|
||||||
|
webviewRefs.current.forEach((webviewRef, appid) => {
|
||||||
|
if (!webviewRef) return
|
||||||
|
webviewRef.style.display = appid === currentMinappId ? 'inline-flex' : 'none'
|
||||||
|
})
|
||||||
|
|
||||||
|
//delete the extra webviewLoadedRefs
|
||||||
|
webviewLoadedRefs.current.forEach((_, appid) => {
|
||||||
|
if (!webviewRefs.current.has(appid)) {
|
||||||
|
webviewLoadedRefs.current.delete(appid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [currentMinappId])
|
||||||
|
|
||||||
|
/** only the keepalive minapp can be minimized */
|
||||||
|
const canMinimize = !(openedOneOffMinapp && openedOneOffMinapp.id == currentMinappId)
|
||||||
|
|
||||||
|
/** combine the openedKeepAliveMinapps and openedOneOffMinapp */
|
||||||
|
const combinedApps = useMemo(() => {
|
||||||
|
return [...openedKeepAliveMinapps, ...(openedOneOffMinapp ? [openedOneOffMinapp] : [])]
|
||||||
|
}, [openedKeepAliveMinapps, openedOneOffMinapp])
|
||||||
|
|
||||||
|
/** get the extra info of the apps */
|
||||||
|
const appsExtraInfo = useMemo(() => {
|
||||||
|
return combinedApps.reduce(
|
||||||
|
(acc, app) => ({
|
||||||
|
...acc,
|
||||||
|
[app.id]: {
|
||||||
|
canPinned: DEFAULT_MIN_APPS.some((item) => item.id === app.id),
|
||||||
|
isPinned: pinned.some((item) => item.id === app.id),
|
||||||
|
canOpenExternalLink: app.url.startsWith('http://') || app.url.startsWith('https://')
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{} as Record<string, AppExtraInfo>
|
||||||
|
)
|
||||||
|
}, [combinedApps, pinned])
|
||||||
|
|
||||||
|
/** get the current app info with extra info */
|
||||||
|
let currentAppInfo: AppInfo | null = null
|
||||||
|
if (currentMinappId) {
|
||||||
|
const currentApp = combinedApps.find((item) => item.id === currentMinappId) as MinAppType
|
||||||
|
currentAppInfo = { ...currentApp, ...appsExtraInfo[currentApp.id] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** will close the popup and delete the webview */
|
||||||
|
const handlePopupClose = async (appid: string) => {
|
||||||
|
setIsPopupShow(false)
|
||||||
|
await delay(0.3)
|
||||||
|
webviewLoadedRefs.current.delete(appid)
|
||||||
|
closeMinapp(appid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** will hide the popup and remain the webviews */
|
||||||
|
const handlePopupMinimize = async () => {
|
||||||
|
setIsPopupShow(false)
|
||||||
|
await delay(0.3)
|
||||||
|
hideMinappPopup()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** the callback function to set the webviews ref */
|
||||||
|
const handleWebviewSetRef = (appid: string, element: WebviewTag | null) => {
|
||||||
|
webviewRefs.current.set(appid, element)
|
||||||
|
|
||||||
|
if (!webviewRefs.current.has(appid)) {
|
||||||
|
webviewRefs.current.set(appid, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
webviewRefs.current.set(appid, element)
|
||||||
|
} else {
|
||||||
|
webviewRefs.current.delete(appid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** the callback function to set the webviews loaded indicator */
|
||||||
|
const handleWebviewLoaded = (appid: string) => {
|
||||||
|
webviewLoadedRefs.current.set(appid, true)
|
||||||
|
if (appid == currentMinappId) {
|
||||||
|
setTimeout(() => setIsReady(true), 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
if (webview) {
|
||||||
|
webview.openDevTools()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** only reload the original url */
|
||||||
|
const handleReload = (appid: string) => {
|
||||||
|
const webview = webviewRefs.current.get(appid)
|
||||||
|
if (webview) {
|
||||||
|
const url = combinedApps.find((item) => item.id === appid)?.url
|
||||||
|
if (url) {
|
||||||
|
webview.src = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** open the giving url in browser */
|
||||||
|
const handleOpenLink = (url: string) => {
|
||||||
|
window.api.openWebsite(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** toggle the pin status of the minapp */
|
||||||
|
const handleTogglePin = (appid: string) => {
|
||||||
|
const app = combinedApps.find((item) => item.id === appid)
|
||||||
|
if (!app) return
|
||||||
|
|
||||||
|
const newPinned = appsExtraInfo[appid].isPinned ? pinned.filter((item) => item.id !== appid) : [...pinned, app]
|
||||||
|
updatePinnedMinapps(newPinned)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Title bar of the popup */
|
||||||
|
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' }}>
|
||||||
|
<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)}>
|
||||||
|
<ReloadOutlined />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
{appInfo.canPinned && (
|
||||||
|
<Tooltip
|
||||||
|
title={appInfo.isPinned ? t('minapp.sidebar.remove.title') : t('minapp.sidebar.add.title')}
|
||||||
|
mouseEnterDelay={0.8}
|
||||||
|
placement="bottom">
|
||||||
|
<Button onClick={() => handleTogglePin(appInfo.id)} className={appInfo.isPinned ? 'pinned' : ''}>
|
||||||
|
<PushpinOutlined style={{ fontSize: 16 }} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{appInfo.canOpenExternalLink && (
|
||||||
|
<Tooltip title={t('minapp.popup.openExternal')} mouseEnterDelay={0.8} placement="bottom">
|
||||||
|
<Button onClick={() => handleOpenLink(url ?? appInfo.url)}>
|
||||||
|
<ExportOutlined />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{isInDevelopment && (
|
||||||
|
<Tooltip title={t('minapp.popup.devtools')} mouseEnterDelay={0.8} placement="bottom">
|
||||||
|
<Button onClick={() => handleOpenDevTools(appInfo.id)}>
|
||||||
|
<CodeOutlined />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{canMinimize && (
|
||||||
|
<Tooltip title={t('minapp.popup.minimize')} mouseEnterDelay={0.8} placement="bottom">
|
||||||
|
<Button onClick={() => handlePopupMinimize()}>
|
||||||
|
<MinusOutlined />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip title={t('minapp.popup.close')} mouseEnterDelay={0.8} placement="bottom">
|
||||||
|
<Button onClick={() => handlePopupClose(appInfo.id)}>
|
||||||
|
<CloseOutlined />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</ButtonsGroup>
|
||||||
|
</TitleContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** group the webview containers with Memo, one of the key to make them keepalive */
|
||||||
|
const WebviewContainerGroup = useMemo(() => {
|
||||||
|
return combinedApps.map((app) => (
|
||||||
|
<WebviewContainer
|
||||||
|
key={app.id}
|
||||||
|
appid={app.id}
|
||||||
|
url={app.url}
|
||||||
|
onSetRefCallback={handleWebviewSetRef}
|
||||||
|
onLoadedCallback={handleWebviewLoaded}
|
||||||
|
onNavigateCallback={handleWebviewNavigate}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
// because the combinedApps is enough
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [combinedApps])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={<Title appInfo={currentAppInfo} url={currentUrl} />}
|
||||||
|
placement="bottom"
|
||||||
|
onClose={handlePopupMinimize}
|
||||||
|
open={isPopupShow}
|
||||||
|
destroyOnClose={false}
|
||||||
|
mask={false}
|
||||||
|
rootClassName="minapp-drawer"
|
||||||
|
maskClassName="minapp-mask"
|
||||||
|
height={'100%'}
|
||||||
|
maskClosable={false}
|
||||||
|
closeIcon={null}
|
||||||
|
style={{ marginLeft: 'var(--sidebar-width)' }}>
|
||||||
|
{!isReady && (
|
||||||
|
<EmptyView>
|
||||||
|
<Avatar
|
||||||
|
src={currentAppInfo?.logo}
|
||||||
|
size={80}
|
||||||
|
style={{ border: '1px solid var(--color-border)', marginTop: -150 }}
|
||||||
|
/>
|
||||||
|
<BeatLoader color="var(--color-text-2)" size="10px" style={{ marginTop: 15 }} />
|
||||||
|
</EmptyView>
|
||||||
|
)}
|
||||||
|
{WebviewContainerGroup}
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TitleContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
padding-left: ${isMac ? '20px' : '10px'};
|
||||||
|
padding-right: 10px;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
`
|
||||||
|
|
||||||
|
const TitleText = styled.div`
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
margin-right: 10px;
|
||||||
|
-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;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
&.windows {
|
||||||
|
margin-right: ${isWindows ? '130px' : 0};
|
||||||
|
background-color: var(--color-background-mute);
|
||||||
|
border-radius: 50px;
|
||||||
|
padding: 0 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const Button = styled.div`
|
||||||
|
cursor: pointer;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 14px;
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-text-1);
|
||||||
|
background-color: var(--color-background-mute);
|
||||||
|
}
|
||||||
|
&.pinned {
|
||||||
|
color: var(--color-primary);
|
||||||
|
background-color: var(--color-primary-bg);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const EmptyView = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
`
|
||||||
|
|
||||||
|
export default MinappPopupContainer
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import MinappPopupContainer from '@renderer/components/MinApp/MinappPopupContainer'
|
||||||
|
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
|
|
||||||
|
const TopViewMinappContainer = () => {
|
||||||
|
const { openedKeepAliveMinapps, openedOneOffMinapp } = useRuntime()
|
||||||
|
const isCreate = openedKeepAliveMinapps.length > 0 || openedOneOffMinapp !== null
|
||||||
|
|
||||||
|
return <>{isCreate && <MinappPopupContainer />}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TopViewMinappContainer
|
||||||
92
src/renderer/src/components/MinApp/WebviewContainer.tsx
Normal file
92
src/renderer/src/components/MinApp/WebviewContainer.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { WebviewTag } from 'electron'
|
||||||
|
import { memo, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebviewContainer is a component that renders a webview element.
|
||||||
|
* It is used in the MinAppPopupContainer component.
|
||||||
|
* The webcontent can be remain in memory
|
||||||
|
*/
|
||||||
|
const WebviewContainer = memo(
|
||||||
|
({
|
||||||
|
appid,
|
||||||
|
url,
|
||||||
|
onSetRefCallback,
|
||||||
|
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)
|
||||||
|
|
||||||
|
const setRef = (appid: string) => {
|
||||||
|
onSetRefCallback(appid, null)
|
||||||
|
|
||||||
|
return (element: WebviewTag | null) => {
|
||||||
|
onSetRefCallback(appid, element)
|
||||||
|
if (element) {
|
||||||
|
webviewRef.current = element
|
||||||
|
} else {
|
||||||
|
webviewRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!webviewRef.current) return
|
||||||
|
|
||||||
|
const handleNewWindow = (event: any) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (webviewRef.current?.loadURL) {
|
||||||
|
webviewRef.current.loadURL(event.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoaded = () => {
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
}, [appid, url])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<webview
|
||||||
|
key={appid}
|
||||||
|
ref={setRef(appid)}
|
||||||
|
style={WebviewStyle}
|
||||||
|
allowpopups={'true' as any}
|
||||||
|
partition="persist:webview"
|
||||||
|
nodeintegration={'true' as any}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const WebviewStyle: React.CSSProperties = {
|
||||||
|
width: 'calc(100vw - var(--sidebar-width))',
|
||||||
|
height: 'calc(100vh - var(--navbar-height))',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
display: 'inline-flex'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WebviewContainer
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
import { CloseOutlined, CodeOutlined, ExportOutlined, PushpinOutlined, ReloadOutlined } from '@ant-design/icons'
|
|
||||||
import { isMac, isWindows } from '@renderer/config/constant'
|
|
||||||
import { AppLogo } from '@renderer/config/env'
|
|
||||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
|
||||||
import { useBridge } from '@renderer/hooks/useBridge'
|
|
||||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
|
||||||
import store from '@renderer/store'
|
|
||||||
import { setMinappShow } from '@renderer/store/runtime'
|
|
||||||
import { MinAppType } from '@renderer/types'
|
|
||||||
import { delay } from '@renderer/utils'
|
|
||||||
import { Avatar, Drawer } from 'antd'
|
|
||||||
import { WebviewTag } from 'electron'
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import BeatLoader from 'react-spinners/BeatLoader'
|
|
||||||
import styled from 'styled-components'
|
|
||||||
|
|
||||||
import { TopView } from '../TopView'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
app: MinAppType
|
|
||||||
resolve: (data: any) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const PopupContainer: React.FC<Props> = ({ app, resolve }) => {
|
|
||||||
const { pinned, updatePinnedMinapps } = useMinapps()
|
|
||||||
const isPinned = pinned.some((p) => p.id === app.id)
|
|
||||||
const [open, setOpen] = useState(true)
|
|
||||||
const [opened, setOpened] = useState(false)
|
|
||||||
const [isReady, setIsReady] = useState(false)
|
|
||||||
const webviewRef = useRef<WebviewTag | null>(null)
|
|
||||||
|
|
||||||
useBridge()
|
|
||||||
|
|
||||||
const canOpenExternalLink = app.url.startsWith('http://') || app.url.startsWith('https://')
|
|
||||||
const canPinned = DEFAULT_MIN_APPS.some((i) => i.id === app?.id)
|
|
||||||
|
|
||||||
const onClose = async (_delay = 0.3) => {
|
|
||||||
setOpen(false)
|
|
||||||
await delay(_delay)
|
|
||||||
resolve({})
|
|
||||||
}
|
|
||||||
|
|
||||||
MinApp.onClose = onClose
|
|
||||||
const openDevTools = () => {
|
|
||||||
if (webviewRef.current) {
|
|
||||||
webviewRef.current.openDevTools()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const onReload = () => {
|
|
||||||
if (webviewRef.current) {
|
|
||||||
webviewRef.current.src = app.url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onOpenLink = () => {
|
|
||||||
if (webviewRef.current) {
|
|
||||||
const currentUrl = webviewRef.current.getURL()
|
|
||||||
window.api.openWebsite(currentUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onTogglePin = () => {
|
|
||||||
const newPinned = isPinned ? pinned.filter((item) => item.id !== app.id) : [...pinned, app]
|
|
||||||
updatePinnedMinapps(newPinned)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isInDevelopment = process.env.NODE_ENV === 'development'
|
|
||||||
|
|
||||||
const Title = () => {
|
|
||||||
return (
|
|
||||||
<TitleContainer style={{ justifyContent: 'space-between' }}>
|
|
||||||
<TitleText>{app.name}</TitleText>
|
|
||||||
<ButtonsGroup className={isWindows ? 'windows' : ''}>
|
|
||||||
<Button onClick={onReload}>
|
|
||||||
<ReloadOutlined />
|
|
||||||
</Button>
|
|
||||||
{canPinned && (
|
|
||||||
<Button onClick={onTogglePin} className={isPinned ? 'pinned' : ''}>
|
|
||||||
<PushpinOutlined style={{ fontSize: 16 }} />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{canOpenExternalLink && (
|
|
||||||
<Button onClick={onOpenLink}>
|
|
||||||
<ExportOutlined />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isInDevelopment && (
|
|
||||||
<Button onClick={openDevTools}>
|
|
||||||
<CodeOutlined />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button onClick={() => onClose()}>
|
|
||||||
<CloseOutlined />
|
|
||||||
</Button>
|
|
||||||
</ButtonsGroup>
|
|
||||||
</TitleContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const webview = webviewRef.current
|
|
||||||
|
|
||||||
if (webview) {
|
|
||||||
const handleNewWindow = (event: any) => {
|
|
||||||
event.preventDefault()
|
|
||||||
if (webview.loadURL) {
|
|
||||||
webview.loadURL(event.url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onLoaded = () => setIsReady(true)
|
|
||||||
|
|
||||||
webview.addEventListener('new-window', handleNewWindow)
|
|
||||||
webview.addEventListener('did-finish-load', onLoaded)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
webview.removeEventListener('new-window', handleNewWindow)
|
|
||||||
webview.removeEventListener('did-finish-load', onLoaded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {}
|
|
||||||
}, [opened])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTimeout(() => setOpened(true), 350)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
title={<Title />}
|
|
||||||
placement="bottom"
|
|
||||||
onClose={() => onClose()}
|
|
||||||
open={open}
|
|
||||||
mask={true}
|
|
||||||
rootClassName="minapp-drawer"
|
|
||||||
maskClassName="minapp-mask"
|
|
||||||
height={'100%'}
|
|
||||||
maskClosable={false}
|
|
||||||
closeIcon={null}
|
|
||||||
style={{ marginLeft: 'var(--sidebar-width)' }}>
|
|
||||||
{!isReady && (
|
|
||||||
<EmptyView>
|
|
||||||
<Avatar src={app.logo} size={80} style={{ border: '1px solid var(--color-border)', marginTop: -150 }} />
|
|
||||||
<BeatLoader color="var(--color-text-2)" size="10" style={{ marginTop: 15 }} />
|
|
||||||
</EmptyView>
|
|
||||||
)}
|
|
||||||
{opened && (
|
|
||||||
<webview
|
|
||||||
src={app.url}
|
|
||||||
ref={webviewRef}
|
|
||||||
style={WebviewStyle}
|
|
||||||
allowpopups={'true' as any}
|
|
||||||
partition="persist:webview"
|
|
||||||
nodeintegration={true}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Drawer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const WebviewStyle: React.CSSProperties = {
|
|
||||||
width: 'calc(100vw - var(--sidebar-width))',
|
|
||||||
height: 'calc(100vh - var(--navbar-height))',
|
|
||||||
backgroundColor: 'white',
|
|
||||||
display: 'inline-flex'
|
|
||||||
}
|
|
||||||
|
|
||||||
const TitleContainer = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
padding-left: ${isMac ? '20px' : '10px'};
|
|
||||||
padding-right: 10px;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: transparent;
|
|
||||||
`
|
|
||||||
|
|
||||||
const TitleText = styled.div`
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--color-text-1);
|
|
||||||
margin-right: 10px;
|
|
||||||
user-select: none;
|
|
||||||
`
|
|
||||||
|
|
||||||
const ButtonsGroup = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
&.windows {
|
|
||||||
margin-right: ${isWindows ? '130px' : 0};
|
|
||||||
background-color: var(--color-background-mute);
|
|
||||||
border-radius: 50px;
|
|
||||||
padding: 0 3px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const Button = styled.div`
|
|
||||||
cursor: pointer;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
border-radius: 5px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--color-text-2);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
font-size: 14px;
|
|
||||||
&:hover {
|
|
||||||
color: var(--color-text-1);
|
|
||||||
background-color: var(--color-background-mute);
|
|
||||||
}
|
|
||||||
&.pinned {
|
|
||||||
color: var(--color-primary);
|
|
||||||
background-color: var(--color-primary-bg);
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const EmptyView = styled.div`
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-color: var(--color-background);
|
|
||||||
`
|
|
||||||
|
|
||||||
export default class MinApp {
|
|
||||||
static topviewId = 0
|
|
||||||
static onClose = () => {}
|
|
||||||
static app: MinAppType | null = null
|
|
||||||
|
|
||||||
static async start(app: MinAppType) {
|
|
||||||
if (app?.id && MinApp.app?.id === app?.id) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (MinApp.app) {
|
|
||||||
// @ts-ignore delay params
|
|
||||||
await MinApp.onClose(0)
|
|
||||||
await delay(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!app.logo) {
|
|
||||||
app.logo = AppLogo
|
|
||||||
}
|
|
||||||
|
|
||||||
MinApp.app = app
|
|
||||||
store.dispatch(setMinappShow(true))
|
|
||||||
|
|
||||||
return new Promise<any>((resolve) => {
|
|
||||||
TopView.show(
|
|
||||||
<PopupContainer
|
|
||||||
app={app}
|
|
||||||
resolve={(v) => {
|
|
||||||
resolve(v)
|
|
||||||
this.close()
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
'MinApp'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
static close() {
|
|
||||||
TopView.hide('MinApp')
|
|
||||||
store.dispatch(setMinappShow(false))
|
|
||||||
MinApp.app = null
|
|
||||||
MinApp.onClose = () => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ import { Agent, Assistant } from '@renderer/types'
|
|||||||
import { uuid } from '@renderer/utils'
|
import { uuid } from '@renderer/utils'
|
||||||
import { Divider, Input, InputRef, Modal, Tag } from 'antd'
|
import { Divider, Input, InputRef, Modal, Tag } from 'antd'
|
||||||
import { take } from 'lodash'
|
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 { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
@@ -30,6 +30,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
|||||||
const inputRef = useRef<InputRef>(null)
|
const inputRef = useRef<InputRef>(null)
|
||||||
const systemAgents = useSystemAgents()
|
const systemAgents = useSystemAgents()
|
||||||
const loadingRef = useRef(false)
|
const loadingRef = useRef(false)
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const agents = useMemo(() => {
|
const agents = useMemo(() => {
|
||||||
const allAgents = [...userAgents, ...systemAgents] as Agent[]
|
const allAgents = [...userAgents, ...systemAgents] as Agent[]
|
||||||
@@ -52,25 +54,80 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
|||||||
return filtered
|
return filtered
|
||||||
}, [assistants, defaultAssistant, searchText, systemAgents, userAgents])
|
}, [assistants, defaultAssistant, searchText, systemAgents, userAgents])
|
||||||
|
|
||||||
const onCreateAssistant = async (agent: Agent) => {
|
// 重置选中索引当搜索或列表内容变更时
|
||||||
if (loadingRef.current) {
|
useEffect(() => {
|
||||||
return
|
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
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
let assistant: Assistant
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [open, selectedIndex, agents, searchText, onCreateAssistant])
|
||||||
|
|
||||||
if (agent.id === 'default') {
|
// 确保选中项在可视区域
|
||||||
assistant = { ...agent, id: uuid() }
|
useEffect(() => {
|
||||||
addAssistant(assistant)
|
if (containerRef.current) {
|
||||||
} else {
|
const agentItems = containerRef.current.querySelectorAll('.agent-item')
|
||||||
assistant = await createAssistantFromAgent(agent)
|
if (agentItems[selectedIndex]) {
|
||||||
|
agentItems[selectedIndex].scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'nearest'
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}, [selectedIndex])
|
||||||
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
|
|
||||||
resolve(assistant)
|
|
||||||
setOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
@@ -121,12 +178,13 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
|||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} />
|
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} />
|
||||||
<Container>
|
<Container ref={containerRef}>
|
||||||
{take(agents, 100).map((agent) => (
|
{take(agents, 100).map((agent, index) => (
|
||||||
<AgentItem
|
<AgentItem
|
||||||
key={agent.id}
|
key={agent.id}
|
||||||
onClick={() => onCreateAssistant(agent)}
|
onClick={() => onCreateAssistant(agent)}
|
||||||
className={agent.id === 'default' ? 'default' : ''}>
|
className={`agent-item ${agent.id === 'default' ? 'default' : ''} ${index === selectedIndex ? 'keyboard-selected' : ''}`}
|
||||||
|
onMouseEnter={() => setSelectedIndex(index)}>
|
||||||
<HStack
|
<HStack
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
gap={5}
|
gap={5}
|
||||||
@@ -161,9 +219,14 @@ const AgentItem = styled.div`
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border: 1px solid transparent;
|
||||||
&.default {
|
&.default {
|
||||||
background-color: var(--color-background-mute);
|
background-color: var(--color-background-mute);
|
||||||
}
|
}
|
||||||
|
&.keyboard-selected {
|
||||||
|
background-color: var(--color-background-mute);
|
||||||
|
border: 1px solid var(--color-primary);
|
||||||
|
}
|
||||||
.anticon {
|
.anticon {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: var(--color-icon);
|
color: var(--color-icon);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer'
|
||||||
import { useAppInit } from '@renderer/hooks/useAppInit'
|
import { useAppInit } from '@renderer/hooks/useAppInit'
|
||||||
import { message, Modal } from 'antd'
|
import { message, Modal } from 'antd'
|
||||||
import React, { PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react'
|
import React, { PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
@@ -76,6 +77,7 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
|
|||||||
{children}
|
{children}
|
||||||
{messageContextHolder}
|
{messageContextHolder}
|
||||||
{modalContextHolder}
|
{modalContextHolder}
|
||||||
|
<TopViewMinappContainer />
|
||||||
{elements.map(({ element: Element, id }) => (
|
{elements.map(({ element: Element, id }) => (
|
||||||
<FullScreenContainer key={`TOPVIEW_${id}`}>
|
<FullScreenContainer key={`TOPVIEW_${id}`}>
|
||||||
{typeof Element === 'function' ? <Element /> : Element}
|
{typeof Element === 'function' ? <Element /> : Element}
|
||||||
|
|||||||
103
src/renderer/src/components/VariableList.tsx
Normal file
103
src/renderer/src/components/VariableList.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { DeleteOutlined, ImportOutlined } from '@ant-design/icons'
|
||||||
|
import { VStack } from '@renderer/components/Layout'
|
||||||
|
import { Variable } from '@renderer/types'
|
||||||
|
import { Button, Input, Tooltip } from 'antd'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
interface VariableListProps {
|
||||||
|
variables: Variable[]
|
||||||
|
setVariables: (variables: Variable[]) => void
|
||||||
|
onUpdate?: (variables: Variable[]) => void
|
||||||
|
onInsertVariable?: (name: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const VariableList: React.FC<VariableListProps> = ({ variables, setVariables, onUpdate, onInsertVariable }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const deleteVariable = (id: string) => {
|
||||||
|
const updatedVariables = variables.filter((v) => v.id !== id)
|
||||||
|
setVariables(updatedVariables)
|
||||||
|
|
||||||
|
if (onUpdate) {
|
||||||
|
onUpdate(updatedVariables)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateVariable = (id: string, field: 'name' | 'value', value: string) => {
|
||||||
|
// Only update the local state when typing, don't call the parent's onUpdate
|
||||||
|
const updatedVariables = variables.map((v) => (v.id === id ? { ...v, [field]: value } : v))
|
||||||
|
setVariables(updatedVariables)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This function will be called when input loses focus
|
||||||
|
const handleInputBlur = () => {
|
||||||
|
if (onUpdate) {
|
||||||
|
onUpdate(variables)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VariablesContainer>
|
||||||
|
{variables.length === 0 ? (
|
||||||
|
<EmptyText>{t('common.no_variables_added')}</EmptyText>
|
||||||
|
) : (
|
||||||
|
<VStack gap={8} width="100%">
|
||||||
|
{variables.map((variable) => (
|
||||||
|
<VariableItem key={variable.id}>
|
||||||
|
<Input
|
||||||
|
placeholder={t('common.variable_name')}
|
||||||
|
value={variable.name}
|
||||||
|
onChange={(e) => updateVariable(variable.id, 'name', e.target.value)}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
|
style={{ width: '30%' }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder={t('common.value')}
|
||||||
|
value={variable.value}
|
||||||
|
onChange={(e) => updateVariable(variable.id, 'value', e.target.value)}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
{onInsertVariable && (
|
||||||
|
<Tooltip title={t('common.insert_variable_into_prompt')}>
|
||||||
|
<Button type="text" onClick={() => onInsertVariable(variable.name)}>
|
||||||
|
<ImportOutlined />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => deleteVariable(variable.id)} />
|
||||||
|
</VariableItem>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</VariablesContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const VariablesContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 200px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const VariableItem = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
`
|
||||||
|
|
||||||
|
const EmptyText = styled.div`
|
||||||
|
color: var(--color-text-2);
|
||||||
|
opacity: 0.6;
|
||||||
|
font-style: italic;
|
||||||
|
`
|
||||||
|
|
||||||
|
export default VariableList
|
||||||
@@ -9,35 +9,36 @@ import { isMac } from '@renderer/config/constant'
|
|||||||
import { AppLogo, UserAvatar } from '@renderer/config/env'
|
import { AppLogo, UserAvatar } from '@renderer/config/env'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import useAvatar from '@renderer/hooks/useAvatar'
|
import useAvatar from '@renderer/hooks/useAvatar'
|
||||||
|
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||||
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
|
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
|
||||||
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
|
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import { isEmoji } from '@renderer/utils'
|
import { isEmoji } from '@renderer/utils'
|
||||||
import type { MenuProps } from 'antd'
|
import type { MenuProps } from 'antd'
|
||||||
import { Tooltip } from 'antd'
|
import { Avatar, Dropdown, Tooltip } from 'antd'
|
||||||
import { Avatar } from 'antd'
|
import { FC, useEffect } from 'react'
|
||||||
import { Dropdown } from 'antd'
|
|
||||||
import { FC } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import DragableList from '../DragableList'
|
import DragableList from '../DragableList'
|
||||||
import MinAppIcon from '../Icons/MinAppIcon'
|
import MinAppIcon from '../Icons/MinAppIcon'
|
||||||
import MinApp from '../MinApp'
|
|
||||||
import UserPopup from '../Popups/UserPopup'
|
import UserPopup from '../Popups/UserPopup'
|
||||||
|
|
||||||
const Sidebar: FC = () => {
|
const Sidebar: FC = () => {
|
||||||
const { pathname } = useLocation()
|
const { hideMinappPopup, openMinapp } = useMinappPopup()
|
||||||
const avatar = useAvatar()
|
const { minappShow, currentMinappId } = useRuntime()
|
||||||
const { minappShow } = useRuntime()
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const { sidebarIcons } = useSettings()
|
const { sidebarIcons } = useSettings()
|
||||||
const { theme, settingTheme, toggleTheme } = useTheme()
|
|
||||||
const { pinned } = useMinapps()
|
const { pinned } = useMinapps()
|
||||||
|
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const { theme, settingTheme, toggleTheme } = useTheme()
|
||||||
|
const avatar = useAvatar()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const onEditUser = () => UserPopup.show()
|
const onEditUser = () => UserPopup.show()
|
||||||
|
|
||||||
const backgroundColor = useNavBackgroundColor()
|
const backgroundColor = useNavBackgroundColor()
|
||||||
@@ -49,9 +50,10 @@ const Sidebar: FC = () => {
|
|||||||
navigate(path)
|
navigate(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const docsId = 'cherrystudio-docs'
|
||||||
const onOpenDocs = () => {
|
const onOpenDocs = () => {
|
||||||
MinApp.start({
|
openMinapp({
|
||||||
id: 'docs',
|
id: docsId,
|
||||||
name: t('docs.title'),
|
name: t('docs.title'),
|
||||||
url: 'https://docs.cherry-ai.com/',
|
url: 'https://docs.cherry-ai.com/',
|
||||||
logo: AppLogo
|
logo: AppLogo
|
||||||
@@ -66,9 +68,10 @@ const Sidebar: FC = () => {
|
|||||||
<AvatarImg src={avatar || UserAvatar} draggable={false} className="nodrag" onClick={onEditUser} />
|
<AvatarImg src={avatar || UserAvatar} draggable={false} className="nodrag" onClick={onEditUser} />
|
||||||
)}
|
)}
|
||||||
<MainMenusContainer>
|
<MainMenusContainer>
|
||||||
<Menus onClick={MinApp.onClose}>
|
<Menus onClick={hideMinappPopup}>
|
||||||
<MainMenus />
|
<MainMenus />
|
||||||
</Menus>
|
</Menus>
|
||||||
|
<SidebarOpenedMinappTabs />
|
||||||
{showPinnedApps && (
|
{showPinnedApps && (
|
||||||
<AppsContainer>
|
<AppsContainer>
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -80,10 +83,7 @@ const Sidebar: FC = () => {
|
|||||||
</MainMenusContainer>
|
</MainMenusContainer>
|
||||||
<Menus>
|
<Menus>
|
||||||
<Tooltip title={t('docs.title')} mouseEnterDelay={0.8} placement="right">
|
<Tooltip title={t('docs.title')} mouseEnterDelay={0.8} placement="right">
|
||||||
<Icon
|
<Icon theme={theme} onClick={onOpenDocs} className={minappShow && currentMinappId === docsId ? 'active' : ''}>
|
||||||
theme={theme}
|
|
||||||
onClick={onOpenDocs}
|
|
||||||
className={minappShow && MinApp.app?.url === 'https://docs.cherry-ai.com/' ? 'active' : ''}>
|
|
||||||
<QuestionCircleOutlined />
|
<QuestionCircleOutlined />
|
||||||
</Icon>
|
</Icon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -102,7 +102,7 @@ const Sidebar: FC = () => {
|
|||||||
<Tooltip title={t('settings.title')} mouseEnterDelay={0.8} placement="right">
|
<Tooltip title={t('settings.title')} mouseEnterDelay={0.8} placement="right">
|
||||||
<StyledLink
|
<StyledLink
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
minappShow && (await MinApp.close())
|
hideMinappPopup()
|
||||||
await modelGenerating()
|
await modelGenerating()
|
||||||
await to('/settings/provider')
|
await to('/settings/provider')
|
||||||
}}>
|
}}>
|
||||||
@@ -117,6 +117,7 @@ const Sidebar: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MainMenus: FC = () => {
|
const MainMenus: FC = () => {
|
||||||
|
const { hideMinappPopup } = useMinappPopup()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { pathname } = useLocation()
|
const { pathname } = useLocation()
|
||||||
const { sidebarIcons } = useSettings()
|
const { sidebarIcons } = useSettings()
|
||||||
@@ -155,7 +156,7 @@ const MainMenus: FC = () => {
|
|||||||
<Tooltip key={icon} title={t(`${icon}.title`)} mouseEnterDelay={0.8} placement="right">
|
<Tooltip key={icon} title={t(`${icon}.title`)} mouseEnterDelay={0.8} placement="right">
|
||||||
<StyledLink
|
<StyledLink
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
minappShow && (await MinApp.close())
|
hideMinappPopup()
|
||||||
await modelGenerating()
|
await modelGenerating()
|
||||||
navigate(path)
|
navigate(path)
|
||||||
}}>
|
}}>
|
||||||
@@ -168,11 +169,103 @@ const MainMenus: FC = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tabs of opened minapps in sidebar */
|
||||||
|
const SidebarOpenedMinappTabs: FC = () => {
|
||||||
|
const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime()
|
||||||
|
const { openMinappKeepAlive, hideMinappPopup, closeMinapp, closeAllMinapps } = useMinappPopup()
|
||||||
|
const { showOpenedMinappsInSidebar } = useSettings() // 获取控制显示的设置
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const handleOnClick = (app) => {
|
||||||
|
if (minappShow && currentMinappId === app.id) {
|
||||||
|
hideMinappPopup()
|
||||||
|
} else {
|
||||||
|
openMinappKeepAlive(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// animation for minapp switch indicator
|
||||||
|
useEffect(() => {
|
||||||
|
//hacky way to get the height of the icon
|
||||||
|
const iconDefaultHeight = 40
|
||||||
|
const iconDefaultOffset = 17
|
||||||
|
const container = document.querySelector('.TabsContainer') as HTMLElement
|
||||||
|
const activeIcon = document.querySelector('.TabsContainer .opened-active') as HTMLElement
|
||||||
|
|
||||||
|
let indicatorTop = 0,
|
||||||
|
indicatorRight = 0
|
||||||
|
if (minappShow && activeIcon && container) {
|
||||||
|
indicatorTop = activeIcon.offsetTop + activeIcon.offsetHeight / 2 - 4 // 4 is half of the indicator's height (8px)
|
||||||
|
indicatorRight = 0
|
||||||
|
} else {
|
||||||
|
indicatorTop =
|
||||||
|
((openedKeepAliveMinapps.length > 0 ? openedKeepAliveMinapps.length : 1) / 2) * iconDefaultHeight +
|
||||||
|
iconDefaultOffset -
|
||||||
|
4
|
||||||
|
indicatorRight = -50
|
||||||
|
}
|
||||||
|
container.style.setProperty('--indicator-top', `${indicatorTop}px`)
|
||||||
|
container.style.setProperty('--indicator-right', `${indicatorRight}px`)
|
||||||
|
}, [currentMinappId, openedKeepAliveMinapps, minappShow])
|
||||||
|
|
||||||
|
// 检查是否需要显示已打开小程序组件
|
||||||
|
const isShowOpened = showOpenedMinappsInSidebar && openedKeepAliveMinapps.length > 0
|
||||||
|
|
||||||
|
// 如果不需要显示,返回空容器保持动画效果但不显示内容
|
||||||
|
if (!isShowOpened) return <TabsContainer className="TabsContainer" />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsContainer className="TabsContainer">
|
||||||
|
<Divider />
|
||||||
|
<TabsWrapper>
|
||||||
|
<Menus>
|
||||||
|
{openedKeepAliveMinapps.map((app) => {
|
||||||
|
const menuItems: MenuProps['items'] = [
|
||||||
|
{
|
||||||
|
key: 'closeApp',
|
||||||
|
label: t('minapp.sidebar.close.title'),
|
||||||
|
onClick: () => {
|
||||||
|
closeMinapp(app.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'closeAllApp',
|
||||||
|
label: t('minapp.sidebar.closeall.title'),
|
||||||
|
onClick: () => {
|
||||||
|
closeAllMinapps()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
const isActive = minappShow && currentMinappId === app.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={app.id} title={app.name} mouseEnterDelay={0.8} placement="right">
|
||||||
|
<StyledLink>
|
||||||
|
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']} overlayStyle={{ zIndex: 10000 }}>
|
||||||
|
<Icon
|
||||||
|
theme={theme}
|
||||||
|
onClick={() => handleOnClick(app)}
|
||||||
|
className={`${isActive ? 'opened-active' : ''}`}>
|
||||||
|
<MinAppIcon size={20} app={app} style={{ borderRadius: 6 }} />
|
||||||
|
</Icon>
|
||||||
|
</Dropdown>
|
||||||
|
</StyledLink>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Menus>
|
||||||
|
</TabsWrapper>
|
||||||
|
</TabsContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const PinnedApps: FC = () => {
|
const PinnedApps: FC = () => {
|
||||||
const { pinned, updatePinnedMinapps } = useMinapps()
|
const { pinned, updatePinnedMinapps } = useMinapps()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { minappShow } = useRuntime()
|
const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
const { openMinappKeepAlive } = useMinappPopup()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragableList list={pinned} onUpdate={updatePinnedMinapps} listStyle={{ marginBottom: 5 }}>
|
<DragableList list={pinned} onUpdate={updatePinnedMinapps} listStyle={{ marginBottom: 5 }}>
|
||||||
@@ -187,12 +280,15 @@ const PinnedApps: FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
const isActive = minappShow && MinApp.app?.id === app.id
|
const isActive = minappShow && currentMinappId === app.id
|
||||||
return (
|
return (
|
||||||
<Tooltip key={app.id} title={app.name} mouseEnterDelay={0.8} placement="right">
|
<Tooltip key={app.id} title={app.name} mouseEnterDelay={0.8} placement="right">
|
||||||
<StyledLink>
|
<StyledLink>
|
||||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
|
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']} overlayStyle={{ zIndex: 10000 }}>
|
||||||
<Icon theme={theme} onClick={() => MinApp.start(app)} className={isActive ? 'active' : ''}>
|
<Icon
|
||||||
|
theme={theme}
|
||||||
|
onClick={() => openMinappKeepAlive(app)}
|
||||||
|
className={`${isActive ? 'active' : ''} ${openedKeepAliveMinapps.some((item) => item.id === app.id) ? 'opened-animation' : ''}`}>
|
||||||
<MinAppIcon size={20} app={app} style={{ borderRadius: 6 }} />
|
<MinAppIcon size={20} app={app} style={{ borderRadius: 6 }} />
|
||||||
</Icon>
|
</Icon>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
@@ -265,6 +361,7 @@ const Icon = styled.div<{ theme: string }>`
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
box-sizing: border-box;
|
||||||
-webkit-app-region: none;
|
-webkit-app-region: none;
|
||||||
border: 0.5px solid transparent;
|
border: 0.5px solid transparent;
|
||||||
.iconfont,
|
.iconfont,
|
||||||
@@ -293,6 +390,39 @@ const Icon = styled.div<{ theme: string }>`
|
|||||||
color: var(--color-icon-white);
|
color: var(--color-icon-white);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes borderBreath {
|
||||||
|
0% {
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
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;
|
||||||
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
const StyledLink = styled.div`
|
const StyledLink = styled.div`
|
||||||
@@ -323,4 +453,37 @@ const Divider = styled.div`
|
|||||||
border-bottom: 0.5px solid var(--color-border);
|
border-bottom: 0.5px solid var(--color-border);
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const TabsContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
-webkit-app-region: none;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: var(--indicator-right, 0);
|
||||||
|
top: var(--indicator-top, 0);
|
||||||
|
width: 4px;
|
||||||
|
height: 8px;
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
transition:
|
||||||
|
top 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
right 0.3s ease-in-out;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const TabsWrapper = styled.div`
|
||||||
|
background-color: rgba(128, 128, 128, 0.1);
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
`
|
||||||
|
|
||||||
export default Sidebar
|
export default Sidebar
|
||||||
|
|||||||
@@ -49,9 +49,7 @@ import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png
|
|||||||
import GroqProviderLogo from '@renderer/assets/images/providers/groq.png?url'
|
import GroqProviderLogo from '@renderer/assets/images/providers/groq.png?url'
|
||||||
import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png?url'
|
import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png?url'
|
||||||
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png?url'
|
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png?url'
|
||||||
import MinApp from '@renderer/components/MinApp'
|
|
||||||
import { MinAppType } from '@renderer/types'
|
import { MinAppType } from '@renderer/types'
|
||||||
|
|
||||||
export const DEFAULT_MIN_APPS: MinAppType[] = [
|
export const DEFAULT_MIN_APPS: MinAppType[] = [
|
||||||
{
|
{
|
||||||
id: 'openai',
|
id: 'openai',
|
||||||
@@ -395,8 +393,3 @@ export const DEFAULT_MIN_APPS: MinAppType[] = [
|
|||||||
bodered: true
|
bodered: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
export function startMinAppById(id: string) {
|
|
||||||
const app = DEFAULT_MIN_APPS.find((app) => app?.id === id)
|
|
||||||
app && MinApp.start(app)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ const visionAllowedModels = [
|
|||||||
'minicpm',
|
'minicpm',
|
||||||
'gemini-1\\.5',
|
'gemini-1\\.5',
|
||||||
'gemini-2\\.0',
|
'gemini-2\\.0',
|
||||||
|
'gemini-2\\.5',
|
||||||
'gemini-exp',
|
'gemini-exp',
|
||||||
'claude-3',
|
'claude-3',
|
||||||
'vision',
|
'vision',
|
||||||
@@ -156,6 +157,7 @@ const visionAllowedModels = [
|
|||||||
'pixtral',
|
'pixtral',
|
||||||
'gpt-4(?:-[\\w-]+)',
|
'gpt-4(?:-[\\w-]+)',
|
||||||
'gpt-4o(?:-[\\w-]+)?',
|
'gpt-4o(?:-[\\w-]+)?',
|
||||||
|
'gpt-4.5(?:-[\\w-]+)',
|
||||||
'chatgpt-4o(?:-[\\w-]+)?',
|
'chatgpt-4o(?:-[\\w-]+)?',
|
||||||
'o1(?:-[\\w-]+)?',
|
'o1(?:-[\\w-]+)?',
|
||||||
'deepseek-vl(?:[\\w-]+)?',
|
'deepseek-vl(?:[\\w-]+)?',
|
||||||
@@ -163,7 +165,15 @@ const visionAllowedModels = [
|
|||||||
'gemma-3(?:-[\\w-]+)'
|
'gemma-3(?:-[\\w-]+)'
|
||||||
]
|
]
|
||||||
|
|
||||||
const visionExcludedModels = ['gpt-4-\\d+-preview', 'gpt-4-turbo-preview', 'gpt-4-32k', 'gpt-4-\\d+']
|
const visionExcludedModels = [
|
||||||
|
'gpt-4-\\d+-preview',
|
||||||
|
'gpt-4-turbo-preview',
|
||||||
|
'gpt-4-32k',
|
||||||
|
'gpt-4-\\d+',
|
||||||
|
'o1-mini',
|
||||||
|
'o1-preview',
|
||||||
|
'AIDC-AI/Marco-o1'
|
||||||
|
]
|
||||||
export const VISION_REGEX = new RegExp(
|
export const VISION_REGEX = new RegExp(
|
||||||
`\\b(?!(?:${visionExcludedModels.join('|')})\\b)(${visionAllowedModels.join('|')})\\b`,
|
`\\b(?!(?:${visionExcludedModels.join('|')})\\b)(${visionAllowedModels.join('|')})\\b`,
|
||||||
'i'
|
'i'
|
||||||
@@ -191,15 +201,23 @@ export const FUNCTION_CALLING_MODELS = [
|
|||||||
'gpt-4o-mini',
|
'gpt-4o-mini',
|
||||||
'gpt-4',
|
'gpt-4',
|
||||||
'gpt-4.5',
|
'gpt-4.5',
|
||||||
|
'o1(?:-[\\w-]+)?',
|
||||||
'claude',
|
'claude',
|
||||||
'qwen',
|
'qwen',
|
||||||
'hunyuan',
|
'hunyuan',
|
||||||
|
'deepseek',
|
||||||
'glm-4(?:-[\\w-]+)?',
|
'glm-4(?:-[\\w-]+)?',
|
||||||
'learnlm(?:-[\\w-]+)?',
|
'learnlm(?:-[\\w-]+)?',
|
||||||
'gemini(?:-[\\w-]+)?' // 提前排除了gemini的嵌入模型
|
'gemini(?:-[\\w-]+)?' // 提前排除了gemini的嵌入模型
|
||||||
]
|
]
|
||||||
|
|
||||||
const FUNCTION_CALLING_EXCLUDED_MODELS = ['aqa(?:-[\\w-]+)?', 'imagen(?:-[\\w-]+)?']
|
const FUNCTION_CALLING_EXCLUDED_MODELS = [
|
||||||
|
'aqa(?:-[\\w-]+)?',
|
||||||
|
'imagen(?:-[\\w-]+)?',
|
||||||
|
'o1-mini',
|
||||||
|
'o1-preview',
|
||||||
|
'AIDC-AI/Marco-o1'
|
||||||
|
]
|
||||||
|
|
||||||
export const FUNCTION_CALLING_REGEX = new RegExp(
|
export const FUNCTION_CALLING_REGEX = new RegExp(
|
||||||
`\\b(?!(?:${FUNCTION_CALLING_EXCLUDED_MODELS.join('|')})\\b)(?:${FUNCTION_CALLING_MODELS.join('|')})\\b`,
|
`\\b(?!(?:${FUNCTION_CALLING_EXCLUDED_MODELS.join('|')})\\b)(?:${FUNCTION_CALLING_MODELS.join('|')})\\b`,
|
||||||
@@ -1213,7 +1231,140 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
|||||||
group: 'Step 1'
|
group: 'Step 1'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
doubao: [],
|
doubao: [
|
||||||
|
{
|
||||||
|
id: 'doubao-1-5-vision-pro-32k-250115',
|
||||||
|
provider: 'doubao',
|
||||||
|
name: 'doubao-1.5-vision-pro',
|
||||||
|
group: 'Doubao-1.5-vision-pro'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubao-1-5-pro-32k-250115',
|
||||||
|
provider: 'doubao',
|
||||||
|
name: 'doubao-1.5-pro-32k',
|
||||||
|
group: 'Doubao-1.5-pro'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubao-1-5-pro-32k-character-250228',
|
||||||
|
provider: 'doubao',
|
||||||
|
name: 'doubao-1.5-pro-32k-character',
|
||||||
|
group: 'Doubao-1.5-pro'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubao-1-5-pro-256k-250115',
|
||||||
|
provider: 'doubao',
|
||||||
|
name: 'Doubao-1.5-pro-256k',
|
||||||
|
group: 'Doubao-1.5-pro'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'deepseek-r1-250120',
|
||||||
|
provider: 'doubao',
|
||||||
|
name: 'DeepSeek-R1',
|
||||||
|
group: 'DeepSeek'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'deepseek-r1-distill-qwen-32b-250120',
|
||||||
|
provider: 'doubao',
|
||||||
|
name: 'DeepSeek-R1-Distill-Qwen-32B',
|
||||||
|
group: 'DeepSeek'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'deepseek-r1-distill-qwen-7b-250120',
|
||||||
|
provider: 'doubao',
|
||||||
|
name: 'DeepSeek-R1-Distill-Qwen-7B',
|
||||||
|
group: 'DeepSeek'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'deepseek-v3-250324',
|
||||||
|
provider: 'doubao',
|
||||||
|
name: 'DeepSeek-V3',
|
||||||
|
group: 'DeepSeek'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'deepseek-v3-250324',
|
||||||
|
provider: 'doubao',
|
||||||
|
name: 'DeepSeek-V3',
|
||||||
|
group: 'DeepSeek'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubao-pro-32k-241215',
|
||||||
|
provider: 'doubao',
|
||||||
|
name: 'Doubao-pro-32k',
|
||||||
|
group: 'Doubao-pro'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubao-pro-32k-functioncall-241028',
|
||||||
|
provider: 'doubao',
|
||||||
|
name: 'Doubao-pro-32k-functioncall-241028',
|
||||||
|
group: 'Doubao-pro'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubao-pro-32k-character-241215',
|
||||||
|
provider: 'doubao',
|
||||||
|
name: 'Doubao-pro-32k-character-241215',
|
||||||
|
group: 'Doubao-pro'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubao-pro-256k-241115',
|
||||||
|
provider: 'doubao',
|
||||||
|
name: 'Doubao-pro-256k',
|
||||||
|
group: 'Doubao-pro'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubao-lite-4k-character-240828',
|
||||||
|
provider: 'doubao',
|
||||||
|
name: 'Doubao-lite-4k-character-240828',
|
||||||
|
group: 'Doubao-lite'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubao-lite-32k-240828',
|
||||||
|
provider: 'doubao',
|
||||||
|
name: 'Doubao-lite-32k',
|
||||||
|
group: 'Doubao-lite'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubao-lite-32k-character-241015',
|
||||||
|
provider: 'doubao',
|
||||||
|
name: 'Doubao-lite-32k-character-241015',
|
||||||
|
group: 'Doubao-lite'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubao-lite-128k-240828',
|
||||||
|
provider: 'doubao',
|
||||||
|
name: 'Doubao-lite-128k',
|
||||||
|
group: 'Doubao-lite'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubao-1-5-lite-32k-250115',
|
||||||
|
provider: 'doubao',
|
||||||
|
name: 'Doubao-1.5-lite-32k',
|
||||||
|
group: 'Doubao-lite'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubao-embedding-large-text-240915',
|
||||||
|
provider: 'doubao',
|
||||||
|
name: 'Doubao-embedding-large',
|
||||||
|
group: 'Doubao-embedding'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubao-embedding-text-240715',
|
||||||
|
provider: 'doubao',
|
||||||
|
name: 'Doubao-embedding',
|
||||||
|
group: 'Doubao-embedding'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubao-embedding-vision-241215',
|
||||||
|
provider: 'doubao',
|
||||||
|
name: 'Doubao-embedding-vision',
|
||||||
|
group: 'Doubao-embedding'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doubao-vision-lite-32k-241015',
|
||||||
|
provider: 'doubao',
|
||||||
|
name: 'Doubao-vision-lite-32k',
|
||||||
|
group: 'Doubao-vision-lite-32k'
|
||||||
|
}
|
||||||
|
],
|
||||||
minimax: [
|
minimax: [
|
||||||
{
|
{
|
||||||
id: 'abab6.5s-chat',
|
id: 'abab6.5s-chat',
|
||||||
@@ -1940,6 +2091,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 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 {
|
export function isTextToImageModel(model: Model): boolean {
|
||||||
return TEXT_TO_IMAGE_REGEX.test(model.id)
|
return TEXT_TO_IMAGE_REGEX.test(model.id)
|
||||||
}
|
}
|
||||||
@@ -2012,6 +2174,10 @@ export function isReasoningModel(model?: Model): boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (model.id.includes('gemini-2.5-pro-exp')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return REASONING_REGEX.test(model.id) || model.type?.includes('reasoning') || false
|
return REASONING_REGEX.test(model.id) || model.type?.includes('reasoning') || false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2040,32 +2206,25 @@ export function isWebSearchModel(model: Model): boolean {
|
|||||||
return false
|
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 (provider?.type === 'openai') {
|
||||||
if (model?.id?.includes('gemini-2.0-flash-exp')) {
|
if (GEMINI_SEARCH_MODELS.includes(model?.id)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider.id === 'gemini' || provider?.type === 'gemini') {
|
if (provider.id === 'gemini' || provider?.type === 'gemini') {
|
||||||
const models = [
|
return GEMINI_SEARCH_MODELS.includes(model?.id)
|
||||||
'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'
|
|
||||||
]
|
|
||||||
return models.includes(model?.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider.id === 'hunyuan') {
|
if (provider.id === 'hunyuan') {
|
||||||
return model?.id !== 'hunyuan-lite'
|
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') {
|
if (provider.id === 'zhipu') {
|
||||||
return model?.id?.startsWith('glm-4-')
|
return model?.id?.startsWith('glm-4-')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from '@renderer/store/assistants'
|
} from '@renderer/store/assistants'
|
||||||
import { setDefaultModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm'
|
import { setDefaultModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm'
|
||||||
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
|
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
import { TopicManager } from './useTopic'
|
import { TopicManager } from './useTopic'
|
||||||
|
|
||||||
@@ -69,7 +70,10 @@ export function useAssistant(id: string) {
|
|||||||
updateTopic: (topic: Topic) => dispatch(updateTopic({ assistantId: assistant.id, topic })),
|
updateTopic: (topic: Topic) => dispatch(updateTopic({ assistantId: assistant.id, topic })),
|
||||||
updateTopics: (topics: Topic[]) => dispatch(updateTopics({ assistantId: assistant.id, topics })),
|
updateTopics: (topics: Topic[]) => dispatch(updateTopics({ assistantId: assistant.id, topics })),
|
||||||
removeAllTopics: () => dispatch(removeAllTopics({ assistantId: assistant.id })),
|
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)),
|
updateAssistant: (assistant: Assistant) => dispatch(updateAssistant(assistant)),
|
||||||
updateAssistantSettings: (settings: Partial<AssistantSettings>) => {
|
updateAssistantSettings: (settings: Partial<AssistantSettings>) => {
|
||||||
dispatch(updateAssistantSettings({ assistantId: assistant.id, settings }))
|
dispatch(updateAssistantSettings({ assistantId: assistant.id, settings }))
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ ipcRenderer.on('mcp:servers-changed', (_event, servers) => {
|
|||||||
|
|
||||||
export const useMCPServers = () => {
|
export const useMCPServers = () => {
|
||||||
const mcpServers = useAppSelector((state) => state.mcp.servers)
|
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()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
117
src/renderer/src/hooks/useMinappPopup.ts
Normal file
117
src/renderer/src/hooks/useMinappPopup.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
|
import { useSettings } from '@renderer/hooks/useSettings' // 使用设置中的值
|
||||||
|
import { useAppDispatch } from '@renderer/store'
|
||||||
|
import {
|
||||||
|
setCurrentMinappId,
|
||||||
|
setMinappShow,
|
||||||
|
setOpenedKeepAliveMinapps,
|
||||||
|
setOpenedOneOffMinapp
|
||||||
|
} from '@renderer/store/runtime'
|
||||||
|
import { MinAppType } from '@renderer/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usage:
|
||||||
|
*
|
||||||
|
* To control the minapp popup, you can use the following hooks:
|
||||||
|
* import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||||
|
*
|
||||||
|
* in the component:
|
||||||
|
* const { openMinapp, openMinappKeepAlive, openMinappById,
|
||||||
|
* closeMinapp, hideMinappPopup, closeAllMinapps } = useMinappPopup()
|
||||||
|
*
|
||||||
|
* To use some key states of the minapp popup:
|
||||||
|
* import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
|
* const { openedKeepAliveMinapps, openedOneOffMinapp, minappShow } = useRuntime()
|
||||||
|
*/
|
||||||
|
export const useMinappPopup = () => {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const { openedKeepAliveMinapps, openedOneOffMinapp, minappShow } = useRuntime()
|
||||||
|
const { maxKeepAliveMinapps } = useSettings() // 使用设置中的值
|
||||||
|
|
||||||
|
/** Open a minapp (popup shows and minapp loaded) */
|
||||||
|
const openMinapp = (app: MinAppType, keepAlive: boolean = false) => {
|
||||||
|
if (keepAlive) {
|
||||||
|
// 如果小程序已经打开,只切换显示
|
||||||
|
if (openedKeepAliveMinapps.some((item) => item.id === app.id)) {
|
||||||
|
dispatch(setCurrentMinappId(app.id))
|
||||||
|
dispatch(setMinappShow(true))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果缓存数量未达上限,添加到缓存列表
|
||||||
|
if (openedKeepAliveMinapps.length < maxKeepAliveMinapps) {
|
||||||
|
dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps]))
|
||||||
|
} else {
|
||||||
|
// 缓存数量达到上限,移除最后一个,添加新的
|
||||||
|
dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps.slice(0, maxKeepAliveMinapps - 1)]))
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(setOpenedOneOffMinapp(null))
|
||||||
|
dispatch(setCurrentMinappId(app.id))
|
||||||
|
dispatch(setMinappShow(true))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
//if the minapp is not keep alive, open it as one-off minapp
|
||||||
|
dispatch(setOpenedOneOffMinapp(app))
|
||||||
|
dispatch(setCurrentMinappId(app.id))
|
||||||
|
dispatch(setMinappShow(true))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/** a wrapper of openMinapp(app, true) */
|
||||||
|
const openMinappKeepAlive = (app: MinAppType) => {
|
||||||
|
openMinapp(app, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a minapp by id (look up the minapp in DEFAULT_MIN_APPS) */
|
||||||
|
const openMinappById = (id: string, keepAlive: boolean = false) => {
|
||||||
|
import('@renderer/config/minapps').then(({ DEFAULT_MIN_APPS }) => {
|
||||||
|
const app = DEFAULT_MIN_APPS.find((app) => app?.id === id)
|
||||||
|
if (app) {
|
||||||
|
openMinapp(app, keepAlive)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close a minapp immediately (popup hides and minapp unloaded) */
|
||||||
|
const closeMinapp = (appid: string) => {
|
||||||
|
if (openedKeepAliveMinapps.some((item) => item.id === appid)) {
|
||||||
|
dispatch(setOpenedKeepAliveMinapps(openedKeepAliveMinapps.filter((item) => item.id !== appid)))
|
||||||
|
} else if (openedOneOffMinapp?.id === appid) {
|
||||||
|
dispatch(setOpenedOneOffMinapp(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(setCurrentMinappId(''))
|
||||||
|
dispatch(setMinappShow(false))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close all minapps (popup hides and all minapps unloaded) */
|
||||||
|
const closeAllMinapps = () => {
|
||||||
|
dispatch(setOpenedKeepAliveMinapps([]))
|
||||||
|
dispatch(setOpenedOneOffMinapp(null))
|
||||||
|
dispatch(setCurrentMinappId(''))
|
||||||
|
dispatch(setMinappShow(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hide the minapp popup (only one-off minapp unloaded) */
|
||||||
|
const hideMinappPopup = () => {
|
||||||
|
if (!minappShow) return
|
||||||
|
|
||||||
|
if (openedOneOffMinapp) {
|
||||||
|
dispatch(setOpenedOneOffMinapp(null))
|
||||||
|
dispatch(setCurrentMinappId(''))
|
||||||
|
}
|
||||||
|
dispatch(setMinappShow(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openMinapp,
|
||||||
|
openMinappKeepAlive,
|
||||||
|
openMinappById,
|
||||||
|
closeMinapp,
|
||||||
|
hideMinappPopup,
|
||||||
|
closeAllMinapps
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useAssistant } from './useAssistant'
|
import { useAssistant } from './useAssistant'
|
||||||
import { getStoreSetting } from './useSettings'
|
import { getStoreSetting } from './useSettings'
|
||||||
|
|
||||||
|
const renamingTopics = new Set<string>()
|
||||||
|
|
||||||
let _activeTopic: Topic
|
let _activeTopic: Topic
|
||||||
let _setActiveTopic: (topic: Topic) => void
|
let _setActiveTopic: (topic: Topic) => void
|
||||||
|
|
||||||
@@ -54,35 +56,45 @@ export async function getTopicById(topicId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const autoRenameTopic = async (assistant: Assistant, topicId: string) => {
|
export const autoRenameTopic = async (assistant: Assistant, topicId: string) => {
|
||||||
const topic = await getTopicById(topicId)
|
if (renamingTopics.has(topicId)) {
|
||||||
const enableTopicNaming = getStoreSetting('enableTopicNaming')
|
|
||||||
|
|
||||||
if (isEmpty(topic.messages)) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (topic.isNameManuallyEdited) {
|
try {
|
||||||
return
|
renamingTopics.add(topicId)
|
||||||
}
|
|
||||||
|
|
||||||
if (!enableTopicNaming) {
|
const topic = await getTopicById(topicId)
|
||||||
const topicName = topic.messages[0]?.content.substring(0, 50)
|
const enableTopicNaming = getStoreSetting('enableTopicNaming')
|
||||||
if (topicName) {
|
|
||||||
const data = { ...topic, name: topicName } as Topic
|
if (isEmpty(topic.messages)) {
|
||||||
_setActiveTopic(data)
|
return
|
||||||
store.dispatch(updateTopic({ assistantId: assistant.id, topic: data }))
|
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (topic && topic.name === i18n.t('chat.default.topic.name') && topic.messages.length >= 2) {
|
if (topic.isNameManuallyEdited) {
|
||||||
const { fetchMessagesSummary } = await import('@renderer/services/ApiService')
|
return
|
||||||
const summaryText = await fetchMessagesSummary({ messages: topic.messages, assistant })
|
|
||||||
if (summaryText) {
|
|
||||||
const data = { ...topic, name: summaryText }
|
|
||||||
_setActiveTopic(data)
|
|
||||||
store.dispatch(updateTopic({ assistantId: assistant.id, topic: data }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!enableTopicNaming) {
|
||||||
|
const topicName = topic.messages[0]?.content.substring(0, 50)
|
||||||
|
if (topicName) {
|
||||||
|
const data = { ...topic, name: topicName } as Topic
|
||||||
|
_setActiveTopic(data)
|
||||||
|
store.dispatch(updateTopic({ assistantId: assistant.id, topic: data }))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topic && topic.name === i18n.t('chat.default.topic.name') && topic.messages.length >= 2) {
|
||||||
|
const { fetchMessagesSummary } = await import('@renderer/services/ApiService')
|
||||||
|
const summaryText = await fetchMessagesSummary({ messages: topic.messages, assistant })
|
||||||
|
if (summaryText) {
|
||||||
|
const data = { ...topic, name: summaryText }
|
||||||
|
_setActiveTopic(data)
|
||||||
|
store.dispatch(updateTopic({ assistantId: assistant.id, topic: data }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
renamingTopics.delete(topicId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,11 @@
|
|||||||
"search": "Search assistants...",
|
"search": "Search assistants...",
|
||||||
"settings.default_model": "Default Model",
|
"settings.default_model": "Default Model",
|
||||||
"settings.knowledge_base": "Knowledge Base Settings",
|
"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.model": "Model Settings",
|
||||||
"settings.preset_messages": "Preset Messages",
|
"settings.preset_messages": "Preset Messages",
|
||||||
"settings.prompt": "Prompt Settings",
|
"settings.prompt": "Prompt Settings",
|
||||||
@@ -145,7 +150,10 @@
|
|||||||
"history": "Chat History",
|
"history": "Chat History",
|
||||||
"last": "Already at the last message",
|
"last": "Already at the last message",
|
||||||
"next": "Next Message",
|
"next": "Next Message",
|
||||||
"prev": "Previous Message"
|
"prev": "Previous Message",
|
||||||
|
"top": "Back to top",
|
||||||
|
"bottom": "Back to bottom",
|
||||||
|
"close": "Close"
|
||||||
},
|
},
|
||||||
"resend": "Resend",
|
"resend": "Resend",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
@@ -224,7 +232,10 @@
|
|||||||
"topics.title": "Topics",
|
"topics.title": "Topics",
|
||||||
"topics.unpinned": "Unpinned Topics",
|
"topics.unpinned": "Unpinned Topics",
|
||||||
"translate": "Translate",
|
"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": {
|
"code_block": {
|
||||||
"collapse": "Collapse",
|
"collapse": "Collapse",
|
||||||
@@ -275,7 +286,13 @@
|
|||||||
"select": "Select",
|
"select": "Select",
|
||||||
"topics": "Topics",
|
"topics": "Topics",
|
||||||
"warning": "Warning",
|
"warning": "Warning",
|
||||||
"you": "You"
|
"you": "You",
|
||||||
|
"variable_name": "Variable Name",
|
||||||
|
"value": "Value",
|
||||||
|
"no_variables_added": "No variables added",
|
||||||
|
"insert_variable_into_prompt": "Insert variable into prompt",
|
||||||
|
"variables": "Variables",
|
||||||
|
"variables_help": "Add variables that need to be replaced in the text, triggered by {{variable_name}} in the replacement document"
|
||||||
},
|
},
|
||||||
"docs": {
|
"docs": {
|
||||||
"title": "Docs"
|
"title": "Docs"
|
||||||
@@ -542,10 +559,20 @@
|
|||||||
"warn.siyuan.exporting": "Exporting to Siyuan Note, please do not request export repeatedly!"
|
"warn.siyuan.exporting": "Exporting to Siyuan Note, please do not request export repeatedly!"
|
||||||
},
|
},
|
||||||
"minapp": {
|
"minapp": {
|
||||||
|
"popup": {
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"close": "Close MinApp",
|
||||||
|
"minimize": "Minimize MinApp",
|
||||||
|
"devtools": "Developer Tools",
|
||||||
|
"openExternal": "Open in Browser",
|
||||||
|
"rightclick_copyurl": "Right-click to copy URL"
|
||||||
|
},
|
||||||
"sidebar.add.title": "Add to sidebar",
|
"sidebar.add.title": "Add to sidebar",
|
||||||
"sidebar.remove.title": "Remove from sidebar",
|
"sidebar.remove.title": "Remove from sidebar",
|
||||||
"title": "MinApp",
|
"sidebar.close.title": "Close",
|
||||||
"sidebar.hide.title": "Hide MinApp"
|
"sidebar.closeall.title": "Close All",
|
||||||
|
"sidebar.hide.title": "Hide MinApp",
|
||||||
|
"title": "MinApp"
|
||||||
},
|
},
|
||||||
"miniwindow": {
|
"miniwindow": {
|
||||||
"clipboard": {
|
"clipboard": {
|
||||||
@@ -559,15 +586,19 @@
|
|||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"copy_last_message": "Press C to copy",
|
"copy_last_message": "Press C to copy",
|
||||||
"esc": "Press ESC {{action}}",
|
"backspace_clear": "Backspace to clear",
|
||||||
"esc_back": "back",
|
"esc": "ESC to {{action}}",
|
||||||
"esc_close": "close the window"
|
"esc_back": "return",
|
||||||
|
"esc_close": "close"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"placeholder": {
|
"placeholder": {
|
||||||
"empty": "Ask {{model}} for help...",
|
"empty": "Ask {{model}} for help...",
|
||||||
"title": "What do you want to do with this text?"
|
"title": "What do you want to do with this text?"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"pin": "Keep Window on Top"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
@@ -904,16 +935,14 @@
|
|||||||
"new_folder.button.confirm": "Confirm",
|
"new_folder.button.confirm": "Confirm",
|
||||||
"new_folder.button.cancel": "Cancel",
|
"new_folder.button.cancel": "Cancel",
|
||||||
"new_folder.button": "New Folder"
|
"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.assistant.title": "Assistant Settings",
|
||||||
"display.custom.css": "Custom CSS",
|
"display.custom.css": "Custom CSS",
|
||||||
"display.custom.css.cherrycss": "Get from cherrycss.com",
|
"display.custom.css.cherrycss": "Get from cherrycss.com",
|
||||||
"display.custom.css.placeholder": "/* Put custom CSS here */",
|
"display.custom.css.placeholder": "/* Put custom CSS here */",
|
||||||
"display.minApp.disabled": "Hidden MinApp",
|
|
||||||
"display.minApp.empty": "Drag minApp from the left to hide them here",
|
|
||||||
"display.minApp.title": "MinApp Settings",
|
|
||||||
"display.minApp.visible": "Visible MinApp",
|
|
||||||
"display.sidebar.chat.hiddenMessage": "Assistants are basic functions, not supported for hiding",
|
"display.sidebar.chat.hiddenMessage": "Assistants are basic functions, not supported for hiding",
|
||||||
"display.sidebar.disabled": "Hide icons",
|
"display.sidebar.disabled": "Hide icons",
|
||||||
"display.sidebar.empty": "Drag the hidden feature from the left side here",
|
"display.sidebar.empty": "Drag the hidden feature from the left side here",
|
||||||
@@ -926,6 +955,20 @@
|
|||||||
"display.sidebar.visible": "Show icons",
|
"display.sidebar.visible": "Show icons",
|
||||||
"display.title": "Display Settings",
|
"display.title": "Display Settings",
|
||||||
"display.topic.title": "Topic Settings",
|
"display.topic.title": "Topic Settings",
|
||||||
|
"miniapps": {
|
||||||
|
"title": "Mini Apps Settings",
|
||||||
|
"disabled": "Hidden Mini Apps",
|
||||||
|
"empty": "Drag mini apps from the left to hide them",
|
||||||
|
"visible": "Visible Mini Apps",
|
||||||
|
"cache_settings": "Cache Settings",
|
||||||
|
"cache_title": "Mini App Cache Limit",
|
||||||
|
"cache_description": "Set the maximum number of active mini apps to keep in memory",
|
||||||
|
"reset_tooltip": "Reset to default",
|
||||||
|
"display_title": "Mini App Display Settings",
|
||||||
|
"sidebar_title": "Sidebar Active Mini Apps Display",
|
||||||
|
"sidebar_description": "Show active mini apps in the sidebar",
|
||||||
|
"cache_change_notice": "Changes will take effect when the number of open mini apps reaches the set value"
|
||||||
|
},
|
||||||
"font_size.title": "Message font size",
|
"font_size.title": "Message font size",
|
||||||
"general": "General Settings",
|
"general": "General Settings",
|
||||||
"general.avatar.reset": "Reset Avatar",
|
"general.avatar.reset": "Reset Avatar",
|
||||||
@@ -1013,7 +1056,17 @@
|
|||||||
"updateSuccess": "Server updated successfully",
|
"updateSuccess": "Server updated successfully",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"editMcpJson": "Edit MCP Configuration",
|
"editMcpJson": "Edit MCP Configuration",
|
||||||
"installHelp": "Get Installation Help"
|
"installHelp": "Get Installation Help",
|
||||||
|
"tools": {
|
||||||
|
"inputSchema": "Input Schema",
|
||||||
|
"availableTools": "Available Tools",
|
||||||
|
"noToolsAvailable": "No tools available"
|
||||||
|
},
|
||||||
|
"deleteServer": "Delete 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.divider": "Show divider between messages",
|
||||||
"messages.grid_columns": "Message grid display columns",
|
"messages.grid_columns": "Message grid display columns",
|
||||||
@@ -1163,7 +1216,7 @@
|
|||||||
"reset_defaults_confirm": "Are you sure you want to reset all shortcuts?",
|
"reset_defaults_confirm": "Are you sure you want to reset all shortcuts?",
|
||||||
"reset_to_default": "Reset to Default",
|
"reset_to_default": "Reset to Default",
|
||||||
"search_message": "Search Message",
|
"search_message": "Search Message",
|
||||||
"show_app": "Show App",
|
"show_app": "Show/Hide App",
|
||||||
"show_settings": "Open Settings",
|
"show_settings": "Open Settings",
|
||||||
"title": "Keyboard Shortcuts",
|
"title": "Keyboard Shortcuts",
|
||||||
"toggle_new_context": "Clear Context",
|
"toggle_new_context": "Clear Context",
|
||||||
|
|||||||
@@ -44,6 +44,11 @@
|
|||||||
"save.success": "保存に成功しました",
|
"save.success": "保存に成功しました",
|
||||||
"save.title": "エージェントに保存",
|
"save.title": "エージェントに保存",
|
||||||
"search": "アシスタントを検索...",
|
"search": "アシスタントを検索...",
|
||||||
|
"settings.mcp": "MCP サーバー",
|
||||||
|
"settings.mcp.enableFirst": "まず MCP 設定でこのサーバーを有効にしてください",
|
||||||
|
"settings.mcp.title": "MCP 設定",
|
||||||
|
"settings.mcp.noServersAvailable": "利用可能な MCP サーバーがありません。設定でサーバーを追加してください",
|
||||||
|
"settings.mcp.description": "デフォルトで有効な MCP サーバー",
|
||||||
"settings.default_model": "デフォルトモデル",
|
"settings.default_model": "デフォルトモデル",
|
||||||
"settings.knowledge_base": "ナレッジベース設定",
|
"settings.knowledge_base": "ナレッジベース設定",
|
||||||
"settings.model": "モデル設定",
|
"settings.model": "モデル設定",
|
||||||
@@ -145,7 +150,10 @@
|
|||||||
"history": "チャット履歴",
|
"history": "チャット履歴",
|
||||||
"last": "最後のメッセージです",
|
"last": "最後のメッセージです",
|
||||||
"next": "次のメッセージ",
|
"next": "次のメッセージ",
|
||||||
"prev": "前のメッセージ"
|
"prev": "前のメッセージ",
|
||||||
|
"top": "トップに戻る",
|
||||||
|
"bottom": "下部に戻る",
|
||||||
|
"close": "閉じる"
|
||||||
},
|
},
|
||||||
"resend": "再送信",
|
"resend": "再送信",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
@@ -224,7 +232,10 @@
|
|||||||
"topics.title": "トピック",
|
"topics.title": "トピック",
|
||||||
"topics.unpinned": "固定解除",
|
"topics.unpinned": "固定解除",
|
||||||
"translate": "翻訳",
|
"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": {
|
"code_block": {
|
||||||
"collapse": "折りたたむ",
|
"collapse": "折りたたむ",
|
||||||
@@ -275,7 +286,13 @@
|
|||||||
"select": "選択",
|
"select": "選択",
|
||||||
"topics": "トピック",
|
"topics": "トピック",
|
||||||
"warning": "警告",
|
"warning": "警告",
|
||||||
"you": "あなた"
|
"you": "あなた",
|
||||||
|
"variable_name": "変数名",
|
||||||
|
"value": "値",
|
||||||
|
"no_variables_added": "変数がありません",
|
||||||
|
"insert_variable_into_prompt": "プロンプトに変数を挿入",
|
||||||
|
"variables": "変数",
|
||||||
|
"variables_help": "テキスト内で置換が必要な変数を追加し、置換ドキュメント内で{{variable_name}}の形式でトリガーします"
|
||||||
},
|
},
|
||||||
"docs": {
|
"docs": {
|
||||||
"title": "ドキュメント"
|
"title": "ドキュメント"
|
||||||
@@ -542,8 +559,18 @@
|
|||||||
"error.yuque.no_config": "語雀のAPIアドレスまたはトークンが設定されていません"
|
"error.yuque.no_config": "語雀のAPIアドレスまたはトークンが設定されていません"
|
||||||
},
|
},
|
||||||
"minapp": {
|
"minapp": {
|
||||||
|
"popup": {
|
||||||
|
"refresh": "更新",
|
||||||
|
"close": "ミニアプリを閉じる",
|
||||||
|
"minimize": "ミニアプリを最小化",
|
||||||
|
"devtools": "開発者ツール",
|
||||||
|
"openExternal": "ブラウザで開く",
|
||||||
|
"rightclick_copyurl": "右クリックでURLをコピー"
|
||||||
|
},
|
||||||
"sidebar.add.title": "サイドバーに追加",
|
"sidebar.add.title": "サイドバーに追加",
|
||||||
"sidebar.remove.title": "サイドバーから削除",
|
"sidebar.remove.title": "サイドバーから削除",
|
||||||
|
"sidebar.close.title": "閉じる",
|
||||||
|
"sidebar.closeall.title": "すべて閉じる",
|
||||||
"sidebar.hide.title": "ミニアプリを隠す",
|
"sidebar.hide.title": "ミニアプリを隠す",
|
||||||
"title": "ミニアプリ"
|
"title": "ミニアプリ"
|
||||||
},
|
},
|
||||||
@@ -561,13 +588,17 @@
|
|||||||
"copy_last_message": "C キーを押してコピー",
|
"copy_last_message": "C キーを押してコピー",
|
||||||
"esc": "ESC キーを押して{{action}}",
|
"esc": "ESC キーを押して{{action}}",
|
||||||
"esc_back": "戻る",
|
"esc_back": "戻る",
|
||||||
"esc_close": "ウィンドウを閉じる"
|
"esc_close": "ウィンドウを閉じる",
|
||||||
|
"backspace_clear": "バックスペースを押してクリアします"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"placeholder": {
|
"placeholder": {
|
||||||
"empty": "{{model}} に質問してください...",
|
"empty": "{{model}} に質問してください...",
|
||||||
"title": "下のテキストに対して何をしますか?"
|
"title": "下のテキストに対して何をしますか?"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"pin": "上部ウィンドウ"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
@@ -904,16 +935,14 @@
|
|||||||
"new_folder.button.confirm": "確認",
|
"new_folder.button.confirm": "確認",
|
||||||
"new_folder.button.cancel": "キャンセル",
|
"new_folder.button.cancel": "キャンセル",
|
||||||
"new_folder.button": "新しいフォルダー"
|
"new_folder.button": "新しいフォルダー"
|
||||||
}
|
},
|
||||||
|
"message_title.use_topic_naming.title": "トピック命名モデルを使用してメッセージのタイトルを作成",
|
||||||
|
"message_title.use_topic_naming.help": "この設定は、すべてのMarkdownエクスポート方法に影響します。"
|
||||||
},
|
},
|
||||||
"display.assistant.title": "アシスタント設定",
|
"display.assistant.title": "アシスタント設定",
|
||||||
"display.custom.css": "カスタムCSS",
|
"display.custom.css": "カスタムCSS",
|
||||||
"display.custom.css.cherrycss": "cherrycss.comから取得",
|
"display.custom.css.cherrycss": "cherrycss.comから取得",
|
||||||
"display.custom.css.placeholder": "/* ここにカスタムCSSを入力 */",
|
"display.custom.css.placeholder": "/* ここにカスタムCSSを入力 */",
|
||||||
"display.minApp.disabled": "非表示ミニプログラム",
|
|
||||||
"display.minApp.empty": "非表示にしたいアプレットを左からここまでドラッグします",
|
|
||||||
"display.minApp.title": "ミニプログラム表示設定",
|
|
||||||
"display.minApp.visible": "表示中ミニプログラム",
|
|
||||||
"display.sidebar.chat.hiddenMessage": "アシスタントは基本的な機能であり、非表示はサポートされていません",
|
"display.sidebar.chat.hiddenMessage": "アシスタントは基本的な機能であり、非表示はサポートされていません",
|
||||||
"display.sidebar.disabled": "アイコンを非表示",
|
"display.sidebar.disabled": "アイコンを非表示",
|
||||||
"display.sidebar.empty": "非表示にする機能を左側からここにドラッグ",
|
"display.sidebar.empty": "非表示にする機能を左側からここにドラッグ",
|
||||||
@@ -926,6 +955,20 @@
|
|||||||
"display.sidebar.visible": "アイコンを表示",
|
"display.sidebar.visible": "アイコンを表示",
|
||||||
"display.title": "表示設定",
|
"display.title": "表示設定",
|
||||||
"display.topic.title": "トピック設定",
|
"display.topic.title": "トピック設定",
|
||||||
|
"miniapps": {
|
||||||
|
"title": "ミニアプリ設定",
|
||||||
|
"disabled": "非表示のミニアプリ",
|
||||||
|
"empty": "非表示にするミニアプリを左側からここにドラッグしてください",
|
||||||
|
"visible": "表示するミニアプリ",
|
||||||
|
"cache_settings": "キャッシュ設定",
|
||||||
|
"cache_title": "ミニアプリのキャッシュ数",
|
||||||
|
"cache_description": "メモリに保持するアクティブなミニアプリの最大数を設定します",
|
||||||
|
"reset_tooltip": "デフォルト値にリセット",
|
||||||
|
"display_title": "ミニアプリ表示設定",
|
||||||
|
"sidebar_title": "サイドバーのアクティブなミニアプリ表示",
|
||||||
|
"sidebar_description": "サイドバーにアクティブなミニアプリを表示するかどうかを設定します",
|
||||||
|
"cache_change_notice": "設定値に達するまでミニアプリの開閉が行われた後に変更が適用されます"
|
||||||
|
},
|
||||||
"font_size.title": "メッセージのフォントサイズ",
|
"font_size.title": "メッセージのフォントサイズ",
|
||||||
"general": "一般設定",
|
"general": "一般設定",
|
||||||
"general.avatar.reset": "アバターをリセット",
|
"general.avatar.reset": "アバターをリセット",
|
||||||
@@ -1012,7 +1055,17 @@
|
|||||||
"32000": "MCP サーバーが起動しませんでした。パラメーターを確認してください"
|
"32000": "MCP サーバーが起動しませんでした。パラメーターを確認してください"
|
||||||
},
|
},
|
||||||
"editMcpJson": "MCP 設定を編集",
|
"editMcpJson": "MCP 設定を編集",
|
||||||
"installHelp": "インストールヘルプを取得"
|
"installHelp": "インストールヘルプを取得",
|
||||||
|
"tools": {
|
||||||
|
"inputSchema": "入力スキーマ",
|
||||||
|
"availableTools": "利用可能なツール",
|
||||||
|
"noToolsAvailable": "利用可能なツールはありません"
|
||||||
|
},
|
||||||
|
"deleteServer": "サーバーを削除",
|
||||||
|
"deleteServerConfirm": "このサーバーを削除してもよろしいですか?",
|
||||||
|
"registry": "パッケージ管理レジストリ",
|
||||||
|
"registryTooltip": "デフォルトのレジストリでネットワークの問題が発生した場合、パッケージインストールに使用するレジストリを選択してください。",
|
||||||
|
"registryDefault": "デフォルト"
|
||||||
},
|
},
|
||||||
"messages.divider": "メッセージ間に区切り線を表示",
|
"messages.divider": "メッセージ間に区切り線を表示",
|
||||||
"messages.grid_columns": "メッセージグリッドの表示列数",
|
"messages.grid_columns": "メッセージグリッドの表示列数",
|
||||||
@@ -1162,7 +1215,7 @@
|
|||||||
"reset_defaults_confirm": "すべてのショートカットをリセットしてもよろしいですか?",
|
"reset_defaults_confirm": "すべてのショートカットをリセットしてもよろしいですか?",
|
||||||
"reset_to_default": "デフォルトにリセット",
|
"reset_to_default": "デフォルトにリセット",
|
||||||
"search_message": "メッセージを検索",
|
"search_message": "メッセージを検索",
|
||||||
"show_app": "アプリを表示",
|
"show_app": "アプリを表示/非表示",
|
||||||
"show_settings": "設定を開く",
|
"show_settings": "設定を開く",
|
||||||
"title": "ショートカット",
|
"title": "ショートカット",
|
||||||
"toggle_new_context": "コンテキストをクリア",
|
"toggle_new_context": "コンテキストをクリア",
|
||||||
|
|||||||
@@ -44,6 +44,11 @@
|
|||||||
"save.success": "Успешно сохранено",
|
"save.success": "Успешно сохранено",
|
||||||
"save.title": "Сохранить в агента",
|
"save.title": "Сохранить в агента",
|
||||||
"search": "Поиск ассистентов...",
|
"search": "Поиск ассистентов...",
|
||||||
|
"settings.mcp": "Серверы MCP",
|
||||||
|
"settings.mcp.enableFirst": "Сначала включите этот сервер в настройках MCP",
|
||||||
|
"settings.mcp.title": "Настройки MCP",
|
||||||
|
"settings.mcp.noServersAvailable": "Нет доступных серверов MCP. Добавьте серверы в настройках",
|
||||||
|
"settings.mcp.description": "Серверы MCP, включенные по умолчанию",
|
||||||
"settings.default_model": "Модель по умолчанию",
|
"settings.default_model": "Модель по умолчанию",
|
||||||
"settings.knowledge_base": "Настройки базы знаний",
|
"settings.knowledge_base": "Настройки базы знаний",
|
||||||
"settings.model": "Настройки модели",
|
"settings.model": "Настройки модели",
|
||||||
@@ -145,7 +150,10 @@
|
|||||||
"history": "История чата",
|
"history": "История чата",
|
||||||
"last": "Уже последнее сообщение",
|
"last": "Уже последнее сообщение",
|
||||||
"next": "Следующее сообщение",
|
"next": "Следующее сообщение",
|
||||||
"prev": "Предыдущее сообщение"
|
"prev": "Предыдущее сообщение",
|
||||||
|
"top": "Вернуться наверх",
|
||||||
|
"bottom": "Вернуться вниз",
|
||||||
|
"close": "Закрыть"
|
||||||
},
|
},
|
||||||
"resend": "Переотправить",
|
"resend": "Переотправить",
|
||||||
"save": "Сохранить",
|
"save": "Сохранить",
|
||||||
@@ -224,7 +232,10 @@
|
|||||||
"topics.title": "Топики",
|
"topics.title": "Топики",
|
||||||
"topics.unpinned": "Открепленные темы",
|
"topics.unpinned": "Открепленные темы",
|
||||||
"translate": "Перевести",
|
"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": {
|
"code_block": {
|
||||||
"collapse": "Свернуть",
|
"collapse": "Свернуть",
|
||||||
@@ -275,7 +286,13 @@
|
|||||||
"select": "Выбрать",
|
"select": "Выбрать",
|
||||||
"topics": "Топики",
|
"topics": "Топики",
|
||||||
"warning": "Предупреждение",
|
"warning": "Предупреждение",
|
||||||
"you": "Вы"
|
"you": "Вы",
|
||||||
|
"variable_name": "Имя переменной",
|
||||||
|
"value": "Значение",
|
||||||
|
"no_variables_added": "Нет переменных",
|
||||||
|
"insert_variable_into_prompt": "Вставить переменную в промпт",
|
||||||
|
"variables": "Переменные",
|
||||||
|
"variables_help": "Добавьте переменные, которые нужно заменить в тексте, замена срабатывает в формате {{variable_name}} в документе замены"
|
||||||
},
|
},
|
||||||
"docs": {
|
"docs": {
|
||||||
"title": "Документация"
|
"title": "Документация"
|
||||||
@@ -542,8 +559,18 @@
|
|||||||
"warn.siyuan.exporting": "Экспортируется в Siyuan, пожалуйста, не отправляйте повторные запросы!"
|
"warn.siyuan.exporting": "Экспортируется в Siyuan, пожалуйста, не отправляйте повторные запросы!"
|
||||||
},
|
},
|
||||||
"minapp": {
|
"minapp": {
|
||||||
|
"popup": {
|
||||||
|
"refresh": "Обновить",
|
||||||
|
"close": "Закрыть встроенное приложение",
|
||||||
|
"minimize": "Свернуть встроенное приложение",
|
||||||
|
"devtools": "Инструменты разработчика",
|
||||||
|
"openExternal": "Открыть в браузере",
|
||||||
|
"rightclick_copyurl": "ПКМ → Копировать URL"
|
||||||
|
},
|
||||||
"sidebar.add.title": "Добавить в боковую панель",
|
"sidebar.add.title": "Добавить в боковую панель",
|
||||||
"sidebar.remove.title": "Удалить из боковой панели",
|
"sidebar.remove.title": "Удалить из боковой панели",
|
||||||
|
"sidebar.close.title": "Закрыть",
|
||||||
|
"sidebar.closeall.title": "Закрыть все",
|
||||||
"sidebar.hide.title": "Скрыть приложение",
|
"sidebar.hide.title": "Скрыть приложение",
|
||||||
"title": "Встроенные приложения"
|
"title": "Встроенные приложения"
|
||||||
},
|
},
|
||||||
@@ -561,13 +588,17 @@
|
|||||||
"copy_last_message": "Нажмите C для копирования",
|
"copy_last_message": "Нажмите C для копирования",
|
||||||
"esc": "Нажмите ESC {{action}}",
|
"esc": "Нажмите ESC {{action}}",
|
||||||
"esc_back": "возвращения",
|
"esc_back": "возвращения",
|
||||||
"esc_close": "закрытия окна"
|
"esc_close": "закрытия окна",
|
||||||
|
"backspace_clear": "Нажмите Backspace, чтобы очистить"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"placeholder": {
|
"placeholder": {
|
||||||
"empty": "Задайте вопрос {{model}}...",
|
"empty": "Задайте вопрос {{model}}...",
|
||||||
"title": "Что вы хотите сделать с этим текстом?"
|
"title": "Что вы хотите сделать с этим текстом?"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"pin": "Верхнее окно"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
@@ -904,16 +935,14 @@
|
|||||||
"new_folder.button.confirm": "Подтвердить",
|
"new_folder.button.confirm": "Подтвердить",
|
||||||
"new_folder.button.cancel": "Отмена",
|
"new_folder.button.cancel": "Отмена",
|
||||||
"new_folder.button": "Новая папка"
|
"new_folder.button": "Новая папка"
|
||||||
}
|
},
|
||||||
|
"message_title.use_topic_naming.title": "Использовать модель именования тем для создания заголовков сообщений",
|
||||||
|
"message_title.use_topic_naming.help": "Этот параметр влияет на все методы экспорта в Markdown, такие как Notion, Yuque и т.д."
|
||||||
},
|
},
|
||||||
"display.assistant.title": "Настройки ассистентов",
|
"display.assistant.title": "Настройки ассистентов",
|
||||||
"display.custom.css": "Пользовательский CSS",
|
"display.custom.css": "Пользовательский CSS",
|
||||||
"display.custom.css.cherrycss": "Получить из cherrycss.com",
|
"display.custom.css.cherrycss": "Получить из cherrycss.com",
|
||||||
"display.custom.css.placeholder": "/* Здесь введите пользовательский CSS */",
|
"display.custom.css.placeholder": "/* Здесь введите пользовательский CSS */",
|
||||||
"display.minApp.disabled": "скрытый апплет",
|
|
||||||
"display.minApp.empty": "Перетащите апплет, который хотите скрыть, слева сюда",
|
|
||||||
"display.minApp.title": "Настройки отображения мини программы",
|
|
||||||
"display.minApp.visible": "Отображаемый апплет",
|
|
||||||
"display.sidebar.chat.hiddenMessage": "Помощник является базовой функцией и не поддерживает скрытие",
|
"display.sidebar.chat.hiddenMessage": "Помощник является базовой функцией и не поддерживает скрытие",
|
||||||
"display.sidebar.disabled": "Скрыть иконки",
|
"display.sidebar.disabled": "Скрыть иконки",
|
||||||
"display.sidebar.empty": "Перетащите скрываемую функцию с левой стороны сюда",
|
"display.sidebar.empty": "Перетащите скрываемую функцию с левой стороны сюда",
|
||||||
@@ -926,6 +955,20 @@
|
|||||||
"display.sidebar.visible": "Показывать иконки",
|
"display.sidebar.visible": "Показывать иконки",
|
||||||
"display.title": "Настройки отображения",
|
"display.title": "Настройки отображения",
|
||||||
"display.topic.title": "Настройки топиков",
|
"display.topic.title": "Настройки топиков",
|
||||||
|
"miniapps": {
|
||||||
|
"title": "Настройки мини-приложений",
|
||||||
|
"disabled": "Скрытые мини-приложения",
|
||||||
|
"empty": "Перетащите мини-приложения слева, чтобы скрыть их",
|
||||||
|
"visible": "Отображаемые мини-приложения",
|
||||||
|
"cache_settings": "Настройки кэша",
|
||||||
|
"cache_title": "Количество кэшируемых мини-приложений",
|
||||||
|
"cache_description": "Установить максимальное количество активных мини-приложений в памяти",
|
||||||
|
"reset_tooltip": "Сбросить до значения по умолчанию",
|
||||||
|
"display_title": "Настройки отображения мини-приложений",
|
||||||
|
"sidebar_title": "Отображение активных мини-приложений в боковой панели",
|
||||||
|
"sidebar_description": "Настройка отображения активных мини-приложений в боковой панели",
|
||||||
|
"cache_change_notice": "Изменения вступят в силу, когда количество открытых мини-приложений достигнет установленного значения"
|
||||||
|
},
|
||||||
"font_size.title": "Размер шрифта сообщений",
|
"font_size.title": "Размер шрифта сообщений",
|
||||||
"general": "Общие настройки",
|
"general": "Общие настройки",
|
||||||
"general.avatar.reset": "Сброс аватара",
|
"general.avatar.reset": "Сброс аватара",
|
||||||
@@ -1011,8 +1054,18 @@
|
|||||||
"updateError": "Ошибка обновления сервера",
|
"updateError": "Ошибка обновления сервера",
|
||||||
"updateSuccess": "Сервер успешно обновлен",
|
"updateSuccess": "Сервер успешно обновлен",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"editMcpJson": "Редактировать MCP 配置",
|
"editMcpJson": "Редактировать MCP",
|
||||||
"installHelp": "Получить помощь по установке"
|
"installHelp": "Получить помощь по установке",
|
||||||
|
"tools": {
|
||||||
|
"inputSchema": "входные параметры",
|
||||||
|
"availableTools": "доступные инструменты",
|
||||||
|
"noToolsAvailable": "нет доступных инструментов"
|
||||||
|
},
|
||||||
|
"deleteServer": "Удалить сервер",
|
||||||
|
"deleteServerConfirm": "Вы уверены, что хотите удалить этот сервер?",
|
||||||
|
"registry": "Реестр пакетов",
|
||||||
|
"registryTooltip": "Выберите реестр для установки пакетов, если возникают проблемы с сетью при использовании реестра по умолчанию.",
|
||||||
|
"registryDefault": "По умолчанию"
|
||||||
},
|
},
|
||||||
"messages.divider": "Показывать разделитель между сообщениями",
|
"messages.divider": "Показывать разделитель между сообщениями",
|
||||||
"messages.grid_columns": "Количество столбцов сетки сообщений",
|
"messages.grid_columns": "Количество столбцов сетки сообщений",
|
||||||
@@ -1162,7 +1215,7 @@
|
|||||||
"reset_defaults_confirm": "Вы уверены, что хотите сбросить все горячие клавиши?",
|
"reset_defaults_confirm": "Вы уверены, что хотите сбросить все горячие клавиши?",
|
||||||
"reset_to_default": "Сбросить настройки по умолчанию",
|
"reset_to_default": "Сбросить настройки по умолчанию",
|
||||||
"search_message": "Поиск сообщения",
|
"search_message": "Поиск сообщения",
|
||||||
"show_app": "Показать приложение",
|
"show_app": "Показать/скрыть приложение",
|
||||||
"show_settings": "Открыть настройки",
|
"show_settings": "Открыть настройки",
|
||||||
"title": "Горячие клавиши",
|
"title": "Горячие клавиши",
|
||||||
"toggle_new_context": "Очистить контекст",
|
"toggle_new_context": "Очистить контекст",
|
||||||
|
|||||||
@@ -44,6 +44,11 @@
|
|||||||
"save.success": "保存成功",
|
"save.success": "保存成功",
|
||||||
"save.title": "保存到智能体",
|
"save.title": "保存到智能体",
|
||||||
"search": "搜索助手",
|
"search": "搜索助手",
|
||||||
|
"settings.mcp": "MCP 服务器",
|
||||||
|
"settings.mcp.enableFirst": "请先在 MCP 设置中启用此服务器",
|
||||||
|
"settings.mcp.title": "MCP 设置",
|
||||||
|
"settings.mcp.noServersAvailable": "无可用 MCP 服务器。请在设置中添加服务器",
|
||||||
|
"settings.mcp.description": "默认启用的 MCP 服务器",
|
||||||
"settings.default_model": "默认模型",
|
"settings.default_model": "默认模型",
|
||||||
"settings.knowledge_base": "知识库设置",
|
"settings.knowledge_base": "知识库设置",
|
||||||
"settings.model": "模型设置",
|
"settings.model": "模型设置",
|
||||||
@@ -145,7 +150,10 @@
|
|||||||
"history": "聊天历史",
|
"history": "聊天历史",
|
||||||
"last": "已经是最后一条消息",
|
"last": "已经是最后一条消息",
|
||||||
"next": "下一条消息",
|
"next": "下一条消息",
|
||||||
"prev": "上一条消息"
|
"prev": "上一条消息",
|
||||||
|
"top": "回到顶部",
|
||||||
|
"bottom": "回到底部",
|
||||||
|
"close": "关闭"
|
||||||
},
|
},
|
||||||
"resend": "重新发送",
|
"resend": "重新发送",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
@@ -224,7 +232,10 @@
|
|||||||
"topics.title": "话题",
|
"topics.title": "话题",
|
||||||
"topics.unpinned": "取消固定",
|
"topics.unpinned": "取消固定",
|
||||||
"translate": "翻译",
|
"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": {
|
"code_block": {
|
||||||
"collapse": "收起",
|
"collapse": "收起",
|
||||||
@@ -275,7 +286,13 @@
|
|||||||
"select": "选择",
|
"select": "选择",
|
||||||
"topics": "话题",
|
"topics": "话题",
|
||||||
"warning": "警告",
|
"warning": "警告",
|
||||||
"you": "用户"
|
"you": "用户",
|
||||||
|
"variable_name": "变量名称",
|
||||||
|
"value": "值",
|
||||||
|
"no_variables_added": "没有添加变量",
|
||||||
|
"insert_variable_into_prompt": "插入变量到提示词",
|
||||||
|
"variables": "变量",
|
||||||
|
"variables_help": "添加需要替换的变量名字和值即可"
|
||||||
},
|
},
|
||||||
"docs": {
|
"docs": {
|
||||||
"title": "帮助文档"
|
"title": "帮助文档"
|
||||||
@@ -542,8 +559,18 @@
|
|||||||
"warn.siyuan.exporting": "正在导出到思源笔记,请勿重复请求导出!"
|
"warn.siyuan.exporting": "正在导出到思源笔记,请勿重复请求导出!"
|
||||||
},
|
},
|
||||||
"minapp": {
|
"minapp": {
|
||||||
|
"popup": {
|
||||||
|
"refresh": "刷新",
|
||||||
|
"close": "关闭小程序",
|
||||||
|
"minimize": "最小化小程序",
|
||||||
|
"devtools": "开发者工具",
|
||||||
|
"openExternal": "在浏览器中打开",
|
||||||
|
"rightclick_copyurl": "右键复制URL"
|
||||||
|
},
|
||||||
"sidebar.add.title": "添加到侧边栏",
|
"sidebar.add.title": "添加到侧边栏",
|
||||||
"sidebar.remove.title": "从侧边栏移除",
|
"sidebar.remove.title": "从侧边栏移除",
|
||||||
|
"sidebar.close.title": "关闭",
|
||||||
|
"sidebar.closeall.title": "全部关闭",
|
||||||
"sidebar.hide.title": "隐藏小程序",
|
"sidebar.hide.title": "隐藏小程序",
|
||||||
"title": "小程序"
|
"title": "小程序"
|
||||||
},
|
},
|
||||||
@@ -559,15 +586,19 @@
|
|||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"copy_last_message": "按 C 键复制",
|
"copy_last_message": "按 C 键复制",
|
||||||
|
"backspace_clear": "按 Backspace 清空",
|
||||||
"esc": "按 ESC {{action}}",
|
"esc": "按 ESC {{action}}",
|
||||||
"esc_back": "返回",
|
"esc_back": "返回",
|
||||||
"esc_close": "关闭窗口"
|
"esc_close": "关闭"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"placeholder": {
|
"placeholder": {
|
||||||
"empty": "询问 {{model}} 获取帮助...",
|
"empty": "询问 {{model}} 获取帮助...",
|
||||||
"title": "你想对下方文字做什么"
|
"title": "你想对下方文字做什么"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"pin": "窗口置顶"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
@@ -785,6 +816,8 @@
|
|||||||
"markdown_export.path_placeholder": "导出路径",
|
"markdown_export.path_placeholder": "导出路径",
|
||||||
"markdown_export.select": "选择",
|
"markdown_export.select": "选择",
|
||||||
"markdown_export.title": "Markdown 导出",
|
"markdown_export.title": "Markdown 导出",
|
||||||
|
"message_title.use_topic_naming.title": "使用话题命名模型为导出的消息创建标题",
|
||||||
|
"message_title.use_topic_naming.help": "开启后,使用话题命名模型为导出的消息创建标题。该项也会影响所有通过Markdown导出的方式。",
|
||||||
"minute_interval_one": "{{count}} 分钟",
|
"minute_interval_one": "{{count}} 分钟",
|
||||||
"minute_interval_other": "{{count}} 分钟",
|
"minute_interval_other": "{{count}} 分钟",
|
||||||
"notion.api_key": "Notion 密钥",
|
"notion.api_key": "Notion 密钥",
|
||||||
@@ -910,10 +943,6 @@
|
|||||||
"display.custom.css": "自定义 CSS",
|
"display.custom.css": "自定义 CSS",
|
||||||
"display.custom.css.cherrycss": "从 cherrycss.com 获取",
|
"display.custom.css.cherrycss": "从 cherrycss.com 获取",
|
||||||
"display.custom.css.placeholder": "/* 这里写自定义CSS */",
|
"display.custom.css.placeholder": "/* 这里写自定义CSS */",
|
||||||
"display.minApp.disabled": "隐藏的小程序",
|
|
||||||
"display.minApp.empty": "把要隐藏的小程序从左侧拖拽到这里",
|
|
||||||
"display.minApp.title": "小程序显示设置",
|
|
||||||
"display.minApp.visible": "显示的小程序",
|
|
||||||
"display.sidebar.chat.hiddenMessage": "助手是基础功能,不支持隐藏",
|
"display.sidebar.chat.hiddenMessage": "助手是基础功能,不支持隐藏",
|
||||||
"display.sidebar.disabled": "隐藏的图标",
|
"display.sidebar.disabled": "隐藏的图标",
|
||||||
"display.sidebar.empty": "把要隐藏的功能从左侧拖拽到这里",
|
"display.sidebar.empty": "把要隐藏的功能从左侧拖拽到这里",
|
||||||
@@ -926,6 +955,20 @@
|
|||||||
"display.sidebar.visible": "显示的图标",
|
"display.sidebar.visible": "显示的图标",
|
||||||
"display.title": "显示设置",
|
"display.title": "显示设置",
|
||||||
"display.topic.title": "话题设置",
|
"display.topic.title": "话题设置",
|
||||||
|
"miniapps": {
|
||||||
|
"title": "小程序设置",
|
||||||
|
"disabled": "隐藏的小程序",
|
||||||
|
"empty": "把要隐藏的小程序从左侧拖拽到这里",
|
||||||
|
"visible": "显示的小程序",
|
||||||
|
"cache_settings": "缓存设置",
|
||||||
|
"cache_title": "小程序缓存数量",
|
||||||
|
"cache_description": "设置同时保持活跃状态的小程序最大数量",
|
||||||
|
"reset_tooltip": "重置为默认值",
|
||||||
|
"display_title": "小程序显示设置",
|
||||||
|
"sidebar_title": "侧边栏活跃小程序显示设置",
|
||||||
|
"sidebar_description": "设置侧边栏是否显示活跃的小程序",
|
||||||
|
"cache_change_notice": "更改将在打开的小程序增减至设定值后生效"
|
||||||
|
},
|
||||||
"font_size.title": "消息字体大小",
|
"font_size.title": "消息字体大小",
|
||||||
"general": "常规设置",
|
"general": "常规设置",
|
||||||
"general.avatar.reset": "重置头像",
|
"general.avatar.reset": "重置头像",
|
||||||
@@ -1013,7 +1056,17 @@
|
|||||||
"updateSuccess": "服务器更新成功",
|
"updateSuccess": "服务器更新成功",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"editMcpJson": "编辑 MCP 配置",
|
"editMcpJson": "编辑 MCP 配置",
|
||||||
"installHelp": "获取安装帮助"
|
"installHelp": "获取安装帮助",
|
||||||
|
"tools": {
|
||||||
|
"inputSchema": "输入参数",
|
||||||
|
"availableTools": "可用工具",
|
||||||
|
"noToolsAvailable": "没有可用工具"
|
||||||
|
},
|
||||||
|
"deleteServer": "删除服务器",
|
||||||
|
"deleteServerConfirm": "确定要删除此服务器吗?",
|
||||||
|
"registry": "包管理源",
|
||||||
|
"registryTooltip": "选择用于安装包的源,以解决默认源的网络问题。",
|
||||||
|
"registryDefault": "默认"
|
||||||
},
|
},
|
||||||
"messages.divider": "消息分割线",
|
"messages.divider": "消息分割线",
|
||||||
"messages.grid_columns": "消息网格展示列数",
|
"messages.grid_columns": "消息网格展示列数",
|
||||||
@@ -1163,7 +1216,7 @@
|
|||||||
"reset_defaults_confirm": "确定要重置所有快捷键吗?",
|
"reset_defaults_confirm": "确定要重置所有快捷键吗?",
|
||||||
"reset_to_default": "重置为默认",
|
"reset_to_default": "重置为默认",
|
||||||
"search_message": "搜索消息",
|
"search_message": "搜索消息",
|
||||||
"show_app": "显示应用",
|
"show_app": "显示/隐藏应用",
|
||||||
"show_settings": "打开设置",
|
"show_settings": "打开设置",
|
||||||
"title": "快捷方式",
|
"title": "快捷方式",
|
||||||
"toggle_new_context": "清除上下文",
|
"toggle_new_context": "清除上下文",
|
||||||
|
|||||||
@@ -44,6 +44,11 @@
|
|||||||
"save.success": "儲存成功",
|
"save.success": "儲存成功",
|
||||||
"save.title": "儲存到智慧代理人",
|
"save.title": "儲存到智慧代理人",
|
||||||
"search": "搜尋助手...",
|
"search": "搜尋助手...",
|
||||||
|
"settings.mcp": "MCP 伺服器",
|
||||||
|
"settings.mcp.enableFirst": "請先在 MCP 設定中啟用此伺服器",
|
||||||
|
"settings.mcp.title": "MCP 設定",
|
||||||
|
"settings.mcp.noServersAvailable": "無可用 MCP 伺服器。請在設定中新增伺服器",
|
||||||
|
"settings.mcp.description": "預設啟用的 MCP 伺服器",
|
||||||
"settings.default_model": "預設模型",
|
"settings.default_model": "預設模型",
|
||||||
"settings.knowledge_base": "知識庫設定",
|
"settings.knowledge_base": "知識庫設定",
|
||||||
"settings.model": "模型設定",
|
"settings.model": "模型設定",
|
||||||
@@ -145,7 +150,10 @@
|
|||||||
"history": "聊天歷史",
|
"history": "聊天歷史",
|
||||||
"last": "已經是最後一條訊息",
|
"last": "已經是最後一條訊息",
|
||||||
"next": "下一條訊息",
|
"next": "下一條訊息",
|
||||||
"prev": "上一條訊息"
|
"prev": "上一條訊息",
|
||||||
|
"top": "回到頂部",
|
||||||
|
"bottom": "回到底部",
|
||||||
|
"close": "關閉"
|
||||||
},
|
},
|
||||||
"resend": "重新傳送",
|
"resend": "重新傳送",
|
||||||
"save": "儲存",
|
"save": "儲存",
|
||||||
@@ -224,7 +232,10 @@
|
|||||||
"topics.title": "話題",
|
"topics.title": "話題",
|
||||||
"topics.unpinned": "取消固定",
|
"topics.unpinned": "取消固定",
|
||||||
"translate": "翻譯",
|
"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": {
|
"code_block": {
|
||||||
"collapse": "折疊",
|
"collapse": "折疊",
|
||||||
@@ -275,7 +286,13 @@
|
|||||||
"select": "選擇",
|
"select": "選擇",
|
||||||
"topics": "話題",
|
"topics": "話題",
|
||||||
"warning": "警告",
|
"warning": "警告",
|
||||||
"you": "您"
|
"you": "您",
|
||||||
|
"variable_name": "變量名稱",
|
||||||
|
"value": "值",
|
||||||
|
"no_variables_added": "沒有添加變量",
|
||||||
|
"insert_variable_into_prompt": "插入變量到提示詞",
|
||||||
|
"variables": "變量",
|
||||||
|
"variables_help": "添加需要替換的變量名字和值即可"
|
||||||
},
|
},
|
||||||
"docs": {
|
"docs": {
|
||||||
"title": "說明文件"
|
"title": "說明文件"
|
||||||
@@ -542,8 +559,18 @@
|
|||||||
"warn.siyuan.exporting": "正在導出到思源筆記,請勿重複請求導出!"
|
"warn.siyuan.exporting": "正在導出到思源筆記,請勿重複請求導出!"
|
||||||
},
|
},
|
||||||
"minapp": {
|
"minapp": {
|
||||||
|
"popup": {
|
||||||
|
"refresh": "重新整理",
|
||||||
|
"close": "關閉小工具",
|
||||||
|
"minimize": "最小化小工具",
|
||||||
|
"devtools": "開發者工具",
|
||||||
|
"openExternal": "在瀏覽器中開啟",
|
||||||
|
"rightclick_copyurl": "右鍵複製URL"
|
||||||
|
},
|
||||||
"sidebar.add.title": "新增到側邊欄",
|
"sidebar.add.title": "新增到側邊欄",
|
||||||
"sidebar.remove.title": "從側邊欄移除",
|
"sidebar.remove.title": "從側邊欄移除",
|
||||||
|
"sidebar.close.title": "關閉",
|
||||||
|
"sidebar.closeall.title": "全部關閉",
|
||||||
"sidebar.hide.title": "隱藏小工具",
|
"sidebar.hide.title": "隱藏小工具",
|
||||||
"title": "小工具"
|
"title": "小工具"
|
||||||
},
|
},
|
||||||
@@ -561,13 +588,17 @@
|
|||||||
"copy_last_message": "按 C 鍵複製",
|
"copy_last_message": "按 C 鍵複製",
|
||||||
"esc": "按 ESC {{action}}",
|
"esc": "按 ESC {{action}}",
|
||||||
"esc_back": "返回",
|
"esc_back": "返回",
|
||||||
"esc_close": "關閉視窗"
|
"esc_close": "關閉視窗",
|
||||||
|
"backspace_clear": "按 Backspace 清空"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"placeholder": {
|
"placeholder": {
|
||||||
"empty": "詢問 {{model}} 取得幫助...",
|
"empty": "詢問 {{model}} 取得幫助...",
|
||||||
"title": "你想對下方文字做什麼"
|
"title": "你想對下方文字做什麼"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"pin": "窗口置頂"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
@@ -904,16 +935,14 @@
|
|||||||
"new_folder.button.confirm": "確定",
|
"new_folder.button.confirm": "確定",
|
||||||
"new_folder.button.cancel": "取消",
|
"new_folder.button.cancel": "取消",
|
||||||
"new_folder.button": "新建文件夾"
|
"new_folder.button": "新建文件夾"
|
||||||
}
|
},
|
||||||
|
"message_title.use_topic_naming.title": "使用話題命名模型為導出的消息創建標題",
|
||||||
|
"message_title.use_topic_naming.help": "此設定會影響所有通過Markdown導出的方式,如Notion、語雀等。"
|
||||||
},
|
},
|
||||||
"display.assistant.title": "助手設定",
|
"display.assistant.title": "助手設定",
|
||||||
"display.custom.css": "自訂 CSS",
|
"display.custom.css": "自訂 CSS",
|
||||||
"display.custom.css.cherrycss": "從 cherrycss.com 取得",
|
"display.custom.css.cherrycss": "從 cherrycss.com 取得",
|
||||||
"display.custom.css.placeholder": "/* 這裡寫自訂 CSS */",
|
"display.custom.css.placeholder": "/* 這裡寫自訂 CSS */",
|
||||||
"display.minApp.disabled": "隱藏的小工具",
|
|
||||||
"display.minApp.empty": "把要隱藏的小工具從左側拖拽到這裡",
|
|
||||||
"display.minApp.title": "小工具顯示設定",
|
|
||||||
"display.minApp.visible": "顯示的小工具",
|
|
||||||
"display.sidebar.chat.hiddenMessage": "助手是基礎功能,不支援隱藏",
|
"display.sidebar.chat.hiddenMessage": "助手是基礎功能,不支援隱藏",
|
||||||
"display.sidebar.disabled": "隱藏的圖示",
|
"display.sidebar.disabled": "隱藏的圖示",
|
||||||
"display.sidebar.empty": "把要隱藏的功能從左側拖拽到這裡",
|
"display.sidebar.empty": "把要隱藏的功能從左側拖拽到這裡",
|
||||||
@@ -926,6 +955,20 @@
|
|||||||
"display.sidebar.visible": "顯示的圖示",
|
"display.sidebar.visible": "顯示的圖示",
|
||||||
"display.title": "顯示設定",
|
"display.title": "顯示設定",
|
||||||
"display.topic.title": "話題設定",
|
"display.topic.title": "話題設定",
|
||||||
|
"miniapps": {
|
||||||
|
"title": "小程式設置",
|
||||||
|
"disabled": "隱藏的小程式",
|
||||||
|
"empty": "把要隱藏的小程式從左側拖拽到這裡",
|
||||||
|
"visible": "顯示的小程式",
|
||||||
|
"cache_settings": "緩存設置",
|
||||||
|
"cache_title": "小程式緩存數量",
|
||||||
|
"cache_description": "設置同時保持活躍狀態的小程式最大數量",
|
||||||
|
"reset_tooltip": "重置為預設值",
|
||||||
|
"display_title": "小程式顯示設置",
|
||||||
|
"sidebar_title": "側邊欄活躍小程式顯示設置",
|
||||||
|
"sidebar_description": "設置側邊欄是否顯示活躍的小程式",
|
||||||
|
"cache_change_notice": "更改將在打開的小程式增減至設定值後生效"
|
||||||
|
},
|
||||||
"font_size.title": "訊息字型大小",
|
"font_size.title": "訊息字型大小",
|
||||||
"general": "一般設定",
|
"general": "一般設定",
|
||||||
"general.avatar.reset": "重設頭像",
|
"general.avatar.reset": "重設頭像",
|
||||||
@@ -1012,7 +1055,17 @@
|
|||||||
"updateSuccess": "伺服器更新成功",
|
"updateSuccess": "伺服器更新成功",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"editMcpJson": "編輯 MCP 配置",
|
"editMcpJson": "編輯 MCP 配置",
|
||||||
"installHelp": "獲取安裝幫助"
|
"installHelp": "獲取安裝幫助",
|
||||||
|
"tools": {
|
||||||
|
"inputSchema": "輸入參數",
|
||||||
|
"availableTools": "可用工具",
|
||||||
|
"noToolsAvailable": "沒有可用工具"
|
||||||
|
},
|
||||||
|
"deleteServer": "刪除伺服器",
|
||||||
|
"deleteServerConfirm": "確定要刪除此伺服器嗎?",
|
||||||
|
"registry": "套件管理源",
|
||||||
|
"registryTooltip": "選擇用於安裝套件的源,以解決預設源的網路問題。",
|
||||||
|
"registryDefault": "預設"
|
||||||
},
|
},
|
||||||
"messages.divider": "訊息間顯示分隔線",
|
"messages.divider": "訊息間顯示分隔線",
|
||||||
"messages.grid_columns": "訊息網格展示列數",
|
"messages.grid_columns": "訊息網格展示列數",
|
||||||
@@ -1162,7 +1215,7 @@
|
|||||||
"reset_defaults_confirm": "確定要重設所有快捷鍵嗎?",
|
"reset_defaults_confirm": "確定要重設所有快捷鍵嗎?",
|
||||||
"reset_to_default": "重設為預設",
|
"reset_to_default": "重設為預設",
|
||||||
"search_message": "搜尋訊息",
|
"search_message": "搜尋訊息",
|
||||||
"show_app": "顯示應用程式",
|
"show_app": "顯示/隱藏應用程式",
|
||||||
"show_settings": "開啟設定",
|
"show_settings": "開啟設定",
|
||||||
"title": "快速方式",
|
"title": "快速方式",
|
||||||
"toggle_new_context": "清除上下文",
|
"toggle_new_context": "清除上下文",
|
||||||
|
|||||||
@@ -130,7 +130,10 @@
|
|||||||
"first": "Ήδη το πρώτο μήνυμα",
|
"first": "Ήδη το πρώτο μήνυμα",
|
||||||
"last": "Ήδη το τελευταίο μήνυμα",
|
"last": "Ήδη το τελευταίο μήνυμα",
|
||||||
"next": "Επόμενο μήνυμα",
|
"next": "Επόμενο μήνυμα",
|
||||||
"prev": "Προηγούμενο μήνυμα"
|
"prev": "Προηγούμενο μήνυμα",
|
||||||
|
"top": "Επιστροφή στην κορυφή",
|
||||||
|
"bottom": "Επιστροφή στο κάτω μέρος",
|
||||||
|
"close": "Κλείσιμο"
|
||||||
},
|
},
|
||||||
"resend": "Ξαναστείλε",
|
"resend": "Ξαναστείλε",
|
||||||
"save": "Αποθήκευση",
|
"save": "Αποθήκευση",
|
||||||
|
|||||||
@@ -130,7 +130,10 @@
|
|||||||
"first": "Ya es el primer mensaje",
|
"first": "Ya es el primer mensaje",
|
||||||
"last": "Ya es el último mensaje",
|
"last": "Ya es el último mensaje",
|
||||||
"next": "Siguiente mensaje",
|
"next": "Siguiente mensaje",
|
||||||
"prev": "Mensaje anterior"
|
"prev": "Mensaje anterior",
|
||||||
|
"top": "Volver arriba",
|
||||||
|
"bottom": "Volver abajo",
|
||||||
|
"close": "Cerrar"
|
||||||
},
|
},
|
||||||
"resend": "Reenviar",
|
"resend": "Reenviar",
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
|
|||||||
@@ -130,7 +130,10 @@
|
|||||||
"first": "Déjà premier message",
|
"first": "Déjà premier message",
|
||||||
"last": "Déjà dernier message",
|
"last": "Déjà dernier message",
|
||||||
"next": "Prochain 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",
|
"resend": "Réenvoyer",
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
|
|||||||
@@ -130,7 +130,10 @@
|
|||||||
"first": "Esta é a primeira mensagem",
|
"first": "Esta é a primeira mensagem",
|
||||||
"last": "Esta é a última mensagem",
|
"last": "Esta é a última mensagem",
|
||||||
"next": "Próxima mensagem",
|
"next": "Próxima mensagem",
|
||||||
"prev": "Mensagem anterior"
|
"prev": "Mensagem anterior",
|
||||||
|
"top": "Voltar ao topo",
|
||||||
|
"bottom": "Voltar ao fundo",
|
||||||
|
"close": "Fechar"
|
||||||
},
|
},
|
||||||
"resend": "Reenviar",
|
"resend": "Reenviar",
|
||||||
"save": "Salvar",
|
"save": "Salvar",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import MinAppIcon from '@renderer/components/Icons/MinAppIcon'
|
import MinAppIcon from '@renderer/components/Icons/MinAppIcon'
|
||||||
import MinApp from '@renderer/components/MinApp'
|
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||||
import { MinAppType } from '@renderer/types'
|
import { MinAppType } from '@renderer/types'
|
||||||
import type { MenuProps } from 'antd'
|
import type { MenuProps } from 'antd'
|
||||||
@@ -15,13 +15,14 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const App: FC<Props> = ({ app, onClick, size = 60 }) => {
|
const App: FC<Props> = ({ app, onClick, size = 60 }) => {
|
||||||
|
const { openMinappKeepAlive } = useMinappPopup()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { minapps, pinned, disabled, updateMinapps, updateDisabledMinapps, updatePinnedMinapps } = useMinapps()
|
const { minapps, pinned, disabled, updateMinapps, updateDisabledMinapps, updatePinnedMinapps } = useMinapps()
|
||||||
const isPinned = pinned.some((p) => p.id === app.id)
|
const isPinned = pinned.some((p) => p.id === app.id)
|
||||||
const isVisible = minapps.some((m) => m.id === app.id)
|
const isVisible = minapps.some((m) => m.id === app.id)
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
MinApp.start(app)
|
openMinappKeepAlive(app)
|
||||||
onClick?.()
|
onClick?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 {
|
import {
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
EllipsisOutlined,
|
ExclamationCircleOutlined,
|
||||||
FileImageOutlined,
|
FileImageOutlined,
|
||||||
FilePdfOutlined,
|
FilePdfOutlined,
|
||||||
FileTextOutlined
|
FileTextOutlined,
|
||||||
|
SortAscendingOutlined,
|
||||||
|
SortDescendingOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||||
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
|
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
|
||||||
import Scrollbar from '@renderer/components/Scrollbar'
|
|
||||||
import db from '@renderer/databases'
|
import db from '@renderer/databases'
|
||||||
import { useProviders } from '@renderer/hooks/useProvider'
|
import { useProviders } from '@renderer/hooks/useProvider'
|
||||||
import FileManager from '@renderer/services/FileManager'
|
import FileManager from '@renderer/services/FileManager'
|
||||||
@@ -16,18 +17,23 @@ import store from '@renderer/store'
|
|||||||
import { FileType, FileTypes } from '@renderer/types'
|
import { FileType, FileTypes } from '@renderer/types'
|
||||||
import { formatFileSize } from '@renderer/utils'
|
import { formatFileSize } from '@renderer/utils'
|
||||||
import type { MenuProps } from 'antd'
|
import type { MenuProps } from 'antd'
|
||||||
import { Button, Dropdown, Menu } from 'antd'
|
import { Button, Empty, Flex, Menu, Popconfirm } from 'antd'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
import { FC, useMemo, useState } from 'react'
|
import { FC, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
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 FilesPage: FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [fileType, setFileType] = useState<string>('document')
|
const [fileType, setFileType] = useState<string>('document')
|
||||||
|
const [sortField, setSortField] = useState<SortField>('created_at')
|
||||||
|
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
|
||||||
const { providers } = useProviders()
|
const { providers } = useProviders()
|
||||||
|
|
||||||
const geminiProviders = providers.filter((provider) => provider.type === 'gemini')
|
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[]>(() => {
|
const files = useLiveQuery<FileType[]>(() => {
|
||||||
if (fileType === 'all') {
|
if (fileType === 'all') {
|
||||||
return db.files.orderBy('count').toArray().then(tempFilesSort)
|
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)
|
return db.files.where('type').equals(fileType).sortBy('count').then(tempFilesSort)
|
||||||
}, [fileType])
|
}, [fileType])
|
||||||
|
|
||||||
|
const sortedFiles = files ? sortFiles(files) : []
|
||||||
|
|
||||||
const handleDelete = async (fileId: string) => {
|
const handleDelete = async (fileId: string) => {
|
||||||
const file = await FileManager.getFile(fileId)
|
const file = await FileManager.getFile(fileId)
|
||||||
|
|
||||||
@@ -89,95 +115,34 @@ const FilesPage: FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getActionMenu = (fileId: string): MenuProps['items'] => [
|
const dataSource = sortedFiles?.map((file) => {
|
||||||
{
|
|
||||||
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) => {
|
|
||||||
return {
|
return {
|
||||||
key: file.id,
|
key: file.id,
|
||||||
file: (
|
file: <span onClick={() => window.api.file.openPath(file.path)}>{FileManager.formatFileName(file)}</span>,
|
||||||
<FileNameText className="text-nowrap" onClick={() => window.api.file.openPath(file.path)}>
|
|
||||||
{FileManager.formatFileName(file)}
|
|
||||||
</FileNameText>
|
|
||||||
),
|
|
||||||
size: formatFileSize(file.size),
|
size: formatFileSize(file.size),
|
||||||
size_bytes: file.size,
|
size_bytes: file.size,
|
||||||
count: file.count,
|
count: file.count,
|
||||||
|
path: file.path,
|
||||||
|
ext: file.ext,
|
||||||
created_at: dayjs(file.created_at).format('MM-DD HH:mm'),
|
created_at: dayjs(file.created_at).format('MM-DD HH:mm'),
|
||||||
created_at_unix: dayjs(file.created_at).unix(),
|
created_at_unix: dayjs(file.created_at).unix(),
|
||||||
actions: (
|
actions: (
|
||||||
<Dropdown menu={{ items: getActionMenu(file.id) }} trigger={['click']} placement="bottom" arrow>
|
<Flex align="center" gap={0} style={{ opacity: 0.7 }}>
|
||||||
<Button type="text" size="small" icon={<EllipsisOutlined />} />
|
<Button type="text" icon={<EditOutlined />} onClick={() => handleRename(file.id)} />
|
||||||
</Dropdown>
|
<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 = [
|
const menuItems = [
|
||||||
{ key: FileTypes.DOCUMENT, label: t('files.document'), icon: <FilePdfOutlined /> },
|
{ key: FileTypes.DOCUMENT, label: t('files.document'), icon: <FilePdfOutlined /> },
|
||||||
{ key: FileTypes.IMAGE, label: t('files.image'), icon: <FileImageOutlined /> },
|
{ key: FileTypes.IMAGE, label: t('files.image'), icon: <FileImageOutlined /> },
|
||||||
@@ -199,9 +164,31 @@ const FilesPage: FC = () => {
|
|||||||
<SideNav>
|
<SideNav>
|
||||||
<Menu selectedKeys={[fileType]} items={menuItems} onSelect={({ key }) => setFileType(key as FileTypes)} />
|
<Menu selectedKeys={[fileType]} items={menuItems} onSelect={({ key }) => setFileType(key as FileTypes)} />
|
||||||
</SideNav>
|
</SideNav>
|
||||||
<TableContainer right>
|
<MainContent>
|
||||||
<ContentView id={fileType} files={files} dataSource={dataSource} columns={columns} />
|
<SortContainer>
|
||||||
</TableContainer>
|
{['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>
|
</ContentContainer>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
@@ -214,6 +201,20 @@ const Container = styled.div`
|
|||||||
height: calc(100vh - var(--navbar-height));
|
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`
|
const ContentContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -221,19 +222,6 @@ const ContentContainer = styled.div`
|
|||||||
min-height: 100%;
|
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`
|
const SideNav = styled.div`
|
||||||
width: var(--assistants-width);
|
width: var(--assistants-width);
|
||||||
border-right: 0.5px solid var(--color-border);
|
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
|
export default FilesPage
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { DeleteOutlined } from '@ant-design/icons'
|
|||||||
import type { FileMetadataResponse } from '@google/generative-ai/server'
|
import type { FileMetadataResponse } from '@google/generative-ai/server'
|
||||||
import { useProvider } from '@renderer/hooks/useProvider'
|
import { useProvider } from '@renderer/hooks/useProvider'
|
||||||
import { runAsyncFunction } from '@renderer/utils'
|
import { runAsyncFunction } from '@renderer/utils'
|
||||||
import { Table } from 'antd'
|
import { Spin } from 'antd'
|
||||||
import type { ColumnsType } from 'antd/es/table'
|
import dayjs from 'dayjs'
|
||||||
import { FC, useCallback, useEffect, useState } from 'react'
|
import { FC, useCallback, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
import FileItem from './FileItem'
|
||||||
|
|
||||||
interface GeminiFilesProps {
|
interface GeminiFilesProps {
|
||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
@@ -15,7 +16,6 @@ interface GeminiFilesProps {
|
|||||||
const GeminiFiles: FC<GeminiFilesProps> = ({ id }) => {
|
const GeminiFiles: FC<GeminiFilesProps> = ({ id }) => {
|
||||||
const { provider } = useProvider(id)
|
const { provider } = useProvider(id)
|
||||||
const [files, setFiles] = useState<FileMetadataResponse[]>([])
|
const [files, setFiles] = useState<FileMetadataResponse[]>([])
|
||||||
const { t } = useTranslation()
|
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
const fetchFiles = useCallback(async () => {
|
const fetchFiles = useCallback(async () => {
|
||||||
@@ -23,51 +23,6 @@ const GeminiFiles: FC<GeminiFilesProps> = ({ id }) => {
|
|||||||
files && setFiles(files.filter((file) => file.state === 'ACTIVE'))
|
files && setFiles(files.filter((file) => file.state === 'ACTIVE'))
|
||||||
}, [provider])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
runAsyncFunction(async () => {
|
runAsyncFunction(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -86,13 +41,61 @@ const GeminiFiles: FC<GeminiFilesProps> = ({ id }) => {
|
|||||||
setFiles([])
|
setFiles([])
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<LoadingWrapper>
|
||||||
|
<Spin />
|
||||||
|
</LoadingWrapper>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<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>
|
</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
|
export default GeminiFiles
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ const Container = styled.div`
|
|||||||
|
|
||||||
const Main = styled(Flex)`
|
const Main = styled(Flex)`
|
||||||
height: calc(100vh - var(--navbar-height));
|
height: calc(100vh - var(--navbar-height));
|
||||||
|
// 设置为containing block,方便子元素fixed定位
|
||||||
|
transform: translateZ(0);
|
||||||
`
|
`
|
||||||
|
|
||||||
export default Chat
|
export default Chat
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import TranslateButton from '@renderer/components/TranslateButton'
|
|||||||
import { isFunctionCallingModel, isGenerateImageModel, isVisionModel, isWebSearchModel } from '@renderer/config/models'
|
import { isFunctionCallingModel, isGenerateImageModel, isVisionModel, isWebSearchModel } from '@renderer/config/models'
|
||||||
import db from '@renderer/databases'
|
import db from '@renderer/databases'
|
||||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||||
|
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||||
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
|
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
|
||||||
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
|
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
|
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 [isTranslating, setIsTranslating] = useState(false)
|
||||||
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<KnowledgeBase[]>([])
|
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<KnowledgeBase[]>([])
|
||||||
const [mentionModels, setMentionModels] = useState<Model[]>([])
|
const [mentionModels, setMentionModels] = useState<Model[]>([])
|
||||||
const [enabledMCPs, setEnabledMCPs] = useState<MCPServer[]>([])
|
const [enabledMCPs, setEnabledMCPs] = useState<MCPServer[]>(assistant.mcpServers || [])
|
||||||
const [isMentionPopupOpen, setIsMentionPopupOpen] = useState(false)
|
const [isMentionPopupOpen, setIsMentionPopupOpen] = useState(false)
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
const [textareaHeight, setTextareaHeight] = useState<number>()
|
const [textareaHeight, setTextareaHeight] = useState<number>()
|
||||||
@@ -101,6 +102,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
|||||||
const isVision = useMemo(() => isVisionModel(model), [model])
|
const isVision = useMemo(() => isVisionModel(model), [model])
|
||||||
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
|
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { activedMcpServers } = useMCPServers()
|
||||||
|
|
||||||
const showKnowledgeIcon = useSidebarIconShow('knowledge')
|
const showKnowledgeIcon = useSidebarIconShow('knowledge')
|
||||||
const showMCPToolsIcon = isFunctionCallingModel(model)
|
const showMCPToolsIcon = isFunctionCallingModel(model)
|
||||||
@@ -145,6 +147,11 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
|||||||
}
|
}
|
||||||
}, [textareaHeight])
|
}, [textareaHeight])
|
||||||
|
|
||||||
|
// Reset to assistant knowledge mcp servers
|
||||||
|
useEffect(() => {
|
||||||
|
setEnabledMCPs(assistant.mcpServers || [])
|
||||||
|
}, [assistant.mcpServers])
|
||||||
|
|
||||||
const sendMessage = useCallback(async () => {
|
const sendMessage = useCallback(async () => {
|
||||||
if (inputEmpty || loading) {
|
if (inputEmpty || loading) {
|
||||||
return
|
return
|
||||||
@@ -174,8 +181,12 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
|||||||
userMessage.mentions = mentionModels
|
userMessage.mentions = mentionModels
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enabledMCPs) {
|
if (isFunctionCallingModel(model)) {
|
||||||
userMessage.enabledMCPs = enabledMCPs
|
if (!isEmpty(assistant.mcpServers) && !isEmpty(activedMcpServers)) {
|
||||||
|
userMessage.enabledMCPs = activedMcpServers.filter((server) =>
|
||||||
|
assistant.mcpServers?.some((s) => s.id === server.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
userMessage.usage = await estimateMessageUsage(userMessage)
|
userMessage.usage = await estimateMessageUsage(userMessage)
|
||||||
@@ -197,13 +208,14 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
|||||||
console.error('Failed to send message:', error)
|
console.error('Failed to send message:', error)
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
activedMcpServers,
|
||||||
assistant,
|
assistant,
|
||||||
dispatch,
|
dispatch,
|
||||||
enabledMCPs,
|
|
||||||
files,
|
files,
|
||||||
inputEmpty,
|
inputEmpty,
|
||||||
loading,
|
loading,
|
||||||
mentionModels,
|
mentionModels,
|
||||||
|
model,
|
||||||
resizeTextArea,
|
resizeTextArea,
|
||||||
selectedKnowledgeBases,
|
selectedKnowledgeBases,
|
||||||
text,
|
text,
|
||||||
@@ -323,8 +335,11 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
|||||||
await db.topics.add({ id: topic.id, messages: [] })
|
await db.topics.add({ id: topic.id, messages: [] })
|
||||||
await addAssistantMessagesToTopic({ assistant, topic })
|
await addAssistantMessagesToTopic({ assistant, topic })
|
||||||
|
|
||||||
|
// Clear previous state
|
||||||
// Reset to assistant default model
|
// Reset to assistant default model
|
||||||
assistant.defaultModel && setModel(assistant.defaultModel)
|
assistant.defaultModel && setModel(assistant.defaultModel)
|
||||||
|
// Reset to assistant knowledge mcp servers
|
||||||
|
setEnabledMCPs(assistant.mcpServers || [])
|
||||||
|
|
||||||
addTopic(topic)
|
addTopic(topic)
|
||||||
setActiveTopic(topic)
|
setActiveTopic(topic)
|
||||||
|
|||||||
@@ -13,26 +13,31 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MCPToolsButton: FC<Props> = ({ enabledMCPs, toggelEnableMCP, ToolbarButton }) => {
|
const MCPToolsButton: FC<Props> = ({ enabledMCPs, toggelEnableMCP, ToolbarButton }) => {
|
||||||
const { mcpServers, activedMcpServers } = useMCPServers()
|
const { activedMcpServers } = useMCPServers()
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const dropdownRef = useRef<any>(null)
|
const dropdownRef = useRef<any>(null)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const { t } = useTranslation()
|
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) => {
|
const truncateText = (text: string, maxLength: number = 50) => {
|
||||||
if (!text || text.length <= maxLength) return text
|
if (!text || text.length <= maxLength) return text
|
||||||
return text.substring(0, maxLength) + '...'
|
return text.substring(0, maxLength) + '...'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if all active servers are enabled
|
// Check if all active servers are enabled
|
||||||
const activeServers = mcpServers.filter((s) => s.isActive)
|
|
||||||
|
|
||||||
const anyEnable = activeServers.some((server) => enabledMCPs.some((enabledServer) => enabledServer.id === server.id))
|
const anyEnable = activedMcpServers.some((server) =>
|
||||||
|
enabledMCPs.some((enabledServer) => enabledServer.id === server.id)
|
||||||
|
)
|
||||||
|
|
||||||
const enableAll = () => mcpServers.forEach(toggelEnableMCP)
|
const enableAll = () => activedMcpServers.forEach(toggelEnableMCP)
|
||||||
|
|
||||||
const disableAll = () =>
|
const disableAll = () =>
|
||||||
mcpServers.forEach((s) => {
|
activedMcpServers.forEach((s) => {
|
||||||
enabledMCPs.forEach((enabledServer) => {
|
enabledMCPs.forEach((enabledServer) => {
|
||||||
if (enabledServer.id === s.id) {
|
if (enabledServer.id === s.id) {
|
||||||
toggelEnableMCP(s)
|
toggelEnableMCP(s)
|
||||||
@@ -60,27 +65,25 @@ const MCPToolsButton: FC<Props> = ({ enabledMCPs, toggelEnableMCP, ToolbarButton
|
|||||||
</div>
|
</div>
|
||||||
</DropdownHeader>
|
</DropdownHeader>
|
||||||
<DropdownBody>
|
<DropdownBody>
|
||||||
{mcpServers.length > 0 ? (
|
{activedMcpServers.length > 0 ? (
|
||||||
mcpServers
|
activedMcpServers.map((server) => (
|
||||||
.filter((s) => s.isActive)
|
<McpServerItems key={server.id} className="ant-dropdown-menu-item">
|
||||||
.map((server) => (
|
<div className="server-info">
|
||||||
<McpServerItems key={server.id} className="ant-dropdown-menu-item">
|
<div className="server-name">{server.name}</div>
|
||||||
<div className="server-info">
|
{server.description && (
|
||||||
<div className="server-name">{server.name}</div>
|
<Tooltip title={server.description} placement="bottom">
|
||||||
{server.description && (
|
<div className="server-description">{truncateText(server.description)}</div>
|
||||||
<Tooltip title={server.description} placement="bottom">
|
</Tooltip>
|
||||||
<div className="server-description">{truncateText(server.description)}</div>
|
)}
|
||||||
</Tooltip>
|
{server.baseUrl && <div className="server-url">{server.baseUrl}</div>}
|
||||||
)}
|
</div>
|
||||||
{server.baseUrl && <div className="server-url">{server.baseUrl}</div>}
|
<Switch
|
||||||
</div>
|
size="small"
|
||||||
<Switch
|
checked={enabledMCPs.some((s) => s.id === server.id)}
|
||||||
size="small"
|
onChange={() => toggelEnableMCP(server)}
|
||||||
checked={enabledMCPs.some((s) => s.id === server.id)}
|
/>
|
||||||
onChange={() => toggelEnableMCP(server)}
|
</McpServerItems>
|
||||||
/>
|
))
|
||||||
</McpServerItems>
|
|
||||||
))
|
|
||||||
) : (
|
) : (
|
||||||
<div className="ant-dropdown-menu-item-group">
|
<div className="ant-dropdown-menu-item-group">
|
||||||
<div className="ant-dropdown-menu-item no-results">{t('settings.mcp.noServers')}</div>
|
<div className="ant-dropdown-menu-item no-results">{t('settings.mcp.noServers')}</div>
|
||||||
@@ -103,7 +106,7 @@ const MCPToolsButton: FC<Props> = ({ enabledMCPs, toggelEnableMCP, ToolbarButton
|
|||||||
overlayClassName="mention-models-dropdown">
|
overlayClassName="mention-models-dropdown">
|
||||||
<Tooltip placement="top" title={t('settings.mcp.title')} arrow>
|
<Tooltip placement="top" title={t('settings.mcp.title')} arrow>
|
||||||
<ToolbarButton type="text" ref={dropdownRef}>
|
<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>
|
</ToolbarButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DownloadOutlined, ExpandOutlined, LinkOutlined } from '@ant-design/icons'
|
import { DownloadOutlined, ExpandOutlined, LinkOutlined } from '@ant-design/icons'
|
||||||
import MinApp from '@renderer/components/MinApp'
|
|
||||||
import { AppLogo } from '@renderer/config/env'
|
import { AppLogo } from '@renderer/config/env'
|
||||||
|
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||||
import { extractTitle } from '@renderer/utils/formats'
|
import { extractTitle } from '@renderer/utils/formats'
|
||||||
import { Button } from 'antd'
|
import { Button } from 'antd'
|
||||||
import { FC } from 'react'
|
import { FC } from 'react'
|
||||||
@@ -14,6 +14,7 @@ interface Props {
|
|||||||
const Artifacts: FC<Props> = ({ html }) => {
|
const Artifacts: FC<Props> = ({ html }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview')
|
const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview')
|
||||||
|
const { openMinapp } = useMinappPopup()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在应用内打开
|
* 在应用内打开
|
||||||
@@ -22,7 +23,8 @@ const Artifacts: FC<Props> = ({ html }) => {
|
|||||||
const path = await window.api.file.create('artifacts-preview.html')
|
const path = await window.api.file.create('artifacts-preview.html')
|
||||||
await window.api.file.write(path, html)
|
await window.api.file.write(path, html)
|
||||||
const filePath = `file://${path}`
|
const filePath = `file://${path}`
|
||||||
MinApp.start({
|
openMinapp({
|
||||||
|
id: 'artifacts-preview',
|
||||||
name: title,
|
name: title,
|
||||||
logo: AppLogo,
|
logo: AppLogo,
|
||||||
url: filePath
|
url: filePath
|
||||||
@@ -73,6 +75,7 @@ const Container = styled.div`
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
padding-bottom: 10px;
|
||||||
`
|
`
|
||||||
|
|
||||||
export default Artifacts
|
export default Artifacts
|
||||||
|
|||||||
@@ -37,12 +37,17 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
|
|||||||
|
|
||||||
const showDownloadButton = ['csv', 'json', 'txt', 'md'].includes(language)
|
const showDownloadButton = ['csv', 'json', 'txt', 'md'].includes(language)
|
||||||
|
|
||||||
|
const shouldShowExpandButtonRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadHighlightedCode = async () => {
|
const loadHighlightedCode = async () => {
|
||||||
const highlightedHtml = await codeToHtml(children, language)
|
const highlightedHtml = await codeToHtml(children, language)
|
||||||
if (codeContentRef.current) {
|
if (codeContentRef.current) {
|
||||||
codeContentRef.current.innerHTML = highlightedHtml
|
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()
|
loadHighlightedCode()
|
||||||
@@ -98,12 +103,18 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
|
|||||||
)}
|
)}
|
||||||
<CodeLanguage>{'<' + language.toUpperCase() + '>'}</CodeLanguage>
|
<CodeLanguage>{'<' + language.toUpperCase() + '>'}</CodeLanguage>
|
||||||
</div>
|
</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} />}
|
{showDownloadButton && <DownloadButton language={language} data={children} />}
|
||||||
{codeWrappable && <UnwrapButton unwrapped={isUnwrapped} onClick={() => setIsUnwrapped(!isUnwrapped)} />}
|
{codeWrappable && <UnwrapButton unwrapped={isUnwrapped} onClick={() => setIsUnwrapped(!isUnwrapped)} />}
|
||||||
<CopyButton text={children} />
|
<CopyButton text={children} />
|
||||||
</HStack>
|
</HStack>
|
||||||
</CodeHeader>
|
</StickyWrapper>
|
||||||
<CodeContent
|
<CodeContent
|
||||||
ref={codeContentRef}
|
ref={codeContentRef}
|
||||||
isShowLineNumbers={codeShowLineNumbers}
|
isShowLineNumbers={codeShowLineNumbers}
|
||||||
@@ -211,19 +222,23 @@ 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 }>`
|
const CodeContent = styled.div<{ isShowLineNumbers: boolean; isUnwrapped: boolean; isCodeWrappable: boolean }>`
|
||||||
.shiki {
|
.shiki {
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
|
|
||||||
code {
|
code {
|
||||||
display: table;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
.line {
|
.line {
|
||||||
display: table-row;
|
display: block;
|
||||||
height: 1.3rem;
|
min-height: 1.3rem;
|
||||||
|
padding-left: ${(props) => (props.isShowLineNumbers ? '2rem' : '0')};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,14 +249,15 @@ const CodeContent = styled.div<{ isShowLineNumbers: boolean; isUnwrapped: boolea
|
|||||||
code {
|
code {
|
||||||
counter-reset: step;
|
counter-reset: step;
|
||||||
counter-increment: step 0;
|
counter-increment: step 0;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
code .line::before {
|
code .line::before {
|
||||||
content: counter(step);
|
content: counter(step);
|
||||||
counter-increment: step;
|
counter-increment: step;
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
padding-right: 1rem;
|
position: absolute;
|
||||||
display: table-cell;
|
left: 0;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
}
|
}
|
||||||
@@ -373,4 +389,10 @@ const DownloadWrapper = styled.div`
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const StickyWrapper = styled.div`
|
||||||
|
position: sticky;
|
||||||
|
top: 28px;
|
||||||
|
z-index: 10;
|
||||||
|
`
|
||||||
|
|
||||||
export default memo(CodeBlock)
|
export default memo(CodeBlock)
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
|||||||
import type { Message } from '@renderer/types'
|
import type { Message } from '@renderer/types'
|
||||||
import { escapeBrackets, removeSvgEmptyLines, withGeminiGrounding } from '@renderer/utils/formats'
|
import { escapeBrackets, removeSvgEmptyLines, withGeminiGrounding } from '@renderer/utils/formats'
|
||||||
import { isEmpty } from 'lodash'
|
import { isEmpty } from 'lodash'
|
||||||
import { type FC, useCallback, useMemo } from 'react'
|
import { type FC, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import ReactMarkdown, { type Components } from 'react-markdown'
|
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||||
import rehypeKatex from 'rehype-katex'
|
import rehypeKatex from 'rehype-katex'
|
||||||
// @ts-ignore next-line
|
// @ts-ignore next-line
|
||||||
import rehypeMathjax from 'rehype-mathjax'
|
import rehypeMathjax from 'rehype-mathjax'
|
||||||
import rehypeRaw from 'rehype-raw'
|
import rehypeRaw from 'rehype-raw'
|
||||||
|
import remarkCjkFriendly from 'remark-cjk-friendly'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import remarkMath from 'remark-math'
|
import remarkMath from 'remark-math'
|
||||||
|
|
||||||
@@ -36,6 +37,8 @@ interface Props {
|
|||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const remarkPlugins = [remarkMath, remarkGfm, remarkCjkFriendly]
|
||||||
|
const disallowedElements = ['iframe']
|
||||||
const Markdown: FC<Props> = ({ message, citationsData }) => {
|
const Markdown: FC<Props> = ({ message, citationsData }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { renderInputMessageAsMarkdown, mathEngine } = useSettings()
|
const { renderInputMessageAsMarkdown, mathEngine } = useSettings()
|
||||||
@@ -54,7 +57,7 @@ const Markdown: FC<Props> = ({ message, citationsData }) => {
|
|||||||
return hasElements ? [rehypeRaw, rehypeMath] : [rehypeMath]
|
return hasElements ? [rehypeRaw, rehypeMath] : [rehypeMath]
|
||||||
}, [messageContent, rehypeMath])
|
}, [messageContent, rehypeMath])
|
||||||
|
|
||||||
const components = useCallback(() => {
|
const components = useMemo(() => {
|
||||||
const baseComponents = {
|
const baseComponents = {
|
||||||
a: (props: any) => {
|
a: (props: any) => {
|
||||||
if (props.href && citationsData?.has(props.href)) {
|
if (props.href && citationsData?.has(props.href)) {
|
||||||
@@ -63,15 +66,12 @@ const Markdown: FC<Props> = ({ message, citationsData }) => {
|
|||||||
return <Link {...props} />
|
return <Link {...props} />
|
||||||
},
|
},
|
||||||
code: CodeBlock,
|
code: CodeBlock,
|
||||||
img: ImagePreview
|
img: ImagePreview,
|
||||||
|
pre: (props: any) => <pre style={{ overflow: 'visible' }} {...props} />,
|
||||||
|
style: MarkdownShadowDOMRenderer as any
|
||||||
} as Partial<Components>
|
} as Partial<Components>
|
||||||
|
|
||||||
if (messageContent.includes('<style>')) {
|
|
||||||
baseComponents.style = MarkdownShadowDOMRenderer as any
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseComponents
|
return baseComponents
|
||||||
}, [messageContent, citationsData])
|
}, [citationsData])
|
||||||
|
|
||||||
if (message.role === 'user' && !renderInputMessageAsMarkdown) {
|
if (message.role === 'user' && !renderInputMessageAsMarkdown) {
|
||||||
return <p style={{ marginBottom: 5, whiteSpace: 'pre-wrap' }}>{messageContent}</p>
|
return <p style={{ marginBottom: 5, whiteSpace: 'pre-wrap' }}>{messageContent}</p>
|
||||||
@@ -80,9 +80,10 @@ const Markdown: FC<Props> = ({ message, citationsData }) => {
|
|||||||
return (
|
return (
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
rehypePlugins={rehypePlugins}
|
rehypePlugins={rehypePlugins}
|
||||||
remarkPlugins={[remarkMath, remarkGfm]}
|
remarkPlugins={remarkPlugins}
|
||||||
className="markdown"
|
className="markdown"
|
||||||
components={components()}
|
components={components}
|
||||||
|
disallowedElements={disallowedElements}
|
||||||
remarkRehypeOptions={{
|
remarkRehypeOptions={{
|
||||||
footnoteLabel: t('common.footnotes'),
|
footnoteLabel: t('common.footnotes'),
|
||||||
footnoteLabelTagName: 'h4',
|
footnoteLabelTagName: 'h4',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import '@xyflow/react/dist/style.css'
|
|||||||
import { RobotOutlined, UserOutlined } from '@ant-design/icons'
|
import { RobotOutlined, UserOutlined } from '@ant-design/icons'
|
||||||
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
||||||
import { getModelLogo } from '@renderer/config/models'
|
import { getModelLogo } from '@renderer/config/models'
|
||||||
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||||
import { RootState } from '@renderer/store'
|
import { RootState } from '@renderer/store'
|
||||||
@@ -190,6 +191,7 @@ const ChatFlowHistory: FC<ChatFlowHistoryProps> = ({ conversationId }) => {
|
|||||||
const [edges, setEdges, onEdgesChange] = useEdgesState<any>([])
|
const [edges, setEdges, onEdgesChange] = useEdgesState<any>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const { userName } = useSettings()
|
const { userName } = useSettings()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
|
||||||
const topicId = conversationId
|
const topicId = conversationId
|
||||||
|
|
||||||
@@ -478,7 +480,8 @@ const ChatFlowHistory: FC<ChatFlowHistoryProps> = ({ conversationId }) => {
|
|||||||
maxZoom: 1
|
maxZoom: 1
|
||||||
}}
|
}}
|
||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
className="react-flow-container">
|
className="react-flow-container"
|
||||||
|
colorMode={theme === 'auto' ? 'system' : theme}>
|
||||||
<Controls showInteractive={false} />
|
<Controls showInteractive={false} />
|
||||||
<MiniMap
|
<MiniMap
|
||||||
nodeStrokeWidth={3}
|
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 { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import { RootState } from '@renderer/store'
|
import { RootState } from '@renderer/store'
|
||||||
import { selectCurrentTopicId } from '@renderer/store/messages'
|
import { selectCurrentTopicId } from '@renderer/store/messages'
|
||||||
@@ -20,11 +27,11 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
|||||||
const [isNearButtons, setIsNearButtons] = useState(false)
|
const [isNearButtons, setIsNearButtons] = useState(false)
|
||||||
const [hideTimer, setHideTimer] = useState<NodeJS.Timeout | null>(null)
|
const [hideTimer, setHideTimer] = useState<NodeJS.Timeout | null>(null)
|
||||||
const [showChatHistory, setShowChatHistory] = useState(false)
|
const [showChatHistory, setShowChatHistory] = useState(false)
|
||||||
|
const [manuallyClosedUntil, setManuallyClosedUntil] = useState<number | null>(null)
|
||||||
const currentTopicId = useSelector((state: RootState) => selectCurrentTopicId(state))
|
const currentTopicId = useSelector((state: RootState) => selectCurrentTopicId(state))
|
||||||
const lastMoveTime = useRef(0)
|
const lastMoveTime = useRef(0)
|
||||||
const { topicPosition, showTopics } = useSettings()
|
const { topicPosition, showTopics } = useSettings()
|
||||||
const showRightTopics = topicPosition === 'right' && showTopics
|
const showRightTopics = topicPosition === 'right' && showTopics
|
||||||
const right = showRightTopics ? 'calc(var(--topic-list-width) + 16px)' : '16px'
|
|
||||||
|
|
||||||
// Reset hide timer and make buttons visible
|
// Reset hide timer and make buttons visible
|
||||||
const resetHideTimer = useCallback(() => {
|
const resetHideTimer = useCallback(() => {
|
||||||
@@ -45,6 +52,10 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
|||||||
|
|
||||||
// Handle mouse entering button area
|
// Handle mouse entering button area
|
||||||
const handleMouseEnter = useCallback(() => {
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
if (manuallyClosedUntil && Date.now() < manuallyClosedUntil) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setIsNearButtons(true)
|
setIsNearButtons(true)
|
||||||
setIsVisible(true)
|
setIsVisible(true)
|
||||||
|
|
||||||
@@ -53,7 +64,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
|||||||
clearTimeout(hideTimer)
|
clearTimeout(hideTimer)
|
||||||
setHideTimer(null)
|
setHideTimer(null)
|
||||||
}
|
}
|
||||||
}, [hideTimer])
|
}, [hideTimer, manuallyClosedUntil])
|
||||||
|
|
||||||
// Handle mouse leaving button area
|
// Handle mouse leaving button area
|
||||||
const handleMouseLeave = useCallback(() => {
|
const handleMouseLeave = useCallback(() => {
|
||||||
@@ -98,7 +109,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
|||||||
|
|
||||||
const scrollToTop = () => {
|
const scrollToTop = () => {
|
||||||
const container = document.getElementById(containerId)
|
const container = document.getElementById(containerId)
|
||||||
container && container.scrollTo({ top: 0, behavior: 'smooth' })
|
container && container.scrollTo({ top: -container.scrollHeight, behavior: 'smooth' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
@@ -149,6 +160,23 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
|||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 修改 handleCloseChatNavigation 函数
|
||||||
|
const handleCloseChatNavigation = () => {
|
||||||
|
setIsVisible(false)
|
||||||
|
// 设置手动关闭状态,1分钟内不响应鼠标靠近事件
|
||||||
|
setManuallyClosedUntil(Date.now() + 60000) // 60000毫秒 = 1分钟
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScrollToTop = () => {
|
||||||
|
resetHideTimer()
|
||||||
|
scrollToTop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScrollToBottom = () => {
|
||||||
|
resetHideTimer()
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
const handleNextMessage = () => {
|
const handleNextMessage = () => {
|
||||||
resetHideTimer()
|
resetHideTimer()
|
||||||
const userMessages = findUserMessages()
|
const userMessages = findUserMessages()
|
||||||
@@ -217,6 +245,11 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
|||||||
|
|
||||||
// Throttled mouse move handler to improve performance
|
// Throttled mouse move handler to improve performance
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
// 如果在手动关闭期间,不响应鼠标移动事件
|
||||||
|
if (manuallyClosedUntil && Date.now() < manuallyClosedUntil) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Throttle mouse move to every 50ms for performance
|
// Throttle mouse move to every 50ms for performance
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (now - lastMoveTime.current < 50) return
|
if (now - lastMoveTime.current < 50) return
|
||||||
@@ -270,22 +303,36 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
|||||||
isNearButtons,
|
isNearButtons,
|
||||||
handleMouseEnter,
|
handleMouseEnter,
|
||||||
handleMouseLeave,
|
handleMouseLeave,
|
||||||
right,
|
showRightTopics,
|
||||||
showRightTopics
|
manuallyClosedUntil
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NavigationContainer
|
<NavigationContainer $isVisible={isVisible} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||||
$isVisible={isVisible}
|
|
||||||
$right={right}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}>
|
|
||||||
<ButtonGroup>
|
<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">
|
<Tooltip title={t('chat.navigation.prev')} placement="left">
|
||||||
<NavigationButton
|
<NavigationButton
|
||||||
type="text"
|
type="text"
|
||||||
icon={<UpOutlined />}
|
icon={<ArrowUpOutlined />}
|
||||||
onClick={handlePrevMessage}
|
onClick={handlePrevMessage}
|
||||||
aria-label={t('chat.navigation.prev')}
|
aria-label={t('chat.navigation.prev')}
|
||||||
/>
|
/>
|
||||||
@@ -294,12 +341,21 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
|||||||
<Tooltip title={t('chat.navigation.next')} placement="left">
|
<Tooltip title={t('chat.navigation.next')} placement="left">
|
||||||
<NavigationButton
|
<NavigationButton
|
||||||
type="text"
|
type="text"
|
||||||
icon={<DownOutlined />}
|
icon={<ArrowDownOutlined />}
|
||||||
onClick={handleNextMessage}
|
onClick={handleNextMessage}
|
||||||
aria-label={t('chat.navigation.next')}
|
aria-label={t('chat.navigation.next')}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Divider />
|
<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">
|
<Tooltip title={t('chat.navigation.history')} placement="left">
|
||||||
<NavigationButton
|
<NavigationButton
|
||||||
type="text"
|
type="text"
|
||||||
@@ -332,12 +388,11 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
|||||||
|
|
||||||
interface NavigationContainerProps {
|
interface NavigationContainerProps {
|
||||||
$isVisible: boolean
|
$isVisible: boolean
|
||||||
$right: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const NavigationContainer = styled.div<NavigationContainerProps>`
|
const NavigationContainer = styled.div<NavigationContainerProps>`
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: ${(props) => props.$right};
|
right: 16px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%) translateX(${(props) => (props.$isVisible ? 0 : '100%')});
|
transform: translateY(-50%) translateX(${(props) => (props.$isVisible ? 0 : '100%')});
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ const MessageItem: FC<Props> = ({
|
|||||||
<MessageHeader message={message} assistant={assistant} model={model} key={getModelUniqId(model)} />
|
<MessageHeader message={message} assistant={assistant} model={model} key={getModelUniqId(model)} />
|
||||||
<MessageContentContainer
|
<MessageContentContainer
|
||||||
className="message-content-container"
|
className="message-content-container"
|
||||||
style={{ fontFamily, fontSize, background: messageBackground }}>
|
style={{ fontFamily, fontSize, background: messageBackground, overflowY: 'visible' }}>
|
||||||
<MessageErrorBoundary>
|
<MessageErrorBoundary>
|
||||||
<MessageContent message={message} model={model} />
|
<MessageContent message={message} model={model} />
|
||||||
</MessageErrorBoundary>
|
</MessageErrorBoundary>
|
||||||
|
|||||||
@@ -33,9 +33,6 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
|
|||||||
const messageItemsRef = useRef<Map<string, HTMLDivElement>>(new Map())
|
const messageItemsRef = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const [mouseY, setMouseY] = useState<number | null>(null)
|
const [mouseY, setMouseY] = useState<number | null>(null)
|
||||||
const { topicPosition, showTopics } = useSettings()
|
|
||||||
const showRightTopics = topicPosition === 'right' && showTopics
|
|
||||||
const right = showRightTopics ? 'calc(var(--topic-list-width) + 15px)' : '15px'
|
|
||||||
|
|
||||||
const [listOffsetY, setListOffsetY] = useState(0)
|
const [listOffsetY, setListOffsetY] = useState(0)
|
||||||
const [containerHeight, setContainerHeight] = useState<number | null>(null)
|
const [containerHeight, setContainerHeight] = useState<number | null>(null)
|
||||||
@@ -160,7 +157,6 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
|
|||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
$right={right}
|
|
||||||
$height={containerHeight}>
|
$height={containerHeight}>
|
||||||
<MessagesList ref={messagesListRef} style={{ transform: `translateY(${listOffsetY}px)` }}>
|
<MessagesList ref={messagesListRef} style={{ transform: `translateY(${listOffsetY}px)` }}>
|
||||||
{messages.map((message, index) => {
|
{messages.map((message, index) => {
|
||||||
@@ -226,11 +222,11 @@ const MessageItemContainer = styled.div`
|
|||||||
transform-origin: right center;
|
transform-origin: right center;
|
||||||
`
|
`
|
||||||
|
|
||||||
const MessageLineContainer = styled.div<{ $right: string; $height: number | null }>`
|
const MessageLineContainer = styled.div<{ $height: number | null }>`
|
||||||
width: 14px;
|
width: 14px;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: ${(props) => (props.$height ? `calc(${props.$height / 2}px + var(--status-bar-height))` : '50%')};
|
top: ${(props) => (props.$height ? `calc(${props.$height / 2}px + var(--status-bar-height))` : '50%')};
|
||||||
right: ${(props) => props.$right};
|
right: 13px;
|
||||||
max-height: ${(props) => (props.$height ? `${props.$height}px` : 'calc(100% - var(--status-bar-height) * 2)')};
|
max-height: ${(props) => (props.$height ? `${props.$height}px` : 'calc(100% - var(--status-bar-height) * 2)')};
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { MultiModelMessageStyle } from '@renderer/store/settings'
|
|||||||
import type { Message, Topic } from '@renderer/types'
|
import type { Message, Topic } from '@renderer/types'
|
||||||
import { classNames } from '@renderer/utils'
|
import { classNames } from '@renderer/utils'
|
||||||
import { Popover } from 'antd'
|
import { Popover } from 'antd'
|
||||||
import { memo, useCallback, useEffect, useState } from 'react'
|
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import styled, { css } from 'styled-components'
|
import styled, { css } from 'styled-components'
|
||||||
|
|
||||||
import MessageGroupMenuBar from './MessageGroupMenuBar'
|
import MessageGroupMenuBar from './MessageGroupMenuBar'
|
||||||
@@ -27,14 +27,53 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const messageLength = messages.length
|
const messageLength = messages.length
|
||||||
|
const prevMessageLengthRef = useRef(messageLength)
|
||||||
const [selectedIndex, setSelectedIndex] = useState(messageLength - 1)
|
const [selectedIndex, setSelectedIndex] = useState(messageLength - 1)
|
||||||
|
|
||||||
|
const getSelectedMessageId = useCallback(() => {
|
||||||
|
const selectedMessage = messages.find((message) => message.foldSelected)
|
||||||
|
if (selectedMessage) {
|
||||||
|
return selectedMessage.id
|
||||||
|
}
|
||||||
|
return messages[0]?.id
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
const setSelectedMessage = useCallback(
|
||||||
|
(message: Message) => {
|
||||||
|
messages.forEach(async (m) => {
|
||||||
|
await editMessage(m.id, { foldSelected: m.id === message.id })
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const messageElement = document.getElementById(`message-${message.id}`)
|
||||||
|
if (messageElement) {
|
||||||
|
messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
},
|
||||||
|
[editMessage, messages]
|
||||||
|
)
|
||||||
|
|
||||||
const isGrouped = messageLength > 1 && messages.every((m) => m.role === 'assistant')
|
const isGrouped = messageLength > 1 && messages.every((m) => m.role === 'assistant')
|
||||||
const isHorizontal = multiModelMessageStyle === 'horizontal'
|
const isHorizontal = multiModelMessageStyle === 'horizontal'
|
||||||
const isGrid = multiModelMessageStyle === 'grid'
|
const isGrid = multiModelMessageStyle === 'grid'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedIndex(messageLength - 1)
|
if (messageLength > prevMessageLengthRef.current) {
|
||||||
|
setSelectedIndex(messageLength - 1)
|
||||||
|
const lastMessage = messages[messageLength - 1]
|
||||||
|
if (lastMessage) {
|
||||||
|
setSelectedMessage(lastMessage)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const selectedId = getSelectedMessageId()
|
||||||
|
const newIndex = messages.findIndex((msg) => msg.id === selectedId)
|
||||||
|
if (newIndex !== -1) {
|
||||||
|
setSelectedIndex(newIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevMessageLengthRef.current = messageLength
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [messageLength])
|
}, [messageLength])
|
||||||
|
|
||||||
// 添加对流程图节点点击事件的监听
|
// 添加对流程图节点点击事件的监听
|
||||||
@@ -70,22 +109,6 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [messages, selectedIndex, isGrouped, messageLength])
|
}, [messages, selectedIndex, isGrouped, messageLength])
|
||||||
|
|
||||||
const setSelectedMessage = useCallback(
|
|
||||||
(message: Message) => {
|
|
||||||
messages.forEach(async (m) => {
|
|
||||||
await editMessage(m.id, { foldSelected: m.id === message.id })
|
|
||||||
})
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const messageElement = document.getElementById(`message-${message.id}`)
|
|
||||||
if (messageElement) {
|
|
||||||
messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
||||||
}
|
|
||||||
}, 200)
|
|
||||||
},
|
|
||||||
[editMessage, messages]
|
|
||||||
)
|
|
||||||
|
|
||||||
// 添加对LOCATE_MESSAGE事件的监听
|
// 添加对LOCATE_MESSAGE事件的监听
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 为每个消息注册一个定位事件监听器
|
// 为每个消息注册一个定位事件监听器
|
||||||
@@ -146,7 +169,7 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
|||||||
className={classNames({
|
className={classNames({
|
||||||
'group-message-wrapper': message.role === 'assistant' && isHorizontal && isGrouped,
|
'group-message-wrapper': message.role === 'assistant' && isHorizontal && isGrouped,
|
||||||
[multiModelMessageStyle]: isGrouped,
|
[multiModelMessageStyle]: isGrouped,
|
||||||
selected: 'foldSelected' in message ? message.foldSelected : index === 0
|
selected: message.id === getSelectedMessageId()
|
||||||
})}>
|
})}>
|
||||||
<MessageStream {...messageProps} />
|
<MessageStream {...messageProps} />
|
||||||
</MessageWrapper>
|
</MessageWrapper>
|
||||||
@@ -183,7 +206,8 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
|||||||
selectedIndex,
|
selectedIndex,
|
||||||
topic,
|
topic,
|
||||||
hidePresetMessages,
|
hidePresetMessages,
|
||||||
gridPopoverTrigger
|
gridPopoverTrigger,
|
||||||
|
getSelectedMessageId
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -210,6 +234,7 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
|
selectMessageId={getSelectedMessageId()}
|
||||||
setSelectedMessage={setSelectedMessage}
|
setSelectedMessage={setSelectedMessage}
|
||||||
topic={topic}
|
topic={topic}
|
||||||
/>
|
/>
|
||||||
@@ -237,7 +262,6 @@ const GridContainer = styled.div<{ $count: number; $layout: MultiModelMessageSty
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: ${({ $layout }) => ($layout === 'horizontal' ? '16px' : '0')};
|
gap: ${({ $layout }) => ($layout === 'horizontal' ? '16px' : '0')};
|
||||||
overflow-y: auto;
|
|
||||||
grid-template-columns: repeat(
|
grid-template-columns: repeat(
|
||||||
${({ $layout, $count }) => (['fold', 'vertical'].includes($layout) ? 1 : $count)},
|
${({ $layout, $count }) => (['fold', 'vertical'].includes($layout) ? 1 : $count)},
|
||||||
minmax(550px, 1fr)
|
minmax(550px, 1fr)
|
||||||
@@ -261,6 +285,13 @@ const GridContainer = styled.div<{ $count: number; $layout: MultiModelMessageSty
|
|||||||
grid-template-rows: auto;
|
grid-template-rows: auto;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
`}
|
`}
|
||||||
|
${({ $layout }) => {
|
||||||
|
return $layout === 'horizontal'
|
||||||
|
? css`
|
||||||
|
overflow-y: auto;
|
||||||
|
`
|
||||||
|
: 'overflow-y: visible;'
|
||||||
|
}}
|
||||||
`
|
`
|
||||||
|
|
||||||
interface MessageWrapperProps {
|
interface MessageWrapperProps {
|
||||||
@@ -299,6 +330,9 @@ const MessageWrapper = styled(Scrollbar)<MessageWrapperProps>`
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
${({ $layout, $isInPopover, $isGrouped }) => {
|
${({ $layout, $isInPopover, $isGrouped }) => {
|
||||||
|
// 如果布局是grid,并且是组消息,则设置最大高度和溢出行为(卡片不可滚动,点击展开后可滚动)
|
||||||
|
// 如果布局是horizontal,则设置溢出行为(卡片可滚动)
|
||||||
|
// 如果布局是fold、vertical,高度不限制,与正常消息流布局一致,则设置卡片不可滚动(visible)
|
||||||
return $layout === 'grid' && $isGrouped
|
return $layout === 'grid' && $isGrouped
|
||||||
? css`
|
? css`
|
||||||
max-height: ${$isInPopover ? '50vh' : '300px'};
|
max-height: ${$isInPopover ? '50vh' : '300px'};
|
||||||
@@ -309,7 +343,7 @@ const MessageWrapper = styled(Scrollbar)<MessageWrapperProps>`
|
|||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
`
|
`
|
||||||
: css`
|
: css`
|
||||||
overflow-y: auto;
|
overflow-y: ${$layout === 'horizontal' ? 'auto' : 'visible'};
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
`
|
`
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface Props {
|
|||||||
multiModelMessageStyle: MultiModelMessageStyle
|
multiModelMessageStyle: MultiModelMessageStyle
|
||||||
setMultiModelMessageStyle: (style: MultiModelMessageStyle) => void
|
setMultiModelMessageStyle: (style: MultiModelMessageStyle) => void
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
|
selectMessageId: string
|
||||||
setSelectedMessage: (message: Message) => void
|
setSelectedMessage: (message: Message) => void
|
||||||
topic: Topic
|
topic: Topic
|
||||||
}
|
}
|
||||||
@@ -29,6 +30,7 @@ const MessageGroupMenuBar: FC<Props> = ({
|
|||||||
multiModelMessageStyle,
|
multiModelMessageStyle,
|
||||||
setMultiModelMessageStyle,
|
setMultiModelMessageStyle,
|
||||||
messages,
|
messages,
|
||||||
|
selectMessageId,
|
||||||
setSelectedMessage,
|
setSelectedMessage,
|
||||||
topic
|
topic
|
||||||
}) => {
|
}) => {
|
||||||
@@ -75,7 +77,11 @@ const MessageGroupMenuBar: FC<Props> = ({
|
|||||||
))}
|
))}
|
||||||
</LayoutContainer>
|
</LayoutContainer>
|
||||||
{multiModelMessageStyle === 'fold' && (
|
{multiModelMessageStyle === 'fold' && (
|
||||||
<MessageGroupModelList messages={messages} setSelectedMessage={setSelectedMessage} />
|
<MessageGroupModelList
|
||||||
|
messages={messages}
|
||||||
|
selectMessageId={selectMessageId}
|
||||||
|
setSelectedMessage={setSelectedMessage}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{multiModelMessageStyle === 'grid' && <MessageGroupSettings />}
|
{multiModelMessageStyle === 'grid' && <MessageGroupSettings />}
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ import styled from 'styled-components'
|
|||||||
|
|
||||||
interface MessageGroupModelListProps {
|
interface MessageGroupModelListProps {
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
|
selectMessageId: string
|
||||||
setSelectedMessage: (message: Message) => void
|
setSelectedMessage: (message: Message) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type DisplayMode = 'compact' | 'expanded'
|
type DisplayMode = 'compact' | 'expanded'
|
||||||
|
|
||||||
const MessageGroupModelList: FC<MessageGroupModelListProps> = ({ messages, setSelectedMessage }) => {
|
const MessageGroupModelList: FC<MessageGroupModelListProps> = ({ messages, selectMessageId, setSelectedMessage }) => {
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { foldDisplayMode } = useSettings()
|
const { foldDisplayMode } = useSettings()
|
||||||
@@ -47,7 +48,7 @@ const MessageGroupModelList: FC<MessageGroupModelListProps> = ({ messages, setSe
|
|||||||
<Tooltip key={index} title={message.model?.name} placement="top" mouseEnterDelay={0.2}>
|
<Tooltip key={index} title={message.model?.name} placement="top" mouseEnterDelay={0.2}>
|
||||||
<AvatarWrapper
|
<AvatarWrapper
|
||||||
className="avatar-wrapper"
|
className="avatar-wrapper"
|
||||||
isSelected={'foldSelected' in message ? message.foldSelected! : index === 0}
|
isSelected={message.id === selectMessageId}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedMessage(message)
|
setSelectedMessage(message)
|
||||||
}}>
|
}}>
|
||||||
@@ -59,7 +60,7 @@ const MessageGroupModelList: FC<MessageGroupModelListProps> = ({ messages, setSe
|
|||||||
) : (
|
) : (
|
||||||
/* Expanded style display */
|
/* Expanded style display */
|
||||||
<Segmented
|
<Segmented
|
||||||
value={messages.find((message) => message.foldSelected)?.id || messages[0].id}
|
value={selectMessageId}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
const message = messages.find((message) => message.id === value) as Message
|
const message = messages.find((message) => message.id === value) as Message
|
||||||
setSelectedMessage(message)
|
setSelectedMessage(message)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import UserPopup from '@renderer/components/Popups/UserPopup'
|
import UserPopup from '@renderer/components/Popups/UserPopup'
|
||||||
import { APP_NAME, AppLogo, isLocalAi } from '@renderer/config/env'
|
import { APP_NAME, AppLogo, isLocalAi } from '@renderer/config/env'
|
||||||
import { startMinAppById } from '@renderer/config/minapps'
|
|
||||||
import { getModelLogo } from '@renderer/config/models'
|
import { getModelLogo } from '@renderer/config/models'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import useAvatar from '@renderer/hooks/useAvatar'
|
import useAvatar from '@renderer/hooks/useAvatar'
|
||||||
|
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||||
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
|
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
|
||||||
import { getMessageModelId } from '@renderer/services/MessagesService'
|
import { getMessageModelId } from '@renderer/services/MessagesService'
|
||||||
import { getModelName } from '@renderer/services/ModelService'
|
import { getModelName } from '@renderer/services/ModelService'
|
||||||
@@ -32,6 +32,7 @@ const MessageHeader: FC<Props> = memo(({ assistant, model, message }) => {
|
|||||||
const { userName, sidebarIcons } = useSettings()
|
const { userName, sidebarIcons } = useSettings()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { isBubbleStyle } = useMessageStyle()
|
const { isBubbleStyle } = useMessageStyle()
|
||||||
|
const { openMinappById } = useMinappPopup()
|
||||||
|
|
||||||
const avatarSource = useMemo(() => getAvatarSource(isLocalAi, getMessageModelId(message)), [message])
|
const avatarSource = useMemo(() => getAvatarSource(isLocalAi, getMessageModelId(message)), [message])
|
||||||
|
|
||||||
@@ -54,7 +55,9 @@ const MessageHeader: FC<Props> = memo(({ assistant, model, message }) => {
|
|||||||
const username = useMemo(() => removeLeadingEmoji(getUserName()), [getUserName])
|
const username = useMemo(() => removeLeadingEmoji(getUserName()), [getUserName])
|
||||||
|
|
||||||
const showMiniApp = useCallback(() => {
|
const showMiniApp = useCallback(() => {
|
||||||
showMinappIcon && model?.provider && startMinAppById(model.provider)
|
showMinappIcon && model?.provider && openMinappById(model.provider)
|
||||||
|
// because don't need openMinappById to be a dependency
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [model?.provider, showMinappIcon])
|
}, [model?.provider, showMinappIcon])
|
||||||
|
|
||||||
const avatarStyle: CSSProperties | undefined = isBubbleStyle
|
const avatarStyle: CSSProperties | undefined = isBubbleStyle
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
key: 'image',
|
key: 'image',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
const imageData = await captureScrollableDivAsDataURL(messageContainerRef)
|
const imageData = await captureScrollableDivAsDataURL(messageContainerRef)
|
||||||
const title = getMessageTitle(message)
|
const title = await getMessageTitle(message)
|
||||||
if (title && imageData) {
|
if (title && imageData) {
|
||||||
window.api.file.saveImage(title, imageData)
|
window.api.file.saveImage(title, imageData)
|
||||||
}
|
}
|
||||||
@@ -211,14 +211,15 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
key: 'word',
|
key: 'word',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
const markdown = messageToMarkdown(message)
|
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'),
|
label: t('chat.topics.export.notion'),
|
||||||
key: 'notion',
|
key: 'notion',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
const title = getMessageTitle(message)
|
const title = await getMessageTitle(message)
|
||||||
const markdown = messageToMarkdown(message)
|
const markdown = messageToMarkdown(message)
|
||||||
exportMarkdownToNotion(title, markdown)
|
exportMarkdownToNotion(title, markdown)
|
||||||
}
|
}
|
||||||
@@ -227,7 +228,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
label: t('chat.topics.export.yuque'),
|
label: t('chat.topics.export.yuque'),
|
||||||
key: 'yuque',
|
key: 'yuque',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
const title = getMessageTitle(message)
|
const title = await getMessageTitle(message)
|
||||||
const markdown = messageToMarkdown(message)
|
const markdown = messageToMarkdown(message)
|
||||||
exportMarkdownToYuque(title, markdown)
|
exportMarkdownToYuque(title, markdown)
|
||||||
}
|
}
|
||||||
@@ -245,7 +246,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
label: t('chat.topics.export.joplin'),
|
label: t('chat.topics.export.joplin'),
|
||||||
key: 'joplin',
|
key: 'joplin',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
const title = getMessageTitle(message)
|
const title = await getMessageTitle(message)
|
||||||
const markdown = messageToMarkdown(message)
|
const markdown = messageToMarkdown(message)
|
||||||
exportMarkdownToJoplin(title, markdown)
|
exportMarkdownToJoplin(title, markdown)
|
||||||
}
|
}
|
||||||
@@ -254,7 +255,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
label: t('chat.topics.export.siyuan'),
|
label: t('chat.topics.export.siyuan'),
|
||||||
key: 'siyuan',
|
key: 'siyuan',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
const title = getMessageTitle(message)
|
const title = await getMessageTitle(message)
|
||||||
const markdown = messageToMarkdown(message)
|
const markdown = messageToMarkdown(message)
|
||||||
exportMarkdownToSiyuan(title, markdown)
|
exportMarkdownToSiyuan(title, markdown)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,7 +256,8 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
|
|||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
loader={null}
|
loader={null}
|
||||||
scrollableTarget="messages"
|
scrollableTarget="messages"
|
||||||
inverse>
|
inverse
|
||||||
|
style={{ overflow: 'visible' }}>
|
||||||
<ScrollContainer>
|
<ScrollContainer>
|
||||||
<LoaderContainer $loading={isLoadingMore}>
|
<LoaderContainer $loading={isLoadingMore}>
|
||||||
<BeatLoader size={8} color="var(--color-text-2)" />
|
<BeatLoader size={8} color="var(--color-text-2)" />
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { hasTopicPendingRequests } from '@renderer/utils/queue'
|
|||||||
import { Dropdown } from 'antd'
|
import { Dropdown } from 'antd'
|
||||||
import { ItemType } from 'antd/es/menu/interface'
|
import { ItemType } from 'antd/es/menu/interface'
|
||||||
import { omit } from 'lodash'
|
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 { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
@@ -84,6 +84,9 @@ const AssistantItem: FC<AssistantItemProps> = ({ assistant, isActive, onSwitch,
|
|||||||
const agent = omit(assistant, ['model', 'emoji'])
|
const agent = omit(assistant, ['model', 'emoji'])
|
||||||
agent.id = uuid()
|
agent.id = uuid()
|
||||||
agent.type = 'agent'
|
agent.type = 'agent'
|
||||||
|
if (assistant.promptVariables) {
|
||||||
|
agent.promptVariables = [...assistant.promptVariables]
|
||||||
|
}
|
||||||
addAgent(agent)
|
addAgent(agent)
|
||||||
window.message.success({
|
window.message.success({
|
||||||
content: t('assistants.save.success'),
|
content: t('assistants.save.success'),
|
||||||
@@ -114,11 +117,16 @@ const AssistantItem: FC<AssistantItemProps> = ({ assistant, isActive, onSwitch,
|
|||||||
const handleSwitch = useCallback(async () => {
|
const handleSwitch = useCallback(async () => {
|
||||||
await modelGenerating()
|
await modelGenerating()
|
||||||
|
|
||||||
if (topicPosition === 'left' && clickAssistantToShowTopic) {
|
if (clickAssistantToShowTopic) {
|
||||||
EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)
|
if (topicPosition === 'left') {
|
||||||
|
EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)
|
||||||
|
}
|
||||||
|
onSwitch(assistant)
|
||||||
|
} else {
|
||||||
|
startTransition(() => {
|
||||||
|
onSwitch(assistant)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onSwitch(assistant)
|
|
||||||
}, [clickAssistantToShowTopic, onSwitch, assistant, topicPosition])
|
}, [clickAssistantToShowTopic, onSwitch, assistant, topicPosition])
|
||||||
|
|
||||||
const assistantName = assistant.name || t('chat.default.name')
|
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 { Dropdown, MenuProps, Tooltip } from 'antd'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { findIndex } from 'lodash'
|
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 { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
@@ -146,7 +146,9 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
|||||||
const onSwitchTopic = useCallback(
|
const onSwitchTopic = useCallback(
|
||||||
async (topic: Topic) => {
|
async (topic: Topic) => {
|
||||||
// await modelGenerating()
|
// await modelGenerating()
|
||||||
setActiveTopic(topic)
|
startTransition(() => {
|
||||||
|
setActiveTopic(topic)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
[setActiveTopic]
|
[setActiveTopic]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,16 +2,13 @@ import {
|
|||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
FileTextOutlined,
|
|
||||||
FolderOutlined,
|
|
||||||
GlobalOutlined,
|
|
||||||
LinkOutlined,
|
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
RedoOutlined,
|
RedoOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
SettingOutlined
|
SettingOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import Ellipsis from '@renderer/components/Ellipsis'
|
import Ellipsis from '@renderer/components/Ellipsis'
|
||||||
|
import { HStack } from '@renderer/components/Layout'
|
||||||
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
||||||
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
|
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
|
||||||
import Scrollbar from '@renderer/components/Scrollbar'
|
import Scrollbar from '@renderer/components/Scrollbar'
|
||||||
@@ -19,24 +16,29 @@ import { useKnowledge } from '@renderer/hooks/useKnowledge'
|
|||||||
import FileManager from '@renderer/services/FileManager'
|
import FileManager from '@renderer/services/FileManager'
|
||||||
import { getProviderName } from '@renderer/services/ProviderService'
|
import { getProviderName } from '@renderer/services/ProviderService'
|
||||||
import { FileType, FileTypes, KnowledgeBase, KnowledgeItem } from '@renderer/types'
|
import { FileType, FileTypes, KnowledgeBase, KnowledgeItem } from '@renderer/types'
|
||||||
|
import { formatFileSize } from '@renderer/utils'
|
||||||
import { bookExts, documentExts, textExts, thirdPartyApplicationExts } from '@shared/config/constant'
|
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 { FC } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
import CustomCollapse from '../../components/CustomCollapse'
|
||||||
|
import FileItem from '../files/FileItem'
|
||||||
import KnowledgeSearchPopup from './components/KnowledgeSearchPopup'
|
import KnowledgeSearchPopup from './components/KnowledgeSearchPopup'
|
||||||
import KnowledgeSettingsPopup from './components/KnowledgeSettingsPopup'
|
import KnowledgeSettingsPopup from './components/KnowledgeSettingsPopup'
|
||||||
import StatusIcon from './components/StatusIcon'
|
import StatusIcon from './components/StatusIcon'
|
||||||
|
|
||||||
const { Dragger } = Upload
|
const { Dragger } = Upload
|
||||||
const { Title } = Typography
|
|
||||||
|
|
||||||
interface KnowledgeContentProps {
|
interface KnowledgeContentProps {
|
||||||
selectedBase: KnowledgeBase
|
selectedBase: KnowledgeBase
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileTypes = [...bookExts, ...thirdPartyApplicationExts, ...documentExts, ...textExts]
|
const fileTypes = [...bookExts, ...thirdPartyApplicationExts, ...documentExts, ...textExts]
|
||||||
|
|
||||||
const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
@@ -234,13 +236,21 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
|||||||
{!providerName && (
|
{!providerName && (
|
||||||
<Alert message={t('knowledge.no_provider')} type="error" style={{ marginBottom: 20 }} showIcon />
|
<Alert message={t('knowledge.no_provider')} type="error" style={{ marginBottom: 20 }} showIcon />
|
||||||
)}
|
)}
|
||||||
<FileSection>
|
|
||||||
<TitleWrapper>
|
<CustomCollapse
|
||||||
<Title level={5}>{t('files.title')}</Title>
|
label={<CollapseLabel label={t('files.title')} count={fileItems.length} />}
|
||||||
<Button icon={<PlusOutlined />} onClick={handleAddFile} disabled={disabled}>
|
extra={
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleAddFile()
|
||||||
|
}}
|
||||||
|
disabled={disabled}>
|
||||||
{t('knowledge.add_file')}
|
{t('knowledge.add_file')}
|
||||||
</Button>
|
</Button>
|
||||||
</TitleWrapper>
|
}>
|
||||||
<Dragger
|
<Dragger
|
||||||
showUploadList={false}
|
showUploadList={false}
|
||||||
customRequest={({ file }) => handleDrop([file as File])}
|
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...' })}
|
{t('knowledge.file_hint', { file_types: 'TXT, MD, HTML, PDF, DOCX, PPTX, XLSX, EPUB...' })}
|
||||||
</p>
|
</p>
|
||||||
</Dragger>
|
</Dragger>
|
||||||
</FileSection>
|
|
||||||
|
|
||||||
<FileListSection>
|
<FlexColumn>
|
||||||
{fileItems.reverse().map((item) => {
|
{fileItems.length === 0 ? (
|
||||||
const file = item.content as FileType
|
<EmptyView />
|
||||||
return (
|
) : (
|
||||||
<ItemCard key={item.id}>
|
<VirtualList
|
||||||
<ItemContent>
|
data={fileItems.reverse()}
|
||||||
<ItemInfo>
|
height={fileItems.length > 5 ? 400 : fileItems.length * 80}
|
||||||
<FileIcon />
|
itemHeight={80}
|
||||||
<ClickableSpan onClick={() => window.api.file.openPath(file.path)}>
|
itemKey="id"
|
||||||
<Ellipsis>
|
styles={{
|
||||||
<Tooltip title={file.origin_name}>{file.origin_name}</Tooltip>
|
verticalScrollBar: {
|
||||||
</Ellipsis>
|
width: 6
|
||||||
</ClickableSpan>
|
},
|
||||||
</ItemInfo>
|
verticalScrollBarThumb: {
|
||||||
<FlexAlignCenter>
|
background: 'var(--color-scrollbar-thumb)'
|
||||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
}
|
||||||
<StatusIconWrapper>
|
}}>
|
||||||
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} type="file" />
|
{(item) => {
|
||||||
</StatusIconWrapper>
|
const file = item.content as FileType
|
||||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
return (
|
||||||
</FlexAlignCenter>
|
<div style={{ height: '80px', paddingTop: '12px' }}>
|
||||||
</ItemContent>
|
<FileItem
|
||||||
</ItemCard>
|
key={item.id}
|
||||||
)
|
fileInfo={{
|
||||||
})}
|
name: (
|
||||||
</FileListSection>
|
<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>
|
<CustomCollapse
|
||||||
<TitleWrapper>
|
label={<CollapseLabel label={t('knowledge.directories')} count={directoryItems.length} />}
|
||||||
<Title level={5}>{t('knowledge.directories')}</Title>
|
extra={
|
||||||
<Button icon={<PlusOutlined />} onClick={handleAddDirectory} disabled={disabled}>
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleAddDirectory()
|
||||||
|
}}
|
||||||
|
disabled={disabled}>
|
||||||
{t('knowledge.add_directory')}
|
{t('knowledge.add_directory')}
|
||||||
</Button>
|
</Button>
|
||||||
</TitleWrapper>
|
}>
|
||||||
<FlexColumn>
|
<FlexColumn>
|
||||||
|
{directoryItems.length === 0 && <EmptyView />}
|
||||||
{directoryItems.reverse().map((item) => (
|
{directoryItems.reverse().map((item) => (
|
||||||
<ItemCard key={item.id}>
|
<FileItem
|
||||||
<ItemContent>
|
key={item.id}
|
||||||
<ItemInfo>
|
fileInfo={{
|
||||||
<FolderOutlined />
|
name: (
|
||||||
<ClickableSpan onClick={() => window.api.file.openPath(item.content as string)}>
|
<ClickableSpan onClick={() => window.api.file.openPath(item.content as string)}>
|
||||||
<Ellipsis>
|
<Ellipsis>
|
||||||
<Tooltip title={item.content as string}>{item.content as string}</Tooltip>
|
<Tooltip title={item.content as string}>{item.content as string}</Tooltip>
|
||||||
</Ellipsis>
|
</Ellipsis>
|
||||||
</ClickableSpan>
|
</ClickableSpan>
|
||||||
</ItemInfo>
|
),
|
||||||
<FlexAlignCenter>
|
ext: '.folder',
|
||||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
extra: `${dayjs(item.created_at).format('MM-DD HH:mm')}`,
|
||||||
<StatusIconWrapper>
|
actions: (
|
||||||
<StatusIcon
|
<FlexAlignCenter>
|
||||||
sourceId={item.id}
|
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||||
base={base}
|
<StatusIconWrapper>
|
||||||
getProcessingStatus={getProcessingStatus}
|
<StatusIcon
|
||||||
getProcessingPercent={getProgressingPercentForItem}
|
sourceId={item.id}
|
||||||
type="directory"
|
base={base}
|
||||||
/>
|
getProcessingStatus={getProcessingStatus}
|
||||||
</StatusIconWrapper>
|
getProcessingPercent={getProgressingPercentForItem}
|
||||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
type="directory"
|
||||||
</FlexAlignCenter>
|
/>
|
||||||
</ItemContent>
|
</StatusIconWrapper>
|
||||||
</ItemCard>
|
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||||
|
</FlexAlignCenter>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</FlexColumn>
|
</FlexColumn>
|
||||||
</ContentSection>
|
</CustomCollapse>
|
||||||
|
|
||||||
<ContentSection>
|
<CustomCollapse
|
||||||
<TitleWrapper>
|
label={<CollapseLabel label={t('knowledge.urls')} count={urlItems.length} />}
|
||||||
<Title level={5}>{t('knowledge.urls')}</Title>
|
extra={
|
||||||
<Button icon={<PlusOutlined />} onClick={handleAddUrl} disabled={disabled}>
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleAddUrl()
|
||||||
|
}}
|
||||||
|
disabled={disabled}>
|
||||||
{t('knowledge.add_url')}
|
{t('knowledge.add_url')}
|
||||||
</Button>
|
</Button>
|
||||||
</TitleWrapper>
|
}>
|
||||||
<FlexColumn>
|
<FlexColumn>
|
||||||
|
{urlItems.length === 0 && <EmptyView />}
|
||||||
{urlItems.reverse().map((item) => (
|
{urlItems.reverse().map((item) => (
|
||||||
<ItemCard key={item.id}>
|
<FileItem
|
||||||
<ItemContent>
|
key={item.id}
|
||||||
<ItemInfo>
|
fileInfo={{
|
||||||
<LinkOutlined />
|
name: (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
menu={{
|
menu={{
|
||||||
items: [
|
items: [
|
||||||
@@ -363,33 +424,45 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ClickableSpan>
|
</ClickableSpan>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</ItemInfo>
|
),
|
||||||
<FlexAlignCenter>
|
ext: '.url',
|
||||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
extra: `${dayjs(item.created_at).format('MM-DD HH:mm')}`,
|
||||||
<StatusIconWrapper>
|
actions: (
|
||||||
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} type="url" />
|
<FlexAlignCenter>
|
||||||
</StatusIconWrapper>
|
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
<StatusIconWrapper>
|
||||||
</FlexAlignCenter>
|
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} type="url" />
|
||||||
</ItemContent>
|
</StatusIconWrapper>
|
||||||
</ItemCard>
|
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||||
|
</FlexAlignCenter>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</FlexColumn>
|
</FlexColumn>
|
||||||
</ContentSection>
|
</CustomCollapse>
|
||||||
|
|
||||||
<ContentSection>
|
<CustomCollapse
|
||||||
<TitleWrapper>
|
label={<CollapseLabel label={t('knowledge.sitemaps')} count={sitemapItems.length} />}
|
||||||
<Title level={5}>{t('knowledge.sitemaps')}</Title>
|
extra={
|
||||||
<Button icon={<PlusOutlined />} onClick={handleAddSitemap} disabled={disabled}>
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleAddSitemap()
|
||||||
|
}}
|
||||||
|
disabled={disabled}>
|
||||||
{t('knowledge.add_sitemap')}
|
{t('knowledge.add_sitemap')}
|
||||||
</Button>
|
</Button>
|
||||||
</TitleWrapper>
|
}>
|
||||||
<FlexColumn>
|
<FlexColumn>
|
||||||
|
{sitemapItems.length === 0 && <EmptyView />}
|
||||||
{sitemapItems.reverse().map((item) => (
|
{sitemapItems.reverse().map((item) => (
|
||||||
<ItemCard key={item.id}>
|
<FileItem
|
||||||
<ItemContent>
|
key={item.id}
|
||||||
<ItemInfo>
|
fileInfo={{
|
||||||
<GlobalOutlined />
|
name: (
|
||||||
<ClickableSpan>
|
<ClickableSpan>
|
||||||
<Tooltip title={item.content as string}>
|
<Tooltip title={item.content as string}>
|
||||||
<Ellipsis>
|
<Ellipsis>
|
||||||
@@ -399,53 +472,71 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
|||||||
</Ellipsis>
|
</Ellipsis>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ClickableSpan>
|
</ClickableSpan>
|
||||||
</ItemInfo>
|
),
|
||||||
<FlexAlignCenter>
|
ext: '.sitemap',
|
||||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
extra: `${dayjs(item.created_at).format('MM-DD HH:mm')}`,
|
||||||
<StatusIconWrapper>
|
actions: (
|
||||||
<StatusIcon
|
<FlexAlignCenter>
|
||||||
sourceId={item.id}
|
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||||
base={base}
|
<StatusIconWrapper>
|
||||||
getProcessingStatus={getProcessingStatus}
|
<StatusIcon
|
||||||
type="sitemap"
|
sourceId={item.id}
|
||||||
/>
|
base={base}
|
||||||
</StatusIconWrapper>
|
getProcessingStatus={getProcessingStatus}
|
||||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
type="sitemap"
|
||||||
</FlexAlignCenter>
|
/>
|
||||||
</ItemContent>
|
</StatusIconWrapper>
|
||||||
</ItemCard>
|
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||||
|
</FlexAlignCenter>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</FlexColumn>
|
</FlexColumn>
|
||||||
</ContentSection>
|
</CustomCollapse>
|
||||||
|
|
||||||
<ContentSection>
|
<CustomCollapse
|
||||||
<TitleWrapper>
|
label={<CollapseLabel label={t('knowledge.notes')} count={noteItems.length} />}
|
||||||
<Title level={5}>{t('knowledge.notes')}</Title>
|
extra={
|
||||||
<Button icon={<PlusOutlined />} onClick={handleAddNote} disabled={disabled}>
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleAddNote()
|
||||||
|
}}
|
||||||
|
disabled={disabled}>
|
||||||
{t('knowledge.add_note')}
|
{t('knowledge.add_note')}
|
||||||
</Button>
|
</Button>
|
||||||
</TitleWrapper>
|
}>
|
||||||
<FlexColumn>
|
<FlexColumn>
|
||||||
|
{noteItems.length === 0 && <EmptyView />}
|
||||||
{noteItems.reverse().map((note) => (
|
{noteItems.reverse().map((note) => (
|
||||||
<ItemCard key={note.id}>
|
<FileItem
|
||||||
<ItemContent>
|
key={note.id}
|
||||||
<ItemInfo onClick={() => handleEditNote(note)} style={{ cursor: 'pointer' }}>
|
fileInfo={{
|
||||||
<span>{(note.content as string).slice(0, 50)}...</span>
|
name: <span onClick={() => handleEditNote(note)}>{(note.content as string).slice(0, 50)}...</span>,
|
||||||
</ItemInfo>
|
ext: '.txt',
|
||||||
<FlexAlignCenter>
|
extra: `${dayjs(note.created_at).format('MM-DD HH:mm')}`,
|
||||||
<Button type="text" onClick={() => handleEditNote(note)} icon={<EditOutlined />} />
|
actions: (
|
||||||
<StatusIconWrapper>
|
<FlexAlignCenter>
|
||||||
<StatusIcon sourceId={note.id} base={base} getProcessingStatus={getProcessingStatus} type="note" />
|
<Button type="text" onClick={() => handleEditNote(note)} icon={<EditOutlined />} />
|
||||||
</StatusIconWrapper>
|
<StatusIconWrapper>
|
||||||
<Button type="text" danger onClick={() => removeItem(note)} icon={<DeleteOutlined />} />
|
<StatusIcon
|
||||||
</FlexAlignCenter>
|
sourceId={note.id}
|
||||||
</ItemContent>
|
base={base}
|
||||||
</ItemCard>
|
getProcessingStatus={getProcessingStatus}
|
||||||
|
type="note"
|
||||||
|
/>
|
||||||
|
</StatusIconWrapper>
|
||||||
|
<Button type="text" danger onClick={() => removeItem(note)} icon={<DeleteOutlined />} />
|
||||||
|
</FlexAlignCenter>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</FlexColumn>
|
</FlexColumn>
|
||||||
</ContentSection>
|
</CustomCollapse>
|
||||||
|
|
||||||
<Divider style={{ margin: '10px 0' }} />
|
|
||||||
<ModelInfo>
|
<ModelInfo>
|
||||||
<div className="model-header">
|
<div className="model-header">
|
||||||
<label>{t('knowledge.model_info')}</label>
|
<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)`
|
const MainContent = styled(Scrollbar)`
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -498,69 +602,9 @@ const MainContent = styled(Scrollbar)`
|
|||||||
padding-bottom: 50px;
|
padding-bottom: 50px;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
position: relative;
|
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;
|
gap: 16px;
|
||||||
`
|
`
|
||||||
|
|
||||||
const ItemInfo = styled.div`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex: 1;
|
|
||||||
`
|
|
||||||
|
|
||||||
const IndexSection = styled.div`
|
const IndexSection = styled.div`
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -602,10 +646,12 @@ const ModelInfo = styled.div`
|
|||||||
color: var(--color-text-2);
|
color: var(--color-text-2);
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
const FlexColumn = styled.div`
|
const FlexColumn = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
`
|
`
|
||||||
|
|
||||||
const FlexAlignCenter = styled.div`
|
const FlexAlignCenter = styled.div`
|
||||||
@@ -620,10 +666,6 @@ const ClickableSpan = styled.span`
|
|||||||
width: 0;
|
width: 0;
|
||||||
`
|
`
|
||||||
|
|
||||||
const FileIcon = styled(FileTextOutlined)`
|
|
||||||
font-size: 16px;
|
|
||||||
`
|
|
||||||
|
|
||||||
const BottomSpacer = styled.div`
|
const BottomSpacer = styled.div`
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ const PopupContainer: React.FC<Props> = ({ base, resolve }) => {
|
|||||||
<List.Item>
|
<List.Item>
|
||||||
<ResultItem>
|
<ResultItem>
|
||||||
<ScoreTag>Score: {(item.score * 100).toFixed(1)}%</ScoreTag>
|
<ScoreTag>Score: {(item.score * 100).toFixed(1)}%</ScoreTag>
|
||||||
<Paragraph>{highlightText(item.pageContent)}</Paragraph>
|
<Paragraph style={{ userSelect: 'text' }}>{highlightText(item.pageContent)}</Paragraph>
|
||||||
<MetadataContainer>
|
<MetadataContainer>
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
{t('knowledge.source')}:{' '}
|
{t('knowledge.source')}:{' '}
|
||||||
@@ -191,6 +191,7 @@ const MetadataContainer = styled.div`
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
|
user-select: text;
|
||||||
`
|
`
|
||||||
|
|
||||||
const TopViewKey = 'KnowledgeSearchPopup'
|
const TopViewKey = 'KnowledgeSearchPopup'
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { GithubOutlined } from '@ant-design/icons'
|
|||||||
import { FileProtectOutlined, GlobalOutlined, MailOutlined, SoundOutlined } from '@ant-design/icons'
|
import { FileProtectOutlined, GlobalOutlined, MailOutlined, SoundOutlined } from '@ant-design/icons'
|
||||||
import IndicatorLight from '@renderer/components/IndicatorLight'
|
import IndicatorLight from '@renderer/components/IndicatorLight'
|
||||||
import { HStack } from '@renderer/components/Layout'
|
import { HStack } from '@renderer/components/Layout'
|
||||||
import MinApp from '@renderer/components/MinApp'
|
|
||||||
import { APP_NAME, AppLogo } from '@renderer/config/env'
|
import { APP_NAME, AppLogo } from '@renderer/config/env'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
|
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import { useAppDispatch } from '@renderer/store'
|
import { useAppDispatch } from '@renderer/store'
|
||||||
@@ -29,6 +29,7 @@ const AboutSettings: FC = () => {
|
|||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { update } = useRuntime()
|
const { update } = useRuntime()
|
||||||
|
const { openMinapp } = useMinappPopup()
|
||||||
|
|
||||||
const onCheckUpdate = debounce(
|
const onCheckUpdate = debounce(
|
||||||
async () => {
|
async () => {
|
||||||
@@ -70,7 +71,8 @@ const AboutSettings: FC = () => {
|
|||||||
|
|
||||||
const showLicense = async () => {
|
const showLicense = async () => {
|
||||||
const { appPath } = await window.api.getAppInfo()
|
const { appPath } = await window.api.getAppInfo()
|
||||||
MinApp.start({
|
openMinapp({
|
||||||
|
id: 'cherrystudio-license',
|
||||||
name: t('settings.about.license.title'),
|
name: t('settings.about.license.title'),
|
||||||
url: `file://${appPath}/resources/cherry-studio/license.html`,
|
url: `file://${appPath}/resources/cherry-studio/license.html`,
|
||||||
logo: AppLogo
|
logo: AppLogo
|
||||||
@@ -79,7 +81,8 @@ const AboutSettings: FC = () => {
|
|||||||
|
|
||||||
const showReleases = async () => {
|
const showReleases = async () => {
|
||||||
const { appPath } = await window.api.getAppInfo()
|
const { appPath } = await window.api.getAppInfo()
|
||||||
MinApp.start({
|
openMinapp({
|
||||||
|
id: 'cherrystudio-releases',
|
||||||
name: t('settings.about.releases.title'),
|
name: t('settings.about.releases.title'),
|
||||||
url: `file://${appPath}/resources/cherry-studio/releases.html?theme=${theme === ThemeMode.dark ? 'dark' : 'light'}`,
|
url: `file://${appPath}/resources/cherry-studio/releases.html?theme=${theme === ThemeMode.dark ? 'dark' : 'light'}`,
|
||||||
logo: AppLogo
|
logo: AppLogo
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
import 'emoji-picker-element'
|
import 'emoji-picker-element'
|
||||||
|
|
||||||
import { CloseCircleFilled } from '@ant-design/icons'
|
import { CloseCircleFilled, PlusOutlined } from '@ant-design/icons'
|
||||||
import EmojiPicker from '@renderer/components/EmojiPicker'
|
import EmojiPicker from '@renderer/components/EmojiPicker'
|
||||||
import { Box, HStack } from '@renderer/components/Layout'
|
import { Box, HStack } from '@renderer/components/Layout'
|
||||||
|
import VariableList from '@renderer/components/VariableList'
|
||||||
import { estimateTextTokens } from '@renderer/services/TokenService'
|
import { estimateTextTokens } from '@renderer/services/TokenService'
|
||||||
import { Assistant, AssistantSettings } from '@renderer/types'
|
import { Assistant, AssistantSettings, Variable } from '@renderer/types'
|
||||||
import { getLeadingEmoji } from '@renderer/utils'
|
import { getLeadingEmoji } from '@renderer/utils'
|
||||||
import { Button, Input, Popover } from 'antd'
|
import { Button, Input, Popover, Tooltip, Typography } from 'antd'
|
||||||
import TextArea from 'antd/es/input/TextArea'
|
import TextArea from 'antd/es/input/TextArea'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assistant: Assistant
|
assistant: Assistant
|
||||||
@@ -24,6 +26,9 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant,
|
|||||||
const [name, setName] = useState(assistant.name.replace(getLeadingEmoji(assistant.name) || '', '').trim())
|
const [name, setName] = useState(assistant.name.replace(getLeadingEmoji(assistant.name) || '', '').trim())
|
||||||
const [prompt, setPrompt] = useState(assistant.prompt)
|
const [prompt, setPrompt] = useState(assistant.prompt)
|
||||||
const [tokenCount, setTokenCount] = useState(0)
|
const [tokenCount, setTokenCount] = useState(0)
|
||||||
|
const [variables, setVariables] = useState<Variable[]>(assistant.promptVariables || [])
|
||||||
|
const [variableName, setVariableName] = useState('')
|
||||||
|
const [variableValue, setVariableValue] = useState('')
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -35,19 +40,77 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant,
|
|||||||
}, [prompt])
|
}, [prompt])
|
||||||
|
|
||||||
const onUpdate = () => {
|
const onUpdate = () => {
|
||||||
const _assistant = { ...assistant, name: name.trim(), emoji, prompt }
|
const _assistant = {
|
||||||
|
...assistant,
|
||||||
|
name: name.trim(),
|
||||||
|
emoji,
|
||||||
|
prompt,
|
||||||
|
promptVariables: variables
|
||||||
|
}
|
||||||
updateAssistant(_assistant)
|
updateAssistant(_assistant)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEmojiSelect = (selectedEmoji: string) => {
|
const handleEmojiSelect = (selectedEmoji: string) => {
|
||||||
setEmoji(selectedEmoji)
|
setEmoji(selectedEmoji)
|
||||||
const _assistant = { ...assistant, name: name.trim(), emoji: selectedEmoji, prompt }
|
const _assistant = {
|
||||||
|
...assistant,
|
||||||
|
name: name.trim(),
|
||||||
|
emoji: selectedEmoji,
|
||||||
|
prompt,
|
||||||
|
promptVariables: variables
|
||||||
|
}
|
||||||
updateAssistant(_assistant)
|
updateAssistant(_assistant)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEmojiDelete = () => {
|
const handleEmojiDelete = () => {
|
||||||
setEmoji('')
|
setEmoji('')
|
||||||
const _assistant = { ...assistant, name: name.trim(), prompt, emoji: '' }
|
const _assistant = {
|
||||||
|
...assistant,
|
||||||
|
name: name.trim(),
|
||||||
|
prompt,
|
||||||
|
emoji: '',
|
||||||
|
promptVariables: variables
|
||||||
|
}
|
||||||
|
updateAssistant(_assistant)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateVariables = (updatedVariables: Variable[]) => {
|
||||||
|
const _assistant = {
|
||||||
|
...assistant,
|
||||||
|
name: name.trim(),
|
||||||
|
emoji,
|
||||||
|
prompt,
|
||||||
|
promptVariables: updatedVariables
|
||||||
|
}
|
||||||
|
updateAssistant(_assistant)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInsertVariable = (varName: string) => {
|
||||||
|
const insertText = `{{${varName}}}`
|
||||||
|
setPrompt((prev) => prev + insertText)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addVariable = () => {
|
||||||
|
if (!variableName.trim()) return
|
||||||
|
|
||||||
|
const newVar: Variable = {
|
||||||
|
id: uuidv4(),
|
||||||
|
name: variableName.trim(),
|
||||||
|
value: variableValue.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedVariables = [...variables, newVar]
|
||||||
|
setVariables(updatedVariables)
|
||||||
|
setVariableName('')
|
||||||
|
setVariableValue('')
|
||||||
|
|
||||||
|
const _assistant = {
|
||||||
|
...assistant,
|
||||||
|
name: name.trim(),
|
||||||
|
emoji,
|
||||||
|
prompt,
|
||||||
|
promptVariables: updatedVariables
|
||||||
|
}
|
||||||
updateAssistant(_assistant)
|
updateAssistant(_assistant)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,10 +162,49 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant,
|
|||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
onBlur={onUpdate}
|
onBlur={onUpdate}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
style={{ minHeight: 'calc(80vh - 200px)', maxHeight: 'calc(80vh - 150px)' }}
|
style={{ minHeight: 'calc(80vh - 320px)', maxHeight: 'calc(80vh - 270px)' }}
|
||||||
/>
|
/>
|
||||||
<TokenCount>Tokens: {tokenCount}</TokenCount>
|
<TokenCount>Tokens: {tokenCount}</TokenCount>
|
||||||
</TextAreaContainer>
|
</TextAreaContainer>
|
||||||
|
|
||||||
|
<Box mt={12} mb={8}>
|
||||||
|
<HStack justifyContent="space-between" alignItems="center">
|
||||||
|
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||||
|
{t('common.variables')}
|
||||||
|
</Typography.Title>
|
||||||
|
<Tooltip title={t('common.variables_help')}>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: '12px', cursor: 'help' }}>
|
||||||
|
?
|
||||||
|
</Typography.Text>
|
||||||
|
</Tooltip>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<VariableList
|
||||||
|
variables={variables}
|
||||||
|
setVariables={setVariables}
|
||||||
|
onUpdate={handleUpdateVariables}
|
||||||
|
onInsertVariable={handleInsertVariable}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HStack gap={8} width="100%" mt={8} mb={8}>
|
||||||
|
<Input
|
||||||
|
placeholder={t('common.variable_name')}
|
||||||
|
value={variableName}
|
||||||
|
onChange={(e) => setVariableName(e.target.value)}
|
||||||
|
style={{ width: '30%' }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder={t('common.value')}
|
||||||
|
value={variableValue}
|
||||||
|
onChange={(e) => setVariableValue(e.target.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={addVariable}>
|
||||||
|
{t('common.add')}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
<HStack width="100%" justifyContent="flex-end" mt="10px">
|
<HStack width="100%" justifyContent="flex-end" mt="10px">
|
||||||
<Button type="primary" onClick={onOk}>
|
<Button type="primary" onClick={onOk}>
|
||||||
{t('common.close')}
|
{t('common.close')}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import AssistantKnowledgeBaseSettings from './AssistantKnowledgeBaseSettings'
|
import AssistantKnowledgeBaseSettings from './AssistantKnowledgeBaseSettings'
|
||||||
|
import AssistantMCPSettings from './AssistantMCPSettings'
|
||||||
import AssistantMessagesSettings from './AssistantMessagesSettings'
|
import AssistantMessagesSettings from './AssistantMessagesSettings'
|
||||||
import AssistantModelSettings from './AssistantModelSettings'
|
import AssistantModelSettings from './AssistantModelSettings'
|
||||||
import AssistantPromptSettings from './AssistantPromptSettings'
|
import AssistantPromptSettings from './AssistantPromptSettings'
|
||||||
@@ -19,7 +20,7 @@ interface AssistantSettingPopupShowParams {
|
|||||||
tab?: AssistantSettingPopupTab
|
tab?: AssistantSettingPopupTab
|
||||||
}
|
}
|
||||||
|
|
||||||
type AssistantSettingPopupTab = 'prompt' | 'model' | 'messages' | 'knowledge_base'
|
type AssistantSettingPopupTab = 'prompt' | 'model' | 'messages' | 'knowledge_base' | 'mcp'
|
||||||
|
|
||||||
interface Props extends AssistantSettingPopupShowParams {
|
interface Props extends AssistantSettingPopupShowParams {
|
||||||
resolve: (assistant: Assistant) => void
|
resolve: (assistant: Assistant) => void
|
||||||
@@ -68,6 +69,10 @@ const AssistantSettingPopupContainer: React.FC<Props> = ({ resolve, tab, ...prop
|
|||||||
showKnowledgeIcon && {
|
showKnowledgeIcon && {
|
||||||
key: 'knowledge_base',
|
key: 'knowledge_base',
|
||||||
label: t('assistants.settings.knowledge_base')
|
label: t('assistants.settings.knowledge_base')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mcp',
|
||||||
|
label: t('assistants.settings.mcp')
|
||||||
}
|
}
|
||||||
].filter(Boolean) as { key: string; label: string }[]
|
].filter(Boolean) as { key: string; label: string }[]
|
||||||
|
|
||||||
@@ -133,6 +138,13 @@ const AssistantSettingPopupContainer: React.FC<Props> = ({ resolve, tab, ...prop
|
|||||||
updateAssistantSettings={updateAssistantSettings}
|
updateAssistantSettings={updateAssistantSettings}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{menu === 'mcp' && (
|
||||||
|
<AssistantMCPSettings
|
||||||
|
assistant={assistant}
|
||||||
|
updateAssistant={updateAssistant}
|
||||||
|
updateAssistantSettings={updateAssistantSettings}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Settings>
|
</Settings>
|
||||||
</HStack>
|
</HStack>
|
||||||
</StyledModal>
|
</StyledModal>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
import { InfoCircleOutlined } from '@ant-design/icons'
|
||||||
import { HStack } from '@renderer/components/Layout'
|
import { HStack } from '@renderer/components/Layout'
|
||||||
import MinApp from '@renderer/components/MinApp'
|
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
|
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||||
import { RootState, useAppDispatch } from '@renderer/store'
|
import { RootState, useAppDispatch } from '@renderer/store'
|
||||||
import { setJoplinToken, setJoplinUrl } from '@renderer/store/settings'
|
import { setJoplinToken, setJoplinUrl } from '@renderer/store/settings'
|
||||||
import { Button, Tooltip } from 'antd'
|
import { Button, Tooltip } from 'antd'
|
||||||
@@ -16,6 +16,7 @@ const JoplinSettings: FC = () => {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
const { openMinapp } = useMinappPopup()
|
||||||
|
|
||||||
const joplinToken = useSelector((state: RootState) => state.settings.joplinToken)
|
const joplinToken = useSelector((state: RootState) => state.settings.joplinToken)
|
||||||
const joplinUrl = useSelector((state: RootState) => state.settings.joplinUrl)
|
const joplinUrl = useSelector((state: RootState) => state.settings.joplinUrl)
|
||||||
@@ -64,7 +65,7 @@ const JoplinSettings: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleJoplinHelpClick = () => {
|
const handleJoplinHelpClick = () => {
|
||||||
MinApp.start({
|
openMinapp({
|
||||||
id: 'joplin-help',
|
id: 'joplin-help',
|
||||||
name: 'Joplin Help',
|
name: 'Joplin Help',
|
||||||
url: 'https://joplinapp.org/help/apps/clipper'
|
url: 'https://joplinapp.org/help/apps/clipper'
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { DeleteOutlined, FolderOpenOutlined } from '@ant-design/icons'
|
|||||||
import { HStack } from '@renderer/components/Layout'
|
import { HStack } from '@renderer/components/Layout'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import { RootState, useAppDispatch } from '@renderer/store'
|
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 { Button, Switch } from 'antd'
|
||||||
import Input from 'antd/es/input/Input'
|
import Input from 'antd/es/input/Input'
|
||||||
import { FC } from 'react'
|
import { FC } from 'react'
|
||||||
@@ -18,6 +22,7 @@ const MarkdownExportSettings: FC = () => {
|
|||||||
|
|
||||||
const markdownExportPath = useSelector((state: RootState) => state.settings.markdownExportPath)
|
const markdownExportPath = useSelector((state: RootState) => state.settings.markdownExportPath)
|
||||||
const forceDollarMathInMarkdown = useSelector((state: RootState) => state.settings.forceDollarMathInMarkdown)
|
const forceDollarMathInMarkdown = useSelector((state: RootState) => state.settings.forceDollarMathInMarkdown)
|
||||||
|
const useTopicNamingForMessageTitle = useSelector((state: RootState) => state.settings.useTopicNamingForMessageTitle)
|
||||||
|
|
||||||
const handleSelectFolder = async () => {
|
const handleSelectFolder = async () => {
|
||||||
const path = await window.api.file.selectFolder()
|
const path = await window.api.file.selectFolder()
|
||||||
@@ -34,6 +39,10 @@ const MarkdownExportSettings: FC = () => {
|
|||||||
dispatch(setForceDollarMathInMarkdown(checked))
|
dispatch(setForceDollarMathInMarkdown(checked))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleToggleTopicNaming = (checked: boolean) => {
|
||||||
|
dispatch(setUseTopicNamingForMessageTitle(checked))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingGroup theme={theme}>
|
<SettingGroup theme={theme}>
|
||||||
<SettingTitle>{t('settings.data.markdown_export.title')}</SettingTitle>
|
<SettingTitle>{t('settings.data.markdown_export.title')}</SettingTitle>
|
||||||
@@ -69,6 +78,14 @@ const MarkdownExportSettings: FC = () => {
|
|||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingHelpText>{t('settings.data.markdown_export.force_dollar_math.help')}</SettingHelpText>
|
<SettingHelpText>{t('settings.data.markdown_export.force_dollar_math.help')}</SettingHelpText>
|
||||||
</SettingRow>
|
</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>
|
</SettingGroup>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
import { InfoCircleOutlined } from '@ant-design/icons'
|
||||||
import { Client } from '@notionhq/client'
|
import { Client } from '@notionhq/client'
|
||||||
import { HStack } from '@renderer/components/Layout'
|
import { HStack } from '@renderer/components/Layout'
|
||||||
import MinApp from '@renderer/components/MinApp'
|
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
|
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||||
import { RootState, useAppDispatch } from '@renderer/store'
|
import { RootState, useAppDispatch } from '@renderer/store'
|
||||||
import {
|
import {
|
||||||
setNotionApiKey,
|
setNotionApiKey,
|
||||||
@@ -22,6 +22,7 @@ const NotionSettings: FC = () => {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
const { openMinapp } = useMinappPopup()
|
||||||
|
|
||||||
const notionApiKey = useSelector((state: RootState) => state.settings.notionApiKey)
|
const notionApiKey = useSelector((state: RootState) => state.settings.notionApiKey)
|
||||||
const notionDatabaseID = useSelector((state: RootState) => state.settings.notionDatabaseID)
|
const notionDatabaseID = useSelector((state: RootState) => state.settings.notionDatabaseID)
|
||||||
@@ -68,7 +69,7 @@ const NotionSettings: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleNotionTitleClick = () => {
|
const handleNotionTitleClick = () => {
|
||||||
MinApp.start({
|
openMinapp({
|
||||||
id: 'notion-help',
|
id: 'notion-help',
|
||||||
name: 'Notion Help',
|
name: 'Notion Help',
|
||||||
url: 'https://docs.cherry-ai.com/advanced-basic/notion'
|
url: 'https://docs.cherry-ai.com/advanced-basic/notion'
|
||||||
|
|||||||
@@ -252,8 +252,10 @@ const NutstoreSettings: FC = () => {
|
|||||||
placeholder={t('settings.data.nutstore.path.placeholder')}
|
placeholder={t('settings.data.nutstore.path.placeholder')}
|
||||||
style={{ width: 250 }}
|
style={{ width: 250 }}
|
||||||
value={nutstorePath}
|
value={nutstorePath}
|
||||||
onChange={(e) => setStoragePath(e.target.value)}
|
onChange={(e) => {
|
||||||
onBlur={() => dispatch(setNutstorePath(storagePath || ''))}
|
setStoragePath(e.target.value)
|
||||||
|
dispatch(setNutstorePath(e.target.value))
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Button type="default" onClick={handleClickPathChange}>
|
<Button type="default" onClick={handleClickPathChange}>
|
||||||
<FolderOutlined />
|
<FolderOutlined />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
import { InfoCircleOutlined } from '@ant-design/icons'
|
||||||
import { HStack } from '@renderer/components/Layout'
|
import { HStack } from '@renderer/components/Layout'
|
||||||
import MinApp from '@renderer/components/MinApp'
|
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
|
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||||
import { RootState, useAppDispatch } from '@renderer/store'
|
import { RootState, useAppDispatch } from '@renderer/store'
|
||||||
import { setSiyuanApiUrl, setSiyuanBoxId, setSiyuanRootPath, setSiyuanToken } from '@renderer/store/settings'
|
import { setSiyuanApiUrl, setSiyuanBoxId, setSiyuanRootPath, setSiyuanToken } from '@renderer/store/settings'
|
||||||
import { Button, Tooltip } from 'antd'
|
import { Button, Tooltip } from 'antd'
|
||||||
@@ -13,6 +13,7 @@ import { useSelector } from 'react-redux'
|
|||||||
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||||
|
|
||||||
const SiyuanSettings: FC = () => {
|
const SiyuanSettings: FC = () => {
|
||||||
|
const { openMinapp } = useMinappPopup()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
@@ -39,7 +40,7 @@ const SiyuanSettings: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSiyuanHelpClick = () => {
|
const handleSiyuanHelpClick = () => {
|
||||||
MinApp.start({
|
openMinapp({
|
||||||
id: 'siyuan-help',
|
id: 'siyuan-help',
|
||||||
name: 'Siyuan Help',
|
name: 'Siyuan Help',
|
||||||
url: 'https://docs.cherry-ai.com/advanced-basic/siyuan'
|
url: 'https://docs.cherry-ai.com/advanced-basic/siyuan'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
import { InfoCircleOutlined } from '@ant-design/icons'
|
||||||
import { HStack } from '@renderer/components/Layout'
|
import { HStack } from '@renderer/components/Layout'
|
||||||
import MinApp from '@renderer/components/MinApp'
|
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
|
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||||
import { RootState, useAppDispatch } from '@renderer/store'
|
import { RootState, useAppDispatch } from '@renderer/store'
|
||||||
import { setYuqueRepoId, setYuqueToken, setYuqueUrl } from '@renderer/store/settings'
|
import { setYuqueRepoId, setYuqueToken, setYuqueUrl } from '@renderer/store/settings'
|
||||||
import { Button, Tooltip } from 'antd'
|
import { Button, Tooltip } from 'antd'
|
||||||
@@ -16,6 +16,7 @@ const YuqueSettings: FC = () => {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
const { openMinapp } = useMinappPopup()
|
||||||
|
|
||||||
const yuqueToken = useSelector((state: RootState) => state.settings.yuqueToken)
|
const yuqueToken = useSelector((state: RootState) => state.settings.yuqueToken)
|
||||||
const yuqueUrl = useSelector((state: RootState) => state.settings.yuqueUrl)
|
const yuqueUrl = useSelector((state: RootState) => state.settings.yuqueUrl)
|
||||||
@@ -64,7 +65,7 @@ const YuqueSettings: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleYuqueHelpClick = () => {
|
const handleYuqueHelpClick = () => {
|
||||||
MinApp.start({
|
openMinapp({
|
||||||
id: 'yuque-help',
|
id: 'yuque-help',
|
||||||
name: 'Yuque Help',
|
name: 'Yuque Help',
|
||||||
url: 'https://www.yuque.com/settings/tokens'
|
url: 'https://www.yuque.com/settings/tokens'
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { SyncOutlined } from '@ant-design/icons'
|
import { SyncOutlined } from '@ant-design/icons'
|
||||||
import { isMac } from '@renderer/config/constant'
|
import { isMac } from '@renderer/config/constant'
|
||||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import { useAppDispatch } from '@renderer/store'
|
import { useAppDispatch } from '@renderer/store'
|
||||||
import {
|
import {
|
||||||
@@ -19,7 +17,6 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||||
import MiniAppIconsManager from './MiniAppIconsManager'
|
|
||||||
import SidebarIconsManager from './SidebarIconsManager'
|
import SidebarIconsManager from './SidebarIconsManager'
|
||||||
|
|
||||||
const DisplaySettings: FC = () => {
|
const DisplaySettings: FC = () => {
|
||||||
@@ -37,17 +34,13 @@ const DisplaySettings: FC = () => {
|
|||||||
showAssistantIcon,
|
showAssistantIcon,
|
||||||
setShowAssistantIcon
|
setShowAssistantIcon
|
||||||
} = useSettings()
|
} = useSettings()
|
||||||
const { minapps, disabled, updateMinapps, updateDisabledMinapps } = useMinapps()
|
|
||||||
const { theme: themeMode } = useTheme()
|
const { theme: themeMode } = useTheme()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
const [visibleIcons, setVisibleIcons] = useState(sidebarIcons?.visible || DEFAULT_SIDEBAR_ICONS)
|
const [visibleIcons, setVisibleIcons] = useState(sidebarIcons?.visible || DEFAULT_SIDEBAR_ICONS)
|
||||||
const [disabledIcons, setDisabledIcons] = useState(sidebarIcons?.disabled || [])
|
const [disabledIcons, setDisabledIcons] = useState(sidebarIcons?.disabled || [])
|
||||||
const [visibleMiniApps, setVisibleMiniApps] = useState(minapps)
|
|
||||||
const [disabledMiniApps, setDisabledMiniApps] = useState(disabled || [])
|
|
||||||
|
|
||||||
// 使用useCallback优化回调函数
|
|
||||||
const handleWindowStyleChange = useCallback(
|
const handleWindowStyleChange = useCallback(
|
||||||
(checked: boolean) => {
|
(checked: boolean) => {
|
||||||
setWindowStyle(checked ? 'transparent' : 'opaque')
|
setWindowStyle(checked ? 'transparent' : 'opaque')
|
||||||
@@ -61,13 +54,6 @@ const DisplaySettings: FC = () => {
|
|||||||
dispatch(setSidebarIcons({ visible: DEFAULT_SIDEBAR_ICONS, disabled: [] }))
|
dispatch(setSidebarIcons({ visible: DEFAULT_SIDEBAR_ICONS, disabled: [] }))
|
||||||
}, [dispatch])
|
}, [dispatch])
|
||||||
|
|
||||||
const handleResetMinApps = useCallback(() => {
|
|
||||||
setVisibleMiniApps(DEFAULT_MIN_APPS)
|
|
||||||
setDisabledMiniApps([])
|
|
||||||
updateMinapps(DEFAULT_MIN_APPS)
|
|
||||||
updateDisabledMinapps([])
|
|
||||||
}, [updateDisabledMinapps, updateMinapps])
|
|
||||||
|
|
||||||
const themeOptions = useMemo(
|
const themeOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -177,22 +163,6 @@ const DisplaySettings: FC = () => {
|
|||||||
setDisabledIcons={setDisabledIcons}
|
setDisabledIcons={setDisabledIcons}
|
||||||
/>
|
/>
|
||||||
</SettingGroup>
|
</SettingGroup>
|
||||||
<SettingGroup theme={theme}>
|
|
||||||
<SettingTitle
|
|
||||||
style={{ display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<span>{t('settings.display.minApp.title')}</span>
|
|
||||||
<ResetButtonWrapper>
|
|
||||||
<Button onClick={handleResetMinApps}>{t('common.reset')}</Button>
|
|
||||||
</ResetButtonWrapper>
|
|
||||||
</SettingTitle>
|
|
||||||
<SettingDivider />
|
|
||||||
<MiniAppIconsManager
|
|
||||||
visibleMiniApps={visibleMiniApps}
|
|
||||||
disabledMiniApps={disabledMiniApps}
|
|
||||||
setVisibleMiniApps={setVisibleMiniApps}
|
|
||||||
setDisabledMiniApps={setDisabledMiniApps}
|
|
||||||
/>
|
|
||||||
</SettingGroup>
|
|
||||||
<SettingGroup theme={theme}>
|
<SettingGroup theme={theme}>
|
||||||
<SettingTitle>
|
<SettingTitle>
|
||||||
{t('settings.display.custom.css')}
|
{t('settings.display.custom.css')}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ const GeneralSettings: FC = () => {
|
|||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingRowTitle>{t('settings.tray.onclose')}</SettingRowTitle>
|
<SettingRowTitle>{t('settings.tray.onclose')}</SettingRowTitle>
|
||||||
<Switch checked={trayOnClose} onChange={(checked) => updateTrayOnClose(checked)} disabled={!tray} />
|
<Switch checked={trayOnClose} onChange={(checked) => updateTrayOnClose(checked)} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</SettingGroup>
|
</SettingGroup>
|
||||||
</SettingContainer>
|
</SettingContainer>
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
|||||||
isActive: false,
|
isActive: false,
|
||||||
...(serverConfig as any)
|
...(serverConfig as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!server.name) {
|
||||||
|
server.name = id
|
||||||
|
}
|
||||||
|
|
||||||
serversArray.push(server)
|
serversArray.push(server)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
|
import { DeleteOutlined, SaveOutlined } from '@ant-design/icons'
|
||||||
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
import { useMCPServers } from '@renderer/hooks/useMCPServers'
|
||||||
import { MCPServer } from '@renderer/types'
|
import { MCPServer, MCPTool } from '@renderer/types'
|
||||||
import { Button, Flex, Form, Input, Radio, Switch } from 'antd'
|
import { Button, Flex, Form, Input, Radio, Switch } from 'antd'
|
||||||
import TextArea from 'antd/es/input/TextArea'
|
import TextArea from 'antd/es/input/TextArea'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '..'
|
import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '..'
|
||||||
|
import MCPToolsSection from './McpTool'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
server: MCPServer
|
server: MCPServer
|
||||||
@@ -18,49 +20,72 @@ interface MCPFormValues {
|
|||||||
serverType: 'sse' | 'stdio'
|
serverType: 'sse' | 'stdio'
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
command?: string
|
command?: string
|
||||||
|
registryUrl?: string
|
||||||
args?: string
|
args?: string
|
||||||
env?: string
|
env?: string
|
||||||
isActive: boolean
|
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 McpSettings: React.FC<Props> = ({ server }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { deleteMCPServer } = useMCPServers()
|
||||||
const [serverType, setServerType] = useState<'sse' | 'stdio'>('stdio')
|
const [serverType, setServerType] = useState<'sse' | 'stdio'>('stdio')
|
||||||
const [form] = Form.useForm<MCPFormValues>()
|
const [form] = Form.useForm<MCPFormValues>()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [isFormChanged, setIsFormChanged] = useState(false)
|
const [isFormChanged, setIsFormChanged] = useState(false)
|
||||||
const [loadingServer, setLoadingServer] = useState<string | null>(null)
|
const [loadingServer, setLoadingServer] = useState<string | null>(null)
|
||||||
const { updateMCPServer } = useMCPServers()
|
const { updateMCPServer } = useMCPServers()
|
||||||
|
const [tools, setTools] = useState<MCPTool[]>([])
|
||||||
useEffect(() => {
|
const [isShowRegistry, setIsShowRegistry] = useState(false)
|
||||||
if (server) {
|
const [registry, setRegistry] = useState<Registry[]>()
|
||||||
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])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const serverType = server.baseUrl ? 'sse' : 'stdio'
|
const serverType = server.baseUrl ? 'sse' : 'stdio'
|
||||||
setServerType(serverType)
|
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({
|
form.setFieldsValue({
|
||||||
name: server.name,
|
name: server.name,
|
||||||
description: server.description,
|
description: server.description,
|
||||||
serverType: serverType,
|
serverType: serverType,
|
||||||
baseUrl: server.baseUrl || '',
|
baseUrl: server.baseUrl || '',
|
||||||
command: server.command || '',
|
command: server.command || '',
|
||||||
|
registryUrl: server.registryUrl || '',
|
||||||
|
isActive: server.isActive,
|
||||||
args: server.args ? server.args.join('\n') : '',
|
args: server.args ? server.args.join('\n') : '',
|
||||||
env: server.env
|
env: server.env
|
||||||
? Object.entries(server.env)
|
? Object.entries(server.env)
|
||||||
@@ -68,7 +93,8 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
|||||||
.join('\n')
|
.join('\n')
|
||||||
: ''
|
: ''
|
||||||
})
|
})
|
||||||
}, [form, server])
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [server])
|
||||||
|
|
||||||
// Watch the serverType field to update the form layout dynamically
|
// Watch the serverType field to update the form layout dynamically
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -76,43 +102,73 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
|||||||
type && setServerType(type)
|
type && setServerType(type)
|
||||||
}, [form])
|
}, [form])
|
||||||
|
|
||||||
|
// Load tools on initial mount if server is active
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTools()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchTools = async () => {
|
||||||
|
if (server.isActive) {
|
||||||
|
try {
|
||||||
|
setLoadingServer(server.id)
|
||||||
|
const localTools = await window.api.mcp.listTools(server)
|
||||||
|
setTools(localTools)
|
||||||
|
// window.message.success(t('settings.mcp.toolsLoaded'))
|
||||||
|
} catch (error) {
|
||||||
|
window.message.error({
|
||||||
|
content: t('settings.mcp.toolsLoadError') + formatError(error),
|
||||||
|
key: 'mcp-tools-error'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLoadingServer(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the form data
|
||||||
const onSave = async () => {
|
const onSave = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields()
|
const values = await form.validateFields()
|
||||||
|
|
||||||
|
// set basic fields
|
||||||
const mcpServer: MCPServer = {
|
const mcpServer: MCPServer = {
|
||||||
id: server.id,
|
id: server.id,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
description: values.description,
|
description: values.description,
|
||||||
isActive: values.isActive
|
isActive: values.isActive,
|
||||||
|
registryUrl: values.registryUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// set stdio or sse server
|
||||||
if (values.serverType === 'sse') {
|
if (values.serverType === 'sse') {
|
||||||
mcpServer.baseUrl = values.baseUrl
|
mcpServer.baseUrl = values.baseUrl
|
||||||
} else {
|
} else {
|
||||||
mcpServer.command = values.command
|
mcpServer.command = values.command
|
||||||
mcpServer.args = values.args ? values.args.split('\n').filter((arg) => arg.trim() !== '') : []
|
mcpServer.args = values.args ? values.args.split('\n').filter((arg) => arg.trim() !== '') : []
|
||||||
|
}
|
||||||
|
|
||||||
|
// set env variables
|
||||||
|
if (values.env) {
|
||||||
const env: Record<string, string> = {}
|
const env: Record<string, string> = {}
|
||||||
if (values.env) {
|
values.env.split('\n').forEach((line) => {
|
||||||
values.env.split('\n').forEach((line) => {
|
if (line.trim()) {
|
||||||
if (line.trim()) {
|
const [key, ...chunks] = line.split('=')
|
||||||
const [key, ...chunks] = line.split('=')
|
const value = chunks.join('=')
|
||||||
const value = chunks.join('=')
|
if (key && value) {
|
||||||
if (key && value) {
|
env[key.trim()] = value.trim()
|
||||||
env[key.trim()] = value.trim()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
}
|
})
|
||||||
mcpServer.env = Object.keys(env).length > 0 ? env : undefined
|
mcpServer.env = env
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await window.api.mcp.listTools(mcpServer)
|
await window.api.mcp.restartServer(mcpServer)
|
||||||
updateMCPServer({ ...mcpServer, isActive: true })
|
updateMCPServer({ ...mcpServer, isActive: true })
|
||||||
window.message.success({ content: t('settings.mcp.updateSuccess'), key: 'mcp-update-success' })
|
window.message.success({ content: t('settings.mcp.updateSuccess'), key: 'mcp-update-success' })
|
||||||
|
await fetchTools()
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setIsFormChanged(false)
|
setIsFormChanged(false)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -126,9 +182,65 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setLoading(false)
|
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 {
|
||||||
|
window.modal.confirm({
|
||||||
|
title: t('settings.mcp.deleteServer'),
|
||||||
|
content: t('settings.mcp.deleteServerConfirm'),
|
||||||
|
centered: true,
|
||||||
|
onOk: async () => {
|
||||||
|
await window.api.mcp.removeServer(server)
|
||||||
|
deleteMCPServer(server.id)
|
||||||
|
window.message.success({ content: t('settings.mcp.deleteSuccess'), key: 'mcp-list' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
window.message.error({
|
||||||
|
content: `${t('settings.mcp.deleteError')}: ${error.message}`,
|
||||||
|
key: 'mcp-list'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[server, t]
|
||||||
|
)
|
||||||
|
|
||||||
const onFormValuesChange = () => {
|
const onFormValuesChange = () => {
|
||||||
setIsFormChanged(true)
|
setIsFormChanged(true)
|
||||||
}
|
}
|
||||||
@@ -144,10 +256,14 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
|||||||
const onToggleActive = async (active: boolean) => {
|
const onToggleActive = async (active: boolean) => {
|
||||||
await form.validateFields()
|
await form.validateFields()
|
||||||
setLoadingServer(server.id)
|
setLoadingServer(server.id)
|
||||||
|
const oldActiveState = server.isActive
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (active) {
|
if (active) {
|
||||||
await window.api.mcp.listTools(server)
|
const localTools = await window.api.mcp.listTools(server)
|
||||||
|
setTools(localTools)
|
||||||
|
} else {
|
||||||
|
await window.api.mcp.stopServer(server)
|
||||||
}
|
}
|
||||||
updateMCPServer({ ...server, isActive: active })
|
updateMCPServer({ ...server, isActive: active })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -156,7 +272,7 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
|||||||
content: formatError(error),
|
content: formatError(error),
|
||||||
centered: true
|
centered: true
|
||||||
})
|
})
|
||||||
console.error('[MCP] Error toggling server active', error)
|
updateMCPServer({ ...server, isActive: oldActiveState })
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingServer(null)
|
setLoadingServer(null)
|
||||||
}
|
}
|
||||||
@@ -166,7 +282,10 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
|||||||
<SettingContainer>
|
<SettingContainer>
|
||||||
<SettingGroup style={{ marginBottom: 0 }}>
|
<SettingGroup style={{ marginBottom: 0 }}>
|
||||||
<SettingTitle>
|
<SettingTitle>
|
||||||
<ServerName>{server?.name}</ServerName>
|
<Flex justify="space-between" align="center" gap={5} style={{ marginRight: 10 }}>
|
||||||
|
<ServerName className="text-nowrap">{server?.name}</ServerName>
|
||||||
|
<Button danger icon={<DeleteOutlined />} type="text" onClick={() => onDeleteMcpServer(server)} />
|
||||||
|
</Flex>
|
||||||
<Flex align="center" gap={16}>
|
<Flex align="center" gap={16}>
|
||||||
<Switch
|
<Switch
|
||||||
value={server.isActive}
|
value={server.isActive}
|
||||||
@@ -174,7 +293,7 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
|||||||
loading={loadingServer === server.id}
|
loading={loadingServer === server.id}
|
||||||
onChange={onToggleActive}
|
onChange={onToggleActive}
|
||||||
/>
|
/>
|
||||||
<Button type="primary" size="small" onClick={onSave} loading={loading} disabled={!isFormChanged}>
|
<Button type="primary" icon={<SaveOutlined />} onClick={onSave} loading={loading} disabled={!isFormChanged}>
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
@@ -185,7 +304,7 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
|||||||
layout="vertical"
|
layout="vertical"
|
||||||
onValuesChange={onFormValuesChange}
|
onValuesChange={onFormValuesChange}
|
||||||
style={{
|
style={{
|
||||||
height: 'calc(100vh - var(--navbar-height) - 115px)',
|
// height: 'calc(100vh - var(--navbar-height) - 315px)',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
width: 'calc(100% + 10px)',
|
width: 'calc(100% + 10px)',
|
||||||
paddingRight: '10px'
|
paddingRight: '10px'
|
||||||
@@ -200,8 +319,8 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
|||||||
<Radio.Group
|
<Radio.Group
|
||||||
onChange={(e) => setServerType(e.target.value)}
|
onChange={(e) => setServerType(e.target.value)}
|
||||||
options={[
|
options={[
|
||||||
{ label: 'SSE', value: 'sse' },
|
{ label: 'STDIO', value: 'stdio' },
|
||||||
{ label: 'STDIO', value: 'stdio' }
|
{ label: 'SSE', value: 'sse' }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -220,14 +339,38 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
|||||||
name="command"
|
name="command"
|
||||||
label={t('settings.mcp.command')}
|
label={t('settings.mcp.command')}
|
||||||
rules={[{ required: serverType === 'stdio', message: '' }]}>
|
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>
|
||||||
|
|
||||||
<Form.Item
|
{isShowRegistry && registry && (
|
||||||
name="args"
|
<Form.Item
|
||||||
label={t('settings.mcp.args')}
|
name="registryUrl"
|
||||||
tooltip={t('settings.mcp.argsTooltip')}
|
label={t('settings.mcp.registry')}
|
||||||
rules={[{ required: serverType === 'stdio', message: '' }]}>
|
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' }} />
|
<TextArea rows={3} placeholder={`arg1\narg2`} style={{ fontFamily: 'monospace' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
@@ -237,6 +380,7 @@ const McpSettings: React.FC<Props> = ({ server }) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
|
{server.isActive && <MCPToolsSection tools={tools} />}
|
||||||
</SettingGroup>
|
</SettingGroup>
|
||||||
</SettingContainer>
|
</SettingContainer>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { EditOutlined, ExportOutlined, SearchOutlined } from '@ant-design/icons'
|
import { EditOutlined, ExportOutlined, SearchOutlined } from '@ant-design/icons'
|
||||||
import { NavbarRight } from '@renderer/components/app/Navbar'
|
import { NavbarRight } from '@renderer/components/app/Navbar'
|
||||||
import { HStack } from '@renderer/components/Layout'
|
import { HStack } from '@renderer/components/Layout'
|
||||||
|
import { isWindows } from '@renderer/config/constant'
|
||||||
import { EventEmitter } from '@renderer/services/EventService'
|
import { EventEmitter } from '@renderer/services/EventService'
|
||||||
import { Button } from 'antd'
|
import { Button } from 'antd'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -13,7 +14,7 @@ export const McpSettingsNavbar = () => {
|
|||||||
const onClick = () => window.open('https://mcp.so/', '_blank')
|
const onClick = () => window.open('https://mcp.so/', '_blank')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavbarRight>
|
<NavbarRight style={{ paddingRight: isWindows ? 150 : 12 }}>
|
||||||
<HStack alignItems="center" gap={5}>
|
<HStack alignItems="center" gap={5}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
132
src/renderer/src/pages/settings/MCPSettings/McpTool.tsx
Normal file
132
src/renderer/src/pages/settings/MCPSettings/McpTool.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { MCPTool } from '@renderer/types'
|
||||||
|
import { Badge, Collapse, Descriptions, Empty, Flex, Tag, Tooltip, Typography } from 'antd'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
const MCPToolsSection = ({ tools }: { tools: MCPTool[] }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
// Render tool properties from the input schema
|
||||||
|
const renderToolProperties = (tool: MCPTool) => {
|
||||||
|
if (!tool.inputSchema?.properties) return null
|
||||||
|
|
||||||
|
const getTypeColor = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'string':
|
||||||
|
return 'blue'
|
||||||
|
case 'number':
|
||||||
|
return 'green'
|
||||||
|
case 'boolean':
|
||||||
|
return 'purple'
|
||||||
|
case 'object':
|
||||||
|
return 'orange'
|
||||||
|
case 'array':
|
||||||
|
return 'cyan'
|
||||||
|
default:
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Typography.Title level={5}>{t('settings.mcp.tools.inputSchema')}:</Typography.Title>
|
||||||
|
<Descriptions bordered size="small" column={1} style={{ marginTop: 8 }}>
|
||||||
|
{Object.entries(tool.inputSchema.properties).map(([key, prop]: [string, any]) => (
|
||||||
|
<Descriptions.Item
|
||||||
|
key={key}
|
||||||
|
label={
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
<Typography.Text strong>{key}</Typography.Text>
|
||||||
|
{tool.inputSchema.required?.includes(key) && (
|
||||||
|
<Tooltip title="Required field">
|
||||||
|
<Tag color="red">Required</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
}>
|
||||||
|
<Flex vertical gap={4}>
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
{prop.type && (
|
||||||
|
// <Typography.Text type="secondary">{prop.type} </Typography.Text>
|
||||||
|
<Badge
|
||||||
|
color={getTypeColor(prop.type)}
|
||||||
|
text={<Typography.Text type="secondary">{prop.type}</Typography.Text>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
{prop.description && (
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||||
|
{prop.description}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
)}
|
||||||
|
{prop.enum && (
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
<Typography.Text type="secondary">Allowed values: </Typography.Text>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
|
||||||
|
{prop.enum.map((value: string, idx: number) => (
|
||||||
|
<Tag key={idx}>{value}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</Descriptions.Item>
|
||||||
|
))}
|
||||||
|
</Descriptions>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
<SectionTitle>{t('settings.mcp.tools.availableTools')}</SectionTitle>
|
||||||
|
{tools.length > 0 ? (
|
||||||
|
<Collapse bordered={false} ghost>
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<Collapse.Panel
|
||||||
|
key={tool.id}
|
||||||
|
header={
|
||||||
|
<Flex vertical align="flex-start" style={{ width: '100%' }}>
|
||||||
|
<Flex align="center" style={{ width: '100%' }}>
|
||||||
|
<Typography.Text strong>{tool.name}</Typography.Text>
|
||||||
|
<Typography.Text type="secondary" style={{ marginLeft: 8, fontSize: '12px' }}>
|
||||||
|
{tool.id}
|
||||||
|
</Typography.Text>
|
||||||
|
</Flex>
|
||||||
|
{tool.description && (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: '13px', marginTop: 4 }}>
|
||||||
|
{tool.description}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
}>
|
||||||
|
<SelectableContent>{renderToolProperties(tool)}</SelectableContent>
|
||||||
|
</Collapse.Panel>
|
||||||
|
))}
|
||||||
|
</Collapse>
|
||||||
|
) : (
|
||||||
|
<Empty description={t('settings.mcp.tools.noToolsAvailable')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Section = styled.div`
|
||||||
|
margin-top: 8px;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding-top: 8px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const SectionTitle = styled.h3`
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
`
|
||||||
|
|
||||||
|
const SelectableContent = styled.div`
|
||||||
|
user-select: text;
|
||||||
|
padding: 0 12px;
|
||||||
|
`
|
||||||
|
|
||||||
|
export default MCPToolsSection
|
||||||
@@ -39,8 +39,10 @@ const NpxSearch: FC = () => {
|
|||||||
_searchResults = searchResults
|
_searchResults = searchResults
|
||||||
|
|
||||||
// Add new function to handle npm scope search
|
// Add new function to handle npm scope search
|
||||||
const handleNpmSearch = async () => {
|
const handleNpmSearch = async (scopeOverride?: string) => {
|
||||||
if (!npmScope.trim()) {
|
const searchScope = scopeOverride || npmScope
|
||||||
|
console.log('handleNpmSearch', searchScope)
|
||||||
|
if (!searchScope.trim()) {
|
||||||
window.message.warning({ content: t('settings.mcp.npx_list.scope_required'), key: 'mcp-npx-scope-required' })
|
window.message.warning({ content: t('settings.mcp.npx_list.scope_required'), key: 'mcp-npx-scope-required' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -53,7 +55,7 @@ const NpxSearch: FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Call npxFinder to search for packages
|
// Call npxFinder to search for packages
|
||||||
const packages = await npxFinder(npmScope)
|
const packages = await npxFinder(searchScope)
|
||||||
|
|
||||||
// Map the packages to our desired format
|
// Map the packages to our desired format
|
||||||
const formattedResults = packages.map((pkg) => {
|
const formattedResults = packages.map((pkg) => {
|
||||||
@@ -74,6 +76,8 @@ const NpxSearch: FC = () => {
|
|||||||
window.message.info({ content: t('settings.mcp.npx_list.no_packages'), key: 'mcp-npx-no-packages' })
|
window.message.info({ content: t('settings.mcp.npx_list.no_packages'), key: 'mcp-npx-no-packages' })
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
setSearchResults([])
|
||||||
|
_searchResults = []
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
window.message.error({
|
window.message.error({
|
||||||
content: `${t('settings.mcp.npx_list.search_error')}: ${error.message}`,
|
content: `${t('settings.mcp.npx_list.search_error')}: ${error.message}`,
|
||||||
@@ -101,9 +105,9 @@ const NpxSearch: FC = () => {
|
|||||||
placeholder={t('settings.mcp.npx_list.scope_placeholder')}
|
placeholder={t('settings.mcp.npx_list.scope_placeholder')}
|
||||||
value={npmScope}
|
value={npmScope}
|
||||||
onChange={(e) => setNpmScope(e.target.value)}
|
onChange={(e) => setNpmScope(e.target.value)}
|
||||||
onPressEnter={handleNpmSearch}
|
onPressEnter={() => handleNpmSearch(npmScope)}
|
||||||
/>
|
/>
|
||||||
<Button icon={<SearchOutlined />} onClick={handleNpmSearch} disabled={searchLoading}>
|
<Button icon={<SearchOutlined />} onClick={() => handleNpmSearch(npmScope)} disabled={searchLoading}>
|
||||||
{t('settings.mcp.npx_list.search')}
|
{t('settings.mcp.npx_list.search')}
|
||||||
</Button>
|
</Button>
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
@@ -112,10 +116,8 @@ const NpxSearch: FC = () => {
|
|||||||
<Tag
|
<Tag
|
||||||
key={scope}
|
key={scope}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!searchLoading) {
|
setNpmScope(scope)
|
||||||
setNpmScope(scope)
|
handleNpmSearch(scope)
|
||||||
setTimeout(handleNpmSearch, 100)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
style={{ cursor: searchLoading ? 'not-allowed' : 'pointer' }}>
|
style={{ cursor: searchLoading ? 'not-allowed' : 'pointer' }}>
|
||||||
{scope}
|
{scope}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ const MiniAppIconsManager: FC<MiniAppManagerProps> = ({
|
|||||||
<ProgramSection>
|
<ProgramSection>
|
||||||
{(['visible', 'disabled'] as const).map((listType) => (
|
{(['visible', 'disabled'] as const).map((listType) => (
|
||||||
<ProgramColumn key={listType}>
|
<ProgramColumn key={listType}>
|
||||||
<h4>{t(`settings.display.minApp.${listType}`)}</h4>
|
<h4>{t(`settings.miniapps.${listType}`)}</h4>
|
||||||
<Droppable droppableId={listType}>
|
<Droppable droppableId={listType}>
|
||||||
{(provided: DroppableProvided) => (
|
{(provided: DroppableProvided) => (
|
||||||
<ProgramList ref={provided.innerRef} {...provided.droppableProps}>
|
<ProgramList ref={provided.innerRef} {...provided.droppableProps}>
|
||||||
@@ -125,7 +125,7 @@ const MiniAppIconsManager: FC<MiniAppManagerProps> = ({
|
|||||||
</Draggable>
|
</Draggable>
|
||||||
))}
|
))}
|
||||||
{disabledMiniApps.length === 0 && listType === 'disabled' && (
|
{disabledMiniApps.length === 0 && listType === 'disabled' && (
|
||||||
<EmptyPlaceholder>{t('settings.display.minApp.empty')}</EmptyPlaceholder>
|
<EmptyPlaceholder>{t('settings.miniapps.empty')}</EmptyPlaceholder>
|
||||||
)}
|
)}
|
||||||
{provided.placeholder}
|
{provided.placeholder}
|
||||||
</ProgramList>
|
</ProgramList>
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { UndoOutlined } from '@ant-design/icons' // 导入重置图标
|
||||||
|
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||||
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
|
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||||
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
|
import { useAppDispatch } from '@renderer/store'
|
||||||
|
import { setMaxKeepAliveMinapps, setShowOpenedMinappsInSidebar } from '@renderer/store/settings'
|
||||||
|
import { Button, message, Slider, Switch, Tooltip } from 'antd'
|
||||||
|
import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
import { SettingContainer, SettingDescription, SettingDivider, SettingGroup, SettingRowTitle, SettingTitle } from '..'
|
||||||
|
import MiniAppIconsManager from './MiniAppIconsManager'
|
||||||
|
|
||||||
|
// 默认小程序缓存数量
|
||||||
|
const DEFAULT_MAX_KEEPALIVE = 3
|
||||||
|
|
||||||
|
const MiniAppSettings: FC = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const { maxKeepAliveMinapps, showOpenedMinappsInSidebar } = useSettings()
|
||||||
|
const { minapps, disabled, updateMinapps, updateDisabledMinapps } = useMinapps()
|
||||||
|
|
||||||
|
const [visibleMiniApps, setVisibleMiniApps] = useState(minapps)
|
||||||
|
const [disabledMiniApps, setDisabledMiniApps] = useState(disabled || [])
|
||||||
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
|
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
const handleResetMinApps = useCallback(() => {
|
||||||
|
setVisibleMiniApps(DEFAULT_MIN_APPS)
|
||||||
|
setDisabledMiniApps([])
|
||||||
|
updateMinapps(DEFAULT_MIN_APPS)
|
||||||
|
updateDisabledMinapps([])
|
||||||
|
}, [updateDisabledMinapps, updateMinapps])
|
||||||
|
|
||||||
|
// 恢复默认缓存数量
|
||||||
|
const handleResetCacheLimit = useCallback(() => {
|
||||||
|
dispatch(setMaxKeepAliveMinapps(DEFAULT_MAX_KEEPALIVE))
|
||||||
|
messageApi.info(t('settings.miniapps.cache_change_notice'))
|
||||||
|
}, [dispatch, messageApi, t])
|
||||||
|
|
||||||
|
// 处理缓存数量变更
|
||||||
|
const handleCacheChange = useCallback(
|
||||||
|
(value: number) => {
|
||||||
|
dispatch(setMaxKeepAliveMinapps(value))
|
||||||
|
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
messageApi.info(t('settings.miniapps.cache_change_notice'))
|
||||||
|
debounceTimerRef.current = null
|
||||||
|
}, 500)
|
||||||
|
},
|
||||||
|
[dispatch, messageApi, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
// 组件卸载时清除定时器
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingContainer theme={theme}>
|
||||||
|
{contextHolder} {/* 添加消息上下文 */}
|
||||||
|
<SettingGroup theme={theme}>
|
||||||
|
<SettingTitle>{t('settings.miniapps.title')}</SettingTitle>
|
||||||
|
<SettingDivider />
|
||||||
|
<SettingTitle
|
||||||
|
style={{ display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span>{t('settings.miniapps.display_title')}</span>
|
||||||
|
<ResetButtonWrapper>
|
||||||
|
<Button onClick={handleResetMinApps}>{t('common.reset')}</Button>
|
||||||
|
</ResetButtonWrapper>
|
||||||
|
</SettingTitle>
|
||||||
|
<BorderedContainer>
|
||||||
|
<MiniAppIconsManager
|
||||||
|
visibleMiniApps={visibleMiniApps}
|
||||||
|
disabledMiniApps={disabledMiniApps}
|
||||||
|
setVisibleMiniApps={setVisibleMiniApps}
|
||||||
|
setDisabledMiniApps={setDisabledMiniApps}
|
||||||
|
/>
|
||||||
|
</BorderedContainer>
|
||||||
|
<SettingDivider />
|
||||||
|
|
||||||
|
{/* 缓存小程序数量设置 */}
|
||||||
|
<CacheSettingRow>
|
||||||
|
<SettingLabelGroup>
|
||||||
|
<SettingRowTitle>{t('settings.miniapps.cache_title')}</SettingRowTitle>
|
||||||
|
<SettingDescription>{t('settings.miniapps.cache_description')}</SettingDescription>
|
||||||
|
</SettingLabelGroup>
|
||||||
|
<CacheSettingControls>
|
||||||
|
<SliderWithResetContainer>
|
||||||
|
<Tooltip title={t('settings.miniapps.reset_tooltip')} placement="top">
|
||||||
|
<ResetButton onClick={handleResetCacheLimit}>
|
||||||
|
<UndoOutlined />
|
||||||
|
</ResetButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Slider
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
value={maxKeepAliveMinapps}
|
||||||
|
onChange={handleCacheChange}
|
||||||
|
marks={{
|
||||||
|
1: '1',
|
||||||
|
3: '3',
|
||||||
|
5: '5'
|
||||||
|
}}
|
||||||
|
tooltip={{ formatter: (value) => `${value}` }}
|
||||||
|
/>
|
||||||
|
</SliderWithResetContainer>
|
||||||
|
</CacheSettingControls>
|
||||||
|
</CacheSettingRow>
|
||||||
|
<SettingDivider />
|
||||||
|
<SidebarSettingRow>
|
||||||
|
<SettingLabelGroup>
|
||||||
|
<SettingRowTitle>{t('settings.miniapps.sidebar_title')}</SettingRowTitle>
|
||||||
|
<SettingDescription>{t('settings.miniapps.sidebar_description')}</SettingDescription>
|
||||||
|
</SettingLabelGroup>
|
||||||
|
<Switch
|
||||||
|
checked={showOpenedMinappsInSidebar}
|
||||||
|
onChange={(checked) => dispatch(setShowOpenedMinappsInSidebar(checked))}
|
||||||
|
/>
|
||||||
|
</SidebarSettingRow>
|
||||||
|
</SettingGroup>
|
||||||
|
</SettingContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改和新增样式
|
||||||
|
const CacheSettingRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 0;
|
||||||
|
gap: 20px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const SettingLabelGroup = styled.div`
|
||||||
|
flex: 1;
|
||||||
|
`
|
||||||
|
|
||||||
|
// 新增控件容器,包含滑块和恢复默认按钮
|
||||||
|
const CacheSettingControls = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
width: 240px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const SliderWithResetContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.ant-slider {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-slider-track {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-slider-handle {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
// 重置按钮样式
|
||||||
|
const ResetButton = styled.button`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
min-width: 28px; /* 确保不会被压缩 */
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background-color: var(--color-bg-1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: var(--color-bg-2);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const ResetButtonWrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
`
|
||||||
|
|
||||||
|
// 新增侧边栏设置行样式
|
||||||
|
const SidebarSettingRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
`
|
||||||
|
|
||||||
|
// 新增: 带边框的容器组件
|
||||||
|
const BorderedContainer = styled.div`
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
margin: 8px 0 8px;
|
||||||
|
background-color: var(--color-bg-1);
|
||||||
|
`
|
||||||
|
|
||||||
|
export default MiniAppSettings
|
||||||
@@ -43,25 +43,37 @@ const PopupContainer: React.FC<Props> = ({ title, provider, model, apiKeys, type
|
|||||||
const newStatuses = [...keyStatuses]
|
const newStatuses = [...keyStatuses]
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (let i = 0; i < newStatuses.length; i++) {
|
// 使用Promise.all并行处理所有API验证请求
|
||||||
|
const checkPromises = newStatuses.map(async (status, i) => {
|
||||||
|
// 先更新当前密钥为检查中状态
|
||||||
setKeyStatuses((prev) => prev.map((status, idx) => (idx === i ? { ...status, checking: true } : status)))
|
setKeyStatuses((prev) => prev.map((status, idx) => (idx === i ? { ...status, checking: true } : status)))
|
||||||
|
|
||||||
let valid = false
|
try {
|
||||||
if (type === 'provider' && model) {
|
let valid = false
|
||||||
const result = await checkApi({ ...(provider as Provider), apiKey: newStatuses[i].key }, model)
|
if (type === 'provider' && model) {
|
||||||
valid = result.valid
|
const result = await checkApi({ ...(provider as Provider), apiKey: status.key }, model)
|
||||||
} else {
|
valid = result.valid
|
||||||
const result = await WebSearchService.checkSearch({
|
} else {
|
||||||
...(provider as WebSearchProvider),
|
const result = await WebSearchService.checkSearch({
|
||||||
apiKey: newStatuses[i].key
|
...(provider as WebSearchProvider),
|
||||||
})
|
apiKey: status.key
|
||||||
valid = result.valid
|
})
|
||||||
}
|
valid = result.valid
|
||||||
|
}
|
||||||
|
|
||||||
setKeyStatuses((prev) =>
|
// 更新验证结果
|
||||||
prev.map((status, idx) => (idx === i ? { ...status, checking: false, isValid: valid } : status))
|
setKeyStatuses((prev) => prev.map((s, idx) => (idx === i ? { ...s, checking: false, isValid: valid } : s)))
|
||||||
)
|
|
||||||
}
|
return { index: i, valid }
|
||||||
|
} catch (error) {
|
||||||
|
// 处理错误情况
|
||||||
|
setKeyStatuses((prev) => prev.map((s, idx) => (idx === i ? { ...s, checking: false, isValid: false } : s)))
|
||||||
|
return { index: i, valid: false }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 等待所有请求完成
|
||||||
|
await Promise.all(checkPromises)
|
||||||
} finally {
|
} finally {
|
||||||
setIsChecking(false)
|
setIsChecking(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import MinApp from '@renderer/components/MinApp'
|
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||||
import { MinAppType, Provider } from '@renderer/types'
|
import { MinAppType, Provider } from '@renderer/types'
|
||||||
import { Button } from 'antd'
|
import { Button } from 'antd'
|
||||||
import { FC } from 'react'
|
import { FC } from 'react'
|
||||||
@@ -15,18 +15,20 @@ const GraphRAGSettings: FC<Props> = ({ provider }) => {
|
|||||||
const apiUrl = provider.apiHost
|
const apiUrl = provider.apiHost
|
||||||
const modalId = provider.models.filter((model) => model.id.includes('global'))[0]?.id
|
const modalId = provider.models.filter((model) => model.id.includes('global'))[0]?.id
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { openMinapp } = useMinappPopup()
|
||||||
|
|
||||||
const onShowGraphRAG = async () => {
|
const onShowGraphRAG = async () => {
|
||||||
const { appPath } = await window.api.getAppInfo()
|
const { appPath } = await window.api.getAppInfo()
|
||||||
const url = `file://${appPath}/resources/graphrag.html?apiUrl=${apiUrl}&modelId=${modalId}`
|
const url = `file://${appPath}/resources/graphrag.html?apiUrl=${apiUrl}&modelId=${modalId}`
|
||||||
|
|
||||||
const app: MinAppType = {
|
const app: MinAppType = {
|
||||||
|
id: 'graphrag',
|
||||||
name: t('words.knowledgeGraph'),
|
name: t('words.knowledgeGraph'),
|
||||||
logo: '',
|
logo: '',
|
||||||
url
|
url
|
||||||
}
|
}
|
||||||
|
|
||||||
MinApp.start(app)
|
openMinapp(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!modalId) {
|
if (!modalId) {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { DownOutlined, UpOutlined } from '@ant-design/icons'
|
import { DownOutlined, UpOutlined } from '@ant-design/icons'
|
||||||
|
import CopyIcon from '@renderer/components/Icons/CopyIcon'
|
||||||
import { isEmbeddingModel, isFunctionCallingModel, isReasoningModel, isVisionModel } from '@renderer/config/models'
|
import { isEmbeddingModel, isFunctionCallingModel, isReasoningModel, isVisionModel } from '@renderer/config/models'
|
||||||
import { Model, ModelType } from '@renderer/types'
|
import { Model, ModelType } from '@renderer/types'
|
||||||
import { getDefaultGroupName } from '@renderer/utils'
|
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 { FC, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
interface ModelEditContentProps {
|
interface ModelEditContentProps {
|
||||||
model: Model
|
model: Model
|
||||||
onUpdateModel: (model: Model) => void
|
onUpdateModel: (model: Model) => void
|
||||||
@@ -65,17 +65,29 @@ const ModelEditContent: FC<ModelEditContentProps> = ({ model, onUpdateModel, ope
|
|||||||
label={t('settings.models.add.model_id')}
|
label={t('settings.models.add.model_id')}
|
||||||
tooltip={t('settings.models.add.model_id.tooltip')}
|
tooltip={t('settings.models.add.model_id.tooltip')}
|
||||||
rules={[{ required: true }]}>
|
rules={[{ required: true }]}>
|
||||||
<Input
|
<Flex justify="space-between" gap={5}>
|
||||||
placeholder={t('settings.models.add.model_id.placeholder')}
|
<Input
|
||||||
spellCheck={false}
|
placeholder={t('settings.models.add.model_id.placeholder')}
|
||||||
maxLength={200}
|
spellCheck={false}
|
||||||
disabled={true}
|
maxLength={200}
|
||||||
onChange={(e) => {
|
disabled={true}
|
||||||
const value = e.target.value
|
value={model.id}
|
||||||
form.setFieldValue('name', value)
|
onChange={(e) => {
|
||||||
form.setFieldValue('group', getDefaultGroupName(value))
|
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>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="name"
|
name="name"
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ import { useProvider } from '@renderer/hooks/useProvider'
|
|||||||
import { ModelCheckStatus } from '@renderer/services/HealthCheckService'
|
import { ModelCheckStatus } from '@renderer/services/HealthCheckService'
|
||||||
import { useAppDispatch } from '@renderer/store'
|
import { useAppDispatch } from '@renderer/store'
|
||||||
import { setModel } from '@renderer/store/assistants'
|
import { setModel } from '@renderer/store/assistants'
|
||||||
import { Model, Provider } from '@renderer/types'
|
import { Model } from '@renderer/types'
|
||||||
import { maskApiKey } from '@renderer/utils/api'
|
import { maskApiKey } from '@renderer/utils/api'
|
||||||
import { Avatar, Button, Card, Flex, Space, Tooltip, Typography } from 'antd'
|
import { Avatar, Button, Card, Flex, Space, Tooltip, Typography } from 'antd'
|
||||||
import { groupBy, sortBy, toPairs } from 'lodash'
|
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 { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ const STATUS_COLORS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ModelListProps {
|
interface ModelListProps {
|
||||||
provider: Provider
|
providerId: string
|
||||||
modelStatuses?: ModelStatus[]
|
modelStatuses?: ModelStatus[]
|
||||||
searchText?: string
|
searchText?: string
|
||||||
}
|
}
|
||||||
@@ -166,10 +166,9 @@ function useModelStatusRendering() {
|
|||||||
return { renderStatusIndicator, renderLatencyText }
|
return { renderStatusIndicator, renderLatencyText }
|
||||||
}
|
}
|
||||||
|
|
||||||
const ModelList: React.FC<ModelListProps> = ({ provider: _provider, modelStatuses = [], searchText = '' }) => {
|
const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], searchText = '' }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { provider } = useProvider(_provider.id)
|
const { provider, updateProvider, models, removeModel } = useProvider(providerId)
|
||||||
const { updateProvider, models, removeModel } = useProvider(_provider.id)
|
|
||||||
const { assistants } = useAssistants()
|
const { assistants } = useAssistants()
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { defaultModel, setDefaultModel } = useDefaultModel()
|
const { defaultModel, setDefaultModel } = useDefaultModel()
|
||||||
@@ -180,59 +179,64 @@ const ModelList: React.FC<ModelListProps> = ({ provider: _provider, modelStatuse
|
|||||||
const modelsWebsite = providerConfig?.websites?.models
|
const modelsWebsite = providerConfig?.websites?.models
|
||||||
|
|
||||||
const [editingModel, setEditingModel] = useState<Model | null>(null)
|
const [editingModel, setEditingModel] = useState<Model | null>(null)
|
||||||
const [debouncedSearchText, setDebouncedSearchText] = useState(searchText)
|
|
||||||
|
|
||||||
useEffect(() => {
|
const modelGroups = useMemo(() => {
|
||||||
const timer = setTimeout(() => {
|
const filteredModels = searchText
|
||||||
setDebouncedSearchText(searchText)
|
? models.filter((model) => model.name.toLowerCase().includes(searchText.toLowerCase()))
|
||||||
}, 50)
|
: models
|
||||||
|
return groupBy(filteredModels, 'group')
|
||||||
|
}, [searchText, models])
|
||||||
|
|
||||||
return () => clearTimeout(timer)
|
const sortedModelGroups = useMemo(() => {
|
||||||
}, [searchText])
|
return sortBy(toPairs(modelGroups), [0]).reduce((acc, [key, value]) => {
|
||||||
|
acc[key] = value
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
}, [modelGroups])
|
||||||
|
|
||||||
const filteredModels = debouncedSearchText
|
const onManageModel = useCallback(() => {
|
||||||
? models.filter((model) => model.name.toLowerCase().includes(debouncedSearchText.toLowerCase()))
|
EditModelsPopup.show({ provider })
|
||||||
: models
|
}, [provider])
|
||||||
|
|
||||||
const modelGroups = groupBy(filteredModels, 'group')
|
const onAddModel = useCallback(
|
||||||
const sortedModelGroups = sortBy(toPairs(modelGroups), [0]).reduce((acc, [key, value]) => {
|
() => AddModelPopup.show({ title: t('settings.models.add.add_model'), provider }),
|
||||||
acc[key] = value
|
[provider, t]
|
||||||
return acc
|
)
|
||||||
}, {})
|
|
||||||
|
|
||||||
const onManageModel = () => EditModelsPopup.show({ provider })
|
const onEditModel = useCallback((model: Model) => {
|
||||||
const onAddModel = () => AddModelPopup.show({ title: t('settings.models.add.add_model'), provider })
|
|
||||||
const onEditModel = (model: Model) => {
|
|
||||||
setEditingModel(model)
|
setEditingModel(model)
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const onUpdateModel = (updatedModel: Model) => {
|
const onUpdateModel = useCallback(
|
||||||
const updatedModels = models.map((m) => {
|
(updatedModel: Model) => {
|
||||||
if (m.id === updatedModel.id) {
|
const updatedModels = models.map((m) => {
|
||||||
return updatedModel
|
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
|
},
|
||||||
})
|
[models, updateProvider, provider, assistants, defaultModel?.id, defaultModel?.provider, dispatch, setDefaultModel]
|
||||||
|
)
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -396,4 +400,4 @@ const ModelLatencyText = styled(Typography.Text)`
|
|||||||
color: var(--color-text-secondary);
|
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 { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import { useProvider } from '@renderer/hooks/useProvider'
|
import { useProvider } from '@renderer/hooks/useProvider'
|
||||||
import i18n from '@renderer/i18n'
|
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 { checkApi, formatApiKeys } from '@renderer/services/ApiService'
|
||||||
import { checkModelsHealth, ModelCheckStatus } from '@renderer/services/HealthCheckService'
|
import { checkModelsHealth, ModelCheckStatus } from '@renderer/services/HealthCheckService'
|
||||||
import { isProviderSupportAuth, isProviderSupportCharge } from '@renderer/services/ProviderService'
|
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 { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd'
|
||||||
import Link from 'antd/es/typography/Link'
|
import Link from 'antd/es/typography/Link'
|
||||||
import { debounce, isEmpty } from 'lodash'
|
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 { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
@@ -51,7 +51,8 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
|||||||
const [apiVersion, setApiVersion] = useState(provider.apiVersion)
|
const [apiVersion, setApiVersion] = useState(provider.apiVersion)
|
||||||
const [apiValid, setApiValid] = useState(false)
|
const [apiValid, setApiValid] = useState(false)
|
||||||
const [apiChecking, setApiChecking] = 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 { updateProvider, models } = useProvider(provider.id)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
@@ -387,7 +388,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
|||||||
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
|
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||||
<Space>
|
<Space>
|
||||||
<span>{t('common.models')}</span>
|
<span>{t('common.models')}</span>
|
||||||
{!isEmpty(models) && <ModelListSearchBar onSearch={setSearchText} />}
|
{!isEmpty(models) && <ModelListSearchBar onSearch={setModelSearchText} />}
|
||||||
</Space>
|
</Space>
|
||||||
{!isEmpty(models) && (
|
{!isEmpty(models) && (
|
||||||
<Tooltip title={t('settings.models.check.button_caption')} mouseEnterDelay={0.5}>
|
<Tooltip title={t('settings.models.check.button_caption')} mouseEnterDelay={0.5}>
|
||||||
@@ -402,7 +403,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
|||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</SettingSubtitle>
|
</SettingSubtitle>
|
||||||
<ModelList provider={provider} modelStatuses={modelStatuses} searchText={searchText} />
|
<ModelList providerId={provider.id} modelStatuses={modelStatuses} searchText={deferredModelSearchText} />
|
||||||
</SettingContainer>
|
</SettingContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
AppstoreOutlined,
|
||||||
CloudOutlined,
|
CloudOutlined,
|
||||||
CodeOutlined,
|
CodeOutlined,
|
||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
@@ -11,7 +12,9 @@ import {
|
|||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||||
import { isLocalAi } from '@renderer/config/env'
|
import { isLocalAi } from '@renderer/config/env'
|
||||||
|
import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
|
||||||
import ModelSettings from '@renderer/pages/settings/ModelSettings/ModelSettings'
|
import ModelSettings from '@renderer/pages/settings/ModelSettings/ModelSettings'
|
||||||
|
// 导入useAppSelector
|
||||||
import { FC } from 'react'
|
import { FC } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Link, Route, Routes, useLocation } from 'react-router-dom'
|
import { Link, Route, Routes, useLocation } from 'react-router-dom'
|
||||||
@@ -23,6 +26,7 @@ import DisplaySettings from './DisplaySettings/DisplaySettings'
|
|||||||
import GeneralSettings from './GeneralSettings'
|
import GeneralSettings from './GeneralSettings'
|
||||||
import MCPSettings from './MCPSettings'
|
import MCPSettings from './MCPSettings'
|
||||||
import { McpSettingsNavbar } from './MCPSettings/McpSettingsNavbar'
|
import { McpSettingsNavbar } from './MCPSettings/McpSettingsNavbar'
|
||||||
|
import MiniAppSettings from './MiniappSettings/MiniAppSettings'
|
||||||
import ProvidersList from './ProviderSettings'
|
import ProvidersList from './ProviderSettings'
|
||||||
import QuickAssistantSettings from './QuickAssistantSettings'
|
import QuickAssistantSettings from './QuickAssistantSettings'
|
||||||
import ShortcutSettings from './ShortcutSettings'
|
import ShortcutSettings from './ShortcutSettings'
|
||||||
@@ -32,6 +36,8 @@ const SettingsPage: FC = () => {
|
|||||||
const { pathname } = useLocation()
|
const { pathname } = useLocation()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const showMiniAppSettings = useSidebarIconShow('minapp')
|
||||||
|
|
||||||
const isRoute = (path: string): string => (pathname.startsWith(path) ? 'active' : '')
|
const isRoute = (path: string): string => (pathname.startsWith(path) ? 'active' : '')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -82,6 +88,14 @@ const SettingsPage: FC = () => {
|
|||||||
{t('settings.display.title')}
|
{t('settings.display.title')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</MenuItemLink>
|
</MenuItemLink>
|
||||||
|
{showMiniAppSettings && (
|
||||||
|
<MenuItemLink to="/settings/miniapps">
|
||||||
|
<MenuItem className={isRoute('/settings/miniapps')}>
|
||||||
|
<AppstoreOutlined />
|
||||||
|
{t('settings.miniapps.title')}
|
||||||
|
</MenuItem>
|
||||||
|
</MenuItemLink>
|
||||||
|
)}
|
||||||
<MenuItemLink to="/settings/shortcut">
|
<MenuItemLink to="/settings/shortcut">
|
||||||
<MenuItem className={isRoute('/settings/shortcut')}>
|
<MenuItem className={isRoute('/settings/shortcut')}>
|
||||||
<MacCommandOutlined />
|
<MacCommandOutlined />
|
||||||
@@ -115,9 +129,10 @@ const SettingsPage: FC = () => {
|
|||||||
<Route path="mcp" element={<MCPSettings />} />
|
<Route path="mcp" element={<MCPSettings />} />
|
||||||
<Route path="general/*" element={<GeneralSettings />} />
|
<Route path="general/*" element={<GeneralSettings />} />
|
||||||
<Route path="display" element={<DisplaySettings />} />
|
<Route path="display" element={<DisplaySettings />} />
|
||||||
<Route path="data/*" element={<DataSettings />} />
|
{showMiniAppSettings && <Route path="miniapps" element={<MiniAppSettings />} />}
|
||||||
<Route path="quickAssistant" element={<QuickAssistantSettings />} />
|
|
||||||
<Route path="shortcut" element={<ShortcutSettings />} />
|
<Route path="shortcut" element={<ShortcutSettings />} />
|
||||||
|
<Route path="quickAssistant" element={<QuickAssistantSettings />} />
|
||||||
|
<Route path="data/*" element={<DataSettings />} />
|
||||||
<Route path="about" element={<AboutSettings />} />
|
<Route path="about" element={<AboutSettings />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</SettingContent>
|
</SettingContent>
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ export default class AnthropicProvider extends BaseProvider {
|
|||||||
return onChunk({
|
return onChunk({
|
||||||
text,
|
text,
|
||||||
reasoning_content,
|
reasoning_content,
|
||||||
usage: message.usage,
|
usage: message.usage as any,
|
||||||
metrics: {
|
metrics: {
|
||||||
completion_tokens: message.usage.output_tokens,
|
completion_tokens: message.usage.output_tokens,
|
||||||
time_completion_millsec,
|
time_completion_millsec,
|
||||||
@@ -54,7 +54,7 @@ export default class GeminiProvider extends BaseProvider {
|
|||||||
super(provider)
|
super(provider)
|
||||||
this.sdk = new GoogleGenerativeAI(this.apiKey)
|
this.sdk = new GoogleGenerativeAI(this.apiKey)
|
||||||
/// this sdk is experimental
|
/// this sdk is experimental
|
||||||
this.imageSdk = new GoogleGenAI({ apiKey: this.apiKey })
|
this.imageSdk = new GoogleGenAI({ apiKey: this.apiKey, httpOptions: { baseUrl: this.getBaseURL() } })
|
||||||
this.requestOptions = {
|
this.requestOptions = {
|
||||||
baseUrl: this.getBaseURL()
|
baseUrl: this.getBaseURL()
|
||||||
}
|
}
|
||||||
@@ -731,6 +731,7 @@ export default class GeminiProvider extends BaseProvider {
|
|||||||
onChunk({
|
onChunk({
|
||||||
text,
|
text,
|
||||||
generateImage: {
|
generateImage: {
|
||||||
|
type: 'base64',
|
||||||
images
|
images
|
||||||
},
|
},
|
||||||
usage: {
|
usage: {
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
Suggestion
|
Suggestion
|
||||||
} from '@renderer/types'
|
} from '@renderer/types'
|
||||||
import { removeSpecialCharactersForTopicName } from '@renderer/utils'
|
import { removeSpecialCharactersForTopicName } from '@renderer/utils'
|
||||||
|
import { addImageFileToContents } from '@renderer/utils/formats'
|
||||||
import {
|
import {
|
||||||
callMCPTool,
|
callMCPTool,
|
||||||
mcpToolsToOpenAITools,
|
mcpToolsToOpenAITools,
|
||||||
@@ -354,7 +355,7 @@ export default class OpenAIProvider extends BaseProvider {
|
|||||||
const defaultModel = getDefaultModel()
|
const defaultModel = getDefaultModel()
|
||||||
const model = assistant.model || defaultModel
|
const model = assistant.model || defaultModel
|
||||||
const { contextCount, maxTokens, streamOutput } = getAssistantSettings(assistant)
|
const { contextCount, maxTokens, streamOutput } = getAssistantSettings(assistant)
|
||||||
|
messages = addImageFileToContents(messages)
|
||||||
let systemMessage = assistant.prompt ? { role: 'system', content: assistant.prompt } : undefined
|
let systemMessage = assistant.prompt ? { role: 'system', content: assistant.prompt } : undefined
|
||||||
|
|
||||||
if (isOpenAIoSeries(model)) {
|
if (isOpenAIoSeries(model)) {
|
||||||
@@ -1,9 +1,47 @@
|
|||||||
import BaseProvider from '@renderer/providers/BaseProvider'
|
import type { GroundingMetadata } from '@google/generative-ai'
|
||||||
import ProviderFactory from '@renderer/providers/ProviderFactory'
|
import BaseProvider from '@renderer/providers/AiProvider/BaseProvider'
|
||||||
import { Assistant, GenerateImageParams, Message, Model, Provider, Suggestion } from '@renderer/types'
|
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 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 {
|
export default class AiProvider {
|
||||||
private sdk: BaseProvider
|
private sdk: BaseProvider
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user