Compare commits

...

64 Commits

Author SHA1 Message Date
Neal_Tan
370cfd6e9f Merge pull request #4331 from CherryHQ/main
Merge main code
2025-04-02 22:06:47 +01:00
Yuzhong Zhang
5cdf4eff77 fix(CodeBlock): incorrect behavior of message in multiple models (#4328)
* 修复多模型对比时的复制按钮sticky行为,并添加注释

* 同时修复横向滚动条消失的问题

* 增加布局判断
2025-04-03 01:01:04 +08:00
LiuVaayne
b53dbcbb30 fix(mcp-tools): enhance tool lookup to match by name in addition to ID (#4323) 2025-04-02 23:20:07 +08:00
Bowie He
a42283e789 feat:add default doubao model to model list 2025-04-02 18:56:30 +08:00
kanweiwei
d2ed9972bd fix(NutstoreService): Fix slash handling in path processing #4208 2025-04-02 13:38:29 +08:00
fullex
0fd9b6e56c feat: miniWindow Pin/Resize (#3201)
feat: [#2030] miniWindow pin/resizable/copy toast/move optimized
2025-04-02 10:26:56 +08:00
Neal_Tan
d213bc1024 Merge branch 'main' into feat/variable_replace_prompt 2025-04-01 18:57:46 +01:00
shiquda
91b9a48c48 fix(Knowledge): enable text selection in knowledge base search results (#4281) 2025-04-01 23:16:34 +08:00
kangfenmao
e572b3801b lint: Added an eslint disable comment in MinappPopupContainer to address 2025-04-01 21:05:36 +08:00
亢奋猫
4bf15aed25 refactor(MCPService, process): Updated MCPService to conditionally set the NPM_CONFIG_REGISTRY
* refactor(MCPService, process): enhance registry URL handling and improve getBinaryPath function

- Updated MCPService to conditionally set the NPM_CONFIG_REGISTRY based on server name, improving flexibility for auto-install scenarios.
- Modified getBinaryPath function to handle optional name parameter, returning a default path when no name is provided, enhancing usability.

* refactor(MCPService, utils): add directory existence check for registry file

- Introduced makeSureDirExists utility function to ensure the specified directory exists, enhancing robustness.
- Updated MCPService to utilize this function when setting the registry URL for the mcp-auto-install server, improving error handling.

* feat:change MCP_REGISTRY_PATH

* refactor(MCPService): streamline environment variable setup for mcp-auto-install

- Updated MCPService to conditionally set NPM_CONFIG_REGISTRY and MCP_REGISTRY_PATH in a more concise manner.
- Enhanced readability by removing redundant code while maintaining functionality.

---------

Co-authored-by: lizhixuan <zhixuan.li@banosuperapp.com>
2025-04-01 20:57:56 +08:00
Chen Tao
6d568688ed fix(KnowledgeContent): adjust VirtualList height based on item count (#4270) 2025-04-01 19:47:58 +08:00
kangfenmao
f20cbf31a8 refactor(McpSettings): simplify args form item and adjust navbar padding for Windows
- Removed unnecessary validation rules from the args Form.Item in McpSettings for cleaner code.
- Updated McpSettingsNavbar to conditionally adjust padding based on the operating system.

close #4244
2025-04-01 17:03:27 +08:00
kangfenmao
bfbfba13fe feat(Inputbar, MCPToolsButton, AssistantMCPSettings): integrate active MCP server handling and UI updates
- Added active MCP server filtering in Inputbar for message sending.
- Updated MCPToolsButton to reflect availability of enabled MCPs.
- Refactored AssistantMCPSettings to streamline MCP server updates and adjusted UI styles for consistency.
2025-04-01 16:57:12 +08:00
Hobee Liu
8b9929cc7b feat: add chat navigation bar close (#4019)
* feat(聊天导航): 新增关闭、置顶和置底按钮并更新图标

在聊天导航组件中新增了关闭、置顶和置底按钮,并更新了相关图标以提升用户体验。同时,添加了点击关闭按钮时隐藏导航的功能。

* feat(消息导航): 添加手动关闭状态以避免误触

在 ChatNavigation 组件中添加 `manuallyClosedUntil` 状态,用于在用户手动关闭导航后,1分钟内不响应鼠标靠近事件。这可以防止用户在操作时误触导航栏,提升用户体验。

* refactor(ChatNavigation): 重命名函数并添加滚动处理逻辑

重命名 handleChatNavigationClick 为 handleCloseChatNavigation 以提高代码可读性,并添加 handleScrollToTop 和 handleScrollToBottom 函数以处理滚动逻辑

* fix: 修复滚动到顶部时位置不正确的问题

将 `scrollToTop` 函数中的 `top` 值从 `0` 改为 `-container.scrollHeight`,以确保滚动到顶部时位置正确

* docs(i18n): 添加新的翻译字符串以支持更多操作

在多个语言文件中添加了“回到顶部”、“回到底部”和“关闭”的翻译字符串,以支持更多用户界面操作。

* refactor: 移除未使用的变量以简化代码
```

解释:
- **类型**: `refactor`,因为这是代码重构,移除了未使用的变量,没有改变功能行为。
- **描述**: 移除了未使用的变量以简化代码,符合简洁和可维护性的原则。
2025-04-01 16:17:57 +08:00
Cherry
a90be7e83f feat: One-click copy model id (#4190)
* feat:One-click copy model id

* fix:model id消失问题,样式问题
2025-04-01 16:11:06 +08:00
fullex
efa68c8519 fix: chat history dark theme (#4254) 2025-04-01 14:55:24 +08:00
LiuVaayne
d7bd240a9a refactor: enhance mcp init (#4238)
* fix(MCPService): extend command support to include 'bun' and 'bunx', and improve environment variable handling

* fix(MCPService): enhance environment variable handling by incorporating default environment settings

* fix(hooks): simplify active MCP servers selection logic
2025-04-01 13:08:25 +08:00
kangfenmao
95df69ff82 refactor: move websearch provider code to providers folder 2025-04-01 11:05:31 +08:00
kangfenmao
e41df917b4 feat(i18n): add topic naming model support for message title generation in Japanese, Russian, and Traditional Chinese locales 2025-04-01 10:41:30 +08:00
kangfenmao
0a33649b3c fix(settings): enable clickAssistantToShowTopic by default 2025-04-01 10:06:24 +08:00
kangfenmao
d1cb7258d2 fix(Inputbar): simplify assistant state reset logic in useEffect 2025-04-01 09:57:21 +08:00
shiquda
8fbedb2bd0 feat: add support for title generation when exporting single message
#3992
2025-04-01 07:34:57 +08:00
MyPrototypeWhat
750247aef8 feat: add React Developer Tools extension support and optimize CodeBlock component 2025-04-01 07:33:16 +08:00
suyao
32e1f428e7 fix(styles): set dropdown menu width to max-content for better layout 2025-03-31 23:25:10 +08:00
suyao
aee6219a75 fix(styles): improve scrollbar visibility by adjusting opacity and background color on hover 2025-03-31 23:25:10 +08:00
suyao
5329fa7ede fix(UI): enhance scrollbar visibility and dropdown menu overflow handling 2025-03-31 23:25:10 +08:00
SuYao
ba640d4070 refactor(MCP): enhance schema validation for gemini (#4153) 2025-03-31 21:13:59 +08:00
LiuVaayne
8c5f61d407 feat(MCP): add registryUrl support for package management (#4200) 2025-03-31 21:13:20 +08:00
one
b43ecb75f5 perf: improve modellist search bar responsiveness (and memorization) (#4221) 2025-03-31 21:11:46 +08:00
Yuzhong Zhang
3dc4947e26 optimize: Sticky CopyButton in CodeBlock (#4205) 2025-03-31 21:11:28 +08:00
LiuVaayne
a5b0480418 Feat/assistant level mcp (#4220) 2025-03-31 21:10:33 +08:00
fullex
8a7db19e73 fix: Resolve a series of miniWindow display issues and improve app behavior across platforms (#3072) 2025-03-31 21:07:16 +08:00
MyPrototypeWhat
2da8a73124 feat(MCP): add auto-install server configuration and migration for ve… (#4156)
* feat(MCP): add auto-install server configuration and migration for version 87

* update persistReducer version
2025-03-31 18:07:50 +08:00
fullex
5223a3c5a6 feat: minapp show&copy current REAL url and can open it 2025-03-31 18:01:10 +08:00
fullex
72c5de3b81 optimize: reduce animation gpu load of sidebar minapp 2025-03-31 17:47:41 +08:00
one
9f11e7c22b perf(Tabs): improve responsiveness when switching items rapidly 2025-03-31 09:33:17 +08:00
fullex
1ce86c11ca fix: zoomfactor should not change when resize (#4159)
* fix: zoomfactor should not change when resize

* add linux fallback support
2025-03-31 09:24:49 +08:00
suyao
57c1b59a51 fix(models): reorganize gemini websearch model lists 2025-03-30 23:52:58 +08:00
kangfenmao
a2f9067908 chore(version): 1.1.17 2025-03-30 14:39:43 +08:00
kangfenmao
2a4c512e49 refactor(BackupManager): switch to stream-based file writing for improved performance
* Updated BackupManager to use streams for writing data to temporary and backup files, enhancing efficiency and error handling.
* Replaced synchronous file writing with asynchronous stream operations to prevent blocking the event loop.
2025-03-30 14:37:20 +08:00
kangfenmao
94eb7f3a34 refactor(knowledge): enhance CustomCollapse component and improve UI consistency
* Updated CustomCollapse to accept React nodes for labels, allowing for more flexible content.
* Replaced static labels with CollapseLabel component to display item counts.
* Introduced EmptyView component for consistent empty state representation across collapsible sections.
* Removed unnecessary styles and improved button click handling to prevent event propagation.
2025-03-30 14:32:57 +08:00
kangfenmao
b363cb06a4 chore(store): update migration logic and increment version to 87
* Updated migration functions to include error handling for provider additions.
* Incremented the version number in the persisted reducer configuration.
2025-03-30 14:08:14 +08:00
Hao He
9e977f4b35 feat: Add keyboard navigation and selection highlighting for AddAssistantPopup (#4022)
* feat(AddAssistantPopup): 添加键盘导航和选中项高亮功能

* feat(AddAssistantPopup): 为所有项添加相同宽度的透明边框,避免布局跳动。
2025-03-30 13:58:52 +08:00
Teo
00de616958 refactor(files): Reconstruct file system UI (#4100)
* refactor(files): Reconstruct file system UI

* refactor(knowledge): replace Card components with CustomCollapse for better UI structure

* refactor(files): update folder icon from FolderOpenOutlined to FolderOpenFilled

* feat(components): add CustomCollapse component for enhanced collapsible UI

* refactor(files): implement virtual scrolling in FileList and KnowledgeContent components
2025-03-30 13:56:34 +08:00
Neal_Tan
1187a47698 Merge pull request #4129 from TeacherTan/main
feat(Assistant): Variables replace prompts
2025-03-30 02:15:18 +01:00
Neal_Tan
83d0eb07aa fix(i18n): update locales json file
关联提交 8f6bf113
2025-03-30 02:10:50 +01:00
Neal_Tan
8f6bf11320 feat(Assistant): 增加提示词变量输入
- 在编辑助手处添加了变量
- 保存智能体时可以保存变量
Fixed #4049
2025-03-30 00:51:48 +00:00
George·Dong
22b0bd54b4 refactor(settings): 重构小程序设置 (#4092) 2025-03-30 08:48:23 +08:00
kangfenmao
be39c5f40c feat(MCPService): enhance PATH management with platform-specific directories 2025-03-30 08:32:45 +08:00
kangfenmao
8b00ff4b93 fix(MCPSettings): ensure server name is set when missing and reorder radio options 2025-03-30 08:26:01 +08:00
Vaayne
f5b675b356 fix(MCPService): clear cache on server close and refactor tool fetching logic 2025-03-30 07:42:27 +08:00
LiuVaayne
de8dbb2646 fix(MCPService): prefix tool IDs with 'f' for consistency (#4121) 2025-03-30 00:26:47 +08:00
fullex
7e67005e70 fix(UI/markdown): markdown not recognized ** as emphasis marks in CJK (#4119) 2025-03-29 23:56:41 +08:00
yangtb2024
d6e66f3a4d feat(config): 增强模型支持 (#4085)
* feat(config): 添加对新模型的支持

- 新增 gemini-2.5 到 visionAllowedModels
- 新增 gpt-4.5 到 visionAllowedModels 和 FUNCTION_CALLING_MODELS
- 新增 o1 到 FUNCTION_CALLING_MODELS
- 从 visionExcludedModels 和 FUNCTION_CALLING_EXCLUDED_MODELS 中排除 o1-mini, o1-preview, AIDC-AI/Marco-o1

* feat(config): 添加对 deepseek-ai 函数调用的支持

- 新增 deepseek-ai 到 FUNCTION_CALLING_MODELS
2025-03-29 23:04:51 +08:00
one
e5aaec2129 fix: race condition in topic auto renaming 2025-03-29 22:58:38 +08:00
Herio
464634d051 feat(ApiCheckPopup): 使用Promise.all并行处理API验证请求并更新状态 2025-03-29 22:00:21 +08:00
fullex
3698238e9e fix: one-off minapp should not show minimize button 2025-03-29 21:59:03 +08:00
George·Dong
ae2a661201 fix(ApiService): context clear failed 2025-03-29 21:43:29 +08:00
Chen Tao
d6dbe357fb fix: add base url for gemini (#4109) 2025-03-29 21:35:04 +08:00
Catwine
e9dd795f9a docs(config): fix typo in electron-builder.yml 2025-03-29 21:08:55 +08:00
kangfenmao
03a18c1f3b fix(WebviewContainer): update webview partition to use a generic identifier 2025-03-29 19:00:08 +08:00
kangfenmao
e3ba44fc2c chore(version): 1.1.16 2025-03-29 15:29:28 +08:00
kangfenmao
9976ad9ed0 fix(migrate): add error handling to migration functions and ensure state integrity during updates 2025-03-29 15:28:57 +08:00
kangfenmao
3bb294e698 chore(version): 1.1.15 2025-03-29 15:00:02 +08:00
105 changed files with 4249 additions and 1541 deletions

View File

@@ -86,3 +86,4 @@ releaseInfo:
小程序支持多开 小程序支持多开
支持 GPT-4o 图像生成 支持 GPT-4o 图像生成
修复 MCP 服务器无法使用问题 修复 MCP 服务器无法使用问题
修复升级导致旧版本数据丢失问题

View File

@@ -1,6 +1,6 @@
{ {
"name": "CherryStudio", "name": "CherryStudio",
"version": "1.1.14", "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",

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,12 @@
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 { nanoid } from '@reduxjs/toolkit' import { nanoid } from '@reduxjs/toolkit'
import { MCPServer, MCPTool } from '@types' import { MCPServer, MCPTool } from '@types'
import { app } from 'electron' import { app } from 'electron'
@@ -20,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
}) })
@@ -67,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
@@ -81,22 +79,45 @@ 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')
@@ -122,6 +143,8 @@ class McpService {
await client.close() await client.close()
Logger.info(`[MCP] Closed server: ${serverKey}`) Logger.info(`[MCP] Closed server: ${serverKey}`)
this.clients.delete(serverKey) this.clients.delete(serverKey)
CacheService.remove(`mcp:list_tool:${serverKey}`)
Logger.info(`[MCP] Cleared cache for server: ${serverKey}`)
} else { } else {
Logger.warn(`[MCP] No client found for server: ${serverKey}`) Logger.warn(`[MCP] No client found for server: ${serverKey}`)
} }
@@ -150,7 +173,8 @@ class McpService {
async listTools(_: Electron.IpcMainInvokeEvent, server: MCPServer) { async listTools(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
const client = await this.initClient(server) const client = await this.initClient(server)
const cacheKey = `mcp:list_tool:${server.id}` const serverKey = this.getServerKey(server)
const cacheKey = `mcp:list_tool:${serverKey}`
if (CacheService.has(cacheKey)) { if (CacheService.has(cacheKey)) {
Logger.info(`[MCP] Tools from ${server.name} loaded from cache`) Logger.info(`[MCP] Tools from ${server.name} loaded from cache`)
const cachedTools = CacheService.get<MCPTool[]>(cacheKey) const cachedTools = CacheService.get<MCPTool[]>(cacheKey)
@@ -164,7 +188,7 @@ class McpService {
tools.map((tool: any) => { tools.map((tool: any) => {
const serverTool: MCPTool = { const serverTool: MCPTool = {
...tool, ...tool,
id: nanoid(), id: `f${nanoid()}`,
serverId: server.id, serverId: server.id,
serverName: server.name serverName: server.name
} }
@@ -200,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()

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -192,3 +192,10 @@
} }
} }
} }
.ant-dropdown-menu .ant-dropdown-menu-sub {
max-height: 350px;
width: max-content;
overflow-y: auto;
overflow-x: hidden;
}

View 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)

View File

@@ -1,6 +1,7 @@
import { import {
CloseOutlined, CloseOutlined,
CodeOutlined, CodeOutlined,
CopyOutlined,
ExportOutlined, ExportOutlined,
MinusOutlined, MinusOutlined,
PushpinOutlined, PushpinOutlined,
@@ -42,6 +43,9 @@ const MinappPopupContainer: React.FC = () => {
const [isPopupShow, setIsPopupShow] = useState(true) const [isPopupShow, setIsPopupShow] = useState(true)
/** whether the current minapp is ready */ /** whether the current minapp is ready */
const [isReady, setIsReady] = useState(false) 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 */ /** store the last minapp id and show status */
const lastMinappId = useRef<string | null>(null) const lastMinappId = useRef<string | null>(null)
@@ -59,6 +63,11 @@ const MinappPopupContainer: React.FC = () => {
/** set the popup display status */ /** set the popup display status */
useEffect(() => { useEffect(() => {
if (minappShow) { if (minappShow) {
// init the current url
if (currentMinappId && currentAppInfo) {
setCurrentUrl(currentAppInfo.url)
}
setIsPopupShow(true) setIsPopupShow(true)
if (webviewLoadedRefs.current.get(currentMinappId)) { if (webviewLoadedRefs.current.get(currentMinappId)) {
@@ -77,6 +86,7 @@ const MinappPopupContainer: React.FC = () => {
lastMinappId.current = currentMinappId lastMinappId.current = currentMinappId
lastMinappShow.current = minappShow lastMinappShow.current = minappShow
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [minappShow, currentMinappId]) }, [minappShow, currentMinappId])
useEffect(() => { useEffect(() => {
@@ -99,6 +109,9 @@ const MinappPopupContainer: React.FC = () => {
}) })
}, [currentMinappId]) }, [currentMinappId])
/** only the keepalive minapp can be minimized */
const canMinimize = !(openedOneOffMinapp && openedOneOffMinapp.id == currentMinappId)
/** combine the openedKeepAliveMinapps and openedOneOffMinapp */ /** combine the openedKeepAliveMinapps and openedOneOffMinapp */
const combinedApps = useMemo(() => { const combinedApps = useMemo(() => {
return [...openedKeepAliveMinapps, ...(openedOneOffMinapp ? [openedOneOffMinapp] : [])] return [...openedKeepAliveMinapps, ...(openedOneOffMinapp ? [openedOneOffMinapp] : [])]
@@ -165,6 +178,13 @@ const MinappPopupContainer: React.FC = () => {
} }
} }
/** the callback function to handle the webview navigate to new url */
const handleWebviewNavigate = (appid: string, url: string) => {
if (appid === currentMinappId) {
setCurrentUrl(url)
}
}
/** will open the devtools of the minapp */ /** will open the devtools of the minapp */
const handleOpenDevTools = (appid: string) => { const handleOpenDevTools = (appid: string) => {
const webview = webviewRefs.current.get(appid) const webview = webviewRefs.current.get(appid)
@@ -184,12 +204,9 @@ const MinappPopupContainer: React.FC = () => {
} }
} }
/** only open the current url */ /** open the giving url in browser */
const handleOpenLink = (appid: string) => { const handleOpenLink = (url: string) => {
const webview = webviewRefs.current.get(appid) window.api.openWebsite(url)
if (webview) {
window.api.openWebsite(webview.getURL())
}
} }
/** toggle the pin status of the minapp */ /** toggle the pin status of the minapp */
@@ -202,11 +219,41 @@ const MinappPopupContainer: React.FC = () => {
} }
/** Title bar of the popup */ /** Title bar of the popup */
const Title = ({ appInfo }: { appInfo: AppInfo | null }) => { const Title = ({ appInfo, url }: { appInfo: AppInfo | null; url: string | null }) => {
if (!appInfo) return null 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 ( return (
<TitleContainer style={{ justifyContent: 'space-between' }}> <TitleContainer style={{ justifyContent: 'space-between' }}>
<TitleText>{appInfo.name}</TitleText> <Tooltip
title={
<TitleTextTooltip>
{url ?? appInfo.url} <br />
<CopyOutlined className="icon-copy" />
{t('minapp.popup.rightclick_copyurl')}
</TitleTextTooltip>
}
mouseEnterDelay={0.8}
placement="rightBottom"
styles={{
root: {
maxWidth: '400px'
}
}}>
<TitleText onContextMenu={(e) => handleCopyUrl(e, url ?? appInfo.url)}>{appInfo.name}</TitleText>
</Tooltip>
<ButtonsGroup className={isWindows ? 'windows' : ''}> <ButtonsGroup className={isWindows ? 'windows' : ''}>
<Tooltip title={t('minapp.popup.refresh')} mouseEnterDelay={0.8} placement="bottom"> <Tooltip title={t('minapp.popup.refresh')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handleReload(appInfo.id)}> <Button onClick={() => handleReload(appInfo.id)}>
@@ -225,7 +272,7 @@ const MinappPopupContainer: React.FC = () => {
)} )}
{appInfo.canOpenExternalLink && ( {appInfo.canOpenExternalLink && (
<Tooltip title={t('minapp.popup.openExternal')} mouseEnterDelay={0.8} placement="bottom"> <Tooltip title={t('minapp.popup.openExternal')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handleOpenLink(appInfo.id)}> <Button onClick={() => handleOpenLink(url ?? appInfo.url)}>
<ExportOutlined /> <ExportOutlined />
</Button> </Button>
</Tooltip> </Tooltip>
@@ -237,11 +284,13 @@ const MinappPopupContainer: React.FC = () => {
</Button> </Button>
</Tooltip> </Tooltip>
)} )}
<Tooltip title={t('minapp.popup.minimize')} mouseEnterDelay={0.8} placement="bottom"> {canMinimize && (
<Button onClick={() => handlePopupMinimize()}> <Tooltip title={t('minapp.popup.minimize')} mouseEnterDelay={0.8} placement="bottom">
<MinusOutlined /> <Button onClick={() => handlePopupMinimize()}>
</Button> <MinusOutlined />
</Tooltip> </Button>
</Tooltip>
)}
<Tooltip title={t('minapp.popup.close')} mouseEnterDelay={0.8} placement="bottom"> <Tooltip title={t('minapp.popup.close')} mouseEnterDelay={0.8} placement="bottom">
<Button onClick={() => handlePopupClose(appInfo.id)}> <Button onClick={() => handlePopupClose(appInfo.id)}>
<CloseOutlined /> <CloseOutlined />
@@ -261,6 +310,7 @@ const MinappPopupContainer: React.FC = () => {
url={app.url} url={app.url}
onSetRefCallback={handleWebviewSetRef} onSetRefCallback={handleWebviewSetRef}
onLoadedCallback={handleWebviewLoaded} onLoadedCallback={handleWebviewLoaded}
onNavigateCallback={handleWebviewNavigate}
/> />
)) ))
@@ -270,7 +320,7 @@ const MinappPopupContainer: React.FC = () => {
return ( return (
<Drawer <Drawer
title={<Title appInfo={currentAppInfo} />} title={<Title appInfo={currentAppInfo} url={currentUrl} />}
placement="bottom" placement="bottom"
onClose={handlePopupMinimize} onClose={handlePopupMinimize}
open={isPopupShow} open={isPopupShow}
@@ -316,8 +366,18 @@ const TitleText = styled.div`
font-size: 14px; font-size: 14px;
color: var(--color-text-1); color: var(--color-text-1);
margin-right: 10px; margin-right: 10px;
user-select: none; -webkit-app-region: no-drag;
` `
const TitleTextTooltip = styled.span`
font-size: 0.8rem;
.icon-copy {
font-size: 0.7rem;
padding-right: 5px;
}
`
const ButtonsGroup = styled.div` const ButtonsGroup = styled.div`
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@@ -11,12 +11,14 @@ const WebviewContainer = memo(
appid, appid,
url, url,
onSetRefCallback, onSetRefCallback,
onLoadedCallback onLoadedCallback,
onNavigateCallback
}: { }: {
appid: string appid: string
url: string url: string
onSetRefCallback: (appid: string, element: WebviewTag | null) => void onSetRefCallback: (appid: string, element: WebviewTag | null) => void
onLoadedCallback: (appid: string) => void onLoadedCallback: (appid: string) => void
onNavigateCallback: (appid: string, url: string) => void
}) => { }) => {
const webviewRef = useRef<WebviewTag | null>(null) const webviewRef = useRef<WebviewTag | null>(null)
@@ -47,8 +49,13 @@ const WebviewContainer = memo(
onLoadedCallback(appid) onLoadedCallback(appid)
} }
const handleNavigate = (event: any) => {
onNavigateCallback(appid, event.url)
}
webviewRef.current.addEventListener('new-window', handleNewWindow) webviewRef.current.addEventListener('new-window', handleNewWindow)
webviewRef.current.addEventListener('did-finish-load', handleLoaded) webviewRef.current.addEventListener('did-finish-load', handleLoaded)
webviewRef.current.addEventListener('did-navigate-in-page', handleNavigate)
// we set the url when the webview is ready // we set the url when the webview is ready
webviewRef.current.src = url webviewRef.current.src = url
@@ -56,6 +63,7 @@ const WebviewContainer = memo(
return () => { return () => {
webviewRef.current?.removeEventListener('new-window', handleNewWindow) webviewRef.current?.removeEventListener('new-window', handleNewWindow)
webviewRef.current?.removeEventListener('did-finish-load', handleLoaded) 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 // because the appid and url are enough, no need to add onLoadedCallback
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -67,7 +75,7 @@ const WebviewContainer = memo(
ref={setRef(appid)} ref={setRef(appid)}
style={WebviewStyle} style={WebviewStyle}
allowpopups={'true' as any} allowpopups={'true' as any}
partition={`persist:webview-${appid}`} partition="persist:webview"
nodeintegration={'true' as any} nodeintegration={'true' as any}
/> />
) )

View File

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

View 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

View File

@@ -173,6 +173,7 @@ const MainMenus: FC = () => {
const SidebarOpenedMinappTabs: FC = () => { const SidebarOpenedMinappTabs: FC = () => {
const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime() const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime()
const { openMinappKeepAlive, hideMinappPopup, closeMinapp, closeAllMinapps } = useMinappPopup() const { openMinappKeepAlive, hideMinappPopup, closeMinapp, closeAllMinapps } = useMinappPopup()
const { showOpenedMinappsInSidebar } = useSettings() // 获取控制显示的设置
const { theme } = useTheme() const { theme } = useTheme()
const { t } = useTranslation() const { t } = useTranslation()
@@ -208,7 +209,10 @@ const SidebarOpenedMinappTabs: FC = () => {
container.style.setProperty('--indicator-right', `${indicatorRight}px`) container.style.setProperty('--indicator-right', `${indicatorRight}px`)
}, [currentMinappId, openedKeepAliveMinapps, minappShow]) }, [currentMinappId, openedKeepAliveMinapps, minappShow])
const isShowOpened = openedKeepAliveMinapps.length > 0 // 检查是否需要显示已打开小程序组件
const isShowOpened = showOpenedMinappsInSidebar && openedKeepAliveMinapps.length > 0
// 如果不需要显示,返回空容器保持动画效果但不显示内容
if (!isShowOpened) return <TabsContainer className="TabsContainer" /> if (!isShowOpened) return <TabsContainer className="TabsContainer" />
return ( return (
@@ -357,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,
@@ -388,18 +393,34 @@ const Icon = styled.div<{ theme: string }>`
@keyframes borderBreath { @keyframes borderBreath {
0% { 0% {
border-color: var(--color-primary-mute); opacity: 0.1;
} }
50% { 50% {
border-color: var(--color-primary); opacity: 1;
} }
100% { 100% {
border-color: var(--color-primary-mute); opacity: 0.1;
} }
} }
&.opened-animation { &.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); 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; animation: borderBreath 4s ease-in-out infinite;
} }
` `

View File

@@ -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)
} }
@@ -2044,34 +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',
'gemini-2.5-pro-exp',
'gemini-2.5-pro-exp-03-25'
]
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-')
} }

View File

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

View File

@@ -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 {

View File

@@ -1,4 +1,5 @@
import { useRuntime } from '@renderer/hooks/useRuntime' import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings' // 使用设置中的值
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { import {
setCurrentMinappId, setCurrentMinappId,
@@ -8,9 +9,6 @@ import {
} from '@renderer/store/runtime' } from '@renderer/store/runtime'
import { MinAppType } from '@renderer/types' import { MinAppType } from '@renderer/types'
/** The max number of keep alive minapps */
const MINAPP_MAX_KEEPALIVE = 3
/** /**
* Usage: * Usage:
* *
@@ -26,27 +24,26 @@ const MINAPP_MAX_KEEPALIVE = 3
* const { openedKeepAliveMinapps, openedOneOffMinapp, minappShow } = useRuntime() * const { openedKeepAliveMinapps, openedOneOffMinapp, minappShow } = useRuntime()
*/ */
export const useMinappPopup = () => { export const useMinappPopup = () => {
const { openedKeepAliveMinapps, openedOneOffMinapp, minappShow } = useRuntime()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { openedKeepAliveMinapps, openedOneOffMinapp, minappShow } = useRuntime()
const { maxKeepAliveMinapps } = useSettings() // 使用设置中的值
/** Open a minapp (popup shows and minapp loaded) */ /** Open a minapp (popup shows and minapp loaded) */
const openMinapp = (app: MinAppType, keepAlive: boolean = false) => { const openMinapp = (app: MinAppType, keepAlive: boolean = false) => {
if (keepAlive) { if (keepAlive) {
//if the minapp is already opened, do nothing // 如果小程序已经打开,只切换显示
if (openedKeepAliveMinapps.some((item) => item.id === app.id)) { if (openedKeepAliveMinapps.some((item) => item.id === app.id)) {
dispatch(setCurrentMinappId(app.id)) dispatch(setCurrentMinappId(app.id))
dispatch(setMinappShow(true)) dispatch(setMinappShow(true))
return return
} }
//if the minapp is not opened, open it // 如果缓存数量未达上限,添加到缓存列表
//check if the keep alive minapps meet the max limit if (openedKeepAliveMinapps.length < maxKeepAliveMinapps) {
if (openedKeepAliveMinapps.length < MINAPP_MAX_KEEPALIVE) {
//always put new minapp at the first
dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps])) dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps]))
} else { } else {
//pop the last one // 缓存数量达到上限,移除最后一个,添加新的
dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps.slice(0, MINAPP_MAX_KEEPALIVE - 1)])) dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps.slice(0, maxKeepAliveMinapps - 1)]))
} }
dispatch(setOpenedOneOffMinapp(null)) dispatch(setOpenedOneOffMinapp(null))

View File

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

View File

@@ -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"
@@ -547,7 +564,8 @@
"close": "Close MinApp", "close": "Close MinApp",
"minimize": "Minimize MinApp", "minimize": "Minimize MinApp",
"devtools": "Developer Tools", "devtools": "Developer Tools",
"openExternal": "Open in Browser" "openExternal": "Open in Browser",
"rightclick_copyurl": "Right-click to copy URL"
}, },
"sidebar.add.title": "Add to sidebar", "sidebar.add.title": "Add to sidebar",
"sidebar.remove.title": "Remove from sidebar", "sidebar.remove.title": "Remove from sidebar",
@@ -568,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": {
@@ -913,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",
@@ -935,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",
@@ -1029,7 +1063,10 @@
"noToolsAvailable": "No tools available" "noToolsAvailable": "No tools available"
}, },
"deleteServer": "Delete Server", "deleteServer": "Delete Server",
"deleteServerConfirm": "Are you sure you want to delete this server?" "deleteServerConfirm": "Are you sure you want to delete this server?",
"registry": "Package Registry",
"registryTooltip": "Choose the registry for package installation to resolve network issues with the default registry.",
"registryDefault": "Default"
}, },
"messages.divider": "Show divider between messages", "messages.divider": "Show divider between messages",
"messages.grid_columns": "Message grid display columns", "messages.grid_columns": "Message grid display columns",
@@ -1179,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",

View File

@@ -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": "ドキュメント"
@@ -547,7 +564,8 @@
"close": "ミニアプリを閉じる", "close": "ミニアプリを閉じる",
"minimize": "ミニアプリを最小化", "minimize": "ミニアプリを最小化",
"devtools": "開発者ツール", "devtools": "開発者ツール",
"openExternal": "ブラウザで開く" "openExternal": "ブラウザで開く",
"rightclick_copyurl": "右クリックでURLをコピー"
}, },
"sidebar.add.title": "サイドバーに追加", "sidebar.add.title": "サイドバーに追加",
"sidebar.remove.title": "サイドバーから削除", "sidebar.remove.title": "サイドバーから削除",
@@ -570,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": {
@@ -913,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": "非表示にする機能を左側からここにドラッグ",
@@ -935,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": "アバターをリセット",
@@ -1028,7 +1062,10 @@
"noToolsAvailable": "利用可能なツールはありません" "noToolsAvailable": "利用可能なツールはありません"
}, },
"deleteServer": "サーバーを削除", "deleteServer": "サーバーを削除",
"deleteServerConfirm": "このサーバーを削除してもよろしいですか?" "deleteServerConfirm": "このサーバーを削除してもよろしいですか?",
"registry": "パッケージ管理レジストリ",
"registryTooltip": "デフォルトのレジストリでネットワークの問題が発生した場合、パッケージインストールに使用するレジストリを選択してください。",
"registryDefault": "デフォルト"
}, },
"messages.divider": "メッセージ間に区切り線を表示", "messages.divider": "メッセージ間に区切り線を表示",
"messages.grid_columns": "メッセージグリッドの表示列数", "messages.grid_columns": "メッセージグリッドの表示列数",
@@ -1178,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": "コンテキストをクリア",

View File

@@ -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": "Документация"
@@ -547,7 +564,8 @@
"close": "Закрыть встроенное приложение", "close": "Закрыть встроенное приложение",
"minimize": "Свернуть встроенное приложение", "minimize": "Свернуть встроенное приложение",
"devtools": "Инструменты разработчика", "devtools": "Инструменты разработчика",
"openExternal": "Открыть в браузере" "openExternal": "Открыть в браузере",
"rightclick_copyurl": "ПКМ → Копировать URL"
}, },
"sidebar.add.title": "Добавить в боковую панель", "sidebar.add.title": "Добавить в боковую панель",
"sidebar.remove.title": "Удалить из боковой панели", "sidebar.remove.title": "Удалить из боковой панели",
@@ -570,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": {
@@ -913,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": "Перетащите скрываемую функцию с левой стороны сюда",
@@ -935,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": "Сброс аватара",
@@ -1028,7 +1062,10 @@
"noToolsAvailable": "нет доступных инструментов" "noToolsAvailable": "нет доступных инструментов"
}, },
"deleteServer": "Удалить сервер", "deleteServer": "Удалить сервер",
"deleteServerConfirm": "Вы уверены, что хотите удалить этот сервер?" "deleteServerConfirm": "Вы уверены, что хотите удалить этот сервер?",
"registry": "Реестр пакетов",
"registryTooltip": "Выберите реестр для установки пакетов, если возникают проблемы с сетью при использовании реестра по умолчанию.",
"registryDefault": "По умолчанию"
}, },
"messages.divider": "Показывать разделитель между сообщениями", "messages.divider": "Показывать разделитель между сообщениями",
"messages.grid_columns": "Количество столбцов сетки сообщений", "messages.grid_columns": "Количество столбцов сетки сообщений",
@@ -1178,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": "Очистить контекст",

View File

@@ -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": "帮助文档"
@@ -547,7 +564,8 @@
"close": "关闭小程序", "close": "关闭小程序",
"minimize": "最小化小程序", "minimize": "最小化小程序",
"devtools": "开发者工具", "devtools": "开发者工具",
"openExternal": "在浏览器中打开" "openExternal": "在浏览器中打开",
"rightclick_copyurl": "右键复制URL"
}, },
"sidebar.add.title": "添加到侧边栏", "sidebar.add.title": "添加到侧边栏",
"sidebar.remove.title": "从侧边栏移除", "sidebar.remove.title": "从侧边栏移除",
@@ -568,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": {
@@ -794,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 密钥",
@@ -919,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": "把要隐藏的功能从左侧拖拽到这里",
@@ -935,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": "重置头像",
@@ -1029,7 +1063,10 @@
"noToolsAvailable": "没有可用工具" "noToolsAvailable": "没有可用工具"
}, },
"deleteServer": "删除服务器", "deleteServer": "删除服务器",
"deleteServerConfirm": "确定要删除此服务器吗?" "deleteServerConfirm": "确定要删除此服务器吗?",
"registry": "包管理源",
"registryTooltip": "选择用于安装包的源,以解决默认源的网络问题。",
"registryDefault": "默认"
}, },
"messages.divider": "消息分割线", "messages.divider": "消息分割线",
"messages.grid_columns": "消息网格展示列数", "messages.grid_columns": "消息网格展示列数",
@@ -1179,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": "清除上下文",

View File

@@ -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": "說明文件"
@@ -547,7 +564,8 @@
"close": "關閉小工具", "close": "關閉小工具",
"minimize": "最小化小工具", "minimize": "最小化小工具",
"devtools": "開發者工具", "devtools": "開發者工具",
"openExternal": "在瀏覽器中開啟" "openExternal": "在瀏覽器中開啟",
"rightclick_copyurl": "右鍵複製URL"
}, },
"sidebar.add.title": "新增到側邊欄", "sidebar.add.title": "新增到側邊欄",
"sidebar.remove.title": "從側邊欄移除", "sidebar.remove.title": "從側邊欄移除",
@@ -570,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": {
@@ -913,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": "把要隱藏的功能從左側拖拽到這裡",
@@ -935,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": "重設頭像",
@@ -1028,7 +1062,10 @@
"noToolsAvailable": "沒有可用工具" "noToolsAvailable": "沒有可用工具"
}, },
"deleteServer": "刪除伺服器", "deleteServer": "刪除伺服器",
"deleteServerConfirm": "確定要刪除此伺服器嗎?" "deleteServerConfirm": "確定要刪除此伺服器嗎?",
"registry": "套件管理源",
"registryTooltip": "選擇用於安裝套件的源,以解決預設源的網路問題。",
"registryDefault": "預設"
}, },
"messages.divider": "訊息間顯示分隔線", "messages.divider": "訊息間顯示分隔線",
"messages.grid_columns": "訊息網格展示列數", "messages.grid_columns": "訊息網格展示列數",
@@ -1178,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": "清除上下文",

View File

@@ -130,7 +130,10 @@
"first": "Ήδη το πρώτο μήνυμα", "first": "Ήδη το πρώτο μήνυμα",
"last": "Ήδη το τελευταίο μήνυμα", "last": "Ήδη το τελευταίο μήνυμα",
"next": "Επόμενο μήνυμα", "next": "Επόμενο μήνυμα",
"prev": "Προηγούμενο μήνυμα" "prev": "Προηγούμενο μήνυμα",
"top": "Επιστροφή στην κορυφή",
"bottom": "Επιστροφή στο κάτω μέρος",
"close": "Κλείσιμο"
}, },
"resend": "Ξαναστείλε", "resend": "Ξαναστείλε",
"save": "Αποθήκευση", "save": "Αποθήκευση",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View 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)

View 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)

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,10 @@ const MCPToolsButton: FC<Props> = ({ enabledMCPs, toggelEnableMCP, ToolbarButton
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) + '...'
@@ -102,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>

View File

@@ -75,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

View File

@@ -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,7 +222,9 @@ const DownloadButton = ({ language, data }: { language: string; data: string })
) )
} }
const CodeBlockWrapper = styled.div`` const CodeBlockWrapper = styled.div`
position: relative;
`
const CodeContent = styled.div<{ isShowLineNumbers: boolean; isUnwrapped: boolean; isCodeWrappable: boolean }>` const CodeContent = styled.div<{ isShowLineNumbers: boolean; isUnwrapped: boolean; isCodeWrappable: boolean }>`
.shiki { .shiki {
@@ -376,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)

View File

@@ -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,10 +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={['iframe']} disallowedElements={disallowedElements}
remarkRehypeOptions={{ remarkRehypeOptions={{
footnoteLabel: t('common.footnotes'), footnoteLabel: t('common.footnotes'),
footnoteLabelTagName: 'h4', footnoteLabelTagName: 'h4',

View File

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

View File

@@ -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,6 +27,7 @@ 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()
@@ -44,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)
@@ -52,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(() => {
@@ -97,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 = () => {
@@ -148,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()
@@ -216,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
@@ -262,16 +296,43 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
clearTimeout(hideTimer) clearTimeout(hideTimer)
} }
} }
}, [containerId, hideTimer, resetHideTimer, isNearButtons, handleMouseEnter, handleMouseLeave, showRightTopics]) }, [
containerId,
hideTimer,
resetHideTimer,
isNearButtons,
handleMouseEnter,
handleMouseLeave,
showRightTopics,
manuallyClosedUntil
])
return ( return (
<> <>
<NavigationContainer $isVisible={isVisible} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}> <NavigationContainer $isVisible={isVisible} 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')}
/> />
@@ -280,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"

View File

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

View File

@@ -262,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)
@@ -286,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 {
@@ -324,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'};
@@ -334,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;
` `
}} }}

View File

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

View File

@@ -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)" />

View File

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

View File

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

View File

@@ -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;
` `

View File

@@ -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'

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 />

View File

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

View File

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

View File

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

View File

@@ -20,11 +20,26 @@ 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 { deleteMCPServer } = useMCPServers()
@@ -35,36 +50,42 @@ const McpSettings: React.FC<Props> = ({ server }) => {
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[]>([]) const [tools, setTools] = useState<MCPTool[]>([])
const [isShowRegistry, setIsShowRegistry] = useState(false)
useEffect(() => { const [registry, setRegistry] = useState<Registry[]>()
if (server) {
form.setFieldsValue({
name: server.name,
description: server.description,
serverType: server.baseUrl ? 'sse' : 'stdio',
baseUrl: server.baseUrl || '',
command: server.command || '',
args: server.args ? server.args.join('\n') : '',
env: server.env
? Object.entries(server.env)
.map(([key, value]) => `${key}=${value}`)
.join('\n')
: '',
isActive: server.isActive
})
}
}, [form, server])
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)
@@ -72,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(() => {
@@ -82,65 +104,71 @@ const McpSettings: React.FC<Props> = ({ server }) => {
// Load tools on initial mount if server is active // Load tools on initial mount if server is active
useEffect(() => { useEffect(() => {
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)
}
}
}
fetchTools() fetchTools()
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 // 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.restartServer(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) {
@@ -154,9 +182,41 @@ 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( const onDeleteMcpServer = useCallback(
async (server: MCPServer) => { async (server: MCPServer) => {
try { try {
@@ -259,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>
@@ -279,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>

View File

@@ -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"

View File

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

View File

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

View File

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

View File

@@ -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"

View File

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

View File

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

View File

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

View File

@@ -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,

View File

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

View File

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

View File

@@ -1,30 +0,0 @@
import type { GroundingMetadata } from '@google/generative-ai'
import type { Assistant, MCPToolResponse, Message, Metrics } from '@renderer/types'
interface ChunkCallbackData {
text?: string
reasoning_content?: string
usage?: OpenAI.Completions.CompletionUsage
metrics?: Metrics
search?: GroundingMetadata
citations?: string[]
mcpToolResponse?: MCPToolResponse[]
generateImage?: GenerateImageResponse
}
interface CompletionsParams {
messages: Message[]
assistant: Assistant
onChunk: ({
text,
reasoning_content,
usage,
metrics,
search,
citations,
mcpToolResponse,
generateImage
}: ChunkCallbackData) => void
onFilterMessages: (messages: Message[]) => void
mcpTools?: MCPTool[]
}

View File

@@ -4,6 +4,7 @@ import i18n from '@renderer/i18n'
import store from '@renderer/store' import store from '@renderer/store'
import { setGenerating } from '@renderer/store/runtime' import { setGenerating } from '@renderer/store/runtime'
import { Assistant, MCPTool, Message, Model, Provider, Suggestion } from '@renderer/types' import { Assistant, MCPTool, Message, Model, Provider, Suggestion } from '@renderer/types'
import { processPromptVariables } from '@renderer/utils'
import { formatMessageError, isAbortError } from '@renderer/utils/error' import { formatMessageError, isAbortError } from '@renderer/utils/error'
import { withGenerateImage } from '@renderer/utils/formats' import { withGenerateImage } from '@renderer/utils/formats'
import { cloneDeep, findLast, isEmpty } from 'lodash' import { cloneDeep, findLast, isEmpty } from 'lodash'
@@ -18,7 +19,7 @@ import {
getTranslateModel getTranslateModel
} from './AssistantService' } from './AssistantService'
import { EVENT_NAMES, EventEmitter } from './EventService' import { EVENT_NAMES, EventEmitter } from './EventService'
import { filterMessages, filterUsefulMessages } from './MessagesService' import { filterContextMessages, filterMessages, filterUsefulMessages } from './MessagesService'
import { estimateMessagesUsage } from './TokenService' import { estimateMessagesUsage } from './TokenService'
import WebSearchService from './WebSearchService' import WebSearchService from './WebSearchService'
@@ -42,6 +43,14 @@ export async function fetchChatCompletion({
let isFirstChunk = true let isFirstChunk = true
let query = '' let query = ''
// Process variables in the prompt if they exist
if (assistant.promptVariables && assistant.promptVariables.length > 0) {
assistant = {
...assistant,
prompt: processPromptVariables(assistant.prompt, assistant.promptVariables)
}
}
// Search web // Search web
if (WebSearchService.isWebSearchEnabled() && assistant.enableWebSearch && assistant.model) { if (WebSearchService.isWebSearchEnabled() && assistant.enableWebSearch && assistant.model) {
const webSearchParams = getOpenAIWebSearchParams(assistant, assistant.model) const webSearchParams = getOpenAIWebSearchParams(assistant, assistant.model)
@@ -112,7 +121,7 @@ export async function fetchChatCompletion({
} }
await AI.completions({ await AI.completions({
messages: filterUsefulMessages(messages), messages: filterUsefulMessages(filterContextMessages(messages)),
assistant, assistant,
onFilterMessages: (messages) => (_messages = messages), onFilterMessages: (messages) => (_messages = messages),
onChunk: ({ text, reasoning_content, usage, metrics, search, citations, mcpToolResponse, generateImage }) => { onChunk: ({ text, reasoning_content, usage, metrics, search, citations, mcpToolResponse, generateImage }) => {

View File

@@ -2,6 +2,7 @@ import SearchPopup from '@renderer/components/Popups/SearchPopup'
import { DEFAULT_CONTEXTCOUNT } from '@renderer/config/constant' import { DEFAULT_CONTEXTCOUNT } from '@renderer/config/constant'
import { getTopicById } from '@renderer/hooks/useTopic' import { getTopicById } from '@renderer/hooks/useTopic'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import { fetchMessagesSummary } from '@renderer/services/ApiService'
import store from '@renderer/store' import store from '@renderer/store'
import { Assistant, Message, Model, Topic } from '@renderer/types' import { Assistant, Message, Model, Topic } from '@renderer/types'
import { getTitleFromString, uuid } from '@renderer/utils' import { getTitleFromString, uuid } from '@renderer/utils'
@@ -80,11 +81,7 @@ export function filterUsefulMessages(messages: Message[]): Message[] {
// 过滤两条及以上 user 类型消息相邻的情况,只保留最新一条 user 消息 // 过滤两条及以上 user 类型消息相邻的情况,只保留最新一条 user 消息
_messages = _messages.filter((message, index, origin) => { _messages = _messages.filter((message, index, origin) => {
if ( if (message.role === 'user' && index + 1 < origin.length && origin[index + 1].role === 'user') {
message.role === 'user'
&& index + 1 < origin.length
&& origin[index + 1].role === 'user'
) {
return false return false
} }
return true return true
@@ -218,7 +215,22 @@ export function resetAssistantMessage(message: Message, model?: Model): Message
} }
} }
export function getMessageTitle(message: Message, length = 30) { export async function getMessageTitle(message: Message, length = 30): Promise<string> {
// 检查 Redux 设置,若开启话题命名则调用 summaries 方法
if ((store.getState().settings as any).useTopicNamingForMessageTitle) {
try {
window.message.loading({ content: t('chat.topics.export.wait_for_title_naming'), key: 'message-title-naming' })
const title = await fetchMessagesSummary({ messages: [message], assistant: {} as Assistant })
if (title) {
window.message.success({ content: t('chat.topics.export.title_naming_success'), key: 'message-title-naming' })
return title
}
} catch (e) {
window.message.error({ content: t('chat.topics.export.title_naming_failed'), key: 'message-title-naming' })
console.error('Failed to generate title using topic naming, downgraded to default logic', e)
}
}
let title = getTitleFromString(message.content, length) let title = getTitleFromString(message.content, length)
if (!title) { if (!title) {
@@ -227,6 +239,7 @@ export function getMessageTitle(message: Message, length = 30) {
return title return title
} }
export function checkRateLimit(assistant: Assistant): boolean { export function checkRateLimit(assistant: Assistant): boolean {
const provider = getAssistantProvider(assistant) const provider = getAssistantProvider(assistant)

View File

@@ -1,8 +1,8 @@
import WebSearchEngineProvider from '@renderer/providers/WebSearchProvider'
import store from '@renderer/store' import store from '@renderer/store'
import { setDefaultProvider, WebSearchState } from '@renderer/store/websearch' import { setDefaultProvider, WebSearchState } from '@renderer/store/websearch'
import { WebSearchProvider, WebSearchResponse } from '@renderer/types' import { WebSearchProvider, WebSearchResponse } from '@renderer/types'
import { hasObjectKey } from '@renderer/utils' import { hasObjectKey } from '@renderer/utils'
import WebSearchEngineProvider from '@renderer/webSearchProvider/WebSearchEngineProvider'
import dayjs from 'dayjs' import dayjs from 'dayjs'
/** /**

View File

@@ -42,7 +42,7 @@ const persistedReducer = persistReducer(
{ {
key: 'cherry-studio', key: 'cherry-studio',
storage, storage,
version: 86, version: 88,
blacklist: ['runtime', 'messages'], blacklist: ['runtime', 'messages'],
migrate migrate
}, },

View File

@@ -1,8 +1,20 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
import { MCPConfig, MCPServer } from '@renderer/types' import { nanoid } from '@reduxjs/toolkit'
import type { MCPConfig, MCPServer } from '@renderer/types'
const initialState: MCPConfig = { const initialState: MCPConfig = {
servers: [] servers: [
{
id: nanoid(),
name: 'mcp-auto-install',
description: 'Automatically install MCP services (Beta version)',
baseUrl: '',
command: 'npx',
args: ['-y', '@mcpmarket/mcp-auto-install', 'connect', '--json'],
env: {},
isActive: false
}
]
} }
const mcpSlice = createSlice({ const mcpSlice = createSlice({
@@ -47,5 +59,6 @@ export const { getActiveServers, getAllServers } = mcpSlice.selectors
// Type-safe selector for accessing this slice from the root state // Type-safe selector for accessing this slice from the root state
export const selectMCP = (state: { mcp: MCPConfig }) => state.mcp export const selectMCP = (state: { mcp: MCPConfig }) => state.mcp
export { mcpSlice }
// Export the reducer as default export // Export the reducer as default export
export default mcpSlice.reducer export default mcpSlice.reducer

File diff suppressed because it is too large Load Diff

View File

@@ -85,6 +85,7 @@ export interface SettingsState {
notionPageNameKey: string | null notionPageNameKey: string | null
markdownExportPath: string | null markdownExportPath: string | null
forceDollarMathInMarkdown: boolean forceDollarMathInMarkdown: boolean
useTopicNamingForMessageTitle: boolean
thoughtAutoCollapse: boolean thoughtAutoCollapse: boolean
notionAutoSplit: boolean notionAutoSplit: boolean
notionSplitSize: number notionSplitSize: number
@@ -99,6 +100,8 @@ export interface SettingsState {
siyuanToken: string | null siyuanToken: string | null
siyuanBoxId: string | null siyuanBoxId: string | null
siyuanRootPath: string | null siyuanRootPath: string | null
maxKeepAliveMinapps: number
showOpenedMinappsInSidebar: boolean
} }
export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid' export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid'
@@ -127,7 +130,7 @@ const initialState: SettingsState = {
showAssistantIcon: false, showAssistantIcon: false,
pasteLongTextAsFile: false, pasteLongTextAsFile: false,
pasteLongTextThreshold: 1500, pasteLongTextThreshold: 1500,
clickAssistantToShowTopic: false, clickAssistantToShowTopic: true,
autoCheckUpdate: true, autoCheckUpdate: true,
renderInputMessageAsMarkdown: false, renderInputMessageAsMarkdown: false,
codeShowLineNumbers: false, codeShowLineNumbers: false,
@@ -165,6 +168,7 @@ const initialState: SettingsState = {
notionPageNameKey: 'Name', notionPageNameKey: 'Name',
markdownExportPath: null, markdownExportPath: null,
forceDollarMathInMarkdown: false, forceDollarMathInMarkdown: false,
useTopicNamingForMessageTitle: false,
thoughtAutoCollapse: true, thoughtAutoCollapse: true,
notionAutoSplit: false, notionAutoSplit: false,
notionSplitSize: 90, notionSplitSize: 90,
@@ -178,7 +182,9 @@ const initialState: SettingsState = {
siyuanApiUrl: null, siyuanApiUrl: null,
siyuanToken: null, siyuanToken: null,
siyuanBoxId: null, siyuanBoxId: null,
siyuanRootPath: null siyuanRootPath: null,
maxKeepAliveMinapps: 3,
showOpenedMinappsInSidebar: true
} }
const settingsSlice = createSlice({ const settingsSlice = createSlice({
@@ -368,6 +374,9 @@ const settingsSlice = createSlice({
setForceDollarMathInMarkdown: (state, action: PayloadAction<boolean>) => { setForceDollarMathInMarkdown: (state, action: PayloadAction<boolean>) => {
state.forceDollarMathInMarkdown = action.payload state.forceDollarMathInMarkdown = action.payload
}, },
setUseTopicNamingForMessageTitle: (state, action: PayloadAction<boolean>) => {
state.useTopicNamingForMessageTitle = action.payload
},
setThoughtAutoCollapse: (state, action: PayloadAction<boolean>) => { setThoughtAutoCollapse: (state, action: PayloadAction<boolean>) => {
state.thoughtAutoCollapse = action.payload state.thoughtAutoCollapse = action.payload
}, },
@@ -409,6 +418,12 @@ const settingsSlice = createSlice({
}, },
setDefaultObsidianVault: (state, action: PayloadAction<string>) => { setDefaultObsidianVault: (state, action: PayloadAction<string>) => {
state.defaultObsidianVault = action.payload state.defaultObsidianVault = action.payload
},
setMaxKeepAliveMinapps: (state, action: PayloadAction<number>) => {
state.maxKeepAliveMinapps = action.payload
},
setShowOpenedMinappsInSidebar: (state, action: PayloadAction<boolean>) => {
state.showOpenedMinappsInSidebar = action.payload
} }
} }
}) })
@@ -473,6 +488,7 @@ export const {
setNotionPageNameKey, setNotionPageNameKey,
setmarkdownExportPath, setmarkdownExportPath,
setForceDollarMathInMarkdown, setForceDollarMathInMarkdown,
setUseTopicNamingForMessageTitle,
setThoughtAutoCollapse, setThoughtAutoCollapse,
setNotionAutoSplit, setNotionAutoSplit,
setNotionSplitSize, setNotionSplitSize,
@@ -486,7 +502,9 @@ export const {
setSiyuanApiUrl, setSiyuanApiUrl,
setSiyuanToken, setSiyuanToken,
setSiyuanBoxId, setSiyuanBoxId,
setSiyuanRootPath setSiyuanRootPath,
setMaxKeepAliveMinapps,
setShowOpenedMinappsInSidebar
} = settingsSlice.actions } = settingsSlice.actions
export default settingsSlice.reducer export default settingsSlice.reducer

View File

@@ -17,6 +17,8 @@ export type Assistant = {
messages?: AssistantMessage[] messages?: AssistantMessage[]
enableWebSearch?: boolean enableWebSearch?: boolean
enableGenerateImage?: boolean enableGenerateImage?: boolean
promptVariables?: Variable[]
mcpServers?: MCPServer[]
} }
export type AssistantMessage = { export type AssistantMessage = {
@@ -43,6 +45,12 @@ export type AssistantSettings = {
reasoning_effort?: 'low' | 'medium' | 'high' reasoning_effort?: 'low' | 'medium' | 'high'
} }
export type Variable = {
id: string
name: string
value: string
}
export type Agent = Omit<Assistant, 'model'> export type Agent = Omit<Assistant, 'model'>
export type Message = { export type Message = {
@@ -367,6 +375,7 @@ export interface MCPServer {
description?: string description?: string
baseUrl?: string baseUrl?: string
command?: string command?: string
registryUrl?: string
args?: string[] args?: string[]
env?: Record<string, string> env?: Record<string, string>
isActive: boolean isActive: boolean

View File

@@ -66,7 +66,8 @@ export const exportMessageAsMarkdown = async (message: Message) => {
const { markdownExportPath } = store.getState().settings const { markdownExportPath } = store.getState().settings
if (!markdownExportPath) { if (!markdownExportPath) {
try { try {
const fileName = removeSpecialCharactersForFileName(getMessageTitle(message)) + '.md' const title = await getMessageTitle(message)
const fileName = removeSpecialCharactersForFileName(title) + '.md'
const markdown = messageToMarkdown(message) const markdown = messageToMarkdown(message)
const result = await window.api.file.save(fileName, markdown) const result = await window.api.file.save(fileName, markdown)
if (result) { if (result) {
@@ -81,7 +82,8 @@ export const exportMessageAsMarkdown = async (message: Message) => {
} else { } else {
try { try {
const timestamp = dayjs().format('YYYY-MM-DD-HH-mm-ss') const timestamp = dayjs().format('YYYY-MM-DD-HH-mm-ss')
const fileName = removeSpecialCharactersForFileName(getMessageTitle(message)) + ` ${timestamp}.md` const title = await getMessageTitle(message)
const fileName = removeSpecialCharactersForFileName(title) + ` ${timestamp}.md`
const markdown = messageToMarkdown(message) const markdown = messageToMarkdown(message)
await window.api.file.write(markdownExportPath + '/' + fileName, markdown) await window.api.file.write(markdownExportPath + '/' + fileName, markdown)
window.message.success({ content: i18n.t('message.success.markdown.export.preconf'), key: 'markdown-success' }) window.message.success({ content: i18n.t('message.success.markdown.export.preconf'), key: 'markdown-success' })

View File

@@ -500,4 +500,23 @@ export function hasObjectKey(obj: any, key: string) {
return Object.keys(obj).includes(key) return Object.keys(obj).includes(key)
} }
/**
* Process variables in a prompt string
* @param prompt The prompt string containing variables in {{var_name}} format
* @param variables Array of variables with name and value
* @returns The prompt with variables replaced
*/
export function processPromptVariables(prompt: string, variables: Array<{ name: string; value: string }> = []) {
if (!prompt || !variables || variables.length === 0) {
return prompt
}
let processedPrompt = prompt
variables.forEach((variable) => {
const pattern = new RegExp(`{{${variable.name}}}`, 'g')
processedPrompt = processedPrompt.replace(pattern, variable.value)
})
return processedPrompt
}
export { classNames } export { classNames }

View File

@@ -1,59 +1,165 @@
import { Tool, ToolUnion, ToolUseBlock } from '@anthropic-ai/sdk/resources' import { Tool, ToolUnion, ToolUseBlock } from '@anthropic-ai/sdk/resources'
import { FunctionCall, FunctionDeclaration, SchemaType, Tool as geminiToool } from '@google/generative-ai' import {
ArraySchema,
BaseSchema,
BooleanSchema,
EnumStringSchema,
FunctionCall,
FunctionDeclaration,
FunctionDeclarationSchema,
FunctionDeclarationSchemaProperty,
IntegerSchema,
NumberSchema,
ObjectSchema,
SchemaType,
SimpleStringSchema,
Tool as geminiTool
} from '@google/generative-ai'
import { nanoid } from '@reduxjs/toolkit'
import store from '@renderer/store' import store from '@renderer/store'
import { addMCPServer } from '@renderer/store/mcp'
import { MCPServer, MCPTool, MCPToolResponse } from '@renderer/types' import { MCPServer, MCPTool, MCPToolResponse } from '@renderer/types'
import { ChatCompletionMessageToolCall, ChatCompletionTool } from 'openai/resources' import { ChatCompletionMessageToolCall, ChatCompletionTool } from 'openai/resources'
import { ChunkCallbackData } from '../providers' import { ChunkCallbackData } from '../providers/AiProvider'
const supportedAttributes = [ const ensureValidSchema = (obj: Record<string, any>): FunctionDeclarationSchemaProperty => {
'type', // Filter out unsupported keys for Gemini
'nullable', const filteredObj = filterUnsupportedKeys(obj)
'required',
// 'format',
'description',
'properties',
'items',
'enum',
'anyOf'
]
function filterPropertieAttributes(tool: MCPTool, filterNestedObj = false) { // Handle base schema properties
const baseSchema = {
description: filteredObj.description,
nullable: filteredObj.nullable
} as BaseSchema
// Handle string type
if (filteredObj.type?.toLowerCase() === SchemaType.STRING) {
if (filteredObj.enum && Array.isArray(filteredObj.enum)) {
return {
...baseSchema,
type: SchemaType.STRING,
format: 'enum',
enum: filteredObj.enum as string[]
} as EnumStringSchema
}
return {
...baseSchema,
type: SchemaType.STRING,
format: filteredObj.format === 'date-time' ? 'date-time' : undefined
} as SimpleStringSchema
}
// Handle number type
if (filteredObj.type?.toLowerCase() === SchemaType.NUMBER) {
return {
...baseSchema,
type: SchemaType.NUMBER,
format: ['float', 'double'].includes(filteredObj.format) ? (filteredObj.format as 'float' | 'double') : undefined
} as NumberSchema
}
// Handle integer type
if (filteredObj.type?.toLowerCase() === SchemaType.INTEGER) {
return {
...baseSchema,
type: SchemaType.INTEGER,
format: ['int32', 'int64'].includes(filteredObj.format) ? (filteredObj.format as 'int32' | 'int64') : undefined
} as IntegerSchema
}
// Handle boolean type
if (filteredObj.type?.toLowerCase() === SchemaType.BOOLEAN) {
return {
...baseSchema,
type: SchemaType.BOOLEAN
} as BooleanSchema
}
// Handle array type
if (filteredObj.type?.toLowerCase() === SchemaType.ARRAY) {
return {
...baseSchema,
type: SchemaType.ARRAY,
items: filteredObj.items
? ensureValidSchema(filteredObj.items as Record<string, any>)
: ({ type: SchemaType.STRING } as SimpleStringSchema),
minItems: filteredObj.minItems,
maxItems: filteredObj.maxItems
} as ArraySchema
}
// Handle object type (default)
const properties = filteredObj.properties
? Object.fromEntries(
Object.entries(filteredObj.properties).map(([key, value]) => [
key,
ensureValidSchema(value as Record<string, any>)
])
)
: { _empty: { type: SchemaType.STRING } as SimpleStringSchema } // Ensure properties is never empty
return {
...baseSchema,
type: SchemaType.OBJECT,
properties,
required: Array.isArray(filteredObj.required) ? filteredObj.required : undefined
} as ObjectSchema
}
function filterUnsupportedKeys(obj: Record<string, any>): Record<string, any> {
const supportedBaseKeys = ['description', 'nullable']
const supportedStringKeys = [...supportedBaseKeys, 'type', 'format', 'enum']
const supportedNumberKeys = [...supportedBaseKeys, 'type', 'format']
const supportedBooleanKeys = [...supportedBaseKeys, 'type']
const supportedArrayKeys = [...supportedBaseKeys, 'type', 'items', 'minItems', 'maxItems']
const supportedObjectKeys = [...supportedBaseKeys, 'type', 'properties', 'required']
const filtered: Record<string, any> = {}
let keysToKeep: string[]
if (obj.type?.toLowerCase() === SchemaType.STRING) {
keysToKeep = supportedStringKeys
} else if (obj.type?.toLowerCase() === SchemaType.NUMBER) {
keysToKeep = supportedNumberKeys
} else if (obj.type?.toLowerCase() === SchemaType.INTEGER) {
keysToKeep = supportedNumberKeys
} else if (obj.type?.toLowerCase() === SchemaType.BOOLEAN) {
keysToKeep = supportedBooleanKeys
} else if (obj.type?.toLowerCase() === SchemaType.ARRAY) {
keysToKeep = supportedArrayKeys
} else {
// Default to object type
keysToKeep = supportedObjectKeys
}
// copy supported keys
for (const key of keysToKeep) {
if (obj[key] !== undefined) {
filtered[key] = obj[key]
}
}
return filtered
}
function filterPropertieAttributes(tool: MCPTool, filterNestedObj: boolean = false): Record<string, object> {
const properties = tool.inputSchema.properties const properties = tool.inputSchema.properties
if (!properties) { if (!properties) {
return {} return {}
} }
const getSubMap = (obj: Record<string, any>, keys: string[]) => {
const filtered = Object.fromEntries(Object.entries(obj).filter(([key]) => keys.includes(key)))
if (filterNestedObj) { // For OpenAI, we don't need to validate as strictly
return { if (!filterNestedObj) {
...filtered, return properties
...(obj.type === 'object' && obj.properties
? {
properties: Object.fromEntries(
Object.entries(obj.properties).map(([k, v]) => [
k,
(v as any).type === 'object' ? getSubMap(v as Record<string, any>, keys) : v
])
)
}
: {}),
...(obj.type === 'array' && obj.items?.type === 'object'
? {
items: getSubMap(obj.items, keys)
}
: {})
}
}
return filtered
} }
for (const [key, val] of Object.entries(properties)) { const processedProperties = Object.fromEntries(
properties[key] = getSubMap(val, supportedAttributes) Object.entries(properties).map(([key, value]) => [key, ensureValidSchema(value as Record<string, any>)])
} )
return properties
return processedProperties
} }
export function mcpToolsToOpenAITools(mcpTools: MCPTool[]): Array<ChatCompletionTool> { export function mcpToolsToOpenAITools(mcpTools: MCPTool[]): Array<ChatCompletionTool> {
@@ -79,7 +185,9 @@ export function openAIToolsToMcpTool(
return undefined return undefined
} }
const tool = mcpTools.find((mcptool) => mcptool.id === llmTool.function.name) const tool = mcpTools.find(
(mcptool) => mcptool.id === llmTool.function.name || mcptool.name === llmTool.function.name
)
if (!tool) { if (!tool) {
console.warn('No MCP Tool found for tool call:', llmTool) console.warn('No MCP Tool found for tool call:', llmTool)
@@ -126,6 +234,23 @@ export async function callMCPTool(tool: MCPTool): Promise<any> {
}) })
console.log(`[MCP] Tool called: ${tool.serverName} ${tool.name}`, resp) console.log(`[MCP] Tool called: ${tool.serverName} ${tool.name}`, resp)
if (tool.serverName === 'mcp-auto-install') {
if (resp.data) {
const mcpServer: MCPServer = {
id: `f${nanoid()}`,
name: resp.data.name,
description: resp.data.description,
baseUrl: resp.data.baseUrl,
command: resp.data.command,
args: resp.data.args,
env: resp.data.env,
isActive: false
}
store.dispatch(addMCPServer(mcpServer))
}
}
return resp return resp
} catch (e) { } catch (e) {
console.error(`[MCP] Error calling Tool: ${tool.serverName} ${tool.name}`, e) console.error(`[MCP] Error calling Tool: ${tool.serverName} ${tool.name}`, e)
@@ -164,7 +289,7 @@ export function anthropicToolUseToMcpTool(mcpTools: MCPTool[] | undefined, toolU
return tool return tool
} }
export function mcpToolsToGeminiTools(mcpTools: MCPTool[] | undefined): geminiToool[] { export function mcpToolsToGeminiTools(mcpTools: MCPTool[] | undefined): geminiTool[] {
if (!mcpTools || mcpTools.length === 0) { if (!mcpTools || mcpTools.length === 0) {
// No tools available // No tools available
return [] return []
@@ -176,18 +301,19 @@ export function mcpToolsToGeminiTools(mcpTools: MCPTool[] | undefined): geminiTo
const functionDeclaration: FunctionDeclaration = { const functionDeclaration: FunctionDeclaration = {
name: tool.id, name: tool.id,
description: tool.description, description: tool.description,
...(Object.keys(properties).length > 0 parameters: {
? { type: SchemaType.OBJECT,
parameters: { properties:
type: SchemaType.OBJECT, Object.keys(properties).length > 0
properties ? Object.fromEntries(
} Object.entries(properties).map(([key, value]) => [key, ensureValidSchema(value as Record<string, any>)])
} )
: {}) : { _empty: { type: SchemaType.STRING } as SimpleStringSchema }
} as FunctionDeclarationSchema
} }
functions.push(functionDeclaration) functions.push(functionDeclaration)
} }
const tool: geminiToool = { const tool: geminiTool = {
functionDeclarations: functions functionDeclarations: functions
} }
return [tool] return [tool]

View File

@@ -1,6 +1,7 @@
import '@renderer/databases' import '@renderer/databases'
import store, { persistor } from '@renderer/store' import store, { persistor } from '@renderer/store'
import { message } from 'antd'
import { Provider } from 'react-redux' import { Provider } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react' import { PersistGate } from 'redux-persist/integration/react'
@@ -10,12 +11,17 @@ import { ThemeProvider } from '../../context/ThemeProvider'
import HomeWindow from './home/HomeWindow' import HomeWindow from './home/HomeWindow'
function MiniWindow(): React.ReactElement { function MiniWindow(): React.ReactElement {
//miniWindow should register its own message component
const [messageApi, messageContextHolder] = message.useMessage()
window.message = messageApi
return ( return (
<Provider store={store}> <Provider store={store}>
<ThemeProvider> <ThemeProvider>
<AntdProvider> <AntdProvider>
<SyntaxHighlighterProvider> <SyntaxHighlighterProvider>
<PersistGate loading={null} persistor={persistor}> <PersistGate loading={null} persistor={persistor}>
{messageContextHolder}
<HomeWindow /> <HomeWindow />
</PersistGate> </PersistGate>
</SyntaxHighlighterProvider> </SyntaxHighlighterProvider>

View File

@@ -38,7 +38,7 @@ const MessageItem: FC<Props> = ({ message: _message, index, total, route, onSetM
const messageBackground = getMessageBackground(true, isAssistantMessage) const messageBackground = getMessageBackground(true, isAssistantMessage)
const maxWidth = isMiniWindow() ? '480px' : '100%' const maxWidth = isMiniWindow() ? '800px' : '100%'
useEffect(() => { useEffect(() => {
if (onGetMessages && onSetMessages) { if (onGetMessages && onSetMessages) {
@@ -93,6 +93,7 @@ const MessageItem: FC<Props> = ({ message: _message, index, total, route, onSetM
const MessageContainer = styled.div` const MessageContainer = styled.div`
display: flex; display: flex;
width: 100%;
flex-direction: column; flex-direction: column;
position: relative; position: relative;
transition: background-color 0.3s ease; transition: background-color 0.3s ease;

View File

@@ -77,6 +77,7 @@ const Messages: FC<Props> = ({ assistant, route }) => {
const Container = styled(Scrollbar)<ContainerProps>` const Container = styled(Scrollbar)<ContainerProps>`
display: flex; display: flex;
flex-direction: column-reverse; flex-direction: column-reverse;
align-items: center;
padding-bottom: 20px; padding-bottom: 20px;
overflow-x: hidden; overflow-x: hidden;
min-width: 100%; min-width: 100%;

View File

@@ -278,6 +278,8 @@ const HomeWindow: FC = () => {
<Divider style={{ margin: '10px 0' }} /> <Divider style={{ margin: '10px 0' }} />
<Footer <Footer
route={route} route={route}
canUseBackspace={text.length > 0 || clipboardText.length == 0}
clearClipboard={clearClipboard}
onExit={() => { onExit={() => {
setRoute('home') setRoute('home')
setText('') setText('')
@@ -292,6 +294,7 @@ const Container = styled.div`
display: flex; display: flex;
flex: 1; flex: 1;
height: 100%; height: 100%;
width: 100%;
flex-direction: column; flex-direction: column;
-webkit-app-region: drag; -webkit-app-region: drag;
padding: 8px 10px; padding: 8px 10px;
@@ -299,6 +302,8 @@ const Container = styled.div`
const Main = styled.main` const Main = styled.main`
display: flex; display: flex;
flex-direction: column;
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
` `

Some files were not shown because too many files have changed in this diff Show More