diff --git a/backend/src/lib/llm/controlCenter/health.ts b/backend/src/lib/llm/controlCenter/health.ts new file mode 100644 index 0000000..0899b59 --- /dev/null +++ b/backend/src/lib/llm/controlCenter/health.ts @@ -0,0 +1,241 @@ +import { buildProvider, getLlmConfig, isProviderLocal, type LlmRole, type ProviderConfig } from "../providers/registry"; +import { classifyLlmError, type LlmErrorKind } from "../friendlyError"; +import { findOllamaInstall, ollamaIsLocal, sameOllamaModel } from "./localRuntime"; +import { providerHasKey, providerNeedsKey } from "./providers"; + +/** + * "Is the AI actually going to work?" — one check per role that goes past the + * saved config: is the runtime up, is the model there, is the key accepted. + * Every problem carries the one action that fixes it, for the UI to offer. + */ + +export type FixAction = + | { action: "setup"; label: string } + | { action: "start-ollama"; label: string } + | { action: "get-ollama"; label: string } + | { action: "download-model"; label: string; model: string } + | { action: "add-key"; label: string; provider: string } + | { action: "turn-off-local-only"; label: string } + | { action: "edit-role"; label: string }; + +export type RoleHealth = { + role: LlmRole; + /** ok: works · warning: works with limits · error: won't work · off: not used (embedding only) */ + status: "ok" | "warning" | "error" | "off"; + binding: { provider: string; label: string; model: string; local: boolean; inherited: boolean } | null; + title: string; + detail?: string; + fix?: FixAction; +}; + +export type AiHealth = { + overall: "ready" | "attention" | "not-set-up"; + localOnly: boolean; + roles: Record; + checkedAt: string; +}; + +type Listing = { models: string[] } | { error: { kind: LlmErrorKind; message: string } }; + +const LIST_TTL_MS = 20_000; +const LIST_TIMEOUT_MS = 8_000; +const listCache = new Map }>(); + +function listModels(pc: ProviderConfig, fresh: boolean): Promise { + const key = JSON.stringify([pc.id, pc.kind, pc.baseUrl, pc.apiKey]); + const hit = listCache.get(key); + if (!fresh && hit && Date.now() - hit.at < LIST_TTL_MS) return hit.value; + + const value = (async (): Promise => { + try { + const provider = buildProvider(pc); + if (!provider.listModels) return { models: [] }; + const timeout = new Promise((_, reject) => + setTimeout(() => reject(Object.assign(new Error("timed out"), { name: "TimeoutError" })), LIST_TIMEOUT_MS), + ); + return { models: await Promise.race([provider.listModels(), timeout]) }; + } catch (err) { + return { error: classifyLlmError(err) }; + } + })(); + listCache.set(key, { at: Date.now(), value }); + return value; +} + +/** Forget cached listings, e.g. right after a key or download changes. */ +export function invalidateHealthCache(): void { + listCache.clear(); +} + +const ROLE_NAME: Record = { + agent: "the assistant", + utility: "background tasks", + embedding: "document search", +}; + +function runtimeName(pc: ProviderConfig): string { + if (pc.id === "ollama") return "Ollama"; + if (pc.id === "lmstudio") return "LM Studio"; + if (pc.id === "llamacpp") return "the llama.cpp server"; + return pc.label ?? pc.id; +} + +function ollamaDownFix(): FixAction { + if (!ollamaIsLocal()) return { action: "edit-role", label: "Change model" }; + return findOllamaInstall() ? { action: "start-ollama", label: "Start Ollama" } : { action: "get-ollama", label: "Install Ollama" }; +} + +async function checkRole(role: LlmRole, fresh: boolean): Promise { + const config = getLlmConfig(); + const own = config.roles[role]; + const binding = own ?? (role === "utility" ? config.roles.agent : undefined); + + if (!binding) { + if (role === "embedding") { + return { + role, + status: "off", + binding: null, + title: "Keyword search", + detail: "No embedding model, so document search matches words rather than meaning. Optional.", + }; + } + return { + role, + status: "error", + binding: null, + title: "Not set up", + detail: `Choose a model so ${ROLE_NAME[role]} can work.`, + fix: { action: "setup", label: "Set up AI" }, + }; + } + + const pc = config.providers.find((p) => p.id === binding.provider); + const view = { + provider: binding.provider, + label: pc?.label ?? binding.provider, + model: binding.model, + local: pc ? isProviderLocal(pc) : false, + inherited: !own, + }; + const fail = (title: string, detail: string, fix?: FixAction): RoleHealth => ({ + role, + status: "error", + binding: view, + title, + detail, + fix, + }); + + if (!pc) { + return fail("Provider missing", `"${binding.provider}" was removed. Pick another model.`, { action: "edit-role", label: "Change model" }); + } + if (config.localOnly && !view.local) { + return fail( + "Blocked by Local-only mode", + `${view.label} is a cloud provider, and Local-only mode blocks anything that leaves this computer.`, + { action: "turn-off-local-only", label: "Allow cloud AI" }, + ); + } + if (providerNeedsKey(pc) && !providerHasKey(pc)) { + return fail("API key missing", `There's no API key saved for ${view.label}.`, { + action: "add-key", + label: "Add key", + provider: pc.id, + }); + } + if (role === "embedding" && !buildProvider(pc).embed) { + return fail("Can't do document search", `${view.label} has no embedding models. Use an on-device model or turn document search off.`, { + action: "edit-role", + label: "Change model", + }); + } + + const listing = await listModels(pc, fresh); + if ("error" in listing) { + const { kind, message } = listing.error; + if (kind === "local-down" || (view.local && (kind === "offline" || kind === "unreachable" || kind === "timeout"))) { + return pc.id === "ollama" + ? fail("Ollama isn't running", "Ollama runs the on-device models. Start it and this fixes itself.", ollamaDownFix()) + : fail(`${runtimeName(pc)} isn't running`, `Open ${runtimeName(pc)} and start its server, then check again.`); + } + if (kind === "auth") { + return fail("API key rejected", `${view.label} didn't accept the saved key. It may have been revoked or mistyped.`, { + action: "add-key", + label: "Replace key", + provider: pc.id, + }); + } + if (kind === "quota") { + return fail("Out of credit", message); + } + if (kind === "offline" || kind === "timeout") { + return fail(`Can't reach ${view.label}`, "Check your internet connection. Screening still works; AI features wait until you're back online."); + } + return fail(`${view.label} isn't responding`, message, { action: "edit-role", label: "Change model" }); + } + + const { models } = listing; + const installed = + !models.length || models.some((m) => (pc.kind === "ollama" ? sameOllamaModel(m, binding.model) : m === binding.model)); + if (!installed) { + if (pc.kind === "ollama") { + return fail(`${binding.model} isn't downloaded`, "The model was removed from Ollama, or hasn't been downloaded yet.", { + action: "download-model", + label: "Download", + model: binding.model, + }); + } + return fail( + "Model not available", + `${view.label} doesn't offer "${binding.model}" to your account. It may be retired — pick another model.`, + { action: "edit-role", label: "Change model" }, + ); + } + + if (role === "agent" && binding.supportsTools === false) { + return { + role, + status: "warning", + binding: view, + title: "Chat only", + detail: "This model can't use tools, so the assistant can answer questions but can't run screenings for you.", + fix: { action: "edit-role", label: "Change model" }, + }; + } + + return { role, status: "ok", binding: view, title: "Ready" }; +} + +export async function checkAiHealth(opts: { fresh?: boolean } = {}): Promise { + const fresh = !!opts.fresh; + let localOnly = false; + let roles: Record; + try { + localOnly = !!getLlmConfig().localOnly; + const [agent, utility, embedding] = await Promise.all( + (["agent", "utility", "embedding"] as const).map((r) => checkRole(r, fresh)), + ); + roles = { agent, utility, embedding }; + } catch (err) { + // A broken config file shouldn't take the page down with it. + const broken = (role: LlmRole): RoleHealth => ({ + role, + status: "error", + binding: null, + title: "Settings couldn't be read", + detail: err instanceof Error ? err.message : String(err), + fix: { action: "setup", label: "Set up again" }, + }); + roles = { agent: broken("agent"), utility: broken("utility"), embedding: broken("embedding") }; + } + + const all = Object.values(roles); + const overall = + !roles.agent.binding && roles.agent.status === "error" + ? "not-set-up" + : all.some((r) => r.status === "error" || r.status === "warning") + ? "attention" + : "ready"; + return { overall, localOnly, roles, checkedAt: new Date().toISOString() }; +} diff --git a/backend/src/lib/llm/controlCenter/localRuntime.ts b/backend/src/lib/llm/controlCenter/localRuntime.ts index 2b98fb6..ccf705f 100644 --- a/backend/src/lib/llm/controlCenter/localRuntime.ts +++ b/backend/src/lib/llm/controlCenter/localRuntime.ts @@ -1,4 +1,8 @@ +import { spawn } from "child_process"; +import fs from "fs"; import os from "os"; +import path from "path"; +import { isLoopbackUrl } from "../providers/registry"; /** * On-device model runtimes (Ollama, LM Studio) — detection, hardware fit, and @@ -17,11 +21,29 @@ export type Hardware = { freeMemGB: number; /** Apple Silicon shares RAM with the GPU, so most of it is usable for models. */ unifiedMemory: boolean; + /** Free space where Ollama stores models; null when it can't be read. */ + freeDiskGB: number | null; }; +const gb = (n: number) => Math.round((n / 1024 ** 3) * 10) / 10; + +function freeDiskGB(): number | null { + // Ollama keeps models under ~/.ollama/models unless OLLAMA_MODELS moves them. + const candidates = [process.env.OLLAMA_MODELS?.trim(), path.join(os.homedir(), ".ollama"), os.homedir()]; + for (const dir of candidates) { + if (!dir) continue; + try { + const s = fs.statfsSync(dir); + return gb(s.bavail * s.bsize); + } catch { + // try the next one + } + } + return null; +} + export function detectHardware(): Hardware { const cpus = os.cpus(); - const gb = (n: number) => Math.round((n / 1024 ** 3) * 10) / 10; return { platform: process.platform, arch: process.arch, @@ -30,9 +52,13 @@ export function detectHardware(): Hardware { totalMemGB: gb(os.totalmem()), freeMemGB: gb(os.freemem()), unifiedMemory: process.platform === "darwin" && process.arch === "arm64", + freeDiskGB: freeDiskGB(), }; } +/** Disk headroom kept free beyond the model itself. */ +export const DISK_HEADROOM_GB = 2; + export type CatalogModel = { id: string; label: string; @@ -105,6 +131,71 @@ export async function ollamaStatus(): Promise<{ running: boolean; version: strin }; } +type OllamaInstall = { kind: "app" | "binary"; path: string }; + +/** Where Ollama is installed on this computer, if anywhere. */ +export function findOllamaInstall(): OllamaInstall | null { + const home = os.homedir(); + const apps: string[] = []; + const bins: string[] = []; + if (process.platform === "darwin") { + apps.push("/Applications/Ollama.app", path.join(home, "Applications", "Ollama.app")); + bins.push("/opt/homebrew/bin/ollama", "/usr/local/bin/ollama"); + } else if (process.platform === "win32") { + const local = process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"); + apps.push(path.join(local, "Programs", "Ollama", "ollama app.exe")); + bins.push(path.join(local, "Programs", "Ollama", "ollama.exe")); + } else { + bins.push("/usr/local/bin/ollama", "/usr/bin/ollama", path.join(home, ".local", "bin", "ollama")); + } + for (const p of apps) if (fs.existsSync(p)) return { kind: "app", path: p }; + for (const p of bins) if (fs.existsSync(p)) return { kind: "binary", path: p }; + return null; +} + +/** Ollama is only ours to start when it is meant to run on this machine. */ +export function ollamaIsLocal(): boolean { + return isLoopbackUrl(OLLAMA_URL); +} + +/** + * Launch the installed Ollama and wait for its API. The desktop app (macOS, + * Windows) also starts its menu-bar helper; a bare binary runs `ollama serve`. + */ +export async function startOllama(timeoutMs = 20_000): Promise<{ started: boolean; detail?: string }> { + if ((await ollamaStatus()).running) return { started: true }; + if (!ollamaIsLocal()) return { started: false, detail: `Ollama is configured at ${OLLAMA_URL}, which isn't this computer.` }; + const install = findOllamaInstall(); + if (!install) return { started: false, detail: "Ollama isn't installed. Download it from ollama.com, then try again." }; + + const [cmd, args] = + install.kind === "app" + ? process.platform === "darwin" + ? ["open", ["-g", "-a", install.path]] + : [install.path, []] + : [install.path, ["serve"]]; + try { + const child = spawn(cmd, args as string[], { detached: true, stdio: "ignore", windowsHide: true }); + child.on("error", () => {}); + child.unref(); + } catch (err) { + return { started: false, detail: `Couldn't launch Ollama: ${err instanceof Error ? err.message : String(err)}` }; + } + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 700)); + if ((await ollamaStatus()).running) return { started: true }; + } + return { started: false, detail: "Ollama was launched but didn't respond yet. Give it a few seconds, then check again." }; +} + +/** Ollama treats "qwen3:4b" and "nomic-embed-text" (= ":latest") as tags of one name. */ +export function sameOllamaModel(a: string, b: string): boolean { + const tag = (s: string) => (s.includes(":") ? s : `${s}:latest`); + return tag(a) === tag(b); +} + export async function lmStudioStatus(): Promise<{ running: boolean; models: string[] }> { const models = await getJson<{ data: Array<{ id: string }> }>(`${LMSTUDIO_URL}/v1/models`, 1500); return { running: !!models, models: models?.data.map((m) => m.id) ?? [] }; diff --git a/backend/src/lib/llm/controlCenter/providers.ts b/backend/src/lib/llm/controlCenter/providers.ts new file mode 100644 index 0000000..41f0eaa --- /dev/null +++ b/backend/src/lib/llm/controlCenter/providers.ts @@ -0,0 +1,102 @@ +import { isProviderLocal, mergeWithPreset, PROVIDER_PRESETS, type ProviderConfig } from "../providers/registry"; +import { getProviderKey, readAiConfig, type StoredProvider } from "./store"; + +/** Providers that need no key (local servers). */ +export const KEYLESS = new Set(["ollama", "lmstudio", "llamacpp"]); + +export function storedProviders(): StoredProvider[] { + return readAiConfig()?.providers ?? []; +} + +/** Full provider config for `id`: stored overrides on top of the preset, plus the decrypted key. */ +export function resolveProvider(id: string): ProviderConfig | null { + const stored = storedProviders().find((p) => p.id === id); + const preset = PROVIDER_PRESETS[id]; + if (!stored && !preset) return null; + const merged = mergeWithPreset({ id, ...(preset ?? {}), ...(stored ?? {}) } as ProviderConfig); + const key = getProviderKey(id); + return key ? { ...merged, apiKey: key } : merged; +} + +export function providerNeedsKey(p: ProviderConfig): boolean { + return !KEYLESS.has(p.id) && !isProviderLocal(p); +} + +/** A key is usable when one is saved in the app, or (dev only) set in the preset's env var. */ +export function providerHasKey(p: ProviderConfig): boolean { + return !!p.apiKey || (!!p.apiKeyEnv && !!process.env[p.apiKeyEnv]?.trim()); +} + +// ── API keys as pasted by people ───────────────────────────────────────────── + +/** + * Undo the usual copy-paste damage: surrounding quotes, `Bearer ` prefixes, + * `OPENAI_API_KEY=sk-…` lines from a .env file, stray whitespace. + */ +export function normalizeApiKey(raw: string): string { + const unquote = (s: string) => s.trim().replace(/^["'`]+|["'`]+$/g, "").trim(); + let k = unquote(raw); + k = unquote(k.replace(/^(export\s+)?[A-Z][A-Z0-9_]*\s*=\s*/, "")); + k = unquote(k.replace(/^bearer\s+/i, "")); + return k; +} + +const KEY_PREFIXES: Array<[provider: string, prefix: RegExp]> = [ + ["anthropic", /^sk-ant-/], + ["openrouter", /^sk-or-/], + ["groq", /^gsk_/], + ["gemini", /^AIza/], + ["openai", /^sk-/], +]; + +/** The preset provider a key's prefix belongs to, when it's recognisable. */ +export function detectKeyProvider(key: string): string | null { + return KEY_PREFIXES.find(([, re]) => re.test(key))?.[0] ?? null; +} + +/** Plain-language reason a key can't be right for `providerId`, or null if it looks fine. */ +export function keyFormatProblem(providerId: string, key: string): string | null { + if (!key) return "Paste your API key first."; + if (/\s/.test(key)) return "API keys don't contain spaces. Copy the key again — only the key itself."; + if (key.length < 16 || key.length > 512) return "That doesn't look like a complete API key. Copy it again from the provider."; + const detected = detectKeyProvider(key); + if (detected && detected !== providerId && providerId in PROVIDER_PRESETS) { + const theirs = PROVIDER_PRESETS[detected]?.label ?? detected; + const ours = PROVIDER_PRESETS[providerId]?.label ?? providerId; + return `This key looks like it's for ${theirs}, not ${ours}. Paste it under ${theirs} instead.`; + } + return null; +} + +// ── Cloud model choice ─────────────────────────────────────────────────────── + +/** Preferred chat models per provider, best first. The provider's own list decides what exists. */ +const RECOMMENDED_CHAT: Record = { + anthropic: ["claude-sonnet-5-5", "claude-sonnet-4-5", "claude-haiku-5-5", "claude-haiku-4-5"], + openai: ["gpt-5-mini", "gpt-4.1-mini", "gpt-4o-mini", "gpt-4.1", "gpt-4o"], + gemini: ["models/gemini-2.5-flash", "gemini-2.5-flash", "models/gemini-2.0-flash", "gemini-2.0-flash"], + groq: ["llama-3.3-70b-versatile", "openai/gpt-oss-120b", "llama-3.1-8b-instant"], + openrouter: ["anthropic/claude-sonnet-4.5", "openai/gpt-4o-mini", "google/gemini-2.5-flash"], +}; + +/** Embedding models a cloud provider offers (providers missing here have none we support). */ +export const CLOUD_EMBEDDING: Record = { + openai: "text-embedding-3-small", + gemini: "gemini-embedding-001", +}; + +const NON_CHAT = /embed|whisper|tts|dall-e|image|audio|realtime|moderation|transcribe|search|computer-use|davinci|babbage|guard|vision-preview|veo|imagen|aqa/i; + +/** Chat-capable models from a provider's listing, recommended ones first. */ +export function chatModels(providerId: string, listed: string[]): string[] { + const chat = listed.filter((m) => !NON_CHAT.test(m)); + const preferred = (RECOMMENDED_CHAT[providerId] ?? []).filter((m) => chat.includes(m)); + return [...preferred, ...chat.filter((m) => !preferred.includes(m)).sort()]; +} + +export function recommendedCloudModel(providerId: string, listed: string[]): string | null { + const preferred = RECOMMENDED_CHAT[providerId] ?? []; + // An empty listing (some gateways) still gets a sensible default. + if (!listed.length) return preferred[0] ?? null; + return chatModels(providerId, listed)[0] ?? null; +} diff --git a/backend/src/lib/llm/friendlyError.ts b/backend/src/lib/llm/friendlyError.ts index 5f31479..49e273f 100644 --- a/backend/src/lib/llm/friendlyError.ts +++ b/backend/src/lib/llm/friendlyError.ts @@ -1,42 +1,122 @@ import { WatchmanUnavailableError } from "../screening/watchman"; -/** Map provider and infra errors to user-readable messages. */ -export function friendlyLlmError(err: unknown): string { - if (err instanceof WatchmanUnavailableError) { - return err.message; - } +export type LlmErrorKind = + | "auth" + | "quota" + | "rate-limit" + | "overloaded" + | "no-key" + | "no-tools" + | "model-missing" + | "local-down" + | "unreachable" + | "offline" + | "timeout" + | "unknown"; - const msg = - err instanceof Error ? err.message : typeof err === "string" ? err : "Request failed"; +const LOCAL_PORTS = /:(11434|1234|8080)\b/; + +function errorCode(err: unknown): string | undefined { + const e = err as { code?: unknown; cause?: { code?: unknown; cause?: { code?: unknown } } } | null; + const code = e?.code ?? e?.cause?.code ?? e?.cause?.cause?.code; + return typeof code === "string" ? code : undefined; +} + +function httpStatus(err: unknown): number | undefined { + const s = (err as { status?: unknown } | null)?.status; + return typeof s === "number" ? s : undefined; +} +/** Classify provider and network errors so callers can offer the right fix. */ +export function classifyLlmError(err: unknown): { kind: LlmErrorKind; message: string } { + const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "Request failed"; + const code = errorCode(err); + const status = httpStatus(err); + // fetch() puts the useful part ("connect ECONNREFUSED 127.0.0.1:11434") on the cause. + const causeMsg = (err as { cause?: { message?: unknown } } | null)?.cause?.message; + const detail = typeof causeMsg === "string" ? `${msg} ${causeMsg}` : msg; + const lower = detail.toLowerCase(); + + if (lower.includes("api key for provider")) { + return { kind: "no-key", message: "No API key is saved for this provider. Add one in the AI Control Center." }; + } + if (lower.includes("does not support tools")) { + return { + kind: "no-tools", + message: "This model can't call tools. Pick a tool-capable model, or turn tool calling off for it in the AI Control Center.", + }; + } if ( - msg.includes("authentication_error") || - msg.includes("invalid x-api-key") || - msg.includes("401") + status === 402 || + lower.includes("insufficient_quota") || + lower.includes("credit balance") || + lower.includes("billing") || + lower.includes("exceeded your current quota") ) { - return "The model provider rejected the API key. Update the key in model settings (or `.env`) and restart the backend."; + return { + kind: "quota", + message: "Your account with this provider is out of credit or has no billing set up. Check billing on the provider's website.", + }; } - - if (msg.includes("rate_limit") || msg.includes("429")) { - return "AI rate limit reached. Wait a moment and try again."; + if ( + status === 401 || + status === 403 || + lower.includes("authentication_error") || + lower.includes("invalid x-api-key") || + lower.includes("incorrect api key") || + lower.includes("invalid api key") || + lower.includes("api key not valid") || + /\b401\b/.test(msg) + ) { + return { kind: "auth", message: "The provider rejected the API key. Paste a new key in the AI Control Center." }; } - - if (msg.includes("overloaded") || msg.includes("529")) { - return "AI service is temporarily overloaded. Try again shortly."; + if (status === 429 || lower.includes("rate_limit") || /\b429\b/.test(msg)) { + return { kind: "rate-limit", message: "AI rate limit reached. Wait a moment and try again." }; } - - if (msg.includes("API key for provider")) { - return "No API key is configured for the selected model provider."; + if (status === 529 || lower.includes("overloaded")) { + return { kind: "overloaded", message: "The AI service is temporarily overloaded. Try again shortly." }; } - - if (msg.includes("does not support tools")) { - return "The selected local model does not support tool calling. Pick a tool-capable model or disable tools for it in model settings."; + if ( + status === 404 || + /model ['"]?[^'"]+['"]? not found/i.test(msg) || + lower.includes("model_not_found") || + (lower.includes("model") && lower.includes("does not exist")) + ) { + return { + kind: "model-missing", + message: "That model isn't available. It may not be downloaded, may be retired, or your key may not have access to it.", + }; } - - if (msg.includes("ECONNREFUSED") && /:(11434|1234|8080)\b/.test(msg)) { - return "The local model server is not running. Start Ollama / LM Studio and try again."; + if (code === "ECONNREFUSED" || lower.includes("econnrefused")) { + return LOCAL_PORTS.test(detail) + ?{ kind: "local-down", message: "The local model server isn't running. Start Ollama / LM Studio and try again." } + : { kind: "unreachable", message: "The model server refused the connection. Check that it's running and the URL is right." }; } + if ( + code === "ENOTFOUND" || + code === "EAI_AGAIN" || + code === "ENETUNREACH" || + code === "ECONNRESET" || + lower.includes("connection error") || + lower.includes("fetch failed") + ) { + return { kind: "offline", message: "Couldn't reach the provider. Check your internet connection and try again." }; + } + if ( + code === "ETIMEDOUT" || + code === "UND_ERR_CONNECT_TIMEOUT" || + (err as { name?: string } | null)?.name === "TimeoutError" || + lower.includes("timed out") + ) { + return { kind: "timeout", message: "The provider took too long to answer. Try again." }; + } + return { kind: "unknown", message: msg }; +} - - return msg; +/** Map provider and infra errors to user-readable messages. */ +export function friendlyLlmError(err: unknown): string { + if (err instanceof WatchmanUnavailableError) { + return err.message; + } + return classifyLlmError(err).message; } diff --git a/backend/src/routes/aiControlCenter.ts b/backend/src/routes/aiControlCenter.ts index ad66e23..41e216b 100644 --- a/backend/src/routes/aiControlCenter.ts +++ b/backend/src/routes/aiControlCenter.ts @@ -2,7 +2,6 @@ import express from "express"; import { buildProvider, describeLlmConfig, - mergeWithPreset, isProviderLocal, PROVIDER_PRESETS, responseText, @@ -11,7 +10,6 @@ import { } from "../lib/llm/providers"; import { deleteProviderKey, - getProviderKey, providerKeyInfo, readAiConfig, setProviderKey, @@ -19,17 +17,34 @@ import { type StoredProvider, } from "../lib/llm/controlCenter/store"; import { + DISK_HEADROOM_GB, deleteOllamaModel, detectHardware, + findOllamaInstall, lmStudioStatus, MODEL_CATALOG, modelFit, ollamaCapabilities, + ollamaIsLocal, ollamaStatus, pullOllamaModel, recommendedChatModel, + sameOllamaModel, + startOllama, } from "../lib/llm/controlCenter/localRuntime"; -import { friendlyLlmError } from "../lib/llm/friendlyError"; +import { + chatModels, + CLOUD_EMBEDDING, + keyFormatProblem, + normalizeApiKey, + providerHasKey, + providerNeedsKey, + recommendedCloudModel, + resolveProvider, + storedProviders, +} from "../lib/llm/controlCenter/providers"; +import { checkAiHealth, invalidateHealthCache } from "../lib/llm/controlCenter/health"; +import { classifyLlmError, friendlyLlmError } from "../lib/llm/friendlyError"; /** * AI Control Center — the user's own model providers, keys, and assignments. @@ -40,26 +55,8 @@ export const aiControlCenterRouter = express.Router(); const ROLES: LlmRole[] = ["agent", "utility", "embedding"]; const ID_RE = /^[a-z0-9][a-z0-9-]{1,39}$/; -/** Providers that need no key (local servers). */ -const KEYLESS = new Set(["ollama", "lmstudio", "llamacpp"]); - -function storedProviders(): StoredProvider[] { - return readAiConfig()?.providers ?? []; -} - -/** Full provider config for `id`: stored overrides on top of the preset, plus the decrypted key. */ -function resolveProvider(id: string): ProviderConfig | null { - const stored = storedProviders().find((p) => p.id === id); - const preset = PROVIDER_PRESETS[id]; - if (!stored && !preset) return null; - const merged = mergeWithPreset({ id, ...(preset ?? {}), ...(stored ?? {}) } as ProviderConfig); - const key = getProviderKey(id); - return key ? { ...merged, apiKey: key } : merged; -} - function providerView(p: ProviderConfig, enabled: boolean) { const key = providerKeyInfo(p.id); - const keyless = KEYLESS.has(p.id) || isProviderLocal(p); return { id: p.id, label: p.label ?? p.id, @@ -68,13 +65,19 @@ function providerView(p: ProviderConfig, enabled: boolean) { local: isProviderLocal(p), preset: p.id in PROVIDER_PRESETS, enabled, - needsKey: !keyless, + needsKey: providerNeedsKey(p), key: { configured: key.configured, hint: key.hint, updatedAt: key.updatedAt }, // Dev only: an env var can stand in for an in-app key. envKey: !key.configured && !!p.apiKeyEnv && !!process.env[p.apiKeyEnv]?.trim(), }; } +/** Roles currently bound to a provider (utility counts when it inherits the agent's). */ +function rolesUsing(providerId: string): LlmRole[] { + const roles = readAiConfig()?.roles ?? {}; + return ROLES.filter((r) => (roles[r] ?? (r === "utility" ? roles.agent : undefined))?.provider === providerId); +} + aiControlCenterRouter.get("/state", (_req, res) => { const cfg = readAiConfig(); const stored = storedProviders(); @@ -88,21 +91,32 @@ aiControlCenterRouter.get("/state", (_req, res) => { }); }); +/** Live check of every role: runtime up, model present, key accepted. `?fresh=1` skips the 20 s cache. */ +aiControlCenterRouter.get("/health", async (req, res) => { + res.json(await checkAiHealth({ fresh: req.query.fresh === "1" })); +}); + aiControlCenterRouter.put("/providers/:id", (req, res) => { const id = req.params.id; if (!ID_RE.test(id)) { res.status(400).json({ detail: "Provider id must be 2–40 lowercase letters, digits or dashes" }); return; } - const { label, baseUrl, kind, local } = req.body as Partial; + const { label, baseUrl: rawUrl, kind, local } = req.body as Partial; + const baseUrl = typeof rawUrl === "string" ? rawUrl.trim() : undefined; const preset = PROVIDER_PRESETS[id]; if (!preset && !baseUrl) { res.status(400).json({ detail: "Custom providers need a base URL" }); return; } - if (baseUrl && !/^https?:\/\//.test(baseUrl)) { - res.status(400).json({ detail: "Base URL must start with http:// or https://" }); - return; + if (baseUrl) { + try { + const u = new URL(baseUrl); + if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error(); + } catch { + res.status(400).json({ detail: "Enter a full URL starting with http:// or https://, e.g. http://127.0.0.1:8000/v1" }); + return; + } } updateAiConfig((cfg) => { const next: StoredProvider = { @@ -114,6 +128,7 @@ aiControlCenterRouter.put("/providers/:id", (req, res) => { }; cfg.providers = [...cfg.providers.filter((p) => p.id !== id), next]; }); + invalidateHealthCache(); res.json({ ok: true }); }); @@ -124,20 +139,44 @@ aiControlCenterRouter.delete("/providers/:id", (req, res) => { cfg.providers = cfg.providers.filter((p) => p.id !== id); for (const role of ROLES) if (cfg.roles[role]?.provider === id) delete cfg.roles[role]; }); + invalidateHealthCache(); res.json({ ok: true }); }); -aiControlCenterRouter.put("/providers/:id/key", (req, res) => { +/** + * Save an API key. The key is checked against the provider first and is only + * stored if the provider accepts it; `skipVerify` saves without that check + * (offline, or a gateway that has no model listing). + */ +aiControlCenterRouter.put("/providers/:id/key", async (req, res) => { const id = req.params.id; - const apiKey = String((req.body as { apiKey?: unknown }).apiKey ?? "").trim(); - if (!resolveProvider(id)) { + const body = req.body as { apiKey?: unknown; skipVerify?: unknown }; + const apiKey = normalizeApiKey(String(body.apiKey ?? "")); + const pc = resolveProvider(id); + if (!pc) { res.status(404).json({ detail: "Unknown provider" }); return; } - if (apiKey.length < 8 || apiKey.length > 512 || /\s/.test(apiKey)) { - res.status(400).json({ detail: "That does not look like an API key" }); + const problem = keyFormatProblem(id, apiKey); + if (problem) { + res.status(400).json({ detail: problem, reason: "format" }); return; } + + let modelCount: number | null = null; + if (body.skipVerify !== true) { + try { + const provider = buildProvider({ ...pc, apiKey }); + modelCount = (await provider.listModels?.())?.length ?? null; + } catch (err) { + const { kind, message } = classifyLlmError(err); + // Rejected keys are never stored; network trouble can be saved anyway. + const reason = kind === "auth" || kind === "quota" ? "rejected" : "unverified"; + res.status(reason === "rejected" ? 422 : 503).json({ detail: message, reason, kind }); + return; + } + } + setProviderKey(id, apiKey); updateAiConfig((cfg) => { if (!cfg.providers.some((p) => p.id === id)) { @@ -145,12 +184,14 @@ aiControlCenterRouter.put("/providers/:id/key", (req, res) => { cfg.providers.push({ id, kind: preset?.kind ?? "openai-compatible" }); } }); - res.json({ ok: true, key: providerKeyInfo(id) }); + invalidateHealthCache(); + res.json({ ok: true, key: providerKeyInfo(id), verified: body.skipVerify !== true, modelCount }); }); aiControlCenterRouter.delete("/providers/:id/key", (req, res) => { deleteProviderKey(req.params.id); - res.json({ ok: true }); + invalidateHealthCache(); + res.json({ ok: true, affectedRoles: rolesUsing(req.params.id) }); }); aiControlCenterRouter.get("/providers/:id/models", async (req, res) => { @@ -161,10 +202,14 @@ aiControlCenterRouter.get("/providers/:id/models", async (req, res) => { } try { const provider = buildProvider(pc); - const models = (await provider.listModels?.()) ?? []; - res.json({ models: models.sort() }); + const models = ((await provider.listModels?.()) ?? []).sort(); + res.json({ + models, + chatModels: pc.kind === "ollama" ? models.filter((m) => !/embed/i.test(m)) : chatModels(pc.id, models), + recommended: pc.kind === "ollama" ? null : recommendedCloudModel(pc.id, models), + }); } catch (err) { - res.status(502).json({ detail: friendlyLlmError(err) }); + res.status(502).json({ detail: friendlyLlmError(err), kind: classifyLlmError(err).kind }); } }); @@ -240,6 +285,10 @@ aiControlCenterRouter.put("/roles", async (req, res) => { res.status(400).json({ detail: `Role "${role}" needs a known provider and a model` }); return; } + if (b && role === "embedding" && !buildProvider(resolveProvider(b.provider)!).embed) { + res.status(400).json({ detail: `${resolveProvider(b.provider)!.label ?? b.provider} has no embedding models` }); + return; + } } // Fill in tool support for Ollama models when the caller did not say. @@ -282,16 +331,27 @@ aiControlCenterRouter.put("/policy", (req, res) => { aiControlCenterRouter.get("/local", async (_req, res) => { const hw = detectHardware(); const [ollama, lmstudio] = await Promise.all([ollamaStatus(), lmStudioStatus()]); - const installed = new Set(ollama.models.map((m) => m.name.replace(/:latest$/, ""))); res.json({ hardware: hw, - ollama, + ollama: { ...ollama, installed: ollama.running || !!findOllamaInstall(), canStart: ollamaIsLocal() && !!findOllamaInstall() }, lmstudio, recommended: recommendedChatModel(hw), - catalog: MODEL_CATALOG.map((m) => ({ ...m, fit: modelFit(m, hw), installed: installed.has(m.id) })), + catalog: MODEL_CATALOG.map((m) => ({ + ...m, + fit: modelFit(m, hw), + installed: ollama.models.some((i) => sameOllamaModel(i.name, m.id)), + })), }); }); +/** Launch an installed Ollama and wait until it answers. */ +aiControlCenterRouter.post("/local/start", async (_req, res) => { + const r = await startOllama(); + invalidateHealthCache(); + if (r.started) res.json({ ok: true }); + else res.status(409).json({ detail: r.detail }); +}); + /** SSE: streams `ollama pull` progress as { status, completed, total, percent }. */ aiControlCenterRouter.post("/local/pull", async (req, res) => { const model = String((req.body as { model?: unknown }).model ?? "").trim(); @@ -299,6 +359,19 @@ aiControlCenterRouter.post("/local/pull", async (req, res) => { res.status(400).json({ detail: "Invalid model name" }); return; } + if (!(await ollamaStatus()).running) { + res.status(409).json({ detail: "Ollama isn't running. Start it, then download again." }); + return; + } + const known = MODEL_CATALOG.find((m) => sameOllamaModel(m.id, model)); + const disk = detectHardware().freeDiskGB; + if (known && disk !== null && disk < known.sizeGB + DISK_HEADROOM_GB) { + res.status(507).json({ + detail: `Not enough disk space: ${model} needs about ${known.sizeGB + DISK_HEADROOM_GB} GB free and this computer has ${disk} GB.`, + }); + return; + } + res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("X-Accel-Buffering", "no"); @@ -321,12 +394,14 @@ aiControlCenterRouter.post("/local/pull", async (req, res) => { } catch (err) { if (!abort.signal.aborted) send({ type: "error", detail: friendlyLlmError(err) }); } + invalidateHealthCache(); res.end(); }); aiControlCenterRouter.delete("/local/models/:name", async (req, res) => { try { await deleteOllamaModel(req.params.name); + invalidateHealthCache(); res.json({ ok: true }); } catch (err) { res.status(502).json({ detail: friendlyLlmError(err) }); @@ -334,31 +409,47 @@ aiControlCenterRouter.delete("/local/models/:name", async (req, res) => { }); /** - * One-click on-device setup: Ollama for chat (best installed tool-capable model) - * and embeddings, Local-only mode on. + * One-click on-device setup: Ollama for chat (the given model, or the best + * installed tool-capable one) and embeddings, Local-only mode on. */ -aiControlCenterRouter.post("/quick-setup/local", async (_req, res) => { +aiControlCenterRouter.post("/quick-setup/local", async (req, res) => { + const requested = String((req.body as { model?: unknown } | undefined)?.model ?? "").trim(); const ollama = await ollamaStatus(); if (!ollama.running) { - res.status(409).json({ detail: "Ollama is not running. Install it from ollama.com and open it, then try again." }); + res.status(409).json({ detail: "Ollama isn't running. Start it, then try again." }); return; } const names = ollama.models.map((m) => m.name); - const withTools: Array<{ name: string; sizeGB: number }> = []; - for (const m of ollama.models) { - const caps = await ollamaCapabilities(m.name); - if (caps?.includes("tools")) withTools.push(m); - } const hw = detectHardware(); - // Largest tool-capable model that still fits in memory. - const chat = withTools - .filter((m) => m.sizeGB + 2.5 <= hw.totalMemGB) - .sort((a, b) => b.sizeGB - a.sizeGB)[0]?.name; - if (!chat) { - res.status(409).json({ - detail: `No tool-capable chat model is installed. Download ${recommendedChatModel(hw)} from the Local models section first.`, - }); - return; + + let chat: string | undefined; + if (requested) { + chat = names.find((n) => sameOllamaModel(n, requested)); + if (!chat) { + res.status(409).json({ detail: `${requested} isn't downloaded yet.` }); + return; + } + const caps = await ollamaCapabilities(chat); + if (caps && !caps.includes("tools")) { + res.status(409).json({ detail: `${requested} can't use tools, so the assistant couldn't run screenings. Pick a model marked "tools".` }); + return; + } + } else { + const withTools: Array<{ name: string; sizeGB: number }> = []; + for (const m of ollama.models) { + const caps = await ollamaCapabilities(m.name); + if (caps?.includes("tools")) withTools.push(m); + } + // Largest tool-capable model that still fits in memory. + chat = withTools + .filter((m) => m.sizeGB + 2.5 <= hw.totalMemGB) + .sort((a, b) => b.sizeGB - a.sizeGB)[0]?.name; + if (!chat) { + res.status(409).json({ + detail: `No tool-capable chat model is downloaded yet. Download ${recommendedChatModel(hw)} first.`, + }); + return; + } } const embed = names.find((n) => /embed/.test(n)); @@ -367,7 +458,65 @@ aiControlCenterRouter.post("/quick-setup/local", async (_req, res) => { cfg.roles.agent = { provider: "ollama", model: chat, supportsTools: true }; cfg.roles.utility = { provider: "ollama", model: chat, supportsTools: true }; if (embed) cfg.roles.embedding = { provider: "ollama", model: embed }; + else delete cfg.roles.embedding; cfg.localOnly = true; }); + invalidateHealthCache(); res.json({ ok: true, chat, embed: embed ?? null, effective: describeLlmConfig() }); }); + +/** + * One-click cloud setup: the provider's model for chat and background tasks, + * Local-only mode off. Document search uses the provider's embeddings when it + * has them, otherwise keyword search — people choosing cloud often can't run + * local models, so nothing here depends on Ollama. + */ +aiControlCenterRouter.post("/quick-setup/cloud", async (req, res) => { + const { provider: id, model: requested } = req.body as { provider?: string; model?: string }; + const pc = id ? resolveProvider(id) : null; + if (!pc || !id) { + res.status(404).json({ detail: "Unknown provider" }); + return; + } + if (providerNeedsKey(pc) && !providerHasKey(pc)) { + res.status(409).json({ detail: `Save your ${pc.label ?? id} API key first.` }); + return; + } + + let listed: string[] = []; + try { + listed = (await buildProvider(pc).listModels?.()) ?? []; + } catch (err) { + const { kind, message } = classifyLlmError(err); + if (kind === "auth" || kind === "quota") { + res.status(422).json({ detail: message }); + return; + } + // Offline: still save the choice; the status card shows the connection problem. + } + const model = requested?.trim() || recommendedCloudModel(id, listed); + if (!model) { + res.status(409).json({ detail: `${pc.label ?? id} didn't list any chat models. Type a model name under Advanced settings.` }); + return; + } + if (listed.length && !listed.includes(model)) { + res.status(409).json({ detail: `${pc.label ?? id} doesn't offer "${model}" to your account. Pick one from the list.` }); + return; + } + + const embedding = CLOUD_EMBEDDING[id] ? { provider: id, model: CLOUD_EMBEDDING[id] } : null; + const embeddingNote = embedding ? `${pc.label ?? id} (${embedding.model})` : "keyword search"; + + updateAiConfig((cfg) => { + for (const p of [id, ...(embedding ? [embedding.provider] : [])]) { + if (!cfg.providers.some((s) => s.id === p)) cfg.providers.push({ id: p, kind: PROVIDER_PRESETS[p]?.kind ?? "openai-compatible" }); + } + cfg.roles.agent = { provider: id, model, supportsTools: true }; + cfg.roles.utility = { provider: id, model, supportsTools: true }; + if (embedding) cfg.roles.embedding = embedding; + else delete cfg.roles.embedding; + cfg.localOnly = false; + }); + invalidateHealthCache(); + res.json({ ok: true, chat: model, embedding: embeddingNote, effective: describeLlmConfig() }); +}); diff --git a/frontend/src/app/(pages)/ai/page.tsx b/frontend/src/app/(pages)/ai/page.tsx index e4041a4..491133d 100644 --- a/frontend/src/app/(pages)/ai/page.tsx +++ b/frontend/src/app/(pages)/ai/page.tsx @@ -1,62 +1,272 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; -import { Loader2 } from "lucide-react"; -import { aiApi, type AiState, type LocalState } from "@/lib/aiControlCenterApi"; -import { PrivacyCard } from "@/app/components/ai/PrivacyCard"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { CheckCircle2, ChevronDown, Loader2, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + aiApi, + OLLAMA_DOWNLOAD_URL, + type AiHealth, + type AiState, + type FixAction, + type LlmRole, + type LocalState, +} from "@/lib/aiControlCenterApi"; +import { AiStatusCard } from "@/app/components/ai/AiStatusCard"; +import { SetupWizard, type SetupPath } from "@/app/components/ai/SetupWizard"; import { RoleAssignments } from "@/app/components/ai/RoleAssignments"; import { LocalModels } from "@/app/components/ai/LocalModels"; import { ProviderList } from "@/app/components/ai/ProviderList"; +import { openExternal, useModelDownload } from "@/app/components/ai/useModelDownload"; + +type Wizard = { open: boolean; path?: SetupPath; provider?: string }; export default function AiControlCenterPage() { const [state, setState] = useState(null); const [local, setLocal] = useState(null); - const [error, setError] = useState(null); + const [health, setHealth] = useState(null); + const [loadError, setLoadError] = useState(null); + const [checking, setChecking] = useState(false); + const [wizard, setWizard] = useState({ open: false }); + const [advancedOpen, setAdvancedOpen] = useState(false); + const [notice, setNotice] = useState(null); + const [busyFix, setBusyFix] = useState(null); + const [fixError, setFixError] = useState(null); + const [savingPolicy, setSavingPolicy] = useState(false); + const autoOpened = useRef(false); + + const checkHealth = useCallback(async (fresh = true) => { + setChecking(true); + try { + setHealth(await aiApi.health(fresh)); + } catch { + // The status card keeps its last result; loadError covers a dead backend. + } finally { + setChecking(false); + } + }, []); const refresh = useCallback(async () => { try { const [s, l] = await Promise.all([aiApi.state(), aiApi.local()]); setState(s); setLocal(l); - setError(null); + setLoadError(null); } catch (e) { - setError((e as Error).message); + setLoadError((e as Error).message); } - }, []); + void checkHealth(true); + }, [checkHealth]); + + const download = useModelDownload(refresh); useEffect(() => { void refresh(); }, [refresh]); - // While Ollama is missing, keep checking so installing it "just works". + // First visit with nothing configured: go straight to setup. useEffect(() => { - if (!local || local.ollama.running) return; - const t = setInterval(() => void aiApi.local().then(setLocal).catch(() => {}), 5000); + if (health?.overall === "not-set-up" && !autoOpened.current) { + autoOpened.current = true; + setWizard({ open: true }); + } + }, [health?.overall]); + + // While Ollama is down, keep checking so installing or opening it "just works". + const ollamaRunning = local?.ollama.running; + useEffect(() => { + if (ollamaRunning !== false) return; + const t = setInterval(() => { + void aiApi + .local() + .then((l) => { + setLocal(l); + if (l.ollama.running) void checkHealth(true); + }) + .catch(() => {}); + }, 3000); return () => clearInterval(t); - }, [local]); + }, [ollamaRunning, checkHealth]); + + // Problems can fix themselves (network back, server started elsewhere). + useEffect(() => { + if (health?.overall !== "attention") return; + const t = setInterval(() => void checkHealth(false), 20_000); + return () => clearInterval(t); + }, [health?.overall, checkHealth]); + + const reveal = (id: string) => { + setAdvancedOpen(true); + requestAnimationFrame(() => + requestAnimationFrame(() => { + const el = document.getElementById(id); + el?.scrollIntoView({ behavior: "smooth", block: "center" }); + el?.querySelector("input, select")?.focus({ preventScroll: true }); + }), + ); + }; + + const setLocalOnly = async (on: boolean) => { + setSavingPolicy(true); + try { + await aiApi.setLocalOnly(on); + await refresh(); + } finally { + setSavingPolicy(false); + } + }; + + const toggleLocalOnly = () => { + if (!health) return; + const cloud = Object.values(health.roles).filter((r) => r.binding && !r.binding.local); + if (!health.localOnly) { + if ( + cloud.length && + !confirm( + `Turn on Local-only mode?\n\n${[...new Set(cloud.map((r) => r.binding!.label))].join(", ")} is a cloud provider, so the AI features using it will stop until you pick on-device models.`, + ) + ) + return; + } else if (!confirm("Turn off Local-only mode?\n\nCloud models you choose will be allowed to receive cap-table details you ask the AI about.")) { + return; + } + void setLocalOnly(!health.localOnly); + }; + + const onFix = async (fix: FixAction, role: LlmRole) => { + setFixError(null); + switch (fix.action) { + case "setup": + setWizard({ open: true }); + return; + case "get-ollama": + openExternal(OLLAMA_DOWNLOAD_URL); + setWizard({ open: true, path: "local" }); + return; + case "download-model": + if (await download.start(fix.model)) setNotice(`${fix.model} is downloaded.`); + return; + case "add-key": + reveal(`provider-${fix.provider}`); + return; + case "edit-role": + reveal(`role-${role}`); + return; + case "turn-off-local-only": + if (confirm("Allow cloud AI?\n\nThis turns off Local-only mode. Cloud models you choose will receive cap-table details you ask the AI about.")) { + await setLocalOnly(false); + } + return; + case "start-ollama": + setBusyFix(`${role}:${fix.action}`); + try { + await aiApi.startOllama(); + setNotice("Ollama is running."); + } catch (e) { + setFixError((e as Error).message); + } finally { + setBusyFix(null); + await refresh(); + } + return; + } + }; + + const finishSetup = (message: string) => { + setWizard({ open: false }); + setNotice(message); + void refresh(); + }; return (

AI Control Center

-

- Choose where CapScreen's AI runs — on this computer, or with your own cloud keys. +

+ Choose where CapScreen's AI runs — on this computer, or with your own cloud API key.

-
- {error && ( -
{error}
+
+ {loadError && ( +
+ Couldn't load AI settings: {loadError} + +
)} - {!state ? ( -
- Loading… + {notice && ( +
+ + {notice} +
+ )} + + {!state || !health ? ( + !loadError && ( +
+ Checking your AI setup… +
+ ) ) : ( <> - - - - + {!(wizard.open && health.overall === "not-set-up") && ( + void onFix(f, r)} + onRecheck={() => void refresh()} + onChangeSetup={() => setWizard({ open: true })} + onToggleLocalOnly={toggleLocalOnly} + /> + )} + {fixError &&

{fixError}

} + + {wizard.open && ( + setWizard({ open: false })} + onDone={finishSetup} + onKeySaved={() => void refresh()} + onLocalChanged={() => void refresh()} + /> + )} + +
+ + {advancedOpen && ( +
+ void refresh()} /> + void refresh()} /> + void refresh()} /> +
+ )} +
)}
diff --git a/frontend/src/app/components/ai/AiStatusCard.tsx b/frontend/src/app/components/ai/AiStatusCard.tsx new file mode 100644 index 0000000..663a200 --- /dev/null +++ b/frontend/src/app/components/ai/AiStatusCard.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { AlertTriangle, CheckCircle2, Cloud, HardDrive, Loader2, RefreshCw, Settings2, Sparkles, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ROLE_INFO, type AiHealth, type FixAction, type LlmRole, type RoleHealth } from "@/lib/aiControlCenterApi"; +import type { ModelDownloader } from "./useModelDownload"; + +const DOT: Record = { + ok: "bg-emerald-500", + warning: "bg-amber-500", + error: "bg-red-500", + off: "bg-gray-300", +}; + +function headline(h: AiHealth): { title: string; subtitle: string; tone: "ok" | "warn" | "new" } { + if (h.overall === "not-set-up") { + return { + title: "AI isn't set up yet", + subtitle: "Sanctions screening works without it. Set up AI to use the assistant, match explanations and document search.", + tone: "new", + }; + } + const bindings = Object.values(h.roles).flatMap((r) => (r.binding ? [r.binding] : [])); + const cloud = [...new Set(bindings.filter((b) => !b.local).map((b) => b.label))]; + const where = + cloud.length === 0 + ? "Runs on this computer — nothing leaves it." + : `Uses ${cloud.join(" and ")} in the cloud. Cap-table details you ask the AI about are sent there.`; + if (h.overall === "ready") return { title: "AI is ready", subtitle: where, tone: "ok" }; + return { title: "AI needs attention", subtitle: where, tone: "warn" }; +} + +/** Same fix for the same cause (e.g. one "Start Ollama" for every task that needs it). */ +function fixId(f: FixAction): string { + return f.action === "download-model" ? `${f.action}:${f.model}` : f.action === "add-key" ? `${f.action}:${f.provider}` : f.action; +} + +function RoleLine({ + r, + sameAsAgent, + showFix, + busyFix, + download, + onFix, +}: { + r: RoleHealth; + sameAsAgent: boolean; + showFix: boolean; + busyFix: string | null; + download: ModelDownloader; + onFix: (fix: FixAction, role: LlmRole) => void; +}) { + const downloading = r.fix?.action === "download-model" && download.current?.model === r.fix.model; + const fixKey = r.fix ? `${r.role}:${r.fix.action}` : null; + return ( +
  • + +
    +
    + {ROLE_INFO[r.role].title} + {r.binding && ( + + {r.binding.local ? : } + {sameAsAgent ? "same model as the assistant" : `${r.binding.model} · ${r.binding.local ? "on this computer" : r.binding.label}`} + + )} +
    + {r.status === "ok" ? ( +

    Ready

    + ) : ( +

    + + {r.title}. + {" "} + {/* The first row with this cause already explains it. */} + {r.fix && !showFix ? "" : r.detail} +

    + )} + {downloading && download.current && ( +
    +
    +
    +
    +

    + {download.current.status} + {download.current.progress ? ` · ${download.current.progress}` : ""} +

    +
    + )} +
    + {r.fix && showFix && ( + downloading ? ( + + ) : ( + + ) + )} +
  • + ); +} + +export function AiStatusCard({ + health, + checking, + busyFix, + download, + savingPolicy, + onFix, + onRecheck, + onChangeSetup, + onToggleLocalOnly, +}: { + health: AiHealth; + checking: boolean; + busyFix: string | null; + download: ModelDownloader; + savingPolicy: boolean; + onFix: (fix: FixAction, role: LlmRole) => void; + onRecheck: () => void; + onChangeSetup: () => void; + onToggleLocalOnly: () => void; +}) { + const h = headline(health); + const roles = (["agent", "utility", "embedding"] as LlmRole[]).map((r) => health.roles[r]); + const agent = health.roles.agent; + + const tone = + h.tone === "ok" + ? { box: "border-emerald-200 bg-emerald-50/50", icon: } + : h.tone === "warn" + ? { box: "border-amber-200 bg-amber-50/50", icon: } + : { box: "border-gray-200 bg-gray-50", icon: }; + + return ( +
    +
    +
    {tone.icon}
    +
    +

    {h.title}

    +

    {h.subtitle}

    +
    +
    + +
    +
    + + {health.overall === "not-set-up" ? ( +
    + +
    + ) : ( + <> +
      + {roles.map((r, i) => { + const earlier = roles.slice(0, i).some((o) => o.fix && r.fix && fixId(o.fix) === fixId(r.fix)); + return ( + + ); + })} +
    + {download.error && ( +

    + Couldn't download {download.error.model}: {download.error.message} +

    + )} + + )} + +
    + + {health.overall !== "not-set-up" && ( + + )} +
    +

    + Cap tables, documents and screening results stay on this computer. Sanctions matching always runs locally. +

    +
    + ); +} diff --git a/frontend/src/app/components/ai/CloudSetup.tsx b/frontend/src/app/components/ai/CloudSetup.tsx new file mode 100644 index 0000000..80d192f --- /dev/null +++ b/frontend/src/app/components/ai/CloudSetup.tsx @@ -0,0 +1,269 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Check, ExternalLink, KeyRound, Loader2, ShieldAlert } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { PasswordInput } from "@/components/ui/password-input"; +import { aiApi, AiApiError, CLOUD_CHOICES, type AiState } from "@/lib/aiControlCenterApi"; +import { openExternal } from "./useModelDownload"; + +type KeyStatus = + | { kind: "idle" } + | { kind: "checking" } + | { kind: "error"; message: string } + | { kind: "rejected"; message: string } + | { kind: "unverified"; message: string }; + +type ModelList = { state: "loading" } | { state: "ready"; models: string[]; recommended: string | null } | { state: "failed"; message: string }; + +export function CloudSetup({ + state, + initialProvider, + onKeySaved, + onDone, +}: { + state: AiState; + initialProvider?: string; + onKeySaved: () => void; + onDone: (message: string) => void; +}) { + const [providerId, setProviderId] = useState( + initialProvider && CLOUD_CHOICES.some((c) => c.id === initialProvider) ? initialProvider : CLOUD_CHOICES[0].id, + ); + const choice = CLOUD_CHOICES.find((c) => c.id === providerId)!; + const provider = state.providers.find((p) => p.id === providerId); + const hasKey = !!provider && (provider.key.configured || provider.envKey); + + const [replacing, setReplacing] = useState(false); + const [key, setKey] = useState(""); + const [keyStatus, setKeyStatus] = useState({ kind: "idle" }); + const [list, setList] = useState({ state: "loading" }); + const [model, setModel] = useState(""); + const [consent, setConsent] = useState(false); + const [finishing, setFinishing] = useState(false); + const [error, setError] = useState(null); + + const keyReady = hasKey && !replacing; + + useEffect(() => { + if (!keyReady) return; + let cancelled = false; + aiApi + .models(providerId) + .then((r) => { + if (cancelled) return; + setList({ state: "ready", models: r.chatModels, recommended: r.recommended }); + setModel(r.recommended ?? r.chatModels[0] ?? ""); + }) + .catch((e: Error) => { + if (!cancelled) setList({ state: "failed", message: e.message }); + }); + return () => { + cancelled = true; + }; + }, [keyReady, providerId]); + + const pickProvider = (id: string) => { + setProviderId(id); + setList({ state: "loading" }); + setReplacing(false); + setKey(""); + setKeyStatus({ kind: "idle" }); + setModel(""); + setError(null); + }; + + const saveKey = async (skipVerify = false) => { + setKeyStatus({ kind: "checking" }); + try { + await aiApi.saveKey(providerId, key, { skipVerify }); + setList({ state: "loading" }); + setKey(""); + setReplacing(false); + setKeyStatus({ kind: "idle" }); + onKeySaved(); + } catch (e) { + const err = e as AiApiError; + if (err.reason === "rejected") setKeyStatus({ kind: "rejected", message: err.message }); + else if (err.reason === "unverified") setKeyStatus({ kind: "unverified", message: err.message }); + else setKeyStatus({ kind: "error", message: err.message }); + } + }; + + const finish = async () => { + setFinishing(true); + setError(null); + try { + const r = await aiApi.quickSetupCloud(providerId, model.trim() || undefined); + onDone(`Cloud AI is ready: ${r.chat} (${choice.label}) handles the assistant and background tasks. Document search uses ${r.embedding}.`); + } catch (e) { + setError((e as Error).message); + } finally { + setFinishing(false); + } + }; + + return ( +
    +
    +

    1. Choose a provider

    +
      + {CLOUD_CHOICES.map((c) => { + const p = state.providers.find((x) => x.id === c.id); + const selected = c.id === providerId; + return ( +
    • + +
    • + ); + })} +
    +
    + +
    +

    2. Add your {choice.label} API key

    + {keyReady ? ( +
    + + + {provider?.key.configured ? `Key saved ${provider.key.hint ?? ""}` : "Using the key from .env (dev)"} + + +
    + ) : ( +
    +

    + Your key is encrypted with this computer's keychain and is only ever sent to {choice.label}.{" "} + +

    +
    +
    + { + setKey(e.target.value); + if (keyStatus.kind !== "checking") setKeyStatus({ kind: "idle" }); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && key.trim()) void saveKey(); + }} + placeholder={`Paste key (${choice.keyHint})`} + autoComplete="off" + spellCheck={false} + aria-label={`${choice.label} API key`} + /> +
    + + {replacing && ( + + )} +
    + {keyStatus.kind === "error" &&

    {keyStatus.message}

    } + {keyStatus.kind === "rejected" && ( +

    + {keyStatus.message} The key was not saved. Make sure you copied the whole key, and that billing is set up on + your {choice.label} account. +

    + )} + {keyStatus.kind === "unverified" && ( +
    + Couldn't check the key: {keyStatus.message} +
    + + +
    +
    + )} +
    + )} +
    + +
    +

    3. Pick a model

    + {!keyReady ? ( +

    Available once your key is saved.

    + ) : list.state === "loading" ? ( +

    + Loading the models your key can use… +

    + ) : list.state === "ready" && list.models.length > 0 ? ( +
    + +

    The recommended model balances quality and cost. You can change it any time.

    +
    + ) : ( +
    + setModel(e.target.value)} placeholder="Leave empty for the recommended model" aria-label="Model" /> +

    + {list.state === "failed" ? `Couldn't load the model list: ${list.message}` : `${choice.label} didn't list any models.`} Type a + model name, or leave it empty. +

    +
    + )} +
    + +
    +

    4. Confirm what gets shared

    + +
    + + {error &&

    {error}

    } + +
    + ); +} diff --git a/frontend/src/app/components/ai/LocalModels.tsx b/frontend/src/app/components/ai/LocalModels.tsx index 214edf8..a8de745 100644 --- a/frontend/src/app/components/ai/LocalModels.tsx +++ b/frontend/src/app/components/ai/LocalModels.tsx @@ -1,10 +1,19 @@ "use client"; -import { useRef, useState } from "react"; -import { Cpu, Download, ExternalLink, HardDrive, Loader2, Sparkles, Trash2, Wrench, X } from "lucide-react"; +import { useState } from "react"; +import { Cpu, Download, ExternalLink, HardDrive, Loader2, Play, Sparkles, Trash2, Wrench, X } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { aiApi, type CatalogModel, type Fit, type LocalState } from "@/lib/aiControlCenterApi"; -import { desktop } from "@/lib/desktop"; +import { + aiApi, + OLLAMA_DOWNLOAD_URL, + ROLE_INFO, + type AiState, + type CatalogModel, + type Fit, + type LlmRole, + type LocalState, +} from "@/lib/aiControlCenterApi"; +import { openExternal, type ModelDownloader } from "./useModelDownload"; const FIT_STYLE: Record = { great: { label: "Runs well", className: "bg-emerald-50 text-emerald-700" }, @@ -13,31 +22,40 @@ const FIT_STYLE: Record = { "too-large": { label: "Too large", className: "bg-gray-100 text-gray-500" }, }; -type PullState = { model: string; percent: number | null; status: string; abort: AbortController }; +const DISK_HEADROOM_GB = 2; -function openExternal(url: string) { - const bridge = desktop(); - if (bridge) void bridge.openExternal(url); - else window.open(url, "_blank", "noopener,noreferrer"); +const sameModel = (a: string, b: string) => { + const tag = (s: string) => (s.includes(":") ? s : `${s}:latest`); + return tag(a) === tag(b); +}; + +/** Tasks currently using an Ollama model. */ +function tasksUsing(state: AiState, name: string): string[] { + const roles = (["agent", "utility", "embedding"] as LlmRole[]).filter((r) => { + const b = state.roles[r] ?? (r === "utility" ? state.roles.agent : undefined); + return b?.provider === "ollama" && sameModel(b.model, name); + }); + return roles.map((r) => ROLE_INFO[r].title.toLowerCase()); } export function LocalModels({ local, + state, + download, onChange, }: { local: LocalState | null; + state: AiState; + download: ModelDownloader; onChange: () => void; }) { - const [pull, setPull] = useState(null); const [error, setError] = useState(null); - const [setupBusy, setSetupBusy] = useState(false); - const [setupMsg, setSetupMsg] = useState(null); - const pullRef = useRef(null); + const [starting, setStarting] = useState(false); if (!local) { return (
    -

    Local models

    +

    On-device models

    Checking this computer…
    @@ -47,67 +65,38 @@ export function LocalModels({ const { hardware: hw, ollama } = local; - const startPull = async (model: string) => { + const start = async () => { + setStarting(true); setError(null); - const state: PullState = { model, percent: null, status: "Starting…", abort: new AbortController() }; - pullRef.current = state; - setPull(state); try { - await aiApi.pull( - model, - (e) => { - if (e.type === "progress") { - const next = { ...pullRef.current!, status: e.status, percent: e.percent ?? pullRef.current!.percent }; - pullRef.current = next; - setPull(next); - } else if (e.type === "error") { - setError(e.detail); - } - }, - state.abort.signal, - ); + await aiApi.startOllama(); } catch (e) { - if (!state.abort.signal.aborted) setError((e as Error).message); + setError((e as Error).message); } finally { - pullRef.current = null; - setPull(null); + setStarting(false); onChange(); } }; const remove = async (name: string) => { - if (!confirm(`Delete ${name} from this computer?`)) return; - try { - await aiApi.deleteLocalModel(name); - onChange(); - } catch (e) { - setError((e as Error).message); - } - }; - - const quickSetup = async () => { - setSetupBusy(true); - setSetupMsg(null); + const using = tasksUsing(state, name); + const warning = using.length ? `\n\n${using.join(" and ")} use${using.length === 1 ? "s" : ""} this model and will stop working until you pick another.` : ""; + if (!confirm(`Delete ${name} from this computer?${warning}`)) return; setError(null); try { - const r = await aiApi.quickSetupLocal(); - setSetupMsg( - `Done — ${r.chat} handles chat and background tasks${r.embed ? `, ${r.embed} handles document search` : ""}. Local-only mode is on.`, - ); + await aiApi.deleteLocalModel(name); onChange(); } catch (e) { setError((e as Error).message); - } finally { - setSetupBusy(false); } }; - const chatModels = local.catalog.filter((m) => m.kind === "chat"); - const embedModels = local.catalog.filter((m) => m.kind === "embedding"); + const fitsOnDisk = (m: CatalogModel) => hw.freeDiskGB === null || hw.freeDiskGB >= m.sizeGB + DISK_HEADROOM_GB; const renderModel = (m: CatalogModel) => { const fit = FIT_STYLE[m.fit]; - const pulling = pull?.model === m.id; + const pulling = download.current?.model === m.id; + const diskOk = fitsOnDisk(m); return (
  • @@ -122,53 +111,55 @@ export function LocalModels({
    {m.id}
  • - - {fit.label} - + {fit.label}

    {m.note}

    {m.sizeGB} GB - {m.tools && ( + {m.tools ? ( tools + ) : ( + m.kind === "chat" && no tools )} {m.installed ? ( - Installed + Downloaded ) : pulling ? ( - ) : ( )}
    - {pulling && ( + {pulling && download.current && (
    -
    +
    - {pull?.status} - {pull?.percent != null ? ` · ${pull.percent}%` : ""} + {download.current.status} + {download.current.progress ? ` · ${download.current.progress}` : ""}
    )} @@ -176,42 +167,41 @@ export function LocalModels({ ); }; + const chatModels = local.catalog.filter((m) => m.kind === "chat"); + const embedModels = local.catalog.filter((m) => m.kind === "embedding"); + return (
    -
    -
    -

    Local models

    -

    - Run models on this computer with Ollama — nothing is sent anywhere. -

    -
    - {ollama.running && ( - - )} -
    +

    On-device models

    +

    Run models on this computer with Ollama — nothing is sent anywhere.

    -
    Runtime
    +
    Ollama
    {ollama.running ? (
    - Ollama {ollama.version} running · {ollama.models.length} model - {ollama.models.length === 1 ? "" : "s"} + Running (version {ollama.version}) · {ollama.models.length} model{ollama.models.length === 1 ? "" : "s"} +
    + ) : ollama.installed && ollama.canStart ? ( +
    +
    + + Installed, but not running +
    +
    ) : (
    - Ollama is not running + Not installed
    -

    - Install Ollama (free), open it, then come back — this page refreshes automatically. -

    -
    @@ -232,39 +222,54 @@ export function LocalModels({
    {hw.totalMemGB} GB {hw.unifiedMemory ? "unified memory (shared with GPU)" : "RAM"}
    + {hw.freeDiskGB !== null && ( +
    + + {hw.freeDiskGB} GB free disk +
    + )} {hw.totalMemGB < 12 && (

    - With {hw.totalMemGB} GB, 4B-parameter models run best. Larger ones work but respond - slowly while CapScreen is open. + With {hw.totalMemGB} GB, 4B-parameter models run best. Larger ones work but respond slowly while CapScreen is open.

    )}
    - {setupMsg &&

    {setupMsg}

    } {error &&

    {error}

    } + {download.error && ( +

    + Couldn't download {download.error.model}: {download.error.message} +

    + )} {ollama.models.length > 0 && (
    -

    Installed

    +

    Downloaded

      - {ollama.models.map((m) => ( -
    • - {m.name} - - {m.parameterSize && {m.parameterSize}} - {m.sizeGB} GB - - -
    • - ))} + {ollama.models.map((m) => { + const using = tasksUsing(state, m.name); + return ( +
    • + + {m.name} + {using.length > 0 && used for {using.join(", ")}} + + + {m.parameterSize && {m.parameterSize}} + {m.sizeGB} GB + + +
    • + ); + })}
    )} diff --git a/frontend/src/app/components/ai/LocalSetup.tsx b/frontend/src/app/components/ai/LocalSetup.tsx new file mode 100644 index 0000000..9224716 --- /dev/null +++ b/frontend/src/app/components/ai/LocalSetup.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { useState } from "react"; +import { Check, Download, ExternalLink, Loader2, Play, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { aiApi, OLLAMA_DOWNLOAD_URL, type CatalogModel, type LocalState } from "@/lib/aiControlCenterApi"; +import { openExternal, type ModelDownloader } from "./useModelDownload"; + +const EMBED_MODEL = "nomic-embed-text"; +const DISK_HEADROOM_GB = 2; + +const FIT_NOTE: Record = { + great: "Runs well", + ok: "Fits", + tight: "Tight — slow", + "too-large": "Too large", +}; + +function Step({ n, title, done, active, children }: { n: number; title: string; done: boolean; active: boolean; children?: React.ReactNode }) { + return ( +
  • + + {done ? : n} + +
    +

    {title}

    + {active &&
    {children}
    } +
    +
  • + ); +} + +export function LocalSetup({ + local, + download, + onLocalChanged, + onDone, + onUseCloud, +}: { + local: LocalState | null; + download: ModelDownloader; + onLocalChanged: () => void; + onDone: (message: string) => void; + onUseCloud: () => void; +}) { + const [starting, setStarting] = useState(false); + const [startError, setStartError] = useState(null); + const [picked, setPicked] = useState(null); + const [withSearch, setWithSearch] = useState(true); + const [finishing, setFinishing] = useState(false); + const [error, setError] = useState(null); + + if (!local) { + return ( +

    + Checking this computer… +

    + ); + } + + const { ollama, hardware: hw } = local; + const candidates = local.catalog.filter((m) => m.kind === "chat" && m.tools && m.fit !== "too-large"); + const fallback = + candidates.find((m) => m.installed && m.id === local.recommended) ?? + candidates.find((m) => m.installed && m.fit !== "tight") ?? + candidates.find((m) => m.id === local.recommended) ?? + candidates[0]; + const model = candidates.find((m) => m.id === picked) ?? fallback; + const embed = local.catalog.find((m) => m.id === EMBED_MODEL); + const needEmbedDownload = withSearch && !!embed && !embed.installed; + + const neededGB = (model && !model.installed ? model.sizeGB : 0) + (needEmbedDownload ? embed!.sizeGB : 0); + const diskShort = hw.freeDiskGB !== null && neededGB > 0 && hw.freeDiskGB < neededGB + DISK_HEADROOM_GB; + const busy = finishing || !!download.current; + + const start = async () => { + setStarting(true); + setStartError(null); + try { + await aiApi.startOllama(); + } catch (e) { + setStartError((e as Error).message); + } finally { + setStarting(false); + onLocalChanged(); + } + }; + + const finish = async () => { + if (!model) return; + setFinishing(true); + setError(null); + download.clearError(); + try { + if (!model.installed && !(await download.start(model.id))) return; + // Document search is optional: a failed embed download doesn't block chat. + const searchOk = !needEmbedDownload || (await download.start(EMBED_MODEL)); + const r = await aiApi.quickSetupLocal(model.id); + onDone( + `On-device AI is ready: ${r.chat} handles the assistant and background tasks${ + r.embed ? `, ${r.embed} handles document search` : "" + }. Local-only mode is on.${searchOk ? "" : " Document search couldn't be downloaded — try it again from Advanced settings."}`, + ); + } catch (e) { + setError((e as Error).message); + } finally { + setFinishing(false); + } + }; + + if (!candidates.length) { + return ( +
    + This computer has {hw.totalMemGB} GB of memory, which isn't enough to run a model that can use CapScreen's + screening tools. Use a cloud provider instead. +
    + +
    +
    + ); + } + + return ( +
      + +

      + Ollama is a free app that runs AI models on your computer. CapScreen uses it so nothing leaves this machine. +

      + {ollama.installed && ollama.canStart ? ( +
      + + Ollama is installed but not running. +
      + ) : ( +
      + +

      + + Install it and open it — this page notices automatically. +

      +
      + )} + {startError &&

      {startError}

      } +
      + + +

      + Picked for your {hw.cpu} with {hw.totalMemGB} GB {hw.unifiedMemory ? "unified memory" : "RAM"}. Bigger models give + better answers but respond slower. +

      +
        + {candidates.map((m) => { + const selected = m.id === model?.id; + return ( +
      • + +
      • + ); + })} +
      + {model?.fit === "tight" && ( +

      + {model.label} is a tight fit for {hw.totalMemGB} GB: answers will be slow and other apps may lag while it runs. +

      + )} + + {embed && ( + + )} + + {download.current && ( +
      +
      + + {download.current.status} {download.current.model} + {download.current.progress ? ` · ${download.current.progress}` : ""} + + +
      +
      +
      +
      +

      Keep this page open until the download finishes.

      +
      + )} + + {diskShort && ( +

      + Not enough disk space: this needs about {Math.ceil(neededGB + DISK_HEADROOM_GB)} GB free and you have {hw.freeDiskGB} GB. + Free up space or pick a smaller model. +

      + )} + {download.error &&

      Couldn't download {download.error.model}: {download.error.message}

      } + {error &&

      {error}

      } + +
      + +
      + +
    + ); +} diff --git a/frontend/src/app/components/ai/PrivacyCard.tsx b/frontend/src/app/components/ai/PrivacyCard.tsx deleted file mode 100644 index 941da86..0000000 --- a/frontend/src/app/components/ai/PrivacyCard.tsx +++ /dev/null @@ -1,103 +0,0 @@ -"use client"; - -import { Cloud, HardDrive, Loader2, ShieldCheck, ShieldAlert } from "lucide-react"; -import { useState } from "react"; -import { aiApi, ROLE_INFO, type AiState, type LlmRole } from "@/lib/aiControlCenterApi"; - -export function PrivacyCard({ state, onChange }: { state: AiState; onChange: () => void }) { - const [saving, setSaving] = useState(false); - const roles = (["agent", "utility", "embedding"] as LlmRole[]) - .map((r) => [r, state.effective.roles[r]] as const) - .filter(([, s]) => !!s); - const cloudRoles = roles.filter(([, s]) => s && !s.local); - const allLocal = roles.length > 0 && cloudRoles.length === 0; - - const toggle = async () => { - setSaving(true); - try { - await aiApi.setLocalOnly(!state.localOnly); - onChange(); - } finally { - setSaving(false); - } - }; - - return ( -
    -
    -
    - {allLocal ? : } -
    -
    -

    - {roles.length === 0 - ? "No AI model configured yet" - : allLocal - ? "Everything stays on this computer" - : `${cloudRoles.length} task${cloudRoles.length > 1 ? "s" : ""} send data to a cloud provider`} -

    -

    - Cap tables, documents and screening results are stored only on this device. Sanctions - matching always runs locally. Only the AI tasks below can reach the internet — and only - when you assign them a cloud model. -

    - - {roles.length > 0 && ( -
      - {roles.map(([role, s]) => ( -
    • - {s!.local ? ( - - ) : ( - - )} - - {ROLE_INFO[role].short}:{" "} - {s!.local ? "on-device" : s!.label} - -
    • - ))} -
    - )} -
    - - -
    - {state.localOnly && ( -

    - Local-only mode is on: any cloud model is blocked, even if one is assigned. -

    - )} -
    - ); -} diff --git a/frontend/src/app/components/ai/ProviderList.tsx b/frontend/src/app/components/ai/ProviderList.tsx index d6f83bb..86d4f19 100644 --- a/frontend/src/app/components/ai/ProviderList.tsx +++ b/frontend/src/app/components/ai/ProviderList.tsx @@ -5,21 +5,14 @@ import { Check, ExternalLink, KeyRound, Loader2, Plus, Server, Trash2, Zap } fro import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { PasswordInput } from "@/components/ui/password-input"; -import { aiApi, type AiState, type ProviderView } from "@/lib/aiControlCenterApi"; -import { desktop } from "@/lib/desktop"; +import { aiApi, AiApiError, KEY_LINKS, ROLE_INFO, type AiState, type LlmRole, type ProviderView } from "@/lib/aiControlCenterApi"; +import { openExternal } from "./useModelDownload"; -const KEY_LINKS: Record = { - anthropic: "https://console.anthropic.com/settings/keys", - openai: "https://platform.openai.com/api-keys", - gemini: "https://aistudio.google.com/apikey", - openrouter: "https://openrouter.ai/keys", - groq: "https://console.groq.com/keys", -}; - -function openExternal(url: string) { - const bridge = desktop(); - if (bridge) void bridge.openExternal(url); - else window.open(url, "_blank", "noopener,noreferrer"); +/** Tasks bound to a provider (utility counts when it inherits the assistant's binding). */ +function tasksUsing(state: AiState, providerId: string): string[] { + return (["agent", "utility", "embedding"] as LlmRole[]) + .filter((r) => (state.roles[r] ?? (r === "utility" ? state.roles.agent : undefined))?.provider === providerId) + .map((r) => ROLE_INFO[r].title.toLowerCase()); } function useStatus() { @@ -27,10 +20,11 @@ function useStatus() { return [status, setStatus] as const; } -function CloudProviderCard({ p, onChange }: { p: ProviderView; onChange: () => void }) { +function CloudProviderCard({ p, state, onChange }: { p: ProviderView; state: AiState; onChange: () => void }) { const [key, setKey] = useState(""); const [busy, setBusy] = useState<"save" | "test" | "remove" | null>(null); const [status, setStatus] = useStatus(); + const [unverified, setUnverified] = useState(false); const run = async (kind: "save" | "test" | "remove", fn: () => Promise<{ ok: boolean; text: string } | void>) => { setBusy(kind); @@ -46,8 +40,27 @@ function CloudProviderCard({ p, onChange }: { p: ProviderView; onChange: () => v } }; + const saveKey = (skipVerify: boolean) => + run("save", async () => { + setUnverified(false); + try { + const r = await aiApi.saveKey(p.id, key, { skipVerify }); + setKey(""); + return r.verified + ? { ok: true, text: `Key checked and saved${r.modelCount ? ` · ${r.modelCount} models available` : ""}` } + : { ok: true, text: "Key saved without checking — the status at the top shows whether it works" }; + } catch (e) { + const err = e as AiApiError; + if (err.reason === "unverified") setUnverified(true); + return { + ok: false, + text: err.reason === "rejected" ? `${err.message} The key was not saved.` : err.message, + }; + } + }); + return ( -
  • +
  • @@ -83,20 +96,11 @@ function CloudProviderCard({ p, onChange }: { p: ProviderView; onChange: () => v />
    @@ -124,12 +128,18 @@ function CloudProviderCard({ p, onChange }: { p: ProviderView; onChange: () => v type="button" className="inline-flex items-center gap-1 text-gray-500 hover:text-red-600" disabled={busy !== null} - onClick={() => - run("remove", async () => { + onClick={() => { + const using = tasksUsing(state, p.id); + if ( + using.length && + !confirm(`Remove the ${p.label} key?\n\n${using.join(" and ")} use${using.length === 1 ? "s" : ""} ${p.label} and will stop working until you add a key or pick another model.`) + ) + return; + void run("remove", async () => { await aiApi.removeKey(p.id); return { ok: true, text: "Key removed from this computer" }; - }) - } + }); + }} > Remove key @@ -140,12 +150,17 @@ function CloudProviderCard({ p, onChange }: { p: ProviderView; onChange: () => v {status.text} )} + {unverified && key.trim() && ( + + )}
  • ); } -function ServerRow({ p, onChange }: { p: ProviderView; onChange: () => void }) { +function ServerRow({ p, state, onChange }: { p: ProviderView; state: AiState; onChange: () => void }) { const [baseUrl, setBaseUrl] = useState(p.baseUrl ?? ""); const [status, setStatus] = useStatus(); const [busy, setBusy] = useState(false); @@ -181,6 +196,9 @@ function ServerRow({ p, onChange }: { p: ProviderView; onChange: () => void }) { + ); +} + +export function SetupWizard({ + state, + local, + download, + initial, + cloudProvider, + canClose, + onClose, + onDone, + onKeySaved, + onLocalChanged, +}: { + state: AiState; + local: LocalState | null; + download: ModelDownloader; + initial?: SetupPath; + cloudProvider?: string; + canClose: boolean; + onClose: () => void; + onDone: (message: string) => void; + onKeySaved: () => void; + onLocalChanged: () => void; +}) { + const [path, setPath] = useState(initial ?? null); + const mem = local?.hardware.totalMemGB; + + return ( +
    +
    +
    + {path && ( + + )} +
    +

    + {path === "local" ? "Set up on-device AI" : path === "cloud" ? "Set up cloud AI" : "Where should CapScreen's AI run?"} +

    + {!path &&

    You can switch later. Sanctions matching always runs on this computer either way.

    } +
    +
    + {canClose && ( + + )} +
    + +
    + {!path && ( +
    + } + title="On this computer" + points={["Private: nothing leaves this computer", "Free, no account needed", "Works offline"]} + caveat={`Needs a one-time download of 3–6 GB.${mem && mem < 12 ? ` With ${mem} GB of memory, answers take a little while.` : ""}`} + onClick={() => setPath("local")} + /> + } + title="Cloud provider" + points={["Fastest and most capable answers", "No download", "Works on any computer"]} + caveat="Needs your own API key (paid usage). Cap-table details you ask the AI about are sent to the provider." + onClick={() => setPath("cloud")} + /> +
    + )} + {path === "local" && ( + setPath("cloud")} /> + )} + {path === "cloud" && } +
    +
    + ); +} diff --git a/frontend/src/app/components/ai/useModelDownload.ts b/frontend/src/app/components/ai/useModelDownload.ts new file mode 100644 index 0000000..9f81d07 --- /dev/null +++ b/frontend/src/app/components/ai/useModelDownload.ts @@ -0,0 +1,104 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { aiApi } from "@/lib/aiControlCenterApi"; +import { desktop } from "@/lib/desktop"; + +export type Download = { + model: string; + percent: number | null; + /** Plain-language phase ("Downloading", "Checking the download…"). */ + status: string; + /** "1.2 of 2.5 GB" once Ollama reports sizes. */ + progress: string | null; +}; + +export type ModelDownloader = { + current: Download | null; + error: { model: string; message: string } | null; + /** Resolves true when the model finished downloading. */ + start: (model: string) => Promise; + cancel: () => void; + clearError: () => void; +}; + +const gb = (bytes: number) => (bytes / 1024 ** 3).toFixed(1); + +/** Ollama's raw pull status ("pulling 6a0746a1ec1a", "verifying sha256 digest") in plain words. */ +function phase(status: string): string { + if (/^pulling manifest/i.test(status)) return "Preparing…"; + if (/^pulling /i.test(status)) return "Downloading"; + if (/^verifying/i.test(status)) return "Checking the download…"; + if (/^(writing|removing|success)/i.test(status)) return "Finishing up…"; + return status; +} + +export function openExternal(url: string) { + const bridge = desktop(); + if (bridge) void bridge.openExternal(url); + else window.open(url, "_blank", "noopener,noreferrer"); +} + +/** One Ollama download at a time, shared by every part of the AI Control Center. */ +export function useModelDownload(onFinished: () => void): ModelDownloader { + const [current, setCurrent] = useState(null); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + const start = useCallback( + async (model: string) => { + if (abortRef.current) return false; + const abort = new AbortController(); + abortRef.current = abort; + setError(null); + setCurrent({ model, percent: null, status: "Starting…", progress: null }); + let done = false; + let failure: string | null = null; + try { + await aiApi.pull( + model, + (e) => { + if (e.type === "progress") { + setCurrent((prev) => ({ + model, + status: phase(e.status), + percent: e.percent ?? prev?.percent ?? null, + progress: + e.total && e.completed != null + ? `${gb(e.completed)} of ${gb(e.total)} GB` + : (prev?.progress ?? null), + })); + } else if (e.type === "done") { + done = true; + } else { + failure = e.detail; + } + }, + abort.signal, + ); + if (!done && !failure && !abort.signal.aborted) { + failure = "The download stopped before it finished. Try again — it picks up where it left off."; + } + } catch (e) { + if (!abort.signal.aborted) { + failure = + e instanceof TypeError + ? "Lost the connection to CapScreen's engine during the download. Try again — it picks up where it left off." + : (e as Error).message; + } + } finally { + abortRef.current = null; + setCurrent(null); + if (failure) setError({ model, message: failure }); + onFinished(); + } + return done; + }, + [onFinished], + ); + + const cancel = useCallback(() => abortRef.current?.abort(), []); + const clearError = useCallback(() => setError(null), []); + + return { current, error, start, cancel, clearError }; +} diff --git a/frontend/src/app/components/shared/DesktopShell.tsx b/frontend/src/app/components/shared/DesktopShell.tsx index 5651db6..9054b0d 100644 --- a/frontend/src/app/components/shared/DesktopShell.tsx +++ b/frontend/src/app/components/shared/DesktopShell.tsx @@ -5,7 +5,17 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { Cpu, X } from "lucide-react"; import { useDesktop } from "@/lib/desktop"; -import { aiApi } from "@/lib/aiControlCenterApi"; +import { aiApi, ROLE_INFO, type AiHealth } from "@/lib/aiControlCenterApi"; + +/** What the banner should say, or null when the AI the rest of the app relies on is fine. */ +function aiProblem(h: AiHealth): string | null { + if (h.overall === "not-set-up") { + return "No AI model is set up yet. Screening works, but the assistant needs a model — run one on this computer or add your own API key."; + } + // Document search problems only matter on the AI page; it falls back to keyword search. + const broken = [h.roles.agent, h.roles.utility].find((r) => r.status === "error"); + return broken ? `${ROLE_INFO[broken.role].title}: ${broken.title}. ${broken.detail ?? ""}` : null; +} /** * Desktop chrome: routes menu/tray/notification clicks, provides the macOS @@ -16,7 +26,7 @@ export function DesktopShell({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const bridge = useDesktop(); const isMac = bridge?.platform === "darwin"; - const [needsModel, setNeedsModel] = useState(false); + const [problem, setProblem] = useState(null); const [dismissed, setDismissed] = useState(false); useEffect(() => { @@ -27,9 +37,9 @@ export function DesktopShell({ children }: { children: React.ReactNode }) { useEffect(() => { let cancelled = false; aiApi - .state() - .then((s) => { - if (!cancelled) setNeedsModel(!s.effective.roles.agent || !!s.effective.roles.agent.error); + .health() + .then((h) => { + if (!cancelled) setProblem(aiProblem(h)); }) .catch(() => {}); return () => { @@ -37,7 +47,7 @@ export function DesktopShell({ children }: { children: React.ReactNode }) { }; }, [pathname]); - const showBanner = needsModel && !dismissed && !pathname.startsWith("/ai"); + const showBanner = !!problem && !dismissed && !pathname.startsWith("/ai"); return ( <> @@ -50,12 +60,9 @@ export function DesktopShell({ children }: { children: React.ReactNode }) { {showBanner && (
    - - No AI model is set up yet. Screening works, but the assistant needs a model — run one on this - computer or add your own API key. - - - Open AI Control Center + {problem} + + Fix in AI Control Center