From fab518f768e69d59e0cf1b652cfbb50ea48a9eaf Mon Sep 17 00:00:00 2001 From: Sma1lboy <541898146chen@gmail.com> Date: Thu, 30 Jul 2026 15:43:59 -0700 Subject: [PATCH] feat(app): run the app in a browser and smooth streamed text Adds a loopback web bridge so the renderer can run in a plain browser tab against the real local AI runtime, plus one command to start both halves: pnpm dev:web The bridge reuses the existing IPC handlers rather than duplicating them: `create*API()` already took an ipcRenderer-shaped argument, so the browser passes an HTTP/WebSocket shim instead. Only the local AI and MCP channels are exposed; window control, clipboard and shortcuts have no browser meaning. Access needs a per-run token, loopback binding and a loopback Origin, and the whole thing is off unless CONVERA_WEB_BRIDGE=1. Streaming: the CLI providers deliver ~100 chars every ~220ms, which reads as paste events rather than typing. Text now drips out on animation frames, draining faster as the backlog grows and flushing on finish, so no character is dropped. Measured 5 -> 40 DOM updates for one reply. Theme: replaces 54 hardcoded palette colors with tokens, and adds the success/warning/info tokens whose absence caused them. Dialogs, sheets and drawers now use the popover surface and a shared overlay scrim, so they match the popovers and menus they sit beside in both themes. --- package.json | 1 + packages/app/package.json | 1 + packages/app/scripts/web-bridge-dev.mts | 83 +++++ .../electro-bridge/ipc/listeners-register.ts | 32 +- .../src/electro-bridge/ipc/local-ai-api.ts | 54 ++++ .../electro-bridge/ipc/local-ai-context.ts | 60 +--- .../app/src/electro-bridge/ipc/mcp-api.ts | 37 +++ .../app/src/electro-bridge/ipc/mcp-context.ts | 54 +--- packages/app/src/electron/main.ts | 38 ++- .../app/src/electron/web-bridge/dispatch.ts | 84 +++++ .../electron/web-bridge/integration.test.ts | 179 +++++++++++ .../src/electron/web-bridge/server.test.ts | 99 ++++++ .../app/src/electron/web-bridge/server.ts | 298 ++++++++++++++++++ packages/app/src/renderer.ts | 3 + .../components/chat/input/context-button.tsx | 10 +- .../components/chat/message/chat-content.tsx | 2 +- .../components/chat/message/tool-call.tsx | 2 +- .../components/chat/popover/agent-popover.tsx | 42 ++- .../chat/popover/model-selector-popover.tsx | 2 +- .../chat/selected/modified-content-block.tsx | 4 +- .../components/drag-window-region.tsx | 2 +- .../src/renderer/components/home/index.tsx | 15 +- .../components/search/HighlightedText.tsx | 2 +- .../components/settings/pages/agents-page.tsx | 12 +- .../settings/pages/developer-page.tsx | 10 +- .../settings/pages/general-page.tsx | 2 +- .../components/settings/pages/mcp-page.tsx | 20 +- .../components/settings/shortcuts-section.tsx | 2 +- .../components/sidebar/ConversationList.tsx | 2 +- .../renderer/components/ui/alert-dialog.tsx | 4 +- .../app/src/renderer/components/ui/dialog.tsx | 4 +- .../app/src/renderer/components/ui/drawer.tsx | 4 +- .../app/src/renderer/components/ui/sheet.tsx | 4 +- .../renderer/libs/hooks/use-local-ai-chat.ts | 16 +- .../src/renderer/libs/local-ai-ui-stream.ts | 44 ++- .../app/src/renderer/libs/text-drip.test.ts | 89 ++++++ packages/app/src/renderer/libs/text-drip.ts | 109 +++++++ .../src/renderer/libs/web-bridge/install.ts | 62 ++++ .../renderer/libs/web-bridge/ipc-shim.test.ts | 80 +++++ .../src/renderer/libs/web-bridge/ipc-shim.ts | 139 ++++++++ packages/app/src/renderer/theme.css | 37 +++ .../app/src/shared/web-bridge/protocol.ts | 24 ++ 42 files changed, 1600 insertions(+), 168 deletions(-) create mode 100644 packages/app/scripts/web-bridge-dev.mts create mode 100644 packages/app/src/electro-bridge/ipc/local-ai-api.ts create mode 100644 packages/app/src/electro-bridge/ipc/mcp-api.ts create mode 100644 packages/app/src/electron/web-bridge/dispatch.ts create mode 100644 packages/app/src/electron/web-bridge/integration.test.ts create mode 100644 packages/app/src/electron/web-bridge/server.test.ts create mode 100644 packages/app/src/electron/web-bridge/server.ts create mode 100644 packages/app/src/renderer/libs/text-drip.test.ts create mode 100644 packages/app/src/renderer/libs/text-drip.ts create mode 100644 packages/app/src/renderer/libs/web-bridge/install.ts create mode 100644 packages/app/src/renderer/libs/web-bridge/ipc-shim.test.ts create mode 100644 packages/app/src/renderer/libs/web-bridge/ipc-shim.ts create mode 100644 packages/app/src/shared/web-bridge/protocol.ts diff --git a/package.json b/package.json index bac6efda..b6b206a4 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "description": "Convera Workspace", "scripts": { "start": "pnpm --filter app start", + "dev:web": "pnpm --filter app dev:web", "make": "pnpm --filter app make", "lint": "pnpm -r lint", "format": "pnpm -r format", diff --git a/packages/app/package.json b/packages/app/package.json index 5343e771..b6a729de 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -8,6 +8,7 @@ "scripts": { "start": "NODE_ENV=production-development electron-forge start", "dev:start": "NODE_ENV=development electron-forge start", + "dev:web": "tsx scripts/web-bridge-dev.mts", "package": "electron-forge package", "package:robotjs": "electron-forge package && ./scripts/post-package-robotjs.sh", "package:sign": "ENABLE_CODE_SIGNING=true pnpm run package:robotjs && ./scripts/apple-sign.sh", diff --git a/packages/app/scripts/web-bridge-dev.mts b/packages/app/scripts/web-bridge-dev.mts new file mode 100644 index 00000000..657ebbb3 --- /dev/null +++ b/packages/app/scripts/web-bridge-dev.mts @@ -0,0 +1,83 @@ +/** + * `pnpm dev:web` — the whole browser dev loop in one command. + * + * Starts the renderer dev server and the web bridge together, wires them to + * each other, and prints one ready-to-open link. Nothing to configure. + * + * The bridge runs the real local AI runtime (Claude Code / Codex CLI), so this + * is a working app in a browser tab — not a mock. Electron is not involved, + * which is what makes it usable from a non-TTY shell and from an agent. + * + * ponytail: dev-only. `CONVERA_WEB_BRIDGE=1 pnpm start` is still the path for + * the real Electron window, where MCP and the OS integrations exist. + */ +import { LocalAiRuntime } from "@/electron/ai"; +import { setupLocalAIIPC } from "@/electro-bridge/ipc/local-ai-context"; +import { + createRecordingIpcMain, + createWebBridgeEvent, + WebBridgeSender, +} from "@/electron/web-bridge/dispatch"; +import { + startWebBridge, + type WebBridgeHandle, +} from "@/electron/web-bridge/server"; +import type { IpcMain } from "electron"; +import { createServer } from "vite"; + +const RENDERER_PORT = Number(process.env.CONVERA_RENDERER_PORT ?? 5199); + +process.env.NODE_ENV ??= "production-development"; + +// Renderer first: it picks the port, so the printed link is always correct +// even when 5199 is taken and vite falls forward to 5200+. +const renderer = await createServer({ + configFile: new URL("../vite.renderer.config.mts", import.meta.url).pathname, + server: { port: RENDERER_PORT }, +}); +await renderer.listen(); + +const rendererURL = renderer.resolvedUrls?.local[0]; +if (!rendererURL) throw new Error("Renderer dev server reported no URL"); + +const runtime = new LocalAiRuntime({ + // ponytail: no MCP hub — it needs Electron's `app` paths. Builtin tools + // cover the chat loop; run the Electron app when you need MCP servers. + getToolGroups: async () => [], + executeTool: async () => { + throw new Error("MCP tools require the full Electron app"); + }, +}); + +const recordingIPC = createRecordingIpcMain({ + handle: () => {}, + removeHandler: () => {}, +} as unknown as IpcMain); + +// Deferred lookup: the sender exists before the bridge it emits through. +const emit = { to: undefined as WebBridgeHandle["emit"] | undefined }; +const sender = new WebBridgeSender((channel, payload) => + emit.to?.(channel, payload), +); + +setupLocalAIIPC( + { runtime, getAllowedWebContents: () => [sender as never] }, + recordingIPC, +); + +const bridge = await startWebBridge({ + rendererURL, + invoke: (channel, args) => + recordingIPC.dispatch(channel, args, createWebBridgeEvent(sender)), +}); + +emit.to = bridge.emit; + +const shutdown = () => { + sender.destroy(); + void Promise.allSettled([bridge.close(), renderer.close()]).then(() => + process.exit(0), + ); +}; +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/packages/app/src/electro-bridge/ipc/listeners-register.ts b/packages/app/src/electro-bridge/ipc/listeners-register.ts index 7f848254..853d6c2b 100644 --- a/packages/app/src/electro-bridge/ipc/listeners-register.ts +++ b/packages/app/src/electro-bridge/ipc/listeners-register.ts @@ -2,7 +2,13 @@ import { WindowSizeConfig } from "@/electron/windows/window-size"; import { ThemeMode } from "@/shared/types/electron"; import type { LocalAIRuntimeService } from "@/shared/types/local-ai"; -import { BrowserWindow, ipcMain, IpcRenderer } from "electron"; +import { + BrowserWindow, + ipcMain, + IpcRenderer, + type IpcMain, + type WebContents, +} from "electron"; import { getAppIcon, getPlatform } from "./active-app-context"; import { CHANNELS, IPCServer, methodChannelMap } from "./channels"; import { setupEnvIPC } from "./env-context"; @@ -91,6 +97,10 @@ export interface ListenerOptions { mainWindow?: () => BrowserWindow | null; registerGlobalShortcuts?: () => void; localAIRuntime?: LocalAIRuntimeService; + /** Extra senders (currently the web bridge) allowed to drive local AI. */ + extraLocalAISenders?: () => WebContents[]; + /** Registration target; the web bridge swaps in a recording proxy. */ + ipc?: Pick; } /** @@ -230,16 +240,22 @@ export function setupElectronAPIIPC(options: ListenerOptions = {}) { // Register all IPC listeners for main process export function registerListeners(options: ListenerOptions = {}) { - setupMCPIPC(); + const ipc = options.ipc ?? ipcMain; + setupMCPIPC(ipc); setupLoggerIPC(); setupElectronAPIIPC(options); setupEnvIPC(); - setupLocalAIIPC({ - runtime: options.localAIRuntime, - getAllowedWebContents: () => { - const window = options.mainWindow?.(); - return window && !window.isDestroyed() ? window.webContents : null; + setupLocalAIIPC( + { + runtime: options.localAIRuntime, + getAllowedWebContents: () => { + const window = options.mainWindow?.(); + const renderer = + window && !window.isDestroyed() ? [window.webContents] : []; + return [...renderer, ...(options.extraLocalAISenders?.() ?? [])]; + }, }, - }); + ipc, + ); console.log("All IPC listeners registered successfully"); } diff --git a/packages/app/src/electro-bridge/ipc/local-ai-api.ts b/packages/app/src/electro-bridge/ipc/local-ai-api.ts new file mode 100644 index 00000000..3bfe21bb --- /dev/null +++ b/packages/app/src/electro-bridge/ipc/local-ai-api.ts @@ -0,0 +1,54 @@ +import type { ILocalAIAPI, LocalAIStreamEvent } from "@/shared/types/local-ai"; + +export const LOCAL_AI_CHANNELS = { + LIST_PROVIDERS: "local-ai:list-providers", + GET_PROVIDER_STATUS: "local-ai:get-provider-status", + START_CHAT: "local-ai:start-chat", + ABORT: "local-ai:abort", + RESPOND_INTERACTION: "local-ai:respond-interaction", + EVENT: "local-ai:event", +} as const; + +/** + * Electron-free so both the preload (real `ipcRenderer`) and the browser + * (HTTP shim) can build the same API. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type IPCListener = (...args: any[]) => void; + +export interface LocalAIRendererIPC { + invoke: (channel: string, ...args: unknown[]) => Promise; + on: (channel: string, listener: IPCListener) => void; + removeListener: (channel: string, listener: IPCListener) => void; +} + +export function createLocalAIAPI(rendererIPC: LocalAIRendererIPC): ILocalAIAPI { + const invoke = rendererIPC.invoke.bind(rendererIPC) as ( + channel: string, + ...args: unknown[] + ) => Promise; + + return { + listProviders: () => invoke(LOCAL_AI_CHANNELS.LIST_PROVIDERS), + getProviderStatus: (providerId) => + invoke(LOCAL_AI_CHANNELS.GET_PROVIDER_STATUS, providerId), + startChat: (request) => invoke(LOCAL_AI_CHANNELS.START_CHAT, request), + abort: (requestId) => invoke(LOCAL_AI_CHANNELS.ABORT, requestId), + respondToInteraction: (requestId, interactionId, response) => + invoke( + LOCAL_AI_CHANNELS.RESPOND_INTERACTION, + requestId, + interactionId, + response, + ), + onEvent: (requestId, callback) => { + const handler = (_event: unknown, event: LocalAIStreamEvent) => { + if (event.requestId === requestId) callback(event); + }; + rendererIPC.on(LOCAL_AI_CHANNELS.EVENT, handler); + return () => { + rendererIPC.removeListener(LOCAL_AI_CHANNELS.EVENT, handler); + }; + }, + }; +} diff --git a/packages/app/src/electro-bridge/ipc/local-ai-context.ts b/packages/app/src/electro-bridge/ipc/local-ai-context.ts index 77735324..83b2534a 100644 --- a/packages/app/src/electro-bridge/ipc/local-ai-context.ts +++ b/packages/app/src/electro-bridge/ipc/local-ai-context.ts @@ -1,5 +1,4 @@ import type { - ILocalAIAPI, LocalAIChatRequest, LocalAIInteractionResponse, LocalAIProviderStatus, @@ -9,28 +8,22 @@ import type { LocalAIStartResult, LocalAIStreamEvent, } from "@/shared/types/local-ai"; +import { createLocalAIAPI, LOCAL_AI_CHANNELS } from "./local-ai-api"; import { contextBridge, ipcMain, ipcRenderer, type IpcMain, type IpcMainInvokeEvent, - type IpcRenderer, type WebContents, } from "electron"; -export const LOCAL_AI_CHANNELS = { - LIST_PROVIDERS: "local-ai:list-providers", - GET_PROVIDER_STATUS: "local-ai:get-provider-status", - START_CHAT: "local-ai:start-chat", - ABORT: "local-ai:abort", - RESPOND_INTERACTION: "local-ai:respond-interaction", - EVENT: "local-ai:event", -} as const; +export { createLocalAIAPI, LOCAL_AI_CHANNELS } from "./local-ai-api"; export interface LocalAIIPCOptions { runtime?: LocalAIRuntimeService; - getAllowedWebContents: () => WebContents | null; + /** The renderer, plus any web bridge sender standing in for a browser tab. */ + getAllowedWebContents: () => WebContents | WebContents[] | null; } interface ActiveRequest { @@ -98,13 +91,18 @@ export function serializeLocalAIError( export function isAllowedLocalAISender( event: IpcMainInvokeEvent, - allowedWebContents: WebContents | null, + allowedWebContents: WebContents | WebContents[] | null, ): boolean { + if (!allowedWebContents || event.sender.isDestroyed()) return false; + + const allowed = Array.isArray(allowedWebContents) + ? allowedWebContents + : [allowedWebContents]; + if ( - !allowedWebContents || - allowedWebContents.isDestroyed() || - event.sender.isDestroyed() || - event.sender !== allowedWebContents + !allowed.some( + (candidate) => candidate === event.sender && !candidate.isDestroyed(), + ) ) { return false; } @@ -539,36 +537,6 @@ export function setupLocalAIIPC( }; } -export function createLocalAIAPI( - rendererIPC: Pick, -): ILocalAIAPI { - return { - listProviders: () => rendererIPC.invoke(LOCAL_AI_CHANNELS.LIST_PROVIDERS), - getProviderStatus: (providerId) => - rendererIPC.invoke(LOCAL_AI_CHANNELS.GET_PROVIDER_STATUS, providerId), - startChat: (request) => - rendererIPC.invoke(LOCAL_AI_CHANNELS.START_CHAT, request), - abort: (requestId) => - rendererIPC.invoke(LOCAL_AI_CHANNELS.ABORT, requestId), - respondToInteraction: (requestId, interactionId, response) => - rendererIPC.invoke( - LOCAL_AI_CHANNELS.RESPOND_INTERACTION, - requestId, - interactionId, - response, - ), - onEvent: (requestId, callback) => { - const handler = (_event: unknown, event: LocalAIStreamEvent) => { - if (event.requestId === requestId) callback(event); - }; - rendererIPC.on(LOCAL_AI_CHANNELS.EVENT, handler); - return () => { - rendererIPC.removeListener(LOCAL_AI_CHANNELS.EVENT, handler); - }; - }, - }; -} - export function exposeLocalAIContext() { contextBridge.exposeInMainWorld("localAI", createLocalAIAPI(ipcRenderer)); } diff --git a/packages/app/src/electro-bridge/ipc/mcp-api.ts b/packages/app/src/electro-bridge/ipc/mcp-api.ts new file mode 100644 index 00000000..be4565fc --- /dev/null +++ b/packages/app/src/electro-bridge/ipc/mcp-api.ts @@ -0,0 +1,37 @@ +import type { IMcpAPI, MCPServerConfig } from "@/shared/types/mcp"; + +/** + * Electron-free so both the preload (real `ipcRenderer`) and the browser + * (HTTP shim) can build the same API from one channel list. + */ +export interface McpRendererIPC { + invoke: (channel: string, ...args: unknown[]) => Promise; +} + +export function createMcpAPI(rendererIPC: McpRendererIPC): IMcpAPI { + const invoke = rendererIPC.invoke.bind(rendererIPC) as ( + channel: string, + ...args: unknown[] + ) => Promise; + + return { + getServers: () => invoke("mcp:getServers"), + getAllTools: () => invoke("mcp:getAllTools"), + startServer: (serverId: string) => invoke("mcp:startServer", serverId), + stopServer: (serverId: string) => invoke("mcp:stopServer", serverId), + getConfigurations: () => invoke("mcp:getConfigurations"), + addServer: (serverId: string, config: MCPServerConfig) => + invoke("mcp:addServer", serverId, config), + updateServer: (serverId: string, config: MCPServerConfig) => + invoke("mcp:updateServer", serverId, config), + removeServer: (serverId: string) => invoke("mcp:removeServer", serverId), + callTool: ( + serverId: string, + toolName: string, + args: Record, + ) => invoke("mcp:callTool", serverId, toolName, args), + mcpToolCall: (toolName: string, args: Record) => + invoke("mcp:mcpToolCall", toolName, args), + getAllNonInputParamTool: () => invoke("mcp:getAllNonInputParamTool"), + }; +} diff --git a/packages/app/src/electro-bridge/ipc/mcp-context.ts b/packages/app/src/electro-bridge/ipc/mcp-context.ts index 1dab6a70..e8bcb90e 100644 --- a/packages/app/src/electro-bridge/ipc/mcp-context.ts +++ b/packages/app/src/electro-bridge/ipc/mcp-context.ts @@ -1,13 +1,16 @@ +import { contextBridge, ipcMain, ipcRenderer, type IpcMain } from "electron"; import type { MCPServerConfig } from "@/shared/types/mcp"; -import { contextBridge, ipcMain, ipcRenderer } from "electron"; +import { createMcpAPI } from "./mcp-api"; import { getAllTools, getMCPHub } from "../../electron/mcp"; /** * Setup MCP IPC handlers in main process */ -export function setupMCPIPC() { +export function setupMCPIPC( + mainIPC: Pick = ipcMain, +) { // Get all server statuses including builtin tools - ipcMain.handle("mcp:getServers", async () => { + mainIPC.handle("mcp:getServers", async () => { try { const hub = getMCPHub(); if (!hub) { @@ -22,7 +25,7 @@ export function setupMCPIPC() { }); // Get all tools in simplified format for chat functionality - ipcMain.handle("mcp:getAllTools", async () => { + mainIPC.handle("mcp:getAllTools", async () => { try { const tools = getAllTools(); return { success: true, data: tools }; @@ -32,7 +35,7 @@ export function setupMCPIPC() { }); // Start specific server - ipcMain.handle("mcp:startServer", async (_, serverId: string) => { + mainIPC.handle("mcp:startServer", async (_, serverId: string) => { try { const hub = getMCPHub(); if (!hub) { @@ -47,7 +50,7 @@ export function setupMCPIPC() { }); // Stop specific server - ipcMain.handle("mcp:stopServer", async (_, serverId: string) => { + mainIPC.handle("mcp:stopServer", async (_, serverId: string) => { try { const hub = getMCPHub(); if (!hub) { @@ -62,7 +65,7 @@ export function setupMCPIPC() { }); // Get server configurations - ipcMain.handle("mcp:getConfigurations", async () => { + mainIPC.handle("mcp:getConfigurations", async () => { try { const hub = getMCPHub(); if (!hub) { @@ -77,7 +80,7 @@ export function setupMCPIPC() { }); // Update server configuration - ipcMain.handle( + mainIPC.handle( "mcp:updateServer", async (_, serverId: string, config: MCPServerConfig) => { try { @@ -95,7 +98,7 @@ export function setupMCPIPC() { ); // Add new server (manual configuration) - ipcMain.handle( + mainIPC.handle( "mcp:addServer", async (_, serverId: string, config: MCPServerConfig) => { try { @@ -113,7 +116,7 @@ export function setupMCPIPC() { ); // Remove server - ipcMain.handle("mcp:removeServer", async (_, serverId: string) => { + mainIPC.handle("mcp:removeServer", async (_, serverId: string) => { try { const hub = getMCPHub(); if (!hub) { @@ -128,7 +131,7 @@ export function setupMCPIPC() { }); // Call tool on server - ipcMain.handle( + mainIPC.handle( "mcp:callTool", async ( _, @@ -151,7 +154,7 @@ export function setupMCPIPC() { ); // Simplified tool call - finds first server with the tool - ipcMain.handle( + mainIPC.handle( "mcp:mcpToolCall", async (_, toolName: string, args: Record) => { try { @@ -169,7 +172,7 @@ export function setupMCPIPC() { ); // Get all tools that don't require input parameters - ipcMain.handle("mcp:getAllNonInputParamTool", async () => { + mainIPC.handle("mcp:getAllNonInputParamTool", async () => { try { const hub = getMCPHub(); if (!hub) { @@ -190,28 +193,5 @@ export function setupMCPIPC() { * Expose MCP context to renderer process */ export function exposeMCPContext() { - contextBridge.exposeInMainWorld("mcpAPI", { - getServers: () => ipcRenderer.invoke("mcp:getServers"), - getAllTools: () => ipcRenderer.invoke("mcp:getAllTools"), - startServer: (serverId: string) => - ipcRenderer.invoke("mcp:startServer", serverId), - stopServer: (serverId: string) => - ipcRenderer.invoke("mcp:stopServer", serverId), - getConfigurations: () => ipcRenderer.invoke("mcp:getConfigurations"), - addServer: (serverId: string, config: MCPServerConfig) => - ipcRenderer.invoke("mcp:addServer", serverId, config), - updateServer: (serverId: string, config: MCPServerConfig) => - ipcRenderer.invoke("mcp:updateServer", serverId, config), - removeServer: (serverId: string) => - ipcRenderer.invoke("mcp:removeServer", serverId), - callTool: ( - serverId: string, - toolName: string, - args: Record, - ) => ipcRenderer.invoke("mcp:callTool", serverId, toolName, args), - mcpToolCall: (toolName: string, args: Record) => - ipcRenderer.invoke("mcp:mcpToolCall", toolName, args), - getAllNonInputParamTool: () => - ipcRenderer.invoke("mcp:getAllNonInputParamTool"), - }); + contextBridge.exposeInMainWorld("mcpAPI", createMcpAPI(ipcRenderer)); } diff --git a/packages/app/src/electron/main.ts b/packages/app/src/electron/main.ts index e623b600..f8bbdec5 100644 --- a/packages/app/src/electron/main.ts +++ b/packages/app/src/electron/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, globalShortcut } from "electron"; +import { app, BrowserWindow, globalShortcut, ipcMain } from "electron"; import { getLogger, initializeLogger } from "@/electron/logger"; import { @@ -16,6 +16,16 @@ import { ListenerOptions, registerListeners, } from "@/electro-bridge/ipc/listeners-register"; +import { + createWebBridgeEvent, + createRecordingIpcMain, + WebBridgeSender, +} from "@/electron/web-bridge/dispatch"; +import { + isWebBridgeEnabled, + startWebBridge, + type WebBridgeHandle, +} from "@/electron/web-bridge/server"; import { createSystemTray, destroySystemTray } from "./tray"; import { createMainWindow, @@ -26,6 +36,8 @@ import { // Initialize logger for main process const logger = getLogger("main-process"); +let webBridge: WebBridgeHandle | undefined; +let webBridgeSender: WebBridgeSender | undefined; const localAIRuntime = new LocalAiRuntime({ getToolGroups: async () => { await initializeMCPHub(); @@ -119,16 +131,36 @@ app.whenReady().then(async () => { mainWindow.focus(); } + const recordingIPC = isWebBridgeEnabled() + ? createRecordingIpcMain(ipcMain) + : undefined; + // Set up options for the new unified listener system const listenerOptions: ListenerOptions = { mainWindow: () => getMainWindow(), registerGlobalShortcuts, localAIRuntime, + ipc: recordingIPC, + extraLocalAISenders: () => + webBridgeSender ? [webBridgeSender as never] : [], }; logger.debug("Registering IPC listeners"); registerListeners(listenerOptions); + if (recordingIPC) { + // The sender is created first; its emit closure reads `webBridge` lazily. + const sender = new WebBridgeSender((channel, payload) => + webBridge?.emit(channel, payload), + ); + webBridgeSender = sender; + webBridge = await startWebBridge({ + rendererURL: MAIN_WINDOW_VITE_DEV_SERVER_URL || undefined, + invoke: (channel, args) => + recordingIPC.dispatch(channel, args, createWebBridgeEvent(sender)), + }); + } + app.on("activate", () => { const mainWin = getMainWindow(); if (mainWin) { @@ -156,6 +188,10 @@ app.on("will-quit", () => { hub.cleanup(); console.log("MCP Hub cleaned up"); } + webBridgeSender?.destroy(); + void webBridge?.close().catch((error) => { + logger.error("Web bridge cleanup failed:", error); + }); void localAIRuntime.dispose().catch((error) => { logger.error("Local AI runtime cleanup failed:", error); }); diff --git a/packages/app/src/electron/web-bridge/dispatch.ts b/packages/app/src/electron/web-bridge/dispatch.ts new file mode 100644 index 00000000..f4b92a40 --- /dev/null +++ b/packages/app/src/electron/web-bridge/dispatch.ts @@ -0,0 +1,84 @@ +import type { IpcMain, IpcMainInvokeEvent, WebContents } from "electron"; +import { EventEmitter } from "node:events"; + +type InvokeHandler = (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown; + +export interface RecordingIpcMain + extends Pick { + /** Call a handler registered through this proxy, bypassing Electron IPC. */ + dispatch: ( + channel: string, + args: unknown[], + event: IpcMainInvokeEvent, + ) => Promise; +} + +/** + * Wraps `ipcMain` so every registered handler stays reachable by channel name. + * Electron exposes no way to call its own handlers, and the web bridge needs + * exactly that — the alternative is duplicating every handler for HTTP. + */ +export function createRecordingIpcMain(target: IpcMain): RecordingIpcMain { + const handlers = new Map(); + + return { + handle: (channel: string, handler: InvokeHandler) => { + handlers.set(channel, handler); + target.handle(channel, handler); + }, + removeHandler: (channel: string) => { + handlers.delete(channel); + target.removeHandler(channel); + }, + dispatch: async (channel, args, event) => { + const handler = handlers.get(channel); + if (!handler) { + throw new Error(`No IPC handler registered for "${channel}"`); + } + return handler(event, ...args); + }, + }; +} + +/** + * A WebContents stand-in for browser clients. `local-ai-context` identifies + * senders by object reference and pushes stream events through `send`, so the + * browser gets the same lifecycle (per-request tracking, abort on disconnect) + * as a real renderer without any special-casing there. + */ +export class WebBridgeSender extends EventEmitter { + readonly id = -1; + readonly mainFrame = {}; + + private destroyed = false; + + constructor( + private readonly emit_: (channel: string, payload: unknown) => void, + ) { + super(); + } + + isDestroyed() { + return this.destroyed; + } + + send(channel: string, payload: unknown) { + if (this.destroyed) return; + this.emit_(channel, payload); + } + + destroy() { + if (this.destroyed) return; + this.destroyed = true; + this.emit("destroyed"); + } +} + +export function createWebBridgeEvent( + sender: WebBridgeSender, +): IpcMainInvokeEvent { + return { + sender: sender as unknown as WebContents, + senderFrame: sender.mainFrame, + } as unknown as IpcMainInvokeEvent; +} diff --git a/packages/app/src/electron/web-bridge/integration.test.ts b/packages/app/src/electron/web-bridge/integration.test.ts new file mode 100644 index 00000000..f9c4b499 --- /dev/null +++ b/packages/app/src/electron/web-bridge/integration.test.ts @@ -0,0 +1,179 @@ +import type { + LocalAIRuntimeService, + LocalAIStreamEvent, +} from "@/shared/types/local-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/electron/logger", () => ({ + getLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock("electron", () => ({ + contextBridge: { exposeInMainWorld: vi.fn() }, + ipcMain: { handle: vi.fn(), removeHandler: vi.fn() }, + ipcRenderer: { invoke: vi.fn(), on: vi.fn(), removeListener: vi.fn() }, +})); + +import { createLocalAIAPI } from "@/electro-bridge/ipc/local-ai-api"; +import { setupLocalAIIPC } from "@/electro-bridge/ipc/local-ai-context"; +import { ipcMain } from "electron"; +import WebSocket from "ws"; +import { + createRecordingIpcMain, + createWebBridgeEvent, + WebBridgeSender, +} from "./dispatch"; +import { startWebBridge, type WebBridgeHandle } from "./server"; +import { + WEB_BRIDGE_INVOKE_PATH, + WEB_BRIDGE_TOKEN_HEADER, + type WebBridgeEventFrame, +} from "@/shared/web-bridge/protocol"; + +let bridge: WebBridgeHandle | undefined; + +afterEach(async () => { + await bridge?.close(); + bridge = undefined; +}); + +/** + * The browser-side shim, minus the DOM: same shape `createLocalAIAPI` expects, + * talking to the bridge over real HTTP and a real WebSocket. + */ +function createBrowserIPC(handle: WebBridgeHandle, socket: WebSocket) { + const listeners = new Map void>>(); + + socket.on("message", (raw) => { + const frame = JSON.parse(String(raw)) as WebBridgeEventFrame; + listeners + .get(frame.channel) + ?.forEach((listener) => listener(null, frame.payload)); + }); + + return { + invoke: async (channel: string, ...args: unknown[]) => { + const response = await fetch(`${handle.url}${WEB_BRIDGE_INVOKE_PATH}`, { + method: "POST", + headers: { + "content-type": "application/json", + [WEB_BRIDGE_TOKEN_HEADER]: handle.token, + }, + body: JSON.stringify({ channel, args }), + }); + const body = await response.json(); + if (!body.ok) throw new Error(body.error); + return body.data; + }, + on: (channel: string, listener: (...args: unknown[]) => void) => { + const set = listeners.get(channel) ?? new Set(); + set.add(listener); + listeners.set(channel, set); + }, + removeListener: ( + channel: string, + listener: (...args: unknown[]) => void, + ) => { + listeners.get(channel)?.delete(listener); + }, + }; +} + +describe("web bridge end to end", () => { + it("drives a local AI chat from a browser client and streams events back", async () => { + const runtime: LocalAIRuntimeService = { + listProviders: async () => [ + { + id: "claude-code", + name: "Claude Code", + kind: "claude-code", + availability: "available", + }, + ], + getProviderStatus: async () => ({ + id: "claude-code", + name: "Claude Code", + kind: "claude-code", + availability: "available", + }), + startChat: async (request, emit) => { + emit({ + type: "ui-message", + requestId: request.requestId, + chunk: { + type: "text-delta", + id: "t1", + delta: "hello from the runtime", + }, + }); + }, + abort: async () => true, + respondToInteraction: async () => false, + }; + + const recordingIPC = createRecordingIpcMain(ipcMain); + const sender = new WebBridgeSender((channel, payload) => + bridge?.emit(channel, payload), + ); + + setupLocalAIIPC( + { runtime, getAllowedWebContents: () => [sender as never] }, + recordingIPC, + ); + + bridge = await startWebBridge({ + port: 45921, + invoke: (channel, args) => + recordingIPC.dispatch(channel, args, createWebBridgeEvent(sender)), + }); + + const socket = new WebSocket( + `ws://127.0.0.1:45921/ipc/events?token=${bridge.token}`, + ); + await new Promise((resolve) => socket.on("open", resolve)); + + const api = createLocalAIAPI(createBrowserIPC(bridge, socket)); + + // The browser reaches the real ipcMain handler through the bridge. + const providers = await api.listProviders(); + expect(providers).toEqual({ + success: true, + data: [ + { + id: "claude-code", + name: "Claude Code", + kind: "claude-code", + availability: "available", + }, + ], + }); + + const received: LocalAIStreamEvent[] = []; + const unsubscribe = api.onEvent("req-1", (event) => received.push(event)); + + const started = await api.startChat({ + requestId: "req-1", + providerId: "claude-code", + messages: [{ role: "user", content: "hi" }], + }); + expect(started).toEqual({ success: true, accepted: true }); + + // Stream events travel main process -> WebSocket -> browser callback. + await vi.waitFor(() => + expect(received.some((event) => event.type === "finish")).toBe(true), + ); + expect(received[0]).toMatchObject({ + type: "ui-message", + requestId: "req-1", + chunk: { type: "text-delta", delta: "hello from the runtime" }, + }); + + unsubscribe(); + socket.close(); + }); +}); diff --git a/packages/app/src/electron/web-bridge/server.test.ts b/packages/app/src/electron/web-bridge/server.test.ts new file mode 100644 index 00000000..9dcaec60 --- /dev/null +++ b/packages/app/src/electron/web-bridge/server.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/electron/logger", () => ({ + getLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +import { WEB_BRIDGE_TOKEN_HEADER } from "@/shared/web-bridge/protocol"; +import { startWebBridge, type WebBridgeHandle } from "./server"; + +let bridge: WebBridgeHandle | undefined; + +afterEach(async () => { + await bridge?.close(); + bridge = undefined; +}); + +async function invoke( + handle: WebBridgeHandle, + channel: string, + args: unknown[] = [], + overrides: { token?: string; origin?: string } = {}, +) { + const headers: Record = { + "content-type": "application/json", + [WEB_BRIDGE_TOKEN_HEADER]: overrides.token ?? handle.token, + }; + if (overrides.origin) headers.origin = overrides.origin; + + return fetch(`${handle.url}/ipc/invoke`, { + method: "POST", + headers, + body: JSON.stringify({ channel, args }), + }); +} + +describe("web bridge server", () => { + it("dispatches allowlisted channels and blocks everything else", async () => { + const invokeSpy = vi.fn(async (channel: string, args: unknown[]) => ({ + channel, + args, + })); + bridge = await startWebBridge({ invoke: invokeSpy, port: 45911 }); + + const allowed = await invoke(bridge, "local-ai:list-providers", []); + expect(allowed.status).toBe(200); + expect(await allowed.json()).toEqual({ + ok: true, + data: { channel: "local-ai:list-providers", args: [] }, + }); + + // Window control is deliberately not reachable from a browser tab. + const blocked = await invoke(bridge, "window:close", []); + expect(blocked.status).toBe(403); + expect(invokeSpy).toHaveBeenCalledTimes(1); + }); + + it("rejects a wrong token and a non-loopback origin", async () => { + const invokeSpy = vi.fn(async () => "ok"); + bridge = await startWebBridge({ invoke: invokeSpy, port: 45912 }); + + const badToken = await invoke(bridge, "local-ai:list-providers", [], { + token: "wrong-token", + }); + expect(badToken.status).toBe(403); + + const badOrigin = await invoke(bridge, "local-ai:list-providers", [], { + origin: "https://evil.example.com", + }); + expect(badOrigin.status).toBe(403); + + const goodOrigin = await invoke(bridge, "local-ai:list-providers", [], { + origin: "http://127.0.0.1:5199", + }); + expect(goodOrigin.status).toBe(200); + + expect(invokeSpy).toHaveBeenCalledTimes(1); + }); + + it("reports handler failures as ok:false instead of throwing", async () => { + bridge = await startWebBridge({ + invoke: async () => { + throw new Error("runtime exploded"); + }, + port: 45913, + }); + + const response = await invoke(bridge, "mcp:getServers", []); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + ok: false, + error: "runtime exploded", + }); + }); +}); diff --git a/packages/app/src/electron/web-bridge/server.ts b/packages/app/src/electron/web-bridge/server.ts new file mode 100644 index 00000000..886c912e --- /dev/null +++ b/packages/app/src/electron/web-bridge/server.ts @@ -0,0 +1,298 @@ +import { getLogger } from "@/electron/logger"; +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; +import { WebSocketServer, type WebSocket } from "ws"; +import { + WEB_BRIDGE_DEFAULT_PORT, + WEB_BRIDGE_EVENT_PATH, + WEB_BRIDGE_INVOKE_PATH, + type WebBridgeEventFrame, + type WebBridgeInvokeRequest, + type WebBridgeInvokeResponse, +} from "@/shared/web-bridge/protocol"; + +const logger = getLogger("web-bridge"); + +const MAX_BODY_BYTES = 8 * 1024 * 1024; + +/** + * Channels the browser is allowed to reach. Window control, clipboard and + * global shortcuts are deliberately absent — they are meaningless outside + * Electron, and every extra channel here is extra attack surface. + */ +const ALLOWED_INVOKE_CHANNELS = new Set([ + "local-ai:list-providers", + "local-ai:get-provider-status", + "local-ai:start-chat", + "local-ai:abort", + "local-ai:respond-interaction", + "mcp:getServers", + "mcp:getAllTools", + "mcp:startServer", + "mcp:stopServer", + "mcp:getConfigurations", + "mcp:addServer", + "mcp:updateServer", + "mcp:removeServer", + "mcp:callTool", + "mcp:mcpToolCall", + "mcp:getAllNonInputParamTool", +]); + +/** Channels the main process may push to the browser. */ +const ALLOWED_EVENT_CHANNELS = new Set(["local-ai:event"]); + +export interface WebBridgeOptions { + /** Dispatch an invoke to the already-registered ipcMain handler. */ + invoke: (channel: string, args: unknown[]) => Promise; + port?: number; + host?: string; + /** Renderer dev server URL, used to print a ready-to-open browser link. */ + rendererURL?: string; +} + +export interface WebBridgeHandle { + url: string; + token: string; + /** Renderer URL with bridge + token already attached. */ + browserURL: string; + /** Push an event frame to every connected browser client. */ + emit: (channel: string, payload: unknown) => void; + close: () => Promise; +} + +export function isWebBridgeEnabled(): boolean { + return process.env.CONVERA_WEB_BRIDGE === "1"; +} + +function tokensMatch(provided: string, expected: string): boolean { + const a = Buffer.from(provided); + const b = Buffer.from(expected); + return a.length === b.length && timingSafeEqual(a, b); +} + +/** + * Reject cross-origin callers. Combined with binding to loopback this is what + * stops a random page on the machine from driving the local CLI agents: + * a browser will not let a page on evil.com set Origin, and a null/absent + * Origin (curl) still needs the token. + */ +function isAllowedOrigin(origin: string | undefined): boolean { + if (!origin) return true; // non-browser caller; token check still applies + try { + // ponytail: any loopback port is fine — the vite dev port is not fixed. + const { hostname } = new URL(origin); + return hostname === "127.0.0.1" || hostname === "localhost"; + } catch { + return false; + } +} + +function readBody(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + request.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + reject(new Error("Request body too large")); + request.destroy(); + return; + } + chunks.push(chunk); + }); + request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + request.on("error", reject); + }); +} + +function sendJSON(response: ServerResponse, status: number, body: unknown) { + const payload = JSON.stringify(body); + response.writeHead(status, { + "content-type": "application/json", + "access-control-allow-origin": "*", + "access-control-allow-headers": "content-type, x-convera-bridge-token", + "access-control-allow-methods": "POST, OPTIONS", + }); + response.end(payload); +} + +function parseInvokeRequest(raw: string): WebBridgeInvokeRequest | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const candidate = parsed as Record; + if (typeof candidate.channel !== "string") return null; + if (candidate.args !== undefined && !Array.isArray(candidate.args)) { + return null; + } + return { + channel: candidate.channel, + args: (candidate.args as unknown[]) ?? [], + }; +} + +export async function startWebBridge( + options: WebBridgeOptions, +): Promise { + const host = options.host ?? "127.0.0.1"; + const port = + options.port ?? + Number(process.env.CONVERA_WEB_BRIDGE_PORT ?? WEB_BRIDGE_DEFAULT_PORT); + const token = randomBytes(24).toString("hex"); + const clients = new Set(); + + const authorize = ( + tokenHeader: string | string[] | undefined, + origin: string | undefined, + ): boolean => { + const provided = Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader; + if (!provided || !tokensMatch(provided, token)) return false; + return isAllowedOrigin(origin); + }; + + const httpServer = createServer((request, response) => { + if (request.method === "OPTIONS") { + sendJSON(response, 204, {}); + return; + } + if (request.method !== "POST" || request.url !== WEB_BRIDGE_INVOKE_PATH) { + sendJSON(response, 404, { error: "Not found" }); + return; + } + if ( + !authorize( + request.headers["x-convera-bridge-token"], + request.headers.origin, + ) + ) { + sendJSON(response, 403, { error: "Forbidden" }); + return; + } + + void (async () => { + let invokeRequest: WebBridgeInvokeRequest | null = null; + try { + invokeRequest = parseInvokeRequest(await readBody(request)); + } catch (error) { + sendJSON(response, 413, { error: String(error) }); + return; + } + + if (!invokeRequest) { + sendJSON(response, 400, { error: "Malformed invoke request" }); + return; + } + if (!ALLOWED_INVOKE_CHANNELS.has(invokeRequest.channel)) { + sendJSON(response, 403, { + error: `Channel "${invokeRequest.channel}" is not exposed over the web bridge`, + }); + return; + } + + try { + const data = await options.invoke( + invokeRequest.channel, + invokeRequest.args, + ); + sendJSON(response, 200, { + ok: true, + data, + } satisfies WebBridgeInvokeResponse); + } catch (error) { + sendJSON(response, 200, { + ok: false, + error: error instanceof Error ? error.message : String(error), + } satisfies WebBridgeInvokeResponse); + } + })(); + }); + + const wsServer = new WebSocketServer({ noServer: true }); + + httpServer.on("upgrade", (request, socket, head) => { + const url = new URL(request.url ?? "/", `http://${host}:${port}`); + if ( + url.pathname !== WEB_BRIDGE_EVENT_PATH || + !authorize( + url.searchParams.get("token") ?? undefined, + request.headers.origin, + ) + ) { + socket.destroy(); + return; + } + wsServer.handleUpgrade(request, socket, head, (ws) => { + clients.add(ws); + ws.on("close", () => clients.delete(ws)); + ws.on("error", () => clients.delete(ws)); + }); + }); + + // Fall forward when the port is taken, the way vite does — a second dev + // instance should just work rather than crash on EADDRINUSE. + const boundPort = await new Promise((resolve, reject) => { + let candidate = port; + + const onError = (error: NodeJS.ErrnoException) => { + if (error.code === "EADDRINUSE" && candidate < port + 20) { + candidate += 1; + httpServer.listen(candidate, host); + return; + } + reject(error); + }; + + httpServer.on("error", onError); + httpServer.listen(candidate, host, () => { + httpServer.removeListener("error", onError); + resolve(candidate); + }); + }); + + const url = `http://${host}:${boundPort}`; + logger.info(`Web bridge listening on ${url}`); + + // Print the whole link, not just the token: without the query string the + // page loads but installs nothing, and the UI can only say "runtime is not + // available". The token never leaves this machine. + const rendererURL = options.rendererURL ?? "http://localhost:5199/"; + const browserURL = new URL(rendererURL); + browserURL.searchParams.set("bridge", url); + browserURL.searchParams.set("token", token); + console.log( + `\n Convera is ready in the browser:\n\n ${browserURL}\n\n (bridge on ${url} — the token in that link is what authorizes this tab)\n`, + ); + + return { + url, + token, + browserURL: browserURL.toString(), + emit: (channel, payload) => { + if (!ALLOWED_EVENT_CHANNELS.has(channel)) return; + const frame: WebBridgeEventFrame = { channel, payload }; + const message = JSON.stringify(frame); + clients.forEach((client) => { + try { + client.send(message); + } catch { + clients.delete(client); + } + }); + }, + close: async () => { + clients.forEach((client) => client.close()); + clients.clear(); + wsServer.close(); + await new Promise((resolve) => httpServer.close(() => resolve())); + }, + }; +} diff --git a/packages/app/src/renderer.ts b/packages/app/src/renderer.ts index 5c469d36..08c3ba85 100644 --- a/packages/app/src/renderer.ts +++ b/packages/app/src/renderer.ts @@ -1 +1,4 @@ +// Must come first: populates window.localAI / window.mcpAPI when running in a +// plain browser, before any app module reads them. No-op under Electron. +import "@/renderer/libs/web-bridge/install"; import "@/renderer/app"; diff --git a/packages/app/src/renderer/components/chat/input/context-button.tsx b/packages/app/src/renderer/components/chat/input/context-button.tsx index f3a80ffc..ff9f1840 100644 --- a/packages/app/src/renderer/components/chat/input/context-button.tsx +++ b/packages/app/src/renderer/components/chat/input/context-button.tsx @@ -36,7 +36,7 @@ const FileBadge = ({ return (
{isImage && preview ? ( @@ -49,7 +49,7 @@ const FileBadge = ({ {file.name}

Welcome to Convera

-

+

Ask me anything about coding, tech, or problems you're facing with your projects.

diff --git a/packages/app/src/renderer/components/chat/message/tool-call.tsx b/packages/app/src/renderer/components/chat/message/tool-call.tsx index 44183e92..c495da8c 100644 --- a/packages/app/src/renderer/components/chat/message/tool-call.tsx +++ b/packages/app/src/renderer/components/chat/message/tool-call.tsx @@ -65,7 +65,7 @@ const ToolCall = ({ toolPart }: ToolCallProps) => {
{!isCompleted ? ( -
+
Waiting for results...
diff --git a/packages/app/src/renderer/components/chat/popover/agent-popover.tsx b/packages/app/src/renderer/components/chat/popover/agent-popover.tsx index 87421256..bafd82ac 100644 --- a/packages/app/src/renderer/components/chat/popover/agent-popover.tsx +++ b/packages/app/src/renderer/components/chat/popover/agent-popover.tsx @@ -655,7 +655,7 @@ export default function AgentPopover() { >
-
+
{tool.id === "websearch" ? ( ) : tool.id === "thinking" ? ( ) : ( - + {tool.name.charAt(0)} )} @@ -747,9 +747,7 @@ export default function AgentPopover() {