From 999cc67a81563b735527b41089f9a2f07c747a54 Mon Sep 17 00:00:00 2001 From: NarwhalChen Date: Thu, 30 Jul 2026 14:54:51 +0800 Subject: [PATCH 01/29] feat(app): define local AI IPC contracts --- packages/app/src/shared/types/local-ai.ts | 144 ++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 packages/app/src/shared/types/local-ai.ts diff --git a/packages/app/src/shared/types/local-ai.ts b/packages/app/src/shared/types/local-ai.ts new file mode 100644 index 00000000..c598793f --- /dev/null +++ b/packages/app/src/shared/types/local-ai.ts @@ -0,0 +1,144 @@ +/** + * Structured-clone-safe contracts shared by Electron main, preload, and + * renderer. Runtime implementations must not put Error instances, functions, + * or other process-owned objects on this boundary. + */ + +export type LocalAIProviderKind = + | "claude-code" + | "codex-cli" + | "openai-compatible"; + +export type LocalAIProviderAvailability = + | "available" + | "missing" + | "unauthenticated" + | "unavailable" + | "error"; + +export interface LocalAIProviderStatus { + id: string; + name: string; + kind: LocalAIProviderKind; + availability: LocalAIProviderAvailability; + detail?: string; + models?: Array<{ + id: string; + name: string; + }>; +} + +export type LocalAIMessageRole = "system" | "user" | "assistant"; + +export interface LocalAIMessage { + id?: string; + role: LocalAIMessageRole; + content: string; +} + +export interface LocalAIChatRequest { + requestId: string; + providerId: string; + modelId?: string; + messages: LocalAIMessage[]; + agent?: { + id?: string; + systemPrompt?: string; + }; + options?: { + temperature?: number; + maxOutputTokens?: number; + cwd?: string; + }; +} + +export interface LocalAISerializableError { + name: string; + message: string; + code?: string; + stack?: string; +} + +export interface LocalAIUsage { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; +} + +export type LocalAIToolState = + | "input-streaming" + | "input-available" + | "output-available" + | "output-error"; + +export type LocalAIFinishReason = + | "stop" + | "length" + | "content-filter" + | "tool-calls" + | "error" + | "aborted" + | "unknown"; + +export type LocalAIStreamEvent = + | { + type: "delta"; + requestId: string; + text: string; + } + | { + type: "tool"; + requestId: string; + toolCallId: string; + name: string; + state: LocalAIToolState; + input?: unknown; + output?: unknown; + error?: LocalAISerializableError; + } + | { + type: "error"; + requestId: string; + error: LocalAISerializableError; + } + | { + type: "finish"; + requestId: string; + finishReason: LocalAIFinishReason; + usage?: LocalAIUsage; + }; + +export interface LocalAIResult { + success: boolean; + data?: T; + error?: LocalAISerializableError; +} + +export interface LocalAIStartResult extends LocalAIResult { + accepted: boolean; +} + +export interface LocalAIRuntimeService { + listProviders(): Promise | LocalAIProviderStatus[]; + getProviderStatus( + providerId: string, + ): Promise | LocalAIProviderStatus; + startChat( + request: LocalAIChatRequest, + emit: (event: LocalAIStreamEvent) => void, + ): Promise | void; + abort(requestId: string): Promise | boolean; +} + +export interface ILocalAIAPI { + listProviders(): Promise>; + getProviderStatus( + providerId: string, + ): Promise>; + startChat(request: LocalAIChatRequest): Promise; + abort(requestId: string): Promise>; + onEvent( + requestId: string, + callback: (event: LocalAIStreamEvent) => void, + ): () => void; +} From 2efe136ab22a9726d02f9b55dd1c484cf244e843 Mon Sep 17 00:00:00 2001 From: NarwhalChen Date: Thu, 30 Jul 2026 14:59:56 +0800 Subject: [PATCH 02/29] feat(app): expose local AI IPC bridge --- .../electro-bridge/ipc/listeners-register.ts | 10 + .../ipc/local-ai-context.test.ts | 362 ++++++++++++++ .../electro-bridge/ipc/local-ai-context.ts | 442 ++++++++++++++++++ packages/app/src/preload.ts | 4 + packages/app/src/shared/types/electron.d.ts | 2 + 5 files changed, 820 insertions(+) create mode 100644 packages/app/src/electro-bridge/ipc/local-ai-context.test.ts create mode 100644 packages/app/src/electro-bridge/ipc/local-ai-context.ts 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..0d2d1201 --- /dev/null +++ b/packages/app/src/electro-bridge/ipc/local-ai-context.test.ts @@ -0,0 +1,362 @@ +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", + 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("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", + 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", + 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", + 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..a3c52f10 --- /dev/null +++ b/packages/app/src/electro-bridge/ipc/local-ai-context.ts @@ -0,0 +1,442 @@ +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}$/; + +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" || + request.providerId.length === 0 || + request.providerId.length > 128 || + !Array.isArray(request.messages) || + request.messages.length === 0 || + request.messages.length > 1_000 + ) { + return false; + } + + return request.messages.every( + (message) => + isRecord(message) && + (message.role === "system" || + message.role === "user" || + message.role === "assistant") && + typeof message.content === "string", + ); +} + +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" || + providerId.length === 0 || + providerId.length > 128 + ) { + 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/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/shared/types/electron.d.ts b/packages/app/src/shared/types/electron.d.ts index 73cec763..15ec5970 100644 --- a/packages/app/src/shared/types/electron.d.ts +++ b/packages/app/src/shared/types/electron.d.ts @@ -1,6 +1,7 @@ // src/types/electron.d.ts import { WindowSizeConfig } from "@/electron/windows/window-size"; +import type { ILocalAIAPI } from "./local-ai"; import type { IMcpAPI } from "./mcp"; // Enum for window types @@ -107,5 +108,6 @@ declare global { mcpAPI: IMcpAPI; envApi: IEnvAPI; logger: WindowLogger; + localAI: ILocalAIAPI; } } From 045c1edfca9873cc95fc3c36a389401b6b460a3d Mon Sep 17 00:00:00 2001 From: NarwhalChen Date: Thu, 30 Jul 2026 14:56:34 +0800 Subject: [PATCH 03/29] feat(app): stream chat through local AI bridge --- .../renderer/libs/hooks/use-local-ai-chat.ts | 264 +++++++++++ .../src/renderer/libs/local-ai-contract.ts | 112 +++++ .../src/renderer/libs/stores/chat-store.tsx | 409 +++--------------- 3 files changed, 432 insertions(+), 353 deletions(-) create mode 100644 packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts create mode 100644 packages/app/src/renderer/libs/local-ai-contract.ts diff --git a/packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts b/packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts new file mode 100644 index 00000000..cf175c33 --- /dev/null +++ b/packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts @@ -0,0 +1,264 @@ +import type { Message } from "ai"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + getLocalAI, + type LocalAIChatEvent, + type LocalAIChatRequest, + type LocalAIProviderId, +} from "../local-ai-contract"; + +export interface LocalAIChatOptions { + providerId: LocalAIProviderId; + model?: string; + agent?: LocalAIChatRequest["agent"]; + options?: Record; +} + +interface UseLocalAIChatResult { + messages: Message[]; + input: string; + isLoading: boolean; + status: "ready" | "submitted" | "streaming" | "error"; + error: Error | undefined; + setInput: (input: string) => void; + setMessages: (messages: Message[]) => void; + send: ( + message: Omit, + options: LocalAIChatOptions, + ) => Promise; + resend: ( + messages: Message[], + options: LocalAIChatOptions, + ) => Promise; + stop: () => Promise; +} + +function createMessageId(prefix: string): string { + return `${prefix}_${crypto.randomUUID()}`; +} + +function toRequestMessages(messages: Message[]) { + return messages + .filter( + ( + message, + ): message is Message & { + role: "system" | "user" | "assistant"; + } => + message.role === "system" || + message.role === "user" || + message.role === "assistant", + ) + .map((message) => ({ + id: message.id, + role: message.role, + content: + typeof message.content === "string" + ? message.content + : JSON.stringify(message.content), + })); +} + +function applyToolEvent( + message: Message, + event: Extract, +): Message { + const existing = message.toolInvocations || []; + const withoutCurrent = existing.filter( + (tool) => tool.toolCallId !== event.toolCallId, + ); + const base = { + toolCallId: event.toolCallId, + toolName: event.name, + args: event.input ?? {}, + }; + + const next = + event.state === "output-available" + ? { ...base, state: "result" as const, result: event.output } + : event.state === "output-error" + ? { ...base, state: "result" as const, result: { error: event.error } } + : { ...base, state: "call" as const }; + + return { + ...message, + toolInvocations: [...withoutCurrent, next], + }; +} + +export function useLocalAIChat(): UseLocalAIChatResult { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [status, setStatus] = + useState("ready"); + const [error, setError] = useState(); + const activeRequestIdRef = useRef(undefined); + const unsubscribeRef = useRef<(() => void) | undefined>(undefined); + + const releaseSubscription = useCallback(() => { + unsubscribeRef.current?.(); + unsubscribeRef.current = undefined; + }, []); + + const handleEvent = useCallback( + (assistantMessageId: string, event: LocalAIChatEvent) => { + if (event.requestId !== activeRequestIdRef.current) return; + + if (event.type === "delta") { + setStatus("streaming"); + setMessages((current) => + current.map((message) => + message.id === assistantMessageId + ? { + ...message, + content: `${message.content}${event.text}`, + } + : message, + ), + ); + return; + } + + if (event.type === "tool") { + setStatus("streaming"); + setMessages((current) => + current.map((message) => + message.id === assistantMessageId + ? applyToolEvent(message, event) + : message, + ), + ); + return; + } + + if (event.type === "error") { + setError(new Error(event.error.message)); + setStatus("error"); + activeRequestIdRef.current = undefined; + releaseSubscription(); + return; + } + + setStatus(event.finishReason === "error" ? "error" : "ready"); + activeRequestIdRef.current = undefined; + releaseSubscription(); + }, + [releaseSubscription], + ); + + const run = useCallback( + async (nextMessages: Message[], options: LocalAIChatOptions) => { + const localAI = getLocalAI(); + if (!localAI) { + setError(new Error("Local AI runtime is not available.")); + setStatus("error"); + return; + } + + if (activeRequestIdRef.current) { + await localAI.abort(activeRequestIdRef.current); + releaseSubscription(); + } + + const requestId = crypto.randomUUID(); + const assistantMessageId = createMessageId("assistant"); + const assistantMessage: Message = { + id: assistantMessageId, + role: "assistant", + content: "", + createdAt: new Date(), + }; + + setError(undefined); + setStatus("submitted"); + setMessages([...nextMessages, assistantMessage]); + activeRequestIdRef.current = requestId; + unsubscribeRef.current = localAI.onEvent(requestId, (event) => { + handleEvent(assistantMessageId, event); + }); + + try { + const result = await localAI.startChat({ + requestId, + providerId: options.providerId, + model: options.model, + messages: toRequestMessages(nextMessages), + agent: options.agent, + options: options.options, + }); + + if (!result.success || !result.data?.accepted) { + throw new Error(result.error || "Local AI runtime rejected the chat."); + } + } catch (startError) { + const nextError = + startError instanceof Error + ? startError + : new Error("Failed to start local AI chat."); + setError(nextError); + setStatus("error"); + activeRequestIdRef.current = undefined; + releaseSubscription(); + } + }, + [handleEvent, releaseSubscription], + ); + + const send = useCallback( + async ( + message: Omit, + options: LocalAIChatOptions, + ) => { + const userMessage: Message = { + ...message, + id: createMessageId("user"), + createdAt: new Date(), + }; + await run([...messages, userMessage], options); + }, + [messages, run], + ); + + const resend = useCallback( + async (nextMessages: Message[], options: LocalAIChatOptions) => { + await run(nextMessages, options); + }, + [run], + ); + + const stop = useCallback(async () => { + const requestId = activeRequestIdRef.current; + const localAI = getLocalAI(); + if (!requestId || !localAI) return; + + await localAI.abort(requestId); + activeRequestIdRef.current = undefined; + releaseSubscription(); + setStatus("ready"); + }, [releaseSubscription]); + + useEffect( + () => () => { + const requestId = activeRequestIdRef.current; + const localAI = getLocalAI(); + releaseSubscription(); + if (requestId && localAI) { + void localAI.abort(requestId); + } + }, + [releaseSubscription], + ); + + return { + messages, + input, + isLoading: status === "submitted" || status === "streaming", + status, + error, + setInput, + setMessages, + send, + resend, + stop, + }; +} diff --git a/packages/app/src/renderer/libs/local-ai-contract.ts b/packages/app/src/renderer/libs/local-ai-contract.ts new file mode 100644 index 00000000..57beb242 --- /dev/null +++ b/packages/app/src/renderer/libs/local-ai-contract.ts @@ -0,0 +1,112 @@ +export type LocalAIProviderId = + | "claude-code" + | "codex-cli" + | "openai-compatible"; + +export interface LocalAIProviderStatus { + id: LocalAIProviderId; + name: string; + available: boolean; + authenticated: boolean; + models?: string[]; + error?: string; +} + +export interface LocalAIMessage { + id?: string; + role: "system" | "user" | "assistant"; + content: string; +} + +export interface LocalAIChatRequest { + requestId: string; + providerId: LocalAIProviderId; + model?: string; + messages: LocalAIMessage[]; + agent?: { + id?: string; + systemPrompt?: string; + }; + options?: Record; +} + +export interface LocalAIError { + name: string; + message: string; + code?: string; + stack?: string; +} + +export type LocalAIChatEvent = + | { + type: "delta"; + requestId: string; + text: string; + } + | { + type: "tool"; + requestId: string; + toolCallId: string; + name: string; + state: + | "input-streaming" + | "input-available" + | "output-available" + | "output-error"; + input?: unknown; + output?: unknown; + error?: string; + } + | { + type: "error"; + requestId: string; + error: LocalAIError; + } + | { + type: "finish"; + requestId: string; + finishReason: + | "stop" + | "length" + | "content-filter" + | "tool-calls" + | "error" + | "aborted" + | "unknown"; + usage?: { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + }; + }; + +export interface LocalAIResult { + success: boolean; + data?: T; + error?: string; +} + +export interface LocalAIStartResult { + accepted: boolean; + requestId: string; +} + +export interface LocalAIAPI { + listProviders(): Promise>; + getProviderStatus( + id: LocalAIProviderId, + ): Promise>; + startChat( + request: LocalAIChatRequest, + ): Promise>; + abort(requestId: string): Promise>; + onEvent( + requestId: string, + callback: (event: LocalAIChatEvent) => void, + ): () => void; +} + +// Temporary renderer-only bridge declaration. Replace it with the shared preload type during integration. +export function getLocalAI(): LocalAIAPI | undefined { + return (window as Window & { localAI?: LocalAIAPI }).localAI; +} diff --git a/packages/app/src/renderer/libs/stores/chat-store.tsx b/packages/app/src/renderer/libs/stores/chat-store.tsx index 22231065..91edc91c 100644 --- a/packages/app/src/renderer/libs/stores/chat-store.tsx +++ b/packages/app/src/renderer/libs/stores/chat-store.tsx @@ -1,7 +1,6 @@ import { ServerInfo, ToolDefinition } from "@/shared/types/mcp"; -import { AppSettings, FOXYCHAT_CONFIG_ID } from "@/shared/types/settings"; +import { AppSettings } from "@/shared/types/settings"; import { Attachment, Message, UIMessage } from "ai"; -import { useChat } from "ai/react"; import React, { createContext, useCallback, @@ -10,12 +9,9 @@ import React, { useRef, useState, } from "react"; -import { authClient } from "../auth-client"; -import { getApiBaseUrl } from "../env"; -import { SpeechConfig, useSpeechToText } from "../hooks/use-speech-to-text"; -import { updateOpenAISettings } from "../utils/settings"; +import { useLocalAIChat } from "../hooks/use-local-ai-chat"; +import type { LocalAIProviderId } from "../local-ai-contract"; import { useAgentStore } from "./agent-store"; -import { useIsLoggedIn, useSetAuthState } from "./auth-store"; import { useChatHistory } from "./chat-history-store"; import { useModelConfigStore } from "./model-config-store"; import { db, createConversation, updateMessages } from "../db"; @@ -119,10 +115,6 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ useState(null); const [attachments, setAttachments] = useState([]); const [viewMode, setViewMode] = useState("compact"); - const [isVoiceInputActive, setIsVoiceInputActive] = useState(false); - const isUserLoggedIn = useIsLoggedIn(); - const setAuthState = useSetAuthState(); - const [useRemoteStore, setUseRemoteStore] = useState(false); // Use shared Zustand store for conversation ID to sync with sidebar selection const { @@ -149,53 +141,13 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ const { selectedAgent } = useAgentStore(); // const { selectedModelId } = useModelStore(); // const currentModelIdRef = useRef(selectedModelId); - const currentInputRef = useRef(""); const settingsRef = useRef(settings); - // Initialize speech-to-text hook - const speechToText = useSpeechToText(); - // Initialize settings on mount (only once) useEffect(() => { initializeSettings(); }, [initializeSettings]); - // Update useRemoteStore when settings change - use specific value to avoid infinite loops - const settingsUseRemoteStore = settings?.openai?.useRemoteStore; - useEffect(() => { - if (settingsUseRemoteStore !== undefined) { - setUseRemoteStore(settingsUseRemoteStore); - } - }, [settingsUseRemoteStore]); - - // Use reactive session hook for real-time authentication state - const { data: session, refetch: refetchSession } = authClient.useSession(); - const isLoggedIn = session?.session?.id ? true : false; - - // Add a ref to track session refetch function for cross-window sync - const refetchSessionRef = useRef(refetchSession); - useEffect(() => { - refetchSessionRef.current = refetchSession; - }, [refetchSession]); - - // Update login status and handle remote store accordingly - useEffect(() => { - // Update auth store with session data - setAuthState({ - isLoggedIn, - sessionId: session?.session?.id || undefined, - }); - - // If user is not logged in, force disable remote server - if (!isLoggedIn && useRemoteStore) { - setUseRemoteStore(false); - // Also save to settings - updateOpenAISettings({ useRemoteStore: false }).catch((error) => { - console.error("Failed to save remote store preference:", error); - }); - } - }, [isLoggedIn, useRemoteStore, session?.session?.id]); - // useEffect(() => { // currentModelIdRef.current = selectedModelId; // }, [selectedModelId]); @@ -204,150 +156,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ settingsRef.current = settings; }, [settings]); - const chatAPI = useChat({ - api: getApiBaseUrl() + "/chat/completion", - maxSteps: 5, - initialInput: "", - fetch: async (url, options = {}) => { - // Use reactive session from hook - const headers: Record = { - "Content-Type": "application/json", - ...(options.headers as Record), - }; - - // Always include session if available - if (session?.session?.id) { - headers["X-Session-ID"] = session.session.id; - } - - // Ensure all requests have complete MCP configuration - if (options.body && typeof options.body === "string") { - try { - const body = JSON.parse(options.body) as { - mcpServers?: Array<{ name: string; tools: ToolDefinition[] }>; - useRemoteServer?: boolean; - customApiSettings?: { endpoint: string; apiKey: string }; - agent?: unknown; - modelId?: string; - messages?: unknown[]; - [key: string]: unknown; - }; - - // Only supplement if missing critical fields - if (!body.mcpServers || body.mcpServers.length === 0) { - const currentSettings = settingsRef.current; - if (currentSettings) { - // Get fresh MCP tools data (includes builtin tools) - const toolsResponse = await window.mcpAPI.getAllTools(); - let mcpServers: Array<{ - name: string; - tools: ToolDefinition[]; - }> = []; - - if (toolsResponse.success && toolsResponse.data) { - mcpServers = toolsResponse.data.map((server) => ({ - name: server.serverName, - tools: server.tools, - })); - } - - // Get model config from store for interceptor - const { - selectedConfigId: interceptorConfigId, - getCurrentConfig: getInterceptorConfig, - } = useModelConfigStore.getState(); - - // Supplement missing configuration with correct logic - const shouldUseRemoteServer = - interceptorConfigId === FOXYCHAT_CONFIG_ID && isUserLoggedIn; - - if ( - !Object.prototype.hasOwnProperty.call(body, "useRemoteServer") - ) { - body.useRemoteServer = shouldUseRemoteServer; - } - - // Set customApiSettings from model config if NOT using remote server - if (!body.customApiSettings && !shouldUseRemoteServer) { - const interceptorConfig = await getInterceptorConfig(); - if (interceptorConfig) { - body.customApiSettings = { - endpoint: interceptorConfig.endpoint, - apiKey: interceptorConfig.apiKey, - }; - } - } - if (!body.agent) { - body.agent = selectedAgent || undefined; - } - if (!body.modelId) { - // body.modelId = // Let backend use default - // selectedModelId || currentSettings.openai?.modelId; - } - - body.mcpServers = mcpServers; - - options.body = JSON.stringify(body); - } - } - } catch (error) { - console.warn("Failed to parse/update request body:", error); - } - } - - return fetch(url, { - ...options, - headers, - credentials: "include", // Always include credentials - }); - }, - onError: (error) => { - if (error instanceof Error && error.message.includes("fetch")) { - console.error( - "Network error - is the backend server running on port 3001?", - ); - } - }, - onToolCall: async ({ toolCall }) => { - // Special handling for ask_user_input - client-side blocking tool - if (toolCall.toolName === "ask_user_input") { - const args = toolCall.args as { question: string; options: string[] }; - - // Register this as a pending input and return a Promise that won't - // resolve until the user interacts with the UI - const userResponse = await useUserInputStore - .getState() - .registerPendingInput( - toolCall.toolCallId, - args.question, - args.options, - ); - - // Return the user's response as the tool result - return { - success: true, - userSelection: userResponse, - message: `User selected: ${userResponse}`, - }; - } - - // Use simplified MCP tool call that finds first server with the tool - const result = await window.mcpAPI.mcpToolCall( - toolCall.toolName, - toolCall.args as Record, - ); - - if (!result.success) { - throw new Error(result.error || "Tool call failed"); - } - - return result.data; - }, - onFinish: async () => { - console.log("✅ Frontend: Message completed successfully"); - // Messages will be saved via the saveMessagesRef callback - }, - }); + const chatAPI = useLocalAIChat(); // Auto-expand when there are messages useEffect(() => { @@ -356,11 +165,6 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ } }, [chatAPI.messages.length, viewMode]); - // Update input ref for speech callbacks - useEffect(() => { - currentInputRef.current = chatAPI.input; - }, [chatAPI.input]); - // Integrate the useChatHistory hook useChatHistory(chatAPI.setMessages); @@ -471,26 +275,6 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ setAttachments([]); }, []); - // Handle remote store preference change - const handleUseRemoteStoreChange = useCallback( - async (useRemote: boolean) => { - // Only allow enabling if user is logged in - if (useRemote && !isUserLoggedIn) { - console.warn("Cannot enable remote server without login"); - return; - } - - setUseRemoteStore(useRemote); - // Save to settings - try { - await updateOpenAISettings({ useRemoteStore: useRemote }); - } catch (error) { - console.error("Failed to save remote store preference:", error); - } - }, - [isUserLoggedIn], - ); - const getAvailableTools = useCallback(async () => { setToolsLoading(true); setToolsError(null); @@ -664,83 +448,24 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ message.experimental_attachments = fileAttachments; } - // Get fresh MCP tools data for this request (includes builtin tools) - const toolsResponse = await window.mcpAPI.getAllTools(); - let mcpServers: Array<{ name: string; tools: ToolDefinition[] }> = []; - - if (toolsResponse.success && toolsResponse.data) { - mcpServers = toolsResponse.data.map((server) => ({ - name: server.serverName, - tools: server.tools, - })); - } - - // Get current model config from store - const { selectedConfigId, getCurrentConfig } = + const { selectedConfigId, selectedModelId } = useModelConfigStore.getState(); + const providerId = selectedConfigId as LocalAIProviderId; - // Determine which API to use based on selected config - let shouldUseRemoteServer = - selectedConfigId === FOXYCHAT_CONFIG_ID && isUserLoggedIn; - let currentConfig = await getCurrentConfig(); - - // If using FOXYCHAT_CONFIG_ID but not logged in, try to find a custom config - if (selectedConfigId === FOXYCHAT_CONFIG_ID && !isUserLoggedIn) { - const allConfigs = await db.modelConfigs.toArray(); - if (allConfigs.length > 0) { - // Auto-select the first available custom config - currentConfig = allConfigs[0]; - shouldUseRemoteServer = false; - } - } - - // Build customApiSettings from the selected model config - const customApiSettings = - !shouldUseRemoteServer && currentConfig + await chatAPI.send(message, { + providerId, + model: selectedModelId, + agent: selectedAgent ? { - endpoint: currentConfig.endpoint, - apiKey: currentConfig.apiKey, + id: selectedAgent.id, + systemPrompt: selectedAgent.systemPrompt, } - : undefined; - - // If neither remote nor custom API is available, show error - const hasValidConfig = shouldUseRemoteServer || currentConfig; - if (!hasValidConfig) { - // Fallback: check legacy settings - const hasLegacyConfig = - currentSettings?.openai?.apiKey && - currentSettings.openai.apiKey.trim() !== "" && - currentSettings.openai.endpoint && - currentSettings.openai.endpoint.trim() !== ""; - - if (!hasLegacyConfig) { - console.error( - "No API configured. Please log in or add a model configuration.", - ); - alert( - "Please log in or add a model configuration to use the chat. Click the Account button to set up.", - ); - return; - } - } - const requestBody = { - agent: selectedAgent || undefined, - // modelId: // Let backend use default model - // currentModelIdRef.current || currentSettings?.openai?.modelId, - conversationId: conversationIdToUse || undefined, - useRemoteServer: shouldUseRemoteServer, - customApiSettings, - // Re-enable MCP servers - mcpServers, - }; - console.log("🔧 Frontend: Sending request with body:", requestBody); - - // Send message with all custom fields - chatAPI.append(message, { - body: requestBody, + : undefined, + options: { + conversationId: conversationIdToUse, + }, }); - // Clear the input after sending to prevent stale data chatAPI.setInput(""); clearAttachments(); } catch (error) { @@ -759,24 +484,19 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ currentConversationId, setCurrentConversationId, selectedAgent, - isUserLoggedIn, - useRemoteStore, - session, ], ); const setInput = useCallback( (newInput: string) => { - chatAPI.handleInputChange({ - target: { value: newInput }, - } as React.ChangeEvent); + chatAPI.setInput(newInput); }, [chatAPI], ); const stopGeneration = useCallback(() => { if (chatAPI.status === "streaming" || chatAPI.status === "submitted") { - chatAPI.stop(); + void chatAPI.stop(); } }, [chatAPI]); @@ -797,20 +517,42 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ updatedMessages.splice(messageIndex + 1); } - chatAPI.setMessages(updatedMessages); - - setTimeout(() => { - chatAPI.reload(); - }, 100); + const { selectedConfigId, selectedModelId } = + useModelConfigStore.getState(); + void chatAPI.resend(updatedMessages, { + providerId: selectedConfigId as LocalAIProviderId, + model: selectedModelId, + agent: selectedAgent + ? { + id: selectedAgent.id, + systemPrompt: selectedAgent.systemPrompt, + } + : undefined, + }); }, - [chatAPI], + [chatAPI, selectedAgent], ); const regenerateMessage = useCallback(() => { if (chatAPI.status === "ready" || chatAPI.status === "error") { - chatAPI.reload(); + const nextMessages = + chatAPI.messages.at(-1)?.role === "assistant" + ? chatAPI.messages.slice(0, -1) + : chatAPI.messages; + const { selectedConfigId, selectedModelId } = + useModelConfigStore.getState(); + void chatAPI.resend(nextMessages, { + providerId: selectedConfigId as LocalAIProviderId, + model: selectedModelId, + agent: selectedAgent + ? { + id: selectedAgent.id, + systemPrompt: selectedAgent.systemPrompt, + } + : undefined, + }); } - }, [chatAPI]); + }, [chatAPI, selectedAgent]); const resetChat = useCallback(() => { console.log("🔄 Frontend: resetChat called, clearing conversation ID"); @@ -822,7 +564,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ setCurrentConversationId(null); // Reset to compact mode when clearing chat setViewMode("compact"); - }, [chatAPI, clearAttachments]); + }, [chatAPI, clearAttachments, setCurrentConversationId]); const rejectSelectedContent = useCallback(() => { setSelectedContent(null); @@ -835,46 +577,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ setInput(""); }, [resetChat, setInput]); - // Enhanced voice input functionality with speech-to-text - const handleVoiceInput = useCallback(async () => { - try { - // Toggle voice input state - setIsVoiceInputActive((prev) => !prev); - - if (speechToText.isRecording) { - // Stop recording - no need to append transcript since it's already added in real-time - await speechToText.stopRecording(); - } else { - // Start recording with default configuration and real-time callback - const config: SpeechConfig = { - languageCode: "en-US", - silenceTimeoutMs: 8000, // Auto-stop after 8 seconds of silence in chat - }; - - // Callback to handle real-time final transcripts - const onInterimResult = (transcript: string) => { - if (transcript.trim()) { - // Get the current input dynamically using ref to avoid stale closures - const currentInput = currentInputRef.current.trim(); - console.log("Real-time transcript received:", transcript); - console.log("Current input for real-time update:", currentInput); - - // Append the new transcript to existing input - const newInput = currentInput - ? `${currentInput} ${transcript}` - : transcript; - - setInput(newInput); - } - }; - - await speechToText.startRecording(config, onInterimResult); - } - } catch (error) { - console.error("Voice input error:", error); - setIsVoiceInputActive(false); - } - }, [speechToText, setInput]); + const handleVoiceInput = useCallback(() => undefined, []); const contextValue: ChatContextType = { messages: chatAPI.messages as UIMessage[], @@ -886,9 +589,9 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ viewMode, setViewMode, toggleViewMode, - useRemoteStore, - setUseRemoteStore: handleUseRemoteStoreChange, - isUserLoggedIn, + useRemoteStore: false, + setUseRemoteStore: () => undefined, + isUserLoggedIn: true, currentConversationId, setInput, sendMessage, @@ -903,10 +606,10 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ clearAttachments, resetChatWindow, handleVoiceInput, - isVoiceInputActive: speechToText.isRecording || isVoiceInputActive, + isVoiceInputActive: false, speechState: { - isRecording: speechToText.isRecording, - error: speechToText.error, + isRecording: false, + error: null, }, availableTools, mcpServers, From 5b7229a4c8674c8024cf2efcc1be7466353ddd4b Mon Sep 17 00:00:00 2001 From: NarwhalChen Date: Thu, 30 Jul 2026 14:58:50 +0800 Subject: [PATCH 04/29] feat(app): select local AI providers --- .../chat/popover/model-selector-popover.tsx | 35 +++++++---- .../settings/pages/general-page.tsx | 63 ++++++++++++------- packages/app/src/renderer/libs/db/database.ts | 12 ---- packages/app/src/renderer/libs/db/hooks.ts | 26 ++++---- packages/app/src/renderer/libs/db/ui-state.ts | 14 +++-- .../renderer/libs/hooks/use-local-ai-chat.ts | 19 +++--- .../libs/hooks/use-local-ai-providers.ts | 46 ++++++++++++++ .../src/renderer/libs/local-ai-contract.ts | 21 +++++-- .../src/renderer/libs/stores/chat-store.tsx | 29 ++++----- .../libs/stores/model-config-store.ts | 47 ++++++++------ 10 files changed, 203 insertions(+), 109 deletions(-) create mode 100644 packages/app/src/renderer/libs/hooks/use-local-ai-providers.ts 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..b511602a 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-contract"; 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,20 @@ 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 + : [DEFAULT_LOCAL_AI_MODEL_ID]; + return models.map((modelId) => ({ ...model, modelId })); + }); + }, [getAvailableModels, providers]); // Group models by configId const groupedModels = useMemo(() => { @@ -107,8 +120,8 @@ export default function ModelSelector() {
{/* Group Header */}
- {group.isRemote ? ( - + {isLocalAIProviderId(configId) ? ( + ) : ( )} @@ -162,7 +175,7 @@ export default function ModelSelector() { No models available

- Log in or add a model configuration + Install Claude Code, Codex, or add a custom endpoint

)} diff --git a/packages/app/src/renderer/components/settings/pages/general-page.tsx b/packages/app/src/renderer/components/settings/pages/general-page.tsx index 904b9efd..9858df11 100644 --- a/packages/app/src/renderer/components/settings/pages/general-page.tsx +++ b/packages/app/src/renderer/components/settings/pages/general-page.tsx @@ -1,5 +1,5 @@ import { Button } from "@/renderer/components/ui/button"; -import { authClient } from "@/renderer/libs/auth-client"; +import { useLocalAIProviders } from "@/renderer/libs/hooks/use-local-ai-providers"; import { useModelConfigStore } from "@/renderer/libs/stores/model-config-store"; import { useSettingsStore } from "@/renderer/libs/stores/settings-store"; import { @@ -8,7 +8,7 @@ import { Loader2, Plus, RotateCcw, - Satellite, + Terminal, Trash2, } from "lucide-react"; import React, { useCallback, useEffect, useRef, useState } from "react"; @@ -25,7 +25,7 @@ export function GeneralSettingsPage() { const [editingConfigId, setEditingConfigId] = useState(null); const { modelConfigs, removeModelConfig, subscribeToModelConfigChanges } = useModelConfigStore(); - const { data: session } = authClient.useSession(); + const { providers, loading: providersLoading } = useLocalAIProviders(); // Settings Store const { @@ -338,25 +338,46 @@ export function GeneralSettingsPage() {
- {/* Foxychat Remote (always shown if logged in) */} - {session?.user && ( -
-
-
- -
-
-

Foxychat

-

- Remote server (logged in) -

+ {providers + .filter((provider) => provider.id !== "openai-compatible") + .map((provider) => ( +
+
+
+ +
+
+

+ {provider.name} +

+

+ {providersLoading + ? "Checking local CLI..." + : provider.available && provider.authenticated + ? "Installed and authenticated" + : provider.error || + (provider.available + ? "Authentication required" + : "CLI not installed")} +

+
+ + {provider.available && provider.authenticated + ? "Ready" + : "Unavailable"} +
- - Default - -
- )} + ))} {/* Custom Model Configs */} {modelConfigs.map((config) => ( @@ -403,7 +424,7 @@ export function GeneralSettingsPage() { ))} {/* Empty state */} - {!session?.user && modelConfigs.length === 0 && ( + {modelConfigs.length === 0 && (

diff --git a/packages/app/src/renderer/libs/db/database.ts b/packages/app/src/renderer/libs/db/database.ts index 56ae2258..08c08e30 100644 --- a/packages/app/src/renderer/libs/db/database.ts +++ b/packages/app/src/renderer/libs/db/database.ts @@ -118,15 +118,3 @@ export const DEFAULT_AGENT: Agent = { createdAt: new Date(), updatedAt: new Date(), }; - -// Foxychat remote models (requires login) -export const DEFAULT_FOXYCHAT_MODELS = [ - "google/gemini-2.5-flash", - "openai/gpt-4o-mini", - "openai/gpt-4o", - "qwen/qwq-32b", - "anthropic/claude-3.7-sonnet", - "openai/o3-mini", -]; - -export const FOXYCHAT_CONFIG_ID = "foxychat-remote"; diff --git a/packages/app/src/renderer/libs/db/hooks.ts b/packages/app/src/renderer/libs/db/hooks.ts index 1b769423..b74d896a 100644 --- a/packages/app/src/renderer/libs/db/hooks.ts +++ b/packages/app/src/renderer/libs/db/hooks.ts @@ -13,9 +13,12 @@ import { type Message, type ModelConfig, DEFAULT_AGENT, - FOXYCHAT_CONFIG_ID, - DEFAULT_FOXYCHAT_MODELS, } from "./database"; +import { + DEFAULT_LOCAL_AI_MODEL_ID, + LOCAL_AI_PROVIDER_NAMES, + isLocalAIProviderId, +} from "../local-ai-contract"; // ==================== Conversation Hooks ==================== @@ -279,7 +282,7 @@ export function useModelConfigs() { export function useModelConfig(id: string | null) { return useLiveQuery( () => - id && id !== FOXYCHAT_CONFIG_ID ? db.modelConfigs.get(id) : undefined, + id && !isLocalAIProviderId(id) ? db.modelConfigs.get(id) : undefined, [id], ); } @@ -351,24 +354,23 @@ interface GroupedModel { isRemote: boolean; } -export function useAvailableModels(isUserLoggedIn: boolean): GroupedModel[] { +export function useAvailableModels(): GroupedModel[] { const configs = useModelConfigs(); if (!configs) return []; const models: GroupedModel[] = []; - // Foxychat remote models (requires login) - if (isUserLoggedIn) { - DEFAULT_FOXYCHAT_MODELS.forEach((modelId) => { + Object.entries(LOCAL_AI_PROVIDER_NAMES) + .filter(([id]) => id !== "openai-compatible") + .forEach(([configId, configName]) => { models.push({ - configId: FOXYCHAT_CONFIG_ID, - configName: "Foxychat", - modelId, - isRemote: true, + configId, + configName, + modelId: DEFAULT_LOCAL_AI_MODEL_ID, + isRemote: false, }); }); - } // User custom models configs.forEach((config) => { diff --git a/packages/app/src/renderer/libs/db/ui-state.ts b/packages/app/src/renderer/libs/db/ui-state.ts index dc48dd5a..4978110a 100644 --- a/packages/app/src/renderer/libs/db/ui-state.ts +++ b/packages/app/src/renderer/libs/db/ui-state.ts @@ -13,10 +13,16 @@ */ import { create } from "zustand"; -import { FOXYCHAT_CONFIG_ID, DEFAULT_FOXYCHAT_MODELS } from "./database"; +import { + DEFAULT_LOCAL_AI_MODEL_ID, + DEFAULT_LOCAL_AI_PROVIDER_ID, +} from "../local-ai-contract"; // Re-export for convenience -export { FOXYCHAT_CONFIG_ID, DEFAULT_FOXYCHAT_MODELS }; +export { + DEFAULT_LOCAL_AI_MODEL_ID, + DEFAULT_LOCAL_AI_PROVIDER_ID, +} from "../local-ai-contract"; // ==================== Selection State ==================== @@ -36,8 +42,8 @@ interface SelectionState { export const useSelectionStore = create((set) => ({ currentConversationId: null, selectedAgentId: null, - selectedConfigId: FOXYCHAT_CONFIG_ID, - selectedModelId: DEFAULT_FOXYCHAT_MODELS[0], + selectedConfigId: DEFAULT_LOCAL_AI_PROVIDER_ID, + selectedModelId: DEFAULT_LOCAL_AI_MODEL_ID, setCurrentConversation: (id) => set({ currentConversationId: id }), setSelectedAgent: (id) => set({ selectedAgentId: id }), diff --git a/packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts b/packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts index cf175c33..221f222e 100644 --- a/packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts +++ b/packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts @@ -26,10 +26,7 @@ interface UseLocalAIChatResult { message: Omit, options: LocalAIChatOptions, ) => Promise; - resend: ( - messages: Message[], - options: LocalAIChatOptions, - ) => Promise; + resend: (messages: Message[], options: LocalAIChatOptions) => Promise; stop: () => Promise; } @@ -89,8 +86,7 @@ function applyToolEvent( export function useLocalAIChat(): UseLocalAIChatResult { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); - const [status, setStatus] = - useState("ready"); + const [status, setStatus] = useState("ready"); const [error, setError] = useState(); const activeRequestIdRef = useRef(undefined); const unsubscribeRef = useRef<(() => void) | undefined>(undefined); @@ -187,8 +183,10 @@ export function useLocalAIChat(): UseLocalAIChatResult { options: options.options, }); - if (!result.success || !result.data?.accepted) { - throw new Error(result.error || "Local AI runtime rejected the chat."); + if (!result.success || !result.accepted) { + throw new Error( + result.error || "Local AI runtime rejected the chat.", + ); } } catch (startError) { const nextError = @@ -205,10 +203,7 @@ export function useLocalAIChat(): UseLocalAIChatResult { ); const send = useCallback( - async ( - message: Omit, - options: LocalAIChatOptions, - ) => { + async (message: Omit, options: LocalAIChatOptions) => { const userMessage: Message = { ...message, id: createMessageId("user"), diff --git a/packages/app/src/renderer/libs/hooks/use-local-ai-providers.ts b/packages/app/src/renderer/libs/hooks/use-local-ai-providers.ts new file mode 100644 index 00000000..6f5a269f --- /dev/null +++ b/packages/app/src/renderer/libs/hooks/use-local-ai-providers.ts @@ -0,0 +1,46 @@ +import { useCallback, useEffect, useState } from "react"; +import { + getLocalAI, + LOCAL_AI_PROVIDER_NAMES, + type LocalAIProviderId, + type LocalAIProviderStatus, +} from "../local-ai-contract"; + +const fallbackProviders = Object.entries(LOCAL_AI_PROVIDER_NAMES).map( + ([id, name]) => ({ + id: id as LocalAIProviderId, + name, + available: false, + authenticated: false, + }), +); + +export function useLocalAIProviders() { + const [providers, setProviders] = + useState(fallbackProviders); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + const localAI = getLocalAI(); + if (!localAI) { + setProviders(fallbackProviders); + setLoading(false); + return; + } + + try { + const result = await localAI.listProviders(); + if (result.success && result.data) { + setProviders(result.data); + } + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { providers, loading, refresh }; +} diff --git a/packages/app/src/renderer/libs/local-ai-contract.ts b/packages/app/src/renderer/libs/local-ai-contract.ts index 57beb242..1eea0775 100644 --- a/packages/app/src/renderer/libs/local-ai-contract.ts +++ b/packages/app/src/renderer/libs/local-ai-contract.ts @@ -3,6 +3,19 @@ export type LocalAIProviderId = | "codex-cli" | "openai-compatible"; +export const DEFAULT_LOCAL_AI_PROVIDER_ID: LocalAIProviderId = "claude-code"; +export const DEFAULT_LOCAL_AI_MODEL_ID = "default"; + +export const LOCAL_AI_PROVIDER_NAMES: Record = { + "claude-code": "Claude Code", + "codex-cli": "Codex", + "openai-compatible": "OpenAI-compatible", +}; + +export function isLocalAIProviderId(value: string): value is LocalAIProviderId { + return Object.prototype.hasOwnProperty.call(LOCAL_AI_PROVIDER_NAMES, value); +} + export interface LocalAIProviderStatus { id: LocalAIProviderId; name: string; @@ -86,7 +99,7 @@ export interface LocalAIResult { error?: string; } -export interface LocalAIStartResult { +export interface LocalAIStartResult extends LocalAIResult { accepted: boolean; requestId: string; } @@ -96,10 +109,8 @@ export interface LocalAIAPI { getProviderStatus( id: LocalAIProviderId, ): Promise>; - startChat( - request: LocalAIChatRequest, - ): Promise>; - abort(requestId: string): Promise>; + startChat(request: LocalAIChatRequest): Promise; + abort(requestId: string): Promise>; onEvent( requestId: string, callback: (event: LocalAIChatEvent) => void, diff --git a/packages/app/src/renderer/libs/stores/chat-store.tsx b/packages/app/src/renderer/libs/stores/chat-store.tsx index 91edc91c..e860ff4a 100644 --- a/packages/app/src/renderer/libs/stores/chat-store.tsx +++ b/packages/app/src/renderer/libs/stores/chat-store.tsx @@ -10,10 +10,12 @@ import React, { useState, } from "react"; import { useLocalAIChat } from "../hooks/use-local-ai-chat"; -import type { LocalAIProviderId } from "../local-ai-contract"; import { useAgentStore } from "./agent-store"; import { useChatHistory } from "./chat-history-store"; -import { useModelConfigStore } from "./model-config-store"; +import { + resolveLocalAIProviderId, + useModelConfigStore, +} from "./model-config-store"; import { db, createConversation, updateMessages } from "../db"; import { useSelectionStore } from "../db/ui-state"; import { useSettingsStore } from "./settings-store"; @@ -48,11 +50,6 @@ interface ChatContextType { setViewMode: (mode: ChatViewMode) => void; toggleViewMode: () => void; - // Remote store management - useRemoteStore: boolean; - setUseRemoteStore: (useRemote: boolean) => void; - isUserLoggedIn: boolean; - // Conversation management currentConversationId: string | null; @@ -448,9 +445,10 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ message.experimental_attachments = fileAttachments; } - const { selectedConfigId, selectedModelId } = + const { selectedConfigId, selectedModelId, getCurrentConfig } = useModelConfigStore.getState(); - const providerId = selectedConfigId as LocalAIProviderId; + const providerId = resolveLocalAIProviderId(selectedConfigId); + const currentConfig = await getCurrentConfig(); await chatAPI.send(message, { providerId, @@ -463,6 +461,12 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ : undefined, options: { conversationId: conversationIdToUse, + customApiSettings: currentConfig + ? { + endpoint: currentConfig.endpoint, + apiKey: currentConfig.apiKey, + } + : undefined, }, }); @@ -520,7 +524,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ const { selectedConfigId, selectedModelId } = useModelConfigStore.getState(); void chatAPI.resend(updatedMessages, { - providerId: selectedConfigId as LocalAIProviderId, + providerId: resolveLocalAIProviderId(selectedConfigId), model: selectedModelId, agent: selectedAgent ? { @@ -542,7 +546,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ const { selectedConfigId, selectedModelId } = useModelConfigStore.getState(); void chatAPI.resend(nextMessages, { - providerId: selectedConfigId as LocalAIProviderId, + providerId: resolveLocalAIProviderId(selectedConfigId), model: selectedModelId, agent: selectedAgent ? { @@ -589,9 +593,6 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ viewMode, setViewMode, toggleViewMode, - useRemoteStore: false, - setUseRemoteStore: () => undefined, - isUserLoggedIn: true, currentConversationId, setInput, sendMessage, diff --git a/packages/app/src/renderer/libs/stores/model-config-store.ts b/packages/app/src/renderer/libs/stores/model-config-store.ts index f5f8074f..87a8d24c 100644 --- a/packages/app/src/renderer/libs/stores/model-config-store.ts +++ b/packages/app/src/renderer/libs/stores/model-config-store.ts @@ -14,14 +14,19 @@ import { deleteModelConfig as deleteConfigDB, db, type ModelConfig, - FOXYCHAT_CONFIG_ID, - DEFAULT_FOXYCHAT_MODELS, } from "../db"; import { useSelectionStore } from "../db/ui-state"; +import { + DEFAULT_LOCAL_AI_MODEL_ID, + DEFAULT_LOCAL_AI_PROVIDER_ID, + LOCAL_AI_PROVIDER_NAMES, + isLocalAIProviderId, + type LocalAIProviderId, +} from "../local-ai-contract"; // Re-export for backward compatibility export type { ModelConfig }; -export { FOXYCHAT_CONFIG_ID }; +export { DEFAULT_LOCAL_AI_PROVIDER_ID }; interface GroupedModel { configId: string; @@ -64,9 +69,12 @@ export function useModelConfigStore() { removeModelConfig: async (id: string) => { await deleteConfigDB(id); - // If removing the selected config, switch to foxychat remote + // If removing the selected config, switch to the default local provider if (selectedConfigId === id) { - setSelectedModel(FOXYCHAT_CONFIG_ID, DEFAULT_FOXYCHAT_MODELS[0]); + setSelectedModel( + DEFAULT_LOCAL_AI_PROVIDER_ID, + DEFAULT_LOCAL_AI_MODEL_ID, + ); } }, @@ -88,20 +96,19 @@ export function useModelConfigStore() { }, // Helpers - getAvailableModels: (isUserLoggedIn: boolean): GroupedModel[] => { + getAvailableModels: (): GroupedModel[] => { const models: GroupedModel[] = []; - // Foxychat remote models (requires login) - if (isUserLoggedIn) { - DEFAULT_FOXYCHAT_MODELS.forEach((modelId) => { + Object.entries(LOCAL_AI_PROVIDER_NAMES) + .filter(([id]) => id !== "openai-compatible") + .forEach(([configId, configName]) => { models.push({ - configId: FOXYCHAT_CONFIG_ID, - configName: "Foxychat", - modelId, - isRemote: true, + configId, + configName, + modelId: DEFAULT_LOCAL_AI_MODEL_ID, + isRemote: false, }); }); - } // User custom models (modelConfigs || []).forEach((config) => { @@ -119,14 +126,14 @@ export function useModelConfigStore() { }, getConfigById: (id: string): ModelConfig | undefined => { - if (id === FOXYCHAT_CONFIG_ID) { + if (isLocalAIProviderId(id)) { return undefined; } return (modelConfigs || []).find((config) => config.id === id); }, getCurrentConfig: (): ModelConfig | undefined => { - if (selectedConfigId === FOXYCHAT_CONFIG_ID) { + if (isLocalAIProviderId(selectedConfigId)) { return undefined; } return currentConfig; @@ -157,13 +164,13 @@ useModelConfigStore.getState = () => { selectedConfigId, selectedModelId, getCurrentConfig: async (): Promise => { - if (selectedConfigId === FOXYCHAT_CONFIG_ID) { + if (isLocalAIProviderId(selectedConfigId)) { return undefined; } return await db.modelConfigs.get(selectedConfigId); }, getConfigById: async (id: string): Promise => { - if (id === FOXYCHAT_CONFIG_ID) { + if (isLocalAIProviderId(id)) { return undefined; } return await db.modelConfigs.get(id); @@ -171,6 +178,10 @@ useModelConfigStore.getState = () => { }; }; +export function resolveLocalAIProviderId(configId: string): LocalAIProviderId { + return isLocalAIProviderId(configId) ? configId : "openai-compatible"; +} + // ==================== Standalone Actions ==================== /** From 3e4145ad2b5c6c0df295b4410c8c51e2105df8dd Mon Sep 17 00:00:00 2001 From: NarwhalChen Date: Thu, 30 Jul 2026 15:02:44 +0800 Subject: [PATCH 05/29] refactor(app): hide cloud service surfaces --- .../chat/input/chat-input-button.tsx | 48 ++---------------- .../components/chat/input/chat-input.tsx | 4 -- .../components/chat/message/chat-message.tsx | 49 +------------------ .../chat/popover/model-selector-popover.tsx | 12 ++++- .../src/renderer/components/home/index.tsx | 17 ++----- .../settings/pages/general-page.tsx | 10 ++-- .../app/src/renderer/layouts/base-layout.tsx | 28 ++--------- packages/app/src/renderer/libs/db/database.ts | 4 +- .../renderer/libs/hooks/use-local-ai-chat.ts | 10 ++-- .../libs/hooks/use-local-ai-providers.ts | 4 +- .../src/renderer/libs/local-ai-contract.ts | 35 ++++++++----- .../src/renderer/libs/stores/chat-store.tsx | 29 +---------- .../renderer/libs/stores/settings-store.ts | 2 +- .../app/src/renderer/libs/utils/settings.ts | 2 +- .../src/renderer/routes/auth/$pathname.tsx | 17 +------ 15 files changed, 67 insertions(+), 204 deletions(-) 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 ? (
diff --git a/packages/app/src/renderer/components/chat/message/chat-message.tsx b/packages/app/src/renderer/components/chat/message/chat-message.tsx index 3f4d8978..1a138496 100644 --- a/packages/app/src/renderer/components/chat/message/chat-message.tsx +++ b/packages/app/src/renderer/components/chat/message/chat-message.tsx @@ -1,5 +1,4 @@ import { BaseLogo } from "@/renderer/components/common/base-logo"; -import { authClient } from "@/renderer/libs/auth-client"; import { SelectedContent } from "@/renderer/libs/stores/chat-store"; import { Attachment, UIMessage } from "ai"; import { AnimatePresence, motion } from "framer-motion"; @@ -13,7 +12,6 @@ import { User, } from "lucide-react"; import React, { memo, useRef, useState } from "react"; -import { Avatar, AvatarFallback, AvatarImage } from "../../ui/avatar"; import SelectedContentBlock from "../selected/selected-content-block"; /** @@ -231,29 +229,6 @@ const ChatMessage = memo( message.experimental_attachments && message.experimental_attachments.length > 0; - // Get user session for avatar - const { data: session } = authClient.useSession(); - - // Get user initials for fallback - const getUserInitials = (name?: string, email?: string) => { - if (name) { - return name - .split(" ") - .map((word) => word.charAt(0)) - .join("") - .toUpperCase() - .slice(0, 2); - } - if (email) { - return email.charAt(0).toUpperCase(); - } - return "U"; - }; - - const userInitials = session?.user - ? getUserInitials(session.user.name, session.user.email) - : "U"; - return (
{isUser ? ( - session?.user ? ( - - {session.user.image && ( - - )} - - {userInitials} - - - ) : ( - - ) + ) : ( )} @@ -294,13 +255,7 @@ const ChatMessage = memo( {/* Header with role and timestamp - inline with avatar */}
- {isUser - ? session?.user - ? session.user.name || - session.user.email?.split("@")[0] || - "You" - : "You" - : "Convera"} + {isUser ? "You" : "Convera"} {formatTimestamp(message.createdAt)} diff --git a/packages/app/src/renderer/components/chat/popover/model-selector-popover.tsx b/packages/app/src/renderer/components/chat/popover/model-selector-popover.tsx index b511602a..400271fd 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 @@ -36,8 +36,16 @@ export default function ModelSelector() { const models = provider?.models && provider.models.length > 0 ? provider.models - : [DEFAULT_LOCAL_AI_MODEL_ID]; - return models.map((modelId) => ({ ...model, modelId })); + : [ + { + id: DEFAULT_LOCAL_AI_MODEL_ID, + name: DEFAULT_LOCAL_AI_MODEL_ID, + }, + ]; + return models.map((providerModel) => ({ + ...model, + modelId: providerModel.id, + })); }); }, [getAvailableModels, providers]); diff --git a/packages/app/src/renderer/components/home/index.tsx b/packages/app/src/renderer/components/home/index.tsx index dc51966f..d598580f 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: @@ -285,7 +279,6 @@ export function HomePage() { )} -
)} diff --git a/packages/app/src/renderer/components/settings/pages/general-page.tsx b/packages/app/src/renderer/components/settings/pages/general-page.tsx index 9858df11..92e0696f 100644 --- a/packages/app/src/renderer/components/settings/pages/general-page.tsx +++ b/packages/app/src/renderer/components/settings/pages/general-page.tsx @@ -356,10 +356,10 @@ export function GeneralSettingsPage() {

{providersLoading ? "Checking local CLI..." - : provider.available && provider.authenticated + : provider.availability === "available" ? "Installed and authenticated" - : provider.error || - (provider.available + : provider.detail || + (provider.availability === "unauthenticated" ? "Authentication required" : "CLI not installed")}

@@ -367,12 +367,12 @@ export function GeneralSettingsPage() {
- {provider.available && provider.authenticated + {provider.availability === "available" ? "Ready" : "Unavailable"} diff --git a/packages/app/src/renderer/layouts/base-layout.tsx b/packages/app/src/renderer/layouts/base-layout.tsx index 6b702a7f..f3bdf5ae 100644 --- a/packages/app/src/renderer/layouts/base-layout.tsx +++ b/packages/app/src/renderer/layouts/base-layout.tsx @@ -1,9 +1,5 @@ -import { AuthQueryProvider } from "@daveyplate/better-auth-tanstack"; -import { AuthUIProviderTanstack } from "@daveyplate/better-auth-ui/tanstack"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { Link, useRouter } from "@tanstack/react-router"; import React from "react"; -import { authClient } from "../libs/auth-client"; import { ChatProvider } from "../libs/stores/chat-store"; const queryClient = new QueryClient({ @@ -22,25 +18,11 @@ export default function BaseLayout({ }: { children: React.ReactNode; }) { - const router = useRouter(); - return ( - <> - - - router.navigate({ href })} - replace={(href) => router.navigate({ href, replace: true })} - Link={({ href, ...props }) => } - providers={["github", "google"]} - > -
- {children} -
-
-
-
- + +
+ {children} +
+
); } diff --git a/packages/app/src/renderer/libs/db/database.ts b/packages/app/src/renderer/libs/db/database.ts index 08c08e30..d667e538 100644 --- a/packages/app/src/renderer/libs/db/database.ts +++ b/packages/app/src/renderer/libs/db/database.ts @@ -7,9 +7,7 @@ * - modelConfigs: Custom model configurations (local only) * - settings: App settings (local only) * - * Cloud API (retained): - * - /api/chat/completion: AI inference - * - /api/marketplace/*: Agent/MCP template downloads (future) + * AI inference and tools run through the Electron main process. */ import Dexie, { type EntityTable } from "dexie"; diff --git a/packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts b/packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts index 221f222e..98fbef7f 100644 --- a/packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts +++ b/packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts @@ -74,7 +74,11 @@ function applyToolEvent( event.state === "output-available" ? { ...base, state: "result" as const, result: event.output } : event.state === "output-error" - ? { ...base, state: "result" as const, result: { error: event.error } } + ? { + ...base, + state: "result" as const, + result: { error: event.error?.message }, + } : { ...base, state: "call" as const }; return { @@ -177,7 +181,7 @@ export function useLocalAIChat(): UseLocalAIChatResult { const result = await localAI.startChat({ requestId, providerId: options.providerId, - model: options.model, + modelId: options.model, messages: toRequestMessages(nextMessages), agent: options.agent, options: options.options, @@ -185,7 +189,7 @@ export function useLocalAIChat(): UseLocalAIChatResult { if (!result.success || !result.accepted) { throw new Error( - result.error || "Local AI runtime rejected the chat.", + result.error?.message || "Local AI runtime rejected the chat.", ); } } catch (startError) { diff --git a/packages/app/src/renderer/libs/hooks/use-local-ai-providers.ts b/packages/app/src/renderer/libs/hooks/use-local-ai-providers.ts index 6f5a269f..53d317d3 100644 --- a/packages/app/src/renderer/libs/hooks/use-local-ai-providers.ts +++ b/packages/app/src/renderer/libs/hooks/use-local-ai-providers.ts @@ -10,8 +10,8 @@ const fallbackProviders = Object.entries(LOCAL_AI_PROVIDER_NAMES).map( ([id, name]) => ({ id: id as LocalAIProviderId, name, - available: false, - authenticated: false, + kind: id as LocalAIProviderId, + availability: "unavailable" as const, }), ); diff --git a/packages/app/src/renderer/libs/local-ai-contract.ts b/packages/app/src/renderer/libs/local-ai-contract.ts index 1eea0775..3f7dee6f 100644 --- a/packages/app/src/renderer/libs/local-ai-contract.ts +++ b/packages/app/src/renderer/libs/local-ai-contract.ts @@ -17,12 +17,20 @@ export function isLocalAIProviderId(value: string): value is LocalAIProviderId { } export interface LocalAIProviderStatus { - id: LocalAIProviderId; + id: string; name: string; - available: boolean; - authenticated: boolean; - models?: string[]; - error?: string; + kind: LocalAIProviderId; + availability: + | "available" + | "missing" + | "unauthenticated" + | "unavailable" + | "error"; + detail?: string; + models?: Array<{ + id: string; + name: string; + }>; } export interface LocalAIMessage { @@ -34,13 +42,17 @@ export interface LocalAIMessage { export interface LocalAIChatRequest { requestId: string; providerId: LocalAIProviderId; - model?: string; + modelId?: string; messages: LocalAIMessage[]; agent?: { id?: string; systemPrompt?: string; }; - options?: Record; + options?: { + temperature?: number; + maxOutputTokens?: number; + cwd?: string; + }; } export interface LocalAIError { @@ -68,7 +80,7 @@ export type LocalAIChatEvent = | "output-error"; input?: unknown; output?: unknown; - error?: string; + error?: LocalAIError; } | { type: "error"; @@ -96,19 +108,16 @@ export type LocalAIChatEvent = export interface LocalAIResult { success: boolean; data?: T; - error?: string; + error?: LocalAIError; } export interface LocalAIStartResult extends LocalAIResult { accepted: boolean; - requestId: string; } export interface LocalAIAPI { listProviders(): Promise>; - getProviderStatus( - id: LocalAIProviderId, - ): Promise>; + getProviderStatus(id: string): Promise>; startChat(request: LocalAIChatRequest): Promise; abort(requestId: string): Promise>; onEvent( diff --git a/packages/app/src/renderer/libs/stores/chat-store.tsx b/packages/app/src/renderer/libs/stores/chat-store.tsx index e860ff4a..27ae1276 100644 --- a/packages/app/src/renderer/libs/stores/chat-store.tsx +++ b/packages/app/src/renderer/libs/stores/chat-store.tsx @@ -73,9 +73,6 @@ interface ChatContextType { // Chat-related actions (previously in app-actions) resetChatWindow: () => void; - handleVoiceInput: () => void; - isVoiceInputActive: boolean; - // MCP Tools methods getAvailableTools: () => Promise; executeTool: ( @@ -90,12 +87,6 @@ interface ChatContextType { toolName: string, args: Record, ) => Promise; - - // Speech-to-text state - speechState: { - isRecording: boolean; - error: string | null; - }; } interface ChatMessage extends Omit { @@ -445,10 +436,9 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ message.experimental_attachments = fileAttachments; } - const { selectedConfigId, selectedModelId, getCurrentConfig } = + const { selectedConfigId, selectedModelId } = useModelConfigStore.getState(); const providerId = resolveLocalAIProviderId(selectedConfigId); - const currentConfig = await getCurrentConfig(); await chatAPI.send(message, { providerId, @@ -459,15 +449,6 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ systemPrompt: selectedAgent.systemPrompt, } : undefined, - options: { - conversationId: conversationIdToUse, - customApiSettings: currentConfig - ? { - endpoint: currentConfig.endpoint, - apiKey: currentConfig.apiKey, - } - : undefined, - }, }); chatAPI.setInput(""); @@ -581,8 +562,6 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ setInput(""); }, [resetChat, setInput]); - const handleVoiceInput = useCallback(() => undefined, []); - const contextValue: ChatContextType = { messages: chatAPI.messages as UIMessage[], input: chatAPI.input, @@ -606,12 +585,6 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ removeAttachment, clearAttachments, resetChatWindow, - handleVoiceInput, - isVoiceInputActive: false, - speechState: { - isRecording: false, - error: null, - }, availableTools, mcpServers, toolsLoading, diff --git a/packages/app/src/renderer/libs/stores/settings-store.ts b/packages/app/src/renderer/libs/stores/settings-store.ts index ba85fd23..1256352f 100644 --- a/packages/app/src/renderer/libs/stores/settings-store.ts +++ b/packages/app/src/renderer/libs/stores/settings-store.ts @@ -50,7 +50,7 @@ const DEFAULT_OPENAI_SETTINGS: OpenAISettings = { "anthropic/claude-3.7-sonnet", "openai/o3-mini", ], - useRemoteStore: true, + useRemoteStore: false, }; async function getDefaultShortcuts(): Promise { diff --git a/packages/app/src/renderer/libs/utils/settings.ts b/packages/app/src/renderer/libs/utils/settings.ts index 2fde66f3..0c8cb0d5 100644 --- a/packages/app/src/renderer/libs/utils/settings.ts +++ b/packages/app/src/renderer/libs/utils/settings.ts @@ -49,7 +49,7 @@ const DEFAULT_OPENAI_SETTINGS: OpenAISettings = { "anthropic/claude-3.7-sonnet", "openai/o3-mini", ], - useRemoteStore: true, // Default to remote server only + useRemoteStore: false, }; // Get default shortcuts based on platform diff --git a/packages/app/src/renderer/routes/auth/$pathname.tsx b/packages/app/src/renderer/routes/auth/$pathname.tsx index 374d2ce8..5fd7fc4c 100644 --- a/packages/app/src/renderer/routes/auth/$pathname.tsx +++ b/packages/app/src/renderer/routes/auth/$pathname.tsx @@ -1,5 +1,4 @@ -import { AuthCard } from "@daveyplate/better-auth-ui"; -import { createFileRoute, useParams, useSearch } from "@tanstack/react-router"; +import { Navigate, createFileRoute } from "@tanstack/react-router"; import React from "react"; export const Route = createFileRoute("/auth/$pathname")({ @@ -7,17 +6,5 @@ export const Route = createFileRoute("/auth/$pathname")({ }); function RouteComponent() { - const { pathname } = useParams({ from: "/auth/$pathname" }); - // keep for future extension if we need to accept ?redirect from web - useSearch({ from: "/auth/$pathname" }); - - // Use website redirect page to complete OAuth and then deep link back to app - const next = encodeURIComponent("/settings?from=auth&tab=general"); - const oauthCallbackURL = `https://www.foxychat.net/redirect/auth?next=${next}`; - - return ( -
- -
- ); + return ; } From 553fc231dd488d7b80628cc61a8672fd8a9e129d Mon Sep 17 00:00:00 2001 From: NarwhalChen Date: Thu, 30 Jul 2026 15:10:08 +0800 Subject: [PATCH 06/29] fix(app): harden local AI integration --- packages/app/src/electron/main.ts | 81 +-------- .../app/src/electron/windows/main-window.ts | 2 +- .../chat/popover/model-selector-popover.tsx | 2 +- .../settings/pages/general-page.tsx | 167 ++++-------------- packages/app/src/renderer/libs/db/hooks.ts | 2 +- packages/app/src/renderer/libs/db/ui-state.ts | 34 +++- .../renderer/libs/hooks/use-local-ai-chat.ts | 56 ++++-- .../libs/hooks/use-local-ai-providers.ts | 4 +- .../src/renderer/libs/local-ai-contract.ts | 132 -------------- packages/app/src/renderer/libs/local-ai.ts | 22 +++ .../libs/stores/chat-history-store.ts | 8 +- .../src/renderer/libs/stores/chat-store.tsx | 65 +++---- .../libs/stores/model-config-store.ts | 26 +-- 13 files changed, 172 insertions(+), 429 deletions(-) delete mode 100644 packages/app/src/renderer/libs/local-ai-contract.ts create mode 100644 packages/app/src/renderer/libs/local-ai.ts diff --git a/packages/app/src/electron/main.ts b/packages/app/src/electron/main.ts index 306db146..574df943 100644 --- a/packages/app/src/electron/main.ts +++ b/packages/app/src/electron/main.ts @@ -1,9 +1,4 @@ -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"; @@ -130,16 +125,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); } @@ -160,67 +145,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/windows/main-window.ts b/packages/app/src/electron/windows/main-window.ts index aacf4a78..40d59ea5 100644 --- a/packages/app/src/electron/windows/main-window.ts +++ b/packages/app/src/electron/windows/main-window.ts @@ -40,7 +40,7 @@ function createPlatformSpecificConfig(): BrowserWindowConstructorOptions { webPreferences: { devTools: inDevelopment, contextIsolation: true, - nodeIntegration: true, + nodeIntegration: false, nodeIntegrationInSubFrames: false, preload: path.join(__dirname, "preload.js"), }, 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 400271fd..cf88b54a 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 @@ -2,7 +2,7 @@ import { useLocalAIProviders } from "@/renderer/libs/hooks/use-local-ai-provider import { DEFAULT_LOCAL_AI_MODEL_ID, isLocalAIProviderId, -} from "@/renderer/libs/local-ai-contract"; +} from "@/renderer/libs/local-ai"; import { useModelConfigStore } from "@/renderer/libs/stores/model-config-store"; import * as Popover from "@radix-ui/react-popover"; import { Key, Terminal } from "lucide-react"; diff --git a/packages/app/src/renderer/components/settings/pages/general-page.tsx b/packages/app/src/renderer/components/settings/pages/general-page.tsx index 92e0696f..32bfbd53 100644 --- a/packages/app/src/renderer/components/settings/pages/general-page.tsx +++ b/packages/app/src/renderer/components/settings/pages/general-page.tsx @@ -2,17 +2,8 @@ import { Button } from "@/renderer/components/ui/button"; import { useLocalAIProviders } from "@/renderer/libs/hooks/use-local-ai-providers"; import { useModelConfigStore } from "@/renderer/libs/stores/model-config-store"; import { useSettingsStore } from "@/renderer/libs/stores/settings-store"; -import { - Edit2, - Key, - Loader2, - Plus, - RotateCcw, - Terminal, - Trash2, -} from "lucide-react"; -import React, { useCallback, useEffect, useRef, useState } from "react"; -import { ModelConfigForm } from "../../../components/auth/model-config-form"; +import { Loader2, RotateCcw, Terminal } from "lucide-react"; +import React, { useCallback, useEffect, useRef } from "react"; export function GeneralSettingsPage() { // Refs for shortcut recording @@ -21,10 +12,7 @@ export function GeneralSettingsPage() { const saveTimeoutRef = useRef(null); // Model Config state - const [showAddConfigModal, setShowAddConfigModal] = useState(false); - const [editingConfigId, setEditingConfigId] = useState(null); - const { modelConfigs, removeModelConfig, subscribeToModelConfigChanges } = - useModelConfigStore(); + const { subscribeToModelConfigChanges } = useModelConfigStore(); const { providers, loading: providersLoading } = useLocalAIProviders(); // Settings Store @@ -319,150 +307,57 @@ export function GeneralSettingsPage() { {/* Model Configurations Section */}
-
+

- Model Configurations + Local AI Providers

- +

+ Convera uses your existing Claude Code or Codex CLI login. +

- {providers - .filter((provider) => provider.id !== "openai-compatible") - .map((provider) => ( -
-
-
- -
-
-

- {provider.name} -

-

- {providersLoading - ? "Checking local CLI..." - : provider.availability === "available" - ? "Installed and authenticated" - : provider.detail || - (provider.availability === "unauthenticated" - ? "Authentication required" - : "CLI not installed")} -

-
-
- - {provider.availability === "available" - ? "Ready" - : "Unavailable"} - -
- ))} - - {/* Custom Model Configs */} - {modelConfigs.map((config) => ( + {providers.map((provider) => (
- +

- {config.name} + {provider.name}

- {config.models.length} model - {config.models.length !== 1 ? "s" : ""} configured + {providersLoading + ? "Checking local CLI..." + : provider.availability === "available" + ? "Installed and authenticated" + : provider.detail || + (provider.availability === "unauthenticated" + ? "Authentication required" + : "CLI not installed")}

-
- - -
+ + {provider.availability === "available" + ? "Ready" + : "Unavailable"} +
))} - - {/* Empty state */} - {modelConfigs.length === 0 && ( -
- -

- No model configurations yet -

-

- Add a configuration to use your own API -

-
- )}
- - {/* Add/Edit Model Config Modal */} - {showAddConfigModal && ( -
-
- { - setShowAddConfigModal(false); - setEditingConfigId(null); - }} - /> - -
-
- )}
); } diff --git a/packages/app/src/renderer/libs/db/hooks.ts b/packages/app/src/renderer/libs/db/hooks.ts index b74d896a..44ad2c9a 100644 --- a/packages/app/src/renderer/libs/db/hooks.ts +++ b/packages/app/src/renderer/libs/db/hooks.ts @@ -18,7 +18,7 @@ import { DEFAULT_LOCAL_AI_MODEL_ID, LOCAL_AI_PROVIDER_NAMES, isLocalAIProviderId, -} from "../local-ai-contract"; +} from "../local-ai"; // ==================== Conversation Hooks ==================== diff --git a/packages/app/src/renderer/libs/db/ui-state.ts b/packages/app/src/renderer/libs/db/ui-state.ts index 4978110a..5f43b9ee 100644 --- a/packages/app/src/renderer/libs/db/ui-state.ts +++ b/packages/app/src/renderer/libs/db/ui-state.ts @@ -13,16 +13,18 @@ */ import { create } from "zustand"; +import { db } from "./database"; import { DEFAULT_LOCAL_AI_MODEL_ID, DEFAULT_LOCAL_AI_PROVIDER_ID, -} from "../local-ai-contract"; + isLocalAIProviderId, +} from "../local-ai"; // Re-export for convenience export { DEFAULT_LOCAL_AI_MODEL_ID, DEFAULT_LOCAL_AI_PROVIDER_ID, -} from "../local-ai-contract"; +} from "../local-ai"; // ==================== Selection State ==================== @@ -47,10 +49,34 @@ export const useSelectionStore = create((set) => ({ setCurrentConversation: (id) => set({ currentConversationId: id }), setSelectedAgent: (id) => set({ selectedAgentId: id }), - setSelectedModel: (configId, modelId) => - set({ selectedConfigId: configId, selectedModelId: modelId }), + setSelectedModel: (configId, modelId) => { + set({ selectedConfigId: configId, selectedModelId: modelId }); + void db.settings.put({ + key: "local-ai-selection", + value: { configId, modelId }, + updatedAt: new Date(), + }); + }, })); +void db.settings.get("local-ai-selection").then((record) => { + const value = record?.value; + if ( + value && + typeof value === "object" && + "configId" in value && + "modelId" in value && + typeof value.configId === "string" && + typeof value.modelId === "string" && + isLocalAIProviderId(value.configId) + ) { + useSelectionStore.setState({ + selectedConfigId: value.configId, + selectedModelId: value.modelId, + }); + } +}); + // ==================== Chat UI State ==================== interface ChatUIState { diff --git a/packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts b/packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts index 98fbef7f..abff27f8 100644 --- a/packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts +++ b/packages/app/src/renderer/libs/hooks/use-local-ai-chat.ts @@ -1,17 +1,16 @@ import type { Message } from "ai"; import { useCallback, useEffect, useRef, useState } from "react"; -import { - getLocalAI, - type LocalAIChatEvent, - type LocalAIChatRequest, - type LocalAIProviderId, -} from "../local-ai-contract"; +import type { + LocalAIChatRequest, + LocalAIStreamEvent, +} from "@/shared/types/local-ai"; +import { getLocalAI, type LocalAIProviderId } from "../local-ai"; export interface LocalAIChatOptions { providerId: LocalAIProviderId; model?: string; agent?: LocalAIChatRequest["agent"]; - options?: Record; + options?: LocalAIChatRequest["options"]; } interface UseLocalAIChatResult { @@ -58,7 +57,7 @@ function toRequestMessages(messages: Message[]) { function applyToolEvent( message: Message, - event: Extract, + event: Extract, ): Message { const existing = message.toolInvocations || []; const withoutCurrent = existing.filter( @@ -101,7 +100,7 @@ export function useLocalAIChat(): UseLocalAIChatResult { }, []); const handleEvent = useCallback( - (assistantMessageId: string, event: LocalAIChatEvent) => { + (assistantMessageId: string, event: LocalAIStreamEvent) => { if (event.requestId !== activeRequestIdRef.current) return; if (event.type === "delta") { @@ -134,8 +133,6 @@ export function useLocalAIChat(): UseLocalAIChatResult { if (event.type === "error") { setError(new Error(event.error.message)); setStatus("error"); - activeRequestIdRef.current = undefined; - releaseSubscription(); return; } @@ -156,8 +153,15 @@ export function useLocalAIChat(): UseLocalAIChatResult { } if (activeRequestIdRef.current) { - await localAI.abort(activeRequestIdRef.current); + const abortResult = await localAI.abort(activeRequestIdRef.current); + if (!abortResult.success) { + throw new Error( + abortResult.error?.message || + "Could not stop the previous local AI request.", + ); + } releaseSubscription(); + activeRequestIdRef.current = undefined; } const requestId = crypto.randomUUID(); @@ -230,10 +234,30 @@ export function useLocalAIChat(): UseLocalAIChatResult { const localAI = getLocalAI(); if (!requestId || !localAI) return; - await localAI.abort(requestId); - activeRequestIdRef.current = undefined; - releaseSubscription(); - setStatus("ready"); + try { + const result = await localAI.abort(requestId); + if (!result.success) { + throw new Error( + result.error?.message || "Could not stop the local AI request.", + ); + } + + // A successful abort emits the terminal event through onEvent. If the + // main process no longer owns the request, there will be no event to + // wait for, so release the local listener here. + if (!result.data?.aborted) { + activeRequestIdRef.current = undefined; + releaseSubscription(); + setStatus("ready"); + } + } catch (abortError) { + setError( + abortError instanceof Error + ? abortError + : new Error("Could not stop the local AI request."), + ); + setStatus("error"); + } }, [releaseSubscription]); useEffect( diff --git a/packages/app/src/renderer/libs/hooks/use-local-ai-providers.ts b/packages/app/src/renderer/libs/hooks/use-local-ai-providers.ts index 53d317d3..1a52f73c 100644 --- a/packages/app/src/renderer/libs/hooks/use-local-ai-providers.ts +++ b/packages/app/src/renderer/libs/hooks/use-local-ai-providers.ts @@ -1,10 +1,10 @@ import { useCallback, useEffect, useState } from "react"; +import type { LocalAIProviderStatus } from "@/shared/types/local-ai"; import { getLocalAI, LOCAL_AI_PROVIDER_NAMES, type LocalAIProviderId, - type LocalAIProviderStatus, -} from "../local-ai-contract"; +} from "../local-ai"; const fallbackProviders = Object.entries(LOCAL_AI_PROVIDER_NAMES).map( ([id, name]) => ({ diff --git a/packages/app/src/renderer/libs/local-ai-contract.ts b/packages/app/src/renderer/libs/local-ai-contract.ts deleted file mode 100644 index 3f7dee6f..00000000 --- a/packages/app/src/renderer/libs/local-ai-contract.ts +++ /dev/null @@ -1,132 +0,0 @@ -export type LocalAIProviderId = - | "claude-code" - | "codex-cli" - | "openai-compatible"; - -export const DEFAULT_LOCAL_AI_PROVIDER_ID: LocalAIProviderId = "claude-code"; -export const DEFAULT_LOCAL_AI_MODEL_ID = "default"; - -export const LOCAL_AI_PROVIDER_NAMES: Record = { - "claude-code": "Claude Code", - "codex-cli": "Codex", - "openai-compatible": "OpenAI-compatible", -}; - -export function isLocalAIProviderId(value: string): value is LocalAIProviderId { - return Object.prototype.hasOwnProperty.call(LOCAL_AI_PROVIDER_NAMES, value); -} - -export interface LocalAIProviderStatus { - id: string; - name: string; - kind: LocalAIProviderId; - availability: - | "available" - | "missing" - | "unauthenticated" - | "unavailable" - | "error"; - detail?: string; - models?: Array<{ - id: string; - name: string; - }>; -} - -export interface LocalAIMessage { - id?: string; - role: "system" | "user" | "assistant"; - content: string; -} - -export interface LocalAIChatRequest { - requestId: string; - providerId: LocalAIProviderId; - modelId?: string; - messages: LocalAIMessage[]; - agent?: { - id?: string; - systemPrompt?: string; - }; - options?: { - temperature?: number; - maxOutputTokens?: number; - cwd?: string; - }; -} - -export interface LocalAIError { - name: string; - message: string; - code?: string; - stack?: string; -} - -export type LocalAIChatEvent = - | { - type: "delta"; - requestId: string; - text: string; - } - | { - type: "tool"; - requestId: string; - toolCallId: string; - name: string; - state: - | "input-streaming" - | "input-available" - | "output-available" - | "output-error"; - input?: unknown; - output?: unknown; - error?: LocalAIError; - } - | { - type: "error"; - requestId: string; - error: LocalAIError; - } - | { - type: "finish"; - requestId: string; - finishReason: - | "stop" - | "length" - | "content-filter" - | "tool-calls" - | "error" - | "aborted" - | "unknown"; - usage?: { - inputTokens?: number; - outputTokens?: number; - totalTokens?: number; - }; - }; - -export interface LocalAIResult { - success: boolean; - data?: T; - error?: LocalAIError; -} - -export interface LocalAIStartResult extends LocalAIResult { - accepted: boolean; -} - -export interface LocalAIAPI { - listProviders(): Promise>; - getProviderStatus(id: string): Promise>; - startChat(request: LocalAIChatRequest): Promise; - abort(requestId: string): Promise>; - onEvent( - requestId: string, - callback: (event: LocalAIChatEvent) => void, - ): () => void; -} - -// Temporary renderer-only bridge declaration. Replace it with the shared preload type during integration. -export function getLocalAI(): LocalAIAPI | undefined { - return (window as Window & { localAI?: LocalAIAPI }).localAI; -} diff --git a/packages/app/src/renderer/libs/local-ai.ts b/packages/app/src/renderer/libs/local-ai.ts new file mode 100644 index 00000000..e5df7643 --- /dev/null +++ b/packages/app/src/renderer/libs/local-ai.ts @@ -0,0 +1,22 @@ +import type { ILocalAIAPI, LocalAIProviderKind } from "@/shared/types/local-ai"; + +export type LocalAIProviderId = Extract< + LocalAIProviderKind, + "claude-code" | "codex-cli" +>; + +export const DEFAULT_LOCAL_AI_PROVIDER_ID: LocalAIProviderId = "claude-code"; +export const DEFAULT_LOCAL_AI_MODEL_ID = "default"; + +export const LOCAL_AI_PROVIDER_NAMES: Record = { + "claude-code": "Claude Code", + "codex-cli": "Codex", +}; + +export function isLocalAIProviderId(value: string): value is LocalAIProviderId { + return Object.prototype.hasOwnProperty.call(LOCAL_AI_PROVIDER_NAMES, value); +} + +export function getLocalAI(): ILocalAIAPI | undefined { + return window.localAI; +} diff --git a/packages/app/src/renderer/libs/stores/chat-history-store.ts b/packages/app/src/renderer/libs/stores/chat-history-store.ts index e053470a..78197679 100644 --- a/packages/app/src/renderer/libs/stores/chat-history-store.ts +++ b/packages/app/src/renderer/libs/stores/chat-history-store.ts @@ -168,13 +168,17 @@ export function useChatHistoryStore() { * Legacy hook for backward compatibility * Used in ChatProvider */ -export function useChatHistory(setMessages: (messages: Message[]) => void) { +export function useChatHistory( + setMessages: (messages: Message[]) => void, + streamActive = false, +) { const conversations = useConversations(); const { currentConversationId, setCurrentConversation } = useSelectionStore(); const messages = useMessages(currentConversationId); // Update AI SDK messages when messages change useEffect(() => { + if (streamActive) return; if (messages && messages.length > 0) { // Filter out "tool" role as AI SDK doesn't support it directly const aiMessages: Message[] = messages @@ -190,7 +194,7 @@ export function useChatHistory(setMessages: (messages: Message[]) => void) { })); setMessages(aiMessages); } - }, [messages, setMessages]); + }, [messages, setMessages, streamActive]); // Convert format const chatHistory: ConversationData[] = (conversations || []).map((conv) => ({ diff --git a/packages/app/src/renderer/libs/stores/chat-store.tsx b/packages/app/src/renderer/libs/stores/chat-store.tsx index 27ae1276..5fff3a3d 100644 --- a/packages/app/src/renderer/libs/stores/chat-store.tsx +++ b/packages/app/src/renderer/libs/stores/chat-store.tsx @@ -154,11 +154,12 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ }, [chatAPI.messages.length, viewMode]); // Integrate the useChatHistory hook - useChatHistory(chatAPI.setMessages); + useChatHistory(chatAPI.setMessages, chatAPI.isLoading); // Track previous loading state to detect when message completes const prevLoadingRef = useRef(false); const currentConversationIdRef = useRef(currentConversationId); + const activeConversationIdRef = useRef(null); const selectedAgentIdRef = useRef(selectedAgent?.id); // Keep refs in sync @@ -180,37 +181,18 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ if (wasLoading && !isLoading && chatAPI.messages.length > 0) { const saveMessages = async () => { try { - let convId = currentConversationIdRef.current; + const convId = activeConversationIdRef.current; const messages = chatAPI.messages; - // Check if conversation exists in database - const existingConv = convId - ? await db.conversations.get(convId) - : null; - - // Create new conversation if none exists or if current ID is not in database - if (!existingConv) { - const firstUserMessage = messages.find( - (m: Message) => m.role === "user", + if (!convId || !(await db.conversations.get(convId))) { + console.error( + "Refusing to save a local AI stream without its originating conversation.", ); - const title = - typeof firstUserMessage?.content === "string" - ? firstUserMessage.content.slice(0, 50) - : "New Conversation"; - - convId = await createConversation({ - title, - agentId: selectedAgentIdRef.current ?? null, - modelId: null, - }); - setCurrentConversationId(convId); - currentConversationIdRef.current = convId; - console.log("📝 Created new conversation:", convId); + return; } - // Save all messages to the conversation (convId is guaranteed to be set now) await updateMessages( - convId!, + convId, messages.map((m: Message) => ({ id: m.id, role: m.role as "user" | "assistant" | "system" | "tool", @@ -229,6 +211,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ })), ); console.log("💾 Saved messages to conversation:", convId); + activeConversationIdRef.current = null; } catch (error) { console.error("Failed to save conversation:", error); } @@ -393,14 +376,6 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ if (!messageText && !selectedContent && filesToSend.length === 0) return; - // Generate conversation ID if this is the first message - let conversationIdToUse = currentConversationId; - - if (!conversationIdToUse) { - conversationIdToUse = `conv_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; - setCurrentConversationId(conversationIdToUse); - } - // Handle selected content (text only) if (selectedContent) { // Handle text content @@ -439,6 +414,22 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ const { selectedConfigId, selectedModelId } = useModelConfigStore.getState(); const providerId = resolveLocalAIProviderId(selectedConfigId); + let conversationIdToUse = currentConversationId; + + if ( + !conversationIdToUse || + !(await db.conversations.get(conversationIdToUse)) + ) { + conversationIdToUse = await createConversation({ + title: messageText.slice(0, 50) || "New Conversation", + agentId: selectedAgent?.id ?? null, + modelId: `${providerId}:${selectedModelId}`, + }); + setCurrentConversationId(conversationIdToUse); + currentConversationIdRef.current = conversationIdToUse; + } + + activeConversationIdRef.current = conversationIdToUse; await chatAPI.send(message, { providerId, @@ -504,6 +495,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ const { selectedConfigId, selectedModelId } = useModelConfigStore.getState(); + activeConversationIdRef.current = currentConversationId; void chatAPI.resend(updatedMessages, { providerId: resolveLocalAIProviderId(selectedConfigId), model: selectedModelId, @@ -515,7 +507,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ : undefined, }); }, - [chatAPI, selectedAgent], + [chatAPI, currentConversationId, selectedAgent], ); const regenerateMessage = useCallback(() => { @@ -526,6 +518,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ : chatAPI.messages; const { selectedConfigId, selectedModelId } = useModelConfigStore.getState(); + activeConversationIdRef.current = currentConversationId; void chatAPI.resend(nextMessages, { providerId: resolveLocalAIProviderId(selectedConfigId), model: selectedModelId, @@ -537,7 +530,7 @@ export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({ : undefined, }); } - }, [chatAPI, selectedAgent]); + }, [chatAPI, currentConversationId, selectedAgent]); const resetChat = useCallback(() => { console.log("🔄 Frontend: resetChat called, clearing conversation ID"); diff --git a/packages/app/src/renderer/libs/stores/model-config-store.ts b/packages/app/src/renderer/libs/stores/model-config-store.ts index 87a8d24c..d0bf23f1 100644 --- a/packages/app/src/renderer/libs/stores/model-config-store.ts +++ b/packages/app/src/renderer/libs/stores/model-config-store.ts @@ -22,7 +22,7 @@ import { LOCAL_AI_PROVIDER_NAMES, isLocalAIProviderId, type LocalAIProviderId, -} from "../local-ai-contract"; +} from "../local-ai"; // Re-export for backward compatibility export type { ModelConfig }; @@ -99,28 +99,16 @@ export function useModelConfigStore() { getAvailableModels: (): GroupedModel[] => { const models: GroupedModel[] = []; - Object.entries(LOCAL_AI_PROVIDER_NAMES) - .filter(([id]) => id !== "openai-compatible") - .forEach(([configId, configName]) => { + Object.entries(LOCAL_AI_PROVIDER_NAMES).forEach( + ([configId, configName]) => { models.push({ configId, configName, modelId: DEFAULT_LOCAL_AI_MODEL_ID, isRemote: false, }); - }); - - // User custom models - (modelConfigs || []).forEach((config) => { - config.models.forEach((modelId) => { - models.push({ - configId: config.id, - configName: config.name, - modelId, - isRemote: false, - }); - }); - }); + }, + ); return models; }, @@ -179,7 +167,9 @@ useModelConfigStore.getState = () => { }; export function resolveLocalAIProviderId(configId: string): LocalAIProviderId { - return isLocalAIProviderId(configId) ? configId : "openai-compatible"; + return isLocalAIProviderId(configId) + ? configId + : DEFAULT_LOCAL_AI_PROVIDER_ID; } // ==================== Standalone Actions ==================== From 21daa2c6891ea98a15381c0b8b0e333b86f123e9 Mon Sep 17 00:00:00 2001 From: NarwhalChen Date: Thu, 30 Jul 2026 15:11:49 +0800 Subject: [PATCH 07/29] refactor(app): remove cloud service codepaths --- .../components/account/account-section.tsx | 58 -- .../src/renderer/components/account/index.ts | 3 - .../components/account/profile-section.tsx | 129 ----- .../account/usage-stats-section.tsx | 71 --- .../renderer/components/auth/auth-modal.tsx | 38 -- .../components/auth/auth-setup-modal.tsx | 113 ---- .../components/auth/model-config-form.tsx | 481 ----------------- .../renderer/components/auth/user-button.tsx | 221 -------- .../src/renderer/components/login-form.tsx | 127 ----- .../components/settings/ai-model-section.tsx | 264 --------- .../components/settings/marketplace-tab.tsx | 373 ------------- .../components/settings/pages/app-page.tsx | 500 ------------------ .../src/renderer/components/user-profile.tsx | 57 -- .../app/src/renderer/layouts/base-layout.tsx | 17 +- packages/app/src/renderer/libs/auth-client.ts | 35 -- packages/app/src/renderer/libs/env.ts | 10 - .../app/src/renderer/libs/hooks/auth-hooks.ts | 22 - .../libs/hooks/use-account-profile.ts | 124 ----- .../renderer/libs/hooks/use-speech-to-text.ts | 489 ----------------- .../renderer/libs/hooks/use-usage-stats.ts | 69 --- .../src/renderer/libs/stores/auth-store.ts | 130 ----- .../renderer/libs/stores/settings-store.ts | 3 - .../app/src/renderer/libs/utils/settings.ts | 1 - .../src/renderer/routes/auth/$pathname.tsx | 10 - .../app/src/renderer/routes/routeTree.gen.ts | 31 +- packages/app/src/renderer/types/auth.ts | 29 - .../src/shared/constants/officialModels.ts | 16 - packages/app/src/shared/types/settings.ts | 7 - 28 files changed, 7 insertions(+), 3421 deletions(-) delete mode 100644 packages/app/src/renderer/components/account/account-section.tsx delete mode 100644 packages/app/src/renderer/components/account/index.ts delete mode 100644 packages/app/src/renderer/components/account/profile-section.tsx delete mode 100644 packages/app/src/renderer/components/account/usage-stats-section.tsx delete mode 100644 packages/app/src/renderer/components/auth/auth-modal.tsx delete mode 100644 packages/app/src/renderer/components/auth/auth-setup-modal.tsx delete mode 100644 packages/app/src/renderer/components/auth/model-config-form.tsx delete mode 100644 packages/app/src/renderer/components/auth/user-button.tsx delete mode 100644 packages/app/src/renderer/components/login-form.tsx delete mode 100644 packages/app/src/renderer/components/settings/ai-model-section.tsx delete mode 100644 packages/app/src/renderer/components/settings/marketplace-tab.tsx delete mode 100644 packages/app/src/renderer/components/settings/pages/app-page.tsx delete mode 100644 packages/app/src/renderer/components/user-profile.tsx delete mode 100644 packages/app/src/renderer/libs/auth-client.ts delete mode 100644 packages/app/src/renderer/libs/env.ts delete mode 100644 packages/app/src/renderer/libs/hooks/auth-hooks.ts delete mode 100644 packages/app/src/renderer/libs/hooks/use-account-profile.ts delete mode 100644 packages/app/src/renderer/libs/hooks/use-speech-to-text.ts delete mode 100644 packages/app/src/renderer/libs/hooks/use-usage-stats.ts delete mode 100644 packages/app/src/renderer/libs/stores/auth-store.ts delete mode 100644 packages/app/src/renderer/routes/auth/$pathname.tsx delete mode 100644 packages/app/src/renderer/types/auth.ts delete mode 100644 packages/app/src/shared/constants/officialModels.ts 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/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. -

-
-