Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
83 changes: 83 additions & 0 deletions packages/app/scripts/web-bridge-dev.mts
Original file line number Diff line number Diff line change
@@ -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);
32 changes: 24 additions & 8 deletions packages/app/src/electro-bridge/ipc/listeners-register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<IpcMain, "handle" | "removeHandler">;
}

/**
Expand Down Expand Up @@ -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");
}
54 changes: 54 additions & 0 deletions packages/app/src/electro-bridge/ipc/local-ai-api.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
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 <T>(
channel: string,
...args: unknown[]
) => Promise<T>;

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);
};
},
};
}
60 changes: 14 additions & 46 deletions packages/app/src/electro-bridge/ipc/local-ai-context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type {
ILocalAIAPI,
LocalAIChatRequest,
LocalAIInteractionResponse,
LocalAIProviderStatus,
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -539,36 +537,6 @@ export function setupLocalAIIPC(
};
}

export function createLocalAIAPI(
rendererIPC: Pick<IpcRenderer, "invoke" | "on" | "removeListener">,
): 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));
}
37 changes: 37 additions & 0 deletions packages/app/src/electro-bridge/ipc/mcp-api.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
}

export function createMcpAPI(rendererIPC: McpRendererIPC): IMcpAPI {
const invoke = rendererIPC.invoke.bind(rendererIPC) as <T>(
channel: string,
...args: unknown[]
) => Promise<T>;

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<string, unknown>,
) => invoke("mcp:callTool", serverId, toolName, args),
mcpToolCall: (toolName: string, args: Record<string, unknown>) =>
invoke("mcp:mcpToolCall", toolName, args),
getAllNonInputParamTool: () => invoke("mcp:getAllNonInputParamTool"),
};
}
Loading
Loading