diff --git a/LICENSE b/LICENSE index c2409e6076..6325e65d84 100644 --- a/LICENSE +++ b/LICENSE @@ -1,62 +1,87 @@ -**许可协议** +**许可协议 (Licensing)** -采用 Apache License 2.0 修改版许可,并附加以下条件: +本项目采用**区分用户的双重许可 (User-Segmented Dual Licensing)** 模式。 -**一. 商用许可** +**核心原则:** -在以下任何一种情况下,您需要联系我们并获得明确的书面商业授权后,方可继续使用 Cherry Studio 材料: +* **个人用户 和 10人及以下企业/组织:** 默认适用 **GNU Affero 通用公共许可证 v3.0 (AGPLv3)**。 +* **超过10人的企业/组织:** **必须** 获取 **商业许可证 (Commercial License)**。 -1. **修改与衍生**: 您对 Cherry Studio 材料进行修改或基于其进行衍生开发(包括但不限于修改应用名称、Logo、代码、功能、界面,数据等)。 -2. **企业服务**: 在您的企业内部,或为企业客户提供基于 Cherry Studio 的服务,且该服务支持 10 人及以上累计用户使用。 -3. **硬件捆绑销售**: 您将 Cherry Studio 预装或集成到硬件设备或产品中进行捆绑销售。 -4. **政府或教育机构大规模采购**: 您的使用场景属于政府或教育机构的大规模采购项目,特别是涉及安全、数据隐私等敏感需求时。 -5. **面向公众的公有云服务**:基于 Cherry Studio,提供面向公众的公有云服务。 - -**二. 贡献者协议** - -作为 Cherry Studio 的贡献者,您应当同意以下条款: - -1. **许可调整**:生产者有权根据需要对开源协议进行调整,使其更加严格或宽松。 -2. **商业用途**:您贡献的代码可能会被用于商业用途,包括但不限于云业务运营。 - -**三. 其他条款** - -1. 本协议条款的解释权归 Cherry Studio 开发者所有。 -2. 本协议可能根据实际情况进行更新,更新时将通过本软件通知用户。 - -如有任何问题或需申请商业授权,请联系 Cherry Studio 开发团队。 - -除上述特定条件外,其他所有权利和限制均遵循 Apache License 2.0。有关 Apache License 2.0 的详细信息,请访问 http://www.apache.org/licenses/LICENSE-2.0。 +定义:“10人及以下” +指在您的组织(包括公司、非营利组织、政府机构、教育机构等任何实体)中,能够访问、使用或以任何方式直接或间接受益于本软件(Cherry Studio)功能的个人总数不超过10人。这包括但不限于开发者、测试人员、运营人员、最终用户、通过集成系统间接使用者等。 --- +**1. 开源许可证 (Open Source License): AGPLv3 - 适用于个人及10人及以下组织** -**License Agreement** +* 如果您是个人用户,或者您的组织满足上述“10人及以下”的定义,您可以在 **AGPLv3** 的条款下自由使用、修改和分发 Cherry Studio。AGPLv3 的完整文本可以访问 [https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html) 获取。 +* **核心义务:** AGPLv3 的一个关键要求是,如果您修改了 Cherry Studio 并通过网络提供服务,或者分发了修改后的版本,您必须以 AGPLv3 许可证向接收者提供相应的**完整源代码**。即使您符合“10人及以下”的标准,如果您希望避免此源代码公开义务,您也需要考虑获取商业许可证(见下文)。 +* 使用前请务必仔细阅读并理解 AGPLv3 的所有条款。 -This software is licensed under a modified version of the Apache License 2.0, with the following additional conditions。 +**2. 商业许可证 (Commercial License) - 适用于超过10人的组织,或希望规避 AGPLv3 义务的用户** -**I. Commercial Licensing** +* **强制要求:** 如果您的组织**不**满足上述“10人及以下”的定义(即有11人或更多人可以访问、使用或受益于本软件),您**必须**联系我们获取并签署一份商业许可证才能使用 Cherry Studio。 +* **自愿选择:** 即使您的组织满足“10人及以下”的条件,但如果您的使用场景**无法满足 AGPLv3 的条款要求**(特别是关于**源代码公开**的义务),或者您需要 AGPLv3 **未提供**的特定商业条款(如保证、赔偿、无 Copyleft 限制等),您也**必须**联系我们获取并签署一份商业许可证。 +* **需要商业许可证的常见情况包括(但不限于):** + * 您的组织规模超过10人。 + * (无论组织规模)您希望分发修改过的 Cherry Studio 版本,但**不希望**根据 AGPLv3 公开您修改部分的源代码。 + * (无论组织规模)您希望基于修改过的 Cherry Studio 提供网络服务(SaaS),但**不希望**根据 AGPLv3 向服务使用者提供修改后的源代码。 + * (无论组织规模)您的公司政策、客户合同或项目要求不允许使用 AGPLv3 许可的软件,或要求闭源分发及保密。 +* 商业许可证将为您提供豁免 AGPLv3 义务(如源代码公开)的权利,并可能包含额外的商业保障条款。 +* **获取商业许可:** 请通过邮箱 **bd@cherry-ai.com** 联系 Cherry Studio 开发团队洽谈商业授权事宜。 -You must contact us and obtain explicit written commercial authorization to continue using Cherry Studio materials under any of the following circumstances: +**3. 贡献 (Contributions)** -1. **Modifications and Derivatives:** You modify Cherry Studio materials or perform derivative development based on them (including but not limited to changing the application’s name, logo, code, functionality, user interface, data, etc.). -2. **Enterprise Services:** You use Cherry Studio internally within your enterprise, or you provide Cherry Studio-based services for enterprise customers, and such services support cumulative usage by 10 or more users. -3. **Hardware Bundling and Sales:** You pre-install or integrate Cherry Studio into hardware devices or products for bundled sale. -4. **Large-scale Procurement by Government or Educational Institutions:** Your usage scenario involves large-scale procurement projects by government or educational institutions, especially in cases involving sensitive requirements such as security and data privacy. -5. **Public Cloud Services:** You provide public cloud-based product services utilizing Cherry Studio. +* 我们欢迎社区对 Cherry Studio 的贡献。所有向本项目提交的贡献都将被视为在 **AGPLv3** 许可证下提供。 +* 通过向本项目提交贡献(例如通过 Pull Request),即表示您同意您的代码以 AGPLv3 许可证授权给本项目及所有后续使用者(无论这些使用者最终遵循 AGPLv3 还是商业许可)。 +* 您也理解并同意,您的贡献可能会被包含在根据商业许可证分发的 Cherry Studio 版本中。 -**II. Contributor Agreement** +**4. 其他条款 (Other Terms)** -As a contributor to Cherry Studio, you must agree to the following terms: +* 关于商业许可证的具体条款和条件,以双方签署的正式商业许可协议为准。 +* 项目维护者保留根据需要更新本许可政策(包括用户规模定义和阈值)的权利。相关更新将通过项目官方渠道(如代码仓库、官方网站)进行通知。 -1. **License Adjustments:** The producer reserves the right to adjust the open-source license as necessary, making it more strict or permissive. -2. **Commercial Usage:** Your contributed code may be used commercially, including but not limited to cloud business operations. +--- -**III. Other Terms** +**Licensing** -1. Cherry Studio developers reserve the right of final interpretation of these agreement terms. -2. This agreement may be updated according to practical circumstances, and users will be notified of updates through this software. +This project employs a **User-Segmented Dual Licensing** model. -If you have any questions or need to apply for commercial authorization, please contact the Cherry Studio development team. +**Core Principle:** -Other than these specific conditions, all remaining rights and restrictions follow the Apache License 2.0. For more detailed information regarding Apache License 2.0, please visit http://www.apache.org/licenses/LICENSE-2.0. +* **Individual Users and Organizations with 10 or Fewer Individuals:** Governed by default under the **GNU Affero General Public License v3.0 (AGPLv3)**. +* **Organizations with More Than 10 Individuals:** **Must** obtain a **Commercial License**. + +Definition: "10 or Fewer Individuals" +Refers to any organization (including companies, non-profits, government agencies, educational institutions, etc.) where the total number of individuals who can access, use, or in any way directly or indirectly benefit from the functionality of this software (Cherry Studio) does not exceed 10. This includes, but is not limited to, developers, testers, operations staff, end-users, and indirect users via integrated systems. + +--- + +**1. Open Source License: AGPLv3 - For Individuals and Organizations of 10 or Fewer** + +* If you are an individual user, or if your organization meets the "10 or Fewer Individuals" definition above, you are free to use, modify, and distribute Cherry Studio under the terms of the **AGPLv3**. The full text of the AGPLv3 can be found in the LICENSE file at [https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html). +* **Core Obligation:** A key requirement of the AGPLv3 is that if you modify Cherry Studio and make it available over a network, or distribute the modified version, you must provide the **complete corresponding source code** under the AGPLv3 license to the recipients. Even if you qualify under the "10 or Fewer Individuals" rule, if you wish to avoid this source code disclosure obligation, you will need to obtain a Commercial License (see below). +* Please read and understand the full terms of the AGPLv3 carefully before use. + +**2. Commercial License - For Organizations with More Than 10 Individuals, or Users Needing to Avoid AGPLv3 Obligations** + +* **Mandatory Requirement:** If your organization does **not** meet the "10 or Fewer Individuals" definition above (i.e., 11 or more individuals can access, use, or benefit from the software), you **must** contact us to obtain and execute a Commercial License to use Cherry Studio. +* **Voluntary Option:** Even if your organization meets the "10 or Fewer Individuals" condition, if your intended use case **cannot comply with the terms of the AGPLv3** (particularly the obligations regarding **source code disclosure**), or if you require specific commercial terms **not offered** by the AGPLv3 (such as warranties, indemnities, or freedom from copyleft restrictions), you also **must** contact us to obtain and execute a Commercial License. +* **Common scenarios requiring a Commercial License include (but are not limited to):** + * Your organization has more than 10 individuals who can access, use, or benefit from the software. + * (Regardless of organization size) You wish to distribute a modified version of Cherry Studio but **do not want** to disclose the source code of your modifications under AGPLv3. + * (Regardless of organization size) You wish to provide a network service (SaaS) based on a modified version of Cherry Studio but **do not want** to provide the modified source code to users of the service under AGPLv3. + * (Regardless of organization size) Your corporate policies, client contracts, or project requirements prohibit the use of AGPLv3-licensed software or mandate closed-source distribution and confidentiality. +* The Commercial License grants you rights exempting you from AGPLv3 obligations (like source code disclosure) and may include additional commercial assurances. +* **Obtaining a Commercial License:** Please contact the Cherry Studio development team via email at **bd@cherry-ai.com** to discuss commercial licensing options. + +**3. Contributions** + +* We welcome community contributions to Cherry Studio. All contributions submitted to this project are considered to be offered under the **AGPLv3** license. +* By submitting a contribution to this project (e.g., via a Pull Request), you agree to license your code under the AGPLv3 to the project and all its downstream users (regardless of whether those users ultimately operate under AGPLv3 or a Commercial License). +* You also understand and agree that your contribution may be included in distributions of Cherry Studio offered under our commercial license. + +**4. Other Terms** + +* The specific terms and conditions of the Commercial License are governed by the formal commercial license agreement signed by both parties. +* The project maintainers reserve the right to update this licensing policy (including the definition and threshold for user count) as needed. Updates will be communicated through official project channels (e.g., code repository, official website). diff --git a/README.md b/README.md index bcb34205a8..bb43825325 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,9 @@ https://docs.cherry-ai.com - Theme Gallery: https://cherrycss.com - Aero Theme: https://github.com/hakadao/CherryStudio-Aero -- PaperMaterial Theme: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial - +- PaperMaterial Theme: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial +- Claude dynamic-style: https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic + Welcome PR for more themes # 🖥️ Develop diff --git a/docs/README.ja.md b/docs/README.ja.md index 0d27ef2f27..1f58a46a60 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -86,7 +86,9 @@ https://docs.cherry-ai.com # 🌈 テーマ テーマギャラリー: https://cherrycss.com -Aero テーマ: https://github.com/hakadao/CherryStudio-Aero +Aero テーマ: https://github.com/hakadao/CherryStudio-Aero +PaperMaterial テーマ: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial +Claude テーマ: https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic より多くのテーマのPRを歓迎します diff --git a/docs/README.zh.md b/docs/README.zh.md index d618016650..c2007edd40 100644 --- a/docs/README.zh.md +++ b/docs/README.zh.md @@ -86,7 +86,9 @@ https://docs.cherry-ai.com # 🌈 主题 主题库:https://cherrycss.com -Aero 主题:https://github.com/hakadao/CherryStudio-Aero +Aero 主题:https://github.com/hakadao/CherryStudio-Aero +PaperMaterial 主题: https://github.com/rainoffallingstar/CherryStudio-PaperMaterial +仿Claude 主题: https://github.com/bjl101501/CherryStudio-Claudestyle-dynamic 欢迎 PR 更多主题 diff --git a/package.json b/package.json index d6bb8a9493..dcfbe91d9e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.2.2", + "version": "1.2.3", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", @@ -93,7 +93,6 @@ "officeparser": "^4.1.1", "proxy-agent": "^6.5.0", "tar": "^7.4.3", - "tiny-pinyin": "^1.3.2", "turndown": "^7.2.0", "turndown-plugin-gfm": "^1.0.2", "undici": "^7.4.0", @@ -162,6 +161,7 @@ "lint-staged": "^15.5.0", "lodash": "^4.17.21", "lru-cache": "^11.1.0", + "lucide-react": "^0.487.0", "mime": "^4.0.4", "npx-scope-finder": "^1.2.0", "openai": "patch:openai@npm%3A4.87.3#~/.yarn/patches/openai-npm-4.87.3-2b30a7685f.patch", @@ -191,6 +191,7 @@ "shiki": "^3.2.1", "string-width": "^7.2.0", "styled-components": "^6.1.11", + "tiny-pinyin": "^1.3.2", "tinycolor2": "^1.6.0", "tokenx": "^0.4.1", "typescript": "^5.6.2", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 31268b6c39..d36b53cb71 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -52,6 +52,8 @@ export enum IpcChannel { Mcp_CallTool = 'mcp:call-tool', Mcp_ListPrompts = 'mcp:list-prompts', Mcp_GetPrompt = 'mcp:get-prompt', + Mcp_ListResources = 'mcp:list-resources', + Mcp_GetResource = 'mcp:get-resource', Mcp_GetInstallInfo = 'mcp:get-install-info', Mcp_ServersChanged = 'mcp:servers-changed', Mcp_ServersUpdated = 'mcp:servers-updated', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 735c6b0046..0333b3d03f 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,4 +1,5 @@ import fs from 'node:fs' +import { arch } from 'node:os' import { isMac, isWin } from '@main/constant' import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' @@ -48,7 +49,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { configPath: getConfigDir(), appDataPath: app.getPath('userData'), resourcesPath: getResourcePath(), - logsPath: log.transports.file.getFile().path + logsPath: log.transports.file.getFile().path, + arch: arch() })) ipcMain.handle(IpcChannel.App_Proxy, async (_, proxy: string) => { @@ -154,7 +156,16 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { // check for update ipcMain.handle(IpcChannel.App_CheckForUpdate, async () => { + // 在 Windows 上,如果架构是 arm64,则不检查更新 + if (isWin && arch().includes('arm')) { + return { + currentVersion: app.getVersion(), + updateInfo: null + } + } + const update = await appUpdater.autoUpdater.checkForUpdates() + return { currentVersion: appUpdater.autoUpdater.currentVersion, updateInfo: update?.updateInfo @@ -266,6 +277,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.Mcp_CallTool, mcpService.callTool) ipcMain.handle(IpcChannel.Mcp_ListPrompts, mcpService.listPrompts) ipcMain.handle(IpcChannel.Mcp_GetPrompt, mcpService.getPrompt) + ipcMain.handle(IpcChannel.Mcp_ListResources, mcpService.listResources) + ipcMain.handle(IpcChannel.Mcp_GetResource, mcpService.getResource) ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo) ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name)) diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index 52105be8e2..e3b93889d1 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -10,7 +10,7 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory' import { nanoid } from '@reduxjs/toolkit' -import { GetMCPPromptResponse, MCPPrompt, MCPServer, MCPTool } from '@types' +import { GetMCPPromptResponse, GetResourceResponse, MCPPrompt, MCPResource, MCPServer, MCPTool } from '@types' import { app } from 'electron' import Logger from 'electron-log' @@ -71,6 +71,8 @@ class McpService { this.callTool = this.callTool.bind(this) this.listPrompts = this.listPrompts.bind(this) this.getPrompt = this.getPrompt.bind(this) + this.listResources = this.listResources.bind(this) + this.getResource = this.getResource.bind(this) this.closeClient = this.closeClient.bind(this) this.removeServer = this.removeServer.bind(this) this.restartServer = this.restartServer.bind(this) @@ -117,9 +119,9 @@ class McpService { try { await inMemoryServer.connect(serverTransport) Logger.info(`[MCP] In-memory server started: ${server.name}`) - } catch (error) { + } catch (error: Error | any) { Logger.error(`[MCP] Error starting in-memory server: ${error}`) - throw new Error(`Failed to start in-memory server: ${error}`) + throw new Error(`Failed to start in-memory server: ${error.message}`) } // set the client transport to the client transport = clientTransport @@ -203,7 +205,7 @@ class McpService { return client } catch (error: any) { Logger.error(`[MCP] Error activating server ${server.name}:`, error) - throw error + throw new Error(`[MCP] Error activating server ${server.name}: ${error.message}`) } } @@ -385,6 +387,89 @@ class McpService { return await cachedGetPrompt(server, name, args) } + /** + * List resources available on an MCP server (implementation) + */ + private async listResourcesImpl(server: MCPServer): Promise { + Logger.info(`[MCP] Listing resources for server: ${server.name}`) + const client = await this.initClient(server) + try { + const result = await client.listResources() + const resources = result.resources || [] + const serverResources = (Array.isArray(resources) ? resources : []).map((resource: any) => ({ + ...resource, + serverId: server.id, + serverName: server.name + })) + return serverResources + } catch (error) { + Logger.error(`[MCP] Failed to list resources for server: ${server.name}`, error) + return [] + } + } + + /** + * List resources available on an MCP server with caching + */ + public async listResources(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise { + const cachedListResources = withCache<[MCPServer], MCPResource[]>( + this.listResourcesImpl.bind(this), + (server) => { + const serverKey = this.getServerKey(server) + return `mcp:list_resources:${serverKey}` + }, + 60 * 60 * 1000, // 60 minutes TTL + `[MCP] Resources from ${server.name}` + ) + return cachedListResources(server) + } + + /** + * Get a specific resource from an MCP server (implementation) + */ + private async getResourceImpl(server: MCPServer, uri: string): Promise { + Logger.info(`[MCP] Getting resource ${uri} from server: ${server.name}`) + const client = await this.initClient(server) + try { + const result = await client.readResource({ uri: uri }) + const contents: MCPResource[] = [] + if (result.contents && result.contents.length > 0) { + result.contents.forEach((content: any) => { + contents.push({ + ...content, + serverId: server.id, + serverName: server.name + }) + }) + } + return { + contents: contents + } + } catch (error: Error | any) { + Logger.error(`[MCP] Failed to get resource ${uri} from server: ${server.name}`, error) + throw new Error(`Failed to get resource ${uri} from server: ${server.name}: ${error.message}`) + } + } + + /** + * Get a specific resource from an MCP server with caching + */ + public async getResource( + _: Electron.IpcMainInvokeEvent, + { server, uri }: { server: MCPServer; uri: string } + ): Promise { + const cachedGetResource = withCache<[MCPServer, string], GetResourceResponse>( + this.getResourceImpl.bind(this), + (server, uri) => { + const serverKey = this.getServerKey(server) + return `mcp:get_resource:${serverKey}:${uri}` + }, + 30 * 60 * 1000, // 30 minutes TTL + `[MCP] Resource ${uri} from ${server.name}` + ) + return await cachedGetResource(server, uri) + } + /** * Get enhanced PATH including common tool locations */ diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 8ba792a351..0ebbc48f25 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,7 +1,7 @@ import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces' import { ElectronAPI } from '@electron-toolkit/preload' import type { FileMetadataResponse, ListFilesResponse, UploadFileResponse } from '@google/generative-ai/server' -import type { MCPServer, MCPTool } from '@renderer/types' +import type { GetMCPPromptResponse, MCPPrompt, MCPResource, MCPServer, MCPTool } from '@renderer/types' import { AppInfo, FileType, KnowledgeBaseParams, KnowledgeItem, LanguageVarious, WebDavConfig } from '@renderer/types' import type { LoaderReturn } from '@shared/config/types' import type { OpenDialogOptions } from 'electron' @@ -161,6 +161,8 @@ declare global { name: string args?: Record }) => Promise + listResources: (server: MCPServer) => Promise + getResource: ({ server, uri }: { server: MCPServer; uri: string }) => Promise getInstallInfo: () => Promise<{ dir: string; uvPath: string; bunPath: string }> } copilot: { diff --git a/src/preload/index.ts b/src/preload/index.ts index f2e891c82b..a4caf82004 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -140,6 +140,9 @@ const api = { listPrompts: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListPrompts, server), getPrompt: ({ server, name, args }: { server: MCPServer; name: string; args?: Record }) => ipcRenderer.invoke(IpcChannel.Mcp_GetPrompt, { server, name, args }), + listResources: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListResources, server), + getResource: ({ server, uri }: { server: MCPServer; uri: string }) => + ipcRenderer.invoke(IpcChannel.Mcp_GetResource, { server, uri }), getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo) }, shell: { diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index 13eb0f9a0e..6be8133f9b 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -281,3 +281,7 @@ body, color: var(--color-text); } } + +.lucide { + color: var(--color-icon); +} diff --git a/src/renderer/src/components/ListItem/index.tsx b/src/renderer/src/components/ListItem/index.tsx index 56c0534611..b3d4132845 100644 --- a/src/renderer/src/components/ListItem/index.tsx +++ b/src/renderer/src/components/ListItem/index.tsx @@ -4,7 +4,7 @@ import styled from 'styled-components' interface ListItemProps { active?: boolean icon?: ReactNode - title: string + title: ReactNode subtitle?: string titleStyle?: React.CSSProperties onClick?: () => void @@ -52,7 +52,7 @@ const ListItemContainer = styled.div` const ListItemContent = styled.div` display: flex; align-items: center; - gap: 5px; + gap: 2px; overflow: hidden; font-size: 13px; ` @@ -65,6 +65,7 @@ const IconWrapper = styled.span` ` const TextContainer = styled.div` + flex: 1; display: flex; flex-direction: column; overflow: hidden; diff --git a/src/renderer/src/components/Popups/AddAssistantPopup.tsx b/src/renderer/src/components/Popups/AddAssistantPopup.tsx index 7d3ec6a04f..d964e0287c 100644 --- a/src/renderer/src/components/Popups/AddAssistantPopup.tsx +++ b/src/renderer/src/components/Popups/AddAssistantPopup.tsx @@ -1,4 +1,3 @@ -import { SearchOutlined } from '@ant-design/icons' import { TopView } from '@renderer/components/TopView' import { useAgents } from '@renderer/hooks/useAgents' import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant' @@ -9,6 +8,7 @@ import { Agent, Assistant } from '@renderer/types' import { uuid } from '@renderer/utils' import { Divider, Input, InputRef, Modal, Tag } from 'antd' import { take } from 'lodash' +import { Search } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -163,7 +163,7 @@ const PopupContainer: React.FC = ({ resolve }) => { - + } ref={inputRef} @@ -177,7 +177,7 @@ const PopupContainer: React.FC = ({ resolve }) => { size="middle" /> - + {take(agents, 100).map((agent, index) => ( = ({ model, resolve }) => { - + } ref={inputRef} @@ -403,7 +404,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { }} /> - + {processedItems.length > 0 ? ( @@ -510,8 +511,8 @@ const EmptyState = styled.div` ` const SearchIcon = styled.div` - width: 36px; - height: 36px; + width: 32px; + height: 32px; border-radius: 50%; display: flex; flex-direction: row; diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx index 08b91b024f..f70c4f7e6c 100644 --- a/src/renderer/src/components/QuickPanel/view.tsx +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -545,6 +545,7 @@ const QuickPanelBody = styled.div` background-color: rgba(240, 240, 240, 0.5); backdrop-filter: blur(35px) saturate(150%); z-index: -1; + border-radius: inherit; body[theme-mode='dark'] & { background-color: rgba(40, 40, 40, 0.4); @@ -603,6 +604,7 @@ const QuickPanelItem = styled.div` cursor: pointer; transition: background-color 0.1s ease; margin-bottom: 1px; + font-family: Ubuntu; &.selected { background-color: var(--selected-color); &.focused { diff --git a/src/renderer/src/components/TranslateButton.tsx b/src/renderer/src/components/TranslateButton.tsx index afd153bbf4..fd8d97c0b0 100644 --- a/src/renderer/src/components/TranslateButton.tsx +++ b/src/renderer/src/components/TranslateButton.tsx @@ -1,10 +1,11 @@ -import { LoadingOutlined, TranslationOutlined } from '@ant-design/icons' +import { LoadingOutlined } from '@ant-design/icons' import { useDefaultModel } from '@renderer/hooks/useAssistant' import { useSettings } from '@renderer/hooks/useSettings' import { fetchTranslate } from '@renderer/services/ApiService' import { getDefaultTopic, getDefaultTranslateAssistant } from '@renderer/services/AssistantService' import { getUserMessage } from '@renderer/services/MessagesService' import { Button, Tooltip } from 'antd' +import { Languages } from 'lucide-react' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -82,7 +83,7 @@ const TranslateButton: FC = ({ text, onTranslated, disabled, style, isLoa title={t('chat.input.translate', { target_language: t(`languages.${targetLanguage.toString()}`) })} arrow> - {isTranslating ? : } + {isTranslating ? : } ) diff --git a/src/renderer/src/components/app/Sidebar.tsx b/src/renderer/src/components/app/Sidebar.tsx index 25409fda31..a62f594446 100644 --- a/src/renderer/src/components/app/Sidebar.tsx +++ b/src/renderer/src/components/app/Sidebar.tsx @@ -1,10 +1,3 @@ -import { - FileSearchOutlined, - FolderOutlined, - PictureOutlined, - QuestionCircleOutlined, - TranslationOutlined -} from '@ant-design/icons' import { isMac } from '@renderer/config/constant' import { AppLogo, UserAvatar } from '@renderer/config/env' import { useTheme } from '@renderer/context/ThemeProvider' @@ -17,6 +10,19 @@ import { useSettings } from '@renderer/hooks/useSettings' import { isEmoji } from '@renderer/utils' import type { MenuProps } from 'antd' import { Avatar, Dropdown, Tooltip } from 'antd' +import { + CircleHelp, + Folder, + Languages, + LayoutGrid, + LibraryBig, + MessageSquareQuote, + Moon, + Palette, + Settings, + Sparkle, + Sun +} from 'lucide-react' import { FC, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useLocation, useNavigate } from 'react-router-dom' @@ -84,7 +90,7 @@ const Sidebar: FC = () => { - + { mouseEnterDelay={0.8} placement="right"> toggleTheme()}> - {theme === 'dark' ? ( - - ) : ( - - )} + {theme === 'dark' ? : } { hideMinappPopup() - await modelGenerating() await to('/settings/provider') }}> - + @@ -129,13 +130,13 @@ const MainMenus: FC = () => { const isRoutes = (path: string): string => (pathname.startsWith(path) && !minappShow ? 'active' : '') const iconMap = { - assistants: , - agents: , - paintings: , - translate: , - minapp: , - knowledge: , - files: + assistants: , + agents: , + paintings: , + translate: , + minapp: , + knowledge: , + files: } const pathMap = { @@ -364,30 +365,19 @@ const Icon = styled.div<{ theme: string }>` box-sizing: border-box; -webkit-app-region: none; border: 0.5px solid transparent; - .iconfont, - .anticon { - color: var(--color-icon); - font-size: 20px; - text-decoration: none; - } - .anticon { - font-size: 17px; - } &:hover { background-color: ${({ theme }) => (theme === 'dark' ? 'var(--color-black)' : 'var(--color-white)')}; opacity: 0.8; cursor: pointer; - .iconfont, - .anticon { + .icon { color: var(--color-icon-white); } } &.active { background-color: ${({ theme }) => (theme === 'dark' ? 'var(--color-black)' : 'var(--color-white)')}; border: 0.5px solid var(--color-border); - .iconfont, - .anticon { - color: var(--color-icon-white); + .icon { + color: var(--color-primary); } } diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index b02c478a00..015e8ad0fe 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -2265,6 +2265,12 @@ export function isWebSearchModel(model: Model): boolean { return false } + if (model.type) { + if (model.type.includes('web_search')) { + return true + } + } + const provider = getProviderByModel(model) if (!provider) { @@ -2301,7 +2307,7 @@ export function isWebSearchModel(model: Model): boolean { } if (provider.id === 'dashscope') { - const models = ['qwen-turbo', 'qwen-max', 'qwen-plus'] + const models = ['qwen-turbo', 'qwen-max', 'qwen-plus', 'qwq'] // matches id like qwen-max-0919, qwen-max-latest return models.some((i) => model.id.startsWith(i)) } @@ -2310,7 +2316,7 @@ export function isWebSearchModel(model: Model): boolean { return true } - return model.type?.includes('web_search') || false + return false } export function isGenerateImageModel(model: Model): boolean { @@ -2406,3 +2412,27 @@ export function isHunyuanSearchModel(model?: Model): boolean { return false } + +/** + * 按 Qwen 系列模型分组 + * @param models 模型列表 + * @returns 分组后的模型 + */ +export function groupQwenModels(models: Model[]): Record { + return models.reduce( + (groups, model) => { + // 匹配 Qwen 系列模型的前缀 + const prefixMatch = model.id.match(/^(qwen(?:\d+\.\d+|2(?:\.\d+)?|-\d+b|-(?:max|coder|vl)))/i) + // 匹配 qwen2.5、qwen2、qwen-7b、qwen-max、qwen-coder 等 + const groupKey = prefixMatch ? prefixMatch[1] : model.group || '其他' + + if (!groups[groupKey]) { + groups[groupKey] = [] + } + groups[groupKey].push(model) + + return groups + }, + {} as Record + ) +} diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index 07e105281e..54d9f9c4e4 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -319,9 +319,9 @@ export const PROVIDER_CONFIG = { }, websites: { official: 'https://www.aliyun.com/product/bailian', - apiKey: 'https://bailian.console.aliyun.com/?apiKey=1#/api-key', + apiKey: 'https://bailian.console.aliyun.com/?tab=model#/api-key', docs: 'https://help.aliyun.com/zh/model-studio/getting-started/', - models: 'https://bailian.console.aliyun.com/model-market#/model-market' + models: 'https://bailian.console.aliyun.com/?tab=model#/model-market' } }, stepfun: { diff --git a/src/renderer/src/context/AntdProvider.tsx b/src/renderer/src/context/AntdProvider.tsx index 3e230931b3..93dcbf2c30 100644 --- a/src/renderer/src/context/AntdProvider.tsx +++ b/src/renderer/src/context/AntdProvider.tsx @@ -33,7 +33,11 @@ const AntdProvider: FC = ({ children }) => { boxShadowSecondary: 'none', defaultShadow: 'none', dangerShadow: 'none', - primaryShadow: 'none' + primaryShadow: 'none', + borderRadius: 20 + }, + Select: { + borderRadius: 20 } }, token: { diff --git a/src/renderer/src/hooks/useSettings.ts b/src/renderer/src/hooks/useSettings.ts index 0a05a162fd..9e75b57dd1 100644 --- a/src/renderer/src/hooks/useSettings.ts +++ b/src/renderer/src/hooks/useSettings.ts @@ -1,10 +1,11 @@ import store, { useAppDispatch, useAppSelector } from '@renderer/store' import { + AssistantIconType, SendMessageShortcut, + setAssistantIconType, setLaunchOnBoot, setLaunchToTray, setSendMessageShortcut as _setSendMessageShortcut, - setShowAssistantIcon, setSidebarIcons, setTargetLanguage, setTheme, @@ -70,8 +71,8 @@ export function useSettings() { updateSidebarDisabledIcons(icons: SidebarIcon[]) { dispatch(setSidebarIcons({ disabled: icons })) }, - setShowAssistantIcon(showAssistantIcon: boolean) { - dispatch(setShowAssistantIcon(showAssistantIcon)) + setAssistantIconType(assistantIconType: AssistantIconType) { + dispatch(setAssistantIconType(assistantIconType)) } } } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 0abed48878..bd2230a25d 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -65,6 +65,7 @@ "edit.title": "Edit Assistant", "save.success": "Saved successfully", "save.title": "Save to agent", + "icon.type": "Assistant Icon", "search": "Search assistants...", "settings.default_model": "Default Model", "settings.knowledge_base": "Knowledge Base Settings", @@ -816,7 +817,10 @@ "advanced.title": "Advanced Settings", "assistant": "Default Assistant", "assistant.model_params": "Model Parameters", - "assistant.show.icon": "Show model icon", + "assistant.icon.type": "Model Icon Type", + "assistant.icon.type.model": "Model Icon", + "assistant.icon.type.emoji": "Emoji Icon", + "assistant.icon.type.none": "Hide", "assistant.title": "Default Assistant", "data": { "app_data": "App Data", @@ -1137,6 +1141,16 @@ "genericError": "Get prompt Error", "loadError": "Get prompts Error" }, + "resources": { + "noResourcesAvailable": "No resources available", + "availableResources": "Available Resources", + "uri": "URI", + "mimeType": "MIME Type", + "size": "Size", + "blob": "Blob", + "blobInvisible": "Blob Invisible", + "text": "Text" + }, "deleteServer": "Delete Server", "deleteServerConfirm": "Are you sure you want to delete this server?", "registry": "Package Registry", @@ -1157,6 +1171,7 @@ "messages.input.show_estimated_tokens": "Show estimated tokens", "messages.input.title": "Input Settings", "messages.input.enable_quick_triggers": "Enable '/' and '@' triggers", + "messages.input.enable_delete_model": "Enable the backspace key to delete models/attachments.", "messages.markdown_rendering_input_message": "Markdown render input message", "messages.math_engine": "Math engine", "messages.metrics": "{{time_first_token_millsec}}ms to first token | {{token_speed}} tok/sec", @@ -1587,4 +1602,4 @@ "visualization": "Visualization" } } -} \ No newline at end of file +} diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index f24fc85436..7d3f5229b6 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -43,6 +43,7 @@ "edit.title": "アシスタントを編集", "save.success": "保存に成功しました", "save.title": "エージェントに保存", + "icon.type": "アシスタントアイコン", "search": "アシスタントを検索...", "settings.mcp": "MCP サーバー", "settings.mcp.enableFirst": "まず MCP 設定でこのサーバーを有効にしてください", @@ -794,7 +795,10 @@ "advanced.title": "詳細設定", "assistant": "デフォルトアシスタント", "assistant.model_params": "モデルパラメータ", - "assistant.show.icon": "モデルアイコンを表示", + "assistant.icon.type": "モデルアイコンタイプ", + "assistant.icon.type.model": "モデルアイコン", + "assistant.icon.type.emoji": "Emoji アイコン", + "assistant.icon.type.none": "表示しない", "assistant.title": "デフォルトアシスタント", "data": { "app_data": "アプリデータ", @@ -1114,6 +1118,16 @@ "genericError": "プロンプト取得エラー", "loadError": "プロンプト取得エラー" }, + "resources": { + "noResourcesAvailable": "利用可能なリソースはありません", + "availableResources": "利用可能なリソース", + "uri": "URI", + "mimeType": "MIMEタイプ", + "size": "サイズ", + "blob": "バイナリデータ", + "blobInvisible": "バイナリデータを非表示", + "text": "テキスト" + }, "deleteServer": "サーバーを削除", "deleteServerConfirm": "このサーバーを削除してもよろしいですか?", "registry": "パッケージ管理レジストリ", @@ -1134,6 +1148,7 @@ "messages.input.show_estimated_tokens": "推定トークン数を表示", "messages.input.title": "入力設定", "messages.input.enable_quick_triggers": "'/' と '@' を有効にしてクイックメニューを表示します。", + "messages.input.enable_delete_model": "バックスペースキーでモデル/添付ファイルを削除します。", "messages.markdown_rendering_input_message": "Markdownで入力メッセージをレンダリング", "messages.math_engine": "数式エンジン", "messages.metrics": "最初のトークンまでの時間 {{time_first_token_millsec}}ms | トークン速度 {{token_speed}} tok/sec", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 322df68934..c518d8aa73 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -43,6 +43,7 @@ "edit.title": "Редактировать ассистента", "save.success": "Успешно сохранено", "save.title": "Сохранить в агента", + "icon.type": "Иконка ассистента", "search": "Поиск ассистентов...", "settings.mcp": "Серверы MCP", "settings.mcp.enableFirst": "Сначала включите этот сервер в настройках MCP", @@ -794,7 +795,10 @@ "advanced.title": "Расширенные настройки", "assistant": "Ассистент по умолчанию", "assistant.model_params": "Параметры модели", - "assistant.show.icon": "Показывать модельный иконки", + "assistant.icon.type": "Тип модели иконки", + "assistant.icon.type.model": "Модель иконки", + "assistant.icon.type.emoji": "Emoji иконка", + "assistant.icon.type.none": "Не отображать", "assistant.title": "Ассистент по умолчанию", "data": { "app_data": "Данные приложения", @@ -1114,6 +1118,16 @@ "genericError": "Ошибка получения подсказки", "loadError": "Ошибка получения подсказок" }, + "resources": { + "noResourcesAvailable": "Нет доступных ресурсов", + "availableResources": "Доступные ресурсы", + "uri": "URI", + "mimeType": "MIME-тип", + "size": "Размер", + "blob": "Двоичные данные", + "blobInvisible": "Скрытые двоичные данные", + "text": "Текст" + }, "deleteServer": "Удалить сервер", "deleteServerConfirm": "Вы уверены, что хотите удалить этот сервер?", "registry": "Реестр пакетов", @@ -1134,6 +1148,7 @@ "messages.input.show_estimated_tokens": "Показывать затраты токенов", "messages.input.title": "Настройки ввода", "messages.input.enable_quick_triggers": "Включите '/' и '@', чтобы вызвать быстрое меню.", + "messages.input.enable_delete_model": "Включите удаление модели/вложения с помощью клавиши Backspace", "messages.markdown_rendering_input_message": "Отображение ввода в формате Markdown", "messages.math_engine": "Математический движок", "messages.metrics": "{{time_first_token_millsec}}ms до первого токена | {{token_speed}} tok/sec", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index f4490c5c68..16521284b4 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -65,6 +65,7 @@ "edit.title": "编辑助手", "save.success": "保存成功", "save.title": "保存到智能体", + "icon.type": "助手图标", "search": "搜索助手", "settings.mcp": "MCP 服务器", "settings.mcp.enableFirst": "请先在 MCP 设置中启用此服务器", @@ -816,7 +817,10 @@ "advanced.title": "高级设置", "assistant": "默认助手", "assistant.model_params": "模型参数", - "assistant.show.icon": "显示模型图标", + "assistant.icon.type": "模型图标类型", + "assistant.icon.type.model": "模型图标", + "assistant.icon.type.emoji": "Emoji 表情", + "assistant.icon.type.none": "不显示", "assistant.title": "默认助手", "data": { "app_data": "应用数据", @@ -1137,6 +1141,16 @@ "genericError": "获取提示错误", "loadError": "获取提示失败" }, + "resources": { + "noResourcesAvailable": "无可用资源", + "availableResources": "可用资源", + "uri": "URI", + "mimeType": "MIME类型", + "size": "大小", + "blob": "二进制数据", + "blobInvisible": "隐藏二进制数据", + "text": "文本" + }, "deleteServer": "删除服务器", "deleteServerConfirm": "确定要删除此服务器吗?", "registry": "包管理源", @@ -1157,6 +1171,7 @@ "messages.input.show_estimated_tokens": "显示预估 Token 数", "messages.input.title": "输入设置", "messages.input.enable_quick_triggers": "启用 '/' 和 '@' 触发快捷菜单", + "messages.input.enable_delete_model": "启用删除键删除输入的模型/附件", "messages.markdown_rendering_input_message": "Markdown 渲染输入消息", "messages.math_engine": "数学公式引擎", "messages.metrics": "首字时延 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index d965418365..339f938cf6 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -43,6 +43,7 @@ "edit.title": "編輯助手", "save.success": "儲存成功", "save.title": "儲存到智慧代理人", + "icon.type": "助手圖示", "search": "搜尋助手...", "settings.mcp": "MCP 伺服器", "settings.mcp.enableFirst": "請先在 MCP 設定中啟用此伺服器", @@ -794,7 +795,10 @@ "advanced.title": "進階設定", "assistant": "預設助手", "assistant.model_params": "模型參數", - "assistant.show.icon": "顯示模型圖示", + "assistant.icon.type": "模型圖示類型", + "assistant.icon.type.model": "模型圖示", + "assistant.icon.type.emoji": "Emoji 表情", + "assistant.icon.type.none": "不顯示", "assistant.title": "預設助手", "data": { "app_data": "應用程式資料", @@ -1114,6 +1118,16 @@ "genericError": "獲取提示錯誤", "loadError": "獲取提示失敗" }, + "resources": { + "noResourcesAvailable": "無可用資源", + "availableResources": "可用資源", + "uri": "URI", + "mimeType": "MIME類型", + "size": "大小", + "blob": "二進位數據", + "blobInvisible": "隱藏二進位數據", + "text": "文字" + }, "deleteServer": "刪除伺服器", "deleteServerConfirm": "確定要刪除此伺服器嗎?", "registry": "套件管理源", @@ -1134,6 +1148,7 @@ "messages.input.show_estimated_tokens": "顯示預估 Token 數", "messages.input.title": "輸入設定", "messages.input.enable_quick_triggers": "啟用 '/' 和 '@' 觸發快捷選單", + "messages.input.enable_delete_model": "啟用刪除鍵刪除模型/附件", "messages.markdown_rendering_input_message": "Markdown 渲染輸入訊息", "messages.math_engine": "Markdown 渲染輸入訊息", "messages.metrics": "首字延遲 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens", diff --git a/src/renderer/src/pages/agents/AgentsPage.tsx b/src/renderer/src/pages/agents/AgentsPage.tsx index 840765e630..162177e838 100644 --- a/src/renderer/src/pages/agents/AgentsPage.tsx +++ b/src/renderer/src/pages/agents/AgentsPage.tsx @@ -1,79 +1,84 @@ -import { SearchOutlined } from '@ant-design/icons' +import { PlusOutlined } from '@ant-design/icons' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' +import CustomTag from '@renderer/components/CustomTag' +import ListItem from '@renderer/components/ListItem' import Scrollbar from '@renderer/components/Scrollbar' +import { useAgents } from '@renderer/hooks/useAgents' import { createAssistantFromAgent } from '@renderer/services/AssistantService' import { Agent } from '@renderer/types' import { uuid } from '@renderer/utils' -import { Col, Empty, Input, Row, Tabs as TabsAntd, Typography } from 'antd' -import { groupBy, omit } from 'lodash' -import { FC, useCallback, useMemo, useState } from 'react' +import { Button, Empty, Flex, Input } from 'antd' +import { omit } from 'lodash' +import { Search } from 'lucide-react' +import { FC, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import ReactMarkdown from 'react-markdown' import styled from 'styled-components' -import { getAgentsFromSystemAgents, useSystemAgents } from '.' +import { groupByCategories, useSystemAgents } from '.' import { groupTranslations } from './agentGroupTranslations' +import AddAgentPopup from './components/AddAgentPopup' import AgentCard from './components/AgentCard' -import MyAgents from './components/MyAgents' - -const { Title } = Typography - -let _agentGroups: Record = {} +import { AgentGroupIcon } from './components/AgentGroupIcon' const AgentsPage: FC = () => { const [search, setSearch] = useState('') const [searchInput, setSearchInput] = useState('') + const [activeGroup, setActiveGroup] = useState('我的') + const [agentGroups, setAgentGroups] = useState>({}) const systemAgents = useSystemAgents() + const { agents: userAgents } = useAgents() - const agentGroups = useMemo(() => { - if (Object.keys(_agentGroups).length === 0) { - _agentGroups = groupBy(getAgentsFromSystemAgents(systemAgents), 'group') + useEffect(() => { + const systemAgentsGroupList = groupByCategories(systemAgents) + const agentsGroupList = { + 我的: userAgents, + 精选: [], + ...systemAgentsGroupList + } as Record + setAgentGroups(agentsGroupList) + }, [systemAgents, userAgents]) + + const filteredAgents = useMemo(() => { + let agents: Agent[] = [] + + if (search.trim()) { + const uniqueAgents = new Map() + + Object.entries(agentGroups).forEach(([, agents]) => { + agents.forEach((agent) => { + if ( + (agent.name.toLowerCase().includes(search.toLowerCase()) || + agent.description?.toLowerCase().includes(search.toLowerCase())) && + !uniqueAgents.has(agent.name) + ) { + uniqueAgents.set(agent.name, agent) + } + }) + }) + agents = Array.from(uniqueAgents.values()) + } else { + agents = agentGroups[activeGroup] || [] } - return _agentGroups - }, [systemAgents]) + return agents.filter((agent) => agent.name.toLowerCase().includes(search.toLowerCase())) + }, [agentGroups, activeGroup, search]) const { t, i18n } = useTranslation() - const filteredAgentGroups = useMemo(() => { - const groups: Record = { - 我的: [], - 精选: agentGroups['精选'] || [] - } - - if (!search.trim()) { - Object.entries(agentGroups).forEach(([group, agents]) => { - if (group !== '精选') { - groups[group] = agents - } - }) - return groups - } - - const uniqueAgents = new Map() - - Object.entries(agentGroups).forEach(([, agents]) => { - agents.forEach((agent) => { - if ( - (agent.name.toLowerCase().includes(search.toLowerCase()) || - agent.description?.toLowerCase().includes(search.toLowerCase())) && - !uniqueAgents.has(agent.name) - ) { - uniqueAgents.set(agent.name, agent) - } - }) - }) - - return { 搜索结果: Array.from(uniqueAgents.values()) } - }, [agentGroups, search]) - const onAddAgentConfirm = useCallback( (agent: Agent) => { window.modal.confirm({ title: agent.name, content: ( - - {agent.description || agent.prompt} - + + {agent.description && {agent.description}} + + {agent.prompt && ( + + {agent.prompt}{' '} + + )} + ), width: 600, icon: null, @@ -106,55 +111,33 @@ const AgentsPage: FC = () => { [i18n.language] ) - const renderAgentList = useCallback( - (agents: Agent[]) => { - return ( - - {agents.map((agent, index) => ( - - onAddAgentConfirm(getAgentFromSystemAgent(agent as any))} - agent={agent as any} - /> - - ))} - - ) - }, - [getAgentFromSystemAgent, onAddAgentConfirm] - ) - - const tabItems = useMemo(() => { - const groups = Object.keys(filteredAgentGroups) - - return groups.map((group, i) => { - const id = String(i + 1) - const localizedGroupName = getLocalizedGroupName(group) - const agents = filteredAgentGroups[group] || [] - - return { - label: localizedGroupName, - key: id, - children: ( - - - {localizedGroupName} - - {group === '我的' ? : renderAgentList(agents)} - - ) - } - }) - }, [filteredAgentGroups, getLocalizedGroupName, onAddAgentConfirm, search, renderAgentList]) - const handleSearch = () => { if (searchInput.trim() === '') { setSearch('') + setActiveGroup('我的') } else { + setActiveGroup('') setSearch(searchInput) } } + const handleSearchClear = () => { + setSearch('') + setActiveGroup('我的') + } + + const handleGroupClick = (group: string) => () => { + setSearch('') + setSearchInput('') + setActiveGroup(group) + } + + const handleAddAgent = () => { + AddAgentPopup.show().then(() => { + handleSearchClear() + }) + } + return ( @@ -163,12 +146,12 @@ const AgentsPage: FC = () => { setSearch('')} - suffix={} + onClear={handleSearchClear} + suffix={} value={searchInput} maxLength={50} onChange={(e) => setSearchInput(e.target.value)} @@ -177,21 +160,78 @@ const AgentsPage: FC = () => {
- - - {Object.values(filteredAgentGroups).flat().length > 0 ? ( - search.trim() ? ( - {renderAgentList(Object.values(filteredAgentGroups).flat())} - ) : ( - - ) + +
+ + {Object.entries(agentGroups).map(([group]) => ( + + + + {getLocalizedGroupName(group)} + + { +
+ + {agentGroups[group].length} + +
+ } + + } + style={{ margin: '0 8px', paddingLeft: 16, paddingRight: 16 }} + onClick={handleGroupClick(group)}>
+ ))} +
+ + + + + {search.trim() ? ( + <> + + {search.trim()}{' '} + + ) : ( + <> + + {getLocalizedGroupName(activeGroup)} + + )} + + { + + {filteredAgents.length} + + } + + + + + {filteredAgents.length > 0 ? ( + + {filteredAgents.map((agent, index) => ( + onAddAgentConfirm(getAgentFromSystemAgent(agent))} + agent={agent} + activegroup={activeGroup} + getLocalizedGroupName={getLocalizedGroupName} + /> + ))} + ) : ( )} - - + +
) } @@ -203,42 +243,76 @@ const Container = styled.div` height: 100%; ` -const ContentContainer = styled.div` +const AgentsGroupList = styled(Scrollbar)` + min-width: 160px; + height: calc(100vh - var(--navbar-height)); display: flex; - flex: 1; - flex-direction: row; - justify-content: center; - height: 100%; - padding: 0 10px; - padding-left: 0; - border-top: 0.5px solid var(--color-border); + flex-direction: column; + gap: 8px; + padding: 8px 0; + border-right: 0.5px solid var(--color-border); + border-top-left-radius: inherit; + border-bottom-left-radius: inherit; + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } ` -const AssistantsContainer = styled.div` - display: flex; +const Main = styled.div` flex: 1; - flex-direction: row; - height: calc(100vh - var(--navbar-height)); + display: flex; ` -const TabContent = styled(Scrollbar)` +const AgentsListContainer = styled.div` height: calc(100vh - var(--navbar-height)); - padding: 10px 10px 10px 15px; - margin-right: -4px; - padding-bottom: 20px !important; - overflow-x: hidden; - transform: translateZ(0); - will-change: transform; - -webkit-font-smoothing: antialiased; + flex: 1; + display: flex; + flex-direction: column; +` + +const AgentsListHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px 12px; +` + +const AgentsListTitle = styled.div` + font-size: 16px; + line-height: 18px; + font-weight: 500; + color: var(--color-text-1); + display: flex; + align-items: center; + gap: 8px; +` + +const AgentsList = styled(Scrollbar)` + flex: 1; + padding: 8px 16px 16px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + grid-auto-rows: 160px; + gap: 16px; +` + +const AgentDescription = styled.div` + color: var(--color-text-2); + font-size: 12px; ` const AgentPrompt = styled.div` max-height: 60vh; overflow-y: scroll; - max-width: 560px; + background-color: var(--color-background-soft); + padding: 8px; + border-radius: 10px; ` const EmptyView = styled.div` + height: 100%; display: flex; flex: 1; justify-content: center; @@ -247,74 +321,4 @@ const EmptyView = styled.div` color: var(--color-text-secondary); ` -const Tabs = styled(TabsAntd)<{ $language: string }>` - display: flex; - flex: 1; - flex-direction: row-reverse; - - .ant-tabs-tabpane { - padding-right: 0 !important; - } - .ant-tabs-nav { - min-width: ${({ $language }) => ($language.startsWith('zh') ? '120px' : '140px')}; - max-width: ${({ $language }) => ($language.startsWith('zh') ? '120px' : '140px')}; - position: relative; - overflow: hidden; - } - .ant-tabs-nav-list { - padding: 10px 8px; - } - .ant-tabs-nav-operations { - display: none !important; - } - .ant-tabs-tab { - margin: 0 !important; - border-radius: var(--list-item-border-radius); - margin-bottom: 5px !important; - font-size: 13px; - justify-content: left; - padding: 7px 15px !important; - border: 0.5px solid transparent; - justify-content: ${({ $language }) => ($language.startsWith('zh') ? 'center' : 'flex-start')}; - user-select: none; - transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1); - outline: none !important; - .ant-tabs-tab-btn { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 100px; - transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1); - outline: none !important; - } - &:hover { - color: var(--color-text) !important; - background-color: var(--color-background-soft); - } - } - .ant-tabs-tab-active { - background-color: var(--color-background-soft); - border: 0.5px solid var(--color-border); - transform: scale(1.02); - } - .ant-tabs-content-holder { - border-left: 0.5px solid var(--color-border); - border-right: none; - } - .ant-tabs-ink-bar { - display: none; - } - .ant-tabs-tab-btn:active { - color: var(--color-text) !important; - } - .ant-tabs-tab-active { - .ant-tabs-tab-btn { - color: var(--color-text) !important; - } - } - .ant-tabs-content { - transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1); - } -` - export default AgentsPage diff --git a/src/renderer/src/pages/agents/components/AddAgentCard.tsx b/src/renderer/src/pages/agents/components/AddAgentCard.tsx deleted file mode 100644 index ea58ffdcc3..0000000000 --- a/src/renderer/src/pages/agents/components/AddAgentCard.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { PlusOutlined } from '@ant-design/icons' -import { useTranslation } from 'react-i18next' -import styled from 'styled-components' - -interface AddAgentCardProps { - onClick: () => void - className?: string -} - -const AddAgentCard = ({ onClick, className }: AddAgentCardProps) => { - const { t } = useTranslation() - - return ( - - - {t('agents.add.title')} - - ) -} - -const StyledCard = styled.div` - width: 100%; - height: 180px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - background-color: var(--color-background); - border-radius: 15px; - border: 1px dashed var(--color-border); - cursor: pointer; - transition: all 0.3s ease; - color: var(--color-text-soft); - - &:hover { - border-color: var(--color-primary); - color: var(--color-primary); - } -` - -export default AddAgentCard diff --git a/src/renderer/src/pages/agents/components/AgentCard.tsx b/src/renderer/src/pages/agents/components/AgentCard.tsx index 2f64e9aa6e..d322061a56 100644 --- a/src/renderer/src/pages/agents/components/AgentCard.tsx +++ b/src/renderer/src/pages/agents/components/AgentCard.tsx @@ -1,78 +1,163 @@ -import { EllipsisOutlined } from '@ant-design/icons' +import { DeleteOutlined, EditOutlined, EllipsisOutlined, PlusOutlined, SortAscendingOutlined } from '@ant-design/icons' +import CustomTag from '@renderer/components/CustomTag' +import { useAgents } from '@renderer/hooks/useAgents' +import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings' +import { createAssistantFromAgent } from '@renderer/services/AssistantService' import type { Agent } from '@renderer/types' import { getLeadingEmoji } from '@renderer/utils' -import { Dropdown } from 'antd' -import { type FC, memo } from 'react' +import { Button, Dropdown } from 'antd' +import { t } from 'i18next' +import { type FC, memo, useCallback, useEffect, useRef, useState } from 'react' import styled from 'styled-components' +import ManageAgentsPopup from './ManageAgentsPopup' + interface Props { agent: Agent + activegroup?: string onClick: () => void - contextMenu?: { - key: string - label: string - icon?: React.ReactNode - danger?: boolean - onClick: () => void - }[] - menuItems?: { - key: string - label: string - icon?: React.ReactNode - danger?: boolean - onClick: () => void - }[] + getLocalizedGroupName: (group: string) => string } -const AgentCard: FC = ({ agent, onClick, contextMenu, menuItems }) => { - const emoji = agent.emoji || getLeadingEmoji(agent.name) - const prompt = (agent.description || agent.prompt).substring(0, 100).replace(/\\n/g, '') - const content = ( - - {emoji && {emoji}} - {emoji} - {menuItems && ( - e.stopPropagation()}> - ({ - ...item, - onClick: (e) => { - e.domEvent.stopPropagation() - e.domEvent.preventDefault() - setTimeout(() => { - item.onClick() - }, 0) - } - })) - }} - trigger={['click']} - placement="bottomRight"> - - - - )} - - {agent.name} - {prompt}... - - +const AgentCard: FC = ({ agent, onClick, activegroup, getLocalizedGroupName }) => { + const { removeAgent } = useAgents() + const [isVisible, setIsVisible] = useState(false) + const cardRef = useRef(null) + + const handleDelete = useCallback( + (agent: Agent) => { + window.modal.confirm({ + centered: true, + content: t('agents.delete.popup.content'), + onOk: () => removeAgent(agent.id) + }) + }, + [removeAgent] ) - if (contextMenu) { + const menuItems = [ + { + key: 'edit', + label: t('agents.edit.title'), + icon: , + onClick: (e: any) => { + e.domEvent.stopPropagation() + AssistantSettingsPopup.show({ assistant: agent }) + } + }, + { + key: 'create', + label: t('agents.add.button'), + icon: , + onClick: (e: any) => { + e.domEvent.stopPropagation() + createAssistantFromAgent(agent) + } + }, + { + key: 'sort', + label: t('agents.sorting.title'), + icon: , + onClick: (e: any) => { + e.domEvent.stopPropagation() + ManageAgentsPopup.show() + } + }, + { + key: 'delete', + label: t('common.delete'), + icon: , + danger: true, + onClick: (e: any) => { + e.domEvent.stopPropagation() + handleDelete(agent) + } + } + ] + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + setIsVisible(true) + observer.disconnect() + } + }, + { threshold: 0.1 } + ) + + if (cardRef.current) { + observer.observe(cardRef.current) + } + + return () => { + observer.disconnect() + } + }, []) + + const emoji = agent.emoji || getLeadingEmoji(agent.name) + const prompt = (agent.description || agent.prompt).substring(0, 200).replace(/\\n/g, '') + + const content = ( + + {isVisible && ( + + {emoji} + + + {agent.name} + + {activegroup === '我的' && ( + + {getLocalizedGroupName('我的')} + + )} + {!!agent.group?.length && + agent.group.map((group) => ( + + {getLocalizedGroupName(group)} + + ))} + + + {activegroup === '我的' ? ( + + {emoji && {emoji}} + + { + e.stopPropagation() + e.preventDefault() + }} + color="default" + variant="filled" + shape="circle" + icon={} + /> + + + ) : ( + emoji && {emoji} + )} + + + {prompt} + + + )} + + ) + + if (activegroup === '我的') { return ( ({ - ...item, - onClick: (e) => { - e.domEvent.stopPropagation() - e.domEvent.preventDefault() - setTimeout(() => { - item.onClick() - }, 0) - } - })) + items: menuItems }} trigger={['contextMenu']}> {content} @@ -83,138 +168,153 @@ const AgentCard: FC = ({ agent, onClick, contextMenu, menuItems }) => { return content } -const Container = styled.div` - width: 100%; - height: 180px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: flex-start; - text-align: center; - gap: 10px; - background-color: var(--color-background); - border-radius: 10px; +const AgentCardHeaderInfoAction = styled.div` + width: 45px; + height: 45px; position: relative; - overflow: hidden; - cursor: pointer; - border: 0.5px solid var(--color-border); - - &::before { - content: ''; - width: 100%; - height: 70px; - position: absolute; - top: 0; - left: 0; - border-top-left-radius: 8px; - border-top-right-radius: 8px; - background: var(--color-background-soft); - transition: all 0.5s ease; - border-bottom: none; - } - - * { - z-index: 1; - } - - .agent-prompt { - opacity: 1; - transform: translateY(0); - } + display: flex; + align-items: flex-start; + justify-content: flex-end; ` -const EmojiContainer = styled.div` - width: 55px; - height: 55px; - min-width: 55px; - min-height: 55px; - background-color: var(--color-background); - border-radius: 50%; - border: 4px solid var(--color-border); - margin-top: 8px; - transition: all 0.5s ease; +const HeaderInfoEmoji = styled.div` + width: 45px; + height: 45px; + border-radius: var(--list-item-border-radius); + font-size: 26px; + line-height: 1; + opacity: 0.8; + flex-shrink: 0; + opacity: 1; + transition: opacity 0.2s ease; + background-color: var(--color-background-soft); display: flex; align-items: center; justify-content: center; - font-size: 32px; +` + +const MenuButton = styled(Button)` + position: absolute; + opacity: 0; + transition: opacity 0.2s ease; +` + +const AgentCardContainer = styled.div` + border-radius: var(--list-item-border-radius); + cursor: pointer; + border: 0.5px solid var(--color-border); + padding: 16px; + overflow: hidden; + transition: + box-shadow 0.2s ease, + background-color 0.2s ease, + transform 0.2s ease; + + --shadow-color: rgba(0, 0, 0, 0.05); + box-shadow: + 0 5px 7px -3px var(--shadow-color), + 0 2px 3px -4px var(--shadow-color); + &:hover { + box-shadow: + 0 10px 15px -3px var(--shadow-color), + 0 4px 6px -4px var(--shadow-color); + transform: translateY(-2px); + + ${AgentCardHeaderInfoAction} ${HeaderInfoEmoji} { + opacity: 0; + } + ${AgentCardHeaderInfoAction} ${MenuButton} { + opacity: 1; + } + } + body[theme-mode='dark'] & { + --shadow-color: rgba(255, 255, 255, 0.02); + } +` + +const AgentCardBody = styled.div` + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + height: 100%; + display: flex; + flex-direction: column; + position: relative; + animation: fadeIn 0.2s ease; +` + +const AgentCardBackground = styled.div` + height: 100%; + position: absolute; + top: 0; + right: -50px; + font-size: 200px; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + opacity: 0.1; + filter: blur(20px); + border-radius: 99px; + overflow: hidden; +` + +const AgentCardHeader = styled.div` + display: flex; + align-items: flex-start; + gap: 8px; + justify-content: flex-start; + overflow: hidden; +` + +const AgentCardHeaderInfo = styled.div` + flex: 1; + display: flex; + flex-direction: column; + gap: 7px; +` + +const AgentCardHeaderInfoTitle = styled.div` + font-size: 16px; + line-height: 1.2; + font-weight: 600; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + word-break: break-all; +` + +const AgentCardHeaderInfoTags = styled.div` + display: flex; + flex-direction: row; + gap: 5px; + flex-wrap: wrap; ` const CardInfo = styled.div` + flex: 1; display: flex; flex-direction: column; - align-items: center; - gap: 5px; - transition: all 0.5s ease; - padding: 0 8px; - width: 100%; -` - -const AgentName = styled.span` - font-weight: 600; - font-size: 16px; - color: var(--color-text); - margin-top: 5px; - line-height: 1.4; - max-width: 100%; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - word-break: break-word; -` - -const AgentPrompt = styled.p` - color: var(--color-text-soft); - font-size: 12px; - max-width: 100%; - opacity: 0; - transform: translateY(20px); - transition: all 0.5s ease; - margin: 0; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - line-height: 1.4; -` - -const BannerBackground = styled.div` - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 70px; - display: flex; - justify-content: center; - align-items: center; - font-size: 500px; - opacity: 0.1; - filter: blur(8px); - z-index: 0; - overflow: hidden; - transition: all 0.5s ease; -` - -const MenuContainer = styled.div` - position: absolute; - top: 10px; - right: 10px; - display: flex; - align-items: center; - justify-content: center; + margin-top: 16px; background-color: var(--color-background-soft); - width: 24px; - height: 24px; - border-radius: 12px; - font-size: 16px; - color: var(--color-icon); - opacity: 0; - transition: opacity 0.3s; - z-index: 2; + padding: 8px; + border-radius: 10px; +` - ${Container}:hover & { - opacity: 1; - } +const AgentPrompt = styled.div` + font-size: 12px; + display: -webkit-box; + line-height: 1.4; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + color: var(--color-text-2); ` export default memo(AgentCard) diff --git a/src/renderer/src/pages/agents/components/AgentGroupIcon.tsx b/src/renderer/src/pages/agents/components/AgentGroupIcon.tsx new file mode 100644 index 0000000000..4078421bd0 --- /dev/null +++ b/src/renderer/src/pages/agents/components/AgentGroupIcon.tsx @@ -0,0 +1,50 @@ +import { DynamicIcon, IconName } from 'lucide-react/dynamic' +import { FC } from 'react' + +interface Props { + groupName: string + size?: number + strokeWidth?: number +} + +export const AgentGroupIcon: FC = ({ groupName, size = 20, strokeWidth = 1.2 }) => { + const iconMap: { [key: string]: IconName } = { + 我的: 'user-check', + 精选: 'star', + 职业: 'briefcase', + 商业: 'handshake', + 工具: 'wrench', + 语言: 'languages', + 办公: 'file-text', + 通用: 'settings', + 写作: 'pen-tool', + 编程: 'code', + 情感: 'heart', + 教育: 'graduation-cap', + 创意: 'lightbulb', + 学术: 'book-open', + 设计: 'wand-sparkles', + 艺术: 'palette', + 娱乐: 'gamepad-2', + 生活: 'coffee', + 医疗: 'stethoscope', + 游戏: 'gamepad-2', + 翻译: 'languages', + 音乐: 'music', + 点评: 'message-square-more', + 文案: 'file-text', + 百科: 'book', + 健康: 'heart-pulse', + 营销: 'trending-up', + 科学: 'flask-conical', + 分析: 'bar-chart', + 法律: 'scale', + 咨询: 'messages-square', + 金融: 'banknote', + 旅游: 'plane', + 管理: 'users', + 搜索: 'search' + } as const + + return +} diff --git a/src/renderer/src/pages/agents/components/MyAgents.tsx b/src/renderer/src/pages/agents/components/MyAgents.tsx deleted file mode 100644 index ad93a825bf..0000000000 --- a/src/renderer/src/pages/agents/components/MyAgents.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { DeleteOutlined, EditOutlined, PlusOutlined, SortAscendingOutlined } from '@ant-design/icons' -import { useAgents } from '@renderer/hooks/useAgents' -import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings' -import { createAssistantFromAgent } from '@renderer/services/AssistantService' -import type { Agent } from '@renderer/types' -import { Col, Row } from 'antd' -import { useCallback, useMemo } from 'react' -import { useTranslation } from 'react-i18next' - -import AddAgentCard from './AddAgentCard' -import AddAgentPopup from './AddAgentPopup' -import AgentCard from './AgentCard' -import ManageAgentsPopup from './ManageAgentsPopup' - -interface Props { - onClick?: (agent: Agent) => void - search?: string -} - -const MyAgents: React.FC = ({ onClick, search }) => { - const { t } = useTranslation() - const { agents, removeAgent } = useAgents() - - const filteredAgents = useMemo(() => { - if (!search?.trim()) return agents - - return agents.filter( - (agent) => - agent.name.toLowerCase().includes(search.toLowerCase()) || - agent.description?.toLowerCase().includes(search.toLowerCase()) - ) - }, [agents, search]) - - const handleDelete = useCallback( - (agent: Agent) => { - window.modal.confirm({ - centered: true, - content: t('agents.delete.popup.content'), - onOk: () => removeAgent(agent.id) - }) - }, - [removeAgent, t] - ) - - return ( - - {filteredAgents.map((agent) => { - const menuItems = [ - { - key: 'edit', - label: t('agents.edit.title'), - icon: , - onClick: () => AssistantSettingsPopup.show({ assistant: agent }) - }, - { - key: 'create', - label: t('agents.add.button'), - icon: , - onClick: () => createAssistantFromAgent(agent) - }, - { - key: 'sort', - label: t('agents.sorting.title'), - icon: , - onClick: () => ManageAgentsPopup.show() - }, - { - key: 'delete', - label: t('common.delete'), - icon: , - danger: true, - onClick: () => handleDelete(agent) - } - ] - - return ( - - onClick?.(agent)} contextMenu={menuItems} menuItems={menuItems} /> - - ) - })} - - AddAgentPopup.show()} /> - - - ) -} - -export default MyAgents diff --git a/src/renderer/src/pages/agents/index.ts b/src/renderer/src/pages/agents/index.ts index 6069d40be8..d5bd6361eb 100644 --- a/src/renderer/src/pages/agents/index.ts +++ b/src/renderer/src/pages/agents/index.ts @@ -22,7 +22,7 @@ export function useSystemAgents() { useEffect(() => { runAsyncFunction(async () => { - if (_agents.length > 0) return + if (!resourcesPath || _agents.length > 0) return const agents = await window.api.fs.read(resourcesPath + '/data/agents.json') _agents = JSON.parse(agents) as Agent[] setAgents(_agents) @@ -31,3 +31,20 @@ export function useSystemAgents() { return agents } + +export function groupByCategories(data: Agent[]) { + const groupedMap = new Map() + data.forEach((item) => { + item.group?.forEach((category) => { + if (!groupedMap.has(category)) { + groupedMap.set(category, []) + } + groupedMap.get(category)?.push(item) + }) + }) + const result: Record = {} + Array.from(groupedMap.entries()).forEach(([category, items]) => { + result[category] = items + }) + return result +} diff --git a/src/renderer/src/pages/apps/AppsPage.tsx b/src/renderer/src/pages/apps/AppsPage.tsx index aef66daf99..e61def4972 100644 --- a/src/renderer/src/pages/apps/AppsPage.tsx +++ b/src/renderer/src/pages/apps/AppsPage.tsx @@ -1,9 +1,9 @@ -import { SearchOutlined } from '@ant-design/icons' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' import { Center } from '@renderer/components/Layout' import { useMinapps } from '@renderer/hooks/useMinapps' import { Empty, Input } from 'antd' import { isEmpty } from 'lodash' +import { Search } from 'lucide-react' import React, { FC, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -40,10 +40,10 @@ const AppsPage: FC = () => { } + suffix={} value={search} onChange={(e) => setSearch(e.target.value)} /> diff --git a/src/renderer/src/pages/files/FilesPage.tsx b/src/renderer/src/pages/files/FilesPage.tsx index 3e4c872354..49cfb44252 100644 --- a/src/renderer/src/pages/files/FilesPage.tsx +++ b/src/renderer/src/pages/files/FilesPage.tsx @@ -2,24 +2,21 @@ import { DeleteOutlined, EditOutlined, ExclamationCircleOutlined, - FileImageOutlined, - FilePdfOutlined, - FileTextOutlined, SortAscendingOutlined, SortDescendingOutlined } from '@ant-design/icons' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' +import ListItem from '@renderer/components/ListItem' import TextEditPopup from '@renderer/components/Popups/TextEditPopup' import db from '@renderer/databases' -import { useProviders } from '@renderer/hooks/useProvider' import FileManager from '@renderer/services/FileManager' import store from '@renderer/store' import { FileType, FileTypes } from '@renderer/types' import { formatFileSize } from '@renderer/utils' -import type { MenuProps } from 'antd' -import { Button, Empty, Flex, Menu, Popconfirm } from 'antd' +import { Button, Empty, Flex, Popconfirm } from 'antd' import dayjs from 'dayjs' import { useLiveQuery } from 'dexie-react-hooks' +import { File as FileIcon, FileImage, FileText, FileType as FileTypeIcon } from 'lucide-react' import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -34,9 +31,6 @@ const FilesPage: FC = () => { const [fileType, setFileType] = useState('document') const [sortField, setSortField] = useState('created_at') const [sortOrder, setSortOrder] = useState('desc') - const { providers } = useProviders() - - const geminiProviders = providers.filter((provider) => provider.type === 'gemini') const tempFilesSort = (files: FileType[]) => { return files.sort((a, b) => { @@ -144,16 +138,11 @@ const FilesPage: FC = () => { }) const menuItems = [ - { key: FileTypes.DOCUMENT, label: t('files.document'), icon: }, - { key: FileTypes.IMAGE, label: t('files.image'), icon: }, - { key: FileTypes.TEXT, label: t('files.text'), icon: }, - ...geminiProviders.map((provider) => ({ - key: 'gemini_' + provider.id, - label: provider.name, - icon: - })), - { key: 'all', label: t('files.all'), icon: } - ].filter(Boolean) as MenuProps['items'] + { key: FileTypes.DOCUMENT, label: t('files.document'), icon: }, + { key: FileTypes.IMAGE, label: t('files.image'), icon: }, + { key: FileTypes.TEXT, label: t('files.text'), icon: }, + { key: 'all', label: t('files.all'), icon: } + ] return ( @@ -162,7 +151,15 @@ const FilesPage: FC = () => { - setFileType(key as FileTypes)} /> + {menuItems.map((item) => ( + setFileType(item.key as FileTypes)} + /> + ))} @@ -223,10 +220,13 @@ const ContentContainer = styled.div` ` const SideNav = styled.div` + display: flex; + flex-direction: column; width: var(--settings-width); border-right: 0.5px solid var(--color-border); - padding: 7px 12px; + padding: 12px 10px; user-select: none; + gap: 6px; .ant-menu { border-inline-end: none !important; diff --git a/src/renderer/src/pages/history/HistoryPage.tsx b/src/renderer/src/pages/history/HistoryPage.tsx index 6bb035e9ad..cde7c34ba9 100644 --- a/src/renderer/src/pages/history/HistoryPage.tsx +++ b/src/renderer/src/pages/history/HistoryPage.tsx @@ -1,7 +1,8 @@ -import { ArrowLeftOutlined, EnterOutlined, SearchOutlined } from '@ant-design/icons' +import { ArrowLeftOutlined, EnterOutlined } from '@ant-design/icons' import { Message, Topic } from '@renderer/types' import { Input, InputRef } from 'antd' import { last } from 'lodash' +import { Search } from 'lucide-react' import { FC, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -83,7 +84,7 @@ const TopicsPage: FC = () => { allowClear ref={inputRef} onChange={(e) => setSearch(e.target.value.trimStart())} - suffix={search.length >= 2 ? : } + suffix={search.length >= 2 ? : } onPressEnter={onSearch} /> diff --git a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx index 5f130d5873..051f5a5784 100644 --- a/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/AttachmentButton.tsx @@ -1,8 +1,8 @@ -import { PaperClipOutlined } from '@ant-design/icons' import { isVisionModel } from '@renderer/config/models' import { FileType, Model } from '@renderer/types' import { documentExts, imageExts, textExts } from '@shared/config/constant' import { Tooltip } from 'antd' +import { Paperclip } from 'lucide-react' import { FC, useCallback, useImperativeHandle, useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -57,9 +57,7 @@ const AttachmentButton: FC = ({ ref, model, files, setFiles, ToolbarButto title={isVisionModel(model) ? t('chat.input.upload') : t('chat.input.upload.document')} arrow> - + ) diff --git a/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx b/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx index 41b6ccc509..297ebc97f4 100644 --- a/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/GenerateImageButton.tsx @@ -1,7 +1,7 @@ -import { PictureOutlined } from '@ant-design/icons' import { isGenerateImageModel } from '@renderer/config/models' import { Assistant, Model } from '@renderer/types' import { Tooltip } from 'antd' +import { Image } from 'lucide-react' import { FC } from 'react' import { useTranslation } from 'react-i18next' @@ -27,7 +27,7 @@ const GenerateImageButton: FC = ({ model, ToolbarButton, assistant, onEna } arrow> - + ) diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 10200fbccf..7c96a6f02a 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -1,11 +1,6 @@ import { - ClearOutlined, CodeOutlined, FileSearchOutlined, - FormOutlined, - FullscreenExitOutlined, - FullscreenOutlined, - GlobalOutlined, HolderOutlined, PaperClipOutlined, PauseCircleOutlined, @@ -47,6 +42,7 @@ import TextArea, { TextAreaRef } from 'antd/es/input/TextArea' import dayjs from 'dayjs' import Logger from 'electron-log/renderer' import { debounce, isEmpty } from 'lodash' +import { Globe, Maximize, MessageSquareDiff, Minimize, PaintbrushVertical } from 'lucide-react' import React, { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' @@ -89,7 +85,8 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = pasteLongTextThreshold, showInputEstimatedTokens, autoTranslateWithSpace, - enableQuickPanelTriggers + enableQuickPanelTriggers, + enableBackspaceDeleteModel } = useSettings() const [expended, setExpend] = useState(false) const [estimateTokenCount, setEstimateTokenCount] = useState(0) @@ -363,7 +360,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } }, { - label: 'MCP Prompt', + label: `MCP ${t('settings.mcp.tabs.prompts')}`, description: '', icon: , isMenu: true, @@ -371,6 +368,15 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = mcpToolsButtonRef.current?.openPromptList() } }, + { + label: `MCP ${t('settings.mcp.tabs.resources')}`, + description: '', + icon: , + isMenu: true, + action: () => { + mcpToolsButtonRef.current?.openResourcesList() + } + }, { label: isVisionModel(model) ? t('chat.input.upload') : t('chat.input.upload.document'), description: '', @@ -476,21 +482,12 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = return event.preventDefault() } - if (event.key === 'Backspace' && text.trim() === '' && mentionModels.length > 0) { + if (enableBackspaceDeleteModel && event.key === 'Backspace' && text.trim() === '' && mentionModels.length > 0) { setMentionModels((prev) => prev.slice(0, -1)) return event.preventDefault() } - if (event.key === 'Backspace' && text.trim() === '' && selectedKnowledgeBases.length > 0) { - setSelectedKnowledgeBases((prev) => { - const newSelectedKnowledgeBases = prev.slice(0, -1) - updateAssistant({ ...assistant, knowledge_bases: newSelectedKnowledgeBases }) - return newSelectedKnowledgeBases - }) - return event.preventDefault() - } - - if (event.key === 'Backspace' && text.trim() === '' && files.length > 0) { + if (enableBackspaceDeleteModel && event.key === 'Backspace' && text.trim() === '' && files.length > 0) { setFiles((prev) => prev.slice(0, -1)) return event.preventDefault() } @@ -1069,7 +1066,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = - + = ({ assistant: _assistant, setActiveTopic, topic }) = /> - @@ -1123,12 +1121,12 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = /> - + - {isExpended ? : } + {isExpended ? : } diff --git a/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx b/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx index dbe87345ce..1377bff90b 100644 --- a/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/KnowledgeBaseButton.tsx @@ -4,6 +4,7 @@ import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPan import { useAppSelector } from '@renderer/store' import { KnowledgeBase } from '@renderer/types' import { Tooltip } from 'antd' +import { LibraryBig } from 'lucide-react' import { FC, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router' @@ -88,7 +89,7 @@ const KnowledgeBaseButton: FC = ({ ref, selectedBases, onSelect, disabled return ( - + ) diff --git a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx index 11914c6ad9..df947dd322 100644 --- a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx @@ -1,15 +1,17 @@ import { CodeOutlined, PlusOutlined } from '@ant-design/icons' import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel' import { useMCPServers } from '@renderer/hooks/useMCPServers' -import { MCPPrompt, MCPServer } from '@renderer/types' +import { MCPPrompt, MCPResource, MCPServer } from '@renderer/types' import { Form, Input, Modal, Tooltip } from 'antd' -import { FC, useCallback, useImperativeHandle, useMemo } from 'react' +import { SquareTerminal } from 'lucide-react' +import { FC, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router' export interface MCPToolsButtonRef { openQuickPanel: () => void openPromptList: () => void + openResourcesList: () => void } interface Props { @@ -167,6 +169,7 @@ const MCPToolsButton: FC = ({ const handlePromptSelect = useCallback( (prompt: MCPPrompt) => { + // Using a 10ms delay to ensure the modal or UI updates are fully rendered before executing the logic. setTimeout(async () => { const server = enabledMCPs.find((s) => s.id === prompt.serverId) if (server) { @@ -283,6 +286,112 @@ const MCPToolsButton: FC = ({ }) }, [promptList, quickPanel, t]) + const handleResourceSelect = useCallback( + (resource: MCPResource) => { + setTimeout(async () => { + const server = enabledMCPs.find((s) => s.id === resource.serverId) + if (server) { + try { + // Fetch the resource data + const response = await window.api.mcp.getResource({ + server, + uri: resource.uri + }) + console.log('Resource Data:', response) + + // Check if the response has the expected structure + if (response && response.contents && Array.isArray(response.contents)) { + // Process each resource in the contents array + for (const resourceData of response.contents) { + // Determine how to handle the resource based on its MIME type + if (resourceData.blob) { + // Handle binary data (images, etc.) + if (resourceData.mimeType?.startsWith('image/')) { + // Insert image as markdown + const imageMarkdown = `![${resourceData.name || 'Image'}](data:${resourceData.mimeType};base64,${resourceData.blob})` + insertPromptIntoTextArea(imageMarkdown) + } else { + // For other binary types, just mention it's available + const resourceInfo = `[${resourceData.name || resource.name} - ${resourceData.mimeType || t('settings.mcp.resources.blobInvisible')}]` + insertPromptIntoTextArea(resourceInfo) + } + } else if (resourceData.text) { + // Handle text data + insertPromptIntoTextArea(resourceData.text) + } else { + // Fallback for resources without content + const resourceInfo = `[${resourceData.name || resource.name} - ${resourceData.uri || resource.uri}]` + insertPromptIntoTextArea(resourceInfo) + } + } + } else { + // Handle legacy format or direct resource data + const resourceData = response + + // Determine how to handle the resource based on its MIME type + if (resourceData.blob) { + // Handle binary data (images, etc.) + if (resourceData.mimeType?.startsWith('image/')) { + // Insert image as markdown + const imageMarkdown = `![${resourceData.name || resource.name}](data:${resourceData.mimeType};base64,${resourceData.blob})` + insertPromptIntoTextArea(imageMarkdown) + } else { + // For other binary types, just mention it's available + const resourceInfo = `[${resourceData.name || resource.name} - ${resourceData.mimeType || t('settings.mcp.resources.blobInvisible')}]` + insertPromptIntoTextArea(resourceInfo) + } + } else if (resourceData.text) { + // Handle text data + insertPromptIntoTextArea(resourceData.text) + } else { + // Fallback for resources without content + const resourceInfo = `[${resourceData.name || resource.name} - ${resourceData.uri || resource.uri}]` + insertPromptIntoTextArea(resourceInfo) + } + } + } catch (error: Error | any) { + Modal.error({ + title: t('common.error'), + content: error.message || t('settings.mcp.resources.genericError') + }) + } + } + }, 10) + }, + [enabledMCPs, t, insertPromptIntoTextArea] + ) + const [resourcesList, setResourcesList] = useState([]) + + useEffect(() => { + const fetchResources = async () => { + const resources: MCPResource[] = [] + for (const server of enabledMCPs) { + const serverResources = await window.api.mcp.listResources(server) + resources.push(...serverResources) + } + setResourcesList( + resources.map((resource) => ({ + label: resource.name, + description: resource.description, + icon: , + action: () => handleResourceSelect(resource) + })) + ) + } + + fetchResources() + }, [handleResourceSelect, enabledMCPs]) + + const openResourcesList = useCallback(async () => { + const resources = resourcesList + quickPanel.open({ + title: t('settings.mcp.title'), + list: resources, + symbol: 'mcp-resource', + multiple: true + }) + }, [resourcesList, quickPanel, t]) + const handleOpenQuickPanel = useCallback(() => { if (quickPanel.isVisible && quickPanel.symbol === 'mcp') { quickPanel.close() @@ -293,7 +402,8 @@ const MCPToolsButton: FC = ({ useImperativeHandle(ref, () => ({ openQuickPanel, - openPromptList + openPromptList, + openResourcesList })) if (activedMcpServers.length === 0) { @@ -303,7 +413,7 @@ const MCPToolsButton: FC = ({ return ( - + ) diff --git a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx index f8bec57c7e..df4fafea1b 100644 --- a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx @@ -8,10 +8,13 @@ import { useProviders } from '@renderer/hooks/useProvider' import { getModelUniqId } from '@renderer/services/ModelService' import { Model } from '@renderer/types' import { Avatar, Tooltip } from 'antd' +import { useLiveQuery } from 'dexie-react-hooks' import { first, sortBy } from 'lodash' -import { FC, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react' +import { AtSign } from 'lucide-react' +import { FC, useCallback, useImperativeHandle, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router' +import styled from 'styled-components' export interface MentionModelsButtonRef { openQuickPanel: () => void @@ -26,47 +29,84 @@ interface Props { const MentionModelsButton: FC = ({ ref, mentionModels, onMentionModel, ToolbarButton }) => { const { providers } = useProviders() - const [pinnedModels, setPinnedModels] = useState([]) const { t } = useTranslation() const navigate = useNavigate() const quickPanel = useQuickPanel() + const pinnedModels = useLiveQuery( + async () => { + const setting = await db.settings.get('pinned:models') + return setting?.value || [] + }, + [], + [] + ) + const modelItems = useMemo(() => { - // Get all models from providers - const allModels = providers - .filter((p) => p.models && p.models.length > 0) - .flatMap((p) => + const items: QuickPanelListItem[] = [] + + if (pinnedModels.length > 0) { + const pinnedItems = providers.flatMap((p) => p.models - .filter((m) => !isEmbeddingModel(m)) - .filter((m) => !isRerankModel(m)) + .filter((m) => !isEmbeddingModel(m) && !isRerankModel(m)) + .filter((m) => pinnedModels.includes(getModelUniqId(m))) .map((m) => ({ - model: m, - provider: p, - isPinned: pinnedModels.includes(getModelUniqId(m)) + label: ( + <> + {p.isSystem ? t(`provider.${p.id}`) : p.name} + | {m.name} + + ), + description: , + icon: ( + + {first(m.name)} + + ), + action: () => onMentionModel(m), + isSelected: mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m)) })) ) - // Sort by pinned status and name - const newList: QuickPanelListItem[] = sortBy(allModels, ['isPinned', 'model.name']) - .reverse() - .map((item) => ({ - label: `${item.provider.isSystem ? t(`provider.${item.provider.id}`) : item.provider.name} | ${item.model.name}`, - description: , - icon: ( - - {first(item.model.name)} - - ), - action: () => onMentionModel(item.model), - isSelected: mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(item.model)) - })) - newList.push({ + if (pinnedItems.length > 0) { + items.push(...sortBy(pinnedItems, ['label'])) + } + } + + providers.forEach((p) => { + const providerModels = p.models + .filter((m) => !isEmbeddingModel(m) && !isRerankModel(m)) + .filter((m) => !pinnedModels.includes(getModelUniqId(m))) + .map((m) => ({ + label: ( + <> + {p.isSystem ? t(`provider.${p.id}`) : p.name} + | {m.name} + + ), + description: , + icon: ( + + {first(m.name)} + + ), + action: () => onMentionModel(m), + isSelected: mentionModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m)) + })) + + if (providerModels.length > 0) { + items.push(...sortBy(providerModels, ['label'])) + } + }) + + items.push({ label: t('settings.models.add.add_model') + '...', icon: , action: () => navigate('/settings/provider'), isSelected: false }) - return newList + + return items }, [providers, t, pinnedModels, mentionModels, onMentionModel, navigate]) const openQuickPanel = useCallback(() => { @@ -89,14 +129,6 @@ const MentionModelsButton: FC = ({ ref, mentionModels, onMentionModel, To } }, [openQuickPanel, quickPanel]) - useEffect(() => { - const loadPinnedModels = async () => { - const setting = await db.settings.get('pinned:models') - setPinnedModels(setting?.value || []) - } - loadPinnedModels() - }, []) - useImperativeHandle(ref, () => ({ openQuickPanel })) @@ -104,10 +136,13 @@ const MentionModelsButton: FC = ({ ref, mentionModels, onMentionModel, To return ( - + ) } export default MentionModelsButton +const ProviderName = styled.span` + font-weight: 500; +` diff --git a/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx b/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx index e0d6454fb4..0dcdbc2f0d 100644 --- a/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx @@ -1,6 +1,6 @@ -import { PicCenterOutlined } from '@ant-design/icons' import { useShortcut, useShortcutDisplay } from '@renderer/hooks/useShortcuts' import { Tooltip } from 'antd' +import { CircleFadingPlus } from 'lucide-react' import { FC } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -20,7 +20,7 @@ const NewContextButton: FC = ({ onNewContext, ToolbarButton }) => { - + diff --git a/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx b/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx index 1025c2aff5..7fd9a2eaa7 100644 --- a/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx @@ -4,6 +4,7 @@ import { QuickPanelListItem, QuickPanelOpenOptions } from '@renderer/components/ import QuickPhraseService from '@renderer/services/QuickPhraseService' import { QuickPhrase } from '@renderer/types' import { Tooltip } from 'antd' +import { Zap } from 'lucide-react' import { memo, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router' @@ -99,7 +100,7 @@ const QuickPhrasesButton = ({ ref, setInputValue, resizeTextArea, ToolbarButton return ( - + ) diff --git a/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx b/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx index b3d74ba75a..45e51ed91a 100644 --- a/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx +++ b/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx @@ -38,6 +38,8 @@ const CitationTooltip: React.FC = ({ children, citation }) placement="top" arrow={false} overlayInnerStyle={{ + backgroundColor: 'var(--color-background-mute)', + border: '1px solid var(--color-border)', padding: 0, borderRadius: '8px' }}> diff --git a/src/renderer/src/pages/home/Messages/MessageContent.tsx b/src/renderer/src/pages/home/Messages/MessageContent.tsx index 43678e51a3..32f14e6203 100644 --- a/src/renderer/src/pages/home/Messages/MessageContent.tsx +++ b/src/renderer/src/pages/home/Messages/MessageContent.tsx @@ -1,4 +1,4 @@ -import { SearchOutlined, SyncOutlined, TranslationOutlined } from '@ant-design/icons' +import { SyncOutlined, TranslationOutlined } from '@ant-design/icons' import TTSHighlightedText from '@renderer/components/TTSHighlightedText' import { isOpenAIWebSearch } from '@renderer/config/models' import { getModelUniqId } from '@renderer/services/ModelService' @@ -7,6 +7,7 @@ import { getBriefInfo } from '@renderer/utils' import { withMessageThought } from '@renderer/utils/formats' import { Divider, Flex } from 'antd' import { clone } from 'lodash' +import { Search } from 'lucide-react' import React, { Fragment, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import BarLoader from 'react-spinners/BarLoader' @@ -200,7 +201,7 @@ const MessageContent: React.FC = ({ message: _message, model }) => { if (message.status === 'searching') { return ( - + {t('message.searching')} diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index 67a0034687..8e702d531a 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -1,17 +1,4 @@ -import { - CheckOutlined, - DeleteOutlined, - EditOutlined, - ForkOutlined, - LikeFilled, - LikeOutlined, - MenuOutlined, - QuestionCircleOutlined, - SaveOutlined, - SyncOutlined, - TranslationOutlined -} from '@ant-design/icons' -import { UploadOutlined } from '@ant-design/icons' +import { CheckOutlined, EditOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons' import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup' import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup' import TextEditPopup from '@renderer/components/Popups/TextEditPopup' @@ -38,6 +25,20 @@ import { withMessageThought } from '@renderer/utils/formats' import { Button, Dropdown, Popconfirm, Tooltip } from 'antd' import dayjs from 'dayjs' import { clone } from 'lodash' +import { + AtSign, + Copy, + FilePenLine, + Languages, + Menu, + RefreshCw, + Save, + Share, + Split, + ThumbsDown, + ThumbsUp, + Trash +} from 'lucide-react' import { FC, memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' @@ -226,18 +227,28 @@ const MessageMenubar: FC = (props) => { { label: t('chat.save'), key: 'save', - icon: , + icon: , onClick: () => { const fileName = dayjs(message.createdAt).format('YYYYMMDDHHmm') + '.md' window.api.file.save(fileName, message.content) } }, - { label: t('common.edit'), key: 'edit', icon: , onClick: onEdit }, - { label: t('chat.message.new.branch'), key: 'new-branch', icon: , onClick: onNewBranch }, + { + label: t('common.edit'), + key: 'edit', + icon: , + onClick: onEdit + }, + { + label: t('chat.message.new.branch'), + key: 'new-branch', + icon: , + onClick: onNewBranch + }, { label: t('chat.topics.export.title'), key: 'export', - icon: , + icon: , children: [ exportMenuOptions.image && { label: t('chat.topics.copy.image'), @@ -374,7 +385,7 @@ const MessageMenubar: FC = (props) => { )} - {!copied && } + {!copied && } {copied && } @@ -391,7 +402,7 @@ const MessageMenubar: FC = (props) => { open={showRegenerateTooltip} onOpenChange={setShowRegenerateTooltip}> - + @@ -399,7 +410,7 @@ const MessageMenubar: FC = (props) => { {isAssistantMessage && ( - + )} @@ -426,7 +437,7 @@ const MessageMenubar: FC = (props) => { arrow> e.stopPropagation()}> - + @@ -434,7 +445,7 @@ const MessageMenubar: FC = (props) => { {isAssistantMessage && isGrouped && ( - {message.useful ? : } + {message.useful ? : } )} @@ -450,7 +461,7 @@ const MessageMenubar: FC = (props) => { mouseEnterDelay={1} open={showDeleteTooltip} onOpenChange={setShowDeleteTooltip}> - + @@ -461,7 +472,7 @@ const MessageMenubar: FC = (props) => { placement="topRight" arrow> e.stopPropagation()}> - + )} diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index 074f38e10c..b32eef90b5 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -2,7 +2,7 @@ import Scrollbar from '@renderer/components/Scrollbar' import { LOAD_MORE_COUNT } from '@renderer/config/constant' import db from '@renderer/databases' import { useAssistant } from '@renderer/hooks/useAssistant' -import { useMessageOperations, useTopicLoading, useTopicMessages } from '@renderer/hooks/useMessageOperations' +import { useMessageOperations, useTopicMessages } from '@renderer/hooks/useMessageOperations' import { useSettings } from '@renderer/hooks/useSettings' import { useShortcut } from '@renderer/hooks/useShortcuts' import { autoRenameTopic, getTopic } from '@renderer/hooks/useTopic' @@ -29,7 +29,6 @@ import ChatNavigation from './ChatNavigation' import MessageAnchorLine from './MessageAnchorLine' import MessageGroup from './MessageGroup' import NarrowLayout from './NarrowLayout' -import NewTopicButton from './NewTopicButton' import Prompt from './Prompt' import TTSStopButton from './TTSStopButton' @@ -51,7 +50,6 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) const [isProcessingContext, setIsProcessingContext] = useState(false) const messages = useTopicMessages(topic) const { displayCount, updateMessages, clearTopicMessages, deleteMessage } = useMessageOperations(topic) - const loading = useTopicLoading(topic) const messagesRef = useRef(messages) useEffect(() => { @@ -226,7 +224,6 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) ref={containerRef} $right={topicPosition === 'left'}> - {messages.length >= 2 && !loading && } = ({ activeAssistant }) => { - + - EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}> - + EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)} style={{ marginRight: 5 }}> + @@ -78,7 +78,7 @@ const HeaderNavbar: FC = ({ activeAssistant }) => { toggleShowAssistants()} style={{ marginRight: 8, marginLeft: isMac ? 4 : -12 }}> - + )} @@ -88,7 +88,7 @@ const HeaderNavbar: FC = ({ activeAssistant }) => { SearchPopup.show()}> - + @@ -100,14 +100,14 @@ const HeaderNavbar: FC = ({ activeAssistant }) => { - + )} {topicPosition === 'right' && ( - + {showTopics ? : } )} diff --git a/src/renderer/src/pages/home/Tabs/AssistantItem.tsx b/src/renderer/src/pages/home/Tabs/AssistantItem.tsx index f88ca3dc10..1af70eccf4 100644 --- a/src/renderer/src/pages/home/Tabs/AssistantItem.tsx +++ b/src/renderer/src/pages/home/Tabs/AssistantItem.tsx @@ -3,6 +3,7 @@ import { EditOutlined, MinusCircleOutlined, SaveOutlined, + SmileOutlined, SortAscendingOutlined, SortDescendingOutlined } from '@ant-design/icons' @@ -39,7 +40,7 @@ interface AssistantItemProps { const AssistantItem: FC = ({ assistant, isActive, onSwitch, onDelete, addAgent, addAssistant }) => { const { t } = useTranslation() const { removeAllTopics } = useAssistant(assistant.id) // 使用当前助手的ID - const { clickAssistantToShowTopic, topicPosition, showAssistantIcon } = useSettings() + const { clickAssistantToShowTopic, topicPosition, assistantIconType, setAssistantIconType } = useSettings() const defaultModel = getDefaultModel() const { assistants, updateAssistants } = useAssistants() @@ -119,6 +120,28 @@ const AssistantItem: FC = ({ assistant, isActive, onSwitch, }) } }, + { + label: t('assistants.icon.type'), + key: 'icon-type', + icon: , + children: [ + { + label: t('settings.assistant.icon.type.model'), + key: 'model', + onClick: () => setAssistantIconType('model') + }, + { + label: t('settings.assistant.icon.type.emoji'), + key: 'emoji', + onClick: () => setAssistantIconType('emoji') + }, + { + label: t('settings.assistant.icon.type.none'), + key: 'none', + onClick: () => setAssistantIconType('none') + } + ] + }, { type: 'divider' }, { label: t('common.sort.pinyin.asc'), @@ -174,14 +197,22 @@ const AssistantItem: FC = ({ assistant, isActive, onSwitch, - {showAssistantIcon && ( + {assistantIconType === 'model' ? ( + ) : ( + assistantIconType === 'emoji' && ( + + {assistant.emoji || assistantName.slice(0, 1)} + + ) )} - {showAssistantIcon ? assistantName : fullAssistantName} + {assistantName} {isActive && ( EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)}> @@ -197,7 +228,8 @@ const Container = styled.div` display: flex; flex-direction: row; justify-content: space-between; - padding: 7px 10px; + padding: 0 10px; + height: 37px; position: relative; font-family: Ubuntu; border-radius: var(--list-item-border-radius); @@ -225,10 +257,40 @@ const AssistantNameRow = styled.div` display: flex; flex-direction: row; align-items: center; - gap: 5px; + gap: 8px; ` -const AssistantName = styled.div`` +const AssistantEmoji = styled.div<{ $emoji: string }>` + width: 26px; + height: 26px; + border-radius: 13px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 15px; + position: relative; + overflow: hidden; + margin-right: 3px; + &:before { + width: 100%; + height: 100%; + content: ${({ $emoji }) => `'${$emoji || ' '}'`}; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 200%; + transform: scale(1.5); + filter: blur(5px); + opacity: 0.4; + } +` + +const AssistantName = styled.div` + font-size: 13px; +` const MenuButton = styled.div` display: flex; diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index 28269161f3..a2ba086880 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -1,4 +1,4 @@ -import { CheckOutlined, QuestionCircleOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons' +import { CheckOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' import Scrollbar from '@renderer/components/Scrollbar' import { @@ -27,6 +27,7 @@ import { setCodeShowLineNumbers, setCodeStyle, setCodeWrappable, + setEnableBackspaceDeleteModel, setEnableQuickPanelTriggers, setFontSize, setMathEngine, @@ -44,6 +45,7 @@ import { import { Assistant, AssistantSettings, CodeStyleVarious, ThemeMode, TranslateLanguageVarious } from '@renderer/types' import { modalConfirm } from '@renderer/utils' import { Button, Col, InputNumber, Row, Segmented, Select, Slider, Switch, Tooltip } from 'antd' +import { CircleHelp, RotateCcw, Settings2 } from 'lucide-react' import { FC, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -90,7 +92,8 @@ const SettingsTab: FC = (props) => { multiModelMessageStyle, thoughtAutoCollapse, messageNavigation, - enableQuickPanelTriggers + enableQuickPanelTriggers, + enableBackspaceDeleteModel } = useSettings() const onUpdateAssistantSettings = (settings: Partial) => { @@ -149,7 +152,9 @@ const SettingsTab: FC = (props) => { setMaxTokens(assistant?.settings?.maxTokens ?? DEFAULT_MAX_TOKENS) setStreamOutput(assistant?.settings?.streamOutput ?? true) setReasoningEffort(assistant?.settings?.reasoning_effort) + }, [assistant]) + useEffect(() => { // 当是Grok模型时,处理reasoning_effort的设置 // For Grok models, only 'low' and 'high' reasoning efforts are supported. // This ensures compatibility with the model's capabilities and avoids unsupported configurations. @@ -163,7 +168,7 @@ const SettingsTab: FC = (props) => { onReasoningEffortChange('high') } } - }, [assistant, onReasoningEffortChange]) + }, [assistant?.model, assistant?.settings?.reasoning_effort, onReasoningEffortChange]) const formatSliderTooltip = (value?: number) => { if (value === undefined) return '' @@ -177,13 +182,13 @@ const SettingsTab: FC = (props) => { {t('assistants.settings.title')}{' '} - + @@ -286,7 +277,7 @@ const KnowledgeContent: FC = ({ selectedBase }) => { size="small" shape="circle" onClick={() => setExpandAll(!expandAll)} - icon={expandAll ? : } + icon={expandAll ? : } disabled={disabled} /> @@ -306,7 +297,7 @@ const KnowledgeContent: FC = ({ selectedBase }) => { extra={ @@ -181,7 +189,7 @@ const AboutSettings: FC = () => { - + {t('settings.about.website.title')} @@ -189,7 +197,7 @@ const AboutSettings: FC = () => { - + {t('settings.about.feedback.title')} @@ -207,7 +215,8 @@ const AboutSettings: FC = () => { - {t('settings.about.contact.title')} + + {t('settings.about.contact.title')} diff --git a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx index 2e29e298cc..d504cbe95d 100644 --- a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx +++ b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx @@ -4,7 +4,9 @@ import { useTheme } from '@renderer/context/ThemeProvider' import { useSettings } from '@renderer/hooks/useSettings' import { useAppDispatch } from '@renderer/store' import { + AssistantIconType, DEFAULT_SIDEBAR_ICONS, + setAssistantIconType, setClickAssistantToShowTopic, setCustomCss, setShowTopicTime, @@ -31,8 +33,7 @@ const DisplaySettings: FC = () => { showTopicTime, customCss, sidebarIcons, - showAssistantIcon, - setShowAssistantIcon + assistantIconType } = useSettings() const { theme: themeMode } = useTheme() const { t } = useTranslation() @@ -87,6 +88,15 @@ const DisplaySettings: FC = () => { [t] ) + const assistantIconTypeOptions = useMemo( + () => [ + { value: 'model', label: t('settings.assistant.icon.type.model') }, + { value: 'emoji', label: t('settings.assistant.icon.type.emoji') }, + { value: 'none', label: t('settings.assistant.icon.type.none') } + ], + [t] + ) + return ( @@ -143,8 +153,13 @@ const DisplaySettings: FC = () => { {t('settings.display.assistant.title')} - {t('settings.assistant.show.icon')} - setShowAssistantIcon(checked)} /> + {t('settings.assistant.icon.type')} + dispatch(setAssistantIconType(value as AssistantIconType))} + options={assistantIconTypeOptions} + /> diff --git a/src/renderer/src/pages/settings/DisplaySettings/SidebarIconsManager.tsx b/src/renderer/src/pages/settings/DisplaySettings/SidebarIconsManager.tsx index db314e30cd..2263651d60 100644 --- a/src/renderer/src/pages/settings/DisplaySettings/SidebarIconsManager.tsx +++ b/src/renderer/src/pages/settings/DisplaySettings/SidebarIconsManager.tsx @@ -1,5 +1,4 @@ import { CloseOutlined } from '@ant-design/icons' -import { FileSearchOutlined, FolderOutlined, PictureOutlined, TranslationOutlined } from '@ant-design/icons' import { DragDropContext, Draggable, @@ -11,6 +10,7 @@ import { import { useAppDispatch } from '@renderer/store' import { setSidebarIcons } from '@renderer/store/settings' import { message } from 'antd' +import { Folder, Languages, LayoutGrid, LibraryBig, MessageSquareQuote, Palette, Sparkle } from 'lucide-react' import { FC, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -109,13 +109,13 @@ const SidebarIconsManager: FC = ({ // 使用useMemo缓存图标映射 const iconMap = useMemo( () => ({ - assistants: , - agents: , - paintings: , - translate: , - minapp: , - knowledge: , - files: + assistants: , + agents: , + paintings: , + translate: , + minapp: , + knowledge: , + files: }), [] ) diff --git a/src/renderer/src/pages/settings/MCPSettings/McpResource.tsx b/src/renderer/src/pages/settings/MCPSettings/McpResource.tsx new file mode 100644 index 0000000000..80660c98a4 --- /dev/null +++ b/src/renderer/src/pages/settings/MCPSettings/McpResource.tsx @@ -0,0 +1,108 @@ +import { MCPResource } from '@renderer/types' +import { Collapse, Descriptions, Empty, Flex, Tag, Typography } from 'antd' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface MCPResourcesSectionProps { + resources: MCPResource[] +} + +const MCPResourcesSection = ({ resources }: MCPResourcesSectionProps) => { + const { t } = useTranslation() + + // Format file size to human-readable format + const formatFileSize = (size?: number) => { + if (size === undefined) return 'Unknown size' + + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let formattedSize = size + let unitIndex = 0 + + while (formattedSize >= 1024 && unitIndex < units.length - 1) { + formattedSize /= 1024 + unitIndex++ + } + + return `${formattedSize.toFixed(2)} ${units[unitIndex]}` + } + + // Render resource properties + const renderResourceProperties = (resource: MCPResource) => { + return ( + + {resource.mimeType && ( + + {resource.mimeType} + + )} + {resource.size !== undefined && ( + + {formatFileSize(resource.size)} + + )} + {resource.text && ( + {resource.text} + )} + {resource.blob && ( + + {t('settings.mcp.resources.blobInvisible') || 'Binary data is not visible here.'} + + )} + + ) + } + + return ( +
+ {t('settings.mcp.resources.availableResources') || 'Available Resources'} + {resources.length > 0 ? ( + + {resources.map((resource) => ( + + + {`${resource.name} (${resource.uri})`} + + {resource.description && ( + + {resource.description.length > 100 + ? `${resource.description.substring(0, 100)}...` + : resource.description} + + )} + + }> + {renderResourceProperties(resource)} + + ))} + + ) : ( + + )} +
+ ) +} + +const Section = styled.div` + margin-top: 8px; + padding-top: 8px; +` + +const SectionTitle = styled.h3` + font-size: 14px; + font-weight: 500; + margin-bottom: 8px; + color: var(--color-text-secondary); +` + +const SelectableContent = styled.div` + user-select: text; + padding: 0 12px; +` + +export default MCPResourcesSection diff --git a/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx b/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx index 499e7484aa..45a06953d6 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx @@ -1,6 +1,6 @@ import { DeleteOutlined, SaveOutlined } from '@ant-design/icons' import { useMCPServers } from '@renderer/hooks/useMCPServers' -import { MCPPrompt, MCPServer, MCPTool } from '@renderer/types' +import { MCPPrompt, MCPResource, MCPServer, MCPTool } from '@renderer/types' import { Button, Flex, Form, Input, Radio, Switch, Tabs } from 'antd' import TextArea from 'antd/es/input/TextArea' import React, { useCallback, useEffect, useState } from 'react' @@ -10,6 +10,7 @@ import styled from 'styled-components' import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '..' import MCPPromptsSection from './McpPrompt' +import MCPResourcesSection from './McpResource' import MCPToolsSection from './McpTool' interface Props { @@ -42,7 +43,7 @@ const PipRegistry: Registry[] = [ { name: '腾讯云', url: 'https://mirrors.cloud.tencent.com/pypi/simple/' } ] -type TabKey = 'settings' | 'tools' | 'prompts' +type TabKey = 'settings' | 'tools' | 'prompts' | 'resources' const McpSettings: React.FC = ({ server }) => { const { t } = useTranslation() @@ -56,6 +57,7 @@ const McpSettings: React.FC = ({ server }) => { const [tools, setTools] = useState([]) const [prompts, setPrompts] = useState([]) + const [resources, setResources] = useState([]) const [isShowRegistry, setIsShowRegistry] = useState(false) const [registry, setRegistry] = useState() @@ -146,10 +148,29 @@ const McpSettings: React.FC = ({ server }) => { } } + const fetchResources = async () => { + if (server.isActive) { + try { + setLoadingServer(server.id) + const localResources = await window.api.mcp.listResources(server) + setResources(localResources) + } catch (error) { + window.message.error({ + content: t('settings.mcp.resources.loadError') + ' ' + formatError(error), + key: 'mcp-resources-error' + }) + setResources([]) + } finally { + setLoadingServer(null) + } + } + } + useEffect(() => { if (server.isActive) { fetchTools() fetchPrompts() + fetchResources() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [server.id, server.isActive]) @@ -294,6 +315,9 @@ const McpSettings: React.FC = ({ server }) => { const localPrompts = await window.api.mcp.listPrompts(server) setPrompts(localPrompts) + + const localResources = await window.api.mcp.listResources(server) + setResources(localResources) } else { await window.api.mcp.stopServer(server) } @@ -466,6 +490,11 @@ const McpSettings: React.FC = ({ server }) => { key: 'prompts', label: t('settings.mcp.tabs.prompts'), children: + }, + { + key: 'resources', + label: t('settings.mcp.tabs.resources'), + children: } ) } diff --git a/src/renderer/src/pages/settings/MCPSettings/McpSettingsNavbar.tsx b/src/renderer/src/pages/settings/MCPSettings/McpSettingsNavbar.tsx index 44c8f70c67..dbd0e8ad69 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpSettingsNavbar.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpSettingsNavbar.tsx @@ -1,8 +1,9 @@ -import { EditOutlined, ExportOutlined, SearchOutlined } from '@ant-design/icons' +import { EditOutlined, ExportOutlined } from '@ant-design/icons' import { NavbarRight } from '@renderer/components/app/Navbar' import { HStack } from '@renderer/components/Layout' import { isWindows } from '@renderer/config/constant' import { Button } from 'antd' +import { Search } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router' @@ -21,7 +22,7 @@ export const McpSettingsNavbar = () => { size="small" type="text" onClick={() => navigate('/settings/mcp/npx-search')} - icon={} + icon={} className="nodrag" style={{ fontSize: 13, height: 28, borderRadius: 20 }}> {t('settings.mcp.searchNpx')} diff --git a/src/renderer/src/pages/settings/MCPSettings/NpxSearch.tsx b/src/renderer/src/pages/settings/MCPSettings/NpxSearch.tsx index 160a13ae9a..7154729eaa 100644 --- a/src/renderer/src/pages/settings/MCPSettings/NpxSearch.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/NpxSearch.tsx @@ -117,8 +117,8 @@ const NpxSearch: FC<{ return (
- -
+ +
npm
@@ -231,6 +231,7 @@ const NpxSearch: FC<{ const Container = styled.div` display: flex; + flex: 1; flex-direction: column; gap: 8px; ` @@ -238,11 +239,13 @@ const Container = styled.div` const ResultList = styled.div` flex: 1; display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(2, 1fr); gap: 16px; width: 100%; padding-right: 4px; overflow-y: auto; + max-width: 1200px; + margin: 0 auto; ` export default NpxSearch diff --git a/src/renderer/src/pages/settings/MCPSettings/index.tsx b/src/renderer/src/pages/settings/MCPSettings/index.tsx index 5f0e3d2d43..e94d583adb 100644 --- a/src/renderer/src/pages/settings/MCPSettings/index.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/index.tsx @@ -244,6 +244,7 @@ const BackButtonContainer = styled.div` ` const MainContainer = styled.div` + display: flex; flex: 1; width: 100%; ` diff --git a/src/renderer/src/pages/settings/ModelSettings/ModelSettings.tsx b/src/renderer/src/pages/settings/ModelSettings/ModelSettings.tsx index 79d1fc3999..bc79777e3a 100644 --- a/src/renderer/src/pages/settings/ModelSettings/ModelSettings.tsx +++ b/src/renderer/src/pages/settings/ModelSettings/ModelSettings.tsx @@ -1,4 +1,4 @@ -import { EditOutlined, MessageOutlined, RedoOutlined, SettingOutlined, TranslationOutlined } from '@ant-design/icons' +import { RedoOutlined } from '@ant-design/icons' import { HStack } from '@renderer/components/Layout' import PromptPopup from '@renderer/components/Popups/PromptPopup' import { isEmbeddingModel } from '@renderer/config/models' @@ -13,6 +13,7 @@ import { setTranslateModelPrompt } from '@renderer/store/settings' import { Model } from '@renderer/types' import { Button, Select, Tooltip } from 'antd' import { find, sortBy } from 'lodash' +import { FolderPen, Languages, MessageSquareMore, Settings2 } from 'lucide-react' import { FC, useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -82,10 +83,10 @@ const ModelSettings: FC = () => { -
- + + {t('settings.models.default_assistant_model')} -
+
{ showSearch placeholder={t('settings.models.empty')} /> - @@ -304,11 +297,11 @@ const TranslatePage: FC = () => { - diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 7da3f88904..2a441d291e 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -42,7 +42,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 95, + version: 96, blacklist: ['runtime', 'messages'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 0f11318778..f8038bcb7c 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1213,6 +1213,25 @@ const migrateConfig = { } catch (error) { return state } + }, + '96': (state: RootState) => { + try { + // @ts-ignore eslint-disable-next-line + state.settings.assistantIconType = state.settings?.showAssistantIcon ? 'model' : 'emoji' + // @ts-ignore eslint-disable-next-line + delete state.settings.showAssistantIcon + return state + } catch (error) { + return state + } + }, + '97': (state: RootState) => { + try { + state.settings.enableBackspaceDeleteModel = true + return state + } catch (error) { + return state + } } } diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index f8942fae36..311915bc2b 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -21,6 +21,8 @@ export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [ export interface NutstoreSyncRuntime extends WebDAVSyncState {} +export type AssistantIconType = 'model' | 'emoji' | 'none' + export interface SettingsState { showAssistants: boolean showTopics: boolean @@ -42,7 +44,7 @@ export interface SettingsState { fontSize: number topicPosition: 'left' | 'right' showTopicTime: boolean - showAssistantIcon: boolean + assistantIconType: AssistantIconType pasteLongTextAsFile: boolean pasteLongTextThreshold: number clickAssistantToShowTopic: boolean @@ -158,6 +160,7 @@ export interface SettingsState { skipNextAutoTTS: boolean // 是否跳过下一次自动TTS // Quick Panel Triggers enableQuickPanelTriggers: boolean + enableBackspaceDeleteModel: boolean // Export Menu Options exportMenuOptions: { image: boolean @@ -195,7 +198,7 @@ export const initialState: SettingsState = { fontSize: 14, topicPosition: 'left', showTopicTime: false, - showAssistantIcon: false, + assistantIconType: 'emoji', pasteLongTextAsFile: false, pasteLongTextThreshold: 1500, clickAssistantToShowTopic: true, @@ -302,8 +305,8 @@ export const initialState: SettingsState = { isVoiceCallActive: false, // 语音通话窗口是否激活 lastPlayedMessageId: null, // 最后一次播放的消息ID skipNextAutoTTS: false, // 是否跳过下一次自动TTS - // Quick Panel Triggers - enableQuickPanelTriggers: false, + enableQuickPanelTriggers: false, // Quick Panel Triggers + enableBackspaceDeleteModel: true, // Export Menu Options exportMenuOptions: { image: true, @@ -389,8 +392,8 @@ const settingsSlice = createSlice({ setShowTopicTime: (state, action: PayloadAction) => { state.showTopicTime = action.payload }, - setShowAssistantIcon: (state, action: PayloadAction) => { - state.showAssistantIcon = action.payload + setAssistantIconType: (state, action: PayloadAction) => { + state.assistantIconType = action.payload }, setPasteLongTextAsFile: (state, action: PayloadAction) => { state.pasteLongTextAsFile = action.payload @@ -759,6 +762,9 @@ const settingsSlice = createSlice({ }, setExportMenuOptions: (state, action: PayloadAction) => { state.exportMenuOptions = action.payload + }, + setEnableBackspaceDeleteModel: (state, action: PayloadAction) => { + state.enableBackspaceDeleteModel = action.payload } } }) @@ -786,7 +792,7 @@ export const { setWindowStyle, setTopicPosition, setShowTopicTime, - setShowAssistantIcon, + setAssistantIconType, setPasteLongTextAsFile, setAutoCheckUpdate, setRenderInputMessageAsMarkdown, @@ -883,7 +889,8 @@ export const { setVoiceCallPrompt, setIsVoiceCallActive, setLastPlayedMessageId, - setSkipNextAutoTTS + setSkipNextAutoTTS, + setEnableBackspaceDeleteModel } = settingsSlice.actions export default settingsSlice.reducer diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index ea40daab8c..dc297c617f 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -45,7 +45,9 @@ export type AssistantSettings = { reasoning_effort?: 'low' | 'medium' | 'high' } -export type Agent = Omit +export type Agent = Omit & { + group?: string[] +} export type Message = { id: string @@ -237,6 +239,7 @@ export type AppInfo = { resourcesPath: string filesPath: string logsPath: string + arch: string } export interface Shortcut { @@ -441,6 +444,22 @@ export interface MCPToolResponse { response?: any } +export interface MCPResource { + serverId: string + serverName: string + uri: string + name: string + description?: string + mimeType?: string + size?: number + text?: string + blob?: string +} + +export interface GetResourceResponse { + contents: MCPResource[] +} + export interface QuickPhrase { id: string title: string diff --git a/yarn.lock b/yarn.lock index a7919c5d31..cceefb5303 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3982,6 +3982,7 @@ __metadata: lint-staged: "npm:^15.5.0" lodash: "npm:^4.17.21" lru-cache: "npm:^11.1.0" + lucide-react: "npm:^0.487.0" markdown-it: "npm:^14.1.0" mime: "npm:^4.0.4" node-edge-tts: "npm:^1.2.8" @@ -10550,6 +10551,15 @@ __metadata: languageName: node linkType: hard +"lucide-react@npm:^0.487.0": + version: 0.487.0 + resolution: "lucide-react@npm:0.487.0" + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/7177778c584b8e9545957bef28e95841c4be1b3bf473f9e2e64454c3e183d7ed0bc977c9f7b5446088023c7000151b7a3b27398d4f70025bf343782192f653ca + languageName: node + linkType: hard + "magic-string@npm:^0.30.10": version: 0.30.17 resolution: "magic-string@npm:0.30.17"