✨ feat: add Model Context Protocol (MCP) support (#2809)
* ✨ feat: add Model Context Protocol (MCP) server configuration (main) - Added `@modelcontextprotocol/sdk` dependency for MCP integration. - Introduced MCP server configuration UI in settings with add, edit, delete, and activation functionalities. - Created `useMCPServers` hook to manage MCP server state and actions. - Added i18n support for MCP settings with translation keys. - Integrated MCP settings into the application's settings navigation and routing. - Implemented Redux state management for MCP servers. - Updated `yarn.lock` with new dependencies and their resolutions. * 🌟 feat: implement mcp service and integrate with ipc handlers - Added `MCPService` class to manage Model Context Protocol servers. - Implemented various handlers in `ipc.ts` for managing MCP servers including listing, adding, updating, deleting, and activating/deactivating servers. - Integrated MCP related types into existing type declarations for consistency across the application. - Updated `preload` to expose new MCP related APIs to the renderer process. - Enhanced `MCPSettings` component to interact directly with the new MCP service for adding, updating, deleting servers and setting their active states. - Introduced selectors in the MCP Redux slice for fetching active and all servers from the store. - Moved MCP types to a centralized location in `@renderer/types` for reuse across different parts of the application. * feat: enhance MCPService initialization to prevent recursive calls and improve error handling * feat: enhance MCP integration by adding MCPTool type and updating related methods * feat: implement streaming support for tool calls in OpenAIProvider and enhance message processing
This commit is contained in:
+44
-3
@@ -1,7 +1,7 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { Shortcut, ThemeMode } from '@types'
|
||||
import { MCPServer, Shortcut, ThemeMode } from '@types'
|
||||
import { BrowserWindow, ipcMain, ProxyConfig, session, shell } from 'electron'
|
||||
import log from 'electron-log'
|
||||
|
||||
@@ -14,17 +14,18 @@ import FileService from './services/FileService'
|
||||
import FileStorage from './services/FileStorage'
|
||||
import { GeminiService } from './services/GeminiService'
|
||||
import KnowledgeService from './services/KnowledgeService'
|
||||
import MCPService from './services/mcp'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import { getResourcePath } from './utils'
|
||||
import { decrypt } from './utils/aes'
|
||||
import { encrypt } from './utils/aes'
|
||||
import { decrypt, encrypt } from './utils/aes'
|
||||
import { compress, decompress } from './utils/zip'
|
||||
|
||||
const fileManager = new FileStorage()
|
||||
const backupManager = new BackupManager()
|
||||
const exportService = new ExportService(fileManager)
|
||||
const mcpService = new MCPService()
|
||||
|
||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
const appUpdater = new AppUpdater(mainWindow)
|
||||
@@ -210,4 +211,44 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle('aes:decrypt', (_, encryptedData: string, iv: string, secretKey: string) =>
|
||||
decrypt(encryptedData, iv, secretKey)
|
||||
)
|
||||
|
||||
// Register MCP handlers
|
||||
ipcMain.handle('mcp:list-servers', async () => {
|
||||
return mcpService.listAvailableServices()
|
||||
})
|
||||
|
||||
ipcMain.handle('mcp:add-server', async (_, server: MCPServer) => {
|
||||
return mcpService.addServer(server)
|
||||
})
|
||||
|
||||
ipcMain.handle('mcp:update-server', async (_, server: MCPServer) => {
|
||||
return mcpService.updateServer(server)
|
||||
})
|
||||
|
||||
ipcMain.handle('mcp:delete-server', async (_, serverName: string) => {
|
||||
return mcpService.deleteServer(serverName)
|
||||
})
|
||||
|
||||
ipcMain.handle('mcp:set-server-active', async (_, { name, isActive }) => {
|
||||
return mcpService.setServerActive({ name, isActive })
|
||||
})
|
||||
|
||||
// According to preload, this should take no parameters, but our implementation accepts
|
||||
// an optional serverName for better flexibility
|
||||
ipcMain.handle('mcp:list-tools', async (_, serverName?: string) => {
|
||||
return mcpService.listTools(serverName)
|
||||
})
|
||||
|
||||
ipcMain.handle('mcp:call-tool', async (_, params: { client: string; name: string; args: any }) => {
|
||||
return mcpService.callTool(params)
|
||||
})
|
||||
|
||||
ipcMain.handle('mcp:cleanup', async () => {
|
||||
return mcpService.cleanup()
|
||||
})
|
||||
|
||||
// Clean up MCP services when app quits
|
||||
app.on('before-quit', async () => {
|
||||
await mcpService.cleanup()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import { MCPServer, MCPTool } from '@types'
|
||||
import log from 'electron-log'
|
||||
import Store from 'electron-store'
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
const store = new Store()
|
||||
|
||||
export default class MCPService extends EventEmitter {
|
||||
private activeServers: Map<string, any> = new Map()
|
||||
private clients: { [key: string]: any } = {}
|
||||
private Client: any
|
||||
private Transport: any
|
||||
private initialized = false
|
||||
private initPromise: Promise<void> | null = null
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.init().catch((err) => {
|
||||
log.error('[MCP] Failed to initialize MCP service:', err)
|
||||
})
|
||||
}
|
||||
private getServersFromStore(): MCPServer[] {
|
||||
return store.get('mcp.servers', []) as MCPServer[]
|
||||
}
|
||||
|
||||
public async init() {
|
||||
// If already initialized, return immediately
|
||||
if (this.initialized) return
|
||||
|
||||
// If initialization is in progress, return that promise
|
||||
if (this.initPromise) return this.initPromise
|
||||
|
||||
// Create and store the initialization promise
|
||||
this.initPromise = (async () => {
|
||||
try {
|
||||
log.info('[MCP] Starting initialization')
|
||||
this.Client = await this.importClient()
|
||||
this.Transport = await this.importTransport()
|
||||
|
||||
// Mark as initialized before loading servers to prevent recursive initialization
|
||||
this.initialized = true
|
||||
|
||||
await this.load(this.getServersFromStore())
|
||||
log.info('[MCP] Initialization completed successfully')
|
||||
} catch (err) {
|
||||
this.initialized = false // Reset flag on error
|
||||
log.error('[MCP] Failed to initialize:', err)
|
||||
throw err
|
||||
} finally {
|
||||
this.initPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
private async importClient() {
|
||||
try {
|
||||
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js')
|
||||
return Client
|
||||
} catch (err) {
|
||||
log.error('[MCP] Failed to import Client:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
private async importTransport() {
|
||||
try {
|
||||
const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js')
|
||||
return StdioClientTransport
|
||||
} catch (err) {
|
||||
log.error('[MCP] Failed to import Transport:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
public async listAvailableServices(): Promise<MCPServer[]> {
|
||||
await this.ensureInitialized()
|
||||
return this.getServersFromStore()
|
||||
}
|
||||
|
||||
private async ensureInitialized() {
|
||||
if (!this.initialized) {
|
||||
log.debug('[MCP] Ensuring initialization')
|
||||
await this.init()
|
||||
}
|
||||
}
|
||||
|
||||
public async addServer(server: MCPServer): Promise<void> {
|
||||
await this.ensureInitialized()
|
||||
try {
|
||||
const servers = this.getServersFromStore()
|
||||
if (servers.some((s) => s.name === server.name)) {
|
||||
throw new Error(`Server with name ${server.name} already exists`)
|
||||
}
|
||||
|
||||
servers.push(server)
|
||||
store.set('mcp.servers', servers)
|
||||
|
||||
if (server.isActive) {
|
||||
await this.activate(server)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to add MCP server:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public async updateServer(server: MCPServer): Promise<void> {
|
||||
await this.ensureInitialized()
|
||||
try {
|
||||
const servers = this.getServersFromStore()
|
||||
const index = servers.findIndex((s) => s.name === server.name)
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error(`Server ${server.name} not found`)
|
||||
}
|
||||
|
||||
const wasActive = servers[index].isActive
|
||||
if (wasActive && !server.isActive) {
|
||||
await this.deactivate(server.name)
|
||||
} else if (!wasActive && server.isActive) {
|
||||
await this.activate(server)
|
||||
}
|
||||
|
||||
servers[index] = server
|
||||
store.set('mcp.servers', servers)
|
||||
} catch (error) {
|
||||
log.error('Failed to update MCP server:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteServer(serverName: string): Promise<void> {
|
||||
await this.ensureInitialized()
|
||||
try {
|
||||
if (this.clients[serverName]) {
|
||||
await this.deactivate(serverName)
|
||||
}
|
||||
|
||||
const servers = this.getServersFromStore()
|
||||
const filteredServers = servers.filter((s) => s.name !== serverName)
|
||||
store.set('mcp.servers', filteredServers)
|
||||
} catch (error) {
|
||||
log.error('Failed to delete MCP server:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public async setServerActive(params: { name: string; isActive: boolean }): Promise<void> {
|
||||
await this.ensureInitialized()
|
||||
try {
|
||||
const { name, isActive } = params
|
||||
const servers = this.getServersFromStore()
|
||||
const server = servers.find((s) => s.name === name)
|
||||
|
||||
if (!server) {
|
||||
throw new Error(`Server ${name} not found`)
|
||||
}
|
||||
|
||||
server.isActive = isActive
|
||||
store.set('mcp.servers', servers)
|
||||
|
||||
if (isActive) {
|
||||
await this.activate(server)
|
||||
} else {
|
||||
await this.deactivate(name)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to set MCP server active status:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public async activate(server: MCPServer): Promise<void> {
|
||||
await this.ensureInitialized()
|
||||
try {
|
||||
const { name, command, args, env } = server
|
||||
|
||||
if (this.clients[name]) {
|
||||
log.info(`[MCP] Server ${name} is already running`)
|
||||
return
|
||||
}
|
||||
|
||||
let cmd: string = command
|
||||
if (command === 'npx') {
|
||||
cmd = process.platform === 'win32' ? `${command}.cmd` : command
|
||||
}
|
||||
|
||||
const mergedEnv = {
|
||||
...env,
|
||||
PATH: process.env.PATH
|
||||
}
|
||||
|
||||
const client = new this.Client(
|
||||
{
|
||||
name: name,
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {}
|
||||
}
|
||||
)
|
||||
|
||||
const transport = new this.Transport({
|
||||
command: cmd,
|
||||
args,
|
||||
stderr: process.platform === 'win32' ? 'pipe' : 'inherit',
|
||||
env: mergedEnv
|
||||
})
|
||||
|
||||
await client.connect(transport)
|
||||
this.clients[name] = client
|
||||
this.activeServers.set(name, { client, server })
|
||||
|
||||
log.info(`[MCP] Server ${name} started successfully`)
|
||||
this.emit('server-started', { name })
|
||||
} catch (error) {
|
||||
log.error('[MCP] Error activating server:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public async deactivate(name: string): Promise<void> {
|
||||
await this.ensureInitialized()
|
||||
try {
|
||||
if (this.clients[name]) {
|
||||
log.info(`[MCP] Stopping server: ${name}`)
|
||||
await this.clients[name].close()
|
||||
delete this.clients[name]
|
||||
this.activeServers.delete(name)
|
||||
this.emit('server-stopped', { name })
|
||||
} else {
|
||||
log.warn(`[MCP] Server ${name} is not running`)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('[MCP] Error deactivating server:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public async listTools(serverName?: string): Promise<MCPTool[]> {
|
||||
await this.ensureInitialized()
|
||||
try {
|
||||
if (serverName) {
|
||||
if (!this.clients[serverName]) {
|
||||
throw new Error(`MCP Client ${serverName} not found`)
|
||||
}
|
||||
const { tools } = await this.clients[serverName].listTools()
|
||||
return tools.map((tool: any) => {
|
||||
return tool
|
||||
})
|
||||
} else {
|
||||
let allTools: MCPTool[] = []
|
||||
for (const clientName in this.clients) {
|
||||
try {
|
||||
const { tools } = await this.clients[clientName].listTools()
|
||||
log.info(`[MCP] Tools for ${clientName}:`, tools)
|
||||
allTools = allTools.concat(
|
||||
tools.map((tool: MCPTool) => {
|
||||
tool.serverName = clientName
|
||||
return tool
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
log.error(`[MCP] Error listing tools for ${clientName}:`, error)
|
||||
}
|
||||
}
|
||||
log.info(`[MCP] Total tools listed: ${allTools.length}`)
|
||||
return allTools
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('[MCP] Error listing tools:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
public async callTool(params: { client: string; name: string; args: any }): Promise<any> {
|
||||
await this.ensureInitialized()
|
||||
try {
|
||||
const { client, name, args } = params
|
||||
if (!this.clients[client]) {
|
||||
throw new Error(`MCP Client ${client} not found`)
|
||||
}
|
||||
|
||||
log.info('[MCP] Calling:', client, name, args)
|
||||
const result = await this.clients[client].callTool({
|
||||
name,
|
||||
arguments: args
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
log.error(`[MCP] Error calling tool ${params.name} on ${params.client}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public async cleanup(): Promise<void> {
|
||||
try {
|
||||
for (const name in this.clients) {
|
||||
await this.deactivate(name).catch((err) => {
|
||||
log.error(`[MCP] Error during cleanup of ${name}:`, err)
|
||||
})
|
||||
}
|
||||
this.clients = {}
|
||||
this.activeServers.clear()
|
||||
log.info('[MCP] All servers cleaned up')
|
||||
} catch (error) {
|
||||
log.error('[MCP] Failed to clean up servers:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public async load(servers: MCPServer[]): Promise<void> {
|
||||
log.info(`[MCP] Loading ${servers.length} servers`)
|
||||
|
||||
const activeServers = servers.filter((server) => server.isActive)
|
||||
|
||||
if (activeServers.length === 0) {
|
||||
log.info('[MCP] No active servers to load')
|
||||
return
|
||||
}
|
||||
|
||||
for (const server of activeServers) {
|
||||
log.info(`[MCP] Activating server: ${server.name}`)
|
||||
try {
|
||||
await this.activate(server)
|
||||
log.info(`[MCP] Successfully activated server: ${server.name}`)
|
||||
} catch (error) {
|
||||
log.error(`[MCP] Failed to activate server ${server.name}:`, error)
|
||||
this.emit('server-error', { name: server.name, error })
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`[MCP] Loaded and activated ${Object.keys(this.clients).length} servers`)
|
||||
}
|
||||
}
|
||||
Vendored
+14
-3
@@ -1,9 +1,7 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
import type { FileMetadataResponse, ListFilesResponse, UploadFileResponse } from '@google/generative-ai/server'
|
||||
import { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
|
||||
import { FileType } from '@renderer/types'
|
||||
import { WebDavConfig } from '@renderer/types'
|
||||
import { AppInfo, KnowledgeBaseParams, KnowledgeItem, LanguageVarious } from '@renderer/types'
|
||||
import { AppInfo, FileType, KnowledgeBaseParams, KnowledgeItem, LanguageVarious, WebDavConfig } from '@renderer/types'
|
||||
import type { LoaderReturn } from '@shared/config/types'
|
||||
import type { OpenDialogOptions } from 'electron'
|
||||
import type { UpdateInfo } from 'electron-updater'
|
||||
@@ -123,6 +121,19 @@ declare global {
|
||||
shell: {
|
||||
openExternal: (url: string, options?: OpenExternalOptions) => Promise<void>
|
||||
}
|
||||
mcp: {
|
||||
// servers
|
||||
listServers: () => Promise<MCPServer[]>
|
||||
addServer: (server: MCPServer) => Promise<void>
|
||||
updateServer: (server: MCPServer) => Promise<void>
|
||||
deleteServer: (serverName: string) => Promise<void>
|
||||
setServerActive: (name: string, isActive: boolean) => Promise<void>
|
||||
// tools
|
||||
listTools: () => Promise<MCPTool>
|
||||
callTool: ({ client, name, args }: { client: string; name: string; args: any }) => Promise<any>
|
||||
// status
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -1,5 +1,5 @@
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem, Shortcut, WebDavConfig } from '@types'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, WebDavConfig } from '@types'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions, shell } from 'electron'
|
||||
|
||||
// Custom APIs for renderer
|
||||
@@ -106,6 +106,17 @@ const api = {
|
||||
decrypt: (encryptedData: string, iv: string, secretKey: string) =>
|
||||
ipcRenderer.invoke('aes:decrypt', encryptedData, iv, secretKey)
|
||||
},
|
||||
mcp: {
|
||||
listServers: () => ipcRenderer.invoke('mcp:list-servers'),
|
||||
addServer: (server: MCPServer) => ipcRenderer.invoke('mcp:add-server', server),
|
||||
updateServer: (server: MCPServer) => ipcRenderer.invoke('mcp:update-server', server),
|
||||
deleteServer: (serverName: string) => ipcRenderer.invoke('mcp:delete-server', serverName),
|
||||
setServerActive: (name: string, isActive: boolean) =>
|
||||
ipcRenderer.invoke('mcp:set-server-active', { name, isActive }),
|
||||
listTools: (serverName?: string) => ipcRenderer.invoke('mcp:list-tools', serverName),
|
||||
callTool: (params: { client: string; name: string; args: any }) => ipcRenderer.invoke('mcp:call-tool', params),
|
||||
cleanup: () => ipcRenderer.invoke('mcp:cleanup')
|
||||
},
|
||||
shell: {
|
||||
openExternal: shell.openExternal
|
||||
}
|
||||
|
||||
@@ -19,8 +19,7 @@ import {
|
||||
updateItemProcessingStatus,
|
||||
updateNotes
|
||||
} from '@renderer/store/knowledge'
|
||||
import { FileType, KnowledgeBase, ProcessingStatus } from '@renderer/types'
|
||||
import { KnowledgeItem } from '@renderer/types'
|
||||
import { FileType, KnowledgeBase, KnowledgeItem, ProcessingStatus } from '@renderer/types'
|
||||
import { runAsyncFunction } from '@renderer/utils'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import {
|
||||
addMCPServer as _addMCPServer,
|
||||
deleteMCPServer as _deleteMCPServer,
|
||||
setMCPServerActive as _setMCPServerActive,
|
||||
updateMCPServer as _updateMCPServer
|
||||
} from '@renderer/store/mcp'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
|
||||
export const useMCPServers = () => {
|
||||
const mcpServers = useAppSelector((state) => state.mcp.servers)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const addMCPServer = (server: MCPServer) => {
|
||||
dispatch(_addMCPServer(server))
|
||||
}
|
||||
|
||||
const updateMCPServer = (server: MCPServer) => {
|
||||
dispatch(_updateMCPServer(server))
|
||||
}
|
||||
|
||||
const deleteMCPServer = (name: string) => {
|
||||
dispatch(_deleteMCPServer(name))
|
||||
}
|
||||
|
||||
const setMCPServerActive = (name: string, isActive: boolean) => {
|
||||
dispatch(_setMCPServerActive({ name, isActive }))
|
||||
}
|
||||
|
||||
const getActiveMCPServers = () => {
|
||||
return mcpServers.filter((server) => server.isActive)
|
||||
}
|
||||
|
||||
return {
|
||||
mcpServers,
|
||||
addMCPServer,
|
||||
updateMCPServer,
|
||||
deleteMCPServer,
|
||||
setMCPServerActive,
|
||||
getActiveMCPServers
|
||||
}
|
||||
}
|
||||
@@ -859,6 +859,32 @@
|
||||
"blacklist_tooltip": "Please use the following format (separated by line breaks)\nexample.com\nhttps://www.example.com\nhttps://example.com\n*://*.example.com",
|
||||
"search_max_result": "Number of search results",
|
||||
"search_result_default": "Default"
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP Servers",
|
||||
"config_description": "Configure Model Context Protocol servers",
|
||||
"description": "Description",
|
||||
"addServer": "Add Server",
|
||||
"editServer": "Edit Server",
|
||||
"name": "Name",
|
||||
"command": "Command",
|
||||
"args": "Arguments",
|
||||
"argsTooltip": "Each argument on a new line",
|
||||
"env": "Environment Variables",
|
||||
"envTooltip": "Format: KEY=value, one per line",
|
||||
"active": "Active",
|
||||
"actions": "Actions",
|
||||
"noServers": "No servers configured",
|
||||
"nameRequired": "Please enter a server name",
|
||||
"commandRequired": "Please enter a command",
|
||||
"confirmDelete": "Delete Server",
|
||||
"confirmDeleteMessage": "Are you sure you want to delete the server?",
|
||||
"addSuccess": "Server added successfully",
|
||||
"updateSuccess": "Server updated successfully",
|
||||
"deleteSuccess": "Server deleted successfully",
|
||||
"duplicateName": "A server with this name already exists",
|
||||
"serverSingular": "server",
|
||||
"serverPlural": "servers"
|
||||
}
|
||||
},
|
||||
"translate": {
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { addMCPServer, deleteMCPServer, setMCPServerActive, updateMCPServer } from '@renderer/store/mcp'
|
||||
import { MCPServer } from '@renderer/types'
|
||||
import { Button, Card, Form, Input, message, Modal, Space, Switch, Table, Tooltip, Typography } from 'antd'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '.'
|
||||
|
||||
interface MCPFormValues {
|
||||
name: string
|
||||
command: string
|
||||
description?: string
|
||||
args: string
|
||||
env?: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const MCPSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const { Paragraph, Text } = Typography
|
||||
const dispatch = useAppDispatch()
|
||||
const mcpServers = useAppSelector((state) => state.mcp.servers)
|
||||
|
||||
const [isModalVisible, setIsModalVisible] = useState(false)
|
||||
const [editingServer, setEditingServer] = useState<MCPServer | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [form] = Form.useForm<MCPFormValues>()
|
||||
|
||||
const showAddModal = () => {
|
||||
form.resetFields()
|
||||
setEditingServer(null)
|
||||
setIsModalVisible(true)
|
||||
}
|
||||
|
||||
const showEditModal = (server: MCPServer) => {
|
||||
setEditingServer(server)
|
||||
form.setFieldsValue({
|
||||
name: server.name,
|
||||
command: server.command,
|
||||
description: server.description,
|
||||
args: server.args.join('\n'),
|
||||
env: server.env
|
||||
? Object.entries(server.env)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')
|
||||
: '',
|
||||
isActive: server.isActive
|
||||
})
|
||||
setIsModalVisible(true)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsModalVisible(false)
|
||||
form.resetFields()
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
setLoading(true)
|
||||
form
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
const args = values.args ? values.args.split('\n').filter((arg) => arg.trim() !== '') : []
|
||||
|
||||
const env: Record<string, string> = {}
|
||||
if (values.env) {
|
||||
values.env.split('\n').forEach((line) => {
|
||||
if (line.trim()) {
|
||||
const [key, value] = line.split('=')
|
||||
if (key && value) {
|
||||
env[key.trim()] = value.trim()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const mcpServer: MCPServer = {
|
||||
name: values.name,
|
||||
command: values.command,
|
||||
description: values.description,
|
||||
args,
|
||||
env: Object.keys(env).length > 0 ? env : undefined,
|
||||
isActive: values.isActive
|
||||
}
|
||||
|
||||
if (editingServer) {
|
||||
window.api.mcp
|
||||
.updateServer(mcpServer)
|
||||
.then(() => {
|
||||
message.success(t('settings.mcp.updateSuccess'))
|
||||
setLoading(false)
|
||||
setIsModalVisible(false)
|
||||
form.resetFields()
|
||||
})
|
||||
.catch((error) => {
|
||||
message.error(`${t('settings.mcp.updateError')}: ${error.message}`)
|
||||
setLoading(false)
|
||||
})
|
||||
dispatch(updateMCPServer(mcpServer))
|
||||
} else {
|
||||
// Check for duplicate name
|
||||
if (mcpServers.some((server: MCPServer) => server.name === mcpServer.name)) {
|
||||
message.error(t('settings.mcp.duplicateName'))
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
window.api.mcp
|
||||
.addServer(mcpServer)
|
||||
.then(() => {
|
||||
message.success(t('settings.mcp.addSuccess'))
|
||||
setLoading(false)
|
||||
setIsModalVisible(false)
|
||||
form.resetFields()
|
||||
})
|
||||
.catch((error) => {
|
||||
message.error(`${t('settings.mcp.addError')}: ${error.message}`)
|
||||
setLoading(false)
|
||||
})
|
||||
dispatch(addMCPServer(mcpServer))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = (serverName: string) => {
|
||||
Modal.confirm({
|
||||
title: t('settings.mcp.confirmDelete'),
|
||||
content: t('settings.mcp.confirmDeleteMessage'),
|
||||
okText: t('common.delete'),
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: t('common.cancel'),
|
||||
onOk: () => {
|
||||
window.api.mcp
|
||||
.deleteServer(serverName)
|
||||
.then(() => {
|
||||
message.success(t('settings.mcp.deleteSuccess'))
|
||||
})
|
||||
.catch((error) => {
|
||||
message.error(`${t('settings.mcp.deleteError')}: ${error.message}`)
|
||||
})
|
||||
dispatch(deleteMCPServer(serverName))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggleActive = (name: string, isActive: boolean) => {
|
||||
window.api.mcp
|
||||
.setServerActive(name, isActive)
|
||||
.then(() => {
|
||||
// Optional: Show success message or update UI
|
||||
})
|
||||
.catch((error) => {
|
||||
message.error(`${t('settings.mcp.toggleError')}: ${error.message}`)
|
||||
})
|
||||
dispatch(setMCPServerActive({ name, isActive }))
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('settings.mcp.name'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: '20%',
|
||||
render: (text: string, record: MCPServer) => <Text strong={record.isActive}>{text}</Text>
|
||||
},
|
||||
{
|
||||
title: t('settings.mcp.description'),
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
width: '40%',
|
||||
render: (text: string) =>
|
||||
text || (
|
||||
<Text type="secondary" italic>
|
||||
{t('common.description')}
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('settings.mcp.active'),
|
||||
dataIndex: 'isActive',
|
||||
key: 'isActive',
|
||||
width: '15%',
|
||||
render: (isActive: boolean, record: MCPServer) => (
|
||||
<Switch checked={isActive} onChange={(checked) => handleToggleActive(record.name, checked)} />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('settings.mcp.actions'),
|
||||
key: 'actions',
|
||||
width: '25%',
|
||||
render: (_: any, record: MCPServer) => (
|
||||
<Space>
|
||||
<Tooltip title={t('common.edit')}>
|
||||
<Button type="primary" ghost icon={<EditOutlined />} onClick={() => showEditModal(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.delete')}>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.name)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
// Create a CSS class for inactive rows instead of using jsx global
|
||||
const inactiveRowStyle = {
|
||||
opacity: 0.7,
|
||||
backgroundColor: theme === 'dark' ? '#1a1a1a' : '#f5f5f5'
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingContainer theme={theme}>
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>
|
||||
{t('settings.mcp.title')}
|
||||
<Tooltip title={t('settings.mcp.config_description')}>
|
||||
<QuestionCircleOutlined style={{ marginLeft: 8, fontSize: 14 }} />
|
||||
</Tooltip>
|
||||
</SettingTitle>
|
||||
<SettingDivider />
|
||||
<Paragraph type="secondary" style={{ margin: '0 0 20px 0' }}>
|
||||
{t('settings.mcp.config_description')}
|
||||
</Paragraph>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={showAddModal}>
|
||||
{t('settings.mcp.addServer')}
|
||||
</Button>
|
||||
<Text type="secondary">
|
||||
{mcpServers.length}{' '}
|
||||
{mcpServers.length === 1 ? t('settings.mcp.serverSingular') : t('settings.mcp.serverPlural')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Card bordered={false} style={{ background: theme === 'dark' ? '#1f1f1f' : '#fff' }}>
|
||||
<Table
|
||||
dataSource={mcpServers}
|
||||
columns={columns}
|
||||
rowKey="name"
|
||||
pagination={false}
|
||||
locale={{ emptyText: t('settings.mcp.noServers') }}
|
||||
rowClassName={(record) => (!record.isActive ? 'inactive-row' : '')}
|
||||
onRow={(record) => ({
|
||||
style: !record.isActive ? inactiveRowStyle : {}
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingServer ? t('settings.mcp.editServer') : t('settings.mcp.addServer')}
|
||||
open={isModalVisible}
|
||||
onCancel={handleCancel}
|
||||
onOk={handleSubmit}
|
||||
confirmLoading={loading}
|
||||
width={600}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('settings.mcp.name')}
|
||||
rules={[{ required: true, message: t('settings.mcp.nameRequired') }]}>
|
||||
<Input disabled={!!editingServer} placeholder={t('common.name')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="description" label={t('settings.mcp.description')}>
|
||||
<TextArea rows={2} placeholder={t('common.description')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="command"
|
||||
label={t('settings.mcp.command')}
|
||||
rules={[{ required: true, message: t('settings.mcp.commandRequired') }]}>
|
||||
<Input placeholder="python script.py" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="args" label={t('settings.mcp.args')} tooltip={t('settings.mcp.argsTooltip')}>
|
||||
<TextArea rows={3} placeholder="{--param1}\n{--param2 value}" style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="env" label={t('settings.mcp.env')} tooltip={t('settings.mcp.envTooltip')}>
|
||||
<TextArea rows={3} placeholder="KEY1=value1\nKEY2=value2" style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="isActive" label={t('settings.mcp.active')} valuePropName="checked" initialValue={true}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</SettingGroup>
|
||||
</SettingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default MCPSettings
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
CloudOutlined,
|
||||
CodeOutlined,
|
||||
GlobalOutlined,
|
||||
InfoCircleOutlined,
|
||||
LayoutOutlined,
|
||||
@@ -20,6 +21,7 @@ import AboutSettings from './AboutSettings'
|
||||
import DataSettings from './DataSettings/DataSettings'
|
||||
import DisplaySettings from './DisplaySettings/DisplaySettings'
|
||||
import GeneralSettings from './GeneralSettings'
|
||||
import MCPSettings from './MCPSettings'
|
||||
import ProvidersList from './ProviderSettings'
|
||||
import QuickAssistantSettings from './QuickAssistantSettings'
|
||||
import ShortcutSettings from './ShortcutSettings'
|
||||
@@ -60,6 +62,12 @@ const SettingsPage: FC = () => {
|
||||
{t('settings.websearch.title')}
|
||||
</MenuItem>
|
||||
</MenuItemLink>
|
||||
<MenuItemLink to="/settings/mcp">
|
||||
<MenuItem className={isRoute('/settings/mcp')}>
|
||||
<CodeOutlined />
|
||||
{t('settings.mcp.title')}
|
||||
</MenuItem>
|
||||
</MenuItemLink>
|
||||
<MenuItemLink to="/settings/general">
|
||||
<MenuItem className={isRoute('/settings/general')}>
|
||||
<SettingOutlined />
|
||||
@@ -102,6 +110,7 @@ const SettingsPage: FC = () => {
|
||||
<Route path="provider" element={<ProvidersList />} />
|
||||
<Route path="model" element={<ModelSettings />} />
|
||||
<Route path="web-search" element={<WebSearchSettings />} />
|
||||
<Route path="mcp" element={<MCPSettings />} />
|
||||
<Route path="general/*" element={<GeneralSettings />} />
|
||||
<Route path="display" element={<DisplaySettings />} />
|
||||
<Route path="data/*" element={<DataSettings />} />
|
||||
|
||||
@@ -16,8 +16,14 @@ export default class AiProvider {
|
||||
return this.sdk.fakeCompletions(params)
|
||||
}
|
||||
|
||||
public async completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void> {
|
||||
return this.sdk.completions({ messages, assistant, onChunk, onFilterMessages })
|
||||
public async completions({
|
||||
messages,
|
||||
assistant,
|
||||
onChunk,
|
||||
onFilterMessages,
|
||||
mcpTools
|
||||
}: CompletionsParams): Promise<void> {
|
||||
return this.sdk.completions({ messages, assistant, onChunk, onFilterMessages, mcpTools })
|
||||
}
|
||||
|
||||
public async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void): Promise<string> {
|
||||
|
||||
@@ -11,14 +11,27 @@ import i18n from '@renderer/i18n'
|
||||
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService'
|
||||
import { EVENT_NAMES } from '@renderer/services/EventService'
|
||||
import { filterContextMessages, filterUserRoleStartMessages } from '@renderer/services/MessagesService'
|
||||
import { Assistant, FileTypes, GenerateImageParams, Message, Model, Provider, Suggestion } from '@renderer/types'
|
||||
import {
|
||||
Assistant,
|
||||
FileTypes,
|
||||
GenerateImageParams,
|
||||
MCPTool,
|
||||
Message,
|
||||
Model,
|
||||
Provider,
|
||||
Suggestion
|
||||
} from '@renderer/types'
|
||||
import { removeSpecialCharacters } from '@renderer/utils'
|
||||
import { takeRight } from 'lodash'
|
||||
import OpenAI, { AzureOpenAI } from 'openai'
|
||||
import {
|
||||
ChatCompletionAssistantMessageParam,
|
||||
ChatCompletionContentPart,
|
||||
ChatCompletionCreateParamsNonStreaming,
|
||||
ChatCompletionMessageParam
|
||||
ChatCompletionMessageParam,
|
||||
ChatCompletionMessageToolCall,
|
||||
ChatCompletionTool,
|
||||
ChatCompletionToolMessageParam
|
||||
} from 'openai/resources'
|
||||
|
||||
import { CompletionsParams } from '.'
|
||||
@@ -213,7 +226,37 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
return model.id.startsWith('o1')
|
||||
}
|
||||
|
||||
async completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void> {
|
||||
private mcpToolsToOpenAITools(mcpTools: MCPTool[]): Array<ChatCompletionTool> {
|
||||
return mcpTools.map((tool) => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: `mcp.${tool.serverName}.${tool.name}`,
|
||||
description: tool.description,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: tool.inputSchema
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
private openAIToolsToMcpTool(tool: ChatCompletionMessageToolCall): MCPTool | undefined {
|
||||
const parts = tool.function.name.split('.')
|
||||
if (parts[0] !== 'mcp') {
|
||||
console.log('Invalid tool name', tool.function.name)
|
||||
return undefined
|
||||
}
|
||||
const serverName = parts[1]
|
||||
const name = parts[2]
|
||||
|
||||
return {
|
||||
serverName: serverName,
|
||||
name: name,
|
||||
inputSchema: JSON.parse(tool.function.arguments)
|
||||
} as MCPTool
|
||||
}
|
||||
|
||||
async completions({ messages, assistant, onChunk, onFilterMessages, mcpTools }: CompletionsParams): Promise<void> {
|
||||
const defaultModel = getDefaultModel()
|
||||
const model = assistant.model || defaultModel
|
||||
const { contextCount, maxTokens, streamOutput } = getAssistantSettings(assistant)
|
||||
@@ -285,17 +328,151 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
const { abortController, cleanup } = this.createAbortController(lastUserMessage?.id)
|
||||
const { signal } = abortController
|
||||
|
||||
const tools = mcpTools ? this.mcpToolsToOpenAITools(mcpTools) : undefined
|
||||
|
||||
const reqMessages: ChatCompletionMessageParam[] = [systemMessage, ...userMessages].filter(
|
||||
Boolean
|
||||
) as ChatCompletionMessageParam[]
|
||||
|
||||
const processStream = async (stream: any) => {
|
||||
if (!isSupportStreamOutput()) {
|
||||
const time_completion_millsec = new Date().getTime() - start_time_millsec
|
||||
return onChunk({
|
||||
text: stream.choices[0].message?.content || '',
|
||||
usage: stream.usage,
|
||||
metrics: {
|
||||
completion_tokens: stream.usage?.completion_tokens,
|
||||
time_completion_millsec,
|
||||
time_first_token_millsec: 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const toolCalls: ChatCompletionMessageToolCall[] = []
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
|
||||
break
|
||||
}
|
||||
|
||||
const delta = chunk.choices[0]?.delta
|
||||
|
||||
if (delta?.reasoning_content || delta?.reasoning) {
|
||||
hasReasoningContent = true
|
||||
}
|
||||
|
||||
if (time_first_token_millsec == 0) {
|
||||
time_first_token_millsec = new Date().getTime() - start_time_millsec
|
||||
}
|
||||
|
||||
if (time_first_content_millsec == 0 && isReasoningJustDone(delta)) {
|
||||
time_first_content_millsec = new Date().getTime()
|
||||
}
|
||||
|
||||
const time_completion_millsec = new Date().getTime() - start_time_millsec
|
||||
const time_thinking_millsec = time_first_content_millsec ? time_first_content_millsec - start_time_millsec : 0
|
||||
|
||||
// Extract citations from the raw response if available
|
||||
const citations = (chunk as OpenAI.Chat.Completions.ChatCompletionChunk & { citations?: string[] })?.citations
|
||||
|
||||
if (delta?.tool_calls) {
|
||||
const chunkToolCalls: OpenAI.Chat.Completions.ChatCompletionChunk.Choice.Delta.ToolCall[] = delta.tool_calls
|
||||
if (chunk.choices[0].finish_reason !== 'tool_calls') {
|
||||
if (toolCalls.length === 0) {
|
||||
for (const toolCall of chunkToolCalls) {
|
||||
toolCalls.push(toolCall as ChatCompletionMessageToolCall)
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < chunkToolCalls.length; i++) {
|
||||
toolCalls[i].function.arguments += chunkToolCalls[i].function?.arguments || ''
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.choices[0].finish_reason === 'tool_calls') {
|
||||
console.log('start invoke tools', toolCalls)
|
||||
reqMessages.push({
|
||||
role: 'assistant',
|
||||
tool_calls: toolCalls
|
||||
} as ChatCompletionAssistantMessageParam)
|
||||
|
||||
for (const toolCall of toolCalls) {
|
||||
const mcpTool = this.openAIToolsToMcpTool(toolCall)
|
||||
console.log('mcpTool', JSON.stringify(mcpTool, null, 2))
|
||||
if (!mcpTool) {
|
||||
console.log('Invalid tool', toolCall)
|
||||
continue
|
||||
}
|
||||
|
||||
const toolCallResponse = await window.api.mcp.callTool({
|
||||
client: mcpTool.serverName,
|
||||
name: mcpTool.name,
|
||||
args: mcpTool.inputSchema
|
||||
})
|
||||
console.log(`Tool ${mcpTool.serverName} - ${mcpTool.name} Call Response:`)
|
||||
console.log(toolCallResponse)
|
||||
|
||||
reqMessages.push({
|
||||
role: 'tool',
|
||||
content: JSON.stringify(toolCallResponse, null, 2),
|
||||
tool_call_id: toolCall.id
|
||||
} as ChatCompletionToolMessageParam)
|
||||
}
|
||||
|
||||
const newStream = await this.sdk.chat.completions
|
||||
// @ts-ignore key is not typed
|
||||
.create(
|
||||
{
|
||||
model: model.id,
|
||||
messages: reqMessages,
|
||||
temperature: this.getTemperature(assistant, model),
|
||||
top_p: this.getTopP(assistant, model),
|
||||
max_tokens: maxTokens,
|
||||
keep_alive: this.keepAliveTime,
|
||||
stream: isSupportStreamOutput(),
|
||||
tools: tools,
|
||||
...getOpenAIWebSearchParams(assistant, model),
|
||||
...this.getReasoningEffort(assistant, model),
|
||||
...this.getProviderSpecificParameters(assistant, model),
|
||||
...this.getCustomParameters(assistant)
|
||||
},
|
||||
{
|
||||
signal
|
||||
}
|
||||
)
|
||||
.finally(cleanup)
|
||||
await processStream(newStream)
|
||||
}
|
||||
|
||||
onChunk({
|
||||
text: delta?.content || '',
|
||||
// @ts-ignore key is not typed
|
||||
reasoning_content: delta?.reasoning_content || delta?.reasoning || '',
|
||||
usage: chunk.usage,
|
||||
metrics: {
|
||||
completion_tokens: chunk.usage?.completion_tokens,
|
||||
time_completion_millsec,
|
||||
time_first_token_millsec,
|
||||
time_thinking_millsec
|
||||
},
|
||||
citations
|
||||
})
|
||||
}
|
||||
}
|
||||
const stream = await this.sdk.chat.completions
|
||||
// @ts-ignore key is not typed
|
||||
.create(
|
||||
{
|
||||
model: model.id,
|
||||
messages: [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[],
|
||||
messages: reqMessages,
|
||||
temperature: this.getTemperature(assistant, model),
|
||||
top_p: this.getTopP(assistant, model),
|
||||
max_tokens: maxTokens,
|
||||
keep_alive: this.keepAliveTime,
|
||||
stream: isSupportStreamOutput(),
|
||||
tools: tools,
|
||||
...getOpenAIWebSearchParams(assistant, model),
|
||||
...this.getReasoningEffort(assistant, model),
|
||||
...this.getProviderSpecificParameters(assistant, model),
|
||||
@@ -307,59 +484,7 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
)
|
||||
.finally(cleanup)
|
||||
|
||||
if (!isSupportStreamOutput()) {
|
||||
const time_completion_millsec = new Date().getTime() - start_time_millsec
|
||||
return onChunk({
|
||||
text: stream.choices[0].message?.content || '',
|
||||
usage: stream.usage,
|
||||
metrics: {
|
||||
completion_tokens: stream.usage?.completion_tokens,
|
||||
time_completion_millsec,
|
||||
time_first_token_millsec: 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// @ts-expect-error `stream` is not typed
|
||||
for await (const chunk of stream) {
|
||||
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
|
||||
break
|
||||
}
|
||||
|
||||
const delta = chunk.choices[0]?.delta
|
||||
|
||||
if (delta?.reasoning_content || delta?.reasoning) {
|
||||
hasReasoningContent = true
|
||||
}
|
||||
|
||||
if (time_first_token_millsec == 0) {
|
||||
time_first_token_millsec = new Date().getTime() - start_time_millsec
|
||||
}
|
||||
|
||||
if (time_first_content_millsec == 0 && isReasoningJustDone(delta)) {
|
||||
time_first_content_millsec = new Date().getTime()
|
||||
}
|
||||
|
||||
const time_completion_millsec = new Date().getTime() - start_time_millsec
|
||||
const time_thinking_millsec = time_first_content_millsec ? time_first_content_millsec - start_time_millsec : 0
|
||||
|
||||
// Extract citations from the raw response if available
|
||||
const citations = (chunk as OpenAI.Chat.Completions.ChatCompletionChunk & { citations?: string[] })?.citations
|
||||
|
||||
onChunk({
|
||||
text: delta?.content || '',
|
||||
// @ts-ignore key is not typed
|
||||
reasoning_content: delta?.reasoning_content || delta?.reasoning || '',
|
||||
usage: chunk.usage,
|
||||
metrics: {
|
||||
completion_tokens: chunk.usage?.completion_tokens,
|
||||
time_completion_millsec,
|
||||
time_first_token_millsec,
|
||||
time_thinking_millsec
|
||||
},
|
||||
citations
|
||||
})
|
||||
}
|
||||
await processStream(stream)
|
||||
}
|
||||
|
||||
async translate(message: Message, assistant: Assistant, onResponse?: (text: string) => void) {
|
||||
|
||||
+1
@@ -15,4 +15,5 @@ interface CompletionsParams {
|
||||
assistant: Assistant
|
||||
onChunk: ({ text, reasoning_content, usage, metrics, search, citations }: ChunkCallbackData) => void
|
||||
onFilterMessages: (messages: Message[]) => void
|
||||
mcpTools?: MCPTool[]
|
||||
}
|
||||
|
||||
@@ -77,6 +77,10 @@ export async function fetchChatCompletion({
|
||||
}
|
||||
}
|
||||
|
||||
const allMCPTools = await window.api.mcp.listTools()
|
||||
if (allMCPTools.length > 0) {
|
||||
console.log('Available MCP tools:', allMCPTools)
|
||||
}
|
||||
await AI.completions({
|
||||
messages: filterUsefulMessages(messages),
|
||||
assistant,
|
||||
@@ -104,7 +108,8 @@ export async function fetchChatCompletion({
|
||||
}
|
||||
|
||||
onResponse({ ...message, status: 'pending' })
|
||||
}
|
||||
},
|
||||
mcpTools: allMCPTools
|
||||
})
|
||||
|
||||
message.status = 'success'
|
||||
|
||||
@@ -7,6 +7,7 @@ import agents from './agents'
|
||||
import assistants from './assistants'
|
||||
import knowledge from './knowledge'
|
||||
import llm from './llm'
|
||||
import mcp from './mcp'
|
||||
import migrate from './migrate'
|
||||
import minapps from './minapps'
|
||||
import paintings from './paintings'
|
||||
@@ -25,7 +26,8 @@ const rootReducer = combineReducers({
|
||||
shortcuts,
|
||||
knowledge,
|
||||
minapps,
|
||||
websearch
|
||||
websearch,
|
||||
mcp
|
||||
})
|
||||
|
||||
const persistedReducer = persistReducer(
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import { MCPConfig, MCPServer } from '@renderer/types'
|
||||
|
||||
const initialState: MCPConfig = {
|
||||
servers: []
|
||||
}
|
||||
|
||||
const mcpSlice = createSlice({
|
||||
name: 'mcp',
|
||||
initialState,
|
||||
reducers: {
|
||||
setMCPServers: (state, action: PayloadAction<MCPServer[]>) => {
|
||||
state.servers = action.payload
|
||||
},
|
||||
addMCPServer: (state, action: PayloadAction<MCPServer>) => {
|
||||
state.servers.push(action.payload)
|
||||
},
|
||||
updateMCPServer: (state, action: PayloadAction<MCPServer>) => {
|
||||
const index = state.servers.findIndex((server) => server.name === action.payload.name)
|
||||
if (index !== -1) {
|
||||
state.servers[index] = action.payload
|
||||
}
|
||||
},
|
||||
deleteMCPServer: (state, action: PayloadAction<string>) => {
|
||||
state.servers = state.servers.filter((server) => server.name !== action.payload)
|
||||
},
|
||||
setMCPServerActive: (state, action: PayloadAction<{ name: string; isActive: boolean }>) => {
|
||||
const index = state.servers.findIndex((server) => server.name === action.payload.name)
|
||||
if (index !== -1) {
|
||||
state.servers[index].isActive = action.payload.isActive
|
||||
}
|
||||
}
|
||||
},
|
||||
selectors: {
|
||||
getActiveServers: (state) => {
|
||||
return state.servers.filter((server) => server.isActive)
|
||||
},
|
||||
getAllServers: (state) => state.servers
|
||||
}
|
||||
})
|
||||
|
||||
export const { setMCPServers, addMCPServer, updateMCPServer, deleteMCPServer, setMCPServerActive } = mcpSlice.actions
|
||||
|
||||
// Export the generated selectors from the slice
|
||||
export const { getActiveServers, getAllServers } = mcpSlice.selectors
|
||||
|
||||
// Type-safe selector for accessing this slice from the root state
|
||||
export const selectMCP = (state: { mcp: MCPConfig }) => state.mcp
|
||||
|
||||
// Export the reducer as default export
|
||||
export default mcpSlice.reducer
|
||||
@@ -301,3 +301,42 @@ export type KnowledgeReference = {
|
||||
type: KnowledgeItemType
|
||||
file?: FileType
|
||||
}
|
||||
|
||||
export type MCPArgType = 'string' | 'list' | 'number'
|
||||
export type MCPEnvType = 'string' | 'number'
|
||||
export type MCPArgParameter = { [key: string]: MCPArgType }
|
||||
export type MCPEnvParameter = { [key: string]: MCPEnvType }
|
||||
|
||||
export interface MCPServerParameter {
|
||||
name: string
|
||||
type: MCPArgType | MCPEnvType
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface MCPServer {
|
||||
name: string
|
||||
command: string
|
||||
description?: string
|
||||
args: string[]
|
||||
env?: Record<string, string>
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface MCPToolInputSchema {
|
||||
type: string
|
||||
title: string
|
||||
description?: string
|
||||
required?: string[]
|
||||
properties: Record<string, object>
|
||||
}
|
||||
|
||||
export interface MCPTool {
|
||||
serverName: string
|
||||
name: string
|
||||
description?: string
|
||||
inputSchema: MCPToolInputSchema
|
||||
}
|
||||
|
||||
export interface MCPConfig {
|
||||
servers: MCPServer[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user