diff --git a/LICENSE b/LICENSE index c962e5fea2..c2409e6076 100644 --- a/LICENSE +++ b/LICENSE @@ -1,13 +1,13 @@ **许可协议** -本软件采用 Apache License 2.0 许可。除 Apache License 2.0 规定的条款外,您在使用 Cherry Studio 时还应遵守以下附加条款: +采用 Apache License 2.0 修改版许可,并附加以下条件: **一. 商用许可** 在以下任何一种情况下,您需要联系我们并获得明确的书面商业授权后,方可继续使用 Cherry Studio 材料: 1. **修改与衍生**: 您对 Cherry Studio 材料进行修改或基于其进行衍生开发(包括但不限于修改应用名称、Logo、代码、功能、界面,数据等)。 -2. **企业服务**: 在您的企业内部,或为企业客户提供基于 CherryStudio 的服务,且该服务支持 10 人及以上累计用户使用。 +2. **企业服务**: 在您的企业内部,或为企业客户提供基于 Cherry Studio 的服务,且该服务支持 10 人及以上累计用户使用。 3. **硬件捆绑销售**: 您将 Cherry Studio 预装或集成到硬件设备或产品中进行捆绑销售。 4. **政府或教育机构大规模采购**: 您的使用场景属于政府或教育机构的大规模采购项目,特别是涉及安全、数据隐私等敏感需求时。 5. **面向公众的公有云服务**:基于 Cherry Studio,提供面向公众的公有云服务。 @@ -33,7 +33,7 @@ **License Agreement** -This software is licensed under the Apache License 2.0. In addition to the terms stipulated by the Apache License 2.0, you must comply with the following supplementary terms when using Cherry Studio: +This software is licensed under a modified version of the Apache License 2.0, with the following additional conditions。 **I. Commercial Licensing** @@ -59,4 +59,4 @@ As a contributor to Cherry Studio, you must agree to the following terms: If you have any questions or need to apply for commercial authorization, please contact the Cherry Studio development team. -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. \ No newline at end of file +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. diff --git a/package.json b/package.json index 6d591845eb..d6bb8a9493 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "@types/react-infinite-scroll-component": "^5.0.0", "@xyflow/react": "^12.4.4", "adm-zip": "^0.5.16", + "async-mutex": "^0.5.0", "color": "^5.0.0", "diff": "^7.0.0", "docx": "^9.0.2", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 92f14549f5..31268b6c39 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -50,6 +50,8 @@ export enum IpcChannel { Mcp_StopServer = 'mcp:stop-server', Mcp_ListTools = 'mcp:list-tools', Mcp_CallTool = 'mcp:call-tool', + Mcp_ListPrompts = 'mcp:list-prompts', + Mcp_GetPrompt = 'mcp:get-prompt', Mcp_GetInstallInfo = 'mcp:get-install-info', Mcp_ServersChanged = 'mcp:servers-changed', Mcp_ServersUpdated = 'mcp:servers-updated', diff --git a/resources/cherry-studio/license.html b/resources/cherry-studio/license.html index 38df70a9f7..74003d87fa 100644 --- a/resources/cherry-studio/license.html +++ b/resources/cherry-studio/license.html @@ -1,111 +1,109 @@ - + - - - - CherryStudio 许可协议-ZH/EN - - - -
-

Cherry Studio 许可协议

-
-

许可协议

-

- 本软件采用 Apache License 2.0 许可。除 Apache License 2.0 规定的条款外,您在使用 Cherry - Studio 时还应遵守以下附加条款: -

-

一. 商用许可

-
    -
  1. 免费商用:用户在不修改代码的情况下,可以免费用于商业目的。
  2. -
  3. - 商业授权:如果您满足以下任意条件之一,需取得商业授权: -
      -
    1. 对本软件进行二次修改、开发(包括但不限于修改应用名称、logo、代码以及功能)。
    2. -
    3. 为企业客户提供多租户服务,且该服务支持 10 人或以上的使用。
    4. -
    5. 预装或集成到硬件设备或产品中进行捆绑销售。
    6. -
    7. 政府或教育机构的大规模采购项目,特别是涉及安全、数据隐私等敏感需求时。
    8. -
    -
  4. + + + + 许可协议 | License Agreement + + + + +
    + +
    +

    许可协议

    + +

    采用 Apache License 2.0 修改版许可,并附加以下条件:

    + +
    +

    一. 商用许可

    +

    在以下任何一种情况下,您需要联系我们并获得明确的书面商业授权后,方可继续使用 Cherry Studio 材料:

    +
      +
    1. 修改与衍生: 您对 Cherry Studio 材料进行修改或基于其进行衍生开发(包括但不限于修改应用名称、Logo、代码、功能、界面,数据等)。
    2. +
    3. 企业服务: 在您的企业内部,或为企业客户提供基于 Cherry Studio 的服务,且该服务支持 10 人及以上累计用户使用。
    4. +
    5. 硬件捆绑销售: 您将 Cherry Studio 预装或集成到硬件设备或产品中进行捆绑销售。
    6. +
    7. 政府或教育机构大规模采购: 您的使用场景属于政府或教育机构的大规模采购项目,特别是涉及安全、数据隐私等敏感需求时。
    8. +
    9. 面向公众的公有云服务:基于 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 -

    -
    -

    Cherry Studio License

    -
    -

    License Agreement

    -

    - This software is licensed under the Apache License 2.0. In addition to the terms of the - Apache License 2.0, the following additional terms apply to the use of Cherry Studio: -

    -

    I. Commercial Use License

    -
      -
    1. - Free Commercial Use: Users can use the software for commercial purposes without modifying - the code. -
    2. -
    3. - Commercial License Required: A commercial license is required if any of the following - conditions are met: -
        -
      1. - You modify, develop, or alter the software, including but not limited to changes to the application - name, logo, code, or functionality. -
      2. -
      3. You provide multi-tenant services to enterprise customers with 10 or more users.
      4. -
      5. - You pre-install or integrate the software into hardware devices or products and bundle it for sale. -
      6. -
      7. - You are engaging in large-scale procurement for government or educational institutions, especially - involving security, data privacy, or other sensitive requirements. -
      8. -
      -
    4. -
    -

    II. Contributor Agreement

    -
      -
    1. - License Adjustment: The producer reserves the right to adjust the open-source license as - needed, making it stricter or more lenient. -
    2. -
    3. - Commercial Use: Any code you contribute may be used for commercial purposes, including but - not limited to cloud business operations. -
    4. -
    -

    III. Other Terms

    -
      -
    1. The interpretation of these terms is subject to the discretion of Cherry Studio developers.
    2. -
    3. These terms may be updated, and users will be notified through the software when changes occur.
    4. -
    -

    - For any questions or to request a commercial license, please contact the Cherry Studio development team. -

    -

    - Apart from the specific conditions mentioned above, all other rights and restrictions follow the Apache - License 2.0. Detailed information about the Apache License 2.0 can be found at - http://www.apache.org/licenses/LICENSE-2.0 -

    -
    +
    - - + +
    + + +
    +

    License Agreement

    + +

    This software is licensed under a modified version of the Apache License 2.0, with + the following additional conditions.

    + +
    +

    I. Commercial Licensing

    +

    You must contact us and obtain explicit written commercial authorization to + continue using Cherry Studio materials under any of the following circumstances:

    +
      +
    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. +
    3. 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.
    4. +
    5. Hardware Bundling and Sales: You pre-install or integrate Cherry Studio into hardware + devices or products for bundled sale.
    6. +
    7. 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.
    8. +
    9. Public Cloud Services: You provide public cloud-based product services utilizing Cherry + Studio.
    10. +
    +
    + +
    +

    II. Contributor Agreement

    +

    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. +
    3. Commercial Usage: Your contributed code may be used commercially, including but not + limited to cloud business operations.
    4. +
    +
    + +
    +

    III. Other Terms

    +
      +
    1. Cherry Studio developers reserve the right of final interpretation of these agreement terms.
    2. +
    3. This agreement may be updated according to practical circumstances, and users will be notified of updates + through this software.
    4. +
    +
    + +

    + 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. +

    +
    +
+ + + \ No newline at end of file diff --git a/src/main/ipc.ts b/src/main/ipc.ts index f5ada1f94e..735c6b0046 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -264,6 +264,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.Mcp_StopServer, mcpService.stopServer) ipcMain.handle(IpcChannel.Mcp_ListTools, mcpService.listTools) 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_GetInstallInfo, mcpService.getInstallInfo) ipcMain.handle(IpcChannel.App_IsBinaryExist, (_, name: string) => isBinaryExists(name)) diff --git a/src/main/mcpServers/memory.ts b/src/main/mcpServers/memory.ts index cee7e15d80..9b4d2d4c8d 100644 --- a/src/main/mcpServers/memory.ts +++ b/src/main/mcpServers/memory.ts @@ -1,15 +1,14 @@ -// port https://github.com/modelcontextprotocol/servers/blob/main/src/memory/index.ts import { getConfigDir } from '@main/utils/file' import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { CallToolRequestSchema, ListToolsRequestSchema, McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js' import { promises as fs } from 'fs' import path from 'path' -import { fileURLToPath } from 'url' +import { Mutex } from 'async-mutex' // 引入 Mutex -// Define memory file path using environment variable with fallback +// Define memory file path const defaultMemoryPath = path.join(getConfigDir(), 'memory.json') -// We are storing our memory using entities, relations, and observations in a graph structure +// Interfaces remain the same interface Entity { name: string entityType: string @@ -22,6 +21,7 @@ interface Relation { relationType: string } +// Structure for storing the graph in memory and in the file interface KnowledgeGraph { entities: Entity[] relations: Relation[] @@ -30,200 +30,304 @@ interface KnowledgeGraph { // The KnowledgeGraphManager class contains all operations to interact with the knowledge graph class KnowledgeGraphManager { private memoryPath: string + private entities: Map // Use Map for efficient entity lookup + private relations: Set // Store stringified relations for easy Set operations + private fileMutex: Mutex // Mutex for file writing - constructor(memoryPath: string) { + private constructor(memoryPath: string) { this.memoryPath = memoryPath - this.ensureMemoryPathExists() + this.entities = new Map() + this.relations = new Set() + this.fileMutex = new Mutex() } - private async ensureMemoryPathExists(): Promise { + // Static async factory method for initialization + public static async create(memoryPath: string): Promise { + const manager = new KnowledgeGraphManager(memoryPath) + await manager._ensureMemoryPathExists() + await manager._loadGraphFromDisk() + return manager + } + + private async _ensureMemoryPathExists(): Promise { try { - // Ensure the directory exists const directory = path.dirname(this.memoryPath) await fs.mkdir(directory, { recursive: true }) - - // Check if the file exists, if not create an empty one try { await fs.access(this.memoryPath) } catch (error) { - // File doesn't exist, create an empty file - await fs.writeFile(this.memoryPath, '') + // File doesn't exist, create an empty file with initial structure + await fs.writeFile(this.memoryPath, JSON.stringify({ entities: [], relations: [] }, null, 2)) } } catch (error) { - console.error('Failed to create memory path:', error) + console.error('Failed to ensure memory path exists:', error) + // Propagate the error or handle it more gracefully depending on requirements + throw new McpError(ErrorCode.InternalError, `Failed to ensure memory path: ${error instanceof Error ? error.message : String(error)}`) } } - private async loadGraph(): Promise { + // Load graph from disk into memory (called once during initialization) + private async _loadGraphFromDisk(): Promise { try { const data = await fs.readFile(this.memoryPath, 'utf-8') - const lines = data.split('\n').filter((line) => line.trim() !== '') - return lines.reduce( - (graph: KnowledgeGraph, line) => { - const item = JSON.parse(line) - if (item.type === 'entity') graph.entities.push(item as Entity) - if (item.type === 'relation') graph.relations.push(item as Relation) - return graph - }, - { entities: [], relations: [] } - ) + // Handle empty file case + if (data.trim() === '') { + this.entities = new Map() + this.relations = new Set() + // Optionally write the initial empty structure back + await this._persistGraph() + return + } + const graph: KnowledgeGraph = JSON.parse(data) + this.entities.clear() + this.relations.clear() + graph.entities.forEach(entity => this.entities.set(entity.name, entity)) + graph.relations.forEach(relation => this.relations.add(this._serializeRelation(relation))) } catch (error) { if (error instanceof Error && 'code' in error && (error as any).code === 'ENOENT') { - return { entities: [], relations: [] } + // File doesn't exist (should have been created by _ensureMemoryPathExists, but handle defensively) + this.entities = new Map() + this.relations = new Set() + await this._persistGraph() // Create the file with empty structure + } else if (error instanceof SyntaxError) { + console.error('Failed to parse memory.json, initializing with empty graph:', error) + // If JSON is invalid, start fresh and overwrite the corrupted file + this.entities = new Map() + this.relations = new Set() + await this._persistGraph() + } else { + console.error('Failed to load knowledge graph from disk:', error) + throw new McpError(ErrorCode.InternalError, `Failed to load graph: ${error instanceof Error ? error.message : String(error)}`) } - throw error } } - private async saveGraph(graph: KnowledgeGraph): Promise { - const lines = [ - ...graph.entities.map((e) => JSON.stringify({ type: 'entity', ...e })), - ...graph.relations.map((r) => JSON.stringify({ type: 'relation', ...r })) - ] - await fs.writeFile(this.memoryPath, lines.join('\n')) + // Persist the current in-memory graph to disk using a mutex + private async _persistGraph(): Promise { + const release = await this.fileMutex.acquire() + try { + const graphData: KnowledgeGraph = { + entities: Array.from(this.entities.values()), + relations: Array.from(this.relations).map(rStr => this._deserializeRelation(rStr)) + } + await fs.writeFile(this.memoryPath, JSON.stringify(graphData, null, 2)) + } catch (error) { + console.error('Failed to save knowledge graph:', error) + // Decide how to handle write errors - potentially retry or notify + throw new McpError(ErrorCode.InternalError, `Failed to save graph: ${error instanceof Error ? error.message : String(error)}`) + } finally { + release() + } + } + + // Helper to consistently serialize relations for Set storage + private _serializeRelation(relation: Relation): string { + // Simple serialization, ensure order doesn't matter if properties are consistent + return JSON.stringify({ from: relation.from, to: relation.to, relationType: relation.relationType }) + } + + // Helper to deserialize relations from Set storage + private _deserializeRelation(relationStr: string): Relation { + return JSON.parse(relationStr) as Relation } async createEntities(entities: Entity[]): Promise { - const graph = await this.loadGraph() - const newEntities = entities.filter((e) => !graph.entities.some((existingEntity) => existingEntity.name === e.name)) - graph.entities.push(...newEntities) - await this.saveGraph(graph) + const newEntities: Entity[] = [] + entities.forEach(entity => { + if (!this.entities.has(entity.name)) { + // Ensure observations is always an array + const newEntity = { ...entity, observations: Array.isArray(entity.observations) ? entity.observations : [] }; + this.entities.set(entity.name, newEntity) + newEntities.push(newEntity) + } + }) + if (newEntities.length > 0) { + await this._persistGraph() + } return newEntities } async createRelations(relations: Relation[]): Promise { - const graph = await this.loadGraph() - const newRelations = relations.filter( - (r) => - !graph.relations.some( - (existingRelation) => - existingRelation.from === r.from && - existingRelation.to === r.to && - existingRelation.relationType === r.relationType - ) - ) - graph.relations.push(...newRelations) - await this.saveGraph(graph) + const newRelations: Relation[] = [] + relations.forEach(relation => { + // Ensure related entities exist before creating a relation + if (!this.entities.has(relation.from) || !this.entities.has(relation.to)) { + console.warn(`Skipping relation creation: Entity not found for relation ${relation.from} -> ${relation.to}`) + return; // Skip this relation + } + const relationStr = this._serializeRelation(relation) + if (!this.relations.has(relationStr)) { + this.relations.add(relationStr) + newRelations.push(relation) + } + }) + if (newRelations.length > 0) { + await this._persistGraph() + } return newRelations } async addObservations( observations: { entityName: string; contents: string[] }[] ): Promise<{ entityName: string; addedObservations: string[] }[]> { - const graph = await this.loadGraph() - const results = observations.map((o) => { - const entity = graph.entities.find((e) => e.name === o.entityName) + const results: { entityName: string; addedObservations: string[] }[] = [] + let changed = false + observations.forEach(o => { + const entity = this.entities.get(o.entityName) if (!entity) { - throw new Error(`Entity with name ${o.entityName} not found`) + // Option 1: Throw error + throw new McpError(ErrorCode.InvalidParams, `Entity with name ${o.entityName} not found`) + // Option 2: Skip and warn + // console.warn(`Entity with name ${o.entityName} not found when adding observations. Skipping.`); + // return; + } + // Ensure observations array exists + if (!Array.isArray(entity.observations)) { + entity.observations = []; + } + const newObservations = o.contents.filter(content => !entity.observations.includes(content)) + if (newObservations.length > 0) { + entity.observations.push(...newObservations) + results.push({ entityName: o.entityName, addedObservations: newObservations }) + changed = true + } else { + // Still include in results even if nothing was added, to confirm processing + results.push({ entityName: o.entityName, addedObservations: [] }) } - const newObservations = o.contents.filter((content) => !entity.observations.includes(content)) - entity.observations.push(...newObservations) - return { entityName: o.entityName, addedObservations: newObservations } }) - await this.saveGraph(graph) + if (changed) { + await this._persistGraph() + } return results } async deleteEntities(entityNames: string[]): Promise { - const graph = await this.loadGraph() - graph.entities = graph.entities.filter((e) => !entityNames.includes(e.name)) - graph.relations = graph.relations.filter((r) => !entityNames.includes(r.from) && !entityNames.includes(r.to)) - await this.saveGraph(graph) + let changed = false + const namesToDelete = new Set(entityNames) + + // Delete entities + namesToDelete.forEach(name => { + if (this.entities.delete(name)) { + changed = true + } + }) + + // Delete relations involving deleted entities + const relationsToDelete = new Set() + this.relations.forEach(relStr => { + const rel = this._deserializeRelation(relStr) + if (namesToDelete.has(rel.from) || namesToDelete.has(rel.to)) { + relationsToDelete.add(relStr) + } + }) + + relationsToDelete.forEach(relStr => { + if (this.relations.delete(relStr)) { + changed = true + } + }) + + if (changed) { + await this._persistGraph() + } } async deleteObservations(deletions: { entityName: string; observations: string[] }[]): Promise { - const graph = await this.loadGraph() - deletions.forEach((d) => { - const entity = graph.entities.find((e) => e.name === d.entityName) - if (entity) { - entity.observations = entity.observations.filter((o) => !d.observations.includes(o)) + let changed = false + deletions.forEach(d => { + const entity = this.entities.get(d.entityName) + if (entity && Array.isArray(entity.observations)) { + const initialLength = entity.observations.length + const observationsToDelete = new Set(d.observations) + entity.observations = entity.observations.filter(o => !observationsToDelete.has(o)) + if (entity.observations.length !== initialLength) { + changed = true + } } }) - await this.saveGraph(graph) + if (changed) { + await this._persistGraph() + } } async deleteRelations(relations: Relation[]): Promise { - const graph = await this.loadGraph() - graph.relations = graph.relations.filter( - (r) => - !relations.some( - (delRelation) => - r.from === delRelation.from && r.to === delRelation.to && r.relationType === delRelation.relationType - ) - ) - await this.saveGraph(graph) + let changed = false + relations.forEach(rel => { + const relStr = this._serializeRelation(rel) + if (this.relations.delete(relStr)) { + changed = true + } + }) + if (changed) { + await this._persistGraph() + } } + // Read the current state from memory async readGraph(): Promise { - return this.loadGraph() + // Return a deep copy to prevent external modification of the internal state + return JSON.parse(JSON.stringify({ + entities: Array.from(this.entities.values()), + relations: Array.from(this.relations).map(rStr => this._deserializeRelation(rStr)) + })); } - // Very basic search function + // Search operates on the in-memory graph async searchNodes(query: string): Promise { - const graph = await this.loadGraph() - - // Filter entities - const filteredEntities = graph.entities.filter( - (e) => - e.name.toLowerCase().includes(query.toLowerCase()) || - e.entityType.toLowerCase().includes(query.toLowerCase()) || - e.observations.some((o) => o.toLowerCase().includes(query.toLowerCase())) + const lowerCaseQuery = query.toLowerCase() + const filteredEntities = Array.from(this.entities.values()).filter( + e => + e.name.toLowerCase().includes(lowerCaseQuery) || + e.entityType.toLowerCase().includes(lowerCaseQuery) || + (Array.isArray(e.observations) && e.observations.some(o => o.toLowerCase().includes(lowerCaseQuery))) ) - // Create a Set of filtered entity names for quick lookup - const filteredEntityNames = new Set(filteredEntities.map((e) => e.name)) + const filteredEntityNames = new Set(filteredEntities.map(e => e.name)) - // Filter relations to only include those between filtered entities - const filteredRelations = graph.relations.filter( - (r) => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to) - ) + const filteredRelations = Array.from(this.relations) + .map(rStr => this._deserializeRelation(rStr)) + .filter(r => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to)) - const filteredGraph: KnowledgeGraph = { + return { entities: filteredEntities, relations: filteredRelations } - - return filteredGraph } + // Open operates on the in-memory graph async openNodes(names: string[]): Promise { - const graph = await this.loadGraph() + const nameSet = new Set(names); + const filteredEntities = Array.from(this.entities.values()).filter(e => nameSet.has(e.name)); + const filteredEntityNames = new Set(filteredEntities.map(e => e.name)); - // Filter entities - const filteredEntities = graph.entities.filter((e) => names.includes(e.name)) + const filteredRelations = Array.from(this.relations) + .map(rStr => this._deserializeRelation(rStr)) + .filter(r => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to)); - // Create a Set of filtered entity names for quick lookup - const filteredEntityNames = new Set(filteredEntities.map((e) => e.name)) - - // Filter relations to only include those between filtered entities - const filteredRelations = graph.relations.filter( - (r) => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to) - ) - - const filteredGraph: KnowledgeGraph = { - entities: filteredEntities, - relations: filteredRelations - } - - return filteredGraph + return { + entities: filteredEntities, + relations: filteredRelations + }; } } class MemoryServer { public server: Server - private knowledgeGraphManager: KnowledgeGraphManager + // Hold the manager instance, initialized asynchronously + private knowledgeGraphManager: KnowledgeGraphManager | null = null; + private initializationPromise: Promise; // To track initialization constructor(envPath: string = '') { const memoryPath = envPath ? path.isAbsolute(envPath) ? envPath - : path.join(path.dirname(fileURLToPath(import.meta.url)), envPath) + : path.resolve(envPath) // Use path.resolve for relative paths based on CWD : defaultMemoryPath - this.knowledgeGraphManager = new KnowledgeGraphManager(memoryPath) + this.server = new Server( { name: 'memory-server', - version: '1.0.0' + version: '1.1.0' // Incremented version for changes }, { capabilities: { @@ -231,276 +335,311 @@ class MemoryServer { } } ) - this.initialize() + // Start initialization, but don't block constructor + this.initializationPromise = this._initializeManager(memoryPath); + this.setupRequestHandlers(); // Setup handlers immediately } - initialize() { - // The server instance and tools exposed to Claude + // Private async method to handle manager initialization + private async _initializeManager(memoryPath: string): Promise { + try { + this.knowledgeGraphManager = await KnowledgeGraphManager.create(memoryPath); + console.log("KnowledgeGraphManager initialized successfully."); + } catch (error) { + console.error("Failed to initialize KnowledgeGraphManager:", error); + // Server might be unusable, consider how to handle this state + // Maybe set a flag and return errors for all tool calls? + this.knowledgeGraphManager = null; // Ensure it's null if init fails + } + } + + // Ensures the manager is initialized before handling tool calls + private async _getManager(): Promise { + await this.initializationPromise; // Wait for initialization to complete + if (!this.knowledgeGraphManager) { + throw new McpError(ErrorCode.InternalError, "Memory server failed to initialize. Cannot process requests."); + } + return this.knowledgeGraphManager; + } + + + // Setup handlers (can be called from constructor) + setupRequestHandlers() { + // ListTools remains largely the same, descriptions might be updated if needed this.server.setRequestHandler(ListToolsRequestSchema, async () => { + // Ensure manager is ready before listing tools that depend on it + // Although ListTools itself doesn't *call* the manager, it implies the + // manager is ready to handle calls for those tools. + try { + await this._getManager(); // Wait for initialization before confirming tools are available + } catch (error) { + // If manager failed to init, maybe return an empty tool list or throw? + console.error("Cannot list tools, manager initialization failed:", error); + return { tools: [] }; // Return empty list if server is not ready + } + return { tools: [ - { - name: 'create_entities', - description: 'Create multiple new entities in the knowledge graph', - inputSchema: { - type: 'object', - properties: { - entities: { - type: 'array', - items: { - type: 'object', - properties: { - name: { type: 'string', description: 'The name of the entity' }, - entityType: { type: 'string', description: 'The type of the entity' }, - observations: { - type: 'array', - items: { type: 'string' }, - description: 'An array of observation contents associated with the entity' - } - }, - required: ['name', 'entityType', 'observations'] + { + name: 'create_entities', + description: 'Create multiple new entities in the knowledge graph. Skips existing entities.', + inputSchema: { + type: 'object', + properties: { + entities: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'The name of the entity' }, + entityType: { type: 'string', description: 'The type of the entity' }, + observations: { + type: 'array', + items: { type: 'string' }, + description: 'An array of observation contents associated with the entity', + default: [] // Add default empty array + } + }, + required: ['name', 'entityType'] // Observations are optional now on creation + } + } + }, + required: ['entities'] + } + }, + { + name: 'create_relations', + description: 'Create multiple new relations between EXISTING entities. Skips existing relations or relations with non-existent entities.', + inputSchema: { + type: 'object', + properties: { + relations: { + type: 'array', + items: { + type: 'object', + properties: { + from: { type: 'string', description: 'The name of the entity where the relation starts' }, + to: { type: 'string', description: 'The name of the entity where the relation ends' }, + relationType: { type: 'string', description: 'The type of the relation' } + }, + required: ['from', 'to', 'relationType'] + } } - } - }, - required: ['entities'] - } - }, - { - name: 'create_relations', - description: - 'Create multiple new relations between entities in the knowledge graph. Relations should be in active voice', - inputSchema: { - type: 'object', - properties: { - relations: { - type: 'array', - items: { - type: 'object', - properties: { - from: { type: 'string', description: 'The name of the entity where the relation starts' }, - to: { type: 'string', description: 'The name of the entity where the relation ends' }, - relationType: { type: 'string', description: 'The type of the relation' } - }, - required: ['from', 'to', 'relationType'] + }, + required: ['relations'] + } + }, + { + name: 'add_observations', + description: 'Add new observations to existing entities. Skips duplicate observations.', + inputSchema: { + type: 'object', + properties: { + observations: { + type: 'array', + items: { + type: 'object', + properties: { + entityName: { type: 'string', description: 'The name of the entity to add the observations to' }, + contents: { + type: 'array', + items: { type: 'string' }, + description: 'An array of observation contents to add' + } + }, + required: ['entityName', 'contents'] + } } - } - }, - required: ['relations'] - } - }, - { - name: 'add_observations', - description: 'Add new observations to existing entities in the knowledge graph', - inputSchema: { - type: 'object', - properties: { - observations: { - type: 'array', - items: { - type: 'object', - properties: { - entityName: { type: 'string', description: 'The name of the entity to add the observations to' }, - contents: { - type: 'array', - items: { type: 'string' }, - description: 'An array of observation contents to add' - } - }, - required: ['entityName', 'contents'] + }, + required: ['observations'] + } + }, + { + name: 'delete_entities', + description: 'Delete multiple entities and their associated relations.', + inputSchema: { + type: 'object', + properties: { + entityNames: { + type: 'array', + items: { type: 'string' }, + description: 'An array of entity names to delete' } - } - }, - required: ['observations'] - } - }, - { - name: 'delete_entities', - description: 'Delete multiple entities and their associated relations from the knowledge graph', - inputSchema: { - type: 'object', - properties: { - entityNames: { - type: 'array', - items: { type: 'string' }, - description: 'An array of entity names to delete' - } - }, - required: ['entityNames'] - } - }, - { - name: 'delete_observations', - description: 'Delete specific observations from entities in the knowledge graph', - inputSchema: { - type: 'object', - properties: { - deletions: { - type: 'array', - items: { - type: 'object', - properties: { - entityName: { type: 'string', description: 'The name of the entity containing the observations' }, - observations: { - type: 'array', - items: { type: 'string' }, - description: 'An array of observations to delete' - } - }, - required: ['entityName', 'observations'] + }, + required: ['entityNames'] + } + }, + { + name: 'delete_observations', + description: 'Delete specific observations from entities.', + inputSchema: { + type: 'object', + properties: { + deletions: { + type: 'array', + items: { + type: 'object', + properties: { + entityName: { type: 'string', description: 'The name of the entity containing the observations' }, + observations: { + type: 'array', + items: { type: 'string' }, + description: 'An array of observations to delete' + } + }, + required: ['entityName', 'observations'] + } } - } - }, - required: ['deletions'] - } - }, - { - name: 'delete_relations', - description: 'Delete multiple relations from the knowledge graph', - inputSchema: { - type: 'object', - properties: { - relations: { - type: 'array', - items: { - type: 'object', - properties: { - from: { type: 'string', description: 'The name of the entity where the relation starts' }, - to: { type: 'string', description: 'The name of the entity where the relation ends' }, - relationType: { type: 'string', description: 'The type of the relation' } + }, + required: ['deletions'] + } + }, + { + name: 'delete_relations', + description: 'Delete multiple specific relations.', + inputSchema: { + type: 'object', + properties: { + relations: { + type: 'array', + items: { + type: 'object', + properties: { + from: { type: 'string', description: 'The name of the entity where the relation starts' }, + to: { type: 'string', description: 'The name of the entity where the relation ends' }, + relationType: { type: 'string', description: 'The type of the relation' } + }, + required: ['from', 'to', 'relationType'] }, - required: ['from', 'to', 'relationType'] - }, - description: 'An array of relations to delete' - } - }, - required: ['relations'] - } - }, - { - name: 'read_graph', - description: 'Read the entire knowledge graph', - inputSchema: { - type: 'object', - properties: {} - } - }, - { - name: 'search_nodes', - description: 'Search for nodes in the knowledge graph based on a query', - inputSchema: { - type: 'object', - properties: { - query: { - type: 'string', - description: 'The search query to match against entity names, types, and observation content' - } - }, - required: ['query'] - } - }, - { - name: 'open_nodes', - description: 'Open specific nodes in the knowledge graph by their names', - inputSchema: { - type: 'object', - properties: { - names: { - type: 'array', - items: { type: 'string' }, - description: 'An array of entity names to retrieve' - } - }, - required: ['names'] - } - } - ] + description: 'An array of relations to delete' + } + }, + required: ['relations'] + } + }, + { + name: 'read_graph', + description: 'Read the entire knowledge graph from memory.', + inputSchema: { + type: 'object', + properties: {} + } + }, + { + name: 'search_nodes', + description: 'Search nodes (entities and relations) in memory based on a query.', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The search query to match against entity names, types, and observation content' + } + }, + required: ['query'] + } + }, + { + name: 'open_nodes', + description: 'Retrieve specific entities and their connecting relations from memory by name.', + inputSchema: { + type: 'object', + properties: { + names: { + type: 'array', + items: { type: 'string' }, + description: 'An array of entity names to retrieve' + } + }, + required: ['names'] + } + } + ] } }) + // CallTool handler needs to await the manager and the async methods this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const manager = await this._getManager(); // Ensure manager is ready const { name, arguments: args } = request.params if (!args) { - throw new Error(`No arguments provided for tool: ${name}`) + // Use McpError for standard errors + throw new McpError(ErrorCode.InvalidParams, `No arguments provided for tool: ${name}`) } - switch (name) { - case 'create_entities': - return { - content: [ - { - type: 'text', - text: JSON.stringify( - await this.knowledgeGraphManager.createEntities(args.entities as Entity[]), - null, - 2 - ) - } - ] + try { + switch (name) { + case 'create_entities': + // Validate args structure if necessary, though SDK might do basic validation + if (!args.entities || !Array.isArray(args.entities)) { + throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for ${name}: 'entities' array is required.`); + } + return { + content: [{ type: 'text', text: JSON.stringify(await manager.createEntities(args.entities as Entity[]), null, 2) }] + } + case 'create_relations': + if (!args.relations || !Array.isArray(args.relations)) { + throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for ${name}: 'relations' array is required.`); + } + return { + content: [{ type: 'text', text: JSON.stringify(await manager.createRelations(args.relations as Relation[]), null, 2) }] + } + case 'add_observations': + if (!args.observations || !Array.isArray(args.observations)) { + throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for ${name}: 'observations' array is required.`); + } + return { + content: [{ type: 'text', text: JSON.stringify(await manager.addObservations(args.observations as { entityName: string; contents: string[] }[]), null, 2) }] + } + case 'delete_entities': + if (!args.entityNames || !Array.isArray(args.entityNames)) { + throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for ${name}: 'entityNames' array is required.`); + } + await manager.deleteEntities(args.entityNames as string[]) + return { content: [{ type: 'text', text: 'Entities deleted successfully' }] } + case 'delete_observations': + if (!args.deletions || !Array.isArray(args.deletions)) { + throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for ${name}: 'deletions' array is required.`); + } + await manager.deleteObservations(args.deletions as { entityName: string; observations: string[] }[]) + return { content: [{ type: 'text', text: 'Observations deleted successfully' }] } + case 'delete_relations': + if (!args.relations || !Array.isArray(args.relations)) { + throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for ${name}: 'relations' array is required.`); + } + await manager.deleteRelations(args.relations as Relation[]) + return { content: [{ type: 'text', text: 'Relations deleted successfully' }] } + case 'read_graph': + // No arguments expected or needed for read_graph based on original schema + return { + content: [{ type: 'text', text: JSON.stringify(await manager.readGraph(), null, 2) }] + } + case 'search_nodes': + if (typeof args.query !== 'string') { + throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for ${name}: 'query' string is required.`); + } + return { + content: [{ type: 'text', text: JSON.stringify(await manager.searchNodes(args.query as string), null, 2) }] + } + case 'open_nodes': + if (!args.names || !Array.isArray(args.names)) { + throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for ${name}: 'names' array is required.`); + } + return { + content: [{ type: 'text', text: JSON.stringify(await manager.openNodes(args.names as string[]), null, 2) }] + } + default: + throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`) + } + } catch (error) { + // Catch errors from manager methods (like entity not found) or other issues + if (error instanceof McpError) { + throw error; // Re-throw McpErrors directly } - case 'create_relations': - return { - content: [ - { - type: 'text', - text: JSON.stringify( - await this.knowledgeGraphManager.createRelations(args.relations as Relation[]), - null, - 2 - ) - } - ] - } - case 'add_observations': - return { - content: [ - { - type: 'text', - text: JSON.stringify( - await this.knowledgeGraphManager.addObservations( - args.observations as { entityName: string; contents: string[] }[] - ), - null, - 2 - ) - } - ] - } - case 'delete_entities': - await this.knowledgeGraphManager.deleteEntities(args.entityNames as string[]) - return { content: [{ type: 'text', text: 'Entities deleted successfully' }] } - case 'delete_observations': - await this.knowledgeGraphManager.deleteObservations( - args.deletions as { entityName: string; observations: string[] }[] - ) - return { content: [{ type: 'text', text: 'Observations deleted successfully' }] } - case 'delete_relations': - await this.knowledgeGraphManager.deleteRelations(args.relations as Relation[]) - return { content: [{ type: 'text', text: 'Relations deleted successfully' }] } - case 'read_graph': - return { - content: [{ type: 'text', text: JSON.stringify(await this.knowledgeGraphManager.readGraph(), null, 2) }] - } - case 'search_nodes': - return { - content: [ - { - type: 'text', - text: JSON.stringify(await this.knowledgeGraphManager.searchNodes(args.query as string), null, 2) - } - ] - } - case 'open_nodes': - return { - content: [ - { - type: 'text', - text: JSON.stringify(await this.knowledgeGraphManager.openNodes(args.names as string[]), null, 2) - } - ] - } - default: - throw new Error(`Unknown tool: ${name}`) + console.error(`Error executing tool ${name}:`, error); + // Throw a generic internal error for unexpected issues + throw new McpError(ErrorCode.InternalError, `Error executing tool ${name}: ${error instanceof Error ? error.message : String(error)}`); } }) } diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index 782b9cc9cd..52105be8e2 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -10,13 +10,47 @@ 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 { MCPServer, MCPTool } from '@types' +import { GetMCPPromptResponse, MCPPrompt, MCPServer, MCPTool } from '@types' import { app } from 'electron' import Logger from 'electron-log' import { CacheService } from './CacheService' import { StreamableHTTPClientTransport, type StreamableHTTPClientTransportOptions } from './MCPStreamableHttpClient' +// Generic type for caching wrapped functions +type CachedFunction = (...args: T) => Promise + +/** + * Higher-order function to add caching capability to any async function + * @param fn The original function to be wrapped with caching + * @param getCacheKey Function to generate a cache key from the function arguments + * @param ttl Time to live for the cache entry in milliseconds + * @param logPrefix Prefix for log messages + * @returns The wrapped function with caching capability + */ +function withCache( + fn: (...args: T) => Promise, + getCacheKey: (...args: T) => string, + ttl: number, + logPrefix: string +): CachedFunction { + return async (...args: T): Promise => { + const cacheKey = getCacheKey(...args) + + if (CacheService.has(cacheKey)) { + Logger.info(`${logPrefix} loaded from cache`) + const cachedData = CacheService.get(cacheKey) + if (cachedData) { + return cachedData + } + } + + const result = await fn(...args) + CacheService.set(cacheKey, result, ttl) + return result + } +} + class McpService { private clients: Map = new Map() @@ -35,6 +69,8 @@ class McpService { this.initClient = this.initClient.bind(this) this.listTools = this.listTools.bind(this) this.callTool = this.callTool.bind(this) + this.listPrompts = this.listPrompts.bind(this) + this.getPrompt = this.getPrompt.bind(this) this.closeClient = this.closeClient.bind(this) this.removeServer = this.removeServer.bind(this) this.restartServer = this.restartServer.bind(this) @@ -216,31 +252,40 @@ class McpService { } } - async listTools(_: Electron.IpcMainInvokeEvent, server: MCPServer) { - const client = await this.initClient(server) - const serverKey = this.getServerKey(server) - const cacheKey = `mcp:list_tool:${serverKey}` - if (CacheService.has(cacheKey)) { - Logger.info(`[MCP] Tools from ${server.name} loaded from cache`) - const cachedTools = CacheService.get(cacheKey) - if (cachedTools && cachedTools.length > 0) { - return cachedTools - } - } + private async listToolsImpl(server: MCPServer): Promise { Logger.info(`[MCP] Listing tools for server: ${server.name}`) - const { tools } = await client.listTools() - const serverTools: MCPTool[] = [] - tools.map((tool: any) => { - const serverTool: MCPTool = { - ...tool, - id: `f${nanoid()}`, - serverId: server.id, - serverName: server.name - } - serverTools.push(serverTool) - }) - CacheService.set(cacheKey, serverTools, 5 * 60 * 1000) - return serverTools + const client = await this.initClient(server) + try { + const { tools } = await client.listTools() + const serverTools: MCPTool[] = [] + tools.map((tool: any) => { + const serverTool: MCPTool = { + ...tool, + id: `f${nanoid()}`, + serverId: server.id, + serverName: server.name + } + serverTools.push(serverTool) + }) + return serverTools + } catch (error) { + Logger.error(`[MCP] Failed to list tools for server: ${server.name}`, error) + return [] + } + } + + async listTools(_: Electron.IpcMainInvokeEvent, server: MCPServer) { + const cachedListTools = withCache<[MCPServer], MCPTool[]>( + this.listToolsImpl.bind(this), + (server) => { + const serverKey = this.getServerKey(server) + return `mcp:list_tool:${serverKey}` + }, + 5 * 60 * 1000, // 5 minutes TTL + `[MCP] Tools from ${server.name}` + ) + + return cachedListTools(server) } /** @@ -270,6 +315,76 @@ class McpService { return { dir, uvPath, bunPath } } + /** + * List prompts available on an MCP server + */ + private async listPromptsImpl(server: MCPServer): Promise { + Logger.info(`[MCP] Listing prompts for server: ${server.name}`) + const client = await this.initClient(server) + try { + const { prompts } = await client.listPrompts() + const serverPrompts = prompts.map((prompt: any) => ({ + ...prompt, + id: `p${nanoid()}`, + serverId: server.id, + serverName: server.name + })) + return serverPrompts + } catch (error) { + Logger.error(`[MCP] Failed to list prompts for server: ${server.name}`, error) + return [] + } + } + + /** + * List prompts available on an MCP server with caching + */ + public async listPrompts(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise { + const cachedListPrompts = withCache<[MCPServer], MCPPrompt[]>( + this.listPromptsImpl.bind(this), + (server) => { + const serverKey = this.getServerKey(server) + return `mcp:list_prompts:${serverKey}` + }, + 60 * 60 * 1000, // 60 minutes TTL + `[MCP] Prompts from ${server.name}` + ) + return cachedListPrompts(server) + } + + /** + * Get a specific prompt from an MCP server (implementation) + */ + private async getPromptImpl( + server: MCPServer, + name: string, + args?: Record + ): Promise { + Logger.info(`[MCP] Getting prompt ${name} from server: ${server.name}`) + const client = await this.initClient(server) + return await client.getPrompt({ name, arguments: args }) + } + + /** + * Get a specific prompt from an MCP server with caching + */ + public async getPrompt( + _: Electron.IpcMainInvokeEvent, + { server, name, args }: { server: MCPServer; name: string; args?: Record } + ): Promise { + const cachedGetPrompt = withCache<[MCPServer, string, Record | undefined], GetMCPPromptResponse>( + this.getPromptImpl.bind(this), + (server, name, args) => { + const serverKey = this.getServerKey(server) + const argsKey = args ? JSON.stringify(args) : 'no-args' + return `mcp:get_prompt:${serverKey}:${name}:${argsKey}` + }, + 30 * 60 * 1000, // 30 minutes TTL + `[MCP] Prompt ${name} from ${server.name}` + ) + return await cachedGetPrompt(server, name, args) + } + /** * Get enhanced PATH including common tool locations */ diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index a1cd1c0c98..8ba792a351 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -151,6 +151,16 @@ declare global { stopServer: (server: MCPServer) => Promise listTools: (server: MCPServer) => Promise callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => Promise + listPrompts: (server: MCPServer) => Promise + getPrompt: ({ + server, + name, + args + }: { + server: MCPServer + name: string + args?: Record + }) => Promise getInstallInfo: () => Promise<{ dir: string; uvPath: string; bunPath: string }> } copilot: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 50b922c917..f2e891c82b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -135,8 +135,11 @@ const api = { restartServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_RestartServer, server), stopServer: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_StopServer, server), listTools: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_ListTools, server), - callTool: ({ server, name, args }: { server: MCPServer; name: string; args: any }) => + callTool: ({ server, name, args }: { server: MCPServer; name: string; args?: Record }) => ipcRenderer.invoke(IpcChannel.Mcp_CallTool, { server, name, args }), + 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 }), getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo) }, shell: { diff --git a/src/renderer/src/assets/images/mcp/npm.svg b/src/renderer/src/assets/images/mcp/npm.svg new file mode 100644 index 0000000000..df5b545d2f --- /dev/null +++ b/src/renderer/src/assets/images/mcp/npm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/renderer/src/components/CustomCollapse.tsx b/src/renderer/src/components/CustomCollapse.tsx index 804c03525c..259e259d53 100644 --- a/src/renderer/src/components/CustomCollapse.tsx +++ b/src/renderer/src/components/CustomCollapse.tsx @@ -1,4 +1,5 @@ import { Collapse } from 'antd' +import { merge } from 'lodash' import { FC, memo } from 'react' interface CustomCollapseProps { @@ -9,6 +10,11 @@ interface CustomCollapseProps { defaultActiveKey?: string[] activeKey?: string[] collapsible?: 'header' | 'icon' | 'disabled' + style?: React.CSSProperties + styles?: { + header?: React.CSSProperties + body?: React.CSSProperties + } } const CustomCollapse: FC = ({ @@ -18,14 +24,17 @@ const CustomCollapse: FC = ({ destroyInactivePanel = false, defaultActiveKey = ['1'], activeKey, - collapsible = undefined + collapsible = undefined, + style, + styles }) => { - const CollapseStyle = { + const defaultCollapseStyle = { width: '100%', background: 'transparent', border: '0.5px solid var(--color-border)' } - const CollapseItemStyles = { + + const defaultCollapseItemStyles = { header: { padding: '8px 16px', alignItems: 'center', @@ -38,17 +47,21 @@ const CustomCollapse: FC = ({ borderTop: '0.5px solid var(--color-border)' } } + + const collapseStyle = merge({}, defaultCollapseStyle, style) + const collapseItemStyles = merge({}, defaultCollapseItemStyles, styles) + return ( ` justify-content: flex-end; flex-shrink: 0; gap: 16px; - font-size: 10px; + font-size: 12px; color: var(--color-text-3); ` const QuickPanelFooterTitle = styled.div` - font-size: 11px; + font-size: 12px; color: var(--color-text-3); overflow: hidden; text-overflow: ellipsis; @@ -635,7 +635,8 @@ const QuickPanelItemIcon = styled.span` const QuickPanelItemLabel = styled.span` flex: 1; - font-size: 12px; + font-size: 13px; + line-height: 16px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/src/renderer/src/hooks/useMCPServers.ts b/src/renderer/src/hooks/useMCPServers.ts index 6cf16a295d..e6993a3473 100644 --- a/src/renderer/src/hooks/useMCPServers.ts +++ b/src/renderer/src/hooks/useMCPServers.ts @@ -26,3 +26,10 @@ export const useMCPServers = () => { updateMcpServers: (servers: MCPServer[]) => dispatch(setMCPServers(servers)) } } + +export const useMCPServer = (id: string) => { + const { mcpServers } = useMCPServers() + return { + server: mcpServers.find((server) => server.id === id) + } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 1a3d07362a..0abed48878 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -585,7 +585,8 @@ "switch.disabled": "Please wait for the current reply to complete", "tools": { "completed": "Completed", - "invoking": "Invoking" + "invoking": "Invoking", + "error": "Error occurred" }, "topic.added": "New topic added", "upgrade.success.button": "Restart", @@ -1092,7 +1093,6 @@ "newServer": "MCP Server", "npx_list": { "actions": "Actions", - "desc": "Search and add npm packages as MCP servers", "description": "Description", "no_packages": "No packages found", "npm": "NPM", @@ -1101,7 +1101,6 @@ "scope_required": "Please enter npm scope", "search": "Search", "search_error": "Search error", - "title": "NPX Package List", "usage": "Usage", "version": "Version" }, @@ -1118,10 +1117,25 @@ "url": "URL", "editMcpJson": "Edit MCP Configuration", "installHelp": "Get Installation Help", + "tabs": { + "general": "General", + "tools": "Tools", + "prompts": "Prompts", + "resources": "Resources" + }, "tools": { "inputSchema": "Input Schema", "availableTools": "Available Tools", - "noToolsAvailable": "No tools available" + "noToolsAvailable": "No tools available", + "loadError": "Get tools Error" + }, + "prompts": { + "availablePrompts": "Available Prompts", + "noPromptsAvailable": "No prompts available", + "arguments": "Arguments", + "requiredField": "Required Field", + "genericError": "Get prompt Error", + "loadError": "Get prompts Error" }, "deleteServer": "Delete Server", "deleteServerConfirm": "Are you sure you want to delete this server?", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index d607690b20..f24fc85436 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -562,7 +562,8 @@ "switch.disabled": "現在の応答が完了するまで切り替えを無効にします", "tools": { "completed": "完了", - "invoking": "呼び出し中" + "invoking": "呼び出し中", + "error": "エラーが発生しました" }, "topic.added": "新しいトピックが追加されました", "upgrade.success.button": "再起動", @@ -1069,7 +1070,6 @@ "newServer": "MCP サーバー", "npx_list": { "actions": "アクション", - "desc": "npm パッケージを検索して MCP サーバーとして追加", "description": "説明", "no_packages": "パッケージが見つかりません", "npm": "NPM", @@ -1078,7 +1078,6 @@ "scope_required": "npm スコープを入力してください", "search": "検索", "search_error": "パッケージの検索に失敗しました", - "title": "NPX パッケージリスト", "usage": "使用法", "version": "バージョン" }, @@ -1095,10 +1094,25 @@ }, "editMcpJson": "MCP 設定を編集", "installHelp": "インストールヘルプを取得", + "tabs": { + "general": "一般", + "tools": "ツール", + "prompts": "プロンプト", + "resources": "リソース" + }, "tools": { "inputSchema": "入力スキーマ", "availableTools": "利用可能なツール", - "noToolsAvailable": "利用可能なツールはありません" + "noToolsAvailable": "利用可能なツールなし", + "loadError": "ツール取得エラー" + }, + "prompts": { + "availablePrompts": "利用可能なプロンプト", + "noPromptsAvailable": "利用可能なプロンプトはありません", + "arguments": "引数", + "requiredField": "必須フィールド", + "genericError": "プロンプト取得エラー", + "loadError": "プロンプト取得エラー" }, "deleteServer": "サーバーを削除", "deleteServerConfirm": "このサーバーを削除してもよろしいですか?", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 156e46833b..322df68934 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -563,7 +563,8 @@ "switch.disabled": "Пожалуйста, дождитесь завершения текущего ответа", "tools": { "completed": "Завершено", - "invoking": "Вызов" + "invoking": "Вызов", + "error": "Произошла ошибка" }, "topic.added": "Новый топик добавлен", "upgrade.success.button": "Перезапустить", @@ -1069,7 +1070,6 @@ "newServer": "MCP сервер", "npx_list": { "actions": "Действия", - "desc": "Поиск и добавление npm пакетов в качестве MCP серверов", "description": "Описание", "no_packages": "Ничего не найдено", "npm": "NPM", @@ -1078,7 +1078,6 @@ "scope_required": "Пожалуйста, введите область npm", "search": "Поиск", "search_error": "Ошибка поиска", - "title": "Список пакетов NPX", "usage": "Использование", "version": "Версия" }, @@ -1095,10 +1094,25 @@ "url": "URL", "editMcpJson": "Редактировать MCP", "installHelp": "Получить помощь по установке", + "tabs": { + "general": "Общие", + "tools": "Инструменты", + "prompts": "Подсказки", + "resources": "Ресурсы" + }, "tools": { - "inputSchema": "входные параметры", - "availableTools": "доступные инструменты", - "noToolsAvailable": "нет доступных инструментов" + "inputSchema": "Схема ввода", + "availableTools": "Доступные инструменты", + "noToolsAvailable": "Нет доступных инструментов", + "loadError": "Ошибка получения инструментов" + }, + "prompts": { + "availablePrompts": "Доступные подсказки", + "noPromptsAvailable": "Нет доступных подсказок", + "arguments": "Аргументы", + "requiredField": "Обязательное поле", + "genericError": "Ошибка получения подсказки", + "loadError": "Ошибка получения подсказок" }, "deleteServer": "Удалить сервер", "deleteServerConfirm": "Вы уверены, что хотите удалить этот сервер?", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 396bed9daf..f4490c5c68 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -585,7 +585,8 @@ "switch.disabled": "请等待当前回复完成后操作", "tools": { "completed": "已完成", - "invoking": "调用中" + "invoking": "调用中", + "error": "发生错误" }, "topic.added": "话题添加成功", "upgrade.success.button": "重启", @@ -1092,7 +1093,6 @@ "newServer": "MCP 服务器", "npx_list": { "actions": "操作", - "desc": "搜索并添加 npm 包作为 MCP 服务", "description": "描述", "no_packages": "未找到包", "npm": "NPM", @@ -1101,7 +1101,6 @@ "scope_required": "请输入 npm 作用域", "search": "搜索", "search_error": "搜索失败", - "title": "NPX 包列表", "usage": "用法", "version": "版本" }, @@ -1118,10 +1117,25 @@ "url": "URL", "editMcpJson": "编辑 MCP 配置", "installHelp": "获取安装帮助", + "tabs": { + "general": "通用", + "tools": "工具", + "prompts": "提示", + "resources": "资源" + }, "tools": { - "inputSchema": "输入参数", + "inputSchema": "输入模式", "availableTools": "可用工具", - "noToolsAvailable": "没有可用工具" + "noToolsAvailable": "无可用工具", + "loadError": "获取工具失败" + }, + "prompts": { + "availablePrompts": "可用提示", + "noPromptsAvailable": "无可用提示", + "arguments": "参数", + "requiredField": "必填字段", + "genericError": "获取提示错误", + "loadError": "获取提示失败" }, "deleteServer": "删除服务器", "deleteServerConfirm": "确定要删除此服务器吗?", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 084143c7f8..d965418365 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -563,7 +563,8 @@ "switch.disabled": "請等待當前回覆完成", "tools": { "completed": "已完成", - "invoking": "調用中" + "invoking": "調用中", + "error": "發生錯誤" }, "topic.added": "新話題已新增", "upgrade.success.button": "重新啟動", @@ -1069,7 +1070,6 @@ "newServer": "MCP 伺服器", "npx_list": { "actions": "操作", - "desc": "搜索並添加 npm 包作為 MCP 服務", "description": "描述", "no_packages": "未找到包", "npm": "NPM", @@ -1078,7 +1078,6 @@ "scope_required": "請輸入 npm 作用域", "search": "搜索", "search_error": "搜索失敗", - "title": "NPX 包列表", "usage": "用法", "version": "版本" }, @@ -1095,10 +1094,25 @@ "url": "URL", "editMcpJson": "編輯 MCP 配置", "installHelp": "獲取安裝幫助", + "tabs": { + "general": "通用", + "tools": "工具", + "prompts": "提示", + "resources": "資源" + }, "tools": { - "inputSchema": "輸入參數", + "inputSchema": "輸入模式", "availableTools": "可用工具", - "noToolsAvailable": "沒有可用工具" + "noToolsAvailable": "無可用工具", + "loadError": "獲取工具失敗" + }, + "prompts": { + "availablePrompts": "可用提示", + "noPromptsAvailable": "無可用提示", + "arguments": "參數", + "requiredField": "必填欄位", + "genericError": "獲取提示錯誤", + "loadError": "獲取提示失敗" }, "deleteServer": "刪除伺服器", "deleteServerConfirm": "確定要刪除此伺服器嗎?", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 22b583a341..db99e7c4d5 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -912,7 +912,6 @@ "noServers": "Δεν έχουν ρυθμιστεί διακομιστές", "npx_list": { "actions": "Ενέργειες", - "desc": "Αναζητήστε και προσθέστε πακέτα npm ως υπηρεσίες MCP", "description": "Περιγραφή", "no_packages": "Δεν βρέθηκαν πακέτα", "npm": "NPM", @@ -921,7 +920,6 @@ "scope_required": "Παρακαλώ εισαγάγετε το σκοπό του npm", "search": "Αναζήτηση", "search_error": "Η αναζήτηση απέτυχε", - "title": "Λίστα πακέτων NPX", "usage": "Χρήση", "version": "Έκδοση" }, diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index ff49357693..cb609f6676 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -912,7 +912,6 @@ "noServers": "No se han configurado servidores", "npx_list": { "actions": "Acciones", - "desc": "Buscar y agregar paquetes npm como servicios MCP", "description": "Descripción", "no_packages": "No se encontraron paquetes", "npm": "NPM", @@ -921,7 +920,6 @@ "scope_required": "Por favor ingrese el ámbito npm", "search": "Buscar", "search_error": "Error de búsqueda", - "title": "Lista de paquetes NPX", "usage": "Uso", "version": "Versión" }, diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index a972714ad9..51d4ee83a5 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -912,7 +912,6 @@ "noServers": "Aucun serveur configuré", "npx_list": { "actions": "Actions", - "desc": "Rechercher et ajouter un package npm en tant que service MCP", "description": "Description", "no_packages": "Aucun package trouvé", "npm": "NPM", @@ -921,7 +920,6 @@ "scope_required": "Veuillez entrer le scope npm", "search": "Rechercher", "search_error": "La recherche a échoué", - "title": "Liste des packages NPX", "usage": "Utilisation", "version": "Version" }, diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index db3da8b96f..d4e9a06d1b 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -912,7 +912,6 @@ "noServers": "Nenhum servidor configurado", "npx_list": { "actions": "Ações", - "desc": "Pesquise e adicione pacotes npm como serviço MCP", "description": "Descrição", "no_packages": "Nenhum pacote encontrado", "npm": "NPM", @@ -921,7 +920,6 @@ "scope_required": "Insira o escopo npm", "search": "Pesquisar", "search_error": "Falha na pesquisa", - "title": "Lista de Pacotes NPX", "usage": "Uso", "version": "Versão" }, diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 57a7384a23..10200fbccf 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -9,7 +9,6 @@ import { HolderOutlined, PaperClipOutlined, PauseCircleOutlined, - QuestionCircleOutlined, ThunderboltOutlined, TranslationOutlined } from '@ant-design/icons' @@ -43,7 +42,7 @@ import { Assistant, FileType, KnowledgeBase, KnowledgeItem, MCPServer, Message, import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils' import { getFilesFromDropEvent } from '@renderer/utils/input' import { documentExts, imageExts, textExts } from '@shared/config/constant' -import { Button, Popconfirm, Tooltip } from 'antd' +import { Button, Tooltip } from 'antd' import TextArea, { TextAreaRef } from 'antd/es/input/TextArea' import dayjs from 'dayjs' import Logger from 'electron-log/renderer' @@ -363,6 +362,15 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = mcpToolsButtonRef.current?.openQuickPanel() } }, + { + label: 'MCP Prompt', + description: '', + icon: , + isMenu: true, + action: () => { + mcpToolsButtonRef.current?.openPromptList() + } + }, { label: isVisionModel(model) ? t('chat.input.upload') : t('chat.input.upload.document'), description: '', @@ -691,9 +699,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = textareaRef.current?.focus() }) - useShortcut('clear_topic', () => { - clearTopic() - }) + useShortcut('clear_topic', clearTopic) useEffect(() => { const _setEstimateTokenCount = debounce(setEstimateTokenCount, 100, { leading: false, trailing: true }) @@ -1094,6 +1100,8 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = enabledMCPs={enabledMCPs} toggelEnableMCP={toggelEnableMCP} ToolbarButton={ToolbarButton} + setInputValue={setText} + resizeTextArea={resizeTextArea} /> = ({ assistant: _assistant, setActiveTopic, topic }) = ToolbarButton={ToolbarButton} /> - } - okText={t('chat.input.clear.title')}> - - - - + + + diff --git a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx index a7d0da647f..11914c6ad9 100644 --- a/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MCPToolsButton.tsx @@ -1,28 +1,40 @@ import { CodeOutlined, PlusOutlined } from '@ant-design/icons' import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel' import { useMCPServers } from '@renderer/hooks/useMCPServers' -import { MCPServer } from '@renderer/types' -import { Tooltip } from 'antd' +import { MCPPrompt, MCPServer } from '@renderer/types' +import { Form, Input, Modal, Tooltip } from 'antd' import { FC, useCallback, useImperativeHandle, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router' export interface MCPToolsButtonRef { openQuickPanel: () => void + openPromptList: () => void } interface Props { ref?: React.RefObject enabledMCPs: MCPServer[] + setInputValue: React.Dispatch> + resizeTextArea: () => void toggelEnableMCP: (server: MCPServer) => void ToolbarButton: any } -const MCPToolsButton: FC = ({ ref, enabledMCPs, toggelEnableMCP, ToolbarButton }) => { +const MCPToolsButton: FC = ({ + ref, + setInputValue, + resizeTextArea, + enabledMCPs, + toggelEnableMCP, + ToolbarButton +}) => { const { activedMcpServers } = useMCPServers() const { t } = useTranslation() const quickPanel = useQuickPanel() const navigate = useNavigate() + // Create form instance at the top level + const [form] = Form.useForm() const availableMCPs = activedMcpServers.filter((server) => enabledMCPs.some((s) => s.id === server.id)) @@ -56,6 +68,220 @@ const MCPToolsButton: FC = ({ ref, enabledMCPs, toggelEnableMCP, ToolbarB } }) }, [menuItems, quickPanel, t]) + // Extract and format all content from the prompt response + const extractPromptContent = useCallback((response: any): string | null => { + // Handle string response (backward compatibility) + if (typeof response === 'string') { + return response + } + + // Handle GetMCPPromptResponse format + if (response && Array.isArray(response.messages)) { + let formattedContent = '' + + for (const message of response.messages) { + if (!message.content) continue + + // Add role prefix if available + const rolePrefix = message.role ? `**${message.role.charAt(0).toUpperCase() + message.role.slice(1)}:** ` : '' + + // Process different content types + switch (message.content.type) { + case 'text': + // Add formatted text content with role + formattedContent += `${rolePrefix}${message.content.text}\n\n` + break + + case 'image': + // Format image as markdown with proper attribution + if (message.content.data && message.content.mimeType) { + const imageData = message.content.data + const mimeType = message.content.mimeType + // Include role if available + if (rolePrefix) { + formattedContent += `${rolePrefix}\n` + } + formattedContent += `![Image](data:${mimeType};base64,${imageData})\n\n` + } + break + + case 'audio': + // Add indicator for audio content with role + formattedContent += `${rolePrefix}[Audio content available]\n\n` + break + + case 'resource': + // Add indicator for resource content with role + if (message.content.text) { + formattedContent += `${rolePrefix}${message.content.text}\n\n` + } else { + formattedContent += `${rolePrefix}[Resource content available]\n\n` + } + break + + default: + // Add text content if available with role, otherwise show placeholder + if (message.content.text) { + formattedContent += `${rolePrefix}${message.content.text}\n\n` + } + } + } + + return formattedContent.trim() + } + + // Fallback handling for single message format + if (response && response.messages && response.messages.length > 0) { + const message = response.messages[0] + if (message.content && message.content.text) { + const rolePrefix = message.role ? `**${message.role.charAt(0).toUpperCase() + message.role.slice(1)}:** ` : '' + return `${rolePrefix}${message.content.text}` + } + } + + return null + }, []) + + // Helper function to insert prompt into text area + const insertPromptIntoTextArea = useCallback( + (promptText: string) => { + setInputValue((prev) => { + const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement + if (!textArea) return prev + promptText // Fallback if we can't find the textarea + + const cursorPosition = textArea.selectionStart + const selectionStart = cursorPosition + const selectionEndPosition = cursorPosition + promptText.length + const newText = prev.slice(0, cursorPosition) + promptText + prev.slice(cursorPosition) + + setTimeout(() => { + textArea.focus() + textArea.setSelectionRange(selectionStart, selectionEndPosition) + resizeTextArea() + }, 10) + return newText + }) + }, + [setInputValue, resizeTextArea] + ) + + const handlePromptSelect = useCallback( + (prompt: MCPPrompt) => { + setTimeout(async () => { + const server = enabledMCPs.find((s) => s.id === prompt.serverId) + if (server) { + try { + // Check if the prompt has arguments + if (prompt.arguments && prompt.arguments.length > 0) { + // Reset form when opening a new modal + form.resetFields() + + Modal.confirm({ + title: `${t('settings.mcp.prompts.arguments')}: ${prompt.name}`, + content: ( +
+ {prompt.arguments.map((arg, index) => ( + + + + ))} +
+ ), + onOk: async () => { + try { + // Validate and get form values + const values = await form.validateFields() + + const response = await window.api.mcp.getPrompt({ + server, + name: prompt.name, + args: values + }) + + // Extract and format prompt content from the response + const promptContent = extractPromptContent(response) + if (promptContent) { + insertPromptIntoTextArea(promptContent) + } else { + throw new Error('Invalid prompt response format') + } + + return Promise.resolve() + } catch (error: Error | any) { + if (error.errorFields) { + // This is a form validation error, handled by Ant Design + return Promise.reject(error) + } + + Modal.error({ + title: t('common.error'), + content: error.message || t('settings.mcp.prompts.genericError') + }) + return Promise.reject(error) + } + }, + okText: t('common.confirm'), + cancelText: t('common.cancel') + }) + } else { + // If no arguments, get the prompt directly + const response = await window.api.mcp.getPrompt({ + server, + name: prompt.name + }) + + // Extract and format prompt content from the response + const promptContent = extractPromptContent(response) + if (promptContent) { + insertPromptIntoTextArea(promptContent) + } else { + throw new Error('Invalid prompt response format') + } + } + } catch (error: Error | any) { + Modal.error({ + title: t('common.error'), + content: error.message || t('settings.mcp.prompt.genericError') + }) + } + } + }, 10) + }, + [enabledMCPs, form, t, extractPromptContent, insertPromptIntoTextArea] // Add form to dependencies + ) + + const promptList = useMemo(async () => { + const prompts: MCPPrompt[] = [] + + for (const server of enabledMCPs) { + const serverPrompts = await window.api.mcp.listPrompts(server) + prompts.push(...serverPrompts) + } + + return prompts.map((prompt) => ({ + label: prompt.name, + description: prompt.description, + icon: , + action: () => handlePromptSelect(prompt) + })) + }, [handlePromptSelect, enabledMCPs]) + + const openPromptList = useCallback(async () => { + const prompts = await promptList + quickPanel.open({ + title: t('settings.mcp.title'), + list: prompts, + symbol: 'mcp-prompt', + multiple: true + }) + }, [promptList, quickPanel, t]) const handleOpenQuickPanel = useCallback(() => { if (quickPanel.isVisible && quickPanel.symbol === 'mcp') { @@ -66,7 +292,8 @@ const MCPToolsButton: FC = ({ ref, enabledMCPs, toggelEnableMCP, ToolbarB }, [openQuickPanel, quickPanel]) useImperativeHandle(ref, () => ({ - openQuickPanel + openQuickPanel, + openPromptList })) if (activedMcpServers.length === 0) { diff --git a/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx b/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx index eb22ec5bfc..e0d6454fb4 100644 --- a/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx @@ -3,6 +3,7 @@ import { useShortcut, useShortcutDisplay } from '@renderer/hooks/useShortcuts' import { Tooltip } from 'antd' import { FC } from 'react' import { useTranslation } from 'react-i18next' +import styled from 'styled-components' interface Props { onNewContext: () => void @@ -16,12 +17,20 @@ const NewContextButton: FC = ({ onNewContext, ToolbarButton }) => { useShortcut('toggle_new_context', onNewContext) return ( - - - - - + + + + + + + ) } +const Container = styled.div` + @media (max-width: 800px) { + display: none; + } +` + export default NewContextButton diff --git a/src/renderer/src/pages/home/Messages/ChatNavigation.tsx b/src/renderer/src/pages/home/Messages/ChatNavigation.tsx index 825bab9480..14ec82c88d 100644 --- a/src/renderer/src/pages/home/Messages/ChatNavigation.tsx +++ b/src/renderer/src/pages/home/Messages/ChatNavigation.tsx @@ -255,15 +255,19 @@ const ChatNavigation: FC = ({ containerId }) => { if (now - lastMoveTime.current < 50) return lastMoveTime.current = now - const triggerWidth = 10 - let rightOffset = 5 + // Calculate if the mouse is in the trigger area + const triggerWidth = 80 // Same as the width in styled component + + // Safe way to calculate position when using calc expressions + let rightOffset = 16 // Default right offset if (showRightTopics) { - rightOffset = 5 + 300 + // When topics are shown on right, we need to account for topic list width + rightOffset = 16 + 300 // Assuming topic list width is 300px, adjust if different } const rightPosition = window.innerWidth - rightOffset - triggerWidth - const topPosition = window.innerHeight * 0.35 - const height = window.innerHeight * 0.3 + const topPosition = window.innerHeight * 0.3 // 30% from top + const height = window.innerHeight * 0.4 // 40% of window height const isInTriggerArea = e.clientX > rightPosition && @@ -403,32 +407,31 @@ const ButtonGroup = styled.div` display: flex; flex-direction: column; background: var(--bg-color); - border-radius: 12px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); overflow: hidden; - backdrop-filter: blur(12px); + backdrop-filter: blur(8px); border: 1px solid var(--color-border); ` const NavigationButton = styled(Button)` - width: 32px; - height: 32px; + width: 28px; + height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 0; border: none; color: var(--color-text); - transition: all 0.25s ease-in-out; + transition: all 0.2s ease-in-out; &:hover { background-color: var(--color-hover); color: var(--color-primary); - transform: scale(1.05); } .anticon { - font-size: 16px; + font-size: 14px; } ` diff --git a/src/renderer/src/pages/home/Messages/MessageTools.tsx b/src/renderer/src/pages/home/Messages/MessageTools.tsx index 59fc0269eb..dcc65c63bb 100644 --- a/src/renderer/src/pages/home/Messages/MessageTools.tsx +++ b/src/renderer/src/pages/home/Messages/MessageTools.tsx @@ -1,4 +1,4 @@ -import { CheckOutlined, ExpandOutlined, LoadingOutlined } from '@ant-design/icons' +import { CheckOutlined, ExpandOutlined, LoadingOutlined, WarningOutlined } from '@ant-design/icons' import { useSettings } from '@renderer/hooks/useSettings' import { Message } from '@renderer/types' import { Collapse, message as antdMessage, Modal, Tooltip } from 'antd' @@ -48,6 +48,7 @@ const MessageTools: FC = ({ message }) => { const { id, tool, status, response } = toolResponse const isInvoking = status === 'invoking' const isDone = status === 'done' + const hasError = isDone && response?.isError === true const result = { params: tool.inputSchema, response: toolResponse.response @@ -59,10 +60,15 @@ const MessageTools: FC = ({ message }) => { {tool.name} - - {isInvoking ? t('message.tools.invoking') : t('message.tools.completed')} + + {isInvoking + ? t('message.tools.invoking') + : hasError + ? t('message.tools.error') + : t('message.tools.completed')} {isInvoking && } - {isDone && } + {isDone && !hasError && } + {hasError && } @@ -195,8 +201,12 @@ const ToolName = styled.span` font-size: 13px; ` -const StatusIndicator = styled.span<{ $isInvoking: boolean }>` - color: ${(props) => (props.$isInvoking ? 'var(--color-primary)' : 'var(--color-success, #52c41a)')}; +const StatusIndicator = styled.span<{ $isInvoking: boolean; $hasError?: boolean }>` + color: ${(props) => { + if (props.$hasError) return 'var(--color-error, #ff4d4f)' + if (props.$isInvoking) return 'var(--color-primary)' + return 'var(--color-success, #52c41a)' + }}; font-size: 11px; display: flex; align-items: center; diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index 9bd50cacd9..074f38e10c 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -39,42 +39,6 @@ interface MessagesProps { setActiveTopic: (topic: Topic) => void } -const computeDisplayMessages = (messages: Message[], startIndex: number, displayCount: number) => { - const reversedMessages = [...messages].reverse() - - // 如果剩余消息数量小于 displayCount,直接返回所有剩余消息 - if (reversedMessages.length - startIndex <= displayCount) { - return reversedMessages.slice(startIndex) - } - - const userIdSet = new Set() // 用户消息 id 集合 - const assistantIdSet = new Set() // 助手消息 askId 集合 - const displayMessages: Message[] = [] - - // 处理单条消息的函数 - const processMessage = (message: Message) => { - if (!message) return - - const idSet = message.role === 'user' ? userIdSet : assistantIdSet - const messageId = message.role === 'user' ? message.id : message.askId - - if (!idSet.has(messageId)) { - idSet.add(messageId) - displayMessages.push(message) - return - } - // 如果是相同 askId 的助手消息,也要显示 - displayMessages.push(message) - } - - // 遍历消息直到满足显示数量要求 - for (let i = startIndex; i < reversedMessages.length && userIdSet.size + assistantIdSet.size < displayCount; i++) { - processMessage(reversedMessages[i]) - } - - return displayMessages -} - const Messages: React.FC = ({ assistant, topic, setActiveTopic }) => { const { t } = useTranslation() const { showTopics, topicPosition, showAssistants, messageNavigation } = useSettings() @@ -119,24 +83,36 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) } }, []) + const clearTopic = useCallback( + async (data: Topic) => { + const defaultTopic = getDefaultTopic(assistant.id) + + if (data && data.id !== topic.id) { + await clearTopicMessages(data.id) + updateTopic({ ...data, name: defaultTopic.name } as Topic) + return + } + + await clearTopicMessages() + + setDisplayMessages([]) + + const _topic = getTopic(assistant, topic.id) + _topic && updateTopic({ ..._topic, name: defaultTopic.name } as Topic) + }, + [assistant, clearTopicMessages, topic.id, updateTopic] + ) + useEffect(() => { const unsubscribes = [ EventEmitter.on(EVENT_NAMES.SEND_MESSAGE, scrollToBottom), EventEmitter.on(EVENT_NAMES.CLEAR_MESSAGES, async (data: Topic) => { - const defaultTopic = getDefaultTopic(assistant.id) - - if (data && data.id !== topic.id) { - await clearTopicMessages(data.id) - updateTopic({ ...data, name: defaultTopic.name } as Topic) - return - } - - await clearTopicMessages() - setDisplayMessages([]) - const _topic = getTopic(assistant, topic.id) - if (_topic) { - updateTopic({ ..._topic, name: defaultTopic.name } as Topic) - } + window.modal.confirm({ + title: t('chat.input.clear.title'), + content: t('chat.input.clear.content'), + centered: true, + onOk: () => clearTopic(data) + }) }), EventEmitter.on(EVENT_NAMES.COPY_TOPIC_IMAGE, async () => { await captureScrollableDivAsBlob(containerRef, async (blob) => { @@ -282,11 +258,43 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) ) } -interface LoaderProps { - $loading: boolean +const computeDisplayMessages = (messages: Message[], startIndex: number, displayCount: number) => { + const reversedMessages = [...messages].reverse() + + // 如果剩余消息数量小于 displayCount,直接返回所有剩余消息 + if (reversedMessages.length - startIndex <= displayCount) { + return reversedMessages.slice(startIndex) + } + + const userIdSet = new Set() // 用户消息 id 集合 + const assistantIdSet = new Set() // 助手消息 askId 集合 + const displayMessages: Message[] = [] + + // 处理单条消息的函数 + const processMessage = (message: Message) => { + if (!message) return + + const idSet = message.role === 'user' ? userIdSet : assistantIdSet + const messageId = message.role === 'user' ? message.id : message.askId + + if (!idSet.has(messageId)) { + idSet.add(messageId) + displayMessages.push(message) + return + } + // 如果是相同 askId 的助手消息,也要显示 + displayMessages.push(message) + } + + // 遍历消息直到满足显示数量要求 + for (let i = startIndex; i < reversedMessages.length && userIdSet.size + assistantIdSet.size < displayCount; i++) { + processMessage(reversedMessages[i]) + } + + return displayMessages } -const LoaderContainer = styled.div` +const LoaderContainer = styled.div<{ $loading: boolean }>` display: flex; justify-content: center; padding: 10px; @@ -300,6 +308,7 @@ const LoaderContainer = styled.div` const ScrollContainer = styled.div` display: flex; flex-direction: column-reverse; + margin-bottom: -20px; // 添加负的底部外边距来减少空间 ` interface ContainerProps { @@ -309,7 +318,7 @@ interface ContainerProps { const Container = styled(Scrollbar)` display: flex; flex-direction: column-reverse; - padding: 10px 0 20px; + padding: 10px 0 10px; overflow-x: hidden; background-color: var(--color-background); z-index: 1; diff --git a/src/renderer/src/pages/home/Messages/NewTopicButton.tsx b/src/renderer/src/pages/home/Messages/NewTopicButton.tsx index 60a3f5e535..350822053e 100644 --- a/src/renderer/src/pages/home/Messages/NewTopicButton.tsx +++ b/src/renderer/src/pages/home/Messages/NewTopicButton.tsx @@ -31,6 +31,8 @@ const Container = styled.div` align-items: center; margin-bottom: 10px; margin-top: -10px; + padding: 0; + min-height: auto; ` const Button = styled(AntdButton)<{ $theme: ThemeMode }>` diff --git a/src/renderer/src/pages/settings/MCPSettings/InstallNpxUv.tsx b/src/renderer/src/pages/settings/MCPSettings/InstallNpxUv.tsx index 86bc7c8b87..3de99712b6 100644 --- a/src/renderer/src/pages/settings/MCPSettings/InstallNpxUv.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/InstallNpxUv.tsx @@ -1,9 +1,9 @@ import { CheckCircleOutlined, QuestionCircleOutlined, WarningOutlined } from '@ant-design/icons' import { Center, VStack } from '@renderer/components/Layout' -import { EventEmitter } from '@renderer/services/EventService' import { Alert, Button } from 'antd' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router' import styled from 'styled-components' import { SettingDescription, SettingRow, SettingSubtitle } from '..' @@ -21,6 +21,7 @@ const InstallNpxUv: FC = ({ mini = false }) => { const [bunPath, setBunPath] = useState(null) const [binariesDir, setBinariesDir] = useState(null) const { t } = useTranslation() + const navigate = useNavigate() const checkBinaries = async () => { const uvExists = await window.api.isBinaryExist('uv') @@ -78,7 +79,7 @@ const InstallNpxUv: FC = ({ mini = false }) => { icon={installed ? : } className="nodrag" color={installed ? 'green' : 'danger'} - onClick={() => EventEmitter.emit('mcp:mcp-install')} + onClick={() => navigate('/settings/mcp/mcp-install')} /> ) } diff --git a/src/renderer/src/pages/settings/MCPSettings/McpPrompt.tsx b/src/renderer/src/pages/settings/MCPSettings/McpPrompt.tsx new file mode 100644 index 0000000000..641c8c4145 --- /dev/null +++ b/src/renderer/src/pages/settings/MCPSettings/McpPrompt.tsx @@ -0,0 +1,96 @@ +import { MCPPrompt } from '@renderer/types' +import { Collapse, Descriptions, Empty, Flex, Tag, Tooltip, Typography } from 'antd' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface MCPPromptsSectionProps { + prompts: MCPPrompt[] +} + +const MCPPromptsSection = ({ prompts }: MCPPromptsSectionProps) => { + const { t } = useTranslation() + + // Render prompt arguments + const renderPromptArguments = (prompt: MCPPrompt) => { + if (!prompt.arguments || prompt.arguments.length === 0) return null + + return ( +
+ {t('settings.mcp.tools.inputSchema')}: + + {prompt.arguments.map((arg, index) => ( + + {arg.name} + {arg.required && ( + + Required + + )} + + }> + + {arg.description && ( + + {arg.description} + + )} + + + ))} + +
+ ) + } + + return ( +
+ {t('settings.mcp.prompts.availablePrompts')} + {prompts.length > 0 ? ( + + {prompts.map((prompt) => ( + + + {prompt.name} + + {prompt.description && ( + + {prompt.description} + + )} + + }> + {renderPromptArguments(prompt)} + + ))} + + ) : ( + + )} +
+ ) +} + +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 MCPPromptsSection diff --git a/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx b/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx index 8bc1b562c5..499e7484aa 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx @@ -1,13 +1,15 @@ import { DeleteOutlined, SaveOutlined } from '@ant-design/icons' import { useMCPServers } from '@renderer/hooks/useMCPServers' -import { MCPServer, MCPTool } from '@renderer/types' -import { Button, Flex, Form, Input, Radio, Switch } from 'antd' +import { MCPPrompt, 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' import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router' import styled from 'styled-components' import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '..' +import MCPPromptsSection from './McpPrompt' import MCPToolsSection from './McpTool' interface Props { @@ -40,6 +42,8 @@ const PipRegistry: Registry[] = [ { name: '腾讯云', url: 'https://mirrors.cloud.tencent.com/pypi/simple/' } ] +type TabKey = 'settings' | 'tools' | 'prompts' + const McpSettings: React.FC = ({ server }) => { const { t } = useTranslation() const { deleteMCPServer, updateMCPServer } = useMCPServers() @@ -48,11 +52,15 @@ const McpSettings: React.FC = ({ server }) => { const [loading, setLoading] = useState(false) const [isFormChanged, setIsFormChanged] = useState(false) const [loadingServer, setLoadingServer] = useState(null) + const [activeTab, setActiveTab] = useState('settings') const [tools, setTools] = useState([]) + const [prompts, setPrompts] = useState([]) const [isShowRegistry, setIsShowRegistry] = useState(false) const [registry, setRegistry] = useState() + const navigate = useNavigate() + useEffect(() => { const serverType: MCPServer['type'] = server.type || (server.baseUrl ? 'sse' : 'stdio') setServerType(serverType) @@ -109,10 +117,9 @@ const McpSettings: React.FC = ({ server }) => { setLoadingServer(server.id) const localTools = await window.api.mcp.listTools(server) setTools(localTools) - // window.message.success(t('settings.mcp.toolsLoaded')) } catch (error) { window.message.error({ - content: t('settings.mcp.toolsLoadError') + formatError(error), + content: t('settings.mcp.tools.loadError') + ' ' + formatError(error), key: 'mcp-tools-error' }) } finally { @@ -121,9 +128,28 @@ const McpSettings: React.FC = ({ server }) => { } } + const fetchPrompts = async () => { + if (server.isActive) { + try { + setLoadingServer(server.id) + const localPrompts = await window.api.mcp.listPrompts(server) + setPrompts(localPrompts) + } catch (error) { + window.message.error({ + content: t('settings.mcp.prompts.loadError') + ' ' + formatError(error), + key: 'mcp-prompts-error' + }) + setPrompts([]) + } finally { + setLoadingServer(null) + } + } + } + useEffect(() => { if (server.isActive) { fetchTools() + fetchPrompts() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [server.id, server.isActive]) @@ -234,6 +260,7 @@ const McpSettings: React.FC = ({ server }) => { await window.api.mcp.removeServer(server) deleteMCPServer(server.id) window.message.success({ content: t('settings.mcp.deleteSuccess'), key: 'mcp-list' }) + navigate('/settings/mcp') } }) } catch (error: any) { @@ -264,6 +291,9 @@ const McpSettings: React.FC = ({ server }) => { if (active) { const localTools = await window.api.mcp.listTools(server) setTools(localTools) + + const localPrompts = await window.api.mcp.listPrompts(server) + setPrompts(localPrompts) } else { await window.api.mcp.stopServer(server) } @@ -309,35 +339,16 @@ const McpSettings: React.FC = ({ server }) => { [server, updateMCPServer] ) - return ( - - - - - {server?.name} - {!(server.type === 'inMemory') && ( - - - - + const tabs = [ + { + key: 'settings', + label: t('settings.mcp.tabs.general'), + children: (
setIsFormChanged(true)} style={{ - // height: 'calc(100vh - var(--navbar-height) - 315px)', overflowY: 'auto', width: 'calc(100% + 10px)', paddingRight: '10px' @@ -440,7 +451,58 @@ const McpSettings: React.FC = ({ server }) => { )} - {server.isActive && } + ) + } + ] + + if (server.isActive) { + tabs.push( + { + key: 'tools', + label: t('settings.mcp.tabs.tools'), + children: + }, + { + key: 'prompts', + label: t('settings.mcp.tabs.prompts'), + children: + } + ) + } + + return ( + + + + + {server?.name} + + + + + + setActiveTab(key as TabKey)} + style={{ marginTop: 8 }} + /> ) diff --git a/src/renderer/src/pages/settings/MCPSettings/McpSettingsNavbar.tsx b/src/renderer/src/pages/settings/MCPSettings/McpSettingsNavbar.tsx index 8ff50a6573..44c8f70c67 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpSettingsNavbar.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpSettingsNavbar.tsx @@ -2,15 +2,16 @@ import { EditOutlined, ExportOutlined, SearchOutlined } from '@ant-design/icons' import { NavbarRight } from '@renderer/components/app/Navbar' import { HStack } from '@renderer/components/Layout' import { isWindows } from '@renderer/config/constant' -import { EventEmitter } from '@renderer/services/EventService' import { Button } from 'antd' import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router' import EditMcpJsonPopup from './EditMcpJsonPopup' import InstallNpxUv from './InstallNpxUv' export const McpSettingsNavbar = () => { const { t } = useTranslation() + const navigate = useNavigate() const onClick = () => window.open('https://mcp.so/', '_blank') return ( @@ -19,7 +20,7 @@ export const McpSettingsNavbar = () => { - + {npmScopes.map((scope) => ( { setNpmScope(scope) handleNpmSearch(scope) }} - style={{ cursor: searchLoading ? 'not-allowed' : 'pointer' }}> + style={{ + cursor: searchLoading ? 'not-allowed' : 'pointer', + borderRadius: 100, + backgroundColor: 'var(--color-background-mute)' + }}> {scope} ))} -
- - - {searchLoading ? ( -
- -
- ) : ( - searchResults?.map((record) => ( - - {record.name} - - } - extra={ - - - v{record.version} - -