diff --git a/packages/app/automation/driver.test.ts b/packages/app/automation/driver.test.ts new file mode 100644 index 00000000..462f7b77 --- /dev/null +++ b/packages/app/automation/driver.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; +import { ConveraDriver } from "./driver.js"; + +describe("ConveraDriver click", () => { + it("returns the pre-click snapshot when the clicked element disappears", async () => { + let exists = true; + const click = vi.fn(async () => { + exists = false; + }); + const element = { + attributes: [{ name: "role", value: "option" }], + click, + doubleClick: vi.fn(), + getTagName: vi.fn(async () => "button"), + getText: vi.fn(async () => "Alpha"), + getValue: vi.fn(async () => null), + isClickable: vi.fn(async () => exists), + isDisplayed: vi.fn(async () => exists), + isEnabled: vi.fn(async () => true), + isExisting: vi.fn(async () => exists), + waitForDisplayed: vi.fn(async () => undefined), + waitForExist: vi.fn(async () => undefined), + }; + const browser = { + $: vi.fn(async () => element), + execute: vi.fn( + async ( + operation: (node: typeof element) => unknown, + node: typeof element, + ) => operation(node), + ), + waitUntil: vi.fn(async (condition: () => Promise) => { + if (!(await condition())) throw new Error("condition not met"); + return true; + }), + }; + const driver = new ConveraDriver(); + Reflect.set(driver, "browser", browser); + + await expect(driver.click('[role="option"]')).resolves.toMatchObject({ + selector: '[role="option"]', + tag: "button", + text: "Alpha", + displayed: true, + enabled: true, + clickable: true, + attributes: { role: "option" }, + action: "click", + completed: true, + }); + expect(click).toHaveBeenCalledOnce(); + expect(browser.$).toHaveBeenCalledTimes(2); + expect(element.isExisting).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/automation/driver.ts b/packages/app/automation/driver.ts index 37470c8c..e2c72961 100644 --- a/packages/app/automation/driver.ts +++ b/packages/app/automation/driver.ts @@ -547,9 +547,14 @@ export class ConveraDriver { async click(selector: string, double = false) { const element = await this.readyElement(selector, true); + const before = await this.inspectElement(selector); if (double) await element.doubleClick(); else await element.click(); - return this.inspectElement(selector); + return { + ...before, + action: double ? "double_click" : "click", + completed: true, + }; } async hover(selector: string) { diff --git a/packages/app/package.json b/packages/app/package.json index 25812943..5343e771 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -30,8 +30,7 @@ "test:all": "vitest run && playwright test", "automation": "WDIO_LOG_LEVEL=silent tsx automation/server.ts", "automation:prepare": "electron-forge package && tsx automation/prepare-driver.ts", - "automation:typecheck": "tsc --noEmit -p automation/tsconfig.json", - "chat-server": "ts-node src/server/startServer.ts" + "automation:typecheck": "tsc --noEmit -p automation/tsconfig.json" }, "author": "Smal1boy <541898146chen@gmail.com>", "license": "MIT", @@ -88,19 +87,11 @@ "webdriverio": "9.27.1" }, "dependencies": { - "@ai-sdk/openai": "^1.3.10", - "@daveyplate/better-auth-tanstack": "1.3.6", - "@daveyplate/better-auth-ui": "1.7.4", "@fontsource/inter": "^5.2.5", - "@google-cloud/speech": "^7.1.0", - "@hono/node-server": "^1.14.3", - "@hono/zod-validator": "^0.7.0", - "@hookform/resolvers": "^5.0.1", "@hurdlegroup/robotjs": "^0.12.3", "@icons-pack/react-simple-icons": "^12.2.0", "@leeoniya/ufuzzy": "^1.0.18", "@modelcontextprotocol/sdk": "1.12.3", - "@openrouter/ai-sdk-provider": "^0.4.5", "@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-alert-dialog": "^1.1.7", "@radix-ui/react-aspect-ratio": "^1.1.3", @@ -130,25 +121,24 @@ "@smithery/sdk": "^1.0.4", "@tailwindcss/typography": "^0.5.15", "@tailwindcss/vite": "^4.0.14", - "@tanstack/react-query": "^5.69.0", "@tanstack/react-router": "^1.120.18", "@tanstack/router-devtools": "^1.120.18", "@tiptap/extension-placeholder": "^2.11.7", "@tiptap/pm": "^2.11.7", "@tiptap/react": "^2.11.7", "@tiptap/starter-kit": "^2.11.7", - "@types/cors": "^2.8.17", "@types/hast": "^3.0.4", "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.3.4", - "ai": "^4.3.19", + "ai": "^6.0.238", + "ai-sdk-provider-claude-code": "3.1.0", + "ai-sdk-provider-codex-cli": "^1.3.1", "babel-plugin-react-compiler": "^19.0.0-beta-e993439-20250328", "bufferutil": "^4.0.9", "cheerio": "^1.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "cors": "^2.8.5", "date-fns": "^3.6.0", "dexie": "^4.2.1", "dexie-react-hooks": "^4.2.0", @@ -157,14 +147,11 @@ "embla-carousel-react": "^8.6.0", "framer-motion": "^12.6.3", "get-windows": "^9.2.0", - "hono": "^4.7.11", "i18next": "^24.2.3", "input-otp": "^1.4.2", "lucide-react": "^0.487.0", "next-themes": "^0.4.6", - "node-record-lpcm16": "^1.0.1", "node-window-manager": "^2.2.4", - "openai": "^4.93.0", "react": "^19.0.0", "react-day-picker": "^8.10.1", "react-dom": "^19.0.0", @@ -172,7 +159,6 @@ "react-i18next": "^15.4.1", "react-resizable-panels": "^2.1.7", "recharts": "^2.15.2", - "reconnecting-eventsource": "^1.6.4", "remark": "^15.0.1", "remark-html": "^16.0.1", "sonner": "^2.0.3", @@ -183,7 +169,7 @@ "uuid": "^11.1.0", "vaul": "^1.1.2", "ws": "^8.18.1", - "zod": "^3.24.4", + "zod": "^3.25.76", "zustand": "^5.0.4" }, "lint-staged": { diff --git a/packages/app/src/electro-bridge/ipc/listeners-register.ts b/packages/app/src/electro-bridge/ipc/listeners-register.ts index 678290df..7f848254 100644 --- a/packages/app/src/electro-bridge/ipc/listeners-register.ts +++ b/packages/app/src/electro-bridge/ipc/listeners-register.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ 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 { getAppIcon, getPlatform } from "./active-app-context"; import { CHANNELS, IPCServer, methodChannelMap } from "./channels"; @@ -25,6 +26,7 @@ import { updateGlobalShortcut, } from "./ipc-handlers"; import { setupLoggerIPC } from "./logger-context"; +import { setupLocalAIIPC } from "./local-ai-context"; import { setupMCPIPC } from "./mcp-context"; // Extended interface that includes additional methods beyond IPCServer @@ -88,6 +90,7 @@ export function createElectronAPI(ipcRenderer: IpcRenderer): ElectronAPI { export interface ListenerOptions { mainWindow?: () => BrowserWindow | null; registerGlobalShortcuts?: () => void; + localAIRuntime?: LocalAIRuntimeService; } /** @@ -231,5 +234,12 @@ export function registerListeners(options: ListenerOptions = {}) { setupLoggerIPC(); setupElectronAPIIPC(options); setupEnvIPC(); + setupLocalAIIPC({ + runtime: options.localAIRuntime, + getAllowedWebContents: () => { + const window = options.mainWindow?.(); + return window && !window.isDestroyed() ? window.webContents : null; + }, + }); console.log("All IPC listeners registered successfully"); } diff --git a/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts b/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts new file mode 100644 index 00000000..e4ef7998 --- /dev/null +++ b/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts @@ -0,0 +1,476 @@ +import type { + LocalAIRuntimeService, + LocalAIStreamEvent, +} from "@/shared/types/local-ai"; +import { EventEmitter } from "node:events"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +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, + LOCAL_AI_CHANNELS, + serializeLocalAIError, + setupLocalAIIPC, +} from "./local-ai-context"; + +type Handler = (event: FakeInvokeEvent, ...args: unknown[]) => unknown; + +class FakeWebContents extends EventEmitter { + readonly id: number; + readonly mainFrame = {}; + readonly sent: Array<{ channel: string; event: LocalAIStreamEvent }> = []; + private destroyed = false; + + constructor(id: number) { + super(); + this.id = id; + } + + isDestroyed() { + return this.destroyed; + } + + send(channel: string, event: LocalAIStreamEvent) { + this.sent.push({ channel, event }); + } + + destroy() { + this.destroyed = true; + this.emit("destroyed"); + } +} + +interface FakeInvokeEvent { + sender: FakeWebContents; + senderFrame: object; +} + +function createMainIPC() { + const handlers = new Map(); + return { + handlers, + ipc: { + handle: (channel: string, handler: Handler) => { + handlers.set(channel, handler); + }, + removeHandler: (channel: string) => { + handlers.delete(channel); + }, + }, + }; +} + +function createEvent(sender: FakeWebContents): FakeInvokeEvent { + return { sender, senderFrame: sender.mainFrame }; +} + +function createRuntime( + overrides: Partial = {}, +): LocalAIRuntimeService { + return { + listProviders: vi.fn(() => []), + getProviderStatus: vi.fn(() => ({ + id: "codex", + name: "Codex", + kind: "codex-cli" as const, + availability: "available" as const, + })), + startChat: vi.fn(), + abort: vi.fn(() => true), + respondToInteraction: vi.fn(() => false), + ...overrides, + }; +} + +describe("local AI IPC", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("isolates stream events by request id and removes its exact listener", () => { + const listeners = new Map void>(); + const rendererIPC = { + invoke: vi.fn(), + on: vi.fn((channel: string, handler: (...args: unknown[]) => void) => { + listeners.set(channel, handler); + }), + removeListener: vi.fn( + (channel: string, handler: (...args: unknown[]) => void) => { + if (listeners.get(channel) === handler) listeners.delete(channel); + }, + ), + }; + const api = createLocalAIAPI(rendererIPC as never); + const callback = vi.fn(); + + const unsubscribe = api.onEvent("request-1", callback); + const listener = listeners.get(LOCAL_AI_CHANNELS.EVENT); + listener?.( + {}, + { + type: "ui-message", + requestId: "request-2", + chunk: { type: "text-delta", id: "text-1", delta: "ignore" }, + }, + ); + listener?.( + {}, + { + type: "ui-message", + requestId: "request-1", + chunk: { type: "text-delta", id: "text-1", delta: "hello" }, + }, + ); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith({ + type: "ui-message", + requestId: "request-1", + chunk: { type: "text-delta", id: "text-1", delta: "hello" }, + }); + + unsubscribe(); + expect(rendererIPC.removeListener).toHaveBeenCalledWith( + LOCAL_AI_CHANNELS.EVENT, + listener, + ); + expect(listeners.has(LOCAL_AI_CHANNELS.EVENT)).toBe(false); + }); + + it("streams only to the sender that owns an accepted request", async () => { + const allowedSender = new FakeWebContents(1); + const otherSender = new FakeWebContents(2); + const runtime = createRuntime({ + startChat: vi.fn((_request, emit) => { + emit({ + type: "ui-message", + requestId: "runtime-cannot-change-owner", + chunk: { type: "text-delta", id: "text-1", delta: "hello" }, + }); + emit({ + type: "finish", + requestId: "runtime-cannot-change-owner", + finishReason: "stop", + }); + }), + }); + const { handlers, ipc } = createMainIPC(); + setupLocalAIIPC( + { + runtime, + getAllowedWebContents: () => allowedSender as never, + }, + ipc as never, + ); + const start = handlers.get(LOCAL_AI_CHANNELS.START_CHAT); + const request = { + requestId: "request-1", + providerId: "codex-cli", + messages: [{ role: "user", content: "hello" }], + }; + + const forbidden = start?.(createEvent(otherSender), request); + expect(forbidden).toMatchObject({ + success: false, + accepted: false, + error: { code: "LOCAL_AI_FORBIDDEN" }, + }); + + const accepted = start?.(createEvent(allowedSender), request); + expect(accepted).toEqual({ success: true, accepted: true }); + await vi.waitFor(() => { + expect(allowedSender.sent).toHaveLength(2); + }); + expect(allowedSender.sent).toEqual([ + { + channel: LOCAL_AI_CHANNELS.EVENT, + event: { + type: "ui-message", + requestId: "request-1", + chunk: { type: "text-delta", id: "text-1", delta: "hello" }, + }, + }, + { + channel: LOCAL_AI_CHANNELS.EVENT, + event: { + type: "finish", + requestId: "request-1", + finishReason: "stop", + }, + }, + ]); + expect(otherSender.sent).toEqual([]); + }); + + it("rejects unsupported providers and oversized renderer input", () => { + const sender = new FakeWebContents(1); + const runtime = createRuntime(); + const { handlers, ipc } = createMainIPC(); + setupLocalAIIPC( + { + runtime, + getAllowedWebContents: () => sender as never, + }, + ipc as never, + ); + const start = handlers.get(LOCAL_AI_CHANNELS.START_CHAT); + const baseRequest = { + requestId: "request-1", + messages: [{ role: "user", content: "hello" }], + }; + + expect( + start?.(createEvent(sender), { + ...baseRequest, + providerId: "remote-service", + }), + ).toMatchObject({ + success: false, + accepted: false, + error: { code: "LOCAL_AI_INVALID_REQUEST" }, + }); + expect( + start?.(createEvent(sender), { + ...baseRequest, + providerId: "claude-code", + messages: [{ role: "user", content: "x".repeat(200_001) }], + }), + ).toMatchObject({ + success: false, + accepted: false, + error: { code: "LOCAL_AI_INVALID_REQUEST" }, + }); + expect(runtime.startChat).not.toHaveBeenCalled(); + }); + + it("accepts interaction responses only from the active request owner", async () => { + const allowedSender = new FakeWebContents(1); + const otherSender = new FakeWebContents(2); + const runtime = createRuntime({ + startChat: vi.fn(() => new Promise(() => undefined)), + respondToInteraction: vi.fn(() => true), + }); + const { handlers, ipc } = createMainIPC(); + setupLocalAIIPC( + { + runtime, + getAllowedWebContents: () => allowedSender as never, + }, + ipc as never, + ); + const start = handlers.get(LOCAL_AI_CHANNELS.START_CHAT); + const respond = handlers.get(LOCAL_AI_CHANNELS.RESPOND_INTERACTION); + + start?.(createEvent(allowedSender), { + requestId: "request-1", + providerId: "claude-code", + messages: [{ role: "user", content: "hello" }], + }); + + await expect( + respond?.(createEvent(allowedSender), "request-1", "interaction-1", { + approved: true, + }), + ).resolves.toEqual({ + success: true, + data: { accepted: true }, + }); + expect(runtime.respondToInteraction).toHaveBeenCalledWith( + "request-1", + "interaction-1", + { approved: true }, + ); + + await expect( + respond?.(createEvent(otherSender), "request-1", "interaction-1", { + approved: true, + }), + ).resolves.toMatchObject({ + success: false, + error: { code: "LOCAL_AI_FORBIDDEN" }, + }); + }); + + it("rejects malformed interaction responses", async () => { + const sender = new FakeWebContents(1); + const runtime = createRuntime(); + const { handlers, ipc } = createMainIPC(); + setupLocalAIIPC( + { + runtime, + getAllowedWebContents: () => sender as never, + }, + ipc as never, + ); + const respond = handlers.get(LOCAL_AI_CHANNELS.RESPOND_INTERACTION); + + await expect( + respond?.(createEvent(sender), "request-1", "interaction-1", { + approved: "yes", + }), + ).resolves.toMatchObject({ + success: false, + error: { code: "LOCAL_AI_INVALID_REQUEST" }, + }); + expect(runtime.respondToInteraction).not.toHaveBeenCalled(); + }); + + it("aborts active work when its webContents is destroyed", async () => { + const sender = new FakeWebContents(1); + let resolveChat: (() => void) | undefined; + const runtime = createRuntime({ + startChat: vi.fn( + () => + new Promise((resolve) => { + resolveChat = resolve; + }), + ), + }); + const { handlers, ipc } = createMainIPC(); + setupLocalAIIPC( + { + runtime, + getAllowedWebContents: () => sender as never, + }, + ipc as never, + ); + const start = handlers.get(LOCAL_AI_CHANNELS.START_CHAT); + + start?.(createEvent(sender), { + requestId: "request-1", + providerId: "codex-cli", + messages: [{ role: "user", content: "hello" }], + }); + sender.destroy(); + + expect(runtime.abort).toHaveBeenCalledWith("request-1"); + resolveChat?.(); + }); + + it("makes an accepted abort terminal and releases the request id", async () => { + const sender = new FakeWebContents(1); + const pendingChats: Array<() => void> = []; + const runtime = createRuntime({ + startChat: vi.fn( + () => + new Promise((resolve) => { + pendingChats.push(resolve); + }), + ), + abort: vi.fn(() => true), + }); + const { handlers, ipc } = createMainIPC(); + setupLocalAIIPC( + { + runtime, + getAllowedWebContents: () => sender as never, + }, + ipc as never, + ); + const start = handlers.get(LOCAL_AI_CHANNELS.START_CHAT); + const abort = handlers.get(LOCAL_AI_CHANNELS.ABORT); + const request = { + requestId: "request-1", + providerId: "codex-cli", + messages: [{ role: "user", content: "hello" }], + }; + + expect(start?.(createEvent(sender), request)).toEqual({ + success: true, + accepted: true, + }); + await expect(abort?.(createEvent(sender), "request-1")).resolves.toEqual({ + success: true, + data: { aborted: true }, + }); + expect(sender.sent.at(-1)).toEqual({ + channel: LOCAL_AI_CHANNELS.EVENT, + event: { + type: "finish", + requestId: "request-1", + finishReason: "aborted", + }, + }); + + expect(start?.(createEvent(sender), request)).toEqual({ + success: true, + accepted: true, + }); + pendingChats.forEach((resolve) => resolve()); + }); + + it("serializes a synchronous runtime failure and releases the request", async () => { + const sender = new FakeWebContents(1); + const runtime = createRuntime({ + startChat: vi.fn(() => { + throw Object.assign(new Error("CLI failed"), { + code: "CLI_EXITED", + }); + }), + }); + const { handlers, ipc } = createMainIPC(); + setupLocalAIIPC( + { + runtime, + getAllowedWebContents: () => sender as never, + }, + ipc as never, + ); + const start = handlers.get(LOCAL_AI_CHANNELS.START_CHAT); + const request = { + requestId: "request-1", + providerId: "codex-cli", + messages: [{ role: "user", content: "hello" }], + }; + + expect(start?.(createEvent(sender), request)).toEqual({ + success: true, + accepted: true, + }); + await vi.waitFor(() => { + expect(sender.sent).toHaveLength(2); + }); + expect(sender.sent).toMatchObject([ + { + event: { + type: "error", + requestId: "request-1", + error: { code: "CLI_EXITED", message: "CLI failed" }, + }, + }, + { + event: { + type: "finish", + requestId: "request-1", + finishReason: "error", + }, + }, + ]); + + expect(start?.(createEvent(sender), request)).toEqual({ + success: true, + accepted: true, + }); + }); + + it("serializes Error fields without crossing the process boundary", () => { + const error = Object.assign(new Error("CLI failed"), { + code: "CLI_EXITED", + }); + + expect(serializeLocalAIError(error)).toMatchObject({ + name: "Error", + message: "CLI failed", + code: "CLI_EXITED", + }); + }); +}); diff --git a/packages/app/src/electro-bridge/ipc/local-ai-context.ts b/packages/app/src/electro-bridge/ipc/local-ai-context.ts new file mode 100644 index 00000000..96e9cf4b --- /dev/null +++ b/packages/app/src/electro-bridge/ipc/local-ai-context.ts @@ -0,0 +1,527 @@ +import type { + ILocalAIAPI, + LocalAIChatRequest, + LocalAIInteractionResponse, + LocalAIProviderStatus, + LocalAIResult, + LocalAIRuntimeService, + LocalAISerializableError, + LocalAIStartResult, + LocalAIStreamEvent, +} from "@/shared/types/local-ai"; +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 interface LocalAIIPCOptions { + runtime?: LocalAIRuntimeService; + getAllowedWebContents: () => WebContents | null; +} + +interface ActiveRequest { + sender: WebContents; +} + +interface SenderRequests { + sender: WebContents; + requestIds: Set; + onDestroyed: () => void; +} + +const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/; +const ALLOWED_PROVIDER_IDS = new Set(["claude-code", "codex-cli"]); +const MAX_MESSAGE_CHARS = 200_000; +const MAX_REQUEST_CHARS = 1_000_000; +const MAX_INTERACTION_RESPONSE_CHARS = 20_000; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function createError(message: string, code: string): LocalAISerializableError { + return { name: "LocalAIIPCError", message, code }; +} + +export function serializeLocalAIError( + error: unknown, +): LocalAISerializableError { + if (error instanceof Error) { + const code = (error as Error & { code?: unknown }).code; + return { + name: error.name || "Error", + message: error.message || String(error), + ...(typeof code === "string" || typeof code === "number" + ? { code: String(code) } + : {}), + ...(error.stack ? { stack: error.stack } : {}), + }; + } + + if (isRecord(error)) { + const name = typeof error.name === "string" ? error.name : "Error"; + const message = + typeof error.message === "string" ? error.message : String(error); + const code = + typeof error.code === "string" || typeof error.code === "number" + ? String(error.code) + : undefined; + return { name, message, ...(code ? { code } : {}) }; + } + + return { name: "Error", message: String(error) }; +} + +export function isAllowedLocalAISender( + event: IpcMainInvokeEvent, + allowedWebContents: WebContents | null, +): boolean { + if ( + !allowedWebContents || + allowedWebContents.isDestroyed() || + event.sender.isDestroyed() || + event.sender !== allowedWebContents + ) { + return false; + } + + return event.senderFrame === event.sender.mainFrame; +} + +function validateRequest(request: unknown): request is LocalAIChatRequest { + if ( + !isRecord(request) || + typeof request.requestId !== "string" || + !REQUEST_ID_PATTERN.test(request.requestId) || + typeof request.providerId !== "string" || + !ALLOWED_PROVIDER_IDS.has(request.providerId) || + !Array.isArray(request.messages) || + request.messages.length === 0 || + request.messages.length > 1_000 + ) { + return false; + } + + let totalChars = 0; + return request.messages.every((message) => { + if ( + isRecord(message) && + (message.role === "system" || + message.role === "user" || + message.role === "assistant") && + typeof message.content === "string" && + message.content.length <= MAX_MESSAGE_CHARS + ) { + totalChars += message.content.length; + return totalChars <= MAX_REQUEST_CHARS; + } + return false; + }); +} + +function validateInteractionResponse( + response: unknown, +): response is LocalAIInteractionResponse { + if (!isRecord(response)) return false; + + const keys = Object.keys(response); + if ( + keys.length === 0 || + keys.some((key) => key !== "approved" && key !== "value") + ) { + return false; + } + + return ( + (response.approved === undefined || + typeof response.approved === "boolean") && + (response.value === undefined || + (typeof response.value === "string" && + response.value.length <= MAX_INTERACTION_RESPONSE_CHARS)) + ); +} + +function failure(error: unknown): LocalAIResult { + return { success: false, error: serializeLocalAIError(error) }; +} + +/** + * Register the privileged side of the local AI bridge. + * + * The runtime is injected so this bridge has no dependency on a particular + * Claude, Codex, or OpenAI-compatible implementation. + */ +export function setupLocalAIIPC( + options: LocalAIIPCOptions, + mainIPC: Pick = ipcMain, +): () => void { + const activeRequests = new Map(); + const senderRequests = new Map(); + + const runtimeUnavailable = () => + createError( + "Local AI runtime is not available", + "LOCAL_AI_RUNTIME_UNAVAILABLE", + ); + + const removeActiveRequest = (requestId: string) => { + const active = activeRequests.get(requestId); + if (!active) return; + + activeRequests.delete(requestId); + const tracked = senderRequests.get(active.sender.id); + tracked?.requestIds.delete(requestId); + if (tracked && tracked.requestIds.size === 0) { + tracked.sender.removeListener("destroyed", tracked.onDestroyed); + senderRequests.delete(active.sender.id); + } + }; + + const abortAndRemove = (requestId: string) => { + removeActiveRequest(requestId); + if (options.runtime) { + void Promise.resolve(options.runtime.abort(requestId)).catch(() => { + // The sender has gone away; there is nowhere safe to report this. + }); + } + }; + + const trackRequest = (requestId: string, sender: WebContents) => { + activeRequests.set(requestId, { sender }); + + let tracked = senderRequests.get(sender.id); + if (!tracked) { + const onDestroyed = () => { + const requestIds = [ + ...(senderRequests.get(sender.id)?.requestIds ?? []), + ]; + senderRequests.delete(sender.id); + requestIds.forEach(abortAndRemove); + }; + tracked = { sender, requestIds: new Set(), onDestroyed }; + senderRequests.set(sender.id, tracked); + sender.once("destroyed", onDestroyed); + } + tracked.requestIds.add(requestId); + }; + + const ensureSender = (event: IpcMainInvokeEvent) => + isAllowedLocalAISender(event, options.getAllowedWebContents()); + + mainIPC.handle( + LOCAL_AI_CHANNELS.LIST_PROVIDERS, + async (event): Promise> => { + if (!ensureSender(event)) { + return failure( + createError("IPC sender is not allowed", "LOCAL_AI_FORBIDDEN"), + ); + } + if (!options.runtime) return failure(runtimeUnavailable()); + + try { + return { + success: true, + data: await options.runtime.listProviders(), + }; + } catch (error) { + return failure(error); + } + }, + ); + + mainIPC.handle( + LOCAL_AI_CHANNELS.GET_PROVIDER_STATUS, + async ( + event, + providerId: unknown, + ): Promise> => { + if (!ensureSender(event)) { + return failure( + createError("IPC sender is not allowed", "LOCAL_AI_FORBIDDEN"), + ); + } + if (!options.runtime) return failure(runtimeUnavailable()); + if ( + typeof providerId !== "string" || + !ALLOWED_PROVIDER_IDS.has(providerId) + ) { + return failure( + createError("Invalid provider id", "LOCAL_AI_INVALID_REQUEST"), + ); + } + + try { + return { + success: true, + data: await options.runtime.getProviderStatus(providerId), + }; + } catch (error) { + return failure(error); + } + }, + ); + + mainIPC.handle( + LOCAL_AI_CHANNELS.START_CHAT, + (event, request: unknown): LocalAIStartResult => { + if (!ensureSender(event)) { + return { + success: false, + accepted: false, + error: createError("IPC sender is not allowed", "LOCAL_AI_FORBIDDEN"), + }; + } + const runtime = options.runtime; + if (!runtime) { + return { + success: false, + accepted: false, + error: runtimeUnavailable(), + }; + } + if (!validateRequest(request)) { + return { + success: false, + accepted: false, + error: createError( + "Invalid local AI chat request", + "LOCAL_AI_INVALID_REQUEST", + ), + }; + } + if (activeRequests.has(request.requestId)) { + return { + success: false, + accepted: false, + error: createError( + `Request "${request.requestId}" is already active`, + "LOCAL_AI_DUPLICATE_REQUEST", + ), + }; + } + + const sender = event.sender; + trackRequest(request.requestId, sender); + + const emit = (runtimeEvent: LocalAIStreamEvent) => { + const active = activeRequests.get(request.requestId); + if (!active || active.sender !== sender || sender.isDestroyed()) { + return; + } + + const streamEvent: LocalAIStreamEvent = + runtimeEvent.type === "error" + ? { + ...runtimeEvent, + requestId: request.requestId, + error: serializeLocalAIError(runtimeEvent.error), + } + : { ...runtimeEvent, requestId: request.requestId }; + + try { + sender.send(LOCAL_AI_CHANNELS.EVENT, streamEvent); + } catch { + abortAndRemove(request.requestId); + return; + } + + if (streamEvent.type === "finish") { + removeActiveRequest(request.requestId); + } + }; + + void Promise.resolve() + .then(() => runtime.startChat(request, emit)) + .then(() => { + if (activeRequests.has(request.requestId)) { + emit({ + type: "finish", + requestId: request.requestId, + finishReason: "unknown", + }); + } + }) + .catch((error) => { + if (!activeRequests.has(request.requestId)) return; + emit({ + type: "error", + requestId: request.requestId, + error: serializeLocalAIError(error), + }); + emit({ + type: "finish", + requestId: request.requestId, + finishReason: "error", + }); + }); + + return { success: true, accepted: true }; + }, + ); + + mainIPC.handle( + LOCAL_AI_CHANNELS.RESPOND_INTERACTION, + async ( + event, + requestId: unknown, + interactionId: unknown, + response: unknown, + ): Promise> => { + if (!ensureSender(event)) { + return failure( + createError("IPC sender is not allowed", "LOCAL_AI_FORBIDDEN"), + ); + } + if (!options.runtime) return failure(runtimeUnavailable()); + if ( + typeof requestId !== "string" || + !REQUEST_ID_PATTERN.test(requestId) || + typeof interactionId !== "string" || + !REQUEST_ID_PATTERN.test(interactionId) || + !validateInteractionResponse(response) + ) { + return failure( + createError( + "Invalid local AI interaction response", + "LOCAL_AI_INVALID_REQUEST", + ), + ); + } + + const active = activeRequests.get(requestId); + if (!active || active.sender !== event.sender) { + return { success: true, data: { accepted: false } }; + } + + try { + return { + success: true, + data: { + accepted: await options.runtime.respondToInteraction( + requestId, + interactionId, + response, + ), + }, + }; + } catch (error) { + return failure(error); + } + }, + ); + + mainIPC.handle( + LOCAL_AI_CHANNELS.ABORT, + async ( + event, + requestId: unknown, + ): Promise> => { + if (!ensureSender(event)) { + return failure( + createError("IPC sender is not allowed", "LOCAL_AI_FORBIDDEN"), + ); + } + if (!options.runtime) return failure(runtimeUnavailable()); + if ( + typeof requestId !== "string" || + !REQUEST_ID_PATTERN.test(requestId) + ) { + return failure( + createError("Invalid request id", "LOCAL_AI_INVALID_REQUEST"), + ); + } + + const active = activeRequests.get(requestId); + if (!active || active.sender !== event.sender) { + return { + success: true, + data: { aborted: false }, + }; + } + + try { + const aborted = await options.runtime.abort(requestId); + const stillActive = activeRequests.get(requestId); + if (aborted && stillActive?.sender === event.sender) { + try { + if (!event.sender.isDestroyed()) { + event.sender.send(LOCAL_AI_CHANNELS.EVENT, { + type: "finish", + requestId, + finishReason: "aborted", + } satisfies LocalAIStreamEvent); + } + } finally { + removeActiveRequest(requestId); + } + } + return { + success: true, + data: { aborted }, + }; + } catch (error) { + return failure(error); + } + }, + ); + + return () => { + Object.values(LOCAL_AI_CHANNELS) + .filter((channel) => channel !== LOCAL_AI_CHANNELS.EVENT) + .forEach((channel) => mainIPC.removeHandler(channel)); + + [...activeRequests.keys()].forEach(abortAndRemove); + senderRequests.forEach(({ sender, onDestroyed }) => { + sender.removeListener("destroyed", onDestroyed); + }); + senderRequests.clear(); + }; +} + +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/electron/ai/__tests__/agent-tools.test.ts b/packages/app/src/electron/ai/__tests__/agent-tools.test.ts new file mode 100644 index 00000000..28a35319 --- /dev/null +++ b/packages/app/src/electron/ai/__tests__/agent-tools.test.ts @@ -0,0 +1,173 @@ +import type { ToolDefinition } from "@/shared/types/mcp"; +import { describe, expect, it, vi } from "vitest"; +import { createAgentToolCatalog } from "../agent-tools"; + +function definition( + name: string, + overrides: Partial = {}, +): ToolDefinition { + return { + name, + description: `Run ${name}`, + inputSchema: { + type: "object", + properties: { + path: { type: "string", minLength: 1 }, + count: { type: "integer", minimum: 1 }, + }, + required: ["path"], + }, + ...overrides, + }; +} + +describe("createAgentToolCatalog", () => { + it("creates stable namespaced tools and validates their JSON schema", async () => { + const executeTool = vi.fn(async () => ({ ok: true })); + const tools = createAgentToolCatalog({ + groups: [{ serverName: "Repo Tools", tools: [definition("read/file")] }], + executeTool, + requestInteraction: vi.fn(async () => ({ approved: true })), + }); + + expect(tools[0]).toMatchObject({ + name: "repo_tools__read_file", + qualifiedName: "Repo Tools:read/file", + }); + await expect(tools[0].execute({ path: "" })).rejects.toThrow(); + await tools[0].execute({ path: "README.md", count: 2 }); + expect(executeTool).toHaveBeenCalledWith("Repo Tools", "read/file", { + path: "README.md", + count: 2, + }); + }); + + it("requires approval for untrusted MCP tools and open-world builtins", async () => { + const requestInteraction = vi + .fn() + .mockResolvedValueOnce({ approved: false }) + .mockResolvedValueOnce({ approved: true }); + const executeTool = vi.fn(async () => "done"); + const tools = createAgentToolCatalog({ + groups: [ + { + serverName: "external", + tools: [ + definition("read", { + annotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, + }), + ], + }, + { + serverName: "builtin", + tools: [ + definition("web_fetch", { + annotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: true, + }, + }), + ], + }, + ], + executeTool, + requestInteraction, + }); + + await expect(tools[0].execute({ path: "x" })).rejects.toThrow( + "User denied external:read", + ); + await expect(tools[1].execute({ path: "x" })).resolves.toBe("done"); + expect(requestInteraction).toHaveBeenCalledTimes(2); + }); + + it("routes ask_user_input through the renderer interaction channel", async () => { + const executeTool = vi.fn(); + const tools = createAgentToolCatalog({ + groups: [ + { + serverName: "builtin", + tools: [ + { + name: "ask_user_input", + description: "Ask the user", + inputSchema: { + type: "object", + properties: { + question: { type: "string" }, + options: { type: "array", items: { type: "string" } }, + }, + required: ["question", "options"], + }, + }, + ], + }, + ], + executeTool, + requestInteraction: vi.fn(async () => ({ value: "Proceed" })), + }); + + await expect( + tools[0].execute({ + question: "Continue?", + options: ["Proceed", "Stop"], + }), + ).resolves.toMatchObject({ userSelection: "Proceed" }); + expect(executeTool).not.toHaveBeenCalled(); + }); + + it("normalizes structured ask_user_input options for Codex clients", async () => { + const requestInteraction = vi.fn(async () => ({ value: "Alpha" })); + const tools = createAgentToolCatalog({ + groups: [ + { + serverName: "builtin", + tools: [ + { + name: "ask_user_input", + inputSchema: { + type: "object", + properties: { + question: { type: "string" }, + options: { + type: "array", + items: { + anyOf: [ + { type: "string" }, + { + type: "object", + properties: { + label: { type: "string" }, + description: { type: "string" }, + }, + required: ["label"], + }, + ], + }, + }, + }, + required: ["question", "options"], + }, + }, + ], + }, + ], + executeTool: vi.fn(), + requestInteraction, + }); + + await tools[0].execute({ + question: "Choose", + options: [{ label: "Alpha", description: "First" }, { label: "Beta" }], + }); + + expect(requestInteraction).toHaveBeenCalledWith( + expect.objectContaining({ options: ["Alpha", "Beta"] }), + ); + }); +}); diff --git a/packages/app/src/electron/ai/__tests__/claude-environment.test.ts b/packages/app/src/electron/ai/__tests__/claude-environment.test.ts new file mode 100644 index 00000000..849e412a --- /dev/null +++ b/packages/app/src/electron/ai/__tests__/claude-environment.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { pickClaudeEnvironment } from "../claude-environment"; + +describe("pickClaudeEnvironment", () => { + it("passes only Claude and Anthropic environment settings", () => { + expect( + pickClaudeEnvironment({ + ANTHROPIC_AUTH_TOKEN: "token", + ANTHROPIC_BASE_URL: "http://127.0.0.1:8317", + CLAUDE_CODE_USE_BEDROCK: "1", + PATH: "/untrusted/bin", + NODE_OPTIONS: "--require malicious.js", + hooks: "not-an-environment-setting", + INVALID_NUMBER: 1, + }), + ).toEqual({ + ANTHROPIC_AUTH_TOKEN: "token", + ANTHROPIC_BASE_URL: "http://127.0.0.1:8317", + CLAUDE_CODE_USE_BEDROCK: "1", + }); + }); + + it("returns an empty object for invalid settings", () => { + expect(pickClaudeEnvironment(null)).toEqual({}); + expect(pickClaudeEnvironment("not-an-object")).toEqual({}); + }); +}); diff --git a/packages/app/src/electron/ai/__tests__/cli-probe.test.ts b/packages/app/src/electron/ai/__tests__/cli-probe.test.ts new file mode 100644 index 00000000..ed6c4a10 --- /dev/null +++ b/packages/app/src/electron/ai/__tests__/cli-probe.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; +import { probeCliProvider, type CliCommandRunner } from "../cli-probe"; + +describe("probeCliProvider", () => { + it("detects an authenticated Claude Code CLI from the configured path", async () => { + const run = vi.fn(async (_command, args) => { + if (args[0] === "--version") { + return { stdout: "2.1.217 (Claude Code)\n", stderr: "" }; + } + return { + stdout: JSON.stringify({ loggedIn: true, authMethod: "oauth_token" }), + stderr: "", + }; + }); + + const status = await probeCliProvider("claude-code", { + run, + environment: { CONVERA_CLAUDE_PATH: "/custom/claude" }, + homeDirectory: "/home/test", + }); + + expect(status).toMatchObject({ + id: "claude-code", + available: true, + authenticated: true, + version: "2.1.217 (Claude Code)", + executablePath: "/custom/claude", + }); + expect(run).toHaveBeenNthCalledWith(1, "/custom/claude", ["--version"]); + }); + + it("reports an installed but logged-out Codex CLI", async () => { + const run = vi.fn(async (_command, args) => { + if (args[0] === "--version") { + return { stdout: "codex-cli 0.146.0\n", stderr: "" }; + } + throw new Error("Not logged in"); + }); + + const status = await probeCliProvider("codex-cli", { + run, + environment: { CONVERA_CODEX_PATH: "/custom/codex" }, + homeDirectory: "/home/test", + }); + + expect(status).toMatchObject({ + id: "codex-cli", + available: true, + authenticated: false, + version: "codex-cli 0.146.0", + executablePath: "/custom/codex", + detail: "Not logged in", + }); + }); + + it("tries all safe candidates before reporting a missing CLI", async () => { + const run = vi.fn(async () => { + throw new Error("ENOENT"); + }); + + const status = await probeCliProvider("claude-code", { + run, + environment: {}, + homeDirectory: "/home/test", + }); + + expect(status.available).toBe(false); + expect(status.authenticated).toBe(false); + expect(status.detail).toContain("was not found"); + expect(run).toHaveBeenCalledWith("/home/test/.local/bin/claude", [ + "--version", + ]); + expect(run).toHaveBeenCalledWith("claude", ["--version"]); + }); +}); diff --git a/packages/app/src/electron/ai/__tests__/codex-cli-mcp.test.ts b/packages/app/src/electron/ai/__tests__/codex-cli-mcp.test.ts new file mode 100644 index 00000000..46ed56e6 --- /dev/null +++ b/packages/app/src/electron/ai/__tests__/codex-cli-mcp.test.ts @@ -0,0 +1,161 @@ +import type { LocalAIChatRequest } from "@/shared/types/local-ai"; +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { LOCAL_AI_PROVIDER_DESCRIPTORS } from "../provider-descriptors"; +import { CodexCliAdapter } from "../providers/codex-cli"; +import type { LocalAiProviderStatus } from "../types"; + +const mocks = vi.hoisted(() => { + const model = {}; + const provider = Object.assign( + vi.fn(() => model), + { + close: vi.fn(async () => undefined), + listModels: vi.fn(async () => ({ + models: [{ id: "gpt-test" }], + defaultModel: { id: "gpt-test" }, + })), + }, + ); + + return { + model, + provider, + createCodexAppServer: vi.fn(() => provider), + tool: vi.fn((definition) => definition), + }; +}); + +vi.mock("ai-sdk-provider-codex-cli", () => ({ + createCodexAppServer: mocks.createCodexAppServer, + tool: mocks.tool, +})); + +function providerSettings() { + const calls = mocks.provider.mock.calls as unknown as Array< + [ + string, + { + mcpServers?: { convera?: unknown }; + serverRequests?: { + onMcpElicitation?: (request: { + id: number; + method: string; + params: Record; + }) => Promise; + }; + }, + ] + >; + return calls.at(-1)?.[1]; +} + +describe("CodexCliAdapter MCP transport", () => { + it("attaches Convera tools without the obsolete RMCP feature flag", async () => { + const adapter = new CodexCliAdapter(); + const request: LocalAIChatRequest = { + requestId: "test", + providerId: "codex-cli", + modelId: "gpt-test", + messages: [{ role: "user", content: "use a tool" }], + options: { cwd: "/tmp/convera-test" }, + }; + const status: LocalAiProviderStatus = { + ...LOCAL_AI_PROVIDER_DESCRIPTORS["codex-cli"], + available: true, + authenticated: true, + executablePath: "/test/codex", + defaultModel: "gpt-test", + models: ["gpt-test"], + checkedAt: new Date(0).toISOString(), + }; + + await adapter.createModel(request, status, { + tools: [ + { + name: "builtin__probe", + qualifiedName: "builtin:probe", + description: "Probe the local MCP bridge", + inputSchema: { + type: "object", + properties: { value: { type: "string" } }, + }, + inputShape: { value: z.string() }, + inputValidator: z.object({ value: z.string() }), + execute: vi.fn(async () => "PROBE_OK"), + }, + ], + requestInteraction: vi.fn(async () => ({ approved: false })), + }); + + const mcpServer = providerSettings()?.mcpServers?.convera; + expect(mcpServer).toEqual( + expect.objectContaining({ + name: "convera", + _start: expect.any(Function), + _stop: expect.any(Function), + }), + ); + expect(mocks.provider).toHaveBeenCalledWith( + "gpt-test", + expect.objectContaining({ + cwd: "/tmp/convera-test", + mcpServers: { convera: mcpServer }, + }), + ); + expect(providerSettings()).not.toHaveProperty("rmcpClient"); + + await adapter.dispose(); + }); + + it("accepts MCP tool calls with structured empty content", async () => { + const adapter = new CodexCliAdapter(); + const request: LocalAIChatRequest = { + requestId: "test", + providerId: "codex-cli", + modelId: "gpt-test", + messages: [{ role: "user", content: "use a tool" }], + }; + const status: LocalAiProviderStatus = { + ...LOCAL_AI_PROVIDER_DESCRIPTORS["codex-cli"], + available: true, + authenticated: true, + executablePath: "/test/codex", + defaultModel: "gpt-test", + models: ["gpt-test"], + checkedAt: new Date(0).toISOString(), + }; + + await adapter.createModel(request, status, { + tools: [ + { + name: "builtin__probe", + qualifiedName: "builtin:probe", + description: "Probe the local MCP bridge", + inputSchema: { type: "object", properties: {} }, + inputShape: {}, + inputValidator: z.object({}), + execute: vi.fn(async () => "PROBE_OK"), + }, + ], + requestInteraction: vi.fn(async () => ({ approved: false })), + }); + + const settings = providerSettings(); + const handler = settings?.serverRequests?.onMcpElicitation; + expect(handler).toBeTypeOf("function"); + await expect( + handler?.({ + id: 1, + method: "mcpServer/elicitation/request", + params: { + threadId: "thread", + serverName: "convera", + _meta: { codex_approval_kind: "mcp_tool_call" }, + }, + }), + ).resolves.toEqual({ action: "accept", content: {} }); + + await adapter.dispose(); + }); +}); diff --git a/packages/app/src/electron/ai/__tests__/codex-cli.test.ts b/packages/app/src/electron/ai/__tests__/codex-cli.test.ts new file mode 100644 index 00000000..a4c67618 --- /dev/null +++ b/packages/app/src/electron/ai/__tests__/codex-cli.test.ts @@ -0,0 +1,39 @@ +import type { LocalAIChatRequest } from "@/shared/types/local-ai"; +import { describe, expect, it } from "vitest"; +import { ZodEffects } from "zod"; +import { LOCAL_AI_PROVIDER_DESCRIPTORS } from "../provider-descriptors"; +import { CodexCliAdapter } from "../providers/codex-cli"; +import type { LocalAiProviderStatus } from "../types"; + +describe("CodexCliAdapter", () => { + it("loads app-server with Zod 3 without retaining the upstream shim", async () => { + const effectsPrototype = + ZodEffects.prototype as typeof ZodEffects.prototype & { + passthrough?: unknown; + }; + expect(effectsPrototype.passthrough).toBeUndefined(); + + const adapter = new CodexCliAdapter(); + const request: LocalAIChatRequest = { + requestId: "test", + providerId: "codex-cli", + messages: [{ role: "user", content: "hello" }], + }; + const status: LocalAiProviderStatus = { + ...LOCAL_AI_PROVIDER_DESCRIPTORS["codex-cli"], + available: true, + authenticated: true, + executablePath: "/test/codex", + checkedAt: new Date(0).toISOString(), + }; + + const model = await adapter.createModel(request, status, { + tools: [], + requestInteraction: async () => ({ approved: false }), + }); + + expect(model).toBeDefined(); + expect(effectsPrototype.passthrough).toBeUndefined(); + await adapter.dispose(); + }); +}); diff --git a/packages/app/src/electron/ai/__tests__/runtime.test.ts b/packages/app/src/electron/ai/__tests__/runtime.test.ts new file mode 100644 index 00000000..d3ac491a --- /dev/null +++ b/packages/app/src/electron/ai/__tests__/runtime.test.ts @@ -0,0 +1,378 @@ +import type { + LocalAIChatRequest, + LocalAIStreamEvent, +} from "@/shared/types/local-ai"; +import type { LanguageModel } from "ai"; +import { describe, expect, it, vi } from "vitest"; +import { + resolveLocalModelId, + type LocalAiProviderAdapter, +} from "../provider-adapter"; +import { LOCAL_AI_PROVIDER_DESCRIPTORS } from "../provider-descriptors"; +import { LocalAiRuntime, type RuntimeStreamInvoker } from "../runtime"; +import type { LocalAiProviderId, LocalAiProviderStatus } from "../types"; + +function fakeAdapter( + id: LocalAiProviderId, + overrides: Partial = {}, +): LocalAiProviderAdapter { + const status: LocalAiProviderStatus = { + ...LOCAL_AI_PROVIDER_DESCRIPTORS[id], + available: true, + authenticated: true, + version: "test-version", + executablePath: `/test/${id}`, + checkedAt: new Date(0).toISOString(), + ...overrides, + }; + + return { + id, + getStatus: vi.fn(async () => status), + createModel: vi.fn(async () => ({}) as LanguageModel), + dispose: vi.fn(async () => undefined), + }; +} + +function request( + overrides: Partial = {}, +): LocalAIChatRequest { + return { + requestId: "request-1", + providerId: "claude-code", + messages: [{ role: "user", content: "hello" }], + ...overrides, + }; +} + +describe("LocalAiRuntime", () => { + it("maps the renderer default sentinel to the provider default model", () => { + expect(resolveLocalModelId(undefined, "provider-default")).toBe( + "provider-default", + ); + expect(resolveLocalModelId("default", "provider-default")).toBe( + "provider-default", + ); + expect(resolveLocalModelId(" explicit-model ", "provider-default")).toBe( + "explicit-model", + ); + }); + + it("maps internal CLI probes to the shared provider status contract", async () => { + const runtime = new LocalAiRuntime({ + adapters: [ + fakeAdapter("claude-code", { + authenticated: false, + detail: "Run claude login", + }), + ], + }); + + const providers = await runtime.listProviders(); + + expect(providers).toEqual([ + expect.objectContaining({ + id: "claude-code", + kind: "claude-code", + availability: "unauthenticated", + detail: "Run claude login ยท test-version", + }), + expect.objectContaining({ + id: "codex-cli", + availability: "unavailable", + }), + ]); + }); + + it("forwards the AI SDK UI stream, usage, and explicit agent context", async () => { + const events: LocalAIStreamEvent[] = []; + let streamOptions: Parameters[0] | undefined; + const streamInvoker: RuntimeStreamInvoker = (options) => { + streamOptions = options; + return { + toUIMessageStream: async function* () { + yield { type: "start" as const, messageId: "assistant-1" }; + yield { type: "text-start" as const, id: "text-1" }; + yield { type: "text-delta" as const, id: "text-1", delta: "Hi" }; + yield { type: "text-end" as const, id: "text-1" }; + yield { + type: "tool-input-available" as const, + toolCallId: "tool-1", + toolName: "read_file", + input: { path: "README.md" }, + dynamic: true, + }; + yield { + type: "tool-output-available" as const, + toolCallId: "tool-1", + output: "contents", + dynamic: true, + }; + yield { type: "finish" as const, finishReason: "stop" as const }; + }, + finishReason: Promise.resolve("stop"), + usage: Promise.resolve({ + inputTokens: 3, + outputTokens: 2, + totalTokens: 5, + }), + }; + }; + const adapter = fakeAdapter("claude-code"); + const runtime = new LocalAiRuntime({ + adapters: [adapter], + streamInvoker, + workingDirectory: "/trusted/workspace", + }); + + await runtime.startChat( + request({ + agent: { systemPrompt: "Be concise." }, + options: { cwd: "/renderer/controlled" }, + }), + (event) => events.push(event), + ); + + expect(adapter.createModel).toHaveBeenCalledWith( + expect.objectContaining({ + options: { cwd: "/trusted/workspace" }, + }), + expect.any(Object), + expect.objectContaining({ + tools: [], + requestInteraction: expect.any(Function), + }), + ); + expect(streamOptions?.messages).toEqual([ + { role: "system", content: "Be concise." }, + { role: "user", content: "hello" }, + ]); + expect(events).toEqual([ + { + type: "ui-message", + requestId: "request-1", + chunk: { type: "start", messageId: "assistant-1" }, + }, + { + type: "ui-message", + requestId: "request-1", + chunk: { type: "text-start", id: "text-1" }, + }, + { + type: "ui-message", + requestId: "request-1", + chunk: { type: "text-delta", id: "text-1", delta: "Hi" }, + }, + { + type: "ui-message", + requestId: "request-1", + chunk: { type: "text-end", id: "text-1" }, + }, + { + type: "ui-message", + requestId: "request-1", + chunk: { + type: "tool-input-available", + toolCallId: "tool-1", + toolName: "read_file", + input: { path: "README.md" }, + dynamic: true, + }, + }, + { + type: "ui-message", + requestId: "request-1", + chunk: { + type: "tool-output-available", + toolCallId: "tool-1", + output: "contents", + dynamic: true, + }, + }, + { + type: "ui-message", + requestId: "request-1", + chunk: { type: "finish", finishReason: "stop" }, + }, + { + type: "finish", + requestId: "request-1", + finishReason: "stop", + usage: { inputTokens: 3, outputTokens: 2, totalTokens: 5 }, + }, + ]); + }); + + it("aborts an active stream and reports an aborted terminal event", async () => { + const events: LocalAIStreamEvent[] = []; + const streamInvoker: RuntimeStreamInvoker = (options) => ({ + toUIMessageStream: async function* () { + yield { type: "start" as const, messageId: "assistant-1" }; + yield { type: "text-start" as const, id: "text-1" }; + yield { + type: "text-delta" as const, + id: "text-1", + delta: "partial", + }; + await new Promise((resolve) => { + options.abortSignal.addEventListener("abort", () => resolve(), { + once: true, + }); + }); + }, + }); + const adapter = fakeAdapter("claude-code"); + const runtime = new LocalAiRuntime({ + adapters: [adapter], + streamInvoker, + }); + + const chat = runtime.startChat(request(), (event) => events.push(event)); + await vi.waitFor(() => { + expect(events).toContainEqual({ + type: "ui-message", + requestId: "request-1", + chunk: { type: "text-delta", id: "text-1", delta: "partial" }, + }); + }); + + expect(runtime.abort("request-1")).toBe(true); + await chat; + expect(runtime.abort("request-1")).toBe(false); + expect(events.at(-1)).toEqual({ + type: "finish", + requestId: "request-1", + finishReason: "aborted", + }); + + await runtime.dispose(); + expect(adapter.dispose).toHaveBeenCalledOnce(); + }); + + it("pauses an approval-gated tool until the renderer responds", async () => { + const events: LocalAIStreamEvent[] = []; + let toolContext: + | Parameters[2] + | undefined; + const adapter = fakeAdapter("claude-code"); + vi.mocked(adapter.createModel).mockImplementation( + async (_request, _status, context) => { + toolContext = context; + return {} as LanguageModel; + }, + ); + const runtime = new LocalAiRuntime({ + adapters: [adapter], + getToolGroups: () => [ + { + serverName: "external", + tools: [ + { + name: "write_value", + description: "Writes a value", + inputSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, + }, + ], + }, + ], + executeTool: vi.fn(async () => ({ written: true })), + streamInvoker: () => ({ + toUIMessageStream: async function* () { + const tool = toolContext?.tools[0]; + if (!tool) throw new Error("Expected tool context"); + const output = await tool.execute({ value: "ready" }); + yield { + type: "tool-input-available" as const, + toolCallId: "tool-1", + toolName: tool.name, + input: { value: "ready" }, + dynamic: true, + }; + yield { + type: "tool-output-available" as const, + toolCallId: "tool-1", + output, + dynamic: true, + }; + yield { type: "finish" as const, finishReason: "stop" as const }; + }, + }), + }); + + const chat = runtime.startChat(request(), (event) => events.push(event)); + await vi.waitFor(() => { + expect(events[0]).toMatchObject({ + type: "interaction", + requestId: "request-1", + kind: "approval", + name: "external:write_value", + }); + }); + const interaction = events[0]; + if (interaction.type !== "interaction") { + throw new Error("Expected interaction event"); + } + + expect( + runtime.respondToInteraction( + interaction.requestId, + interaction.interactionId, + { approved: true }, + ), + ).toBe(true); + await chat; + + expect(events).toContainEqual({ + type: "ui-message", + requestId: "request-1", + chunk: { + type: "tool-input-available", + toolCallId: "tool-1", + toolName: "external:write_value", + input: { value: "ready" }, + dynamic: true, + }, + }); + expect(events.at(-1)).toEqual({ + type: "finish", + requestId: "request-1", + finishReason: "stop", + usage: undefined, + }); + }); + + it("emits a structured error and terminal event for unavailable auth", async () => { + const events: LocalAIStreamEvent[] = []; + const runtime = new LocalAiRuntime({ + adapters: [ + fakeAdapter("codex-cli", { + authenticated: false, + detail: "Not logged in", + }), + ], + }); + + await runtime.startChat(request({ providerId: "codex-cli" }), (event) => + events.push(event), + ); + + expect(events[0]).toMatchObject({ + type: "error", + requestId: "request-1", + error: { + name: "Error", + message: "Not logged in", + code: "PROVIDER_UNAUTHENTICATED", + }, + }); + expect(events[1]).toEqual({ + type: "finish", + requestId: "request-1", + finishReason: "error", + }); + }); +}); diff --git a/packages/app/src/electron/ai/agent-tools.ts b/packages/app/src/electron/ai/agent-tools.ts new file mode 100644 index 00000000..2c0bbcab --- /dev/null +++ b/packages/app/src/electron/ai/agent-tools.ts @@ -0,0 +1,302 @@ +import type { ToolDefinition } from "@/shared/types/mcp"; +import { z, type ZodRawShape, type ZodTypeAny } from "zod"; + +export interface AgentToolGroup { + serverName: string; + tools: ToolDefinition[]; +} + +export interface AgentToolInteraction { + kind: "approval" | "input"; + name: string; + prompt: string; + input?: unknown; + options?: string[]; +} + +export interface AgentTool { + name: string; + qualifiedName: string; + description: string; + inputSchema: Record; + inputShape: ZodRawShape; + inputValidator: ZodTypeAny; + execute(input: Record): Promise; +} + +export interface AgentToolCatalogOptions { + groups: AgentToolGroup[]; + executeTool( + serverName: string, + toolName: string, + input: Record, + ): Promise; + requestInteraction(interaction: AgentToolInteraction): Promise<{ + approved?: boolean; + value?: string; + }>; +} + +const BUILTIN_SERVER = "builtin"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function toolSchema(tool: ToolDefinition): Record { + const schema = tool.inputSchema ?? tool.parameters; + return isRecord(schema) + ? schema + : { type: "object", properties: {}, additionalProperties: true }; +} + +function zodForSchema(schema: unknown): ZodTypeAny { + if (!isRecord(schema)) return z.unknown(); + + if (Array.isArray(schema.enum) && schema.enum.length > 0) { + const values = schema.enum.filter( + (value): value is string | number | boolean | null => + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean", + ); + if (values.length === 0) return z.unknown(); + const literals = values.map((value) => z.literal(value)); + return literals.length === 1 + ? literals[0] + : z.union( + literals as [ + (typeof literals)[number], + (typeof literals)[number], + ...(typeof literals)[number][], + ], + ); + } + + if ( + "const" in schema && + (schema.const === null || + typeof schema.const === "string" || + typeof schema.const === "number" || + typeof schema.const === "boolean") + ) { + return z.literal(schema.const); + } + + const alternatives = Array.isArray(schema.anyOf) + ? schema.anyOf + : Array.isArray(schema.oneOf) + ? schema.oneOf + : undefined; + if (alternatives?.length) { + const variants = alternatives.map(zodForSchema); + return variants.length === 1 + ? variants[0] + : z.union( + variants as [ + (typeof variants)[number], + (typeof variants)[number], + ...(typeof variants)[number][], + ], + ); + } + + switch (schema.type) { + case "string": { + let value = z.string(); + if (typeof schema.minLength === "number") + value = value.min(schema.minLength); + if (typeof schema.maxLength === "number") + value = value.max(schema.maxLength); + if (typeof schema.pattern === "string") { + try { + value = value.regex(new RegExp(schema.pattern)); + } catch { + return value; + } + } + if (schema.format === "uri" || schema.format === "url") + return value.url(); + return value; + } + case "integer": { + let value = z.number().int(); + if (typeof schema.minimum === "number") value = value.min(schema.minimum); + if (typeof schema.maximum === "number") value = value.max(schema.maximum); + return value; + } + case "number": { + let value = z.number(); + if (typeof schema.minimum === "number") value = value.min(schema.minimum); + if (typeof schema.maximum === "number") value = value.max(schema.maximum); + return value; + } + case "boolean": + return z.boolean(); + case "array": { + let value = z.array(zodForSchema(schema.items)); + if (typeof schema.minItems === "number") + value = value.min(schema.minItems); + if (typeof schema.maxItems === "number") + value = value.max(schema.maxItems); + return value; + } + case "object": + return z.object(shapeForSchema(schema)).passthrough(); + default: + return z.unknown(); + } +} + +export function shapeForSchema(schema: unknown): ZodRawShape { + if (!isRecord(schema) || !isRecord(schema.properties)) return {}; + + const required = new Set( + Array.isArray(schema.required) + ? schema.required.filter( + (property): property is string => typeof property === "string", + ) + : [], + ); + + return Object.fromEntries( + Object.entries(schema.properties).map(([name, propertySchema]) => { + const validator = zodForSchema(propertySchema); + return [name, required.has(name) ? validator : validator.optional()]; + }), + ); +} + +function slug(value: string): string { + return ( + value + .normalize("NFKD") + .replace(/[^A-Za-z0-9_-]+/g, "_") + .replace(/^_+|_+$/g, "") + .toLowerCase() + .slice(0, 48) || "tool" + ); +} + +function stableSuffix(value: string): string { + let hash = 2166136261; + for (const character of value) { + hash ^= character.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(36); +} + +function requiresApproval(serverName: string, tool: ToolDefinition): boolean { + if (serverName.toLowerCase() !== BUILTIN_SERVER) return true; + return ( + tool.annotations?.readOnlyHint !== true || + tool.annotations?.openWorldHint === true + ); +} + +function interactionPrompt( + qualifiedName: string, + input: Record, +): string { + return `Allow ${qualifiedName} to run with these arguments?\n${JSON.stringify( + input, + null, + 2, + )}`; +} + +function interactionOptions(value: unknown): string[] { + if (!Array.isArray(value)) return []; + + return value.flatMap((option) => { + if (typeof option === "string") return [option]; + if ( + isRecord(option) && + typeof option.label === "string" && + option.label.trim() + ) { + return [option.label]; + } + return []; + }); +} + +export function createAgentToolCatalog( + options: AgentToolCatalogOptions, +): AgentTool[] { + const aliases = new Set(); + + return options.groups.flatMap((group) => + group.tools.map((definition) => { + const qualifiedName = `${group.serverName}:${definition.name}`; + const baseAlias = `${slug(group.serverName)}__${slug(definition.name)}`; + const name = aliases.has(baseAlias) + ? `${baseAlias}__${stableSuffix(qualifiedName)}` + : baseAlias; + aliases.add(name); + + const inputSchema = toolSchema(definition); + const inputShape = shapeForSchema(inputSchema); + const inputValidator = z.object(inputShape).passthrough(); + const description = [ + `Fully qualified tool: ${qualifiedName}.`, + definition.description?.trim() || "No description provided.", + "Returns the underlying tool result or an actionable execution error.", + ].join(" "); + + return { + name, + qualifiedName, + description, + inputSchema, + inputShape, + inputValidator, + execute: async (input: Record) => { + const parsed = inputValidator.parse(input); + + if ( + group.serverName.toLowerCase() === BUILTIN_SERVER && + definition.name === "ask_user_input" + ) { + const question = + typeof parsed.question === "string" + ? parsed.question + : "What should I do next?"; + const interaction = await options.requestInteraction({ + kind: "input", + name: qualifiedName, + prompt: question, + input: parsed, + options: interactionOptions(parsed.options), + }); + if (typeof interaction.value !== "string") { + throw new Error(`User cancelled ${qualifiedName}.`); + } + return { + success: true, + userSelection: interaction.value, + message: `User selected: ${interaction.value}`, + }; + } + + if (requiresApproval(group.serverName, definition)) { + const interaction = await options.requestInteraction({ + kind: "approval", + name: qualifiedName, + prompt: interactionPrompt(qualifiedName, parsed), + input: parsed, + options: ["Allow once", "Deny"], + }); + if (interaction.approved !== true) { + throw new Error(`User denied ${qualifiedName}.`); + } + } + + return options.executeTool(group.serverName, definition.name, parsed); + }, + } satisfies AgentTool; + }), + ); +} diff --git a/packages/app/src/electron/ai/claude-environment.ts b/packages/app/src/electron/ai/claude-environment.ts new file mode 100644 index 00000000..d8af8199 --- /dev/null +++ b/packages/app/src/electron/ai/claude-environment.ts @@ -0,0 +1,43 @@ +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const ALLOWED_CLAUDE_ENVIRONMENT_PREFIXES = [ + "ANTHROPIC_", + "CLAUDE_CODE_", +] as const; + +export function pickClaudeEnvironment(value: unknown): Record { + if (!value || typeof value !== "object") { + return {}; + } + + const environment: Record = {}; + for (const [name, entry] of Object.entries(value)) { + if ( + typeof entry === "string" && + ALLOWED_CLAUDE_ENVIRONMENT_PREFIXES.some((prefix) => + name.startsWith(prefix), + ) + ) { + environment[name] = entry; + } + } + return environment; +} + +export function loadClaudeEnvironment( + settingsPath = join(homedir(), ".claude", "settings.json"), +): NodeJS.ProcessEnv { + try { + const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as { + env?: unknown; + }; + return { + ...process.env, + ...pickClaudeEnvironment(settings.env), + }; + } catch { + return { ...process.env }; + } +} diff --git a/packages/app/src/electron/ai/cli-probe.ts b/packages/app/src/electron/ai/cli-probe.ts new file mode 100644 index 00000000..a66040c6 --- /dev/null +++ b/packages/app/src/electron/ai/cli-probe.ts @@ -0,0 +1,158 @@ +import { execFile } from "node:child_process"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { LocalAiProviderId, LocalAiProviderStatus } from "./types"; +import { LOCAL_AI_PROVIDER_DESCRIPTORS } from "./provider-descriptors"; + +const execFileAsync = promisify(execFile); +const PROBE_TIMEOUT_MS = 5_000; + +interface CommandResult { + stdout: string; + stderr: string; +} + +export type CliCommandRunner = ( + command: string, + args: string[], +) => Promise; + +interface CliDefinition { + providerId: LocalAiProviderId; + executableName: string; + environmentVariable: string; + versionArgs: string[]; + authArgs: string[]; + isAuthenticated: (result: CommandResult) => boolean; +} + +const CLI_DEFINITIONS: Record = { + "claude-code": { + providerId: "claude-code", + executableName: "claude", + environmentVariable: "CONVERA_CLAUDE_PATH", + versionArgs: ["--version"], + authArgs: ["auth", "status", "--json"], + isAuthenticated: ({ stdout }) => { + try { + const status = JSON.parse(stdout) as { loggedIn?: unknown }; + return status.loggedIn === true; + } catch { + return false; + } + }, + }, + "codex-cli": { + providerId: "codex-cli", + executableName: "codex", + environmentVariable: "CONVERA_CODEX_PATH", + versionArgs: ["--version"], + authArgs: ["login", "status"], + isAuthenticated: ({ stdout, stderr }) => + /logged in/i.test(`${stdout}\n${stderr}`), + }, +}; + +function defaultRunner( + command: string, + args: string[], +): Promise { + return execFileAsync(command, args, { + timeout: PROBE_TIMEOUT_MS, + windowsHide: true, + maxBuffer: 1024 * 1024, + }); +} + +function executableCandidates( + definition: CliDefinition, + environment: NodeJS.ProcessEnv, + homeDirectory: string, +): string[] { + const configuredPath = environment[definition.environmentVariable]; + const candidates = [ + configuredPath, + join(homeDirectory, ".local", "bin", definition.executableName), + join(homeDirectory, ".npm-global", "bin", definition.executableName), + `/opt/homebrew/bin/${definition.executableName}`, + `/usr/local/bin/${definition.executableName}`, + definition.executableName, + ].filter((candidate): candidate is string => Boolean(candidate)); + + return [...new Set(candidates)]; +} + +function errorDetail(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +export async function probeCliProvider( + providerId: LocalAiProviderId, + options: { + run?: CliCommandRunner; + environment?: NodeJS.ProcessEnv; + homeDirectory?: string; + } = {}, +): Promise { + const definition = CLI_DEFINITIONS[providerId]; + const descriptor = LOCAL_AI_PROVIDER_DESCRIPTORS[providerId]; + const run = options.run ?? defaultRunner; + const environment = options.environment ?? process.env; + const homeDirectory = options.homeDirectory ?? homedir(); + let lastError = "Executable not found"; + + for (const candidate of executableCandidates( + definition, + environment, + homeDirectory, + )) { + let versionResult: CommandResult; + try { + versionResult = await run(candidate, definition.versionArgs); + } catch (error) { + lastError = errorDetail(error); + continue; + } + + const version = + versionResult.stdout.trim() || versionResult.stderr.trim() || undefined; + + try { + const authResult = await run(candidate, definition.authArgs); + const authenticated = definition.isAuthenticated(authResult); + return { + ...descriptor, + available: true, + authenticated, + version, + executablePath: candidate, + detail: authenticated + ? undefined + : `Run "${definition.executableName} login" to authenticate.`, + checkedAt: new Date().toISOString(), + }; + } catch (error) { + return { + ...descriptor, + available: true, + authenticated: false, + version, + executablePath: candidate, + detail: errorDetail(error), + checkedAt: new Date().toISOString(), + }; + } + } + + return { + ...descriptor, + available: false, + authenticated: false, + detail: `${descriptor.label} CLI was not found. ${lastError}`, + checkedAt: new Date().toISOString(), + }; +} diff --git a/packages/app/src/electron/ai/index.ts b/packages/app/src/electron/ai/index.ts new file mode 100644 index 00000000..9d3cf9a1 --- /dev/null +++ b/packages/app/src/electron/ai/index.ts @@ -0,0 +1,8 @@ +export { LocalAiRuntime, serializeLocalAiError } from "./runtime"; +export type { RuntimeStreamInvoker } from "./runtime"; +export type { LocalAiProviderAdapter } from "./provider-adapter"; +export { + LOCAL_AI_PROVIDER_IDS, + type LocalAiProviderId, + type LocalAiProviderStatus, +} from "./types"; diff --git a/packages/app/src/electron/ai/provider-adapter.ts b/packages/app/src/electron/ai/provider-adapter.ts new file mode 100644 index 00000000..2bd5a646 --- /dev/null +++ b/packages/app/src/electron/ai/provider-adapter.ts @@ -0,0 +1,28 @@ +import type { LocalAIChatRequest } from "@/shared/types/local-ai"; +import type { LanguageModel } from "ai"; +import type { AgentTool, AgentToolInteraction } from "./agent-tools"; +import type { LocalAiProviderId, LocalAiProviderStatus } from "./types"; + +export function resolveLocalModelId( + requestedModelId: string | undefined, + defaultModelId: string, +): string { + const requested = requestedModelId?.trim(); + return requested && requested !== "default" ? requested : defaultModelId; +} + +export interface LocalAiProviderAdapter { + readonly id: LocalAiProviderId; + getStatus(): Promise; + createModel( + request: LocalAIChatRequest, + status: LocalAiProviderStatus, + context: { + tools: AgentTool[]; + requestInteraction( + interaction: AgentToolInteraction, + ): Promise<{ approved?: boolean; value?: string }>; + }, + ): Promise; + dispose(): Promise; +} diff --git a/packages/app/src/electron/ai/provider-descriptors.ts b/packages/app/src/electron/ai/provider-descriptors.ts new file mode 100644 index 00000000..44ec4954 --- /dev/null +++ b/packages/app/src/electron/ai/provider-descriptors.ts @@ -0,0 +1,23 @@ +import { LocalAiProviderDescriptor, LocalAiProviderId } from "./types"; + +export const LOCAL_AI_PROVIDER_DESCRIPTORS: Record< + LocalAiProviderId, + LocalAiProviderDescriptor +> = { + "claude-code": { + id: "claude-code", + label: "Claude Code", + defaultModel: "sonnet", + models: ["sonnet", "opus", "haiku"], + transport: "claude-agent-sdk", + supportsStreaming: true, + }, + "codex-cli": { + id: "codex-cli", + label: "Codex", + defaultModel: "gpt-5.3-codex", + models: ["gpt-5.3-codex", "gpt-5.2-codex", "gpt-5.2-codex-mini"], + transport: "codex-app-server", + supportsStreaming: true, + }, +}; diff --git a/packages/app/src/electron/ai/providers/claude-code.ts b/packages/app/src/electron/ai/providers/claude-code.ts new file mode 100644 index 00000000..742acfad --- /dev/null +++ b/packages/app/src/electron/ai/providers/claude-code.ts @@ -0,0 +1,92 @@ +import type { LocalAIChatRequest } from "@/shared/types/local-ai"; +import type { LanguageModel } from "ai"; +import { + createClaudeCode, + createSdkMcpServer, + tool as createClaudeTool, +} from "ai-sdk-provider-claude-code"; +import { loadClaudeEnvironment } from "../claude-environment"; +import { probeCliProvider } from "../cli-probe"; +import { + resolveLocalModelId, + type LocalAiProviderAdapter, +} from "../provider-adapter"; +import { toMcpToolResult } from "../tool-result"; +import type { LocalAiProviderStatus } from "../types"; + +export class ClaudeCodeAdapter implements LocalAiProviderAdapter { + readonly id = "claude-code" as const; + + private readonly provider = createClaudeCode({ + defaultSettings: { + // Do not silently inherit machine-wide or repository instructions. + settingSources: [], + permissionMode: "default", + // Tool wiring is a separate, approval-aware integration. Text chat starts + // without implicitly granting local access. + tools: [], + maxTurns: 12, + logger: false, + // Some local Claude subscriptions store their auth/base URL in the + // user settings env block. Load only those environment entries without + // enabling user hooks, permissions, tools, or project instructions. + sdkOptions: { + env: loadClaudeEnvironment(), + }, + }, + }); + + getStatus(): Promise { + return probeCliProvider(this.id); + } + + async createModel( + request: LocalAIChatRequest, + status: LocalAiProviderStatus, + context: Parameters[2], + ): Promise { + const tools = context.tools.map((definition) => + createClaudeTool( + definition.name, + definition.description, + definition.inputShape, + async (input) => { + try { + const output = await definition.execute(input); + return toMcpToolResult(output); + } catch (error) { + return { + content: [ + { + type: "text" as const, + text: error instanceof Error ? error.message : String(error), + }, + ], + isError: true, + }; + } + }, + ), + ); + const mcpServer = + tools.length > 0 + ? createSdkMcpServer({ name: "convera", tools }) + : undefined; + + return this.provider( + resolveLocalModelId(request.modelId, status.defaultModel), + { + pathToClaudeCodeExecutable: status.executablePath, + cwd: request.options?.cwd, + mcpServers: mcpServer ? { convera: mcpServer } : undefined, + allowedTools: context.tools.map( + (definition) => `mcp__convera__${definition.name}`, + ), + }, + ); + } + + async dispose(): Promise { + // Claude Agent SDK processes are request-scoped and use AbortSignal. + } +} diff --git a/packages/app/src/electron/ai/providers/codex-cli.ts b/packages/app/src/electron/ai/providers/codex-cli.ts new file mode 100644 index 00000000..0de6e12d --- /dev/null +++ b/packages/app/src/electron/ai/providers/codex-cli.ts @@ -0,0 +1,219 @@ +import type { LocalAIChatRequest } from "@/shared/types/local-ai"; +import type { LanguageModel } from "ai"; +import type { + CodexAppServerProvider, + CodexAppServerRequestHandlers, +} from "ai-sdk-provider-codex-cli"; +import type { ZodEffects, ZodTypeAny } from "zod"; +import { probeCliProvider } from "../cli-probe"; +import { + resolveLocalModelId, + type LocalAiProviderAdapter, +} from "../provider-adapter"; +import type { LocalAiProviderStatus } from "../types"; +import { createCodexMcpServer } from "./codex-mcp-server"; + +export class CodexCliAdapter implements LocalAiProviderAdapter { + readonly id = "codex-cli" as const; + + private provider?: CodexAppServerProvider; + private providerExecutablePath?: string; + private modelCatalog?: { + defaultModel: string; + models: string[]; + }; + + async getStatus(): Promise { + const status = await probeCliProvider(this.id); + if (!status.available || !status.authenticated) { + return status; + } + + try { + await this.ensureProvider(status.executablePath); + if (!this.modelCatalog) { + const catalog = await this.provider!.listModels(); + const models = catalog.models.map((model) => model.id); + const defaultModel = catalog.defaultModel?.id ?? models[0]; + if (defaultModel && models.length > 0) { + this.modelCatalog = { defaultModel, models }; + } + } + } catch (error) { + return { + ...status, + detail: `Model discovery failed: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + + return this.modelCatalog ? { ...status, ...this.modelCatalog } : status; + } + + async createModel( + request: LocalAIChatRequest, + status: LocalAiProviderStatus, + context: Parameters[2], + ): Promise { + await this.ensureProvider(status.executablePath); + const { tool } = await importCodexProviderWithZod3Compatibility(); + const tools = context.tools.map((definition) => + tool({ + name: definition.name, + description: definition.description, + parameters: definition.inputValidator, + execute: async (input) => + definition.execute(input as Record), + }), + ); + const mcpServer = + tools.length > 0 + ? createCodexMcpServer({ + name: "convera", + tools, + definitions: context.tools, + }) + : undefined; + const requestApproval = async ( + name: string, + prompt: string, + input: unknown, + ) => + ( + await context.requestInteraction({ + kind: "approval", + name, + prompt, + input, + options: ["Allow once", "Deny"], + }) + ).approved === true; + const serverRequests: CodexAppServerRequestHandlers = { + onCommandExecutionApproval: async ({ params }) => ({ + decision: (await requestApproval( + "codex:command_execution", + `Allow Codex to execute this command?\n${params.command ?? ""}`, + params, + )) + ? "accept" + : "decline", + }), + onFileChangeApproval: async ({ params }) => ({ + decision: (await requestApproval( + "codex:file_change", + "Allow Codex to modify files in the current workspace?", + params, + )) + ? "accept" + : "decline", + }), + onSkillApproval: async () => ({ decision: "decline" }), + onMcpElicitation: async ({ params }) => + params._meta?.codex_approval_kind === "mcp_tool_call" + ? { action: "accept", content: {} } + : { action: "decline", content: null }, + }; + const cwd = request.options?.cwd; + + return this.provider!( + resolveLocalModelId(request.modelId, status.defaultModel), + { + cwd, + mcpServers: mcpServer ? { convera: mcpServer } : undefined, + serverRequests, + approvalPolicy: "on-request", + sandboxPolicy: { + type: "workspaceWrite", + writableRoots: cwd ? [cwd] : [], + networkAccess: false, + }, + }, + ); + } + + async dispose(): Promise { + const provider = this.provider; + this.provider = undefined; + this.providerExecutablePath = undefined; + this.modelCatalog = undefined; + await provider?.close(); + } + + private async ensureProvider(executablePath?: string): Promise { + if (this.provider && this.providerExecutablePath === executablePath) { + return; + } + + await this.dispose(); + const { createCodexAppServer } = + await importCodexProviderWithZod3Compatibility(); + this.providerExecutablePath = executablePath; + this.provider = createCodexAppServer({ + defaultSettings: { + codexPath: executablePath, + minCodexVersion: "0.144.0", + threadMode: "stateless", + autoApprove: false, + approvalPolicy: "on-request", + sandboxPolicy: "read-only", + idleTimeoutMs: 5 * 60_000, + logger: false, + }, + }); + } +} + +/** + * ai-sdk-provider-codex-cli 1.1-1.3 declares Zod 3 support but one app-server + * response schema calls `.passthrough()` after `.refine()`. Zod 3 returns a + * ZodEffects from refine, whereas Zod 4 forwards the object method. + * + * The refined schema declares both fields it consumes (`id` and `result`), so + * retaining Zod 3's default unknown-key stripping is sufficient. Keep this + * narrowly scoped shim until the provider fixes the upstream chain. The + * prototype is restored immediately after module evaluation, so other schemas + * retain their normal Zod 3 behavior. + */ +async function importCodexProviderWithZod3Compatibility(): Promise< + typeof import("ai-sdk-provider-codex-cli") +> { + // This must be a dynamic ESM import. Electron/Vite may load the provider's + // Zod peer through its ESM entry while a static app import resolves CJS; + // patching the other module instance would not affect provider evaluation. + const { ZodEffects } = await import("zod"); + const prototype = ZodEffects.prototype as typeof ZodEffects.prototype & { + passthrough?: () => unknown; + }; + if (prototype.passthrough) { + return import("ai-sdk-provider-codex-cli"); + } + + Object.defineProperty(prototype, "passthrough", { + configurable: true, + value(this: ZodEffects) { + const parseRefinedSchema = this._parse.bind(this); + const innerObject = this.innerType() as unknown as { + passthrough(): object; + }; + const objectSchema = innerObject.passthrough() as object & { + _parse?: unknown; + }; + + // Zod 3's discriminatedUnion requires an object with a `shape`, while + // parsing still needs the original refinement. Delegate only this + // returned schema's parser back to the ZodEffects instance. + Object.defineProperty(objectSchema, "_parse", { + configurable: true, + value: parseRefinedSchema, + }); + return objectSchema; + }, + }); + + try { + return await import("ai-sdk-provider-codex-cli"); + } finally { + delete prototype.passthrough; + } +} diff --git a/packages/app/src/electron/ai/providers/codex-mcp-server.test.ts b/packages/app/src/electron/ai/providers/codex-mcp-server.test.ts new file mode 100644 index 00000000..19693cc9 --- /dev/null +++ b/packages/app/src/electron/ai/providers/codex-mcp-server.test.ts @@ -0,0 +1,89 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { LocalTool } from "ai-sdk-provider-codex-cli"; +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import type { AgentTool } from "../agent-tools"; +import { createCodexMcpServer } from "./codex-mcp-server"; + +describe("Codex image-capable MCP server", () => { + it("preserves image content and rejects unauthenticated callers", async () => { + const execute = vi.fn(async () => ({ + content: [ + { type: "text" as const, text: "screen" }, + { type: "image" as const, data: "cG5n", mimeType: "image/png" }, + ], + })); + const definition: AgentTool = { + name: "builtin__computer_control", + qualifiedName: "builtin:computer_control", + description: "Capture the screen", + inputSchema: { + type: "object", + properties: { action: { type: "string" } }, + }, + inputShape: { action: z.string() }, + inputValidator: z.object({ action: z.string() }), + execute, + }; + const localTool: LocalTool = { + name: definition.name, + description: definition.description, + inputSchema: definition.inputSchema, + execute, + }; + const server = createCodexMcpServer({ + name: "convera", + tools: [localTool], + definitions: [definition], + }); + + const config = await server._start(); + expect(config.transport).toBe("http"); + if (config.transport !== "http") { + throw new Error("Expected HTTP MCP transport."); + } + + const unauthorized = await fetch(config.url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "test", version: "1.0.0" }, + }, + }), + }); + expect(unauthorized.status).toBe(401); + + const client = new Client({ name: "test", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport(new URL(config.url), { + requestInit: { + headers: { Authorization: `Bearer ${config.bearerToken}` }, + }, + }); + + try { + await client.connect(transport); + await expect( + client.callTool({ + name: definition.name, + arguments: { action: "screenshot" }, + }), + ).resolves.toEqual({ + content: [ + { type: "text", text: "screen" }, + { type: "image", data: "cG5n", mimeType: "image/png" }, + ], + }); + expect(execute).toHaveBeenCalledWith({ action: "screenshot" }); + } finally { + await client.close(); + await server._stop(); + } + }); +}); diff --git a/packages/app/src/electron/ai/providers/codex-mcp-server.ts b/packages/app/src/electron/ai/providers/codex-mcp-server.ts new file mode 100644 index 00000000..a869cbbb --- /dev/null +++ b/packages/app/src/electron/ai/providers/codex-mcp-server.ts @@ -0,0 +1,176 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import type { ServerResponse } from "node:http"; +import { createServer, type Server } from "node:http"; +import { randomBytes } from "node:crypto"; +import type { LocalTool, SdkMcpServer } from "ai-sdk-provider-codex-cli"; +import type { AgentTool } from "../agent-tools"; +import { toMcpToolResult } from "../tool-result"; + +const SDK_MCP_SERVER_MARKER = Symbol.for( + "ai-sdk-provider-codex-cli.sdkMcpServer", +); + +function sendJson( + response: ServerResponse, + statusCode: number, + body: unknown, +): void { + response.writeHead(statusCode, { "Content-Type": "application/json" }); + response.end(JSON.stringify(body)); +} + +async function closeHttpServer(server: Server): Promise { + if (!server.listening) return; + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +async function waitForResponse(response: ServerResponse): Promise { + if (response.writableFinished || response.destroyed) return; + await new Promise((resolve) => { + response.once("finish", resolve); + response.once("close", resolve); + }); +} + +function createRequestServer( + name: string, + definitions: AgentTool[], +): McpServer { + const server = new McpServer({ name, version: "1.0.0" }); + for (const definition of definitions) { + server.registerTool( + definition.name, + { + description: definition.description, + inputSchema: definition.inputShape, + }, + async (input) => + toMcpToolResult( + await definition.execute(input as Record), + ), + ); + } + return server; +} + +export function createCodexMcpServer(options: { + name: string; + tools: LocalTool[]; + definitions: AgentTool[]; +}): SdkMcpServer { + const bearerToken = randomBytes(32).toString("hex"); + const expectedAuthorization = `Bearer ${bearerToken}`; + let httpServer: Server | undefined; + let startPromise: Promise<{ + transport: "http"; + url: string; + bearerToken: string; + }> | null = null; + let stopPromise: Promise | null = null; + + const sdkServer = { + [SDK_MCP_SERVER_MARKER]: true, + name: options.name, + tools: options.tools, + async _start() { + while (stopPromise) await stopPromise; + if (httpServer?.listening) { + const address = httpServer.address(); + if (address && typeof address !== "string") { + return { + transport: "http" as const, + url: `http://127.0.0.1:${address.port}/mcp`, + bearerToken, + }; + } + } + if (startPromise) return startPromise; + + startPromise = new Promise((resolve, reject) => { + const server = createServer(async (request, response) => { + if (request.url !== "/mcp") { + sendJson(response, 404, { error: "Not found" }); + return; + } + if (request.method !== "POST") { + sendJson(response, 405, { error: "Method not allowed" }); + return; + } + if (request.headers.authorization !== expectedAuthorization) { + response.setHeader("WWW-Authenticate", "Bearer"); + sendJson(response, 401, { error: "Unauthorized" }); + return; + } + + const mcpServer = createRequestServer( + options.name, + options.definitions, + ); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + try { + await mcpServer.connect(transport); + await transport.handleRequest(request, response); + await waitForResponse(response); + } catch (error) { + if (!response.headersSent) { + sendJson(response, 500, { + jsonrpc: "2.0", + id: null, + error: { + code: -32603, + message: + error instanceof Error ? error.message : String(error), + }, + }); + } + } finally { + await mcpServer.close(); + } + }); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("Failed to resolve local MCP server address.")); + return; + } + httpServer = server; + resolve({ + transport: "http", + url: `http://127.0.0.1:${address.port}/mcp`, + bearerToken, + }); + }); + }); + + try { + return await startPromise; + } finally { + startPromise = null; + } + }, + async _stop() { + if (stopPromise) return stopPromise; + stopPromise = (async () => { + await startPromise?.catch(() => undefined); + const server = httpServer; + httpServer = undefined; + if (server) await closeHttpServer(server); + })(); + try { + await stopPromise; + } finally { + stopPromise = null; + } + }, + }; + + return sdkServer as unknown as SdkMcpServer; +} diff --git a/packages/app/src/electron/ai/runtime.ts b/packages/app/src/electron/ai/runtime.ts new file mode 100644 index 00000000..5ddf1296 --- /dev/null +++ b/packages/app/src/electron/ai/runtime.ts @@ -0,0 +1,580 @@ +import type { + LocalAIChatRequest, + LocalAIFinishReason, + LocalAIInteractionResponse, + LocalAIProviderAvailability, + LocalAIProviderStatus, + LocalAIRuntimeService, + LocalAISerializableError, + LocalAIStreamEvent, + LocalAIUsage, +} from "@/shared/types/local-ai"; +import { + streamText, + type LanguageModel, + type ModelMessage, + type UIMessageChunk, +} from "ai"; +import { randomUUID } from "node:crypto"; +import { + createAgentToolCatalog, + type AgentTool, + type AgentToolGroup, + type AgentToolInteraction, +} from "./agent-tools"; +import { LOCAL_AI_PROVIDER_DESCRIPTORS } from "./provider-descriptors"; +import type { LocalAiProviderAdapter } from "./provider-adapter"; +import { ClaudeCodeAdapter } from "./providers/claude-code"; +import { CodexCliAdapter } from "./providers/codex-cli"; +import { + LOCAL_AI_PROVIDER_IDS, + type LocalAiProviderId, + type LocalAiProviderStatus as ProbeStatus, +} from "./types"; + +interface RuntimeStreamResult { + toUIMessageStream(options?: { + onError?: (error: unknown) => string; + sendReasoning?: boolean; + sendSources?: boolean; + }): AsyncIterable; + finishReason?: PromiseLike; + usage?: PromiseLike; +} + +interface RuntimeStreamOptions { + model: LanguageModel; + messages: ModelMessage[]; + abortSignal: AbortSignal; + maxOutputTokens?: number; +} + +export type RuntimeStreamInvoker = ( + options: RuntimeStreamOptions, +) => RuntimeStreamResult; + +export type AgentToolGroupProvider = () => + | AgentToolGroup[] + | Promise; + +export type AgentToolExecutor = ( + serverName: string, + toolName: string, + input: Record, +) => Promise; + +const defaultStreamInvoker: RuntimeStreamInvoker = (options) => + streamText(options) as unknown as RuntimeStreamResult; + +function isProviderId(providerId: string): providerId is LocalAiProviderId { + return LOCAL_AI_PROVIDER_IDS.includes(providerId as LocalAiProviderId); +} + +function availabilityFor(status: ProbeStatus): LocalAIProviderAvailability { + if (!status.available) { + return "missing"; + } + if (!status.authenticated) { + return "unauthenticated"; + } + return "available"; +} + +function publicStatus(status: ProbeStatus): LocalAIProviderStatus { + const detailParts = [status.detail]; + if (status.version) { + detailParts.push(status.version); + } + + return { + id: status.id, + name: status.label, + kind: status.id, + availability: availabilityFor(status), + detail: detailParts.filter(Boolean).join(" ยท ") || undefined, + models: status.models.map((model) => ({ id: model, name: model })), + }; +} + +function missingProviderStatus(providerId: string): LocalAIProviderStatus { + return { + id: providerId, + name: providerId, + kind: "openai-compatible", + availability: "unavailable", + detail: `Unknown local AI provider: ${providerId}`, + }; +} + +export function serializeLocalAiError( + error: unknown, + code?: string, +): LocalAISerializableError { + if (error instanceof Error) { + const errorWithCode = error as Error & { code?: unknown }; + return { + name: error.name, + message: error.message, + code: + code ?? + (typeof errorWithCode.code === "string" + ? errorWithCode.code + : undefined), + stack: error.stack, + }; + } + + return { + name: "Error", + message: typeof error === "string" ? error : JSON.stringify(error), + code, + }; +} + +function toMessages(request: LocalAIChatRequest): ModelMessage[] { + const agentPrompt = request.agent?.systemPrompt?.trim(); + const messages: ModelMessage[] = request.messages.map((message) => ({ + role: message.role, + content: message.content, + })); + + if (agentPrompt) { + messages.unshift({ role: "system", content: agentPrompt }); + } + + return messages; +} + +function finishReason(reason: unknown): LocalAIFinishReason { + switch (reason) { + case "stop": + case "length": + case "content-filter": + case "tool-calls": + case "error": + return reason; + default: + return "unknown"; + } +} + +function usageFrom(value: unknown): LocalAIUsage | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + + const usage = value as Record; + const inputTokens = + typeof usage.inputTokens === "number" ? usage.inputTokens : undefined; + const outputTokens = + typeof usage.outputTokens === "number" ? usage.outputTokens : undefined; + const totalTokens = + typeof usage.totalTokens === "number" ? usage.totalTokens : undefined; + + if ( + inputTokens === undefined && + outputTokens === undefined && + totalTokens === undefined + ) { + return undefined; + } + + return { inputTokens, outputTokens, totalTokens }; +} + +interface PendingInteraction { + requestId: string; + resolve(response: LocalAIInteractionResponse): void; + reject(error: Error): void; + timeout: ReturnType; + abortSignal: AbortSignal; + onAbort(): void; +} + +export class LocalAiRuntime implements LocalAIRuntimeService { + private readonly adapters = new Map< + LocalAiProviderId, + LocalAiProviderAdapter + >(); + private readonly activeRequests = new Map(); + private readonly streamInvoker: RuntimeStreamInvoker; + private readonly workingDirectory: string; + private readonly getToolGroups: AgentToolGroupProvider; + private readonly executeTool: AgentToolExecutor; + private readonly pendingInteractions = new Map(); + + constructor( + options: { + adapters?: LocalAiProviderAdapter[]; + streamInvoker?: RuntimeStreamInvoker; + workingDirectory?: string; + getToolGroups?: AgentToolGroupProvider; + executeTool?: AgentToolExecutor; + } = {}, + ) { + const adapters = options.adapters ?? [ + new ClaudeCodeAdapter(), + new CodexCliAdapter(), + ]; + this.streamInvoker = options.streamInvoker ?? defaultStreamInvoker; + this.workingDirectory = options.workingDirectory ?? process.cwd(); + this.getToolGroups = options.getToolGroups ?? (() => []); + this.executeTool = + options.executeTool ?? + (async (serverName, toolName) => { + throw new Error( + `Tool executor is unavailable for ${serverName}:${toolName}.`, + ); + }); + + for (const adapter of adapters) { + this.adapters.set(adapter.id, adapter); + } + } + + async listProviders(): Promise { + return Promise.all( + LOCAL_AI_PROVIDER_IDS.map((providerId) => + this.getProviderStatus(providerId), + ), + ); + } + + async getProviderStatus(providerId: string): Promise { + if (!isProviderId(providerId)) { + return missingProviderStatus(providerId); + } + + const adapter = this.adapters.get(providerId); + if (!adapter) { + const descriptor = LOCAL_AI_PROVIDER_DESCRIPTORS[providerId]; + return { + id: providerId, + name: descriptor.label, + kind: providerId, + availability: "unavailable", + detail: `${descriptor.label} adapter is not configured.`, + models: descriptor.models.map((model) => ({ + id: model, + name: model, + })), + }; + } + + try { + return publicStatus(await adapter.getStatus()); + } catch (error) { + return { + id: providerId, + name: LOCAL_AI_PROVIDER_DESCRIPTORS[providerId].label, + kind: providerId, + availability: "error", + detail: serializeLocalAiError(error).message, + }; + } + } + + async startChat( + request: LocalAIChatRequest, + emit: (event: LocalAIStreamEvent) => void, + ): Promise { + if (this.activeRequests.has(request.requestId)) { + this.emitFailure( + request.requestId, + emit, + new Error(`Request is already active: ${request.requestId}`), + "DUPLICATE_REQUEST", + ); + return; + } + + if (!isProviderId(request.providerId)) { + this.emitFailure( + request.requestId, + emit, + new Error(`Unknown local AI provider: ${request.providerId}`), + "UNKNOWN_PROVIDER", + ); + return; + } + + if (request.messages.length === 0) { + this.emitFailure( + request.requestId, + emit, + new Error("At least one chat message is required."), + "EMPTY_MESSAGES", + ); + return; + } + + const adapter = this.adapters.get(request.providerId); + if (!adapter) { + this.emitFailure( + request.requestId, + emit, + new Error(`Provider adapter is unavailable: ${request.providerId}`), + "PROVIDER_UNAVAILABLE", + ); + return; + } + + const controller = new AbortController(); + this.activeRequests.set(request.requestId, controller); + + try { + const probeStatus = await adapter.getStatus(); + if (!probeStatus.available || !probeStatus.authenticated) { + this.emitFailure( + request.requestId, + emit, + new Error( + probeStatus.detail ?? + `${probeStatus.label} is unavailable or unauthenticated.`, + ), + probeStatus.available + ? "PROVIDER_UNAUTHENTICATED" + : "PROVIDER_MISSING", + ); + return; + } + + // Renderer input must not expand filesystem scope. Main chooses a single + // trusted working directory when constructing the runtime. + const trustedRequest: LocalAIChatRequest = { + ...request, + options: { + ...request.options, + cwd: this.workingDirectory, + }, + }; + const requestInteraction = (interaction: AgentToolInteraction) => + this.requestInteraction( + request.requestId, + interaction, + controller.signal, + emit, + ); + const tools = createAgentToolCatalog({ + groups: await this.getToolGroups(), + executeTool: this.executeTool, + requestInteraction, + }); + const model = await adapter.createModel(trustedRequest, probeStatus, { + tools, + requestInteraction, + }); + const result = this.streamInvoker({ + model, + messages: toMessages(request), + abortSignal: controller.signal, + maxOutputTokens: request.options?.maxOutputTokens, + }); + await this.forwardStream( + request.requestId, + result, + controller, + emit, + tools, + ); + } catch (error) { + if (controller.signal.aborted) { + emit({ + type: "finish", + requestId: request.requestId, + finishReason: "aborted", + }); + } else { + this.emitFailure(request.requestId, emit, error); + } + } finally { + this.rejectRequestInteractions( + request.requestId, + new Error( + "Local AI request finished before the interaction completed.", + ), + ); + this.activeRequests.delete(request.requestId); + } + } + + abort(requestId: string): boolean { + const controller = this.activeRequests.get(requestId); + if (!controller) { + return false; + } + + controller.abort(); + return true; + } + + respondToInteraction( + requestId: string, + interactionId: string, + response: LocalAIInteractionResponse, + ): boolean { + const pending = this.pendingInteractions.get(interactionId); + if (!pending || pending.requestId !== requestId) return false; + + this.releaseInteraction(interactionId, pending); + pending.resolve(response); + return true; + } + + async dispose(): Promise { + for (const controller of this.activeRequests.values()) { + controller.abort(); + } + this.activeRequests.clear(); + for (const [interactionId, pending] of this.pendingInteractions) { + this.releaseInteraction(interactionId, pending); + pending.reject(new Error("Local AI runtime disposed.")); + } + + await Promise.all( + [...this.adapters.values()].map((adapter) => adapter.dispose()), + ); + } + + private async forwardStream( + requestId: string, + result: RuntimeStreamResult, + controller: AbortController, + emit: (event: LocalAIStreamEvent) => void, + tools: AgentTool[], + ): Promise { + const eventNames = new Map( + tools.map((tool) => [tool.name, tool.qualifiedName]), + ); + let streamedFinishReason: LocalAIFinishReason = "unknown"; + + for await (const chunk of result.toUIMessageStream({ + onError: (error) => serializeLocalAiError(error).message, + })) { + const qualifiedChunk = this.qualifyToolChunk(chunk, eventNames); + if (qualifiedChunk.type === "finish") { + streamedFinishReason = finishReason(qualifiedChunk.finishReason); + } else if (qualifiedChunk.type === "error") { + streamedFinishReason = "error"; + } + emit({ type: "ui-message", requestId, chunk: qualifiedChunk }); + } + + const resolvedFinishReason = result.finishReason + ? finishReason(await result.finishReason) + : streamedFinishReason; + const usage = result.usage ? usageFrom(await result.usage) : undefined; + emit({ + type: "finish", + requestId, + finishReason: controller.signal.aborted + ? "aborted" + : resolvedFinishReason, + usage, + }); + } + + private emitFailure( + requestId: string, + emit: (event: LocalAIStreamEvent) => void, + error: unknown, + code?: string, + ): void { + emit({ + type: "error", + requestId, + error: serializeLocalAiError(error, code), + }); + emit({ type: "finish", requestId, finishReason: "error" }); + } + + private requestInteraction( + requestId: string, + interaction: AgentToolInteraction, + abortSignal: AbortSignal, + emit: (event: LocalAIStreamEvent) => void, + ): Promise { + const interactionId = randomUUID(); + + return new Promise((resolve, reject) => { + const onAbort = () => { + const pending = this.pendingInteractions.get(interactionId); + if (!pending) return; + this.releaseInteraction(interactionId, pending); + reject(new Error(`Interaction cancelled for ${interaction.name}.`)); + }; + const timeout = setTimeout(() => { + const pending = this.pendingInteractions.get(interactionId); + if (!pending) return; + this.releaseInteraction(interactionId, pending); + reject(new Error(`Interaction timed out for ${interaction.name}.`)); + }, 5 * 60_000); + + this.pendingInteractions.set(interactionId, { + requestId, + resolve, + reject, + timeout, + abortSignal, + onAbort, + }); + abortSignal.addEventListener("abort", onAbort, { once: true }); + emit({ + type: "interaction", + requestId, + interactionId, + ...interaction, + }); + }); + } + + private rejectRequestInteractions(requestId: string, error: Error): void { + for (const [interactionId, pending] of this.pendingInteractions) { + if (pending.requestId !== requestId) continue; + this.releaseInteraction(interactionId, pending); + pending.reject(error); + } + } + + private releaseInteraction( + interactionId: string, + pending: PendingInteraction, + ): void { + clearTimeout(pending.timeout); + pending.abortSignal.removeEventListener("abort", pending.onAbort); + this.pendingInteractions.delete(interactionId); + } + + private toolEventName( + providerName: string, + eventNames: Map, + ): string { + for (const [alias, qualifiedName] of eventNames) { + if ( + providerName === alias || + providerName.endsWith(`__${alias}`) || + providerName.endsWith(`.${alias}`) + ) { + return qualifiedName; + } + } + return providerName; + } + + private qualifyToolChunk( + chunk: UIMessageChunk, + eventNames: Map, + ): UIMessageChunk { + switch (chunk.type) { + case "tool-input-start": + case "tool-input-available": + case "tool-input-error": + return { + ...chunk, + toolName: this.toolEventName(chunk.toolName, eventNames), + }; + default: + return chunk; + } + } +} diff --git a/packages/app/src/electron/ai/tool-result.test.ts b/packages/app/src/electron/ai/tool-result.test.ts new file mode 100644 index 00000000..b6b43867 --- /dev/null +++ b/packages/app/src/electron/ai/tool-result.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { isMcpToolResult, toMcpToolResult } from "./tool-result"; + +describe("tool result conversion", () => { + it("preserves MCP image content", () => { + const result = { + content: [ + { type: "text" as const, text: "screen" }, + { type: "image" as const, data: "cG5n", mimeType: "image/png" }, + ], + }; + + expect(isMcpToolResult(result)).toBe(true); + expect(toMcpToolResult(result)).toBe(result); + }); + + it("converts ordinary tool output to MCP text content", () => { + expect(toMcpToolResult({ success: true })).toEqual({ + content: [{ type: "text", text: '{"success":true}' }], + }); + }); +}); diff --git a/packages/app/src/electron/ai/tool-result.ts b/packages/app/src/electron/ai/tool-result.ts new file mode 100644 index 00000000..4d0e622d --- /dev/null +++ b/packages/app/src/electron/ai/tool-result.ts @@ -0,0 +1,33 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isMcpToolResult(value: unknown): value is CallToolResult { + return ( + isRecord(value) && + Array.isArray(value.content) && + value.content.every( + (content) => isRecord(content) && typeof content.type === "string", + ) + ); +} + +export function toMcpToolResult(output: unknown): CallToolResult { + if (isMcpToolResult(output)) { + return output; + } + + return { + content: [ + { + type: "text", + text: + typeof output === "string" + ? output + : (JSON.stringify(output) ?? String(output)), + }, + ], + }; +} diff --git a/packages/app/src/electron/ai/types.ts b/packages/app/src/electron/ai/types.ts new file mode 100644 index 00000000..3f2d4670 --- /dev/null +++ b/packages/app/src/electron/ai/types.ts @@ -0,0 +1,21 @@ +export const LOCAL_AI_PROVIDER_IDS = ["claude-code", "codex-cli"] as const; + +export type LocalAiProviderId = (typeof LOCAL_AI_PROVIDER_IDS)[number]; + +export interface LocalAiProviderDescriptor { + id: LocalAiProviderId; + label: string; + defaultModel: string; + models: string[]; + transport: "claude-agent-sdk" | "codex-app-server"; + supportsStreaming: true; +} + +export interface LocalAiProviderStatus extends LocalAiProviderDescriptor { + available: boolean; + authenticated: boolean; + version?: string; + executablePath?: string; + detail?: string; + checkedAt: string; +} diff --git a/packages/app/src/electron/main.ts b/packages/app/src/electron/main.ts index 306db146..e623b600 100644 --- a/packages/app/src/electron/main.ts +++ b/packages/app/src/electron/main.ts @@ -1,12 +1,14 @@ -import { - app, - BrowserWindow, - CookiesSetDetails, - globalShortcut, -} from "electron"; +import { app, BrowserWindow, globalShortcut } from "electron"; import { getLogger, initializeLogger } from "@/electron/logger"; -import { getMCPHub, initializeMCPHub } from "@/electron/mcp"; +import { + callTool, + getAllTools, + getMCPHub, + initializeMCPHub, + mcpToolCall, +} from "@/electron/mcp"; +import { LocalAiRuntime } from "@/electron/ai"; import { getCurrentShortcut } from "@/electro-bridge/ipc/ipc-handlers"; @@ -24,6 +26,16 @@ import { // Initialize logger for main process const logger = getLogger("main-process"); +const localAIRuntime = new LocalAiRuntime({ + getToolGroups: async () => { + await initializeMCPHub(); + return getAllTools(); + }, + executeTool: (serverName, toolName, input) => + serverName.toLowerCase() === "builtin" + ? mcpToolCall(toolName, input) + : callTool(serverName, toolName, input), +}); function registerGlobalShortcuts() { globalShortcut.unregisterAll(); @@ -111,6 +123,7 @@ app.whenReady().then(async () => { const listenerOptions: ListenerOptions = { mainWindow: () => getMainWindow(), registerGlobalShortcuts, + localAIRuntime, }; logger.debug("Registering IPC listeners"); @@ -130,16 +143,6 @@ app.whenReady().then(async () => { }); createSystemTray(); - - // Register custom protocol for deep link callbacks (macOS) - try { - if (process.platform === "darwin") { - app.setAsDefaultProtocolClient("convera"); - logger.info(`Registered custom protocol 'convera': ok`); - } - } catch (e) { - logger.error("Failed to register custom protocol:", e); - } } catch (error) { logger.error("Error during app initialization", error); } @@ -153,6 +156,9 @@ app.on("will-quit", () => { hub.cleanup(); console.log("MCP Hub cleaned up"); } + void localAIRuntime.dispose().catch((error) => { + logger.error("Local AI runtime cleanup failed:", error); + }); }); app.on("window-all-closed", () => { @@ -160,67 +166,3 @@ app.on("window-all-closed", () => { app.quit(); } }); - -// Handle deep link callbacks like convera://auth/callback?next=/settings -app.on("open-url", (event, urlStr) => { - try { - event.preventDefault(); - logger.info("Received open-url:", urlStr); - - const url = new URL(urlStr); - const exchangeToken = url.searchParams.get("token"); - - const afterNavigate = () => { - const win = getMainWindow(); - if (win) { - win.show(); - win.focus(); - win.webContents.reload(); - } - }; - - if (exchangeToken) { - // Redeem token then write cookie into Electron session - fetch("https://api.foxychat.net/api/auth/exchange/redeem", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ token: exchangeToken }), - }) - .then((r) => r.json()) - .then(async (data) => { - try { - logger.info("Received exchange token:", data); - const cookies = data?.cookies as { name: string; value: string }[]; - const domain = data?.domain || "api.foxychat.net"; - const secure = !!data?.secure; - if (Array.isArray(cookies)) { - for (const ck of cookies) { - await BrowserWindow.getFocusedWindow()?.webContents.session.cookies.set( - { - url: `https://${domain}`, - name: ck.name, - value: ck.value, - domain, - path: "/", - secure, - httpOnly: true, - sameSite: "no_restriction", - } as unknown as CookiesSetDetails, - ); - } - } - } catch (e) { - logger.error("Failed to set session cookies:", e); - } - }) - .finally(() => { - afterNavigate(); - }); - return; - } - - afterNavigate(); - } catch (e) { - logger.error("Error handling open-url:", e); - } -}); diff --git a/packages/app/src/electron/mcp/connection.ts b/packages/app/src/electron/mcp/connection.ts index 53008583..c14f4344 100644 --- a/packages/app/src/electron/mcp/connection.ts +++ b/packages/app/src/electron/mcp/connection.ts @@ -20,10 +20,9 @@ import { ServerInfo, ToolDefinition, } from "@/shared/types/mcp"; -import { experimental_createMCPClient, type Tool } from "ai"; -import { Experimental_StdioMCPTransport } from "ai/mcp-stdio"; -// import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { app } from "electron"; import { EventEmitter } from "events"; @@ -35,11 +34,6 @@ import { zodToJsonSchema } from "zod-to-json-schema"; import { getLogger } from "../logger"; const logger = getLogger("MCPConnectionAI"); -// Type for the MCP client instance -type MCPClientInstance = Awaited< - ReturnType ->; - // Define proper MCP tool types interface MCPToolProperty { type: string; @@ -72,30 +66,18 @@ interface UnifiedTool { } /** - * MCPConnection using the ai package's experimental MCP client - * This implementation leverages the built-in MCP client from the ai package - * for better integration and maintenance - * - * Key differences from the original MCPConnection: - * 1. Uses experimental_createMCPClient from 'ai' package - * 2. Supports both stdio (via Experimental_StdioMCPTransport) and SSE transports - * 3. Currently focused on tool functionality (resources and prompts not yet supported by ai package) - * 4. Simpler connection management with built-in error handling - * - * Note: This is experimental and may change as the ai package's MCP support evolves + * MCP connection backed by the official MCP SDK for stdio, Streamable HTTP, + * and legacy SSE transports. */ export class MCPConnection extends EventEmitter { private name: string; private displayName: string; private description?: string; private config: MCPServerConfig; - private client: MCPClientInstance | null = null; - private mcpClient: Client | null = null; // Fallback MCP client for Streamable HTTP + private mcpClient: Client | null = null; private transportType: string; - private useAiSdk: boolean = false; // Track which client is being used private tools: Record = {}; // Store unified tools - private aiSdkTools: Record> = {}; // Store AI SDK tools separately private mcpTools: MCPToolDefinition[] = []; // Store tools from MCP client private toolNames: Set = new Set(); // Cache tool names for quick lookup private resources: ResourceDefinition[] = []; @@ -108,7 +90,6 @@ export class MCPConnection extends EventEmitter { private lastStarted: string | null = null; private disabled: boolean; private authorizationUrl?: string; - private sseStatusPollingInterval: NodeJS.Timeout | null = null; constructor(name: string, config: MCPServerConfig) { super(); @@ -135,6 +116,22 @@ export class MCPConnection extends EventEmitter { ); } + private createMcpClient(): Client { + return new Client( + { + name: "convera-electron", + version: app.getVersion(), + }, + { + capabilities: { + tools: {}, + prompts: {}, + resources: {}, + }, + }, + ); + } + /** * Start the connection (enable if disabled) */ @@ -235,17 +232,22 @@ export class MCPConnection extends EventEmitter { } } - const transport = new Experimental_StdioMCPTransport({ - command: actualCommand, - args: resolvedConfig.args || [], - env: { + const environment = Object.fromEntries( + Object.entries({ ...process.env, ELECTRON_APP_PATH: app.getAppPath(), ELECTRON_USER_DATA: app.getPath("userData"), CONVERA_APP_PATH: app.getAppPath(), CONVERA_USER_DATA: app.getPath("userData"), ...resolvedConfig.env, - }, + }).filter((entry): entry is [string, string] => { + return typeof entry[1] === "string"; + }), + ); + const transport = new StdioClientTransport({ + command: actualCommand, + args: resolvedConfig.args || [], + env: environment, cwd: resolvedConfig.cwd || app.getPath("userData"), }); @@ -254,35 +256,13 @@ export class MCPConnection extends EventEmitter { transport.onerror = (error) => this.handleTransportError(error as Error); - this.client = await experimental_createMCPClient({ - transport, - name: `convera-electron`, - onUncaughtError: (error) => { - console.error( - `Uncaught error in MCP client '${this.name}':`, - error, - ); - this.emit("error", { server: this.name, error }); - }, - }); - - this.useAiSdk = true; + this.mcpClient = this.createMcpClient(); + await this.mcpClient.connect(transport); } else { // For HTTP/SSE - try Streamable HTTP first, fallback to SSE await this.connectWithHttpFallback(resolvedConfig); } - // For SSE connections using AI SDK, status will be updated by polling - if (this.useAiSdk && this.transportType !== "stdio") { - // SSE connection - status will be updated by polling - console.log( - `MCP server '${this.name}' SSE connection initiated, polling for status...`, - ); - this.startSseStatusPolling(); - return; - } - - // For other connections, fetch initial capabilities and mark as connected await this.updateCapabilities(); // Mark as connected @@ -310,14 +290,6 @@ export class MCPConnection extends EventEmitter { * Disconnect from the MCP server */ async disconnect(errorMessage?: string): Promise { - if (this.client) { - try { - await this.client.close(); - } catch (error) { - console.warn(`Error closing AI SDK client for ${this.name}:`, error); - } - } - if (this.mcpClient) { try { await this.mcpClient.close(); @@ -333,11 +305,8 @@ export class MCPConnection extends EventEmitter { * Reset connection state */ private resetState(errorMessage?: string): void { - this.client = null; this.mcpClient = null; - this.useAiSdk = false; this.tools = {}; - this.aiSdkTools = {}; this.mcpTools = []; this.toolNames.clear(); this.resources = []; @@ -349,12 +318,6 @@ export class MCPConnection extends EventEmitter { this.error = errorMessage || null; this.startTime = null; this.authorizationUrl = undefined; - - // Clear SSE polling interval - if (this.sseStatusPollingInterval) { - clearInterval(this.sseStatusPollingInterval); - this.sseStatusPollingInterval = null; - } } /** @@ -382,19 +345,7 @@ export class MCPConnection extends EventEmitter { */ async updateCapabilities(): Promise { try { - if (this.useAiSdk && this.client) { - // Use AI SDK client - const aiTools = await this.client.tools(); - this.aiSdkTools = aiTools as unknown as Record< - string, - Tool - >; - this.tools = this.convertAiSdkToolsToUnified( - aiTools as unknown as Record>, - ); - this.toolNames = new Set(Object.keys(aiTools)); - } else if (this.mcpClient) { - // Use MCP client and convert tools + if (this.mcpClient) { const result = await this.mcpClient.listTools(); this.mcpTools = (result.tools || []) as MCPToolDefinition[]; @@ -411,7 +362,6 @@ export class MCPConnection extends EventEmitter { logger.info(`Updated capabilities for ${this.name}`, { toolCount: this.toolNames.size, - useAiSdk: this.useAiSdk, }); } catch (error) { logger.warn( @@ -428,7 +378,7 @@ export class MCPConnection extends EventEmitter { toolName: string, args: Record, ): Promise { - if (!this.client && !this.mcpClient) { + if (!this.mcpClient) { throw this.createError( "SERVER_NOT_INITIALIZED", "Server not initialized", @@ -513,16 +463,15 @@ export class MCPConnection extends EventEmitter { try { // First try: Streamable HTTP with MCP client await this.connectWithStreamableHttp(resolvedConfig); - this.useAiSdk = false; logger.info("Connected successfully with Streamable HTTP"); } catch (httpError) { - logger.warn("Streamable HTTP failed, trying SSE with AI SDK:", httpError); + logger.warn("Streamable HTTP failed, trying legacy SSE:", httpError); + await this.mcpClient?.close().catch(() => undefined); + this.mcpClient = null; try { - // Second try: SSE with AI SDK - await this.connectWithAiSdkSse(resolvedConfig); - this.useAiSdk = true; - logger.info("Connected successfully with AI SDK SSE"); + await this.connectWithSse(resolvedConfig); + logger.info("Connected successfully with legacy SSE"); } catch (sseError) { logger.error("Both HTTP and SSE failed:", { httpError, sseError }); throw new Error( @@ -552,116 +501,33 @@ export class MCPConnection extends EventEmitter { }, ); - this.mcpClient = new Client( - { - name: "convera-electron", - version: "1.0.0", - }, - { - capabilities: { - tools: {}, - prompts: {}, - resources: {}, - }, - }, - ); + this.mcpClient = this.createMcpClient(); await this.mcpClient.connect(httpTransport); } /** - * Connect using AI SDK SSE + * Connect using the MCP SDK legacy SSE transport. */ - private async connectWithAiSdkSse( - resolvedConfig: MCPServerConfig, - ): Promise { - this.client = await experimental_createMCPClient({ - transport: { - type: "sse", - url: resolvedConfig.url!, - headers: { - "User-Agent": `Convera/${app.getVersion()} (Electron)`, - ...(resolvedConfig.apiKey && { - Authorization: `Bearer ${resolvedConfig.apiKey}`, + private async connectWithSse(resolvedConfig: MCPServerConfig): Promise { + const headers = { + "User-Agent": `Convera/${app.getVersion()} (Electron)`, + ...(resolvedConfig.apiKey && { + Authorization: `Bearer ${resolvedConfig.apiKey}`, + }), + }; + const transport = new SSEClientTransport(new URL(resolvedConfig.url!), { + eventSourceInit: { + fetch: (url, init) => + fetch(url, { + ...init, + headers: { ...headers, ...init?.headers }, }), - }, - }, - name: `convera-electron`, - onUncaughtError: (error) => { - console.error(`Uncaught error in MCP client '${this.name}':`, error); - this.emit("error", { server: this.name, error }); }, + requestInit: { headers }, }); - - // Start polling for connection status for SSE connections - this.startSseStatusPolling(); - } - - /** - * Start polling for SSE connection status - */ - private startSseStatusPolling(): void { - if (this.sseStatusPollingInterval) { - clearInterval(this.sseStatusPollingInterval); - } - - this.sseStatusPollingInterval = setInterval(async () => { - try { - if (this.client && this.status === ConnectionStatus.CONNECTING) { - // Try to get tools to check if connection is actually ready - const tools = await this.client.tools(); - if (tools && Object.keys(tools).length >= 0) { - // Connection is ready, update status - this.status = ConnectionStatus.CONNECTED; - this.startTime = Date.now(); - this.error = null; - - // Update capabilities - await this.updateCapabilities(); - - // Clear polling interval - if (this.sseStatusPollingInterval) { - clearInterval(this.sseStatusPollingInterval); - this.sseStatusPollingInterval = null; - } - - console.log( - `MCP server '${this.name}' connected successfully via SSE`, - ); - } - } - } catch (error) { - // Still connecting or authorization needed, continue polling - console.debug( - `SSE connection still in progress for ${this.name}:`, - error, - ); - } - }, 2000); // Poll every 2 seconds - } - - /** - * Convert AI SDK tools to unified format - */ - private convertAiSdkToolsToUnified( - aiTools: Record>, - ): Record { - const unifiedTools: Record = {}; - - for (const [name, tool] of Object.entries(aiTools)) { - unifiedTools[name] = { - description: tool.description || "", - parameters: tool.parameters, - execute: async (args: Record) => { - return await tool.execute?.(args, { - toolCallId: `${this.name}-${name}-${Date.now()}`, - messages: [], - }); - }, - }; - } - - return unifiedTools; + this.mcpClient = this.createMcpClient(); + await this.mcpClient.connect(transport); } /** @@ -765,16 +631,7 @@ export class MCPConnection extends EventEmitter { * Convert tools to legacy ToolDefinition format for backward compatibility */ private convertToolsToLegacyFormat(): ToolDefinition[] { - if (this.useAiSdk && this.client) { - // AI SDK tools - return Object.entries(this.tools).map(([name, tool]) => ({ - name, - description: tool.description, - inputSchema: this.zodSchemaToJsonSchema(tool.parameters), - parameters: this.zodSchemaToJsonSchema(tool.parameters), - })); - } else if (this.mcpClient && this.mcpTools.length > 0) { - // MCP tools + if (this.mcpClient && this.mcpTools.length > 0) { return this.mcpTools.map((tool) => ({ name: tool.name, description: tool.description, diff --git a/packages/app/src/electron/mcp/hub.ts b/packages/app/src/electron/mcp/hub.ts index 74537664..4e2b8ad7 100644 --- a/packages/app/src/electron/mcp/hub.ts +++ b/packages/app/src/electron/mcp/hub.ts @@ -9,7 +9,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { zodToJsonSchema } from "zod-to-json-schema"; -import { BUILTIN_TOOLS_REGISTRY } from "../tools"; +import { BUILTIN_TOOL_ANNOTATIONS, BUILTIN_TOOLS_REGISTRY } from "../tools"; import { MCPConnection } from "./connection"; /** @@ -542,8 +542,10 @@ export class MCPHub extends EventEmitter { return Object.entries(BUILTIN_TOOLS_REGISTRY).map(([name, tool]) => ({ name, description: tool.description || "", - inputSchema: this.zodSchemaToJsonSchema(tool.parameters), - parameters: this.zodSchemaToJsonSchema(tool.parameters), + inputSchema: this.zodSchemaToJsonSchema(tool.inputSchema), + parameters: this.zodSchemaToJsonSchema(tool.inputSchema), + annotations: + BUILTIN_TOOL_ANNOTATIONS[name as keyof typeof BUILTIN_TOOL_ANNOTATIONS], })); } diff --git a/packages/app/src/electron/mcp/index.ts b/packages/app/src/electron/mcp/index.ts index 1eb2dd11..820cdfab 100644 --- a/packages/app/src/electron/mcp/index.ts +++ b/packages/app/src/electron/mcp/index.ts @@ -113,7 +113,7 @@ export function getAllTools(): Array<{ } const builtinTools = { - serverName: "Builtin", + serverName: "builtin", tools: globalHub.getBuiltinToolsDefinition(), }; diff --git a/packages/app/src/electron/mcp/runtime-catalog.test.ts b/packages/app/src/electron/mcp/runtime-catalog.test.ts new file mode 100644 index 00000000..a49f1051 --- /dev/null +++ b/packages/app/src/electron/mcp/runtime-catalog.test.ts @@ -0,0 +1,66 @@ +import type { LanguageModel } from "ai"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { LocalAiProviderAdapter } from "../ai/provider-adapter"; +import { LOCAL_AI_PROVIDER_DESCRIPTORS } from "../ai/provider-descriptors"; +import { LocalAiRuntime } from "../ai/runtime"; +import { cleanupMCPHub, getAllTools, initializeMCPHub } from "./index"; + +describe("main-process agent tool catalog", () => { + afterEach(async () => { + await cleanupMCPHub(); + }); + + it("provides every builtin tool to startChat after MCP initialization", async () => { + const createModel = vi.fn( + async () => ({}) as LanguageModel, + ); + const adapter: LocalAiProviderAdapter = { + id: "codex-cli", + getStatus: vi.fn(async () => ({ + ...LOCAL_AI_PROVIDER_DESCRIPTORS["codex-cli"], + available: true, + authenticated: true, + checkedAt: new Date(0).toISOString(), + })), + createModel, + dispose: vi.fn(async () => undefined), + }; + const configPath = join( + tmpdir(), + `convera-mcp-runtime-catalog-${process.pid}.json`, + ); + const runtime = new LocalAiRuntime({ + adapters: [adapter], + getToolGroups: async () => { + await initializeMCPHub(configPath); + return getAllTools(); + }, + streamInvoker: () => ({ + toUIMessageStream: async function* () { + yield { type: "finish" as const, finishReason: "stop" as const }; + }, + }), + }); + + await runtime.startChat( + { + requestId: "runtime-catalog", + providerId: "codex-cli", + messages: [{ role: "user", content: "List available tools." }], + }, + vi.fn(), + ); + + const context = createModel.mock.calls[0]?.[2]; + expect(context?.tools.map((tool) => tool.qualifiedName)).toEqual([ + "builtin:ask_user_input", + "builtin:computer_control", + "builtin:execute_command", + "builtin:web_fetch", + ]); + + await runtime.dispose(); + }); +}); diff --git a/packages/app/src/electron/tools/ask-user-input.ts b/packages/app/src/electron/tools/ask-user-input.ts index b6bb47e2..08e8b73d 100644 --- a/packages/app/src/electron/tools/ask-user-input.ts +++ b/packages/app/src/electron/tools/ask-user-input.ts @@ -8,6 +8,14 @@ import { tool } from "ai"; import { z } from "zod"; +const inputOption = z.union([ + z.string(), + z.object({ + label: z.string(), + description: z.string().optional(), + }), +]); + /** * Ask user input tool * @@ -19,12 +27,14 @@ import { z } from "zod"; export const askUserInput = tool({ description: "Ask the user to select from predefined options or provide custom input. Use this when you need user clarification or a choice before proceeding.", - parameters: z.object({ + inputSchema: z.object({ question: z.string().describe("The question to ask the user"), options: z - .array(z.string()) + .array(inputOption) .max(3) - .describe("Up to 3 predefined options for the user to choose from"), + .describe( + "Up to 3 predefined options. Each option may be a plain string or an object with a label and optional description.", + ), }), execute: async ({ question, options }) => { // This is a client-side tool - execution happens in renderer process @@ -33,7 +43,9 @@ export const askUserInput = tool({ return { _clientSideTool: true, question, - options, + options: options.map((option) => + typeof option === "string" ? option : option.label, + ), message: "Waiting for user input...", }; }, diff --git a/packages/app/src/electron/tools/computer-control.test.ts b/packages/app/src/electron/tools/computer-control.test.ts new file mode 100644 index 00000000..7584b91b --- /dev/null +++ b/packages/app/src/electron/tools/computer-control.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createComputerControl, + type ComputerBitmap, + type ComputerControlDependencies, + type ComputerRobot, +} from "./computer-control"; + +function testRig( + permissionOverrides: Partial< + Awaited> + > = {}, +) { + const bitmap: ComputerBitmap = { + width: 1440, + height: 900, + image: Buffer.alloc(1440 * 900 * 4), + byteWidth: 1440 * 4, + bitsPerPixel: 32, + bytesPerPixel: 4, + }; + const robot: ComputerRobot = { + screen: { capture: vi.fn(() => bitmap) }, + getScreenSize: vi.fn(() => ({ width: 1440, height: 900 })), + getMousePos: vi.fn(() => ({ x: 100, y: 200 })), + moveMouse: vi.fn(), + moveMouseSmooth: vi.fn(), + mouseClick: vi.fn(), + mouseToggle: vi.fn(), + scrollMouse: vi.fn(), + keyTap: vi.fn(), + typeString: vi.fn(), + }; + const dependencies: ComputerControlDependencies = { + getRobot: vi.fn(async () => robot), + bitmapToPng: vi.fn(async () => Buffer.from("png")), + getPermissions: vi.fn(async () => ({ + accessibility: true, + screenRecording: "granted", + ...permissionOverrides, + })), + wait: vi.fn(async () => undefined), + }; + const computer = createComputerControl(dependencies); + const execute = ( + input: Parameters>[0], + ) => + computer.execute!(input, { + toolCallId: "computer-control-test", + messages: [], + }); + + return { bitmap, computer, dependencies, execute, robot }; +} + +describe("computer_control", () => { + it("returns screenshots as MCP image content with coordinate metadata", async () => { + const { execute } = testRig(); + + await expect(execute({ action: "screenshot" })).resolves.toEqual({ + content: [ + { + type: "text", + text: JSON.stringify({ + success: true, + action: "screenshot", + screen: { width: 1440, height: 900 }, + cursor: { x: 100, y: 200 }, + coordinateSpace: "Screenshot top-left is [0, 0].", + }), + }, + { + type: "image", + data: Buffer.from("png").toString("base64"), + mimeType: "image/png", + }, + ], + }); + }); + + it("executes clicks at validated screenshot coordinates", async () => { + const { execute, robot } = testRig(); + + await execute({ action: "double_click", coordinate: [320, 240] }); + + expect(robot.moveMouse).toHaveBeenCalledWith(320, 240); + expect(robot.mouseClick).toHaveBeenCalledWith("left", true); + }); + + it("parses portable key shortcuts", async () => { + const { execute, robot } = testRig(); + + await execute({ action: "key", key: "CMD+SHIFT+P" }); + + expect(robot.keyTap).toHaveBeenCalledWith("p", ["command", "shift"]); + }); + + it("releases the mouse if a drag fails", async () => { + const { execute, robot } = testRig(); + vi.mocked(robot.moveMouseSmooth).mockImplementation(() => { + throw new Error("drag failed"); + }); + + await expect( + execute({ + action: "left_click_drag", + start_coordinate: [10, 20], + coordinate: [30, 40], + }), + ).rejects.toThrow("drag failed"); + expect(robot.mouseToggle).toHaveBeenNthCalledWith(1, "down", "left"); + expect(robot.mouseToggle).toHaveBeenNthCalledWith(2, "up", "left"); + }); + + it("returns actionable validation and permission errors", async () => { + const { execute } = testRig({ accessibility: false }); + + await expect(execute({ action: "left_click" })).rejects.toThrow( + "requires 'coordinate'", + ); + await expect( + execute({ action: "left_click", coordinate: [2000, 10] }), + ).rejects.toThrow("ACCESSIBILITY_PERMISSION_REQUIRED"); + }); +}); diff --git a/packages/app/src/electron/tools/computer-control.ts b/packages/app/src/electron/tools/computer-control.ts new file mode 100644 index 00000000..2964fa4c --- /dev/null +++ b/packages/app/src/electron/tools/computer-control.ts @@ -0,0 +1,387 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { tool } from "ai"; +import { z } from "zod"; + +type MouseButton = "left" | "right" | "middle"; +type KeyModifier = "alt" | "command" | "control" | "shift"; + +export interface ComputerBitmap { + width: number; + height: number; + image: Buffer; + byteWidth: number; + bitsPerPixel: number; + bytesPerPixel: number; +} + +export interface ComputerRobot { + screen: { + capture( + x?: number, + y?: number, + width?: number, + height?: number, + ): ComputerBitmap; + }; + getScreenSize(): { width: number; height: number }; + getMousePos(): { x: number; y: number }; + moveMouse(x: number, y: number): void; + moveMouseSmooth(x: number, y: number): void; + mouseClick(button?: MouseButton, double?: boolean): void; + mouseToggle(state: "up" | "down", button?: MouseButton): void; + scrollMouse(x: number, y: number): void; + keyTap(key: string, modifier?: KeyModifier | KeyModifier[]): void; + typeString(text: string): void; +} + +export interface ComputerControlDependencies { + getRobot(): Promise; + bitmapToPng(bitmap: ComputerBitmap): Promise; + getPermissions(): Promise<{ + accessibility: boolean | "unsupported"; + screenRecording: string | "unsupported"; + }>; + wait(durationMs: number): Promise; +} + +const coordinateSchema = z + .tuple([z.number().int().nonnegative(), z.number().int().nonnegative()]) + .describe("Absolute [x, y] coordinates in the screenshot coordinate space"); + +const computerControlInput = z.object({ + action: z + .enum([ + "screenshot", + "mouse_move", + "left_click", + "right_click", + "middle_click", + "double_click", + "left_click_drag", + "type", + "key", + "scroll", + "wait", + ]) + .describe("One atomic computer action to execute"), + coordinate: coordinateSchema.optional(), + start_coordinate: coordinateSchema + .optional() + .describe("Drag start [x, y]; required for left_click_drag"), + text: z + .string() + .max(20000) + .optional() + .describe("Text to type; required for the type action"), + key: z + .string() + .max(100) + .optional() + .describe("Key or shortcut such as ENTER, CMD+L, or CTRL+SHIFT+P"), + scroll_x: z + .number() + .int() + .min(-10000) + .max(10000) + .optional() + .describe("Horizontal scroll amount; negative scrolls left"), + scroll_y: z + .number() + .int() + .min(-10000) + .max(10000) + .optional() + .describe("Vertical scroll amount; negative scrolls down"), + duration_ms: z + .number() + .int() + .min(0) + .max(10000) + .optional() + .describe("Wait duration in milliseconds; required for wait"), +}); + +type ComputerControlInput = z.infer; + +const actionRequirements: Partial< + Record> +> = { + mouse_move: ["coordinate"], + left_click: ["coordinate"], + right_click: ["coordinate"], + middle_click: ["coordinate"], + double_click: ["coordinate"], + left_click_drag: ["start_coordinate", "coordinate"], + type: ["text"], + key: ["key"], + wait: ["duration_ms"], +}; + +function validateActionInput(input: ComputerControlInput): void { + for (const field of actionRequirements[input.action] ?? []) { + if (input[field] === undefined) { + throw new Error( + `INVALID_COMPUTER_ACTION: '${input.action}' requires '${field}'. Add the missing field and retry.`, + ); + } + } + + if ( + input.action === "scroll" && + input.scroll_x === undefined && + input.scroll_y === undefined + ) { + throw new Error( + "INVALID_COMPUTER_ACTION: 'scroll' requires scroll_x or scroll_y. Add a non-zero scroll amount and retry.", + ); + } +} + +function validateCoordinate( + coordinate: [number, number], + screenSize: { width: number; height: number }, +): void { + const [x, y] = coordinate; + if (x >= screenSize.width || y >= screenSize.height) { + throw new Error( + `COORDINATE_OUT_OF_BOUNDS: [${x}, ${y}] is outside ${screenSize.width}x${screenSize.height}. Take a new screenshot and retry with coordinates inside it.`, + ); + } +} + +function parseKeyShortcut(value: string): { + key: string; + modifiers?: KeyModifier[]; +} { + const parts = value + .split("+") + .map((part) => part.trim().toLowerCase()) + .filter(Boolean); + const key = parts.pop(); + if (!key) { + throw new Error( + "INVALID_KEY: provide a key such as ENTER, CMD+L, or CTRL+SHIFT+P.", + ); + } + + const aliases: Record = { + alt: "alt", + option: "alt", + cmd: "command", + command: "command", + meta: "command", + ctrl: "control", + control: "control", + shift: "shift", + }; + const modifiers = parts.map((part) => aliases[part]); + const invalidModifier = parts.find((_, index) => !modifiers[index]); + if (invalidModifier) { + throw new Error( + `INVALID_KEY_MODIFIER: '${invalidModifier}' is unsupported. Use CMD, CTRL, ALT/OPTION, or SHIFT.`, + ); + } + + return { + key, + modifiers: modifiers.length ? modifiers : undefined, + }; +} + +function textResult( + input: ComputerControlInput, + robot: ComputerRobot, +): CallToolResult { + const screen = robot.getScreenSize(); + const cursor = robot.getMousePos(); + return { + content: [ + { + type: "text", + text: JSON.stringify({ + success: true, + action: input.action, + screen, + cursor, + }), + }, + ], + }; +} + +async function screenshotResult( + robot: ComputerRobot, + bitmapToPng: ComputerControlDependencies["bitmapToPng"], +): Promise { + const bitmap = robot.screen.capture(); + const png = await bitmapToPng(bitmap); + const cursor = robot.getMousePos(); + return { + content: [ + { + type: "text", + text: JSON.stringify({ + success: true, + action: "screenshot", + screen: { width: bitmap.width, height: bitmap.height }, + cursor, + coordinateSpace: "Screenshot top-left is [0, 0].", + }), + }, + { + type: "image", + data: png.toString("base64"), + mimeType: "image/png", + }, + ], + }; +} + +function assertPermission( + action: ComputerControlInput["action"], + permissions: Awaited< + ReturnType + >, +): void { + if (action === "screenshot") { + if ( + permissions.screenRecording !== "granted" && + permissions.screenRecording !== "unsupported" + ) { + throw new Error( + `SCREEN_RECORDING_PERMISSION_REQUIRED: current status is '${permissions.screenRecording}'. Grant Convera Screen Recording permission in system settings, restart Convera, and retry.`, + ); + } + return; + } + + if (action !== "wait" && permissions.accessibility === false) { + throw new Error( + "ACCESSIBILITY_PERMISSION_REQUIRED: grant Convera Accessibility permission in system settings, restart Convera, and retry.", + ); + } +} + +async function defaultDependencies(): Promise { + return { + getRobot: async () => + (await import("../../shared/robot.js")) + .default as unknown as ComputerRobot, + bitmapToPng: async (bitmap) => { + if ( + bitmap.bitsPerPixel !== 32 || + bitmap.bytesPerPixel !== 4 || + bitmap.byteWidth !== bitmap.width * 4 + ) { + throw new Error( + `UNSUPPORTED_SCREEN_BITMAP: expected packed 32-bit pixels, received ${bitmap.bitsPerPixel}-bit with byte width ${bitmap.byteWidth}.`, + ); + } + const { nativeImage } = await import("electron"); + const image = nativeImage.createFromBitmap(Buffer.from(bitmap.image), { + width: bitmap.width, + height: bitmap.height, + scaleFactor: 1, + }); + const png = image.toPNG(); + if (png.length === 0) { + throw new Error( + "SCREENSHOT_ENCODING_FAILED: Electron could not encode the captured display. Retry after checking Screen Recording permission.", + ); + } + return png; + }, + getPermissions: async () => { + if (process.platform !== "darwin") { + return { + accessibility: "unsupported", + screenRecording: "unsupported", + }; + } + const { systemPreferences } = await import("electron"); + return { + accessibility: systemPreferences.isTrustedAccessibilityClient(false), + screenRecording: systemPreferences.getMediaAccessStatus("screen"), + }; + }, + wait: (durationMs) => + new Promise((resolve) => setTimeout(resolve, durationMs)), + }; +} + +export function createComputerControl( + dependencies?: ComputerControlDependencies, +) { + return tool({ + description: + "Observe and control the user's real desktop with one atomic action. Use screenshot first and again after actions when visual confirmation is needed. Coordinates use the latest screenshot's top-left as [0, 0]. Supported actions are screenshot, mouse_move, left_click, right_click, middle_click, double_click, left_click_drag, type, key, scroll, and wait. Returns JSON state for actions and an MCP image plus screen dimensions for screenshots. Desktop contents may be sensitive and every call requires user approval.", + inputSchema: computerControlInput, + execute: async (input) => { + validateActionInput(input); + const resolvedDependencies = + dependencies ?? (await defaultDependencies()); + const permissions = await resolvedDependencies.getPermissions(); + assertPermission(input.action, permissions); + const robot = await resolvedDependencies.getRobot(); + + if (input.action === "screenshot") { + return screenshotResult(robot, resolvedDependencies.bitmapToPng); + } + + const screenSize = robot.getScreenSize(); + if (input.coordinate) { + validateCoordinate(input.coordinate, screenSize); + } + if (input.start_coordinate) { + validateCoordinate(input.start_coordinate, screenSize); + } + + switch (input.action) { + case "mouse_move": + robot.moveMouse(...input.coordinate!); + break; + case "left_click": + case "right_click": + case "middle_click": + case "double_click": { + robot.moveMouse(...input.coordinate!); + const button = + input.action === "right_click" + ? "right" + : input.action === "middle_click" + ? "middle" + : "left"; + robot.mouseClick(button, input.action === "double_click"); + break; + } + case "left_click_drag": + robot.moveMouse(...input.start_coordinate!); + robot.mouseToggle("down", "left"); + try { + robot.moveMouseSmooth(...input.coordinate!); + } finally { + robot.mouseToggle("up", "left"); + } + break; + case "type": + robot.typeString(input.text!); + break; + case "key": { + const shortcut = parseKeyShortcut(input.key!); + robot.keyTap(shortcut.key, shortcut.modifiers); + break; + } + case "scroll": + robot.scrollMouse(input.scroll_x ?? 0, input.scroll_y ?? 0); + break; + case "wait": + await resolvedDependencies.wait(input.duration_ms!); + break; + } + + return textResult(input, robot); + }, + }); +} + +export const computerControl = createComputerControl(); diff --git a/packages/app/src/electron/tools/execute-command.ts b/packages/app/src/electron/tools/execute-command.ts index 09d7c031..fded7795 100644 --- a/packages/app/src/electron/tools/execute-command.ts +++ b/packages/app/src/electron/tools/execute-command.ts @@ -32,7 +32,7 @@ const execAsync = (command: string, options: any) => { // Define execute command tool export const executeCommand = tool({ description: "Execute a shell command and return the output", - parameters: z.object({ + inputSchema: z.object({ command: z.string().describe("The shell command to execute"), timeout: z .number() diff --git a/packages/app/src/electron/tools/index.ts b/packages/app/src/electron/tools/index.ts index 369d71e8..330864a1 100644 --- a/packages/app/src/electron/tools/index.ts +++ b/packages/app/src/electron/tools/index.ts @@ -5,11 +5,13 @@ */ import { askUserInput } from "./ask-user-input"; +import { computerControl } from "./computer-control"; import { executeCommand } from "./execute-command"; import { webFetch } from "./web-fetch"; export const builtinTools = { askUserInput, + computerControl, executeCommand, webFetch, }; @@ -20,6 +22,30 @@ export const builtinTools = { */ export const BUILTIN_TOOLS_REGISTRY = { ask_user_input: askUserInput, + computer_control: computerControl, execute_command: executeCommand, web_fetch: webFetch, } as const; + +export const BUILTIN_TOOL_ANNOTATIONS = { + ask_user_input: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, + computer_control: { + readOnlyHint: false, + destructiveHint: true, + openWorldHint: true, + }, + execute_command: { + readOnlyHint: false, + destructiveHint: true, + openWorldHint: true, + }, + web_fetch: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: true, + }, +} as const; diff --git a/packages/app/src/electron/tools/web-fetch.ts b/packages/app/src/electron/tools/web-fetch.ts index 51a76933..5f7da8da 100644 --- a/packages/app/src/electron/tools/web-fetch.ts +++ b/packages/app/src/electron/tools/web-fetch.ts @@ -88,7 +88,7 @@ async function fetchUrl( // Define web fetch tool export const webFetch = tool({ description: "Fetch content from a web URL and return the response", - parameters: z.object({ + inputSchema: z.object({ url: z.string().url().describe("The URL to fetch"), timeout: z .number() diff --git a/packages/app/src/electron/windows/main-window.ts b/packages/app/src/electron/windows/main-window.ts index aacf4a78..46d6145a 100644 --- a/packages/app/src/electron/windows/main-window.ts +++ b/packages/app/src/electron/windows/main-window.ts @@ -40,8 +40,12 @@ function createPlatformSpecificConfig(): BrowserWindowConstructorOptions { webPreferences: { devTools: inDevelopment, contextIsolation: true, - nodeIntegration: true, + nodeIntegration: false, nodeIntegrationInSubFrames: false, + // The preload bundle owns the privileged bridge and imports Node-backed + // Electron modules. Keep the renderer isolated, but do not run preload + // inside Electron's restricted sandbox where those imports fail. + sandbox: false, preload: path.join(__dirname, "preload.js"), }, show: false, @@ -95,6 +99,10 @@ function loadWindowContent(window: BrowserWindow) { // Setup window event handlers function setupWindowEventHandlers(window: BrowserWindow) { + window.webContents.on("preload-error", (_event, preloadPath, error) => { + logger.error(`Failed to load preload script at ${preloadPath}`, error); + }); + // Intercept navigations that might be triggered by OAuth flows and open externally const maybeOpenExternal = (targetUrl: string): boolean => { try { diff --git a/packages/app/src/preload.ts b/packages/app/src/preload.ts index e8de4fe2..b120e535 100644 --- a/packages/app/src/preload.ts +++ b/packages/app/src/preload.ts @@ -3,6 +3,7 @@ import { CHANNELS } from "./electro-bridge/ipc/channels"; import { exposeEnvContext } from "./electro-bridge/ipc/env-context"; import { createElectronAPI } from "./electro-bridge/ipc/listeners-register"; import { exposeLoggerContext } from "./electro-bridge/ipc/logger-context"; +import { exposeLocalAIContext } from "./electro-bridge/ipc/local-ai-context"; import { exposeMCPContext } from "./electro-bridge/ipc/mcp-context"; // SOURCE(Sma1lboy): https://www.electronjs.org/docs/latest/tutorial/process-model @@ -32,6 +33,9 @@ exposeLoggerContext(); // Expose Environment API to renderer process (separate from electronAPI) exposeEnvContext(); +// Expose local AI API to renderer process +exposeLocalAIContext(); + // Expose Platform API to renderer process contextBridge.exposeInMainWorld("platformAPI", { getPlatform: () => ipcRenderer.invoke(CHANNELS.PLATFORM.GET), diff --git a/packages/app/src/renderer/components/account/account-section.tsx b/packages/app/src/renderer/components/account/account-section.tsx deleted file mode 100644 index 00d8136f..00000000 --- a/packages/app/src/renderer/components/account/account-section.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { AuthModal } from "@/renderer/components/auth/auth-modal"; -import { useAccountProfile } from "@/renderer/libs/hooks/use-account-profile"; -import { useUsageStats } from "@/renderer/libs/hooks/use-usage-stats"; -import React from "react"; -import { ProfileSection } from "./profile-section"; -import { UsageStatsSection } from "./usage-stats-section"; - -export function AccountSection() { - const { - user, - currentAvatar, - isEditingName, - editedName, - isUpdatingName, - isUploadingAvatar, - setEditedName, - handleSignOut, - handleStartEdit, - handleCancelEdit, - handleSaveName, - handleAvatarUpload, - } = useAccountProfile(); - - const { usageStats, loadingStats, formatNumber } = useUsageStats(user); - - if (user) { - return ( -
- - - -
- ); - } - - return ( -
- -
- ); -} diff --git a/packages/app/src/renderer/components/account/index.ts b/packages/app/src/renderer/components/account/index.ts deleted file mode 100644 index 0b54a668..00000000 --- a/packages/app/src/renderer/components/account/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { AccountSection } from "./account-section"; -export { ProfileSection } from "./profile-section"; -export { UsageStatsSection } from "./usage-stats-section"; diff --git a/packages/app/src/renderer/components/account/profile-section.tsx b/packages/app/src/renderer/components/account/profile-section.tsx deleted file mode 100644 index 8168b591..00000000 --- a/packages/app/src/renderer/components/account/profile-section.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { Button } from "@/renderer/components/ui/button"; -import { Input } from "@/renderer/components/ui/input"; -import { Camera, Check, Edit, LogOut, User, X } from "lucide-react"; -import React from "react"; -import { User as UserType } from "@/renderer/types/auth"; - -interface ProfileSectionProps { - user: UserType | null; - currentAvatar: string | null; - isEditingName: boolean; - editedName: string; - isUpdatingName: boolean; - isUploadingAvatar: boolean; - setEditedName: (name: string) => void; - onStartEdit: () => void; - onCancelEdit: () => void; - onSaveName: () => void; - onAvatarUpload: (event: React.ChangeEvent) => void; - onSignOut: () => void; -} - -export function ProfileSection({ - user, - currentAvatar, - isEditingName, - editedName, - isUpdatingName, - isUploadingAvatar, - setEditedName, - onStartEdit, - onCancelEdit, - onSaveName, - onAvatarUpload, - onSignOut, -}: ProfileSectionProps) { - return ( -
-

Profile

-
-
-
-
-
- {currentAvatar || user?.image ? ( - {user?.name - ) : ( -
- -
- )} -
- -
- -
-
-
- ); -} diff --git a/packages/app/src/renderer/components/account/usage-stats-section.tsx b/packages/app/src/renderer/components/account/usage-stats-section.tsx deleted file mode 100644 index ca1d2b0b..00000000 --- a/packages/app/src/renderer/components/account/usage-stats-section.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { BarChart3 } from "lucide-react"; -import React from "react"; -import { UsageStats } from "@/renderer/libs/hooks/use-usage-stats"; - -interface UsageStatsSectionProps { - usageStats: UsageStats | null; - loadingStats: boolean; - formatNumber: (num: number) => string; -} - -export function UsageStatsSection({ - usageStats, - loadingStats, - formatNumber, -}: UsageStatsSectionProps) { - return ( -
-

Usage Statistics

- - {loadingStats ? ( -
-
-
- - Loading usage statistics... - -
-
- ) : usageStats && usageStats.total.requests > 0 ? ( -
-
-
-
- {formatNumber(usageStats.total.requests)} -
-
- Total Requests -
-
- {formatNumber(usageStats.recent.requests)} this month -
-
-
-
- {formatNumber(usageStats.total.totalTokens)} -
-
Total Tokens
-
- {formatNumber(usageStats.recent.tokens)} this month -
-
-
-
- ) : ( -
-
-
- -
-

- No Usage Data Yet -

-

- Start using remote servers to see your usage statistics here. -

-
-
- )} -
- ); -} diff --git a/packages/app/src/renderer/components/auth/auth-modal.tsx b/packages/app/src/renderer/components/auth/auth-modal.tsx deleted file mode 100644 index d3024402..00000000 --- a/packages/app/src/renderer/components/auth/auth-modal.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { AuthCard } from "@daveyplate/better-auth-ui"; -import React from "react"; - -export function AuthModal() { - const next = encodeURIComponent("/settings?from=auth&tab=general"); - const callbackURL = `https://api.foxychat.net/redirect/auth?next=${next}`; - - return ( -
- -
- ); -} diff --git a/packages/app/src/renderer/components/auth/auth-setup-modal.tsx b/packages/app/src/renderer/components/auth/auth-setup-modal.tsx deleted file mode 100644 index a1d8a08d..00000000 --- a/packages/app/src/renderer/components/auth/auth-setup-modal.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { AnimatePresence, motion } from "framer-motion"; -import { X } from "lucide-react"; -import React, { useState } from "react"; -import { createPortal } from "react-dom"; -import { AuthModal } from "./auth-modal"; -import { ModelConfigForm } from "./model-config-form"; - -interface AuthSetupModalProps { - open: boolean; - onClose: () => void; -} - -type TabType = "login" | "custom-api"; - -export function AuthSetupModal({ open, onClose }: AuthSetupModalProps) { - const [activeTab, setActiveTab] = useState("login"); - - if (!open) return null; - - return createPortal( - - {/* Close button */} - - - {/* Fixed Tab Switcher at top - absolute positioned */} -
-
- {/* Animated background pill */} - - - - -
-
- - {/* Centered Content Area */} -
-
- - {activeTab === "login" && ( - - - - )} - - {activeTab === "custom-api" && ( - - - - )} - -
-
-
, - document.body, - ); -} diff --git a/packages/app/src/renderer/components/auth/model-config-form.tsx b/packages/app/src/renderer/components/auth/model-config-form.tsx deleted file mode 100644 index 83139bb8..00000000 --- a/packages/app/src/renderer/components/auth/model-config-form.tsx +++ /dev/null @@ -1,481 +0,0 @@ -import { Button } from "@/renderer/components/ui/button"; -import { Input } from "@/renderer/components/ui/input"; -import { Label } from "@/renderer/components/ui/label"; -import { - fetchModelsFromEndpoint, - useModelConfigStore, -} from "@/renderer/libs/stores/model-config-store"; -import { Check, Loader2, Search, X } from "lucide-react"; -import React, { useCallback, useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; - -interface ValidationErrors { - name?: string; - endpoint?: string; - apiKey?: string; - models?: string; -} - -const DEFAULT_ENDPOINT = "https://openrouter.ai/api/v1"; -const DEFAULT_NAME = "OpenRouter"; - -interface ModelConfigFormProps { - onSuccess?: () => void; - editingConfigId?: string; -} - -export function ModelConfigForm({ - onSuccess, - editingConfigId, -}: ModelConfigFormProps) { - const { addModelConfig, updateModelConfig, getConfigById } = - useModelConfigStore(); - - // Load existing config if editing - const existingConfig = editingConfigId - ? getConfigById(editingConfigId) - : undefined; - - const [name, setName] = useState(existingConfig?.name || DEFAULT_NAME); - const [endpoint, setEndpoint] = useState( - existingConfig?.endpoint || DEFAULT_ENDPOINT, - ); - const [apiKey, setApiKey] = useState(existingConfig?.apiKey || ""); - const [selectedModels, setSelectedModels] = useState>( - new Set(existingConfig?.models || []), - ); - const [availableModels, setAvailableModels] = useState( - existingConfig?.models || [], - ); - const [modelFilter, setModelFilter] = useState(""); - const [errors, setErrors] = useState({}); - - // Convert selected models set to array for validation and saving - const models = Array.from(selectedModels); - const [isLoading, setIsLoading] = useState(false); - const [isFetchingModels, setIsFetchingModels] = useState(false); - const lastFetchedEndpointRef = useRef(""); - - const validateForm = (): boolean => { - const newErrors: ValidationErrors = {}; - - // Validate name - if (!name.trim()) { - newErrors.name = "Configuration name is required"; - } - - // Validate endpoint - if (!endpoint.trim()) { - newErrors.endpoint = "API endpoint is required"; - } else { - try { - const url = new URL(endpoint); - if (!url.protocol.startsWith("http")) { - newErrors.endpoint = "Endpoint must use HTTP or HTTPS"; - } - } catch { - newErrors.endpoint = "Invalid URL format"; - } - } - - // Validate API key - if (!apiKey.trim()) { - newErrors.apiKey = "API key is required"; - } else if (apiKey.startsWith("Bearer ")) { - newErrors.apiKey = 'Please enter the API key without "Bearer" prefix'; - } - - // Validate models (at least one required) - if (models.length === 0) { - newErrors.models = "At least one model is required"; - } - - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }; - - const handleFetchModels = useCallback(async () => { - if (!endpoint.trim()) { - return; - } - - // Skip if we already fetched for this endpoint + apiKey combo - const fetchKey = `${endpoint.trim()}:${apiKey.trim()}`; - if (lastFetchedEndpointRef.current === fetchKey) { - return; - } - - setIsFetchingModels(true); - try { - const fetchedModels = await fetchModelsFromEndpoint(endpoint, apiKey); - lastFetchedEndpointRef.current = fetchKey; - - if (fetchedModels.length === 0) { - toast.warning("No models found from this endpoint"); - return; - } - - // Set available models (don't auto-select) - setAvailableModels(fetchedModels); - setErrors((prev) => ({ ...prev, models: undefined })); - - toast.success(`Found ${fetchedModels.length} models`); - } catch (error) { - console.error("Failed to fetch models:", error); - toast.error("Failed to fetch models. Check your endpoint and API key."); - } finally { - setIsFetchingModels(false); - } - }, [endpoint, apiKey]); - - const handleEndpointBlur = useCallback(() => { - // Auto-fetch models when user leaves endpoint input - if (endpoint.trim()) { - handleFetchModels(); - } - }, [endpoint, handleFetchModels]); - - // Auto-fetch models when endpoint is filled (apiKey is optional for public APIs) - useEffect(() => { - if (endpoint.trim() && models.length === 0) { - // Debounce to avoid fetching on every keystroke - const timer = setTimeout(() => { - handleFetchModels(); - }, 500); - return () => clearTimeout(timer); - } - }, [endpoint, apiKey, models.length, handleFetchModels]); - - const handleToggleModel = (modelId: string) => { - setSelectedModels((prev) => { - const newSet = new Set(prev); - if (newSet.has(modelId)) { - newSet.delete(modelId); - } else { - newSet.add(modelId); - } - return newSet; - }); - setErrors((prev) => ({ ...prev, models: undefined })); - }; - - const handleSelectAll = () => { - setSelectedModels(new Set(filteredModels)); - setErrors((prev) => ({ ...prev, models: undefined })); - }; - - const handleDeselectAll = () => { - setSelectedModels(new Set()); - }; - - // Filter models based on search input - const filteredModels = availableModels.filter((model) => - model.toLowerCase().includes(modelFilter.toLowerCase()), - ); - - const handleTestConnection = async () => { - if (!endpoint.trim() || !apiKey.trim()) { - toast.error("Please enter endpoint and API key first"); - return; - } - - setIsLoading(true); - try { - const testEndpoint = endpoint.endsWith("/v1") - ? `${endpoint}/models` - : `${endpoint}/models`; - - const response = await fetch(testEndpoint, { - method: "GET", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - }); - - if (response.ok) { - toast.success("Connection successful!"); - } else if (response.status === 401) { - toast.error("Invalid API key"); - } else { - toast.error(`Connection failed: ${response.statusText}`); - } - } catch (error) { - console.error("Connection test failed:", error); - toast.error("Failed to connect to API. Check your endpoint."); - } finally { - setIsLoading(false); - } - }; - - const handleSave = async () => { - if (!validateForm()) return; - - // Warn if endpoint doesn't end with /v1 - if (!endpoint.endsWith("/v1")) { - toast.warning("Tip: Most OpenAI-compatible APIs use /v1 endpoint"); - } - - setIsLoading(true); - try { - if (editingConfigId) { - // Update existing config - updateModelConfig(editingConfigId, { - name, - endpoint, - apiKey, - models, - }); - toast.success("Configuration updated successfully!"); - } else { - // Add new config - addModelConfig({ - name, - endpoint, - apiKey, - models, - isDefault: false, - }); - toast.success("Configuration added successfully!"); - } - - // Dispatch event to close modal - window.dispatchEvent(new CustomEvent("model-config-saved")); - onSuccess?.(); - } catch (error) { - console.error("Failed to save configuration:", error); - toast.error("Failed to save configuration"); - } finally { - setIsLoading(false); - } - }; - - return ( -
-
-

- {editingConfigId - ? "Edit Model Configuration" - : "Add Model Configuration"} -

-

- Configure an OpenAI-compatible API endpoint -

-
- -
- {/* Config Name */} -
- - { - setName(e.target.value); - setErrors((prev) => ({ ...prev, name: undefined })); - }} - aria-invalid={!!errors.name} - disabled={isLoading} - /> - {errors.name && ( -

{errors.name}

- )} -
- - {/* API Endpoint */} -
- - { - setEndpoint(e.target.value); - setErrors((prev) => ({ ...prev, endpoint: undefined })); - }} - onBlur={handleEndpointBlur} - aria-invalid={!!errors.endpoint} - disabled={isLoading} - /> - {errors.endpoint && ( -

{errors.endpoint}

- )} -
- - {/* API Key */} -
- - { - setApiKey(e.target.value); - setErrors((prev) => ({ ...prev, apiKey: undefined })); - }} - onBlur={handleEndpointBlur} - aria-invalid={!!errors.apiKey} - disabled={isLoading} - /> - {errors.apiKey && ( -

{errors.apiKey}

- )} -
- - {/* Models Section */} -
-
- - {isFetchingModels && ( -
- - Fetching... -
- )} -
- - {/* Search/Filter input */} - {availableModels.length > 0 && ( -
- - setModelFilter(e.target.value)} - disabled={isLoading} - className="pl-8 h-8 text-sm" - /> - {modelFilter && ( - - )} -
- )} - - {/* Select all / Deselect all */} - {availableModels.length > 0 && ( -
- - | - -
- )} - - {/* Model list */} -
- {availableModels.length === 0 ? ( -

- {isFetchingModels - ? "Loading models..." - : "Enter endpoint to load models"} -

- ) : filteredModels.length === 0 ? ( -

- No models match “{modelFilter}” -

- ) : ( -
- {filteredModels.map((model) => { - const isSelected = selectedModels.has(model); - return ( - - ); - })} -
- )} -
- {errors.models && ( -

{errors.models}

- )} -
-
- -
- - -
-
- ); -} diff --git a/packages/app/src/renderer/components/auth/user-button.tsx b/packages/app/src/renderer/components/auth/user-button.tsx deleted file mode 100644 index 6049861e..00000000 --- a/packages/app/src/renderer/components/auth/user-button.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import { authClient } from "@/renderer/libs/auth-client"; -import { useModelConfigStore } from "@/renderer/libs/stores/model-config-store"; -import { FOXYCHAT_CONFIG_ID } from "@/shared/types/settings"; -import { AnimatePresence } from "framer-motion"; -import { ChevronUp, Key, LogOut, User } from "lucide-react"; -import React, { useEffect, useState } from "react"; -import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; -import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; -import { AuthSetupModal } from "./auth-setup-modal"; - -interface CustomUserButtonProps { - collapsed?: boolean; -} - -export function UserButton({ collapsed = false }: CustomUserButtonProps) { - const [showAuthModal, setShowAuthModal] = useState(false); - const [showMenu, setShowMenu] = useState(false); - const { data: session, isPending } = authClient.useSession(); - const { signOut } = authClient; - const { modelConfigs, selectedConfigId } = useModelConfigStore(); - - // Determine if using custom model config (not foxychat remote) - const hasModelConfigs = modelConfigs.length > 0; - const isUsingCustomConfig = - selectedConfigId !== FOXYCHAT_CONFIG_ID && hasModelConfigs; - - // Get user initials for fallback - const getUserInitials = (name?: string, email?: string) => { - if (name) { - return name - .split(" ") - .map((word) => word.charAt(0)) - .join("") - .toUpperCase() - .slice(0, 2); - } - if (email) { - return email.charAt(0).toUpperCase(); - } - return "U"; - }; - - const handleClick = () => { - if (!session?.user) { - setShowAuthModal(true); - } else { - setShowMenu(!showMenu); - } - }; - - const handleSignOut = async () => { - await signOut(); - setShowMenu(false); - //TODO: remove reload the page or redirect - window.location.reload(); - }; - - // Auto-close modal when user successfully logs in - useEffect(() => { - if (session?.user && showAuthModal) { - setShowAuthModal(false); - } - }, [session?.user, showAuthModal]); - - // Close modal when model config is saved - useEffect(() => { - const handleModelConfigSaved = () => { - setShowAuthModal(false); - }; - - window.addEventListener("model-config-saved", handleModelConfigSaved); - - return () => { - window.removeEventListener("model-config-saved", handleModelConfigSaved); - }; - }, []); - - if (isPending) { - return ( - - ); - } - - const userInitials = session?.user - ? getUserInitials(session.user.name, session.user.email) - : ""; - - return ( - <> - {session?.user ? ( - // Logged in state with popover menu - - - - - -
- {/* User Info */} -
-
- - {session.user.image && ( - - )} - - {userInitials} - - -
-

- {session.user.name || "User"} -

-

- {session.user.email} -

-
-
-
- - {/* Menu Items */} -
- -
-
-
-
- ) : isUsingCustomConfig ? ( - // Custom model config mode - using custom API endpoint - - ) : ( - // Not logged in state - - )} - - - {showAuthModal && ( - setShowAuthModal(false)} - /> - )} - - - ); -} diff --git a/packages/app/src/renderer/components/chat/input/ask-user-input-overlay.tsx b/packages/app/src/renderer/components/chat/input/ask-user-input-overlay.tsx index 64d1dfa8..d8086cc0 100644 --- a/packages/app/src/renderer/components/chat/input/ask-user-input-overlay.tsx +++ b/packages/app/src/renderer/components/chat/input/ask-user-input-overlay.tsx @@ -2,97 +2,104 @@ import { useUserInputStore } from "@/renderer/libs/stores/user-input-store"; import { Send } from "lucide-react"; import React, { useState } from "react"; -/** - * Overlay component that replaces ChatInput when waiting for user input. - * Only rendered when there's a pending input request (controlled by ChatInputContainer). - */ export function AskUserInputOverlay() { const [customInput, setCustomInput] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); - + const [responseError, setResponseError] = useState(); const { pendingInputs, resolvePendingInput } = useUserInputStore(); + const pending = pendingInputs.values().next().value; - // Get the first pending input (there should typically only be one) - const pendingEntries = Array.from(pendingInputs.entries()); - const pending = pendingEntries.length > 0 ? pendingEntries[0][1] : null; - - // Safety check - shouldn't happen since parent controls rendering if (!pending) return null; - const { toolCallId, question, options } = pending; - - // Handle option selection - const handleOptionSelect = (option: string) => { + const submit = async (value: string) => { if (isSubmitting) return; setIsSubmitting(true); - resolvePendingInput(toolCallId, option); - setTimeout(() => { - setIsSubmitting(false); + setResponseError(undefined); + try { + await resolvePendingInput(pending.interactionId, value); setCustomInput(""); - }, 100); - }; - - // Handle custom input submission - const handleCustomSubmit = () => { - if (isSubmitting || !customInput.trim()) return; - setIsSubmitting(true); - resolvePendingInput(toolCallId, customInput.trim()); - setTimeout(() => { + } catch (error) { + setResponseError( + error instanceof Error ? error.message : "Could not send response.", + ); + } finally { setIsSubmitting(false); - setCustomInput(""); - }, 100); + } }; + const customInputEnabled = pending.kind === "input"; + const details = + pending.kind === "approval" && pending.input !== undefined + ? JSON.stringify(pending.input, null, 2) + : undefined; + return ( -
- {/* Question */} -
- {question} +
+
+ {pending.kind === "approval" + ? `Approval required ยท ${pending.name}` + : pending.name} +
+
+ {pending.question}
- {/* Options */} -
- {options.map((option, index) => ( + {details && ( +
+          {details}
+        
+ )} + +
+ {pending.options.map((option) => ( ))}
- {/* Custom input */} -
-
- setCustomInput(e.target.value)} - disabled={isSubmitting} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleCustomSubmit(); - } - }} - className="flex-1 h-6 text-sm bg-transparent border-0 outline-none text-foreground placeholder:text-muted-foreground disabled:opacity-50" - autoFocus - /> - {customInput.trim() && ( -
+ )} + + {customInputEnabled && ( +
+
+ setCustomInput(event.target.value)} disabled={isSubmitting} - className="p-1 text-primary hover:bg-primary/10 rounded disabled:opacity-50" - > - - - )} + onKeyDown={(event) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + if (customInput.trim()) void submit(customInput.trim()); + } + }} + className="h-6 flex-1 border-0 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-50" + autoFocus + /> + {customInput.trim() && ( + + )} +
-
+ )}
); } diff --git a/packages/app/src/renderer/components/chat/input/chat-input-button.tsx b/packages/app/src/renderer/components/chat/input/chat-input-button.tsx index 44166427..da43c5f1 100644 --- a/packages/app/src/renderer/components/chat/input/chat-input-button.tsx +++ b/packages/app/src/renderer/components/chat/input/chat-input-button.tsx @@ -1,21 +1,11 @@ import { useAgentStore } from "@/renderer/libs/stores/agent-store"; -import { - Bot, - History, - LucideIcon, - Mic, - MicOff, - Send, - Settings, - Square, -} from "lucide-react"; +import { Bot, History, LucideIcon, Send, Settings, Square } from "lucide-react"; import React, { useEffect } from "react"; import ModelSelector from "../popover/model-selector-popover"; interface ChatInputButtonsProps { onReset?: () => void; onOpenSettings?: () => void; - onVoiceInput?: () => void; onStopGeneration?: () => void; onSendMessage?: () => void; triggerHistoryWindow: () => void; @@ -23,7 +13,6 @@ interface ChatInputButtonsProps { hasContent: boolean; selectedModelId?: string; onModelSelect?: (modelId: string) => void; - isRecording?: boolean; } interface ActionButtonConfig { @@ -38,8 +27,7 @@ interface ActionButtonConfig { } export function ChatInputButtons(props: ChatInputButtonsProps) { - const { onOpenSettings, triggerHistoryWindow, onVoiceInput, isRecording } = - props; + const { onOpenSettings, triggerHistoryWindow } = props; const { selectedAgent, triggerAgentSelect, subscribeToAgentChanges } = useAgentStore(); @@ -117,27 +105,6 @@ export function ChatInputButtons(props: ChatInputButtonsProps) { const defaultButtonClassName = "no-drag-region text-foreground/70 hover:text-foreground"; - // Determine mic button state and styling - const getMicButtonProps = () => { - if (isRecording) { - return { - Icon: MicOff, - className: - "no-drag-region bg-red-500/20 text-red-500 hover:bg-red-500/30 hover:text-red-600 active:bg-red-500/40 mr-3 rounded-full p-1.5 animate-pulse", - title: "Stop recording (click to stop and transcribe)", - }; - } else { - return { - Icon: Mic, - className: - "no-drag-region text-foreground/70 hover:bg-foreground/10 hover:text-foreground active:bg-foreground/20 mr-3 rounded-full p-1.5", - title: "Start voice input (speech-to-text)", - }; - } - }; - - const micButtonProps = getMicButtonProps(); - return (
{/* Left icons and elements */} @@ -173,17 +140,8 @@ export function ChatInputButtons(props: ChatInputButtonsProps) { })}
- {/* Right side - Mic and Send buttons */} + {/* Right side - Send button */}
- {/* Enhanced Mic Button with Speech State */} - - {props.isLoading && props.onStopGeneration ? ( - {selectedContent && (
; - result?: string | { message?: string; [key: string]: unknown }; -} - -interface ToolPart { - type: "tool-invocation"; - toolInvocation: ToolInvocation; -} - interface ChatContentProps { messages: UIMessage[]; messagesEndRef: React.RefObject; @@ -208,56 +194,34 @@ export default function ChatContent({ ); // Render tool calls with detailed information - const renderToolCall = useCallback((part: ToolPart, index: number) => { - if (!part.toolInvocation) return null; - - const toolInvocation = part.toolInvocation; - const toolName = toolInvocation.toolName || "Tool"; + const renderToolCall = useCallback( + (toolPart: ToolMessagePart, index: number) => { + const toolName = getToolName(toolPart); + const rendererName = toolName.includes(":") + ? toolName.slice(toolName.lastIndexOf(":") + 1) + : toolName; + + // Check if there's a custom renderer for this tool + const CustomRenderer = + TOOL_COMPONENTS[rendererName as keyof typeof TOOL_COMPONENTS]; + if (CustomRenderer) { + return ( + + ); + } - // Check if there's a custom renderer for this tool - const CustomRenderer = - TOOL_COMPONENTS[toolName as keyof typeof TOOL_COMPONENTS]; - if (CustomRenderer) { return ( - ); - } - - // Fallback to generic ToolCall component - const args = toolInvocation.args || {}; - let result = "Pending result..."; - - if (toolInvocation.result) { - if (typeof toolInvocation.result === "string") { - result = toolInvocation.result; - } else if (typeof toolInvocation.result === "object") { - // Try to extract message from result object if it exists - if (toolInvocation.result.message) { - result = toolInvocation.result.message as string; - } else { - result = JSON.stringify(toolInvocation.result, null, 2); - } - } - } - - const isCompleted = - toolInvocation.state === "complete" || - toolInvocation.state === "result" || - !!toolInvocation.result; - - return ( - - ); - }, []); + }, + [], + ); // Renders tool calls and text content in order const renderToolCalls = useCallback( @@ -276,11 +240,8 @@ export default function ChatContent({ {renderMessageContent(part.text, message.id, isStreaming)}
, ); - } else if ( - part.type === "tool-invocation" && - "toolInvocation" in part - ) { - contentElements.push(renderToolCall(part as ToolPart, index)); + } else if (isToolUIPart(part)) { + contentElements.push(renderToolCall(part, index)); } }); diff --git a/packages/app/src/renderer/components/chat/message/chat-message.tsx b/packages/app/src/renderer/components/chat/message/chat-message.tsx index 3f4d8978..b98cda08 100644 --- a/packages/app/src/renderer/components/chat/message/chat-message.tsx +++ b/packages/app/src/renderer/components/chat/message/chat-message.tsx @@ -1,7 +1,6 @@ import { BaseLogo } from "@/renderer/components/common/base-logo"; -import { authClient } from "@/renderer/libs/auth-client"; import { SelectedContent } from "@/renderer/libs/stores/chat-store"; -import { Attachment, UIMessage } from "ai"; +import type { Attachment, UIMessage } from "@/renderer/types/chat"; import { AnimatePresence, motion } from "framer-motion"; import { Check, @@ -13,7 +12,6 @@ import { User, } from "lucide-react"; import React, { memo, useRef, useState } from "react"; -import { Avatar, AvatarFallback, AvatarImage } from "../../ui/avatar"; import SelectedContentBlock from "../selected/selected-content-block"; /** @@ -231,29 +229,6 @@ const ChatMessage = memo( message.experimental_attachments && message.experimental_attachments.length > 0; - // Get user session for avatar - const { data: session } = authClient.useSession(); - - // Get user initials for fallback - const getUserInitials = (name?: string, email?: string) => { - if (name) { - return name - .split(" ") - .map((word) => word.charAt(0)) - .join("") - .toUpperCase() - .slice(0, 2); - } - if (email) { - return email.charAt(0).toUpperCase(); - } - return "U"; - }; - - const userInitials = session?.user - ? getUserInitials(session.user.name, session.user.email) - : "U"; - return (
{isUser ? ( - session?.user ? ( - - {session.user.image && ( - - )} - - {userInitials} - - - ) : ( - - ) + ) : ( )} @@ -294,13 +255,7 @@ const ChatMessage = memo( {/* Header with role and timestamp - inline with avatar */}
- {isUser - ? session?.user - ? session.user.name || - session.user.email?.split("@")[0] || - "You" - : "You" - : "Convera"} + {isUser ? "You" : "Convera"} {formatTimestamp(message.createdAt)} 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 5a3d316a..44183e92 100644 --- a/packages/app/src/renderer/components/chat/message/tool-call.tsx +++ b/packages/app/src/renderer/components/chat/message/tool-call.tsx @@ -1,22 +1,27 @@ import { ChevronDown, ChevronUp, Code, Loader } from "lucide-react"; +import { getToolName } from "ai"; import React, { useState } from "react"; +import { + formatToolOutput, + getToolOutput, + isToolComplete, + normalizeToolInput, + type ToolMessagePart, +} from "../tools/tool-part"; interface ToolCallProps { - tool: string; - args: Record; - result: string; - isCompleted?: boolean; + toolPart: ToolMessagePart; } /** * Tool Call component to display tool invocations in an expanded/collapsed view */ -const ToolCall = ({ - tool, - args, - result, - isCompleted = false, -}: ToolCallProps) => { +const ToolCall = ({ toolPart }: ToolCallProps) => { + const tool = getToolName(toolPart); + const args = normalizeToolInput(toolPart.input); + const isCompleted = isToolComplete(toolPart); + const result = formatToolOutput(getToolOutput(toolPart)); + // Auto-collapse completed tool calls const [isExpanded, setIsExpanded] = useState(!isCompleted); diff --git a/packages/app/src/renderer/components/chat/popover/model-selector-popover.tsx b/packages/app/src/renderer/components/chat/popover/model-selector-popover.tsx index 01ac8543..9ada98ae 100644 --- a/packages/app/src/renderer/components/chat/popover/model-selector-popover.tsx +++ b/packages/app/src/renderer/components/chat/popover/model-selector-popover.tsx @@ -1,7 +1,11 @@ -import { authClient } from "@/renderer/libs/auth-client"; +import { useLocalAIProviders } from "@/renderer/libs/hooks/use-local-ai-providers"; +import { + DEFAULT_LOCAL_AI_MODEL_ID, + isLocalAIProviderId, +} from "@/renderer/libs/local-ai"; import { useModelConfigStore } from "@/renderer/libs/stores/model-config-store"; import * as Popover from "@radix-ui/react-popover"; -import { Key, Satellite } from "lucide-react"; +import { Key, Terminal } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; export default function ModelSelector() { @@ -14,8 +18,7 @@ export default function ModelSelector() { subscribeToModelConfigChanges, } = useModelConfigStore(); - const { data: session } = authClient.useSession(); - const isUserLoggedIn = !!session?.user; + const { providers } = useLocalAIProviders(); useEffect(() => { const unsubscribe = subscribeToModelConfigChanges(); @@ -23,10 +26,28 @@ export default function ModelSelector() { }, [subscribeToModelConfigChanges]); // Get all available models grouped by config - const availableModels = useMemo( - () => getAvailableModels(isUserLoggedIn), - [getAvailableModels, isUserLoggedIn], - ); + const availableModels = useMemo(() => { + const configuredModels = getAvailableModels(); + return configuredModels.flatMap((model) => { + if (!isLocalAIProviderId(model.configId)) return model; + const provider = providers.find( + (candidate) => candidate.id === model.configId, + ); + const models = + provider?.models && provider.models.length > 0 + ? provider.models + : [ + { + id: DEFAULT_LOCAL_AI_MODEL_ID, + name: DEFAULT_LOCAL_AI_MODEL_ID, + }, + ]; + return models.map((providerModel) => ({ + ...model, + modelId: providerModel.id, + })); + }); + }, [getAvailableModels, providers]); // Group models by configId const groupedModels = useMemo(() => { @@ -60,6 +81,9 @@ export default function ModelSelector() { // Find current selected model display name const selectedDisplayName = useMemo(() => { + if (selectedModelId === DEFAULT_LOCAL_AI_MODEL_ID) { + return "Auto"; + } return formatModelName(selectedModelId); }, [selectedModelId]); @@ -107,8 +131,8 @@ export default function ModelSelector() {
{/* Group Header */}
- {group.isRemote ? ( - + {isLocalAIProviderId(configId) ? ( + ) : ( )} @@ -162,7 +186,7 @@ export default function ModelSelector() { No models available

- Log in or add a model configuration + Install and sign in to Claude Code or Codex

)} diff --git a/packages/app/src/renderer/components/chat/tools/ask-user-input.tsx b/packages/app/src/renderer/components/chat/tools/ask-user-input.tsx index 7d14555d..4c9e60b1 100644 --- a/packages/app/src/renderer/components/chat/tools/ask-user-input.tsx +++ b/packages/app/src/renderer/components/chat/tools/ask-user-input.tsx @@ -1,9 +1,14 @@ import { Loader2 } from "lucide-react"; import React, { memo } from "react"; -import { ToolInvocation } from "../types"; +import { + getToolOutput, + isToolComplete, + normalizeToolInput, + type ToolMessagePart, +} from "./tool-part"; export interface AskUserInputRendererProps { - toolInvocation: ToolInvocation; + toolPart: ToolMessagePart; } /** @@ -12,24 +17,24 @@ export interface AskUserInputRendererProps { * The actual input UI is handled by AskUserInputOverlay in the input area. */ export const AskUserInputRenderer = memo( - ({ toolInvocation }: AskUserInputRendererProps) => { - const args = toolInvocation.args as { - question?: string; - options?: string[]; - }; - const question = args?.question || "Waiting for your input..."; - - // Check if completed (has result) - const isCompleted = - toolInvocation.state === "result" && "result" in toolInvocation; + ({ toolPart }: AskUserInputRendererProps) => { + const args = normalizeToolInput(toolPart.input); + const question = + typeof args?.question === "string" + ? args.question + : "Waiting for your input..."; + const isCompleted = isToolComplete(toolPart); // If completed, show the question and user's answer if (isCompleted) { - const result = toolInvocation.result; - const userSelection = - typeof result === "object" && result?.userSelection - ? result.userSelection - : String(result); + const result = getToolOutput(toolPart); + const resultObject = + result && typeof result === "object" + ? (result as Record) + : undefined; + const userSelection = resultObject?.userSelection + ? String(resultObject.userSelection) + : String(result); return (
diff --git a/packages/app/src/renderer/components/chat/tools/execute-command.tsx b/packages/app/src/renderer/components/chat/tools/execute-command.tsx index f55da290..7f097257 100644 --- a/packages/app/src/renderer/components/chat/tools/execute-command.tsx +++ b/packages/app/src/renderer/components/chat/tools/execute-command.tsx @@ -1,31 +1,32 @@ import { Loader2 } from "lucide-react"; import React, { memo } from "react"; -import { ToolInvocation } from "../types"; import { CodeBlock } from "../../common/code-block"; +import { + getToolOutput, + isToolComplete, + normalizeToolInput, + type ToolMessagePart, +} from "./tool-part"; export interface ExecuteCommandRendererProps { - toolInvocation: ToolInvocation; + toolPart: ToolMessagePart; } /** * Special component for execute-command tool calls */ export const ExecuteCommandRenderer = memo( - ({ toolInvocation }: ExecuteCommandRendererProps) => { - let isCompleted = false; + ({ toolPart }: ExecuteCommandRendererProps) => { + const isCompleted = isToolComplete(toolPart); let result = ""; - let command = ""; + const args = normalizeToolInput(toolPart.input); + const command = String(args.command || ""); - // Extract command from args - command = String(toolInvocation.args?.command || ""); - - // Check if the tool invocation has a result (AI SDK structure) - if (toolInvocation.state === "result" && "result" in toolInvocation) { - isCompleted = true; - const toolResult = toolInvocation.result; + if (isCompleted) { + const toolResult = getToolOutput(toolPart); if (toolResult && typeof toolResult === "object") { - const resultObj = toolResult; + const resultObj = toolResult as Record; // Build result output similar to terminal output const parts: string[] = []; diff --git a/packages/app/src/renderer/components/chat/tools/tool-part.test.ts b/packages/app/src/renderer/components/chat/tools/tool-part.test.ts new file mode 100644 index 00000000..792bb03d --- /dev/null +++ b/packages/app/src/renderer/components/chat/tools/tool-part.test.ts @@ -0,0 +1,51 @@ +import type { DynamicToolUIPart } from "ai"; +import { describe, expect, it } from "vitest"; +import { + formatToolOutput, + getToolOutput, + isToolComplete, + normalizeToolInput, +} from "./tool-part"; + +describe("AI SDK tool part rendering", () => { + it("reads input and output directly from a completed dynamic tool part", () => { + const part: DynamicToolUIPart = { + type: "dynamic-tool", + toolName: "exec", + toolCallId: "tool-1", + state: "output-available", + input: '{"command":"pwd"}', + output: { stdout: "/workspace", exitCode: 0 }, + }; + + expect(normalizeToolInput(part.input)).toEqual({ command: "pwd" }); + expect(isToolComplete(part)).toBe(true); + expect(formatToolOutput(getToolOutput(part))).toContain('"exitCode": 0'); + }); + + it("uses native AI SDK error and denial states", () => { + const errorPart: DynamicToolUIPart = { + type: "dynamic-tool", + toolName: "exec", + toolCallId: "tool-2", + state: "output-error", + input: { command: "false" }, + errorText: "Command failed", + }; + const deniedPart: DynamicToolUIPart = { + type: "dynamic-tool", + toolName: "exec", + toolCallId: "tool-3", + state: "output-denied", + input: { command: "rm file" }, + approval: { + id: "approval-1", + approved: false, + reason: "Denied by user", + }, + }; + + expect(getToolOutput(errorPart)).toEqual({ error: "Command failed" }); + expect(getToolOutput(deniedPart)).toEqual({ error: "Denied by user" }); + }); +}); diff --git a/packages/app/src/renderer/components/chat/tools/tool-part.ts b/packages/app/src/renderer/components/chat/tools/tool-part.ts new file mode 100644 index 00000000..f3d83eeb --- /dev/null +++ b/packages/app/src/renderer/components/chat/tools/tool-part.ts @@ -0,0 +1,48 @@ +import type { DynamicToolUIPart, ToolUIPart } from "ai"; + +export type ToolMessagePart = ToolUIPart | DynamicToolUIPart; + +export function normalizeToolInput(input: unknown): Record { + if (input && typeof input === "object" && !Array.isArray(input)) { + return input as Record; + } + if (typeof input === "string") { + try { + const parsed = JSON.parse(input); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + return { input }; + } + } + return input === undefined ? {} : { input }; +} + +export function isToolComplete(part: ToolMessagePart): boolean { + return ( + part.state === "output-available" || + part.state === "output-error" || + part.state === "output-denied" + ); +} + +export function getToolOutput(part: ToolMessagePart): unknown { + if (part.state === "output-available") return part.output; + if (part.state === "output-error") return { error: part.errorText }; + if (part.state === "output-denied") { + return { error: part.approval.reason || "Tool execution was denied." }; + } + return undefined; +} + +export function formatToolOutput(output: unknown): string { + if (output === undefined) return "Pending result..."; + if (typeof output === "string") return output; + if (output && typeof output === "object") { + const outputObject = output as Record; + if (outputObject.message) return String(outputObject.message); + return JSON.stringify(output, null, 2); + } + return String(output); +} diff --git a/packages/app/src/renderer/components/chat/tools/web-fetch.tsx b/packages/app/src/renderer/components/chat/tools/web-fetch.tsx index e64a96d6..def956c5 100644 --- a/packages/app/src/renderer/components/chat/tools/web-fetch.tsx +++ b/packages/app/src/renderer/components/chat/tools/web-fetch.tsx @@ -1,101 +1,101 @@ import { Loader2 } from "lucide-react"; import React, { memo } from "react"; import { Markdown } from "../../common/markdown"; -import { ToolInvocation } from "../types"; +import { + getToolOutput, + isToolComplete, + normalizeToolInput, + type ToolMessagePart, +} from "./tool-part"; export interface WebFetchRendererProps { - toolInvocation: ToolInvocation; + toolPart: ToolMessagePart; } /** * Special component for web-fetch tool calls */ -export const WebFetchRenderer = memo( - ({ toolInvocation }: WebFetchRendererProps) => { - let isCompleted = false; - let result = ""; - let url = ""; - let status = ""; - let contentType = ""; +export const WebFetchRenderer = memo(({ toolPart }: WebFetchRendererProps) => { + const isCompleted = isToolComplete(toolPart); + let result = ""; + const args = normalizeToolInput(toolPart.input); + const url = String(args.url || ""); + let status = ""; + let contentType = ""; - // Extract URL from args - url = String(toolInvocation.args?.url || ""); + if (isCompleted) { + const toolResult = getToolOutput(toolPart); - // Check if the tool invocation has a result (AI SDK structure) - if (toolInvocation.state === "result" && "result" in toolInvocation) { - isCompleted = true; - const toolResult = toolInvocation.result; - - if (toolResult && typeof toolResult === "object") { - // Extract status information - if (toolResult.status && toolResult.statusText) { - status = `${toolResult.status} ${toolResult.statusText}`; - } + if (toolResult && typeof toolResult === "object") { + const resultObject = toolResult as Record; + // Extract status information + if (resultObject.status && resultObject.statusText) { + status = `${resultObject.status} ${resultObject.statusText}`; + } - if (toolResult.contentType) { - contentType = toolResult.contentType; - } + if (resultObject.contentType) { + contentType = String(resultObject.contentType); + } - // Format the result for display - if (toolResult.success && toolResult.content) { - // Show successful fetch with content - const parts: string[] = []; + // Format the result for display + if (resultObject.success && resultObject.content) { + // Show successful fetch with content + const parts: string[] = []; - if (status) { - parts.push(`Status: ${status}`); - } + if (status) { + parts.push(`Status: ${status}`); + } - if (contentType) { - parts.push(`Content-Type: ${contentType}`); - } + if (contentType) { + parts.push(`Content-Type: ${contentType}`); + } - parts.push(""); // Empty line - parts.push("Content:"); - parts.push(toolResult.content); + parts.push(""); // Empty line + parts.push("Content:"); + parts.push(String(resultObject.content)); - result = parts.join("\n"); - } else if (!toolResult.success) { - // Show error information - result = `Error: ${toolResult.error || toolResult.message || "Failed to fetch"}`; - } else { - // Fallback to message - result = toolResult.message || "No content available"; - } - } else if (typeof toolResult === "string") { - result = toolResult; + result = parts.join("\n"); + } else if (!resultObject.success) { + // Show error information + result = `Error: ${resultObject.error || resultObject.message || "Failed to fetch"}`; + } else { + // Fallback to message + result = String(resultObject.message || "No content available"); } + } else if (typeof toolResult === "string") { + result = toolResult; } + } - return ( -
- {/* Tool Call */} -
- ๐ŸŒ Fetching {url} - {!isCompleted && } -
+ return ( +
+ {/* Tool Call */} +
+ ๐ŸŒ Fetching {url} + {!isCompleted && } +
- {/* Results */} - {isCompleted && result && ( -
-
- Response: -
-
- {/* Use markdown for syntax highlighting if it looks like code */} - {contentType.includes("json") || - contentType.includes("xml") || - contentType.includes("html") ? ( - {`\`\`\`${getLanguageFromContentType(contentType)}\n${result}\n\`\`\``} - ) : ( - {`\`\`\`\n${result}\n\`\`\``} - )} -
+ {/* Results */} + {isCompleted && result && ( +
+
+ Response:
- )} -
- ); - }, -); +
+ {/* Use markdown for syntax highlighting if it looks like code */} + {contentType.includes("json") || + contentType.includes("xml") || + contentType.includes("html") ? ( + {`\`\`\`${getLanguageFromContentType(contentType)}\n${result}\n\`\`\``} + ) : ( + {`\`\`\`\n${result}\n\`\`\``} + )} +
+
+ )} +
+ ); +}); /** * Helper to determine language from content type for syntax highlighting diff --git a/packages/app/src/renderer/components/chat/tools/web-search.tsx b/packages/app/src/renderer/components/chat/tools/web-search.tsx index 31f88f4f..67139ccd 100644 --- a/packages/app/src/renderer/components/chat/tools/web-search.tsx +++ b/packages/app/src/renderer/components/chat/tools/web-search.tsx @@ -1,35 +1,37 @@ import { Loader2 } from "lucide-react"; import React, { memo } from "react"; import { Markdown } from "../../common/markdown"; -import { ToolInvocation } from "../types"; +import { + getToolOutput, + isToolComplete, + normalizeToolInput, + type ToolMessagePart, +} from "./tool-part"; export interface WebSearchRendererProps { - toolInvocation: ToolInvocation; + toolPart: ToolMessagePart; } /** * Special component for web_fetch tool calls */ export const WebSearchRenderer = memo( - ({ toolInvocation }: WebSearchRendererProps) => { - let isCompleted = false; + ({ toolPart }: WebSearchRendererProps) => { + const isCompleted = isToolComplete(toolPart); let result = ""; - // Extract search query from args - const searchQuery = String( - toolInvocation.args?.query || toolInvocation.args?.url || "", - ); + const args = normalizeToolInput(toolPart.input); + const searchQuery = String(args.query || args.url || ""); - // Check if the tool invocation has a result (AI SDK structure) - if (toolInvocation.state === "result" && "result" in toolInvocation) { - isCompleted = true; - const toolResult = toolInvocation.result; + if (isCompleted) { + const toolResult = getToolOutput(toolPart); if (typeof toolResult === "string") { result = toolResult; - } else if (typeof toolResult === "object") { - if (toolResult.message) { - result = toolResult.message as string; + } else if (toolResult && typeof toolResult === "object") { + const resultObject = toolResult as Record; + if (resultObject.message) { + result = String(resultObject.message); } else { result = JSON.stringify(toolResult, null, 2); } diff --git a/packages/app/src/renderer/components/chat/types.ts b/packages/app/src/renderer/components/chat/types.ts deleted file mode 100644 index a0c1e7d4..00000000 --- a/packages/app/src/renderer/components/chat/types.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { - UIMessage as AISDKUIMessage, - ToolInvocation as AISDKToolInvocation, -} from "ai"; - -/** - * Re-export AI SDK types for consistency - */ -export type UIMessage = AISDKUIMessage; -export type ToolInvocation = AISDKToolInvocation; - -/** - * Extract the parts type from UIMessage - * The AI SDK UIMessage can have parts array with different content types - */ -export type MessagePart = NonNullable[number]; - -/** - * Type guards for different part types - */ -export function isTextPart( - part: MessagePart, -): part is Extract { - return part.type === "text"; -} - -export function isToolInvocationPart( - part: MessagePart, -): part is Extract { - return part.type === "tool-invocation"; -} - -/** - * Component props using AI SDK types - */ -export interface MessagePartRendererProps { - part: MessagePart; - index: number; -} - -export interface ToolCallRendererProps { - toolInvocation: ToolInvocation; - index: number; -} - -export interface MessageContentRendererProps { - message: UIMessage; -} diff --git a/packages/app/src/renderer/components/home/index.tsx b/packages/app/src/renderer/components/home/index.tsx index dc51966f..fdc97d92 100644 --- a/packages/app/src/renderer/components/home/index.tsx +++ b/packages/app/src/renderer/components/home/index.tsx @@ -9,7 +9,6 @@ import { ChevronLeft, ChevronRight, Code, - LayoutGrid, Moon, Plus, Search, @@ -17,8 +16,7 @@ import { Settings, Sun, } from "lucide-react"; -import React, { useEffect, useRef, useState } from "react"; -import { UserButton } from "../auth/user-button"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import ChatInputContainer from "../chat/input/chat-input-container"; import type { ChatInputRef } from "../chat/input/chat-input-container"; import ChatContent from "../chat/message/chat-content"; @@ -29,7 +27,6 @@ import { // Import settings pages import { AgentsSettingsPage } from "@/renderer/components/settings/pages/agents-page"; -import { AppSettingsPage } from "@/renderer/components/settings/pages/app-page"; import { DeveloperSettingsPage } from "@/renderer/components/settings/pages/developer-page"; import { GeneralSettingsPage } from "@/renderer/components/settings/pages/general-page"; import { McpSettingsPage } from "@/renderer/components/settings/pages/mcp-page"; @@ -46,7 +43,7 @@ import { useKeyboardShortcut } from "@/renderer/libs/hooks/use-keyboard-shortcut import { branchFromMessage } from "@/renderer/libs/db/hooks"; type ViewType = "chat" | "settings"; -type SettingsTab = "general" | "app" | "agents" | "mcp" | "developer"; +type SettingsTab = "general" | "agents" | "mcp" | "developer"; export function HomePage() { const chatInputRef = useRef(null); @@ -79,17 +76,17 @@ export function HomePage() { }); // Helper to navigate to settings - auto-expands sidebar - const navigateToSettings = () => { + const navigateToSettings = useCallback(() => { if (sidebarCollapsed) { setSidebarCollapsed(false); } setActiveView("settings"); - }; + }, [sidebarCollapsed]); // Listen for navigate-to-settings from main process (tray menu) useEffect(() => { window.electronAPI?.onNavigateToSettings?.(navigateToSettings); - }, [sidebarCollapsed]); + }, [navigateToSettings]); const handleNewChat = () => { resetChat(); @@ -119,7 +116,6 @@ export function HomePage() { // Settings navigation items const settingsNavItems = [ { id: "general" as SettingsTab, label: "General", icon: Settings }, - { id: "app" as SettingsTab, label: "Apps", icon: LayoutGrid }, { id: "agents" as SettingsTab, label: "Agents", icon: Bot }, { id: "mcp" as SettingsTab, label: "MCP Servers", icon: Server }, { id: "developer" as SettingsTab, label: "Developer", icon: Code }, @@ -138,8 +134,6 @@ export function HomePage() { ); case "mcp": return ; - case "app": - return ; case "developer": return ; default: @@ -180,9 +174,14 @@ export function HomePage() { @@ -285,7 +284,6 @@ export function HomePage() { )} -
)} diff --git a/packages/app/src/renderer/components/login-form.tsx b/packages/app/src/renderer/components/login-form.tsx deleted file mode 100644 index f7bf82dd..00000000 --- a/packages/app/src/renderer/components/login-form.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import React, { useState } from "react"; -import { authClient } from "../libs/auth-client"; - -interface LoginFormProps { - onSuccess?: () => void; -} - -export function LoginForm({ onSuccess }: LoginFormProps) { - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - const [isSignUp, setIsSignUp] = useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - setError(""); - - try { - if (isSignUp) { - const result = await authClient.signUp.email({ - email, - password, - name: email.split("@")[0], // Use email prefix as default name - }); - - if (result.error) { - setError(result.error.message || "Sign up failed"); - } else { - alert("Account created! Please check your email for verification."); - setIsSignUp(false); - } - } else { - const result = await authClient.signIn.email({ - email, - password, - }); - - if (result.error) { - setError(result.error.message || "Sign in failed"); - } else { - onSuccess?.(); - } - } - } catch (err) { - setError("An unexpected error occurred"); - console.error("Auth error:", err); - } finally { - setLoading(false); - } - }; - - return ( -
-
-

- {isSignUp ? "Create Account" : "Sign In"} -

-

- {isSignUp ? "Join Convera today" : "Welcome back to Convera"} -

-
- -
-
- - setEmail(e.target.value)} - className="mt-1 block w-full px-3 py-2 border border-border rounded-md shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground" - placeholder="your@email.com" - required - /> -
- -
- - setPassword(e.target.value)} - className="mt-1 block w-full px-3 py-2 border border-border rounded-md shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground" - placeholder="Enter your password" - required - minLength={6} - /> -
- - {error &&
{error}
} - - - -
- -
-
-
- ); -} diff --git a/packages/app/src/renderer/components/settings/ai-model-section.tsx b/packages/app/src/renderer/components/settings/ai-model-section.tsx deleted file mode 100644 index 02c4a288..00000000 --- a/packages/app/src/renderer/components/settings/ai-model-section.tsx +++ /dev/null @@ -1,264 +0,0 @@ -import React, { useEffect, useMemo, useState } from "react"; - -import { AppSettings } from "@/shared/types/settings"; -import { X } from "lucide-react"; - -import { useModelStore } from "@/renderer/libs/stores/model-store"; -import { - loadFuzzyInstance, - searchModels, -} from "@/renderer/libs/utils/model-search-utils"; -import { - OFFICIAL_MODELS, - fetchOpenRouterModels, -} from "@/shared/constants/officialModels"; -import { Badge } from "../ui/badge"; -import { Button } from "../ui/button"; -import { Input } from "../ui/input"; -import { Label } from "../ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../ui/select"; - -type AIModelSectionProps = { - settings: AppSettings; - onOpenAIChange: (field: string, value: string) => void; - onAddSupportedModel?: (model: string) => void; - onRemoveSupportedModel?: (model: string) => void; -}; - -export function AIModelSection({ - settings, - onOpenAIChange, -}: AIModelSectionProps) { - const [newModelInput, setNewModelInput] = useState(""); - const [officialModels, setOfficialModels] = - useState(OFFICIAL_MODELS); - const [showDropdown, setShowDropdown] = useState(false); - const [filteredModels, setFilteredModels] = useState([]); - - const { - supportedModelIds, - setSupportedModelIds, - setSelectedModelId, - subscribeToModelChanges, - } = useModelStore(); - useEffect(() => { - const unsubscribe = subscribeToModelChanges(); - return unsubscribe; - }, [subscribeToModelChanges]); - - /** - * Fetch models from OpenRouter API and sort them alphabetically. - * Falls back to static model list if the fetch fails. - */ - useEffect(() => { - fetchOpenRouterModels() - .then((models) => { - setOfficialModels([...models].sort()); - }) - .catch(() => { - setOfficialModels([...OFFICIAL_MODELS].sort()); - }); - }, []); - - // Get list of models that aren't already added - const getAvailableModels = () => { - return officialModels.filter((m) => !supportedModelIds.includes(m)); - }; - - // Available models list - const availableModels = useMemo( - () => getAvailableModels(), - [officialModels, supportedModelIds], - ); - - /** - * Memoize fuzzy instance for model search - */ - const fuzzyInstance = useMemo(() => loadFuzzyInstance(), [availableModels]); - - // Update filtered models when input or availableModels changes - useEffect(() => { - searchModels( - newModelInput, - availableModels, - setFilteredModels, - fuzzyInstance, - ); - }, [newModelInput, availableModels, fuzzyInstance]); - - /** - * Add a model to supported models list, update localStorage, - * and trigger events to notify other components - */ - const handleAddModel = (model: string) => { - if (!model.trim()) return; - - const newModels = [...supportedModelIds]; - if (!newModels.includes(model)) { - newModels.push(model); - setSupportedModelIds(newModels); - } - setTimeout(() => { - onOpenAIChange("modelId", model); - setSelectedModelId(model); - }, 0); - }; - - /** - * Remove a model from supported models list, update localStorage, - * and trigger events to notify other components - */ - const handleRemoveModel = (model: string) => { - const newModels = supportedModelIds.filter((m) => m !== model); - setSupportedModelIds(newModels); - if (model === settings.openai.modelId) { - const newSelectedModel = newModels.length > 0 ? newModels[0] : ""; - setSelectedModelId(newSelectedModel); - } - }; - - return ( -
-
-
-

- AI Model Settings -

-

- Configure your AI model settings (Remote server only) -

-
-
- -
-
- - -
- - {/* Add New Models */} -
- -
- {supportedModelIds.map((model) => ( - - {model} - - - ))} -
-
-
- setNewModelInput(e.target.value)} - onFocus={() => setShowDropdown(true)} - onBlur={() => { - // Allow time for click events before closing dropdown - setTimeout(() => setShowDropdown(false), 200); - }} - onKeyDown={(e) => { - if (e.key === "Enter" && newModelInput.trim()) { - handleAddModel(newModelInput.trim()); - setNewModelInput(""); - } - }} - autoComplete="off" - /> - {showDropdown && ( -
    - {filteredModels.length > 0 ? ( - filteredModels.map((model) => ( -
  • { - handleAddModel(model); - setNewModelInput(""); - }} - className=" - relative flex items-center px-3 py-2 - text-sm select-none cursor-pointer - hover:bg-secondary/30 transition-colors duration-100 rounded-2xl - " - > - {model} -
  • - )) - ) : ( -
  • - No matching models found -
  • - )} -
- )} -
- -
-
-
-
- ); -} diff --git a/packages/app/src/renderer/components/settings/marketplace-tab.tsx b/packages/app/src/renderer/components/settings/marketplace-tab.tsx deleted file mode 100644 index 24d22139..00000000 --- a/packages/app/src/renderer/components/settings/marketplace-tab.tsx +++ /dev/null @@ -1,373 +0,0 @@ -import { MCPConfig, ServerInfo } from "@/shared/types/mcp"; -import { - AlertCircle, - FileText, - FolderOpen, - Loader2, - Plus, - Trash2, -} from "lucide-react"; -import React, { useState } from "react"; -import { Alert, AlertDescription } from "../ui/alert"; -import { Badge } from "../ui/badge"; -import { Button } from "../ui/button"; -import { - Dialog, - DialogClose, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "../ui/dialog"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "../ui/dropdown-menu"; - -type MarketplaceSectionProps = { - mcpServers: ServerInfo[]; - loadingMcpServers: boolean; - onManualInstallMcp?: (configJson: string) => Promise; - onRemoveServer?: (serverId: string) => Promise; - onRefreshServers?: () => void; -}; - -const MCP_CONFIG_FOLDER_PATH = "~/.convera"; -const MCP_CONFIG_FILE_PATH = "~/.convera/mcp.json"; - -export function MarketplaceSection({ - mcpServers, - loadingMcpServers, - onManualInstallMcp, - onRemoveServer, - onRefreshServers, -}: MarketplaceSectionProps) { - const [showManualConfigDialog, setShowManualConfigDialog] = useState(false); - const [manualConfig, setManualConfig] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - const [removingServers, setRemovingServers] = useState< - Record - >({}); - const [manualConfigError, setManualConfigError] = useState( - null, - ); - - const handleSubmitManualConfig = async () => { - if (!manualConfig.trim() || !onManualInstallMcp) return; - - setIsSubmitting(true); - setManualConfigError(null); - try { - // Parse and validate the JSON configuration - let config: MCPConfig; - try { - config = JSON.parse(manualConfig); - } catch { - throw new Error("Invalid JSON format"); - } - - if (!config.mcpServers || typeof config.mcpServers !== "object") { - throw new Error("Invalid configuration: missing 'mcpServers' object"); - } - - await onManualInstallMcp(manualConfig); - setShowManualConfigDialog(false); - setManualConfig(""); - setManualConfigError(null); - - // Refresh server list after installation - if (onRefreshServers) { - onRefreshServers(); - } - } catch (error) { - console.error("Error submitting manual config:", error); - const errorMessage = - error instanceof Error ? error.message : "Unknown error occurred"; - setManualConfigError(errorMessage); - } finally { - setIsSubmitting(false); - } - }; - - const handleOpenManualDialog = () => { - setManualConfig(""); - setManualConfigError(null); - setShowManualConfigDialog(true); - }; - - const handleRemoveServer = async (serverId: string) => { - if (!onRemoveServer) return; - - setRemovingServers((prev) => ({ ...prev, [serverId]: true })); - try { - await onRemoveServer(serverId); - // Refresh server list after removal - if (onRefreshServers) { - onRefreshServers(); - } - } catch (error) { - console.error(`Error removing server ${serverId}:`, error); - } finally { - setRemovingServers((prev) => ({ ...prev, [serverId]: false })); - } - }; - - const handleOpenMCPConfigFolder = async () => { - await window.electronAPI.openPath(MCP_CONFIG_FOLDER_PATH); - }; - - const handleOpenMCPConfigFile = async () => { - await window.electronAPI.openPath(MCP_CONFIG_FILE_PATH); - }; - - return ( -
-
-
-
-

- MCP Servers -

-

- Manage your MCP (Model Context Protocol) servers -

-
- -
-
- -
-
-
-

- Installed MCP Servers -

- - - - - - - - Open Config Folder - - - - Open Config File - - - -
-
- {loadingMcpServers ? ( -
- -
- ) : mcpServers.length === 0 ? ( -
-

- No MCP servers configured yet -

- -
- ) : ( -
- {mcpServers.map((server) => ( -
-
-
-
-
-

- {server.displayName || server.name} -

- - {server.status === "connected" - ? "Connected" - : server.status === "connecting" - ? "Connecting" - : "Disconnected"} - - - {server.transportType} - -
- -

- {server.description || "No description available"} -

- - {server.error && ( -

- Error: {server.error} -

- )} - - {server.capabilities && ( -
- {server.capabilities.tools?.length > 0 && ( - - {server.capabilities.tools.length} tools - - )} - {server.capabilities.resources?.length > 0 && ( - - {server.capabilities.resources.length}{" "} - resources - - )} - {server.capabilities.prompts?.length > 0 && ( - - {server.capabilities.prompts.length} prompts - - )} -
- )} -
-
- -
- -
-
-
- ))} -
- )} -
-
-
- - - - - Add MCP Server - -
-

- Configure a new MCP server by providing its configuration in JSON - format. You can copy this from the server's documentation or - GitHub page. -

-
-