统一多端模态的消息桥接层

This commit is contained in:
锦恢 2025-04-30 14:54:41 +08:00
parent 0bea084c35
commit 354380cf23
12 changed files with 295 additions and 187 deletions

View File

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

View File

@ -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<string, Set<CommandHandler>>();
public isConnected = ref(false);
private isConnected: Promise<boolean> | 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 {
const platform = getPlatform();
switch (platform) {
case 'vscode':
this.setupVsCodeListener();
pinkLog('当前模式: vscode');
break;
case 'electron':
this.setupElectronListener();
pinkLog('当前模式: electron');
break;
case 'web':
this.setupWebSocket();
pinkLog('当前模式debug');
pinkLog('当前模式: web');
break;
}
}
// VS Code 环境监听
private setupVSCodeListener() {
private setupVsCodeListener() {
const vscode = acquireVsCodeApi();
window.addEventListener('message', (event: MessageEvent<VSCodeMessage>) => {
@ -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<boolean>((resolve, reject) => {
ws.onopen = () => {
resolve(true);
};
});
}
public async awaitForWebsockt() {
if (this.isConnected) {
await this.isConnected;
}
}
private setupElectronListener() {
electronApi.onReply((event: MessageEvent<VSCodeMessage>) => {
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)
};
}

View File

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

View File

@ -3,10 +3,10 @@ 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 => {
const data = await bridge.commandRequest('setting/load');
if (data.code !== 200) {
pinkLog('配置加载失败');
console.log(data.msg);
@ -22,12 +22,6 @@ export function loadSetting() {
llms.push(element);
});
}
}, { once: true });
bridge.postMessage({
command: 'setting/load'
});
}
export function saveSetting(saveHandler?: () => void) {

View File

@ -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() {
}
function getLaunchSignature(signatureName: string) {
return new Promise<ILaunchSigature>((resolve, reject) => {
// 与 vscode 进行同步
async function getLaunchSignature(signatureName: string) {
const bridge = useMessageBridge();
const { code, msg } = await bridge.commandRequest(signatureName);
bridge.addCommandListener(signatureName, data => {
pinkLog('收到启动参数');
resolve(data.msg);
}, { once: true });
bridge.postMessage({
command: signatureName,
data: {}
});
})
return msg;
}
export function doReconnect() {

View File

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

View File

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

View File

@ -15,7 +15,6 @@ export interface WebSocketResponse {
export interface PostMessageble {
postMessage(message: any): void;
onDidReceiveMessage(callback: MessageHandler): { dispose: () => void };
}
// 监听器回调类型

View File

@ -108,7 +108,6 @@ wss.on('connection', ws => {
}
});
const option = getInitConnectionOption();
// 注册消息接受的管线

View File

@ -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
@ -11,20 +12,14 @@ function createWindow(): void {
width: 1200,
webPreferences: {
nodeIntegration: true,
contextIsolation: false
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) => {
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()

12
software/src/preload.ts Normal file
View File

@ -0,0 +1,12 @@
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('electronApi', {
onReply: (callback: (event: MessageEvent<any>) => void) => {
ipcRenderer.on('message', (event, data) => {
callback({ data } as MessageEvent<any>);
});
},
sendToMain: (message: any) => {
ipcRenderer.send('message', message);
}
});

89
software/src/util.ts Normal file
View File

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