diff --git a/apps/desktop/e2e/command.spec.ts b/apps/desktop/e2e/command.spec.ts index 013b2b3..925f60f 100644 --- a/apps/desktop/e2e/command.spec.ts +++ b/apps/desktop/e2e/command.spec.ts @@ -137,6 +137,27 @@ test("a command crosses out a list item on the page, is saved, and the voice com } }); +test("an exact empty-section command skips the model and still supports one-step undo", async () => { + test.setTimeout(60_000); + const { app, page } = await launch([]); + try { + await modeButton(page, "Command").click(); + await say(page, 'create an empty section named "Failure modes"'); + await expect( + page.locator(".page-editor h2").filter({ hasText: "Failure modes" }), + ).toBeVisible(); + await expect(page.getByText("Created empty section “Failure modes”.")).toBeVisible(); + expect(mock?.requests).toHaveLength(0); + + await page.getByRole("button", { name: "Undo" }).click(); + await expect(page.locator(".page-editor h2").filter({ hasText: "Failure modes" })).toHaveCount( + 0, + ); + } finally { + await app.close(); + } +}); + test("a command that throws something away waits for a yes: by button, or by voice", async () => { test.setTimeout(90_000); const remove = json({ @@ -196,6 +217,66 @@ test("an unclear command asks which one, and the answer is sent back to the mode } }); +test("a long clarification with four long choices stays readable in a narrow window", async () => { + test.setTimeout(90_000); + const ask = json({ + kind: "clarify", + question: + "Which of these similarly named connections should be removed from the request-processing diagram?", + options: [ + { label: "The web server connection to the primary Redis cache", ref: "e2" }, + { label: "The web server connection to the fallback Redis cache", ref: "e3" }, + { label: "The API gateway connection to the primary application server", ref: "e4" }, + { label: "The API gateway connection to the secondary application server", ref: "e5" }, + ], + }); + const { app, page } = await launch([reply(ask)]); + try { + await modeButton(page, "Command").click(); + await say(page, "remove the connection"); + const card = page.locator('.dictation-card[data-card="clarify"]'); + await expect(card).toBeVisible({ timeout: 20_000 }); + await app.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0]; + win?.unmaximize(); + win?.setSize(820, 760); + }); + await expect.poll(() => page.evaluate(() => window.innerWidth)).toBeLessThanOrEqual(820); + await expect( + card.getByRole("group", { name: "Choose an answer" }).getByRole("button"), + ).toHaveCount(5); + await expect(card).toContainText("secondary application server"); + + const layout = await card.evaluate((element) => { + const text = element.querySelector(".card-text")?.getBoundingClientRect(); + const actions = element.querySelector(".card-actions")?.getBoundingClientRect(); + const buttons = [...element.querySelectorAll("button")].map((button) => { + const box = button.getBoundingClientRect(); + return { left: box.left, right: box.right, top: box.top, bottom: box.bottom }; + }); + return { + contained: element.scrollWidth <= element.clientWidth, + separated: Boolean(text && actions && text.bottom <= actions.top), + buttons, + }; + }); + expect(layout.contained).toBe(true); + expect(layout.separated).toBe(true); + for (let i = 0; i < layout.buttons.length; i++) { + for (let j = i + 1; j < layout.buttons.length; j++) { + const a = layout.buttons[i]; + const b = layout.buttons[j]; + if (!a || !b) continue; + const overlaps = + a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; + expect(overlaps).toBe(false); + } + } + } finally { + await app.close(); + } +}); + test("'Lucid, ...' in Input mode and holding the right Option key both send one command", async () => { test.setTimeout(90_000); const { app, page } = await launch([ diff --git a/apps/desktop/e2e/settings.spec.ts b/apps/desktop/e2e/settings.spec.ts index cbcd0d1..7bef98d 100644 --- a/apps/desktop/e2e/settings.spec.ts +++ b/apps/desktop/e2e/settings.spec.ts @@ -39,7 +39,7 @@ const dialog = (page: Page) => page.getByRole("dialog", { name: "Settings" }); /** A field of the dialog by the start of its label (the labels carry a help line too). */ const field = (page: Page, label: RegExp) => dialog(page).getByRole("textbox", { name: label }); const MODEL = /^Model\b/; -const ADDRESS = /^Address/; +const ADDRESS = /^Base URL/; const NAME = /^Your name/; const FOOTER = /^Footer/; const WORDS = /^One per line/; @@ -110,6 +110,77 @@ test("Settings opens from the sidebar and with ⌘, , closes with Esc, and shows } }); +test("provider profiles expose only relevant controls and keep cloud use explicit", async () => { + test.setTimeout(60_000); + const { app, page } = await launch(); + try { + await page.getByRole("button", { name: /Settings/ }).click(); + const box = dialog(page); + await expect(box.getByRole("group", { name: "AI providers" })).toContainText( + "never switches providers automatically", + ); + await box.getByLabel("Provider to add").selectOption("openai"); + await box.getByRole("button", { name: "Add", exact: true }).click(); + await expect(field(page, /^API key/)).toBeVisible(); + await expect(field(page, ADDRESS)).toBeDisabled(); + await expect(box).toContainText("subscriptions do not include API access"); + await expect(box).toContainText("selected note context"); + await box.getByRole("button", { name: "Set default" }).click(); + await box.getByRole("button", { name: "Save", exact: true }).click(); + await expect(box).toBeHidden(); + + await page.keyboard.press("Meta+,"); + await expect(box).toContainText("OpenAI API · default"); + } finally { + await app.close(); + } +}); + +test("selecting Ollama shows structured-output guidance and hides LM Studio status", async () => { + test.setTimeout(60_000); + const { app, page } = await launch(); + try { + await page.getByRole("complementary").getByRole("button", { name: "New page" }).click(); + const pill = page.getByRole("group", { name: "Model status" }); + await expect(pill).toHaveAttribute("data-state", "ready"); + + await page.getByRole("button", { name: /Settings/ }).click(); + const box = dialog(page); + await box.getByLabel("Provider to add").selectOption("ollama"); + await box.getByRole("button", { name: "Add", exact: true }).click(); + await expect(box).toContainText("Command Mode requires a current Ollama version"); + await field(page, MODEL).fill("qwen3:8b"); + await box.getByRole("button", { name: "Set default" }).click(); + await box.getByRole("button", { name: "Save", exact: true }).click(); + await expect(box).toBeHidden(); + await expect(pill).toBeHidden(); + } finally { + await app.close(); + } +}); + +test("hosted compatibility presets explain capability limits and custom URLs stay editable", async () => { + test.setTimeout(60_000); + const { app, page } = await launch(); + try { + await page.getByRole("button", { name: /Settings/ }).click(); + const box = dialog(page); + await box.getByLabel("Provider to add").selectOption("ollama-cloud"); + await box.getByRole("button", { name: "Add", exact: true }).click(); + await expect(box).toContainText("Ollama Cloud currently lacks JSON Schema output"); + await expect(field(page, ADDRESS)).toBeDisabled(); + + await box.getByLabel("Provider to add").selectOption("custom"); + await box.getByRole("button", { name: "Add", exact: true }).click(); + await expect(field(page, ADDRESS)).toBeEnabled(); + await field(page, ADDRESS).fill("https://llm.example.test/v1"); + await expect(box).toContainText("you can enter a model ID manually"); + await field(page, MODEL).fill("my-model"); + } finally { + await app.close(); + } +}); + test("saved settings apply at once: the model asked, the PDF footer, the words; and they are in the settings file", async () => { test.setTimeout(120_000); const notes = "## Caching\n- Redis keeps hot keys in memory."; diff --git a/apps/desktop/electron/ai/credentials.test.ts b/apps/desktop/electron/ai/credentials.test.ts new file mode 100644 index 0000000..d86ad89 --- /dev/null +++ b/apps/desktop/electron/ai/credentials.test.ts @@ -0,0 +1,65 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { CredentialVault, type SecretEncryption } from "./credentials"; + +const dirs: string[] = []; +afterEach(async () => Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true })))); + +const encryption = (available = true): SecretEncryption => ({ + secureAvailable: () => available, + encrypt: (value) => Buffer.from(`encrypted:${value}`, "utf8"), + decrypt: (value) => value.toString("utf8").replace(/^encrypted:/, ""), +}); + +async function vault(available = true) { + const dir = await mkdtemp(join(tmpdir(), "lucid-credentials-")); + dirs.push(dir); + return { dir, vault: new CredentialVault(dir, encryption(available)) }; +} + +describe("CredentialVault", () => { + it("saves, reads, and deletes an encrypted persistent secret without writing plaintext", async () => { + const { dir, vault: first } = await vault(); + const status = await first.save("openai", "sk-private-value", true); + expect(status).toEqual({ present: true, storage: "secure", securePersistenceAvailable: true }); + expect(await first.read("openai")).toBe("sk-private-value"); + expect(await readFile(join(dir, "credentials.json"), "utf8")).not.toContain("sk-private-value"); + + const reopened = new CredentialVault(dir, encryption()); + expect(await reopened.read("openai")).toBe("sk-private-value"); + await reopened.delete("openai"); + expect(await reopened.status("openai")).toMatchObject({ present: false, storage: "none" }); + }); + + it("falls back only to memory when secure persistence is unavailable", async () => { + const { dir, vault: current } = await vault(false); + expect(await current.save("anthropic", "session-secret", true)).toEqual({ + present: true, + storage: "session", + securePersistenceAvailable: false, + }); + expect(await current.read("anthropic")).toBe("session-secret"); + await expect(readFile(join(dir, "credentials.json"), "utf8")).rejects.toThrow(); + expect(await new CredentialVault(dir, encryption(false)).read("anthropic")).toBeUndefined(); + }); + + it("uses session storage when encryption fails temporarily", async () => { + const dir = await mkdtemp(join(tmpdir(), "lucid-credentials-")); + dirs.push(dir); + const failing: SecretEncryption = { + secureAvailable: () => true, + encrypt: () => { + throw new Error("keychain unavailable"); + }, + decrypt: () => "", + }; + const current = new CredentialVault(dir, failing); + expect(await current.save("openrouter", "temporary-secret", true)).toMatchObject({ + present: true, + storage: "session", + }); + expect(await current.read("openrouter")).toBe("temporary-secret"); + }); +}); diff --git a/apps/desktop/electron/ai/credentials.ts b/apps/desktop/electron/ai/credentials.ts new file mode 100644 index 0000000..77abca5 --- /dev/null +++ b/apps/desktop/electron/ai/credentials.ts @@ -0,0 +1,113 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +export interface SecretEncryption { + secureAvailable(): boolean; + encrypt(value: string): Buffer; + decrypt(value: Buffer): string; +} + +export type CredentialStorage = "secure" | "session"; +export interface CredentialStatus { + present: boolean; + storage: CredentialStorage | "none"; + securePersistenceAvailable: boolean; +} + +interface StoredSecrets { + version: 1; + secrets: Record; +} + +const credentialId = (value: string): string => { + if (!/^[a-z0-9][a-z0-9._-]{0,79}$/i.test(value)) throw new Error("invalid credential id"); + return value; +}; +const without = (values: Record, id: string): Record => + Object.fromEntries(Object.entries(values).filter(([key]) => key !== id)); + +/** Main-process-only credentials. Disk values are encrypted bytes encoded as base64, never plaintext. */ +export class CredentialVault { + private readonly session = new Map(); + private readonly file: string; + + constructor( + directory: string, + private readonly encryption: SecretEncryption, + ) { + this.file = join(directory, "credentials.json"); + } + + private async stored(): Promise { + try { + const parsed = JSON.parse(await readFile(this.file, "utf8")) as Partial; + return parsed.version === 1 && parsed.secrets && typeof parsed.secrets === "object" + ? { version: 1, secrets: { ...parsed.secrets } } + : { version: 1, secrets: {} }; + } catch { + return { version: 1, secrets: {} }; + } + } + + private async write(stored: StoredSecrets): Promise { + await mkdir(dirname(this.file), { recursive: true }); + const temporary = `${this.file}.${String(process.pid)}.tmp`; + await writeFile(temporary, `${JSON.stringify(stored, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await rename(temporary, this.file); + } + + async save(idValue: string, secret: string, persist: boolean): Promise { + const id = credentialId(idValue); + if (!secret || secret.length > 20_000) throw new Error("invalid credential"); + const stored = await this.stored(); + if (persist && this.encryption.secureAvailable()) { + try { + stored.secrets[id] = this.encryption.encrypt(secret).toString("base64"); + await this.write(stored); + this.session.delete(id); + return await this.status(id); + } catch { + // Temporary keychain failures fall through to memory. Never write the secret itself. + } + } + if (id in stored.secrets) { + stored.secrets = without(stored.secrets, id); + await this.write(stored); + } + this.session.set(id, secret); + return this.status(id); + } + + /** Deliberately not exposed through preload or IPC. */ + async read(idValue: string): Promise { + const id = credentialId(idValue); + const inSession = this.session.get(id); + if (inSession !== undefined) return inSession; + if (!this.encryption.secureAvailable()) return undefined; + const encrypted = (await this.stored()).secrets[id]; + if (!encrypted) return undefined; + return this.encryption.decrypt(Buffer.from(encrypted, "base64")); + } + + async delete(idValue: string): Promise { + const id = credentialId(idValue); + this.session.delete(id); + const stored = await this.stored(); + if (id in stored.secrets) { + stored.secrets = without(stored.secrets, id); + await this.write(stored); + } + } + + async status(idValue: string): Promise { + const id = credentialId(idValue); + const securePersistenceAvailable = this.encryption.secureAvailable(); + if (this.session.has(id)) + return { present: true, storage: "session", securePersistenceAvailable }; + const secure = securePersistenceAvailable && id in (await this.stored()).secrets; + return { present: secure, storage: secure ? "secure" : "none", securePersistenceAvailable }; + } +} diff --git a/apps/desktop/electron/ai/providers.test.ts b/apps/desktop/electron/ai/providers.test.ts new file mode 100644 index 0000000..b48256f --- /dev/null +++ b/apps/desktop/electron/ai/providers.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ProviderProfile } from "../ipc/types"; +import { inspectProvider } from "./providers"; + +const profile = (change: Partial = {}): ProviderProfile => ({ + id: "local", + name: "Local", + kind: "lmstudio", + baseUrl: "http://127.0.0.1:1234", + model: "local/model", + ...change, +}); + +describe("inspectProvider", () => { + it("reads OpenAI-shaped model lists from the provider's models endpoint", async () => { + const request = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: [{ id: "z-model" }, { id: "a-model" }] }), { + status: 200, + }), + ); + const result = await inspectProvider(profile(), undefined, request); + expect(result).toEqual({ + ok: true, + message: "Connected to LM Studio. 2 models available.", + models: ["a-model", "z-model"], + }); + expect(request.mock.calls[0]?.[0]).toEqual(new URL("http://127.0.0.1:1234/v1/models")); + }); + + it("reads Ollama-shaped model lists from the native tags endpoint", async () => { + const request = vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ models: [{ name: "llama3.2" }, { name: "deepseek:cloud" }] }), + { status: 200 }, + ), + ); + const result = await inspectProvider( + profile({ kind: "ollama", baseUrl: "http://localhost:11434" }), + undefined, + request, + ); + expect(result.models).toEqual(["llama3.2"]); + expect(request.mock.calls[0]?.[0]).toEqual(new URL("http://localhost:11434/api/tags")); + }); + + it("gives actionable Ollama setup guidance", async () => { + const ollama = profile({ kind: "ollama", baseUrl: "http://localhost:11434" }); + const empty = await inspectProvider( + ollama, + undefined, + vi.fn().mockResolvedValue(Response.json({ models: [] })), + ); + expect(empty.message).toContain("ollama pull"); + + const offline = await inspectProvider( + ollama, + undefined, + vi.fn().mockRejectedValue(new Error("connection refused")), + ); + expect(offline.message).toContain("ollama serve"); + }); + + it("requires credentials before contacting a cloud provider", async () => { + const request = vi.fn(); + const result = await inspectProvider( + profile({ + kind: "openai", + baseUrl: "https://api.openai.com/v1", + model: "gpt-4.1-mini", + }), + undefined, + request, + ); + expect(result).toEqual({ + ok: false, + message: "Add an API key for OpenAI API, then try again.", + }); + expect(request).not.toHaveBeenCalled(); + }); + + it("uses provider-specific authentication without returning the key", async () => { + const request = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ data: [] }), { status: 200 })); + const result = await inspectProvider( + profile({ + kind: "anthropic", + baseUrl: "https://api.anthropic.com/v1", + model: "claude-sonnet-4-5", + }), + "top-secret", + request, + ); + expect(request.mock.calls[0]?.[1]?.headers).toMatchObject({ + "x-api-key": "top-secret", + "anthropic-version": "2023-06-01", + }); + expect(JSON.stringify(result)).not.toContain("top-secret"); + }); + + it("redacts credentials from actionable network failures", async () => { + const request = vi + .fn() + .mockRejectedValue(new Error("Bearer top-secret was rejected")); + const result = await inspectProvider( + profile({ + kind: "custom", + baseUrl: "https://models.example.test/v1", + model: "custom", + }), + "top-secret", + request, + ); + expect(result.ok).toBe(false); + expect(result.message).toContain("Could not connect"); + expect(result.message).not.toContain("top-secret"); + }); + + it("uses Ollama Cloud's native model list with explicit bearer authentication", async () => { + const request = vi + .fn() + .mockResolvedValue(Response.json({ models: [{ model: "gemma4:31b" }] })); + const result = await inspectProvider( + profile({ kind: "ollama-cloud", baseUrl: "https://ollama.com/v1" }), + "cloud-secret", + request, + ); + expect(result.models).toEqual(["gemma4:31b"]); + expect(request.mock.calls[0]?.[0]).toEqual(new URL("https://ollama.com/api/tags")); + expect(request.mock.calls[0]?.[1]?.headers).toMatchObject({ + Authorization: "Bearer cloud-secret", + }); + }); + + it("explains that custom model listing is optional", async () => { + const result = await inspectProvider( + profile({ kind: "custom", baseUrl: "https://llm.example.test/v1" }), + undefined, + vi.fn().mockResolvedValue(new Response(null, { status: 404 })), + ); + expect(result).toMatchObject({ ok: false }); + expect(result.message).toContain("Enter its model ID manually"); + }); + + it("rejects unsafe custom addresses and cross-origin redirects before sending a key", async () => { + const request = vi.fn(); + const unsafe = await inspectProvider( + profile({ kind: "custom", baseUrl: "http://private.example.test/v1" }), + "secret", + request, + ); + expect(unsafe.ok).toBe(false); + expect(request).not.toHaveBeenCalled(); + + const redirect = await inspectProvider( + profile({ kind: "custom", baseUrl: "https://llm.example.test/v1" }), + "secret", + vi.fn().mockResolvedValue( + new Response(null, { + status: 302, + headers: { Location: "https://other.example.test/models" }, + }), + ), + ); + expect(redirect.ok).toBe(false); + expect(redirect.message).not.toContain("secret"); + }); +}); diff --git a/apps/desktop/electron/ai/providers.ts b/apps/desktop/electron/ai/providers.ts new file mode 100644 index 0000000..91db6c3 --- /dev/null +++ b/apps/desktop/electron/ai/providers.ts @@ -0,0 +1,100 @@ +import { + AI_PROVIDER_CATALOG, + type ProviderConnectionResult, + type ProviderProfile, +} from "../ipc/types"; +import { redactSensitive, secureProviderFetch } from "./security"; + +const providerFor = (profile: ProviderProfile) => + AI_PROVIDER_CATALOG.find((provider) => provider.kind === profile.kind); + +const modelsUrl = (profile: ProviderProfile): string => { + const base = profile.baseUrl.replace(/\/+$/, ""); + if (profile.kind === "lmstudio") return `${base}/v1/models`; + if (profile.kind === "ollama") return `${base}/api/tags`; + if (profile.kind === "ollama-cloud") return `${new URL(base).origin}/api/tags`; + return `${base}/models`; +}; + +const headersFor = (profile: ProviderProfile, secret?: string): Record => { + if (!secret) return { Accept: "application/json" }; + if (profile.kind === "anthropic") + return { + Accept: "application/json", + "x-api-key": secret, + "anthropic-version": "2023-06-01", + }; + return { Accept: "application/json", Authorization: `Bearer ${secret}` }; +}; + +const modelIds = (value: unknown, localModelsOnly = false): string[] => { + if (!value || typeof value !== "object") return []; + const body = value as { data?: unknown; models?: unknown }; + const entries = Array.isArray(body.data) + ? body.data + : Array.isArray(body.models) + ? body.models + : []; + return [ + ...new Set( + entries + .map((entry) => { + if (!entry || typeof entry !== "object") return undefined; + const item = entry as { id?: unknown; name?: unknown; model?: unknown }; + const id = item.id ?? item.name ?? item.model; + return typeof id === "string" && id.length <= 240 ? id : undefined; + }) + .filter( + (id): id is string => id !== undefined && (!localModelsOnly || !/:cloud$/i.test(id)), + ), + ), + ] + .sort((a, b) => a.localeCompare(b)) + .slice(0, 1000); +}; + +/** Check one persisted profile without ever returning its credential or request headers. */ +export async function inspectProvider( + profile: ProviderProfile, + secret?: string, + doFetch: typeof fetch = fetch, +): Promise { + const provider = providerFor(profile); + if (!provider) return { ok: false, message: "That provider type is not supported." }; + if (provider.credential === "required" && !secret) + return { ok: false, message: `Add an API key for ${provider.label}, then try again.` }; + try { + const response = await secureProviderFetch( + modelsUrl(profile), + { headers: headersFor(profile, secret) }, + doFetch, + ); + if (!response.ok) + return { + ok: false, + message: + profile.kind === "custom" && response.status === 404 + ? "This endpoint has no model-list API. Enter its model ID manually; Test connection cannot verify it until a request runs." + : `${provider.label} returned HTTP ${String(response.status)}. Check the address, API key, and account access.`, + }; + const models = modelIds(await response.json(), profile.kind === "ollama"); + return { + ok: true, + message: + models.length > 0 + ? `Connected to ${provider.label}. ${String(models.length)} model${models.length === 1 ? "" : "s"} available.` + : profile.kind === "ollama" + ? "Connected to Ollama, but no local models are installed. Run “ollama pull ”, then fetch models again." + : `Connected to ${provider.label}, but it reported no models.`, + models, + }; + } catch (error) { + return { + ok: false, + message: + profile.kind === "ollama" + ? `Could not connect to Ollama. Start it with “ollama serve”, then try again. ${redactSensitive(error)}` + : `Could not connect to ${provider.label}: ${redactSensitive(error, secret ? [secret] : [])}`, + }; + } +} diff --git a/apps/desktop/electron/ai/runtime.test.ts b/apps/desktop/electron/ai/runtime.test.ts new file mode 100644 index 0000000..ab31c3b --- /dev/null +++ b/apps/desktop/electron/ai/runtime.test.ts @@ -0,0 +1,182 @@ +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { createRuntimeResolver } from "./runtime"; + +describe("createRuntimeResolver", () => { + it("builds the LM Studio adapter in main from a migrated settings file", async () => { + const dir = await mkdtemp(join(tmpdir(), "lucid-runtime-")); + await mkdir(join(dir, ".lucid")); + await writeFile( + join(dir, ".lucid", "settings.json"), + JSON.stringify({ llm: { baseUrl: "http://localhost:9876", model: "local/model" } }), + "utf8", + ); + const runtime = await createRuntimeResolver(dir, vi.fn(), undefined)("lmstudio"); + expect(runtime).toMatchObject({ + endpoint: "http://localhost:9876", + client: { provider: "lmstudio" }, + }); + }); + + it("keeps the environment override used by tests and development", async () => { + const dir = await mkdtemp(join(tmpdir(), "lucid-runtime-")); + const runtime = await createRuntimeResolver( + dir, + vi.fn(), + "http://127.0.0.1:4567/", + )("lmstudio"); + expect(runtime?.endpoint).toBe("http://127.0.0.1:4567"); + }); + + it("builds the native Ollama adapter without applying the LM Studio override", async () => { + const dir = await mkdtemp(join(tmpdir(), "lucid-runtime-")); + await mkdir(join(dir, ".lucid")); + await writeFile( + join(dir, ".lucid", "settings.json"), + JSON.stringify({ + ai: { + defaultProfileId: "ollama", + profiles: [ + { + id: "ollama", + name: "Ollama", + kind: "ollama", + baseUrl: "http://localhost:11434", + model: "qwen3:8b", + }, + ], + }, + }), + "utf8", + ); + const runtime = await createRuntimeResolver( + dir, + vi.fn(), + "http://127.0.0.1:4567", + )("ollama"); + expect(runtime).toMatchObject({ + endpoint: "http://localhost:11434", + client: { provider: "ollama" }, + }); + }); + + it("resolves cloud providers only when main can read the saved credential", async () => { + const dir = await mkdtemp(join(tmpdir(), "lucid-runtime-")); + await mkdir(join(dir, ".lucid")); + await writeFile( + join(dir, ".lucid", "settings.json"), + JSON.stringify({ + ai: { + defaultProfileId: "openai", + profiles: [ + { + id: "openai", + name: "OpenAI", + kind: "openai", + baseUrl: "https://api.openai.com/v1", + model: "gpt-5-mini", + }, + ], + }, + }), + "utf8", + ); + expect(await createRuntimeResolver(dir)("openai")).toBeUndefined(); + const read = vi.fn(() => Promise.resolve("main-only-secret")); + const runtime = await createRuntimeResolver( + dir, + vi.fn(), + undefined, + read, + )("openai"); + expect(runtime).toMatchObject({ + endpoint: "https://api.openai.com/v1", + client: { provider: "openai" }, + secrets: ["main-only-secret"], + }); + expect(read).toHaveBeenCalledWith("provider.openai"); + }); + + it("resolves Anthropic with only its saved main-process key", async () => { + const dir = await mkdtemp(join(tmpdir(), "lucid-runtime-")); + await mkdir(join(dir, ".lucid")); + await writeFile( + join(dir, ".lucid", "settings.json"), + JSON.stringify({ + ai: { + defaultProfileId: "claude", + profiles: [ + { + id: "claude", + name: "Claude", + kind: "anthropic", + baseUrl: "https://api.anthropic.com/v1", + model: "claude-sonnet-4-5", + }, + ], + }, + }), + "utf8", + ); + const read = vi.fn(() => Promise.resolve("main-only-secret")); + const runtime = await createRuntimeResolver( + dir, + vi.fn(), + undefined, + read, + )("claude"); + expect(runtime).toMatchObject({ + endpoint: "https://api.anthropic.com/v1", + client: { provider: "anthropic" }, + secrets: ["main-only-secret"], + }); + expect(read).toHaveBeenCalledWith("provider.claude"); + }); + + it("resolves hosted compatibility presets but never falls back to another provider", async () => { + const dir = await mkdtemp(join(tmpdir(), "lucid-runtime-")); + await mkdir(join(dir, ".lucid")); + await writeFile( + join(dir, ".lucid", "settings.json"), + JSON.stringify({ + ai: { + defaultProfileId: "router", + profiles: [ + { + id: "router", + name: "Router", + kind: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + model: "openai/gpt-4.1-mini", + }, + { + id: "cloud", + name: "Cloud", + kind: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + model: "gemma4:31b", + }, + { + id: "proxy", + name: "Proxy", + kind: "custom", + baseUrl: "https://llm.example.test/v1", + model: "custom/model", + }, + ], + }, + }), + "utf8", + ); + const read = vi.fn((id: string) => + Promise.resolve(id === "provider.router" ? "router-key" : undefined), + ); + const resolve = createRuntimeResolver(dir, vi.fn(), undefined, read); + expect(await resolve("router")).toMatchObject({ client: { provider: "openrouter" } }); + expect(await resolve("cloud")).toBeUndefined(); + expect(await resolve("proxy")).toMatchObject({ client: { provider: "custom" } }); + expect(await resolve("unknown")).toBeUndefined(); + }); +}); diff --git a/apps/desktop/electron/ai/runtime.ts b/apps/desktop/electron/ai/runtime.ts new file mode 100644 index 0000000..79dbf1d --- /dev/null +++ b/apps/desktop/electron/ai/runtime.ts @@ -0,0 +1,63 @@ +import { + AnthropicClient, + LmStudioClient, + OllamaClient, + OpenAiClient, + OpenAiCompatibleClient, +} from "@lucid/llm"; +import { loadSettings } from "../settings"; +import { providerCredentialId } from "../ipc/types"; +import type { AiRuntimeResolver } from "./service"; +import { secureProviderFetch } from "./security"; + +/** Resolve a persisted profile into a main-process adapter. Other providers are added incrementally. */ +export const createRuntimeResolver = ( + libraryDir: string, + doFetch: typeof fetch = fetch, + environmentUrl: string | undefined = process.env["LUCID_LLM_URL"], + readCredential: (id: string) => Promise = () => Promise.resolve(undefined), +): AiRuntimeResolver => { + return async (profileId) => { + const settings = await loadSettings(libraryDir); + const profile = settings.ai.profiles.find((candidate) => candidate.id === profileId); + if (!profile) return undefined; + const endpoint = ( + profile.kind === "lmstudio" ? (environmentUrl ?? profile.baseUrl) : profile.baseUrl + ).replace(/\/+$/, ""); + const providerFetch: typeof fetch = (input, init) => secureProviderFetch(input, init, doFetch); + const secret = + profile.kind !== "lmstudio" && profile.kind !== "ollama" + ? await readCredential(providerCredentialId(profile.id)) + : undefined; + if ( + profile.kind !== "lmstudio" && + profile.kind !== "ollama" && + profile.kind !== "custom" && + !secret + ) + return undefined; + return { + endpoint, + ...(secret ? { secrets: [secret] } : {}), + client: + profile.kind === "lmstudio" + ? new LmStudioClient({ baseUrl: endpoint, fetch: providerFetch }) + : profile.kind === "ollama" + ? new OllamaClient({ baseUrl: endpoint, fetch: providerFetch }) + : profile.kind === "openai" + ? new OpenAiClient({ apiKey: secret ?? "", baseUrl: endpoint, fetch: providerFetch }) + : profile.kind === "anthropic" + ? new AnthropicClient({ + apiKey: secret ?? "", + baseUrl: endpoint, + fetch: providerFetch, + }) + : new OpenAiCompatibleClient({ + kind: profile.kind, + ...(secret ? { apiKey: secret } : {}), + baseUrl: endpoint, + fetch: providerFetch, + }), + }; + }; +}; diff --git a/apps/desktop/electron/ai/security.test.ts b/apps/desktop/electron/ai/security.test.ts new file mode 100644 index 0000000..af24087 --- /dev/null +++ b/apps/desktop/electron/ai/security.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + redactSensitive, + secureProviderFetch, + validateProviderEndpoint, + validateProviderRedirect, +} from "./security"; + +describe("provider endpoint security", () => { + it.each([ + "https://api.openai.com/v1", + "https://models.example.test/custom/v1", + "http://127.0.0.1:11434/v1", + "http://localhost:1234/v1", + "http://[::1]:8080/v1", + ])("allows %s", (value) => { + expect(validateProviderEndpoint(value).href).toContain("://"); + }); + + it.each([ + "http://example.com/v1", + "ftp://example.com/v1", + "file:///tmp/secret", + "https://key@example.com/v1", + "https://example.com/v1?api_key=secret", + "not a URL", + ])("refuses %s", (value) => { + expect(() => validateProviderEndpoint(value)).toThrow(); + }); + + it("allows only validated same-origin redirects", () => { + const from = validateProviderEndpoint("https://api.example.com/v1/chat"); + expect(validateProviderRedirect(from, "/v2/chat").href).toBe("https://api.example.com/v2/chat"); + expect(() => validateProviderRedirect(from, "https://elsewhere.example/v1")).toThrow( + /another origin/, + ); + expect(() => validateProviderRedirect(from, "http://api.example.com/v1")).toThrow(); + }); + + it("manually validates a redirect before following it", async () => { + const fetcher = (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + return Promise.resolve( + url.endsWith("/first") + ? new Response(null, { status: 307, headers: { location: "/second" } }) + : new Response("ok", { status: 200 }), + ); + }; + expect( + await (await secureProviderFetch("https://api.example.com/first", {}, fetcher)).text(), + ).toBe("ok"); + await expect( + secureProviderFetch("https://api.example.com/first", {}, async () => + Promise.resolve( + new Response(null, { + status: 302, + headers: { location: "https://attacker.example/steal" }, + }), + ), + ), + ).rejects.toThrow(/another origin/); + }); + + it("redacts bearer tokens, query credentials, and known secrets", () => { + const secret = "sk-super-secret"; + const text = redactSensitive( + new Error(`Bearer abc123 failed at https://x.test?a=1&api_key=query-secret: ${secret}`), + [secret], + ); + expect(text).not.toContain("abc123"); + expect(text).not.toContain("query-secret"); + expect(text).not.toContain(secret); + expect(text).toContain("[redacted]"); + }); +}); diff --git a/apps/desktop/electron/ai/security.ts b/apps/desktop/electron/ai/security.ts new file mode 100644 index 0000000..c0d768b --- /dev/null +++ b/apps/desktop/electron/ai/security.ts @@ -0,0 +1,63 @@ +const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]); + +/** Cloud endpoints require HTTPS; plaintext HTTP is allowed only on this machine. */ +export function validateProviderEndpoint(value: string): URL { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error("The provider address is not a valid URL."); + } + if (url.username || url.password) + throw new Error("Provider credentials must not be included in the address."); + if (url.hash || url.search) + throw new Error("The provider address cannot include a query or fragment."); + const secure = url.protocol === "https:"; + const local = url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname); + if (!secure && !local) + throw new Error("Provider addresses must use HTTPS; HTTP is allowed only on this computer."); + return url; +} + +/** Redirects are deliberately same-origin so an Authorization header can never cross origins. */ +export function validateProviderRedirect(from: URL, location: string): URL { + const target = validateProviderEndpoint(new URL(location, from).href); + if (target.origin !== from.origin) throw new Error("The provider redirected to another origin."); + return target; +} + +/** Fetch with redirects disabled at the platform layer and revalidated before following. */ +export async function secureProviderFetch( + input: string | URL | Request, + init: RequestInit = {}, + doFetch: typeof fetch = fetch, +): Promise { + let url = validateProviderEndpoint( + typeof input === "string" ? input : input instanceof URL ? input.href : input.url, + ); + let options = { ...init }; + for (let redirects = 0; redirects <= 5; redirects++) { + const response = await doFetch(url, { ...options, redirect: "manual" }); + if (response.status < 300 || response.status >= 400) return response; + const location = response.headers.get("location"); + if (!location) throw new Error("The provider returned a redirect without a destination."); + if (redirects === 5) throw new Error("The provider redirected too many times."); + url = validateProviderRedirect(url, location); + if (response.status === 303) { + const { body: _body, ...withoutBody } = options; + options = { ...withoutBody, method: "GET" }; + } + } + throw new Error("The provider redirected too many times."); +} + +/** A last line of defence for errors that may be shown or logged. */ +export function redactSensitive(value: unknown, secrets: readonly string[] = []): string { + let text = value instanceof Error ? value.message : String(value); + text = text.replace(/\b(Bearer|Basic)\s+[^\s,;]+/gi, "$1 [redacted]"); + text = text.replace(/([?&](?:api[_-]?key|token|access_token|key)=)[^&\s]+/gi, "$1[redacted]"); + for (const secret of [...secrets].filter(Boolean).sort((a, b) => b.length - a.length)) { + text = text.split(secret).join("[redacted]"); + } + return text.slice(0, 500); +} diff --git a/apps/desktop/electron/ai/service.test.ts b/apps/desktop/electron/ai/service.test.ts new file mode 100644 index 0000000..2f6aa94 --- /dev/null +++ b/apps/desktop/electron/ai/service.test.ts @@ -0,0 +1,85 @@ +import type { AiClient } from "@lucid/llm"; +import { describe, expect, it, vi } from "vitest"; +import type { AiEvent, AiStartRequest } from "../ipc/types"; +import { MainAiService } from "./service"; + +const request: AiStartRequest = { + id: 7, + providerId: "test", + model: "model", + messages: [{ role: "user", content: "hello" }], + lane: { temperature: 0, topP: 1, topK: 20, presencePenalty: 0, maxTokens: 100 }, +}; + +const client = (chat: AiClient["chat"]): AiClient => ({ + provider: "test", + capabilities: { + streaming: true, + cancellation: true, + structuredOutput: "native", + modelDiscovery: false, + usage: "estimated", + reasoningControl: false, + localModelManagement: false, + customBaseUrl: false, + }, + chat, + health: () => Promise.resolve({ reachable: true, models: [] }), +}); + +describe("MainAiService", () => { + it("streams deltas and a secret-free completion through narrow events", async () => { + const emit = vi.fn<(event: AiEvent) => void>(); + const service = new MainAiService(() => + Promise.resolve({ + endpoint: "https://api.example.test/v1", + client: client(({ onDelta }) => { + onDelta?.("hello"); + return Promise.resolve({ + content: "hello", + usage: { prompt: 2, completion: 1, reasoning: 0, source: "estimated" }, + characters: { request: 5, response: 5 }, + timings: { sentAt: 1, firstTokenAt: 2, doneAt: 3 }, + model: "model", + provider: "test", + repairAttempts: 0, + }); + }), + }), + ); + expect(await service.start(request, emit)).toEqual({ ok: true }); + await vi.waitFor(() => { + expect(emit).toHaveBeenCalledTimes(2); + }); + expect(emit.mock.calls[0]?.[0]).toEqual({ id: 7, type: "delta", text: "hello" }); + expect(emit.mock.calls[1]?.[0]).toMatchObject({ id: 7, type: "complete" }); + expect(emit.mock.calls[1]?.[0]).not.toHaveProperty("result.content"); + }); + + it("cancels through AbortSignal and redacts provider errors", async () => { + const emit = vi.fn<(event: AiEvent) => void>(); + const service = new MainAiService(() => + Promise.resolve({ + endpoint: "https://api.example.test/v1", + secrets: ["sk-secret"], + client: client( + ({ signal }) => + new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => { + reject(new Error("Bearer sk-secret cancelled")); + }); + }), + ), + }), + ); + expect(await service.start(request, emit)).toEqual({ ok: true }); + service.abort(request.id); + await vi.waitFor(() => { + expect(emit).toHaveBeenCalledOnce(); + }); + expect(emit.mock.calls[0]?.[0]).toMatchObject({ + type: "error", + message: "Bearer [redacted] cancelled", + }); + }); +}); diff --git a/apps/desktop/electron/ai/service.ts b/apps/desktop/electron/ai/service.ts new file mode 100644 index 0000000..9a31d36 --- /dev/null +++ b/apps/desktop/electron/ai/service.ts @@ -0,0 +1,79 @@ +import type { AiClient, ChatResult, Lane } from "@lucid/llm"; +import type { AiEvent, AiStartRequest, AiStartResult } from "../ipc/types"; +import { redactSensitive, validateProviderEndpoint } from "./security"; + +export interface AiRuntime { + client: AiClient; + /** Main-owned endpoint configuration, never supplied in an individual renderer request. */ + endpoint: string; + /** Known credentials are used only to scrub provider errors before they leave main. */ + secrets?: readonly string[] | undefined; +} +export type AiRuntimeResolver = (providerId: string) => Promise; + +const validRequest = (request: AiStartRequest): boolean => + Number.isSafeInteger(request.id) && + request.id >= 0 && + /^[a-z0-9][a-z0-9._-]{0,79}$/i.test(request.providerId) && + request.model.length > 0 && + request.model.length <= 240 && + request.messages.length > 0 && + request.messages.length <= 100 && + request.messages.every((message) => message.content.length <= 1_000_000); + +export class MainAiService { + private readonly running = new Map(); + + constructor(private readonly resolve: AiRuntimeResolver) {} + + async start(request: AiStartRequest, emit: (event: AiEvent) => void): Promise { + if (!validRequest(request)) return { ok: false, message: "Invalid AI request." }; + if (this.running.has(request.id)) + return { ok: false, message: "That AI request is already running." }; + const runtime = await this.resolve(request.providerId); + if (!runtime) return { ok: false, message: "That AI provider is not configured." }; + try { + validateProviderEndpoint(runtime.endpoint); + } catch (error) { + return { ok: false, message: redactSensitive(error, runtime.secrets) }; + } + const controller = new AbortController(); + this.running.set(request.id, controller); + const lane: Lane = { ...request.lane, model: request.model }; + void runtime.client + .chat({ + lane, + messages: request.messages, + jsonSchema: request.jsonSchema, + reasoning: request.reasoning, + signal: controller.signal, + onDelta: (text) => { + emit({ id: request.id, type: "delta", text }); + }, + }) + .then((result: ChatResult) => { + const { content: _content, ...safeResult } = result; + emit({ id: request.id, type: "complete", result: safeResult }); + }) + .catch((error: unknown) => { + const code = + error instanceof Error && + "code" in error && + typeof (error as { code?: unknown }).code === "string" + ? (error as { code: string }).code + : "ProviderError"; + emit({ + id: request.id, + type: "error", + code, + message: redactSensitive(error, runtime.secrets), + }); + }) + .finally(() => this.running.delete(request.id)); + return { ok: true }; + } + + abort(id: number): void { + this.running.get(id)?.abort(); + } +} diff --git a/apps/desktop/electron/ipc/ai.ts b/apps/desktop/electron/ipc/ai.ts new file mode 100644 index 0000000..94a5f02 --- /dev/null +++ b/apps/desktop/electron/ipc/ai.ts @@ -0,0 +1,61 @@ +import { ipcMain, safeStorage } from "electron"; +import { join } from "node:path"; +import { CredentialVault, type SecretEncryption } from "../ai/credentials"; +import { inspectProvider } from "../ai/providers"; +import { createRuntimeResolver } from "../ai/runtime"; +import { MainAiService, type AiRuntimeResolver } from "../ai/service"; +import { loadSettings } from "../settings"; +import { + IPC, + providerCredentialId, + type AiEvent, + type AiStartRequest, + type CredentialSaveRequest, + type ProviderConnectionResult, +} from "./types"; + +const encryption: SecretEncryption = { + secureAvailable: () => + safeStorage.isEncryptionAvailable() && + (process.platform !== "linux" || safeStorage.getSelectedStorageBackend() !== "basic_text"), + encrypt: (value) => safeStorage.encryptString(value), + decrypt: (value) => safeStorage.decryptString(value), +}; + +export function registerSecureAiIpc( + libraryDir: string, + resolve?: AiRuntimeResolver, +): CredentialVault { + const vault = new CredentialVault(join(libraryDir, ".lucid"), encryption); + const service = new MainAiService( + resolve ?? + createRuntimeResolver(libraryDir, fetch, process.env["LUCID_LLM_URL"], (id) => + vault.read(id), + ), + ); + ipcMain.handle(IPC.aiStart, async (event, request: AiStartRequest) => + service.start(request, (aiEvent: AiEvent) => { + if (!event.sender.isDestroyed()) event.sender.send(IPC.aiEvent, aiEvent); + }), + ); + ipcMain.handle(IPC.aiAbort, (_event, id: unknown) => { + if (typeof id === "number") service.abort(id); + }); + ipcMain.handle(IPC.credentialSave, (_event, request: CredentialSaveRequest) => + vault.save(request.id, request.secret, request.persist), + ); + ipcMain.handle(IPC.credentialDelete, (_event, id: string) => vault.delete(id)); + ipcMain.handle(IPC.credentialStatus, (_event, id: string) => vault.status(id)); + const inspect = async (id: unknown): Promise => { + if (typeof id !== "string") return { ok: false, message: "Choose a provider profile first." }; + const profile = (await loadSettings(libraryDir)).ai.profiles.find( + (candidate) => candidate.id === id, + ); + if (!profile) return { ok: false, message: "That provider profile no longer exists." }; + const secret = await vault.read(providerCredentialId(profile.id)); + return inspectProvider(profile, secret); + }; + ipcMain.handle(IPC.providerTest, (_event, id: unknown) => inspect(id)); + ipcMain.handle(IPC.providerModels, (_event, id: unknown) => inspect(id)); + return vault; +} diff --git a/apps/desktop/electron/ipc/index.ts b/apps/desktop/electron/ipc/index.ts index c326c87..ba90b5f 100644 --- a/apps/desktop/electron/ipc/index.ts +++ b/apps/desktop/electron/ipc/index.ts @@ -22,7 +22,8 @@ import { AppBackend } from "../appBackend"; import { RendererBridge } from "../agentBridge"; import { lmLoad, lmStatus, type LmDeps } from "../lmstudio"; import { LMSTUDIO_DOWNLOAD, defaultLmsPath, findLmStudio } from "../studio"; -import { checkLlmUrl, registerLlmIpc } from "./llm"; +import { checkLlmUrl } from "./llm"; +import { registerSecureAiIpc } from "./ai"; import { IPC, type NoteImportResult, @@ -91,7 +92,7 @@ export function registerIpc(): void { ipcMain.handle(IPC.pagesSave, async (_e, file: unknown) => (await library).save(parseLucidFile(file)), ); - registerLlmIpc(); + registerSecureAiIpc(libraryDir()); const lmDeps = async (): Promise => { const s = await loadSettings(libraryDir()); const baseUrl = process.env["LUCID_LLM_URL"] ?? s.llm.baseUrl; @@ -161,18 +162,47 @@ export function registerIpc(): void { model: s.llm.model, contextLength: s.llm.contextLength, }, + ai: { + defaultProfileId: s.ai.defaultProfileId, + profiles: s.ai.profiles.map((profile) => ({ + ...profile, + baseUrl: + profile.kind === "lmstudio" && process.env["LUCID_LLM_URL"] + ? process.env["LUCID_LLM_URL"] + : profile.baseUrl, + })), + }, llmUrlLocked: process.env["LUCID_LLM_URL"] !== undefined, }); ipcMain.handle(IPC.settingsGet, async () => publicSettings(await loadSettings(libraryDir()))); ipcMain.handle(IPC.settingsSave, async (_e, input: unknown): Promise => { try { // With the address fixed from the environment, keep the stored one whatever the window sends. - const sent = input as { llm?: { baseUrl?: unknown } } | null; + const sent = input as { + llm?: { baseUrl?: unknown }; + ai?: { profiles?: { id?: unknown; kind?: unknown; baseUrl?: unknown }[] }; + } | null; const locked = process.env["LUCID_LLM_URL"] !== undefined; const stored = await loadSettings(libraryDir()); const checked = locked && sent?.llm - ? { ...sent, llm: { ...sent.llm, baseUrl: stored.llm.baseUrl } } + ? { + ...sent, + llm: { ...sent.llm, baseUrl: stored.llm.baseUrl }, + ai: sent.ai + ? { + ...sent.ai, + profiles: sent.ai.profiles?.map((profile) => ({ + ...profile, + baseUrl: + profile.kind === "lmstudio" + ? (stored.ai.profiles.find((saved) => saved.id === profile.id)?.baseUrl ?? + "http://127.0.0.1:1234") + : profile.baseUrl, + })), + } + : sent.ai, + } : input; const saved = await saveSettings(libraryDir(), checked); (await compiler).setStyle({ diff --git a/apps/desktop/electron/ipc/llm.ts b/apps/desktop/electron/ipc/llm.ts index 0bbd6a3..84722b6 100644 --- a/apps/desktop/electron/ipc/llm.ts +++ b/apps/desktop/electron/ipc/llm.ts @@ -1,7 +1,4 @@ -import { ipcMain } from "electron"; -import { IPC, type LlmHead, type LlmRequest } from "./types"; - -/** Only these LM Studio endpoints, only on this machine. The renderer cannot use this to reach anything else. */ +/** Only these LM Studio endpoints, only on this machine. */ const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]); const PATHS = new Set(["/v1/models", "/v1/chat/completions"]); @@ -14,56 +11,3 @@ export function checkLlmUrl(url: string): URL { throw new Error(`Not an LM Studio endpoint Lucid uses: ${parsed.pathname}`); return parsed; } - -export function registerLlmIpc(): void { - const running = new Map(); - - ipcMain.handle(IPC.llmStart, async (event, raw: unknown): Promise => { - const req = raw as Partial; - if (typeof req.id !== "number" || typeof req.url !== "string") - return { ok: false, message: "invalid request" }; - try { - const url = checkLlmUrl(req.url); - const method = req.method === "POST" ? "POST" : "GET"; - const controller = new AbortController(); - running.set(req.id, controller); - const id = req.id; - const res = await fetch(url, { - method, - headers: typeof req.headers === "object" ? req.headers : {}, - ...(method === "POST" && typeof req.body === "string" ? { body: req.body } : {}), - signal: controller.signal, - }); - // Stream the body back as it arrives; the renderer is already listening for this id. - void (async () => { - try { - if (res.body) { - for await (const chunk of res.body as unknown as AsyncIterable) { - if (event.sender.isDestroyed()) return; - event.sender.send(IPC.llmEvent, { id, type: "chunk", data: chunk }); - } - } - if (!event.sender.isDestroyed()) event.sender.send(IPC.llmEvent, { id, type: "end" }); - } catch (error) { - if (!event.sender.isDestroyed()) { - event.sender.send(IPC.llmEvent, { - id, - type: "error", - message: error instanceof Error ? error.message : String(error), - }); - } - } finally { - running.delete(id); - } - })(); - return { ok: true, status: res.status, headers: Object.fromEntries(res.headers) }; - } catch (error) { - running.delete(req.id); - return { ok: false, message: error instanceof Error ? error.message : String(error) }; - } - }); - - ipcMain.handle(IPC.llmAbort, (_e, id: unknown) => { - if (typeof id === "number") running.get(id)?.abort(); - }); -} diff --git a/apps/desktop/electron/ipc/types.ts b/apps/desktop/electron/ipc/types.ts index 9fc3122..6012c03 100644 --- a/apps/desktop/electron/ipc/types.ts +++ b/apps/desktop/electron/ipc/types.ts @@ -112,6 +112,7 @@ export interface PublicSettings { dictation: { autoSendMs: AutoSendMs }; vocabulary: string[]; llm: { baseUrl: string; model: string; contextLength: number }; + ai: AiSettings; /** The address comes from the LUCID_LLM_URL environment variable (tests, development) and is not changed here. */ llmUrlLocked: boolean; } @@ -119,20 +120,133 @@ export type SettingsInput = Omit; export type SettingsSaveResult = { ok: true; settings: PublicSettings } | { ok: false; problems: string[] }; -/** A request for LM Studio, made by the main process because LM Studio sends no CORS headers. */ -export interface LlmRequest { +export const AI_PROVIDER_CATALOG = [ + { + kind: "lmstudio", + label: "LM Studio", + credential: "none", + customBaseUrl: true, + cloud: false, + defaultBaseUrl: "http://127.0.0.1:1234", + }, + { + kind: "ollama", + label: "Ollama", + credential: "none", + customBaseUrl: true, + cloud: false, + defaultBaseUrl: "http://127.0.0.1:11434", + }, + { + kind: "openai", + label: "OpenAI API", + credential: "required", + customBaseUrl: false, + cloud: true, + defaultBaseUrl: "https://api.openai.com/v1", + }, + { + kind: "anthropic", + label: "Anthropic API", + credential: "required", + customBaseUrl: false, + cloud: true, + defaultBaseUrl: "https://api.anthropic.com/v1", + }, + { + kind: "openrouter", + label: "OpenRouter", + credential: "required", + customBaseUrl: false, + cloud: true, + defaultBaseUrl: "https://openrouter.ai/api/v1", + }, + { + kind: "ollama-cloud", + label: "Ollama Cloud", + credential: "required", + customBaseUrl: false, + cloud: true, + defaultBaseUrl: "https://ollama.com/v1", + }, + { + kind: "custom", + label: "OpenAI-compatible", + credential: "optional", + customBaseUrl: true, + cloud: true, + defaultBaseUrl: "https://api.example.com/v1", + }, +] as const; +export type AiProviderKind = (typeof AI_PROVIDER_CATALOG)[number]["kind"]; +export interface ProviderProfile { + id: string; + name: string; + kind: AiProviderKind; + baseUrl: string; + model: string; +} +export interface AiSettings { + defaultProfileId: string; + profiles: ProviderProfile[]; +} +export interface ProviderConnectionResult { + ok: boolean; + message: string; + models?: string[] | undefined; +} + +export const providerCredentialId = (profileId: string): string => `provider.${profileId}`; + +export interface AiStartRequest { id: number; - url: string; - method: string; - headers: Record; - body?: string | undefined; + /** Resolves to main-process configuration; it is not an endpoint or credential. */ + providerId: string; + model: string; + messages: { role: "system" | "user" | "assistant"; content: string }[]; + lane: { + temperature: number; + topP: number; + topK: number; + presencePenalty: number; + maxTokens: number; + }; + jsonSchema?: { name: string; schema: object } | undefined; + reasoning?: "none" | "minimal" | "low" | "medium" | "high" | undefined; +} +export type AiStartResult = { ok: true } | { ok: false; message: string }; +export type AiEvent = + | { id: number; type: "delta"; text: string } + | { + id: number; + type: "complete"; + result: { + usage: { + prompt: number; + completion: number; + reasoning: number; + source: "provider" | "estimated"; + }; + characters: { request: number; response: number }; + timings: { sentAt: number; firstTokenAt: number | null; doneAt: number }; + model: string; + provider: string; + repairAttempts: number; + }; + } + | { id: number; type: "error"; code: string; message: string }; + +export interface CredentialSaveRequest { + id: string; + secret: string; + /** When secure persistence is unavailable, the result reports session storage instead. */ + persist: boolean; +} +export interface CredentialStatus { + present: boolean; + storage: "secure" | "session" | "none"; + securePersistenceAvailable: boolean; } -export type LlmHead = - { ok: true; status: number; headers: Record } | { ok: false; message: string }; -export type LlmEvent = - | { id: number; type: "chunk"; data: Uint8Array } - | { id: number; type: "end" } - | { id: number; type: "error"; message: string }; /** The PDF template (`lucid.sty`): the person's own, or the one that comes with Lucid. */ export interface TemplateState { @@ -165,14 +279,19 @@ export const IPC = { templateGet: "lucid:template-get", templateSave: "lucid:template-save", templateReset: "lucid:template-reset", - llmStart: "lucid:llm-start", - llmAbort: "lucid:llm-abort", lmStatus: "lucid:lm-status", lmLoad: "lucid:lm-load", agentRequest: "lucid:agent-request", agentReply: "lucid:agent-reply", pagesChanged: "lucid:pages-changed", - llmEvent: "lucid:llm-event", + aiStart: "lucid:ai-start", + aiAbort: "lucid:ai-abort", + aiEvent: "lucid:ai-event", + credentialSave: "lucid:credential-save", + credentialDelete: "lucid:credential-delete", + credentialStatus: "lucid:credential-status", + providerTest: "lucid:provider-test", + providerModels: "lucid:provider-models", } as const; export interface LucidApi { @@ -205,12 +324,22 @@ export interface LucidApi { /** Back to the template that comes with Lucid. */ reset(): Promise; }; - llm: { - /** Starts the request; resolves when the response headers are in. The body then arrives as events. */ - start(req: LlmRequest): Promise; + ai: { + start(request: AiStartRequest): Promise; abort(id: number): Promise; - /** Returns a function that stops listening. */ - onEvent(listener: (event: LlmEvent) => void): () => void; + onEvent(listener: (event: AiEvent) => void): () => void; + }; + credentials: { + /** Saves a secret but never returns it. */ + save(request: CredentialSaveRequest): Promise; + delete(id: string): Promise; + status(id: string): Promise; + }; + providers: { + /** Tests the saved profile. Main resolves its URL and credential by id. */ + test(id: string): Promise; + /** Returns model ids from the saved profile without exposing credentials. */ + models(id: string): Promise; }; lm: { status(): Promise; diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 9762a76..fe55074 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -1,9 +1,9 @@ import { contextBridge, ipcRenderer, type IpcRendererEvent } from "electron"; import { IPC, + type AiEvent, type AgentReply, type AgentRequest, - type LlmEvent, type LmLoadResult, type LmStatus, type LucidApi, @@ -83,19 +83,35 @@ const api: LucidApi = { save: (input) => ipcRenderer.invoke(IPC.settingsSave, input) as ReturnType, }, - llm: { - start: (req) => ipcRenderer.invoke(IPC.llmStart, req) as ReturnType, - abort: (id) => ipcRenderer.invoke(IPC.llmAbort, id) as Promise, + ai: { + start: (request) => + ipcRenderer.invoke(IPC.aiStart, request) as ReturnType, + abort: (id) => ipcRenderer.invoke(IPC.aiAbort, id) as Promise, onEvent: (listener) => { - const handler = (_e: IpcRendererEvent, event: LlmEvent) => { - listener(event); + const handler = (_event: IpcRendererEvent, aiEvent: AiEvent) => { + listener(aiEvent); }; - ipcRenderer.on(IPC.llmEvent, handler); + ipcRenderer.on(IPC.aiEvent, handler); return () => { - ipcRenderer.removeListener(IPC.llmEvent, handler); + ipcRenderer.removeListener(IPC.aiEvent, handler); }; }, }, + credentials: { + save: (request) => + ipcRenderer.invoke(IPC.credentialSave, request) as ReturnType< + LucidApi["credentials"]["save"] + >, + delete: (id) => ipcRenderer.invoke(IPC.credentialDelete, id) as Promise, + status: (id) => + ipcRenderer.invoke(IPC.credentialStatus, id) as ReturnType, + }, + providers: { + test: (id) => + ipcRenderer.invoke(IPC.providerTest, id) as ReturnType, + models: (id) => + ipcRenderer.invoke(IPC.providerModels, id) as ReturnType, + }, lm: { status: () => ipcRenderer.invoke(IPC.lmStatus) as Promise, load: () => ipcRenderer.invoke(IPC.lmLoad) as Promise, diff --git a/apps/desktop/electron/settings.test.ts b/apps/desktop/electron/settings.test.ts index 0ebb901..2ad2ea7 100644 --- a/apps/desktop/electron/settings.test.ts +++ b/apps/desktop/electron/settings.test.ts @@ -25,6 +25,28 @@ describe("loadSettings", () => { expect(await loadSettings(dir)).toMatchObject({ author: "Asha", footerRight: "" }); }); + it("migrates an existing LM Studio address and model into the first provider profile", async () => { + const dir = await mkdtemp(join(tmpdir(), "lucid-set-")); + await mkdir(join(dir, ".lucid")); + await writeFile( + join(dir, ".lucid", "settings.json"), + JSON.stringify({ llm: { baseUrl: "http://localhost:9876", model: "local/model" } }), + "utf8", + ); + expect((await loadSettings(dir)).ai).toEqual({ + defaultProfileId: "lmstudio", + profiles: [ + { + id: "lmstudio", + name: "LM Studio", + kind: "lmstudio", + baseUrl: "http://localhost:9876", + model: "local/model", + }, + ], + }); + }); + it("sends a paste after 0.5 s unless the settings say otherwise (a file from before has no such setting)", async () => { const dir = await mkdtemp(join(tmpdir(), "lucid-set-")); await mkdir(join(dir, ".lucid")); @@ -69,6 +91,18 @@ describe("saveSettings", () => { dictation: { autoSendMs: 1000 }, vocabulary: ["vLLM", "Kafka", "vLLM"], llm: { baseUrl: "http://localhost:1234/", model: "other/model-7b", contextLength: 16384 }, + ai: { + defaultProfileId: "lmstudio", + profiles: [ + { + id: "lmstudio", + name: "Local", + kind: "lmstudio", + baseUrl: "http://localhost:1234", + model: "other/model-7b", + }, + ], + }, }; it("writes the new values, keeps program paths and everything else, and tidies the address and the words", async () => { @@ -93,6 +127,7 @@ describe("saveSettings", () => { contextLength: 16384, lmsPath: "/custom/lms", }, + ai: valid.ai, }); expect(JSON.parse(await readFile(join(dir, ".lucid", "settings.json"), "utf8"))).toEqual(saved); expect(await loadSettings(dir)).toEqual(saved); @@ -135,6 +170,59 @@ describe("saveSettings", () => { expect(await bad({ pdf: undefined })).toBeInstanceOf(SettingsError); expect(await bad({ dictation: { autoSendMs: 300 } })).toBeInstanceOf(SettingsError); expect(await bad({ dictation: undefined })).toBeInstanceOf(SettingsError); + expect( + await bad({ + ai: { + defaultProfileId: "missing", + profiles: valid.ai.profiles, + }, + }), + ).toBeInstanceOf(SettingsError); + expect( + await bad({ + ai: { + defaultProfileId: "lmstudio", + profiles: [ + { + ...valid.ai.profiles[0], + baseUrl: "https://lm-studio.example.com", + }, + ], + }, + }), + ).toBeInstanceOf(SettingsError); + expect( + await bad({ + ai: { + defaultProfileId: "ollama", + profiles: [ + { + id: "ollama", + name: "Ollama", + kind: "ollama", + baseUrl: "http://127.0.0.1:11434", + model: "remote:cloud", + }, + ], + }, + }), + ).toBeInstanceOf(SettingsError); + expect( + await bad({ + ai: { + defaultProfileId: "openai", + profiles: [ + { + id: "openai", + name: "OpenAI", + kind: "openai", + baseUrl: "https://example.com/v1", + model: "gpt-4.1-mini", + }, + ], + }, + }), + ).toBeInstanceOf(SettingsError); expect(await loadSettings(dir)).toEqual(before); }); }); diff --git a/apps/desktop/electron/settings.ts b/apps/desktop/electron/settings.ts index b160170..f37d912 100644 --- a/apps/desktop/electron/settings.ts +++ b/apps/desktop/electron/settings.ts @@ -1,7 +1,13 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { DEFAULT_PDF_STYLE, PdfStyleSchema } from "@lucid/kit"; -import { AUTO_SEND_MS } from "./ipc/types"; +import { + AI_PROVIDER_CATALOG, + AUTO_SEND_MS, + type AiProviderKind, + type AiSettings, +} from "./ipc/types"; +import { validateProviderEndpoint } from "./ai/security"; import { z } from "zod"; const AutoSendMsSchema = z.union([ @@ -11,6 +17,106 @@ const AutoSendMsSchema = z.union([ z.literal(AUTO_SEND_MS[3]), ]); +const defaultAiSettings = (llm?: { baseUrl?: string; model?: string }): AiSettings => ({ + defaultProfileId: "lmstudio", + profiles: [ + { + id: "lmstudio", + name: "LM Studio", + kind: "lmstudio", + baseUrl: llm?.baseUrl ?? "http://127.0.0.1:1234", + model: llm?.model ?? "qwen/qwen3.8-27b", + }, + ], +}); + +const ProviderKindSchema = z.enum( + AI_PROVIDER_CATALOG.map((provider) => provider.kind) as [AiProviderKind, ...AiProviderKind[]], +); +const ProviderProfileSchema = z.strictObject({ + id: z + .string() + .trim() + .regex(/^[a-z0-9][a-z0-9._-]{0,79}$/i, "must contain only letters, numbers, dots or dashes"), + name: z + .string() + .trim() + .min(1) + .max(80) + .regex(/^[^\r\n]*$/, "must be one line"), + kind: ProviderKindSchema, + baseUrl: z + .string() + .trim() + .transform((value, ctx) => { + try { + return validateProviderEndpoint(value).href.replace(/\/+$/, ""); + } catch (error) { + ctx.addIssue({ + code: "custom", + message: error instanceof Error ? error.message : "must be a valid provider address", + }); + return z.NEVER; + } + }), + model: z + .string() + .trim() + .min(1) + .max(240) + .regex(/^[^\r\n]*$/, "must be one line"), +}); +const LOCAL_PROVIDER_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]); +const AiSettingsSchema = z + .strictObject({ + defaultProfileId: z.string().trim(), + profiles: z.array(ProviderProfileSchema).min(1).max(20), + }) + .superRefine((settings, ctx) => { + const ids = new Set(); + for (const [index, profile] of settings.profiles.entries()) { + if (ids.has(profile.id)) + ctx.addIssue({ + code: "custom", + path: ["profiles", index, "id"], + message: "must be unique", + }); + ids.add(profile.id); + if (profile.kind === "ollama" && /:cloud$/i.test(profile.model)) + ctx.addIssue({ + code: "custom", + path: ["profiles", index, "model"], + message: "Ollama Cloud models require a cloud provider profile", + }); + const provider = AI_PROVIDER_CATALOG.find((candidate) => candidate.kind === profile.kind); + if (provider && !provider.cloud) { + const url = new URL(profile.baseUrl); + if (url.protocol !== "http:" || !LOCAL_PROVIDER_HOSTS.has(url.hostname)) + ctx.addIssue({ + code: "custom", + path: ["profiles", index, "baseUrl"], + message: `${provider.label} must use an address on this computer`, + }); + } + if ( + provider && + !provider.customBaseUrl && + profile.baseUrl !== provider.defaultBaseUrl.replace(/\/+$/, "") + ) + ctx.addIssue({ + code: "custom", + path: ["profiles", index, "baseUrl"], + message: `is fixed for ${provider.label}`, + }); + } + if (!ids.has(settings.defaultProfileId)) + ctx.addIssue({ + code: "custom", + path: ["defaultProfileId"], + message: "must name one of the provider profiles", + }); + }); + /** User settings in `/.lucid/settings.json`. Missing file or fields fall back to the defaults. */ export const SettingsSchema = z.object({ /** Your name under the title of exported PDFs (optional). */ @@ -40,6 +146,7 @@ export const SettingsSchema = z.object({ contextLength: 32768, lmsPath: "", }), + ai: AiSettingsSchema.default(defaultAiSettings()), }); export type Settings = z.infer; @@ -56,7 +163,11 @@ export async function loadSettings(libraryDir: string): Promise { return defaults; } try { - const parsed = SettingsSchema.safeParse(JSON.parse(text)); + const source = JSON.parse(text) as { ai?: unknown; llm?: { baseUrl?: string; model?: string } }; + const parsed = SettingsSchema.safeParse({ + ...source, + ai: source.ai ?? defaultAiSettings(source.llm), + }); return parsed.success ? parsed.data : SettingsSchema.parse({}); } catch { return SettingsSchema.parse({}); @@ -107,6 +218,7 @@ export const EditableSettingsSchema = z.strictObject({ .regex(/^[\w][\w./@:+-]{0,200}$/, "must be a model name like qwen/qwen3.8-27b"), contextLength: z.number().int().min(2048).max(262144), }), + ai: AiSettingsSchema, }); export type EditableSettings = z.infer; @@ -134,7 +246,15 @@ export async function saveSettings(libraryDir: string, input: unknown): Promise< dictation: parsed.data.dictation, vocabulary: [...new Set(parsed.data.vocabulary)], llm: { ...current.llm, ...parsed.data.llm }, + ai: parsed.data.ai, }; + const defaultProfile = next.ai.profiles.find( + (profile) => profile.id === next.ai.defaultProfileId, + ); + if (defaultProfile?.kind === "lmstudio") { + next.llm.baseUrl = defaultProfile.baseUrl; + next.llm.model = defaultProfile.model; + } const dir = join(libraryDir, ".lucid"); await mkdir(dir, { recursive: true }); const tmp = join(dir, `settings.json.${String(process.pid)}.tmp`); diff --git a/apps/desktop/src/dictation/DictationBar.tsx b/apps/desktop/src/dictation/DictationBar.tsx index 6ea9250..9bfc14a 100644 --- a/apps/desktop/src/dictation/DictationBar.tsx +++ b/apps/desktop/src/dictation/DictationBar.tsx @@ -225,7 +225,11 @@ export function DictationBar({ return (
-
+
{status.kind === "working" && ( <> @@ -276,6 +280,19 @@ export function DictationBar({ )} + {status.kind === "unavailable" && ( + <> + + + Command unavailable. {status.message} + + + + )} {status.kind === "proposal" && ( <>
@@ -300,7 +317,7 @@ export function DictationBar({ {status.question} Click one, or say “the first one”, “the second one”…
-
+
{status.options.map((o, i) => ( + ); + })} +
+ + +
+
+ +
+ + + + + {provider.credential !== "none" && ( +
+ +
+ + {credential.present && ( + + )} +
+
+ )} + + {provider.cloud && ( +

+ Requests send the selected note context to {provider.label}. ChatGPT and Claude + subscriptions do not include API access; API billing is separate. +

+ )} + + {active.kind === "ollama" && ( +

+ Command Mode requires a current Ollama version and a model that accepts JSON Schema. + If Ollama rejects structured output, Lucid stops without applying the command. +

+ )} + {active.kind === "ollama-cloud" && ( +

+ Ollama Cloud currently lacks JSON Schema output. Input can run here; Command and + Advisor stop safely without sending a schema request. +

+ )} + {(active.kind === "openrouter" || active.kind === "custom") && ( +

+ Structured output depends on the selected model and endpoint. If it is not supported, + Lucid stops Command or Advisor without applying an edit. Model listing may also be + unavailable; you can enter a model ID manually. +

+ )} + +
+ + + + +
+ {connection && ( +

+ {connection.message} +

+ )} +
+
+ + ); +} diff --git a/apps/desktop/src/settings/SettingsDialog.tsx b/apps/desktop/src/settings/SettingsDialog.tsx index 8f27134..5080c6e 100644 --- a/apps/desktop/src/settings/SettingsDialog.tsx +++ b/apps/desktop/src/settings/SettingsDialog.tsx @@ -1,6 +1,11 @@ -import { useEffect, useId, useRef, useState } from "react"; +import { useEffect, useId, useState } from "react"; import { ACCENT_COLORS, FONT_NAMES, type PdfStyle } from "@lucid/kit"; -import { AUTO_SEND_MS, type AutoSendMs, type PublicSettings } from "../../electron/ipc/types"; +import { + AUTO_SEND_MS, + type AiSettings, + type AutoSendMs, + type PublicSettings, +} from "../../electron/ipc/types"; import { setFont, setPageLook, @@ -14,6 +19,7 @@ import { setBlockNumbers, useBlockNumbers } from "../theme/blockNumbers"; import { PAGE_LOOKS } from "../pages/PageLookSwitch"; import { saveSettings } from "./settingsStore"; import { TemplateDialog } from "./TemplateDialog"; +import { ProviderSettings } from "./ProviderSettings"; const THEME_OPTIONS: { value: ThemeChoice; label: string; icon: IconName }[] = [ { value: "light", label: "Light", icon: "sun" }, @@ -112,18 +118,15 @@ export function SettingsDialog({ settings, onClose }: Props) { const changePdf = (change: Partial) => { setPdf((before) => ({ ...before, ...change })); }; - const [address, setAddress] = useState(settings.llm.baseUrl); - const [model, setModel] = useState(settings.llm.model); + const [ai, setAi] = useState(settings.ai); const [context, setContext] = useState(String(settings.llm.contextLength)); const [words, setWords] = useState(settings.vocabulary.join("\n")); const [problems, setProblems] = useState([]); const [saving, setSaving] = useState(false); - const first = useRef(null); const appearance = useAppearance(); const numbers = useBlockNumbers(); useEffect(() => { - first.current?.focus(); const onKey = (e: KeyboardEvent) => { // The template window has its own Escape. if (e.key === "Escape" && !editingTemplate) onClose(); @@ -134,10 +137,11 @@ export function SettingsDialog({ settings, onClose }: Props) { }; }, [onClose, editingTemplate]); - const save = async () => { + const persist = async (close = true): Promise => { setSaving(true); setProblems([]); try { + const defaultProfile = ai.profiles.find((profile) => profile.id === ai.defaultProfileId); const result = await saveSettings({ author, footerRight: footer, @@ -147,15 +151,25 @@ export function SettingsDialog({ settings, onClose }: Props) { .split(/\r?\n/) .map((w) => w.trim()) .filter((w) => w.length > 0), - llm: { baseUrl: address, model, contextLength: Number(context) }, + llm: { + baseUrl: + defaultProfile?.kind === "lmstudio" ? defaultProfile.baseUrl : settings.llm.baseUrl, + model: defaultProfile?.kind === "lmstudio" ? defaultProfile.model : settings.llm.model, + contextLength: Number(context), + }, + ai, }); - if (result.ok) onClose(); - else setProblems(result.problems); + if (result.ok) { + if (close) onClose(); + return true; + } + setProblems(result.problems); } catch (error) { setProblems([error instanceof Error ? error.message : "The settings could not be saved."]); } finally { setSaving(false); } + return false; }; return ( @@ -272,39 +286,15 @@ export function SettingsDialog({ settings, onClose }: Props) { + persist(false)} + /> +
- Language model (LM Studio) - - + Local model