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..b94728f7 --- /dev/null +++ b/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts @@ -0,0 +1,403 @@ +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), + ...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: "delta", + requestId: "request-2", + text: "ignore", + }, + ); + listener?.( + {}, + { + type: "delta", + requestId: "request-1", + text: "hello", + }, + ); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith({ + type: "delta", + requestId: "request-1", + text: "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: "delta", + requestId: "runtime-cannot-change-owner", + text: "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: "delta", + requestId: "request-1", + text: "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("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..f71541b5 --- /dev/null +++ b/packages/app/src/electro-bridge/ipc/local-ai-context.ts @@ -0,0 +1,450 @@ +import type { + ILocalAIAPI, + LocalAIChatRequest, + 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", + 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; + +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 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.type === "tool" && runtimeEvent.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.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), + 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__/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.test.ts b/packages/app/src/electron/ai/__tests__/codex-cli.test.ts new file mode 100644 index 00000000..05631cf5 --- /dev/null +++ b/packages/app/src/electron/ai/__tests__/codex-cli.test.ts @@ -0,0 +1,36 @@ +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); + + 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..33008961 --- /dev/null +++ b/packages/app/src/electron/ai/__tests__/runtime.test.ts @@ -0,0 +1,242 @@ +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 text, tool, finish, usage, and explicit agent context", async () => { + const events: LocalAIStreamEvent[] = []; + let streamOptions: Parameters[0] | undefined; + const streamInvoker: RuntimeStreamInvoker = (options) => { + streamOptions = options; + return { + fullStream: (async function* () { + yield { type: "text-delta", text: "Hi" }; + yield { + type: "tool-call", + toolCallId: "tool-1", + toolName: "read_file", + input: { path: "README.md" }, + }; + yield { + type: "tool-result", + toolCallId: "tool-1", + toolName: "read_file", + output: "contents", + }; + yield { + type: "finish", + finishReason: "stop", + totalUsage: { + 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(streamOptions?.messages).toEqual([ + { role: "system", content: "Be concise." }, + { role: "user", content: "hello" }, + ]); + expect(events).toEqual([ + { type: "delta", requestId: "request-1", text: "Hi" }, + { + type: "tool", + requestId: "request-1", + toolCallId: "tool-1", + name: "read_file", + state: "input-available", + input: { path: "README.md" }, + }, + { + type: "tool", + requestId: "request-1", + toolCallId: "tool-1", + name: "read_file", + state: "output-available", + output: "contents", + }, + { + 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) => ({ + fullStream: (async function* () { + yield { type: "text-delta", text: "partial" }; + await new Promise((resolve) => { + options.abortSignal.addEventListener("abort", () => resolve(), { + once: true, + }); + }); + yield { type: "abort" }; + })(), + }); + 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: "delta", + requestId: "request-1", + text: "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("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/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..97e2a1d9 --- /dev/null +++ b/packages/app/src/electron/ai/provider-adapter.ts @@ -0,0 +1,21 @@ +import type { LocalAIChatRequest } from "@/shared/types/local-ai"; +import type { LanguageModel } from "ai"; +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, + ): 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..7f640dcb --- /dev/null +++ b/packages/app/src/electron/ai/providers/claude-code.ts @@ -0,0 +1,54 @@ +import type { LocalAIChatRequest } from "@/shared/types/local-ai"; +import type { LanguageModel } from "ai"; +import { createClaudeCode } from "ai-sdk-provider-claude-code"; +import { loadClaudeEnvironment } from "../claude-environment"; +import { probeCliProvider } from "../cli-probe"; +import { + resolveLocalModelId, + type LocalAiProviderAdapter, +} from "../provider-adapter"; +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: 1, + 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, + ): Promise { + return this.provider( + resolveLocalModelId(request.modelId, status.defaultModel), + { + pathToClaudeCodeExecutable: status.executablePath, + cwd: request.options?.cwd, + }, + ); + } + + 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..d10bfdf8 --- /dev/null +++ b/packages/app/src/electron/ai/providers/codex-cli.ts @@ -0,0 +1,148 @@ +import type { LocalAIChatRequest } from "@/shared/types/local-ai"; +import type { LanguageModel } from "ai"; +import type { CodexAppServerProvider } 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"; + +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, + ): Promise { + await this.ensureProvider(status.executablePath); + + return this.provider!( + resolveLocalModelId(request.modelId, status.defaultModel), + { + cwd: request.options?.cwd, + }, + ); + } + + 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/runtime.ts b/packages/app/src/electron/ai/runtime.ts new file mode 100644 index 00000000..c3eea048 --- /dev/null +++ b/packages/app/src/electron/ai/runtime.ts @@ -0,0 +1,506 @@ +import type { + LocalAIChatRequest, + LocalAIFinishReason, + LocalAIProviderAvailability, + LocalAIProviderStatus, + LocalAIRuntimeService, + LocalAISerializableError, + LocalAIStreamEvent, + LocalAIUsage, +} from "@/shared/types/local-ai"; +import { streamText, type LanguageModel, type ModelMessage } from "ai"; +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"; + +type RuntimeStreamPart = Record & { type: string }; + +interface RuntimeStreamResult { + fullStream: AsyncIterable; +} + +interface RuntimeStreamOptions { + model: LanguageModel; + messages: ModelMessage[]; + abortSignal: AbortSignal; + maxOutputTokens?: number; +} + +export type RuntimeStreamInvoker = ( + options: RuntimeStreamOptions, +) => RuntimeStreamResult; + +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 }; +} + +function stringField( + part: RuntimeStreamPart, + ...fields: string[] +): string | undefined { + for (const field of fields) { + const value = part[field]; + if (typeof value === "string") { + return value; + } + } + return undefined; +} + +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; + + constructor( + options: { + adapters?: LocalAiProviderAdapter[]; + streamInvoker?: RuntimeStreamInvoker; + workingDirectory?: string; + } = {}, + ) { + const adapters = options.adapters ?? [ + new ClaudeCodeAdapter(), + new CodexCliAdapter(), + ]; + this.streamInvoker = options.streamInvoker ?? defaultStreamInvoker; + this.workingDirectory = options.workingDirectory ?? process.cwd(); + + 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 model = await adapter.createModel(trustedRequest, probeStatus); + const result = this.streamInvoker({ + model, + messages: toMessages(request), + abortSignal: controller.signal, + maxOutputTokens: request.options?.maxOutputTokens, + }); + await this.forwardStream(request.requestId, result, controller, emit); + } catch (error) { + if (controller.signal.aborted) { + emit({ + type: "finish", + requestId: request.requestId, + finishReason: "aborted", + }); + } else { + this.emitFailure(request.requestId, emit, error); + } + } finally { + this.activeRequests.delete(request.requestId); + } + } + + abort(requestId: string): boolean { + const controller = this.activeRequests.get(requestId); + if (!controller) { + return false; + } + + controller.abort(); + return true; + } + + async dispose(): Promise { + for (const controller of this.activeRequests.values()) { + controller.abort(); + } + this.activeRequests.clear(); + + await Promise.all( + [...this.adapters.values()].map((adapter) => adapter.dispose()), + ); + } + + private async forwardStream( + requestId: string, + result: RuntimeStreamResult, + controller: AbortController, + emit: (event: LocalAIStreamEvent) => void, + ): Promise { + const toolNames = new Map(); + const toolInputs = new Map(); + let terminalEventEmitted = false; + + for await (const part of result.fullStream) { + switch (part.type) { + case "text-delta": { + const text = stringField(part, "text"); + if (text) { + emit({ type: "delta", requestId, text }); + } + break; + } + case "tool-input-start": { + const toolCallId = stringField(part, "id") ?? "unknown"; + const name = stringField(part, "toolName") ?? "unknown"; + toolNames.set(toolCallId, name); + toolInputs.set(toolCallId, ""); + emit({ + type: "tool", + requestId, + toolCallId, + name, + state: "input-streaming", + }); + break; + } + case "tool-input-delta": { + const toolCallId = stringField(part, "id") ?? "unknown"; + const delta = stringField(part, "delta") ?? ""; + const input = `${toolInputs.get(toolCallId) ?? ""}${delta}`; + toolInputs.set(toolCallId, input); + emit({ + type: "tool", + requestId, + toolCallId, + name: toolNames.get(toolCallId) ?? "unknown", + state: "input-streaming", + input, + }); + break; + } + case "tool-call": { + const toolCallId = stringField(part, "toolCallId", "id") ?? "unknown"; + const name = + stringField(part, "toolName") ?? + toolNames.get(toolCallId) ?? + "unknown"; + emit({ + type: "tool", + requestId, + toolCallId, + name, + state: "input-available", + input: part.input, + }); + break; + } + case "tool-result": { + const toolCallId = stringField(part, "toolCallId", "id") ?? "unknown"; + emit({ + type: "tool", + requestId, + toolCallId, + name: + stringField(part, "toolName") ?? + toolNames.get(toolCallId) ?? + "unknown", + state: "output-available", + output: part.output, + }); + break; + } + case "tool-error": { + const toolCallId = stringField(part, "toolCallId", "id") ?? "unknown"; + emit({ + type: "tool", + requestId, + toolCallId, + name: + stringField(part, "toolName") ?? + toolNames.get(toolCallId) ?? + "unknown", + state: "output-error", + error: serializeLocalAiError(part.error), + }); + break; + } + case "abort": + emit({ type: "finish", requestId, finishReason: "aborted" }); + terminalEventEmitted = true; + break; + case "error": + emit({ + type: "error", + requestId, + error: serializeLocalAiError(part.error), + }); + emit({ type: "finish", requestId, finishReason: "error" }); + terminalEventEmitted = true; + break; + case "finish": + emit({ + type: "finish", + requestId, + finishReason: controller.signal.aborted + ? "aborted" + : finishReason(part.finishReason), + usage: usageFrom(part.totalUsage), + }); + terminalEventEmitted = true; + break; + } + + if (terminalEventEmitted) { + break; + } + } + + if (!terminalEventEmitted) { + emit({ + type: "finish", + requestId, + finishReason: controller.signal.aborted ? "aborted" : "unknown", + }); + } + } + + 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" }); + } +} 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..61754117 100644 --- a/packages/app/src/electron/main.ts +++ b/packages/app/src/electron/main.ts @@ -1,12 +1,8 @@ -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 { LocalAiRuntime } from "@/electron/ai"; import { getCurrentShortcut } from "@/electro-bridge/ipc/ipc-handlers"; @@ -24,6 +20,7 @@ import { // Initialize logger for main process const logger = getLogger("main-process"); +const localAIRuntime = new LocalAiRuntime(); function registerGlobalShortcuts() { globalShortcut.unregisterAll(); @@ -111,6 +108,7 @@ app.whenReady().then(async () => { const listenerOptions: ListenerOptions = { mainWindow: () => getMainWindow(), registerGlobalShortcuts, + localAIRuntime, }; logger.debug("Registering IPC listeners"); @@ -130,16 +128,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 +141,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 +151,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..98f4b22c 100644 --- a/packages/app/src/electron/mcp/hub.ts +++ b/packages/app/src/electron/mcp/hub.ts @@ -542,8 +542,8 @@ 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), })); } diff --git a/packages/app/src/electron/tools/ask-user-input.ts b/packages/app/src/electron/tools/ask-user-input.ts index b6bb47e2..f4179400 100644 --- a/packages/app/src/electron/tools/ask-user-input.ts +++ b/packages/app/src/electron/tools/ask-user-input.ts @@ -19,7 +19,7 @@ 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()) 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/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/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 }; } @@ -244,9 +244,7 @@ export default function ChatContent({ } const isCompleted = - toolInvocation.state === "complete" || - toolInvocation.state === "result" || - !!toolInvocation.result; + toolInvocation.state === "result" || !!toolInvocation.result; return ( 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/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..2b69c854 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 @@ -26,10 +26,13 @@ export const AskUserInputRenderer = memo( // 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 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..207bf611 100644 --- a/packages/app/src/renderer/components/chat/tools/execute-command.tsx +++ b/packages/app/src/renderer/components/chat/tools/execute-command.tsx @@ -25,7 +25,7 @@ export const ExecuteCommandRenderer = memo( const toolResult = toolInvocation.result; 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/web-fetch.tsx b/packages/app/src/renderer/components/chat/tools/web-fetch.tsx index e64a96d6..687f2c9e 100644 --- a/packages/app/src/renderer/components/chat/tools/web-fetch.tsx +++ b/packages/app/src/renderer/components/chat/tools/web-fetch.tsx @@ -27,17 +27,18 @@ export const WebFetchRenderer = memo( const toolResult = toolInvocation.result; if (toolResult && typeof toolResult === "object") { + const resultObject = toolResult as Record; // Extract status information - if (toolResult.status && toolResult.statusText) { - status = `${toolResult.status} ${toolResult.statusText}`; + 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) { + if (resultObject.success && resultObject.content) { // Show successful fetch with content const parts: string[] = []; @@ -51,15 +52,15 @@ export const WebFetchRenderer = memo( parts.push(""); // Empty line parts.push("Content:"); - parts.push(toolResult.content); + parts.push(String(resultObject.content)); result = parts.join("\n"); - } else if (!toolResult.success) { + } else if (!resultObject.success) { // Show error information - result = `Error: ${toolResult.error || toolResult.message || "Failed to fetch"}`; + result = `Error: ${resultObject.error || resultObject.message || "Failed to fetch"}`; } else { // Fallback to message - result = toolResult.message || "No content available"; + result = String(resultObject.message || "No content available"); } } else if (typeof toolResult === "string") { result = toolResult; 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..c7a50282 100644 --- a/packages/app/src/renderer/components/chat/tools/web-search.tsx +++ b/packages/app/src/renderer/components/chat/tools/web-search.tsx @@ -27,9 +27,10 @@ export const WebSearchRenderer = memo( 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 index a0c1e7d4..d7a7fd7c 100644 --- a/packages/app/src/renderer/components/chat/types.ts +++ b/packages/app/src/renderer/components/chat/types.ts @@ -1,19 +1,13 @@ import type { - UIMessage as AISDKUIMessage, - ToolInvocation as AISDKToolInvocation, -} from "ai"; + MessagePart, + ToolInvocation, + UIMessage, +} from "@/renderer/types/chat"; /** * 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]; +export type { MessagePart, ToolInvocation, UIMessage }; /** * Type guards for different part types 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. -

-
-