diff --git a/renderer/src/App.vue b/renderer/src/App.vue index 1bb1d9a..c4b7782 100644 --- a/renderer/src/App.vue +++ b/renderer/src/App.vue @@ -14,10 +14,11 @@ import Sidebar from '@/components/sidebar/index.vue'; import MainPanel from '@/components/main-panel/index.vue'; import { setDefaultCss } from './hook/css'; import { greenLog, pinkLog } from './views/setting/util'; -import { acquireVsCodeApi, useMessageBridge } from './api/message-bridge'; -import { connectionArgs, connectionMethods, doWebConnect, doVscodeConnect, loadEnvVar } from './views/connect/connection'; +import { useMessageBridge } from './api/message-bridge'; +import { connectionArgs, connectionMethods, doConnect, loadEnvVar } from './views/connect/connection'; import { loadSetting } from './hook/setting'; import { loadPanels } from './hook/panel'; +import { getPlatform } from './api/platform'; const bridge = useMessageBridge(); @@ -28,51 +29,10 @@ bridge.addCommandListener('hello', data => { }, { once: true }); -function initDebug() { - - setTimeout(async () => { - // 初始化 设置 - loadSetting(); - - // 初始化环境变量 - loadEnvVar(); - - // 尝试连接 - await doWebConnect(); - - // 初始化 tab - loadPanels(); - - }, 200); -} - const route = useRoute(); const router = useRouter(); -async function initProduce() { - // TODO: get from vscode - connectionArgs.commandString = 'mcp run ../servers/main.py'; - connectionMethods.current = 'STDIO'; - - // 初始化 设置 - loadSetting(); - - // 初始化环境变量 - loadEnvVar(); - - // 尝试连接 - await doVscodeConnect(); - - // 初始化 tab - await loadPanels(); - - if (route.name !== 'debug') { - router.replace('/debug'); - router.push('/debug'); - } -} - -onMounted(() => { +onMounted(async () => { // 初始化 css setDefaultCss(); @@ -82,11 +42,36 @@ onMounted(() => { pinkLog('OpenMCP Client 启动'); - if (acquireVsCodeApi === undefined) { - initDebug(); - } else { - initProduce(); + const platform = getPlatform(); + + // 跳转到首页 + if (platform !== 'web') { + if (route.name !== 'debug') { + router.replace('/debug'); + router.push('/debug'); + } } + + // 进行桥接 + await bridge.awaitForWebsockt(); + + pinkLog('准备请求设置'); + + // 加载全局设置 + loadSetting(); + + // 设置环境变量 + loadEnvVar(); + + // 尝试进行初始化连接 + await doConnect({ + namespace: platform, + updateCommandString: true + }); + + // loading panels + await loadPanels(); + }); diff --git a/renderer/src/api/message-bridge.ts b/renderer/src/api/message-bridge.ts index 1198cd2..4180c4b 100644 --- a/renderer/src/api/message-bridge.ts +++ b/renderer/src/api/message-bridge.ts @@ -1,5 +1,6 @@ -import { pinkLog } from '@/views/setting/util'; -import { onUnmounted, ref } from 'vue'; +import { pinkLog, redLog } from '@/views/setting/util'; +import { acquireVsCodeApi, electronApi, getPlatform } from './platform'; +import { ref } from 'vue'; export interface VSCodeMessage { command: string; @@ -15,8 +16,6 @@ export interface RestFulResponse { export type MessageHandler = (message: VSCodeMessage) => void; export type CommandHandler = (data: any) => void; -export const acquireVsCodeApi = (window as any)['acquireVsCodeApi']; - interface AddCommandListenerOption { once: boolean // 只调用一次就销毁 } @@ -24,27 +23,36 @@ interface AddCommandListenerOption { class MessageBridge { private ws: WebSocket | null = null; private handlers = new Map>(); - public isConnected = ref(false); + private isConnected: Promise | null = null; constructor(private wsUrl: string = 'ws://localhost:8080') { - this.init(); - } - private init() { // 环境检测优先级: // 1. VS Code WebView 环境 // 2. 浏览器 WebSocket 环境 - if (typeof acquireVsCodeApi !== 'undefined') { - this.setupVSCodeListener(); - pinkLog('当前模式:release'); - } else { - this.setupWebSocket(); - pinkLog('当前模式:debug'); + + const platform = getPlatform(); + + switch (platform) { + case 'vscode': + this.setupVsCodeListener(); + pinkLog('当前模式: vscode'); + break; + + case 'electron': + this.setupElectronListener(); + pinkLog('当前模式: electron'); + break; + + case 'web': + this.setupWebSocket(); + pinkLog('当前模式: web'); + break; } } // VS Code 环境监听 - private setupVSCodeListener() { + private setupVsCodeListener() { const vscode = acquireVsCodeApi(); window.addEventListener('message', (event: MessageEvent) => { @@ -52,17 +60,12 @@ class MessageBridge { }); this.postMessage = (message) => vscode.postMessage(message); - this.isConnected.value = true; } // WebSocket 环境连接 private setupWebSocket() { this.ws = new WebSocket(this.wsUrl); - this.ws.onopen = () => { - this.isConnected.value = true; - }; - this.ws.onmessage = (event) => { try { const message = JSON.parse(event.data) as VSCodeMessage; @@ -74,16 +77,41 @@ class MessageBridge { }; this.ws.onclose = () => { - this.isConnected.value = false; + redLog('WebSocket connection closed'); }; this.postMessage = (message) => { if (this.ws?.readyState === WebSocket.OPEN) { - console.log(message); - + console.log('send', { command: message.command }); this.ws.send(JSON.stringify(message)); } }; + + const ws = this.ws; + + this.isConnected = new Promise((resolve, reject) => { + ws.onopen = () => { + resolve(true); + }; + }); + } + + public async awaitForWebsockt() { + if (this.isConnected) { + await this.isConnected; + } + } + + private setupElectronListener() { + electronApi.onReply((event: MessageEvent) => { + console.log(event); + this.dispatchMessage(event.data); + }); + + this.postMessage = (message) => { + console.log(message); + electronApi.sendToMain(message); + }; } /** @@ -176,6 +204,6 @@ export function useMessageBridge() { postMessage: bridge.postMessage.bind(bridge), addCommandListener: bridge.addCommandListener.bind(bridge), commandRequest: bridge.commandRequest.bind(bridge), - isConnected: bridge.isConnected + awaitForWebsockt: bridge.awaitForWebsockt.bind(bridge) }; } \ No newline at end of file diff --git a/renderer/src/api/platform.ts b/renderer/src/api/platform.ts new file mode 100644 index 0000000..e1e9a1b --- /dev/null +++ b/renderer/src/api/platform.ts @@ -0,0 +1,15 @@ +export type OpenMcpSupportPlatform = 'web' | 'vscode' | 'electron'; + +export const acquireVsCodeApi = (window as any)['acquireVsCodeApi']; + +export const electronApi = (window as any)['electronApi']; + +export function getPlatform(): OpenMcpSupportPlatform { + if (typeof acquireVsCodeApi !== 'undefined') { + return 'vscode'; + } else if (typeof electronApi !== 'undefined') { + return 'electron'; + } else { + return 'web'; + } +} \ No newline at end of file diff --git a/renderer/src/hook/setting.ts b/renderer/src/hook/setting.ts index d477a44..8063636 100644 --- a/renderer/src/hook/setting.ts +++ b/renderer/src/hook/setting.ts @@ -3,31 +3,25 @@ import { llmManager, llms } from "@/views/setting/llm"; import { pinkLog } from "@/views/setting/util"; import I18n from '@/i18n/index'; -export function loadSetting() { +export async function loadSetting() { const bridge = useMessageBridge(); - bridge.addCommandListener('setting/load', data => { - if (data.code !== 200) { - pinkLog('配置加载失败'); - console.log(data.msg); + const data = await bridge.commandRequest('setting/load'); + if (data.code !== 200) { + pinkLog('配置加载失败'); + console.log(data.msg); - } else { - const persistConfig = data.msg; - pinkLog('配置加载成功'); + } else { + const persistConfig = data.msg; + pinkLog('配置加载成功'); - llmManager.currentModelIndex = persistConfig.MODEL_INDEX; - I18n.global.locale.value = persistConfig.LANG; + llmManager.currentModelIndex = persistConfig.MODEL_INDEX; + I18n.global.locale.value = persistConfig.LANG; - persistConfig.LLM_INFO.forEach((element: any) => { - llms.push(element); - }); - } - - }, { once: true }); - - bridge.postMessage({ - command: 'setting/load' - }); + persistConfig.LLM_INFO.forEach((element: any) => { + llms.push(element); + }); + } } export function saveSetting(saveHandler?: () => void) { diff --git a/renderer/src/views/connect/connection.ts b/renderer/src/views/connect/connection.ts index c61bd92..4d4eaca 100644 --- a/renderer/src/views/connect/connection.ts +++ b/renderer/src/views/connect/connection.ts @@ -3,6 +3,7 @@ import { reactive } from 'vue'; import { pinkLog } from '../setting/util'; import { arrowMiddleware, ElMessage } from 'element-plus'; import { ILaunchSigature } from '@/hook/type'; +import { OpenMcpSupportPlatform } from '@/api/platform'; export const connectionMethods = reactive({ current: 'STDIO', @@ -69,15 +70,21 @@ export interface McpOptions { clientVersion?: string; } -export async function doWebConnect(option: { updateCommandString?: boolean } = {}) { +export async function doConnect( + option: { + namespace: OpenMcpSupportPlatform + updateCommandString?: boolean + } +) { const { // updateCommandString 为 true 代表是初始化阶段 + namespace, updateCommandString = true } = option; if (updateCommandString) { pinkLog('请求启动参数'); - const connectionItem = await getLaunchSignature('web/launch-signature'); + const connectionItem = await getLaunchSignature(namespace + '/launch-signature'); if (connectionItem.type ==='stdio') { connectionMethods.current = 'STDIO'; @@ -98,54 +105,13 @@ export async function doWebConnect(option: { updateCommandString?: boolean } = { } if (connectionMethods.current === 'STDIO') { - await launchStdio(); + await launchStdio(namespace); } else { - await launchSSE(); + await launchSSE(namespace); } } -/** - * @description vscode 中初始化启动 - */ -export async function doVscodeConnect(option: { updateCommandString?: boolean } = {}) { - // 本地开发只用 IPC 进行启动 - // 后续需要考虑到不同的连接方式 - - const { - // updateCommandString 为 true 代表是初始化阶段 - updateCommandString = true - } = option; - - if (updateCommandString) { - pinkLog('请求启动参数'); - const connectionItem = await getLaunchSignature('vscode/launch-signature'); - - if (connectionItem.type ==='stdio') { - connectionMethods.current = 'STDIO'; - connectionArgs.commandString = connectionItem.commandString; - connectionArgs.cwd = connectionItem.cwd; - - if (connectionArgs.commandString.length === 0) { - return; - } - } else { - connectionMethods.current = 'SSE'; - connectionArgs.urlString = connectionItem.url; - - if (connectionArgs.urlString.length === 0) { - return; - } - } - } - - if (connectionMethods.current === 'STDIO') { - await launchStdio(); - } else { - await launchSSE(); - } -} - -async function launchStdio() { +async function launchStdio(namespace: string) { const bridge = useMessageBridge(); const env = makeEnv(); @@ -193,7 +159,7 @@ async function launchStdio() { }; bridge.postMessage({ - command: 'vscode/update-connection-sigature', + command: namespace + '/update-connection-sigature', data: JSON.parse(JSON.stringify(clientStdioConnectionItem)) }); @@ -210,7 +176,7 @@ async function launchStdio() { } } -async function launchSSE() { +async function launchSSE(namespace: string) { const bridge = useMessageBridge(); const env = makeEnv(); @@ -247,7 +213,7 @@ async function launchSSE() { }; bridge.postMessage({ - command: 'vscode/update-connection-sigature', + command: namespace + '/update-connection-sigature', data: JSON.parse(JSON.stringify(clientSseConnectionItem)) }); @@ -265,23 +231,11 @@ async function launchSSE() { } +async function getLaunchSignature(signatureName: string) { + const bridge = useMessageBridge(); + const { code, msg } = await bridge.commandRequest(signatureName); -function getLaunchSignature(signatureName: string) { - return new Promise((resolve, reject) => { - // 与 vscode 进行同步 - const bridge = useMessageBridge(); - - bridge.addCommandListener(signatureName, data => { - pinkLog('收到启动参数'); - resolve(data.msg); - - }, { once: true }); - - bridge.postMessage({ - command: signatureName, - data: {} - }); - }) + return msg; } export function doReconnect() { diff --git a/renderer/src/views/connect/index.vue b/renderer/src/views/connect/index.vue index 7dc2cda..f7f5495 100644 --- a/renderer/src/views/connect/index.vue +++ b/renderer/src/views/connect/index.vue @@ -29,15 +29,14 @@ import { useI18n } from 'vue-i18n'; const { t } = useI18n(); -import { connectionResult, doWebConnect, doVscodeConnect } from './connection'; +import { connectionResult, doConnect } from './connection'; import ConnectionMethod from './connection-method.vue'; import ConnectionArgs from './connection-args.vue'; import EnvVar from './env-var.vue'; import ConnectionLog from './connection-log.vue'; - -import { acquireVsCodeApi } from '@/api/message-bridge'; +import { getPlatform } from '@/api/platform'; defineComponent({ name: 'connect' }); @@ -46,11 +45,9 @@ const isLoading = ref(false); async function suitableConnect() { isLoading.value = true; - if (acquireVsCodeApi === undefined) { - await doWebConnect({ updateCommandString: false }); - } else { - await doVscodeConnect({ updateCommandString: false }); - } + const plaform = getPlatform(); + + await doConnect({ namespace: plaform, updateCommandString: false }) isLoading.value = false; } diff --git a/service/package.json b/service/package.json index 394fa37..6b87d71 100644 --- a/service/package.json +++ b/service/package.json @@ -6,7 +6,7 @@ "types": "dist/index.d.ts", "scripts": { "serve": "ts-node-dev --respawn --transpile-only src/main.ts", - "build": "tsc && webpack --config webpack.config.js", + "build": "tsc", "build:watch": "tsc --watch", "start": "node dist/main.js", "start:prod": "NODE_ENV=production node dist/main.js", diff --git a/service/src/hook/adapter.ts b/service/src/hook/adapter.ts index d7a4c35..50f715b 100644 --- a/service/src/hook/adapter.ts +++ b/service/src/hook/adapter.ts @@ -15,7 +15,6 @@ export interface WebSocketResponse { export interface PostMessageble { postMessage(message: any): void; - onDidReceiveMessage(callback: MessageHandler): { dispose: () => void }; } // 监听器回调类型 diff --git a/service/src/main.ts b/service/src/main.ts index 5c722a8..bc63db3 100644 --- a/service/src/main.ts +++ b/service/src/main.ts @@ -108,7 +108,6 @@ wss.on('connection', ws => { } }); - const option = getInitConnectionOption(); // 注册消息接受的管线 diff --git a/software/src/main.ts b/software/src/main.ts index 0e771e1..b1f586f 100644 --- a/software/src/main.ts +++ b/software/src/main.ts @@ -1,6 +1,7 @@ -import { app, BrowserWindow } from 'electron'; -import WebSocket from 'ws'; +import { app, BrowserWindow, ipcMain } from 'electron'; import * as OpenMCPService from '../resources/service'; +import * as path from 'path'; +import { ElectronIPCLike, getInitConnectionOption, ILaunchSigature, updateConnectionOption } from './util'; let mainWindow: BrowserWindow @@ -10,21 +11,15 @@ function createWindow(): void { useContentSize: true, width: 1200, webPreferences: { - nodeIntegration: true, - contextIsolation: false + nodeIntegration: true, + contextIsolation: true, + preload: path.join(__dirname, 'preload.js') }, - autoHideMenuBar: true - }) + autoHideMenuBar: true, + icon: path.join(__dirname, '..', 'icons', 'icon.png') + }); - mainWindow.loadFile('resources/renderer/index.html') -} - -const wss = new (WebSocket as any).Server({ port: 8080 }); - -wss.on('connection', (ws: any) => { - - // 仿造 webview 进行统一接口访问 - const webview = new OpenMCPService.VSCodeWebViewLike(ws); + const webview = new ElectronIPCLike(mainWindow.webContents); // 先发送成功建立的消息 webview.postMessage({ @@ -35,17 +30,58 @@ wss.on('connection', (ws: any) => { } }); + const option = getInitConnectionOption(); + // 注册消息接受的管线 - webview.onDidReceiveMessage((message: any) => { + webview.onDidReceiveMessage((message: any) => { console.info(`command: [${message.command || 'No Command'}]`); const { command, data } = message; - OpenMCPService.routeMessage(command, data, webview); + + switch (command) { + case 'electron/launch-signature': + const launchResultMessage: ILaunchSigature = option.type === 'stdio' ? + { + type: 'stdio', + commandString: option.command + ' ' + option.args.join(' '), + cwd: option.cwd || '' + } : + { + type: 'sse', + url: option.url, + oauth: option.oauth || '' + }; + + const launchResult = { + code: 200, + msg: launchResultMessage + }; + + webview.postMessage({ + command: 'electron/launch-signature', + data: launchResult + }); + + break; + + case 'electron/update-connection-sigature': + updateConnectionOption(data); + break; + + default: + OpenMCPService.routeMessage(command, data, webview); + break; + } }); -}); + + + const indexPath = path.join(__dirname, '..', 'resources/renderer/index.html'); + mainWindow.loadFile(indexPath); +} app.whenReady().then(() => { - createWindow() + + createWindow(); app.on('activate', function () { if (BrowserWindow.getAllWindows().length === 0) createWindow() diff --git a/software/src/preload.ts b/software/src/preload.ts new file mode 100644 index 0000000..45e1e75 --- /dev/null +++ b/software/src/preload.ts @@ -0,0 +1,12 @@ +import { contextBridge, ipcRenderer } from 'electron'; + +contextBridge.exposeInMainWorld('electronApi', { + onReply: (callback: (event: MessageEvent) => void) => { + ipcRenderer.on('message', (event, data) => { + callback({ data } as MessageEvent); + }); + }, + sendToMain: (message: any) => { + ipcRenderer.send('message', message); + } +}); \ No newline at end of file diff --git a/software/src/util.ts b/software/src/util.ts new file mode 100644 index 0000000..909e819 --- /dev/null +++ b/software/src/util.ts @@ -0,0 +1,89 @@ + +import { ipcMain } from 'electron'; +import * as fs from 'fs'; +import * as path from 'path'; + +export class ElectronIPCLike { + private webContents: Electron.WebContents; + + constructor(webContents: Electron.WebContents) { + this.webContents = webContents; + } + + postMessage(message: { command: string; data: any }): void { + this.webContents.send('message', message); + } + + onDidReceiveMessage(callback: (message: { command: string; data: any }) => void): void { + ipcMain.on('message', (event, message) => { + callback(message); + }); + } +} + + +interface IStdioLaunchSignature { + type: 'stdio'; + commandString: string; + cwd: string; +} + +interface ISSELaunchSignature { + type:'sse'; + url: string; + oauth: string; +} + +export type ILaunchSigature = IStdioLaunchSignature | ISSELaunchSignature; + +export function refreshConnectionOption(envPath: string) { + const defaultOption = { + type:'stdio', + command: 'mcp', + args: ['run', 'main.py'], + cwd: '../server' + }; + + fs.writeFileSync(envPath, JSON.stringify(defaultOption, null, 4)); + + return defaultOption; +} + +export function getInitConnectionOption() { + const envPath = path.join(__dirname, '..', '.env'); + + if (!fs.existsSync(envPath)) { + return refreshConnectionOption(envPath); + } + + try { + const option = JSON.parse(fs.readFileSync(envPath, 'utf-8')); + return option; + + } catch (error) { + return refreshConnectionOption(envPath); + } +} + +export function updateConnectionOption(data: any) { + const envPath = path.join(__dirname, '..', '.env'); + + if (data.connectionType === 'STDIO') { + const connectionItem = { + type: 'stdio', + command: data.command, + args: data.args, + cwd: data.cwd.replace(/\\/g, '/') + }; + + fs.writeFileSync(envPath, JSON.stringify(connectionItem, null, 4)); + } else { + const connectionItem = { + type: 'sse', + url: data.url, + oauth: data.oauth + }; + + fs.writeFileSync(envPath, JSON.stringify(connectionItem, null, 4)); + } +} \ No newline at end of file