diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 66ad21680..a450d3de1 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -20,6 +20,7 @@ - [ ] `npm run lint` passes - [ ] `npm test` passes (runs `npm run compile` first so `electron/bundle-guard.test.ts` actually executes — `npm run test:bundle` to run just that) - [ ] `npm run test:e2e` passes (run before merging UI changes or cutting a release) +- [ ] If this ships a **major, user-facing feature**: added an entry to `scripts/features.config.js` so it appears in the "What's New" modal (generated JSON — run `node scripts/generate-features.js`) - [ ] No hardcoded colours — CSS variables only (`var(--accent)`, `var(--text-primary)`, etc.) - [ ] No `text-[Npx]` pixel font classes — rem equivalents only (`text-[0.714rem]`, `text-xs`, etc.) - [ ] New IPC handlers wrapped in `handle()` and return `IpcResult` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0be3eae76..aa2da5a01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,9 @@ jobs: - name: Generate licenses (required by type check) run: node scripts/generate-licenses.js + - name: Generate What's New feature registry (required by type check) + run: node scripts/generate-features.js + - name: Type check run: npm run type-check:all @@ -101,6 +104,8 @@ jobs: cp node_modules/better-sqlite3/build/Release/better_sqlite3.node vitest-native/ - name: Run tests + # `npm test` generates licenses + the What's New registry (imported by app + # code under test) and compiles before running vitest. run: npm test # ── Build ─────────────────────────────────────────────────────────────────── @@ -139,6 +144,9 @@ jobs: - name: Generate licenses run: node scripts/generate-licenses.js + - name: Generate What's New feature registry + run: node scripts/generate-features.js + - name: Build run: npx cross-env ELECTRON_BUILD=true npx next build @@ -186,6 +194,11 @@ jobs: - name: Install Playwright system dependencies run: npx playwright install-deps chromium + - name: Generate licenses + What's New registry (baked into the built app) + run: | + node scripts/generate-licenses.js + node scripts/generate-features.js + - name: Run E2E tests run: npm run test:e2e diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9eaa7fa23..fa53f7bd8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -174,6 +174,7 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | node scripts/generate-licenses.js + node scripts/generate-features.js npx cross-env ELECTRON_BUILD=true npx next build npm run compile npm run smoke-test @@ -250,6 +251,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | node scripts/generate-licenses.js + node scripts/generate-features.js npx cross-env ELECTRON_BUILD=true npx next build npm run compile npm run smoke-test @@ -318,6 +320,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | node scripts/generate-licenses.js + node scripts/generate-features.js npx cross-env ELECTRON_BUILD=true npx next build npm run compile npm run smoke-test diff --git a/AGENTS.md b/AGENTS.md index 1d2c917e5..bd1c6decb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,16 @@ Procedure for the relevant target (desktop and/or mobile): This guarantees `release.sh`'s gate (`changelogs/v.md` must exist for the chosen bump) is satisfied, so the pending changelog and the version bump stay in lockstep — you never have to be told which changelog to touch. +### "What's New" modal rule (announce major features) + +The What's New modal (`src/components/layout/NewFeatureModal.tsx`) is generated from a curated source, exactly like `licenses.json`: + +- **Source of truth**: `scripts/features.config.js` (the `FEATURES` array). Hand-authored. +- **Generator**: `scripts/generate-features.js` → `src/generated/new-features.json` (git-ignored, baked into the static export). Runs in `build.js`, the `dev` script, and CI (next to `generate-licenses.js`). +- **Runtime**: `src/lib/new-features-registry.ts` reads the generated JSON — never edit the registry by hand. + +**When you ship a MAJOR, user-facing feature, add an entry to `scripts/features.config.js`** (not every changelog line — only headline features worth announcing on launch; fixes and internal work stay in `changelogs/` only). Append at the end in release order with a stable, never-reused `id` (e.g. `v2.6.0-my-feature`), the release `version` (`vX.Y.Z`), a short `title`, a one-word-ish `category`, a `description`, and 3–4 `highlights` (a `"Prefix: rest"` highlight bolds the prefix). The modal auto-shows unseen entries belonging to the newest `version` present. Run `node scripts/generate-features.js` after editing; it validates the shape and rejects duplicate ids. + ## Views and navigation | View | Key | Component | diff --git a/changelogs/v2.5.9.md b/changelogs/v2.5.9.md index 4153ee38a..1dca20039 100644 --- a/changelogs/v2.5.9.md +++ b/changelogs/v2.5.9.md @@ -7,9 +7,14 @@ ## Changed -- **Cleaner endpoint settings.** Now that saved providers cover switching endpoints, the old OpenAI / Ollama / LM Studio quick-preset pills under the Base URL field have been removed from both the AI settings and the Coding Agent settings — pick or add a saved provider instead. The provider picker now uses Cairn's standard dropdown menu (matching the model picker and other settings) instead of a plain OS select box, and shows each provider's model beneath its name. +- **Cleaner endpoint settings.** Now that saved providers cover switching endpoints, the old OpenAI / Ollama / LM Studio quick-preset pills under the Base URL field have been removed from both the AI settings and the Coding Agent settings — pick or add a saved provider instead. The provider picker now uses Cairn's standard dropdown menu (matching the model picker and other settings) instead of a plain OS select box, and shows each provider's model beneath its name. The standalone Base URL / API Key / Model rows are gone too — a provider's connection is edited entirely inside its Add/Edit form. - **Consistent model picker everywhere.** The model selector from the in-chat quick settings — a searchable dropdown of the endpoint's models with a Refresh action and a "Custom model…" option for anything the endpoint doesn't list — is now used across the app: in Settings → AI, in the Coding Agent settings, and in the saved-provider Add/Edit form. This replaces the older text box + Fetch button + wall of model pills, so choosing a model looks and behaves the same in every place. - **The model dropdown only shows real models.** It no longer pre-fills with a hardcoded list of guessed model names. Instead each endpoint's model list is fetched and cached per endpoint (base URL + key), so it appears instantly the next time you open the picker; if nothing is cached yet, it fetches once automatically. You can still Refresh to re-query or enter a custom model id. Cached lists refresh after a week. +- **Pick the model for the active provider — separately for Chat and the Agent.** The connection (endpoint + key) is shared across your saved providers, but the model is now a per-surface choice: there's a Model picker right under the provider switcher in both Settings → AI and the Coding Agent settings, so you can change the active provider's model without opening its Edit form. Because the choice lives with each surface, AI Chat and the Coding Agent can run **different models on the same provider** (e.g. the agent on a larger model, chat on a cheaper one). Each provider still has a "default model" that seeds a surface the first time it selects that provider. + +## Security + +- **AI provider API keys now live in the OS keychain.** Keys for the cloud/local AI providers (chat + coding agent, one per saved provider) are stored encrypted in your operating system's keychain (macOS Keychain, Windows DPAPI, Linux libsecret) — the same secure store already used for MCP/service credentials — instead of in plain text in the app's settings files or local storage. The app only ever holds an opaque reference token; the real key is decrypted in the background process at request time, and never written to disk unencrypted, kept in the window, or exposed to web content. Existing keys are relocated into the keychain automatically on first launch and scrubbed from the old plaintext locations. Fetching an endpoint's model list now also happens in the background process, so your key isn't handled by the in-app browser layer. The API-key field shows only whether a key is stored ("•••• stored — type to replace"), never the saved key. ## Fixed diff --git a/electron/ipc/ai-handlers.ts b/electron/ipc/ai-handlers.ts index 4f0124f1c..599c6aa3f 100644 --- a/electron/ipc/ai-handlers.ts +++ b/electron/ipc/ai-handlers.ts @@ -11,7 +11,8 @@ import { registerIpcHandle } from "./registry"; import { err, handle, type DbContext } from "./result-helpers"; import { generatePrd } from "../lib/prd"; import { callLLM, isLocalEndpoint, normaliseBaseUrl, type LLMConfig } from "../lib/llm"; -import { saveCachedConfig, getCachedConfig } from "../lib/config-cache"; +import { getCachedConfig, cacheLlmConnection } from "../lib/config-cache"; +import { resolveLlmApiKey } from "../lib/secure-store"; function resolveConfig( config: { baseUrl?: string; model?: string; apiKey?: string } | undefined, @@ -19,7 +20,8 @@ function resolveConfig( ): { error: string } | LLMConfig { const cached = cacheKey === "ai" ? getCachedConfig().aiConfig : getCachedConfig().agentConfig; - // Merge request config with cached config, always merging cached values if not provided. + // Merge request config with cached config, always merging cached values if not + // provided. `apiKey` here is a keychain reference token (or empty), not a raw key. const mergedConfig = { baseUrl: config?.baseUrl || cached?.baseUrl || "", model: config?.model || cached?.model || "", @@ -28,13 +30,14 @@ function resolveConfig( const baseUrl = normaliseBaseUrl(mergedConfig.baseUrl || "https://api.openai.com"); const model = mergedConfig.model || "gpt-4o-mini"; - const apiKey = mergedConfig.apiKey || ""; + const keyRef = mergedConfig.apiKey || ""; const isLocal = isLocalEndpoint(baseUrl); - if (!apiKey && !isLocal) { + if (!keyRef && !isLocal) { const sectionName = cacheKey === "ai" ? "Settings → AI & Chat" : "Settings"; return { error: `AI is not configured. Add an API key in ${sectionName}, or use a local endpoint.` }; } - return { baseUrl, model, apiKey }; + // Resolve the ref to the real key only now, for this request. + return { baseUrl, model, apiKey: resolveLlmApiKey(keyRef) }; } function cleanOutput(text: string): string { @@ -98,6 +101,34 @@ export function registerAiHandlers(ctx: DbContext): void { }); }); + // ── Model discovery (GET {baseUrl}/v1/models) ───── + // Runs in the main process so the API key (a keychain ref) is resolved here + // and the raw key never lives in the renderer or crosses the CSP boundary. + registerIpcHandle( + "ai:fetchModels", + async (_e, args: { baseUrl?: string; apiKey?: string }) => + handle(async () => { + const url = normaliseBaseUrl(args.baseUrl || "https://api.openai.com"); + const realKey = resolveLlmApiKey(args.apiKey); + const headers: Record = {}; + if (realKey) headers["Authorization"] = `Bearer ${realKey}`; + // Abort a hung endpoint after 12s so the picker's spinner can't hang forever. + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), 12_000); + try { + const res = await fetch(`${url}/v1/models`, { headers, signal: ac.signal }); + if (!res.ok) throw new Error(`${res.status}`); + const data = (await res.json()) as { data?: Array<{ id: string }> }; + return (data?.data ?? []) + .map((m) => m.id) + .filter((id) => !id.includes("embed") && !id.includes("whisper") && !id.includes("tts") && !id.includes("dall-e")) + .sort(); + } finally { + clearTimeout(timer); + } + }) + ); + // ── AI PRD generation (direct, no chat loop) ────── registerIpcHandle("ai:generatePrd", async (_e, args: { projectId: string; @@ -105,14 +136,9 @@ export function registerAiHandlers(ctx: DbContext): void { requirements: string; config: { baseUrl: string; model: string; apiKey: string }; }) => { - // PRD returns its own { error } shape for user-facing validation errors - if (args.config?.apiKey) { - saveCachedConfig("ai", { - baseUrl: args.config.baseUrl, - model: args.config.model, - apiKey: args.config.apiKey, - }); - } + // PRD returns its own { error } shape for user-facing validation errors. + // Cache the connection (apiKey scrubbed to a ref-or-clear by the cache layer). + cacheLlmConnection("ai", args.config); const resolved = resolveConfig(args.config, "ai"); if ("error" in resolved) { diff --git a/electron/ipc/chat.ts b/electron/ipc/chat.ts index 2ac69fb91..815d80e89 100644 --- a/electron/ipc/chat.ts +++ b/electron/ipc/chat.ts @@ -14,8 +14,9 @@ import type Database from "better-sqlite3"; import { isLocalEndpoint, normaliseBaseUrl, type OpenAIMessage, calculatePromptBreakdown, scaleBreakdown, type TokenBreakdown } from "../lib/llm"; import { TOOLS, buildSystemPrompt, type ChatRequest } from "../lib/tools"; import { getExternalToolDefs } from "../lib/external-tools"; -import { saveCachedConfig, getCachedConfig } from "../lib/config-cache"; +import { getCachedConfig, cacheLlmConnection } from "../lib/config-cache"; import { runToolLoop } from "../lib/chat-loop"; +import { resolveLlmApiKey } from "../lib/secure-store"; // Track one AbortController per renderer webContents ID const abortControllers = new Map(); @@ -31,14 +32,9 @@ function resolveAIConfig(config?: { model: string; apiKey: string; } { - if (config?.apiKey) { - saveCachedConfig("ai", { - provider: config.provider, - baseUrl: config.baseUrl, - model: config.model, - apiKey: config.apiKey, - }); - } + // Persist the connection (provider/baseUrl/model + a keychain-ref apiKey only; + // cacheLlmConnection scrubs any raw key). The renderer sends a ref, not a key. + cacheLlmConnection("ai", config); let reqConfig = config; const isLocal = config?.baseUrl ? isLocalEndpoint(normaliseBaseUrl(config.baseUrl)) : false; @@ -59,7 +55,9 @@ function resolveAIConfig(config?: { provider: reqConfig?.provider ?? "openai", baseUrl: normaliseBaseUrl(reqConfig?.baseUrl ?? "https://api.openai.com"), model: reqConfig?.model ?? "gpt-4o-mini", - apiKey: reqConfig?.apiKey ?? "", + // Resolve the keychain ref (or pass a literal through) to the real key used + // for this request only. Never stored anywhere from here on. + apiKey: resolveLlmApiKey(reqConfig?.apiKey), }; } diff --git a/electron/ipc/flow-handlers.ts b/electron/ipc/flow-handlers.ts index b284ff55e..619c0eee1 100644 --- a/electron/ipc/flow-handlers.ts +++ b/electron/ipc/flow-handlers.ts @@ -18,7 +18,8 @@ import { registerIpcHandle } from "./registry"; import { handle, type DbContext } from "./result-helpers"; import * as q from "../db/queries"; import { isLocalEndpoint, callLLM, normaliseBaseUrl } from "../lib/llm"; -import { saveCachedConfig, getCachedConfig } from "../lib/config-cache"; +import { getCachedConfig, cacheLlmConnection } from "../lib/config-cache"; +import { resolveLlmApiKey } from "../lib/secure-store"; /** * Resolve the effective AI config (cache fallback + normalisation). @@ -46,12 +47,13 @@ function resolveAiConfig(input: { baseUrl?: string; model?: string; apiKey?: str const baseUrl = normaliseBaseUrl(reqConfig?.baseUrl || "https://api.openai.com"); const model = reqConfig?.model || "gpt-4o-mini"; - const apiKey = reqConfig?.apiKey || ""; + const keyRef = reqConfig?.apiKey || ""; const isLocal = isLocalEndpoint(baseUrl); - if (!apiKey && !isLocal) { + if (!keyRef && !isLocal) { return { error: "AI is not configured. Add an API key in Settings → AI & Chat, or use a local endpoint." }; } - return { baseUrl, model, apiKey }; + // `keyRef` is a keychain reference token; resolve to the real key for this request only. + return { baseUrl, model, apiKey: resolveLlmApiKey(keyRef) }; } export function registerFlowHandlers(ctx: DbContext): void { @@ -102,13 +104,8 @@ export function registerFlowHandlers(ctx: DbContext): void { "db:flow:node:summarize", (_e, args: { nodeId: string; config: { baseUrl: string; model: string; apiKey: string } }) => handle(async () => { - if (args.config?.apiKey) { - saveCachedConfig("ai", { - baseUrl: args.config.baseUrl, - model: args.config.model, - apiKey: args.config.apiKey, - }); - } + // Cache the connection (apiKey scrubbed to a ref-or-clear by the cache layer). + cacheLlmConnection("ai", args.config); const resolved = resolveAiConfig(args.config); if ("error" in resolved) throw new Error(resolved.error); diff --git a/electron/ipc/pi-agent.ts b/electron/ipc/pi-agent.ts index ec692c7cc..223a051f9 100644 --- a/electron/ipc/pi-agent.ts +++ b/electron/ipc/pi-agent.ts @@ -27,7 +27,8 @@ import { normaliseBaseUrl } from "../lib/llm"; import type { DbContext } from "./handlers"; import * as q from "../db/queries"; import { ts } from "../db/utils"; -import { saveCachedConfig, getCachedConfig } from "../lib/config-cache"; +import { getCachedConfig, cacheLlmConnection } from "../lib/config-cache"; +import { resolveLlmApiKey } from "../lib/secure-store"; // ── Session registry ────────────────────────────────────────────────────────── @@ -192,16 +193,16 @@ export function registerPiAgentHandler( return; } - if (req.config?.apiKey) { - saveCachedConfig("agent", { - baseUrl: req.config.baseUrl, - model: req.config.model, - apiKey: req.config.apiKey, - maxSteps: req.config.maxSteps, - temperature: req.config.temperature, - autoApprove: req.config.autoApprove, - }); - } + // Cache the connection + behavioural fields (apiKey scrubbed to a ref-or-clear + // by the cache layer, never a raw key). + cacheLlmConnection("agent", { + baseUrl: req.config?.baseUrl, + model: req.config?.model, + apiKey: req.config?.apiKey, + maxSteps: req.config?.maxSteps, + temperature: req.config?.temperature, + autoApprove: req.config?.autoApprove, + }); let reqConfig = req.config; const cached = getCachedConfig().agentConfig; @@ -225,7 +226,7 @@ export function registerPiAgentHandler( const llmConfig: AgentLLMConfig = { baseUrl: normaliseBaseUrl(reqConfig?.baseUrl || "https://api.openai.com"), model: reqConfig?.model || "gpt-4o", - apiKey: reqConfig?.apiKey || "", + apiKey: resolveLlmApiKey(reqConfig?.apiKey), maxSteps: reqConfig?.maxSteps ?? 20, temperature: reqConfig?.temperature ?? 0.3, autoApprove: reqConfig?.autoApprove !== undefined ? reqConfig.autoApprove : true, @@ -284,16 +285,16 @@ export function registerPiAgentHandler( return; } - if (req.config?.apiKey) { - saveCachedConfig("agent", { - baseUrl: req.config.baseUrl, - model: req.config.model, - apiKey: req.config.apiKey, - maxSteps: req.config.maxSteps, - temperature: req.config.temperature, - autoApprove: req.config.autoApprove, - }); - } + // Cache the connection + behavioural fields (apiKey scrubbed to a ref-or-clear + // by the cache layer, never a raw key). + cacheLlmConnection("agent", { + baseUrl: req.config?.baseUrl, + model: req.config?.model, + apiKey: req.config?.apiKey, + maxSteps: req.config?.maxSteps, + temperature: req.config?.temperature, + autoApprove: req.config?.autoApprove, + }); let reqConfig = req.config; const cached = getCachedConfig().agentConfig; @@ -317,7 +318,7 @@ export function registerPiAgentHandler( const llmConfig: AgentLLMConfig = { baseUrl: normaliseBaseUrl(reqConfig?.baseUrl || "https://api.openai.com"), model: reqConfig?.model || "gpt-4o", - apiKey: reqConfig?.apiKey || "", + apiKey: resolveLlmApiKey(reqConfig?.apiKey), maxSteps: reqConfig?.maxSteps ?? 20, temperature: reqConfig?.temperature ?? 0.3, autoApprove: reqConfig?.autoApprove !== undefined ? reqConfig.autoApprove : true, @@ -367,13 +368,12 @@ export function registerPiAgentHandler( broadcastEvent(channel, payload); }; - if (req.config?.apiKey) { - saveCachedConfig("agent", { - baseUrl: req.config.baseUrl, - model: req.config.model, - apiKey: req.config.apiKey, - }); - } + // Cache the connection (apiKey scrubbed to a ref-or-clear by the cache layer). + cacheLlmConnection("agent", { + baseUrl: req.config?.baseUrl, + model: req.config?.model, + apiKey: req.config?.apiKey, + }); let reqConfig = req.config; if (!reqConfig?.apiKey) { @@ -391,7 +391,7 @@ export function registerPiAgentHandler( const llmConfig: AgentLLMConfig = { baseUrl: normaliseBaseUrl(reqConfig?.baseUrl || "https://api.openai.com"), model: reqConfig?.model || "gpt-4o", - apiKey: reqConfig?.apiKey || "", + apiKey: resolveLlmApiKey(reqConfig?.apiKey), maxSteps: 20, temperature: 0.1, }; diff --git a/electron/ipc/tools.ts b/electron/ipc/tools.ts index 033e7f590..2fbfaf078 100644 --- a/electron/ipc/tools.ts +++ b/electron/ipc/tools.ts @@ -356,7 +356,7 @@ export function registerToolsHandlers(db: Database): void { * never create a junk ref or address the wrong tool kind's namespace. */ function assertSecretIdentity(toolType: string, toolId: string, key: string): void { - if (toolType !== "mcp" && toolType !== "service") { + if (toolType !== "mcp" && toolType !== "service" && toolType !== "llm") { throw new Error(`Invalid secret toolType: ${String(toolType)}`); } if (typeof toolId !== "string" || !toolId.trim()) { diff --git a/electron/lib/config-cache.ts b/electron/lib/config-cache.ts index f3d1028cf..6e0e2f30b 100644 --- a/electron/lib/config-cache.ts +++ b/electron/lib/config-cache.ts @@ -88,14 +88,27 @@ export function saveCachedConfig(type: "ai" | "agent" | "embeddings" | "theme" | // Type-guard/assert type safe access const configRecord = config as Record | null | undefined; - + + // Only keychain reference tokens (`secret://…`) may be cached for an apiKey — + // never a raw key. Resolve the cached value for an incoming apiKey field: + // - field absent → keep the current cached value + // - a `secret://…` ref → store the ref + // - anything else (incl "") → store "" (explicit clear; never a raw key) + // (Local check rather than importing secure-store's isSecretRef, which pulls + // in `electron` and would break the MCP runtime that also loads this module.) + const isRef = (v: unknown): v is string => typeof v === "string" && v.startsWith("secret://"); + const resolveCachedKey = (incoming: unknown, current: string | undefined): string | undefined => { + if (!("apiKey" in (configRecord as object))) return current; // omitted → preserve + return isRef(incoming) ? incoming : ""; // ref kept, else cleared + }; + if (type === "ai" && configRecord) { current.aiConfig = { ...current.aiConfig, provider: typeof configRecord.provider === "string" ? configRecord.provider : current.aiConfig?.provider, baseUrl: typeof configRecord.baseUrl === "string" ? configRecord.baseUrl : current.aiConfig?.baseUrl, model: typeof configRecord.model === "string" ? configRecord.model : current.aiConfig?.model, - apiKey: typeof configRecord.apiKey === "string" ? configRecord.apiKey : current.aiConfig?.apiKey, + apiKey: resolveCachedKey(configRecord.apiKey, current.aiConfig?.apiKey), // Preserve behavioural fields too — omitting maxSteps here made the chat // tool-call limit reset to the default on the next hydrate. maxSteps: typeof configRecord.maxSteps === "number" ? configRecord.maxSteps : current.aiConfig?.maxSteps, @@ -103,10 +116,12 @@ export function saveCachedConfig(type: "ai" | "agent" | "embeddings" | "theme" | contextLimit: typeof configRecord.contextLimit === "number" ? configRecord.contextLimit : current.aiConfig?.contextLimit, aiEnabled: typeof configRecord.aiEnabled === "boolean" ? configRecord.aiEnabled : current.aiConfig?.aiEnabled, subagentsEnabled: typeof configRecord.subagentsEnabled === "boolean" ? configRecord.subagentsEnabled : current.aiConfig?.subagentsEnabled, - // Saved-provider switcher state (array + active id). `configRecord` is - // typed for scalars, so read the raw config object for these. + // Saved-provider switcher state (array + active id). Each provider's + // apiKey is scrubbed to a ref (or dropped) so no raw key is cached. savedProviders: Array.isArray((config as { savedProviders?: unknown }).savedProviders) - ? (config as { savedProviders: NonNullable["savedProviders"] }).savedProviders + ? (config as { savedProviders: NonNullable["savedProviders"] }).savedProviders!.map( + (p) => ({ ...p, apiKey: isRef(p.apiKey) ? p.apiKey : "" }), + ) : current.aiConfig?.savedProviders, activeProviderId: typeof configRecord.activeProviderId === "string" ? configRecord.activeProviderId : current.aiConfig?.activeProviderId, }; @@ -115,7 +130,7 @@ export function saveCachedConfig(type: "ai" | "agent" | "embeddings" | "theme" | ...current.agentConfig, baseUrl: typeof configRecord.baseUrl === "string" ? configRecord.baseUrl : current.agentConfig?.baseUrl, model: typeof configRecord.model === "string" ? configRecord.model : current.agentConfig?.model, - apiKey: typeof configRecord.apiKey === "string" ? configRecord.apiKey : current.agentConfig?.apiKey, + apiKey: resolveCachedKey(configRecord.apiKey, current.agentConfig?.apiKey), maxSteps: typeof configRecord.maxSteps === "number" ? configRecord.maxSteps : current.agentConfig?.maxSteps, temperature: typeof configRecord.temperature === "number" ? configRecord.temperature : current.agentConfig?.temperature, autoApprove: typeof configRecord.autoApprove === "boolean" ? configRecord.autoApprove : current.agentConfig?.autoApprove, @@ -154,3 +169,20 @@ export function getCachedConfig(): CachedConfig { export function getEmbeddingsSettingsCached(): CachedEmbeddingsConfig { return getCachedConfig().embeddingsConfig ?? {}; } + +/** + * Persist an LLM connection to the cache from an IPC request config. Shared by + * the chat / prd / flow / pi-agent handlers so the "cache only a keychain ref, + * never a raw key" rule lives in one place. `saveCachedConfig` scrubs the apiKey + * (a non-ref value is stored as an explicit clear), so callers can pass the + * request config straight through without their own guard. + */ +export function cacheLlmConnection( + type: "ai" | "agent", + config: { provider?: string; baseUrl?: string; model?: string; apiKey?: string; maxSteps?: number; temperature?: number; autoApprove?: boolean } | undefined, +): void { + if (!config) return; + if (config.baseUrl === undefined && config.model === undefined && config.apiKey === undefined) return; + saveCachedConfig(type, config); +} + diff --git a/electron/lib/secure-store.test.ts b/electron/lib/secure-store.test.ts index 2f87bf219..df1841ea5 100644 --- a/electron/lib/secure-store.test.ts +++ b/electron/lib/secure-store.test.ts @@ -158,3 +158,53 @@ describe("secure-store persistence round-trip", () => { expect(store.hasSecret("service", "shared", "Authorization")).toBe(true); }); }); + +describe("LLM API keys", () => { + it("stores a provider key and returns its reference token", () => { + const ref = store.setLlmApiKey("prov1", "sk-abc123"); + expect(ref).toBe("secret://llm:prov1/apiKey"); + expect(store.llmSecretRef("prov1")).toBe(ref); + expect(store.hasSecret("llm", "prov1", "apiKey")).toBe(true); + }); + + it("resolves a reference token back to the raw key", () => { + const ref = store.setLlmApiKey("prov1", "sk-abc123"); + expect(store.resolveLlmApiKey(ref)).toBe("sk-abc123"); + }); + + it("passes a literal (non-ref) key through unchanged", () => { + expect(store.resolveLlmApiKey("sk-literal")).toBe("sk-literal"); + }); + + it("returns empty for empty/undefined input", () => { + expect(store.resolveLlmApiKey("")).toBe(""); + expect(store.resolveLlmApiKey(undefined)).toBe(""); + expect(store.resolveLlmApiKey(null)).toBe(""); + }); + + it("returns empty when a ref can't be resolved (never leaks the token)", () => { + expect(store.resolveLlmApiKey("secret://llm:missing/apiKey")).toBe(""); + }); + + it("clears the stored key when set to empty and returns empty ref", () => { + store.setLlmApiKey("prov1", "sk-abc123"); + expect(store.hasSecret("llm", "prov1", "apiKey")).toBe(true); + const ref = store.setLlmApiKey("prov1", ""); + expect(ref).toBe(""); + expect(store.hasSecret("llm", "prov1", "apiKey")).toBe(false); + }); + + it("deleteLlmApiKey removes the stored key", () => { + store.setLlmApiKey("prov1", "sk-abc123"); + store.deleteLlmApiKey("prov1"); + expect(store.hasSecret("llm", "prov1", "apiKey")).toBe(false); + }); + + it("never writes the raw key to disk (only ciphertext)", () => { + store.setLlmApiKey("prov1", "super-secret-llm-key"); + const dump = JSON.stringify(virtualFiles); + expect(dump).not.toContain("super-secret-llm-key"); + // Stored as base64 of the mock cipher ("enc:"), never the plaintext. + expect(dump).toContain(Buffer.from("enc:super-secret-llm-key", "utf-8").toString("base64")); + }); +}); diff --git a/electron/lib/secure-store.ts b/electron/lib/secure-store.ts index 8fec49097..e98907a21 100644 --- a/electron/lib/secure-store.ts +++ b/electron/lib/secure-store.ts @@ -34,8 +34,11 @@ type SecretStore = Record; // ── Pure helpers (unit-testable, no Electron) ──────────────────────────────── -/** Tool kinds that own secrets. Mirrors ToolType in src/types. */ -export type ToolKind = "mcp" | "service"; +/** + * Tool kinds that own secrets. Mirrors ToolType in src/types, plus "llm" for + * the built-in AI provider API keys (the OpenAI-compatible chat/agent endpoints). + */ +export type ToolKind = "mcp" | "service" | "llm"; /** * Build the canonical reference token for a tool's named secret. The toolType is @@ -280,3 +283,57 @@ export function resolveSecrets(headers: Record): Record/apiKey`); the raw key is resolved +// exclusively in the main process, at the IPC boundary, right before a request. + +/** Canonical secret key under which every LLM provider stores its API key. */ +export const LLM_API_KEY = "apiKey"; + +/** The reference token for a saved provider's API key. */ +export function llmSecretRef(providerId: string): string { + return secretRef("llm", providerId, LLM_API_KEY); +} + +/** + * Store a provider's raw API key in the keychain and return its reference token. + * Empty/placeholder input clears any stored key and returns "" (meaning "no + * key" — e.g. a keyless local server). Main-process only. + */ +export function setLlmApiKey(providerId: string, rawKey: string): string { + const trimmed = (rawKey ?? "").trim(); + if (!trimmed || isPlaceholder(trimmed) || containsPlaceholder(trimmed)) { + deleteSecret("llm", providerId, LLM_API_KEY); + return ""; + } + return setSecret("llm", providerId, LLM_API_KEY, trimmed); +} + +/** Delete a provider's stored API key. No-op if absent. */ +export function deleteLlmApiKey(providerId: string): void { + deleteSecret("llm", providerId, LLM_API_KEY); +} + +/** + * Resolve an incoming `apiKey` config value to the real key for an outbound + * request. Accepts either: + * - a `secret://llm:…/apiKey` reference token → decrypt from the keychain + * - any other non-empty string → treat as a literal key (already raw) + * - "" / undefined → "" (no key; keyless local endpoint) + * Returns "" when a ref can't be resolved, so we never send the token on the wire. + * Main-process only. + */ +export function resolveLlmApiKey(apiKey: string | undefined | null): string { + const v = apiKey ?? ""; + if (!v) return ""; + if (isSecretRef(v)) return getSecretByRef(v) ?? ""; + // A literal key: guard against an unfilled placeholder ever reaching the wire, + // mirroring setSecret/getSecretByRef. + if (isPlaceholder(v) || containsPlaceholder(v)) return ""; + return v; // literal key +} diff --git a/electron/preload.ts b/electron/preload.ts index f739f3475..61b2bfcb9 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -362,6 +362,8 @@ const api = { explainArchitecture: (args: { summary: string; config: { baseUrl: string; model: string; apiKey: string } }) => invoke<{ overview: string; modules: string }>("ai:explainArchitecture", args), localLLMStatus: () => invoke<{ available: boolean; reason?: string }>("ai:localLLMStatus"), + fetchModels: (args: { baseUrl?: string; apiKey?: string }) => + invoke("ai:fetchModels", args), }, // ── App paths ───────────────────────────────── @@ -668,11 +670,11 @@ const api = { // ── Secrets (OS keychain). No get() by design — renderer only learns set/not-set. secrets: { available: () => invoke("secrets:available"), - set: (toolType: "mcp" | "service", toolId: string, key: string, value: string) => + set: (toolType: "mcp" | "service" | "llm", toolId: string, key: string, value: string) => invoke("secrets:set", { toolType, toolId, key, value }), - has: (toolType: "mcp" | "service", toolId: string, key: string) => + has: (toolType: "mcp" | "service" | "llm", toolId: string, key: string) => invoke("secrets:has", { toolType, toolId, key }), - delete: (toolType: "mcp" | "service", toolId: string, key: string) => + delete: (toolType: "mcp" | "service" | "llm", toolId: string, key: string) => invoke("secrets:delete", { toolType, toolId, key }), }, diff --git a/package.json b/package.json index 4158c4b82..ccca603b2 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,14 @@ "author": "Cairn", "private": true, "scripts": { - "test": "npm run compile && vitest run", + "test": "node scripts/generate-licenses.js && node scripts/generate-features.js && npm run compile && vitest run", "test:fast": "vitest run", "test:live": "CAIRN_LIVE_TESTS=1 vitest run", "test:unit": "vitest run --project node", "test:component": "vitest run --project component", "test:watch": "vitest", "test:coverage": "npm run compile && vitest run --coverage", - "dev": "node scripts/generate-licenses.js && concurrently -n next,esbuild,electron -c cyan,magenta,yellow \"next dev\" \"npm run compile:watch\" \"wait-on http://localhost:3000 dist-electron/main.js && node scripts/electron-dev.js\"", + "dev": "node scripts/generate-licenses.js && node scripts/generate-features.js && concurrently -n next,esbuild,electron -c cyan,magenta,yellow \"next dev\" \"npm run compile:watch\" \"wait-on http://localhost:3000 dist-electron/main.js && node scripts/electron-dev.js\"", "build:mac": "node scripts/build.js --mac", "build:win": "node scripts/build.js --win", "build:linux": "node scripts/build.js --linux", diff --git a/scripts/build.js b/scripts/build.js index 01d836eb7..3ae494b45 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -50,6 +50,9 @@ console.log(`\nBuilding Cairn for: ${platformFlags}`); // 1. Generate licenses + stack metadata (baked into the Next.js static export) run("node scripts/generate-licenses.js"); +// 1b. Generate the What's New feature registry (baked into the static export) +run("node scripts/generate-features.js"); + // 2. Next.js static export run("cross-env ELECTRON_BUILD=true next build"); diff --git a/scripts/features.config.js b/scripts/features.config.js new file mode 100644 index 000000000..11b4fcca3 --- /dev/null +++ b/scripts/features.config.js @@ -0,0 +1,209 @@ +/** + * Cairn — "What's New" feature source of truth (curated). + * + * This is the hand-authored list of MAJOR, user-facing features surfaced in the + * What's New modal (src/components/layout/NewFeatureModal.tsx). It is the input + * to scripts/generate-features.js, which validates it and writes the baked + * src/generated/new-features.json the app reads at runtime — mirroring how + * scripts/generate-licenses.js turns the curated ROLE_MAP into licenses.json. + * + * ── When to add an entry ────────────────────────────────────────────────────── + * Add an entry ONLY for a major, headline feature a user would want announced on + * launch — not every changelog line (fixes, small tweaks, and internal work stay + * in changelogs/ only). Append new entries at the END, in release order; the + * modal auto-shows unseen entries belonging to the newest `version` present. + * + * ── Entry shape ─────────────────────────────────────────────────────────────── + * id Stable unique key, e.g. "v2.5.9-saved-providers". NEVER reuse or + * renumber — it's how "already seen" is tracked per user. + * version Release tag string, e.g. "v2.5.9". Group multiple features of the + * same release under the same version. + * title Short headline (a few words). + * category One-word-ish label shown as the eyebrow (e.g. "AI Chat", "Agent"). + * description One or two sentences of context. + * highlights 3–4 bullet strings. "Prefix: rest" bolds the prefix in the modal. + * + * @typedef {{ id: string, version: string, title: string, category: string, + * description: string, highlights: string[] }} NewFeature + * @type {NewFeature[]} + */ +const FEATURES = [ + { + id: "v1.5.0-obsidian", + version: "v1.5.0", + title: "Obsidian Vault Compatibility", + category: "Integrations", + description: "Cairn is now fully compatible with Obsidian vaults, operating seamlessly side-by-side.", + highlights: [ + "Obsidian Vault Interoperability: Point any Cairn workspace directly to an Obsidian folder without breaking configuration files.", + "Double-Bracket Image Embeds: Render Obsidian-style `![[image.png]]` media links natively inside the Markdown previewer and editor.", + "Vault-Aware Assets: Pasted and uploaded assets are saved directly to your vault's `attachments/` folder.", + "Preservation YAML Sync: Modifying note metadata in Cairn preserves external Obsidian aliases, dates, or custom YAML tags.", + ], + }, + { + id: "v1.6.0-decoupled-ai", + version: "v1.6.0", + title: "Decoupled AI Configurations", + category: "AI Chat", + description: "Configure different models for daily AI chat versus complex coding agent loops.", + highlights: [ + "Independent Parameters: Run lightweight, on-device models for inline editor actions while using large cloud endpoints for coding loops.", + "Domain Partitioned Settings: Separated AI settings panels divide General Chat/MCP from Coding Agent (Pi) context parameters.", + "Sync & Migration: Credential configurations are synchronized across General Chat and Agent loops upon first setup.", + ], + }, + { + id: "v1.7.0-local-llm", + version: "v1.7.0", + title: "Local LLM Integration", + category: "Local AI", + description: "Full cross-platform local engine routing inference offline to local llama-server binaries.", + highlights: [ + "1-Click Binary Downloader: Automatically downloads, unpacks, and executes ggml llama-server setups customized for your platform.", + "CDN Toggle Mirroring: Alternate model weight downloads between Hugging Face and high-speed regional CDN mirrors.", + "Self-Healing XML Parser: Stream repairs to malformed JSON output blocks generated by smaller quantized local models.", + "Star Toggle: Select downloaded weights to launch automatically on app startup without manual model load.", + ], + }, + { + id: "v1.8.0-mobile-companion", + version: "v1.8.0", + title: "Mobile Companion Service", + category: "Mobile", + description: "Secure, local-network mobile access to Cairn notes, boards, and agents.", + highlights: [ + "Mobile QR Code Pairing: Scan pairing QR codes or enter single-use PINs to launch secure mobile client sessions.", + "Mobile Responsive Layouts: Sidebar collapsible drawers, swipeable settings categories, and dynamic content scaling.", + "Touch DnD Support: Holds and scrolls on touchscreens cede canvas pointer control instantly to board DnD sensors.", + "Web Share PDF Export: Generate standard print-layout PDFs and export them directly to native mobile share sheets.", + ], + }, + { + id: "v2.0.0-codebase-agent", + version: "v2.0.0", + title: "Codebase Indexing & Agent Guardrails", + category: "Agent", + description: "AST-free regex symbol indexers and live execution confirmations.", + highlights: [ + "Semantic Codebase Indexing: Incremental parser mapping functions, classes, and call graph relations for Rust, Go, Python, and JS.", + "Plan & Execute Toggles: Lower temperature Outline settings for safe, deterministic planning drafts.", + "Modifying Tool Confirmation: Security gate pausing execution for inline Confirm/Deny tool approval in message bubbles.", + ], + }, + { + id: "v2.1.1-semantic-search", + version: "v2.1.1", + title: "Local Semantic Search", + category: "Knowledge Graph", + description: "Find related notes by meaning — powered by a local embedding model that runs fully offline.", + highlights: [ + "Semantic Neighbours: A packaged on-device embedding model links notes by meaning; the Knowledge Graph gains a \"semantic\" edge type and the editor's Backlinks panel shows the most similar notes with score bars.", + "Section-Based Embeddings: Notes are split by headings so each section gets its own vector — multi-topic notes connect to the right clusters instead of averaging their signal away.", + "Fully Offline: Inference runs in a local background worker (ONNX Runtime); nothing is sent to a cloud service.", + "Edge Hover Tooltips: Hovering a semantic link shows the two note/section titles and the similarity percentage.", + ], + }, + { + id: "v2.2.0-thinking-stream", + version: "v2.2.0", + title: "Reasoning & Thinking Streams", + category: "AI Chat", + description: "Peek behind the curtain to observe the model's logic as it streams in real-time.", + highlights: [ + "Collapsible Thinking Blocks: Follow the model's reasoning stream. Blocks auto-collapse once generation starts.", + "Context & Usage Metrics: View exact breakdown of answer tokens vs reasoning tokens in the ContextRing popover.", + ], + }, + { + id: "v2.3.0-git-workflow", + version: "v2.3.0", + title: "Polished Git Integrations", + category: "Git", + description: "Streamlined branch management and file discarding controls.", + highlights: [ + "Radix Branch Switcher: Search, filter, and switch branches quickly, or create new branches from a dedicated dialog.", + "File-Level & Bulk Discarding: Clean your workspace by discarding specific changes or sections with verification prompts.", + "Action Tooltips: Custom theme-aware tooltips details for all stage, unstage, commit, and discard actions.", + ], + }, + { + id: "v2.3.0-chat-layout", + version: "v2.3.0", + title: "Dynamic Chat Layout & Previews", + category: "AI Chat", + description: "Work with notes and tasks side-by-side during AI chats.", + highlights: [ + "Dynamic Layout Morphing: Toggle the chat between right-docked sidebar and full workspace modes smoothly.", + "Side-by-Side Context Previews: Click references, tasks, or notes in assistant responses to view them instantly next to your chat.", + "Direct Workspace Navigation: Jump straight to the board or notes workspace directly from the preview header.", + ], + }, + { + id: "v2.3.2-onboarding-tour", + version: "v2.3.2", + title: "Interactive Workspace Tour & Highlights", + category: "Workspace Help", + description: "Learn your way around the workspaces with visual guided tours and feature slides.", + highlights: [ + "Guided Workspace Tour: Step through an interactive, visual tour explaining Overview, Notes, Board, and Flow views.", + "Real-Time Transition Cutout: Highlight portal adapts instantly to window resizing and CSS sliding panel drawers.", + "What's New Modal: Paginated carousel modal highlights new updates automatically on launch.", + "Skip All Controls: Skip remaining slide cards instantly and mark everything as seen to prevent boot prompts.", + ], + }, + { + id: "v2.3.4-calendar", + version: "v2.3.4", + title: "Calendar View", + category: "Planning", + description: "Schedule tasks by due date and see what's overdue at a glance.", + highlights: [ + "Month & Week Layouts: A project-scoped calendar (⌘4) lays out task cards by due date with prev / next / today navigation.", + "Drag to Reschedule: Drag any task onto a different day to change its due date — an optimistic, fully undoable update.", + "Overdue & Unscheduled Trays: A past-due banner and a collapsible no-due-date tray let you drag tasks straight onto a day to schedule them.", + "Day Detail Modal: Packed days show a \"+N more\" affordance opening a list of everything due that day.", + ], + }, + { + id: "v2.3.4-external-tools", + version: "v2.3.4", + title: "External Tools — MCP Servers & HTTP APIs", + category: "AI Tools", + description: "Connect the AI chat and agent to remote MCP servers and any HTTP API.", + highlights: [ + "Settings → Tools: Add remote MCP servers (SSE or streamable-HTTP) and expose any HTTP API as a single tool, each with an enable toggle and Test connection button.", + "Per-Project Attach: Attach tools per-project from the Overview → Tools panel so each project only sees the tools it needs.", + "AI Tool Builder: Describe an endpoint in plain English; the builder probes it live, figures out the auth and response shape, and trims it to just the useful fields to save tokens.", + "Secure Credentials: API keys and tokens live in your OS keychain (Keychain / DPAPI / libsecret) — never in the database, plain text, or sent to the AI model.", + ], + }, + { + id: "v2.3.4-mcp-oauth", + version: "v2.3.4", + title: "OAuth Sign-in for Remote MCP Servers", + category: "AI Tools", + description: "Connect to MCP servers that gate access behind a sign-in page — Figma, Linear, Notion, GitHub, and more.", + highlights: [ + "One-Click Sign In: Choose OAuth as the auth mode, click Sign in, approve in your browser, and you're redirected back via a cairn:// deep link.", + "Full OAuth 2.1 Flow: Server discovery, dynamic client registration, PKCE, token exchange, and automatic refresh are handled for you by the MCP SDK.", + "Keychain-Backed Tokens: Access and refresh tokens are encrypted in your OS keychain and never exposed to the AI model or the app's UI layer.", + "Connection Status: A Connected / Sign out control shows each server's auth state at a glance.", + ], + }, + { + id: "v2.5.9-saved-providers", + version: "v2.5.9", + title: "Saved AI Providers", + category: "AI Chat", + description: "Save several AI connections and switch between them in a click — no more retyping endpoints and keys.", + highlights: [ + "Named Connections: Save multiple OpenAI-compatible providers (OpenAI, OpenRouter, a local Ollama / LM Studio server…) each with its own base URL, key, and model, and switch the active one from a dropdown.", + "Shared With The Coding Agent: Chat and the coding agent draw from the same provider list but keep independent selections — run the agent on a bigger model while chat stays on a cheaper one.", + "Per-Surface Model Picker: A searchable model dropdown (with Refresh and a custom-model option) sits under the switcher, so each surface can use a different model on the same provider.", + "Keys In Your OS Keychain: Provider API keys are stored encrypted in your operating system's keychain — never in plain text — and moved there automatically from any existing setup.", + ], + }, +]; + +module.exports = { FEATURES }; diff --git a/scripts/generate-features.js b/scripts/generate-features.js new file mode 100644 index 000000000..ac66ce6cd --- /dev/null +++ b/scripts/generate-features.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Cairn — "What's New" feature registry generator + * + * Reads the curated feature list from scripts/features.config.js, validates it, + * and writes src/generated/new-features.json — the registry the What's New modal + * (src/components/layout/NewFeatureModal.tsx) reads at runtime. Mirrors + * scripts/generate-licenses.js (curated source → generated JSON baked into the + * static export). + * + * Run automatically by build.js before the Next.js build, and by the `dev` + * script. Can also be run standalone: node scripts/generate-features.js + */ + +const fs = require("fs"); +const path = require("path"); + +const root = path.resolve(__dirname, ".."); +const { FEATURES } = require("./features.config.js"); + +// ── Validation ──────────────────────────────────────────────────────────────── +// Fail loudly at generate time rather than shipping a broken/duplicated modal. + +const REQUIRED_STRING_FIELDS = ["id", "version", "title", "category", "description"]; +const errors = []; +const seenIds = new Set(); + +if (!Array.isArray(FEATURES)) { + console.error("[generate-features] FEATURES export must be an array"); + process.exit(1); +} + +FEATURES.forEach((f, i) => { + const where = `entry #${i}${f && f.id ? ` (${f.id})` : ""}`; + if (!f || typeof f !== "object") { + errors.push(`${where}: not an object`); + return; + } + for (const field of REQUIRED_STRING_FIELDS) { + if (typeof f[field] !== "string" || f[field].trim() === "") { + errors.push(`${where}: missing/empty string field "${field}"`); + } + } + if (typeof f.id === "string") { + if (seenIds.has(f.id)) errors.push(`${where}: duplicate id "${f.id}"`); + seenIds.add(f.id); + } + if (typeof f.version === "string" && !/^v\d+\.\d+\.\d+$/.test(f.version)) { + errors.push(`${where}: version "${f.version}" should look like "v2.5.9"`); + } + if (!Array.isArray(f.highlights) || f.highlights.length === 0) { + errors.push(`${where}: "highlights" must be a non-empty array`); + } else if (!f.highlights.every((h) => typeof h === "string" && h.trim() !== "")) { + errors.push(`${where}: every highlight must be a non-empty string`); + } +}); + +if (errors.length > 0) { + console.error("[generate-features] Invalid features.config.js:"); + for (const e of errors) console.error(` - ${e}`); + process.exit(1); +} + +// Normalise to exactly the fields the modal consumes (drop any extras). +const registry = FEATURES.map((f) => ({ + id: f.id, + version: f.version, + title: f.title, + category: f.category, + description: f.description, + highlights: f.highlights, +})); + +const output = { registry, generatedAt: new Date().toISOString() }; + +const outDir = path.join(root, "src", "generated"); +fs.mkdirSync(outDir, { recursive: true }); +const outPath = path.join(outDir, "new-features.json"); +fs.writeFileSync(outPath, JSON.stringify(output, null, 2), "utf-8"); + +const latest = registry.length > 0 ? registry[registry.length - 1].version : "—"; +console.log(`[generate-features] Written ${registry.length} feature entries (latest ${latest}) to src/generated/new-features.json`); diff --git a/src/components/settings/AISettings.tsx b/src/components/settings/AISettings.tsx index f6564ebd2..10c19e8a3 100644 --- a/src/components/settings/AISettings.tsx +++ b/src/components/settings/AISettings.tsx @@ -7,10 +7,6 @@ import { Cpu, Globe } from "lucide-react"; import { cn } from "@/lib/utils"; import { contextLimitForModel } from "@/lib/models-dev"; import { SettingsGroup, SettingsRow, Toggle, StepperSettingsRow } from "./shared"; -import { - BaseUrlRow, ApiKeyRow, ModelSelectionRow, CloudConnectionStatus, - useEndpointConfig, isLocalBaseUrl, -} from "./endpoint-components"; import { MCPServerSettings } from "./MCPSettings"; import { LlamaServerConsole } from "./LlamaServerConsole"; import { ProviderManager } from "./ProviderManager"; @@ -21,31 +17,16 @@ export function AISettings() { setAIConfig: s.setAIConfig, }))); - const { - showKey, testState, testError, availableModels, modelsLoading, - setShowKey, fetchModels, ensureModels, resetModels, - } = useEndpointConfig(); - - // General config destructuring - const { provider = "openai", baseUrl, model, apiKey, aiEnabled, activeProviderId } = aiConfig; - const isLocal = isLocalBaseUrl(baseUrl); + // General config destructuring. Connection fields (baseUrl/apiKey/model) are + // now managed entirely by the ProviderManager switcher below; here we only + // need `provider` (which backend), `model` (for the context lookup), and the + // behavioural fields. + const { provider = "openai", model, aiEnabled } = aiConfig; function updateAIConfig(patch: Partial) { setAIConfig(patch); - if (patch.baseUrl !== undefined || patch.apiKey !== undefined) { - resetModels(); - } } - // Populate the model list from cache (or fetch once) when the cloud provider - // is selected or the active saved provider changes. Not on every keystroke in - // the URL/key fields (Refresh in the picker re-queries after edits). No - // hardcoded fallbacks — the picker shows only real models. - useEffect(() => { - if (provider !== "localllm") ensureModels(baseUrl, apiKey); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [provider, activeProviderId]); - // Look up the current model's context window from models.dev (cached). When // Auto is enabled, the detected value is applied to contextLimit automatically // as the model changes; the user can still override with a manual value or @@ -161,25 +142,6 @@ export function AISettings() { ) : ( <> - updateAIConfig({ baseUrl: url })} showPresets={false} /> - setShowKey((s) => !s)} - onChange={(key) => updateAIConfig({ apiKey: key })} - /> - updateAIConfig({ model: m })} - onFetch={() => fetchModels(baseUrl, apiKey)} - /> {/* Max Steps — applies to all providers */} {maxStepsRow} @@ -242,11 +204,6 @@ export function AISettings() { label="Enable subagents" /> - - {/* Cloud connection status */} -
- -
)} diff --git a/src/components/settings/AgentSettings.tsx b/src/components/settings/AgentSettings.tsx index 964ee4226..9c9104750 100644 --- a/src/components/settings/AgentSettings.tsx +++ b/src/components/settings/AgentSettings.tsx @@ -12,10 +12,6 @@ import { contextLimitForModel } from "@/lib/models-dev"; import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; import type { CodingAgent } from "@/store/slices/coding-agents"; import { SettingsGroup, SettingsRow, StepperSettingsRow } from "./shared"; -import { - BaseUrlRow, ApiKeyRow, ModelSelectionRow, CloudConnectionStatus, - useEndpointConfig, isLocalBaseUrl, -} from "./endpoint-components"; import { ProviderManager } from "./ProviderManager"; // ── Agent form ──────────────────────────────────────────────────────────────── @@ -409,33 +405,16 @@ export function AgentSettings() { const [adding, setAdding] = useState(false); const [editingId, setEditingId] = useState(null); - const { - showKey: showKeyAgent, testState: testStateAgent, testError: testErrorAgent, - availableModels: availableModelsAgent, modelsLoading: modelsLoadingAgent, - setShowKey: setShowKeyAgent, fetchModels: fetchModelsAgent, ensureModels: ensureModelsAgent, resetModels: resetModelsAgent, - } = useEndpointConfig(); - useEffect(() => { fetchAgents(); }, [fetchAgents]); - const { baseUrl: baseUrlAgent, model: modelAgent, apiKey: apiKeyAgent, maxSteps: maxStepsAgent, temperature: temperatureAgent, contextLimit: contextLimitAgent, autoApprove = true } = agentConfig; - const isLocalAgent = isLocalBaseUrl(baseUrlAgent); + // Connection fields (baseUrl/apiKey/model) are managed by the ProviderManager + // switcher below; here we only need `model` (context lookup) + behavioural fields. + const { model: modelAgent, maxSteps: maxStepsAgent, temperature: temperatureAgent, contextLimit: contextLimitAgent, autoApprove = true } = agentConfig; function updateAgent(patch: Partial) { setAgentConfig(patch); - if (patch.baseUrl !== undefined || patch.apiKey !== undefined) { - resetModelsAgent(); - } } - // Populate the agent's model list from cache (or fetch once) on mount and when - // the active saved provider changes. Not on every keystroke in the URL/key - // fields — Refresh in the picker re-queries after edits. No hardcoded - // fallbacks — the picker shows only real models. - useEffect(() => { - ensureModelsAgent(baseUrlAgent, apiKeyAgent); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [agentConfig.activeProviderId]); - // Look up the agent model's context window from models.dev (cached). When Auto // is enabled, the detected value is applied to contextLimit automatically as // the model changes; a manual value or preset turns Auto off. Cloud only. @@ -485,34 +464,9 @@ export function AgentSettings() { title="Cairn Coding Agent (Pi)" description="Configure endpoint parameters for the native autonomous coding agent. Coding agents require high-capacity cloud/local models supporting function calling." > - {/* Saved providers switcher */} + {/* Saved providers switcher (base URL, key, model all live in the form) */} - {/* Base URL */} - updateAgent({ baseUrl: url })} showPresets={false} /> - - {/* API Key */} - setShowKeyAgent((s) => !s)} - onChange={(key) => updateAgent({ apiKey: key })} - /> - - {/* Model Selection */} - updateAgent({ model: m })} - onFetch={() => fetchModelsAgent(baseUrlAgent, apiKeyAgent)} - /> - {/* Max steps */} - - {/* Agent Connection Status */} -
- -
{/* Agent list */} diff --git a/src/components/settings/ProviderManager.tsx b/src/components/settings/ProviderManager.tsx index 8e708ed63..011d27a33 100644 --- a/src/components/settings/ProviderManager.tsx +++ b/src/components/settings/ProviderManager.tsx @@ -29,17 +29,22 @@ const EMPTY_PROVIDERS: SavedProvider[] = []; */ export function ProviderManager({ kind = "ai" }: { kind?: "ai" | "agent" }) { const { - savedProviders, activeProviderId, - addSavedProvider, updateSavedProvider, deleteSavedProvider, selectProvider, + savedProviders, activeProviderId, activeModel, + addSavedProvider, updateSavedProvider, deleteSavedProvider, selectProvider, setModel, } = useCairnStore(useShallow((s) => ({ // The list is always shared — read it from aiConfig. savedProviders: s.aiConfig.savedProviders, - // Active id + select target depend on which config this instance drives. + // Active id + the surface's chosen model depend on which config this drives. activeProviderId: kind === "agent" ? s.agentConfig.activeProviderId : s.aiConfig.activeProviderId, + activeModel: kind === "agent" ? s.agentConfig.model : s.aiConfig.model, addSavedProvider: s.addSavedProvider, updateSavedProvider: s.updateSavedProvider, deleteSavedProvider: s.deleteSavedProvider, selectProvider: kind === "agent" ? s.selectAgentProvider : s.selectSavedProvider, + // Model is a PER-SURFACE choice on the config, not the shared provider. + setModel: kind === "agent" + ? (model: string) => s.setAgentConfig({ model }) + : (model: string) => s.setAIConfig({ model }), }))); const providers = savedProviders ?? EMPTY_PROVIDERS; @@ -91,7 +96,9 @@ export function ProviderManager({ kind = "ai" }: { kind?: "ai" | "agent" }) { {p.name} - {p.model} + + {p.id === activeProviderId ? (activeModel || p.model) : p.model} + ))} @@ -116,7 +123,10 @@ export function ProviderManager({ kind = "ai" }: { kind?: "ai" | "agent" }) { Edit ); } + +/** + * A standalone "Model" settings row for the currently-active provider, so the + * model can be changed without opening the full Add/Edit form. Fetches the + * provider's model list (cache-first, via the main-process IPC that resolves the + * key ref) and writes the chosen model to THIS surface's config — the coding + * agent and chat keep independent models on the same shared provider. + */ +function ActiveModelRow({ + provider, + model, + onModelChange, +}: { + provider: SavedProvider; + model: string; + onModelChange: (model: string) => void; +}) { + const { availableModels, fetchModels, ensureModels, modelsLoading, testState } = useEndpointConfig(); + useEffect(() => { + ensureModels(provider.baseUrl, provider.apiKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [provider.id]); + + return ( + +
+ fetchModels(provider.baseUrl, provider.apiKey)} + /> +
+
+ ); +} diff --git a/src/components/settings/endpoint-components.tsx b/src/components/settings/endpoint-components.tsx index 39dbeb5d3..03e1f7fca 100644 --- a/src/components/settings/endpoint-components.tsx +++ b/src/components/settings/endpoint-components.tsx @@ -93,44 +93,56 @@ export function useEndpointConfig(): UseEndpointConfigResult { const [testError, setTestError] = useState(""); const [availableModels, setAvailableModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); - const abortRef = React.useRef(null); const resetTimerRef = React.useRef | null>(null); + // Advances on every fetch so a slow older request can't overwrite the state of + // a newer one (endpoint/key changed, or a rapid re-fetch). + const fetchGenRef = React.useRef(0); const fetchModels = useCallback(async (baseUrl: string, apiKey: string) => { - if (abortRef.current) abortRef.current.abort(); if (resetTimerRef.current) clearTimeout(resetTimerRef.current); - const ac = new AbortController(); - abortRef.current = ac; + const gen = ++fetchGenRef.current; + const isCurrent = () => gen === fetchGenRef.current; setModelsLoading(true); setTestState("testing"); try { - const url = normBaseUrl(baseUrl); - const headers: Record = {}; - if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; - - const res = await fetch(`${url}/v1/models`, { headers, signal: ac.signal }); - if (!res.ok) throw new Error(`${res.status}`); - - const data = await res.json(); - const ids: string[] = (data?.data ?? []) - .map((m: { id: string }) => m.id) - .filter((id: string) => { - return !id.includes("embed") && !id.includes("whisper") && !id.includes("tts") && !id.includes("dall-e"); - }) - .sort(); + // Fetch via the main process so the key (a keychain ref) is resolved there + // and never crosses into the renderer or the CSP boundary. Falls back to a + // renderer fetch only in the web/no-electron context. + let ids: string[]; + if (typeof window !== "undefined" && window.electron?.ai?.fetchModels) { + ids = await window.electron.ai.fetchModels({ baseUrl, apiKey }); + } else { + const url = normBaseUrl(baseUrl); + const headers: Record = {}; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + // Abort a hung endpoint after 12s (matches the main-process handler). + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), 12_000); + try { + const res = await fetch(`${url}/v1/models`, { headers, signal: ac.signal }); + if (!res.ok) throw new Error(`${res.status}`); + const data = await res.json(); + ids = (data?.data ?? []) + .map((m: { id: string }) => m.id) + .filter((id: string) => !id.includes("embed") && !id.includes("whisper") && !id.includes("tts") && !id.includes("dall-e")) + .sort(); + } finally { + clearTimeout(timer); + } + } - if (ac.signal.aborted) return; + if (!isCurrent()) return; // a newer fetch superseded this one setAvailableModels(ids); - writeModelCache(baseUrl, apiKey, ids); // persist for next open + writeModelCache(baseUrl, apiKey, ids); // persist for next open (keyed by endpoint, not the key value) setTestState("ok"); } catch (err) { - if (ac.signal.aborted) return; + if (!isCurrent()) return; setTestState("error"); setTestError(err instanceof Error ? err.message : "Failed to fetch models"); setAvailableModels([]); } finally { - if (!ac.signal.aborted) { + if (isCurrent()) { setModelsLoading(false); resetTimerRef.current = setTimeout(() => setTestState("idle"), 5000); } diff --git a/src/lib/new-features-registry.ts b/src/lib/new-features-registry.ts index 3ca7e16b3..dbcb6ed43 100644 --- a/src/lib/new-features-registry.ts +++ b/src/lib/new-features-registry.ts @@ -1,3 +1,5 @@ +import newFeaturesData from "@/generated/new-features.json"; + export interface NewFeature { id: string; // Feature/V key e.g., 'v2.3.0-chat-layout' version: string; // e.g. 'v2.3.0' @@ -7,6 +9,15 @@ export interface NewFeature { highlights: string[]; } +/** + * The What's New registry, generated from the curated `scripts/features.config.js` + * by `scripts/generate-features.js` into `src/generated/new-features.json` (baked + * into the static export; run in `build.js` and the `dev` script — same pattern as + * `licenses.json`). To announce a new major feature, add an entry to + * `scripts/features.config.js`, not here. + */ +export const NEW_FEATURES_REGISTRY: NewFeature[] = (newFeaturesData.registry ?? []) as NewFeature[]; + /** * Compute which features to surface in the "What's New" modal. * @@ -31,167 +42,3 @@ export function getUnseenLatestFeatures( (f) => f.version === latestVersion && !seenFeatures.includes(f.id), ); } - -export const NEW_FEATURES_REGISTRY: NewFeature[] = [ - { - id: "v1.5.0-obsidian", - version: "v1.5.0", - title: "Obsidian Vault Compatibility", - category: "Integrations", - description: "Cairn is now fully compatible with Obsidian vaults, operating seamlessly side-by-side.", - highlights: [ - "Obsidian Vault Interoperability: Point any Cairn workspace directly to an Obsidian folder without breaking configuration files.", - "Double-Bracket Image Embeds: Render Obsidian-style `![[image.png]]` media links natively inside the Markdown previewer and editor.", - "Vault-Aware Assets: Pasted and uploaded assets are saved directly to your vault's `attachments/` folder.", - "Preservation YAML Sync: Modifying note metadata in Cairn preserves external Obsidian aliases, dates, or custom YAML tags." - ] - }, - { - id: "v1.6.0-decoupled-ai", - version: "v1.6.0", - title: "Decoupled AI Configurations", - category: "AI Chat", - description: "Configure different models for daily AI chat versus complex coding agent loops.", - highlights: [ - "Independent Parameters: Run lightweight, on-device models for inline editor actions while using large cloud endpoints for coding loops.", - "Domain Partitioned Settings: Separated AI settings panels divide General Chat/MCP from Coding Agent (Pi) context parameters.", - "Sync & Migration: Credential configurations are synchronized across General Chat and Agent loops upon first setup." - ] - }, - { - id: "v1.7.0-local-llm", - version: "v1.7.0", - title: "Local LLM Integration", - category: "Local AI", - description: "Full cross-platform local engine routing inference offline to local llama-server binaries.", - highlights: [ - "1-Click Binary Downloader: Automatically downloads, unpacks, and executes ggml llama-server setups customized for your platform.", - "CDN Toggle Mirroring: Alternate model weight downloads between Hugging Face and high-speed regional CDN mirrors.", - "Self-Healing XML Parser: Stream repairs to malformed JSON output blocks generated by smaller quantized local models.", - " Star Toggle: Select downloaded weights to launch automatically on app startup without manual model load." - ] - }, - { - id: "v1.8.0-mobile-companion", - version: "v1.8.0", - title: "Mobile Companion Service", - category: "Mobile", - description: "Secure, local-network mobile access to Cairn notes, boards, and agents.", - highlights: [ - "Mobile QR Code Pairing: Scan pairing QR codes or enter single-use PINs to launch secure mobile client sessions.", - "Mobile Responsive Layouts: Sidebar collapsible drawers, swipeable settings categories, and dynamic content scaling.", - "Touch DnD Support: Holds and scrolls on touchscreens cede canvas pointer control instantly to board DnD sensors.", - "Web Share PDF Export: Generate standard print-layout PDFs and export them directly to native mobile share sheets." - ] - }, - { - id: "v2.0.0-codebase-agent", - version: "v2.0.0", - title: "Codebase Indexing & Agent Guardrails", - category: "Agent", - description: "AST-free regex symbol indexers and live execution confirmations.", - highlights: [ - "Semantic Codebase Indexing: Incremental parser mapping functions, classes, and call graph relations for Rust, Go, Python, and JS.", - "Plan & Execute Toggles: Lower temperature Outline settings for safe, deterministic planning drafts.", - "Modifying Tool Confirmation: Security gate pausing execution for inline Confirm/Deny tool approval in message bubbles." - ] - }, - { - id: "v2.1.0-graph-radial", - version: "v2.1.0", - title: "Radial Knowledge Graph", - category: "Knowledge Graph", - description: "Visualize note connections with radial hierarchical alignments.", - highlights: [ - "Radial Tree Layout: Toggle between classic Force-directed nodes and a clean, centered radial hierarchy.", - "Interactive SVG Scaling: Fully scales fonts and SVG elements to match your in-app custom font settings." - ] - }, - { - id: "v2.2.0-thinking-stream", - version: "v2.2.0", - title: "Reasoning & Thinking Streams", - category: "AI Chat", - description: "Peek behind the curtain to observe the model's logic as it streams in real-time.", - highlights: [ - "Collapsible Thinking Blocks: Follow the model's reasoning stream. Blocks auto-collapse once generation starts.", - "Context & Usage Metrics: View exact breakdown of answer tokens vs reasoning tokens in the ContextRing popover." - ] - }, - { - id: "v2.3.0-git-workflow", - version: "v2.3.0", - title: "Polished Git Integrations", - category: "Git", - description: "Streamlined branch management and file discarding controls.", - highlights: [ - "Radix Branch Switcher: Search, filter, and switch branches quickly, or create new branches from a dedicated dialog.", - "File-Level & Bulk Discarding: Clean your workspace by discarding specific changes or sections with verification prompts.", - "Action Tooltips: Custom theme-aware tooltips details for all stage, unstage, commit, and discard actions." - ] - }, - { - id: "v2.3.0-chat-layout", - version: "v2.3.0", - title: "Dynamic Chat Layout & Previews", - category: "AI Chat", - description: "Work with notes and tasks side-by-side during AI chats.", - highlights: [ - "Dynamic Layout Morphing: Toggle the chat between right-docked sidebar and full workspace modes smoothly.", - "Side-by-Side Context Previews: Click references, tasks, or notes in assistant responses to view them instantly next to your chat.", - "Direct Workspace Navigation: Jump straight to the board or notes workspace directly from the preview header." - ] - }, - { - id: "v2.3.2-onboarding-tour", - version: "v2.3.2", - title: "Interactive Workspace Tour & Highlights", - category: "Workspace Help", - description: "Learn your way around the workspaces with visual guided tours and feature slides.", - highlights: [ - "Guided Workspace Tour: Step through an interactive, visual tour explaining Overview, Notes, Board, and Flow views.", - "Real-Time Transition Cutout: Highlight portal adapts instantly to window resizing and CSS sliding panel drawers.", - "What's New Modal: Paginated carousel modal highlights new updates automatically on launch.", - "Skip All Controls: Skip remaining slide cards instantly and mark everything as seen to prevent boot prompts." - ] - }, - { - id: "v2.3.4-calendar", - version: "v2.3.4", - title: "Calendar View", - category: "Planning", - description: "Schedule tasks by due date and see what's overdue at a glance.", - highlights: [ - "Month & Week Layouts: A project-scoped calendar (⌘4) lays out task cards by due date with prev / next / today navigation.", - "Drag to Reschedule: Drag any task onto a different day to change its due date — an optimistic, fully undoable update.", - "Overdue & Unscheduled Trays: A past-due banner and a collapsible no-due-date tray let you drag tasks straight onto a day to schedule them.", - "Day Detail Modal: Packed days show a \"+N more\" affordance opening a list of everything due that day." - ] - }, - { - id: "v2.3.4-external-tools", - version: "v2.3.4", - title: "External Tools — MCP Servers & HTTP APIs", - category: "AI Tools", - description: "Connect the AI chat and agent to remote MCP servers and any HTTP API.", - highlights: [ - "Settings → Tools: Add remote MCP servers (SSE or streamable-HTTP) and expose any HTTP API as a single tool, each with an enable toggle and Test connection button.", - "Per-Project Attach: Attach tools per-project from the Overview → Tools panel so each project only sees the tools it needs.", - "AI Tool Builder: Describe an endpoint in plain English; the builder probes it live, figures out the auth and response shape, and trims it to just the useful fields to save tokens.", - "Secure Credentials: API keys and tokens live in your OS keychain (Keychain / DPAPI / libsecret) — never in the database, plain text, or sent to the AI model." - ] - }, - { - id: "v2.3.4-mcp-oauth", - version: "v2.3.4", - title: "OAuth Sign-in for Remote MCP Servers", - category: "AI Tools", - description: "Connect to MCP servers that gate access behind a sign-in page — Figma, Linear, Notion, GitHub, and more.", - highlights: [ - "One-Click Sign In: Choose OAuth as the auth mode, click Sign in, approve in your browser, and you're redirected back via a cairn:// deep link.", - "Full OAuth 2.1 Flow: Server discovery, dynamic client registration, PKCE, token exchange, and automatic refresh are handled for you by the MCP SDK.", - "Keychain-Backed Tokens: Access and refresh tokens are encrypted in your OS keychain and never exposed to the AI model or the app's UI layer.", - "Connection Status: A Connected / Sign out control shows each server's auth state at a glance." - ] - } -]; diff --git a/src/store/index.ts b/src/store/index.ts index ef7ffec59..01d36e232 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -25,7 +25,7 @@ import { MIN_NOTES_SIDEBAR_WIDTH, MAX_NOTES_SIDEBAR_WIDTH } from "./slices/ui"; // ── Slice imports ───────────────────────────────────────────────────────────── import { createUISlice } from "./slices/ui"; import type { UISlice, AIConfig, AgentConfig, Theme, ToggleableView } from "./slices/ui"; -import { applyTheme, THEME_KEY, applyFontScale, FONT_SCALE_KEY, DEFAULT_FONT_SCALE, HIDDEN_VIEWS_KEY, SEEN_FEATURES_KEY, MIN_CHAT_PANEL_WIDTH, MAX_CHAT_PANEL_WIDTH } from "./slices/ui"; +import { applyTheme, THEME_KEY, applyFontScale, FONT_SCALE_KEY, DEFAULT_FONT_SCALE, HIDDEN_VIEWS_KEY, SEEN_FEATURES_KEY, MIN_CHAT_PANEL_WIDTH, MAX_CHAT_PANEL_WIDTH, migrateLlmKeysToKeychain } from "./slices/ui"; import type { FontScale } from "./slices/ui"; import { createWorkspaceSlice } from "./slices/workspace"; import type { WorkspaceSlice } from "./slices/workspace"; @@ -394,6 +394,27 @@ export const useCairnStore = create()( set({ agentConfig: DEFAULT_AGENT_CONFIG }); } + // One-time: relocate any raw LLM API keys (legacy top-level or per-provider) + // into the OS keychain, replacing them with reference tokens. Persists the + // scrubbed configs so plaintext keys leave localStorage + the settings cache. + try { + const cur = get(); + const migrated = await migrateLlmKeysToKeychain(cur.aiConfig, cur.agentConfig); + if (migrated.changed) { + set({ aiConfig: migrated.ai, agentConfig: migrated.agent }); + storage.set(AI_CONFIG_KEY, migrated.ai); + storage.set(AGENT_CONFIG_KEY, migrated.agent); + if (window.electron?.saveAiSettings) { + window.electron.saveAiSettings(migrated.ai as unknown as Record).catch(() => {}); + } + if (window.electron?.saveAgentSettings) { + window.electron.saveAgentSettings(migrated.agent as unknown as Record).catch(() => {}); + } + } + } catch (e) { + console.warn("LLM key keychain migration failed:", e); + } + restorePersistedUiPrefs(set); const snap = (await window.electron!.snapshot()) as PersistedState; diff --git a/src/store/slices/ui.ts b/src/store/slices/ui.ts index e450dd9c7..d86fe6f11 100644 --- a/src/store/slices/ui.ts +++ b/src/store/slices/ui.ts @@ -36,7 +36,12 @@ export interface SavedProvider { baseUrl: string; /** API key. Empty = use the server-side OPENAI_API_KEY env var / keyless local. */ apiKey: string; - /** Default model id for this provider. */ + /** + * Default model id for this provider. Used to SEED a surface's model when it + * first selects this provider. Each consumer (AI Chat, coding agent) then + * keeps its own `model` on `aiConfig`/`agentConfig` and can diverge — so the + * same provider can run a different model in chat vs. the agent. + */ model: string; } @@ -52,22 +57,34 @@ type ProviderCarrier = { activeProviderId?: string; }; -/** Mirror a provider's connection into a config + mark it that config's active. */ +/** + * Mirror a provider's connection into a config + mark it that config's active. + * Seeds `model` from the provider default (used on select/add). Once selected, + * the config's model is independent — see `reconcileConfig`, which preserves it. + */ function mirrorProvider(config: C, p: SavedProvider): C { return { ...config, activeProviderId: p.id, baseUrl: p.baseUrl, apiKey: p.apiKey, model: p.model }; } +/** Re-sync only the CONNECTION (baseUrl/apiKey) from a provider, keeping the + * config's own chosen model. Used when the shared list is edited/deleted. */ +function syncConnection(config: C, p: SavedProvider): C { + return { ...config, activeProviderId: p.id, baseUrl: p.baseUrl, apiKey: p.apiKey }; +} + /** * Reconcile a config after the shared provider list changed. Only touches a * config that had a provider selected: if its active provider still exists, - * re-mirror it (picks up edits); if it was just deleted, fall back to the first - * remaining provider. A config with NO active provider is left untouched — an - * unselected surface must never inherit list[0] or the other surface's choice. + * re-sync its connection (baseUrl/apiKey) while KEEPING the config's own model; + * if it was just deleted, fall back to the first remaining provider (seeding + * that provider's default model). A config with NO active provider is left + * untouched — an unselected surface must never inherit list[0] or the other + * surface's choice. */ function reconcileConfig(config: C, list: SavedProvider[]): C { if (!config.activeProviderId) return config; // never auto-select for an unselected surface const active = list.find((p) => p.id === config.activeProviderId); - if (active) return mirrorProvider(config, active); + if (active) return syncConnection(config, active); // keep the config's own model const fallback = list[0]; return fallback ? mirrorProvider(config, fallback) : config; } @@ -88,6 +105,92 @@ function persistAgent(next: AgentConfig): void { } } +/** True if a value is a keychain reference token (not a raw key). */ +function isKeyRef(v: string | undefined | null): boolean { + return typeof v === "string" && v.startsWith("secret://"); +} + +/** + * One-time migration: move any RAW LLM API key found in the persisted configs + * (legacy top-level `apiKey`, or `savedProviders[].apiKey`) into the OS keychain + * and replace it with a `secret://llm:…/apiKey` reference token. Returns the + * possibly-updated configs (or the originals if nothing changed / no electron). + * + * Runs during hydration so upgrading users' plaintext keys are relocated to the + * keychain and scrubbed from localStorage + the settings cache on next launch. + */ +export async function migrateLlmKeysToKeychain( + ai: AIConfig, + agent: AgentConfig, +): Promise<{ ai: AIConfig; agent: AgentConfig; changed: boolean }> { + const secrets = typeof window !== "undefined" ? window.electron?.secrets : undefined; + if (!secrets) return { ai, agent, changed: false }; + + let changed = false; + + // 1. Saved providers: convert each provider's raw key to a keychain ref. + const providers = ai.savedProviders ?? []; + const migratedProviders: SavedProvider[] = []; + for (const p of providers) { + if (p.apiKey && !isKeyRef(p.apiKey)) { + try { + const ref = await secrets.set("llm", p.id, "apiKey", p.apiKey); + migratedProviders.push({ ...p, apiKey: ref }); + changed = true; + } catch { + // Keychain unavailable — drop the raw key rather than keep it in plaintext. + migratedProviders.push({ ...p, apiKey: "" }); + changed = true; + } + } else { + migratedProviders.push(p); + } + } + + let nextAi = ai; + if (changed) nextAi = { ...ai, savedProviders: migratedProviders }; + + // 2. Mirror the active provider's (now-ref) key onto the top-level field. + const activeAi = migratedProviders.find((p) => p.id === nextAi.activeProviderId); + if (activeAi) { + if (nextAi.apiKey !== activeAi.apiKey) { + nextAi = { ...nextAi, apiKey: activeAi.apiKey }; + changed = true; // persist the refreshed top-level key + } + } else if (nextAi.apiKey && !isKeyRef(nextAi.apiKey)) { + // No provider selected but a legacy raw top-level key exists: store it under + // a stable synthetic id so it survives as a ref (keyless if it fails). + try { + const ref = await secrets.set("llm", "legacy-ai", "apiKey", nextAi.apiKey); + nextAi = { ...nextAi, apiKey: ref }; + } catch { + nextAi = { ...nextAi, apiKey: "" }; + } + changed = true; + } + + // 3. Coding agent top-level key (shares the provider list, but may carry its + // own legacy raw key / its active provider's ref). + let nextAgent = agent; + const activeAgent = migratedProviders.find((p) => p.id === agent.activeProviderId); + if (activeAgent) { + if (agent.apiKey !== activeAgent.apiKey) { + nextAgent = { ...agent, apiKey: activeAgent.apiKey }; + changed = true; + } + } else if (agent.apiKey && !isKeyRef(agent.apiKey)) { + try { + const ref = await secrets.set("llm", "legacy-agent", "apiKey", agent.apiKey); + nextAgent = { ...agent, apiKey: ref }; + } catch { + nextAgent = { ...agent, apiKey: "" }; + } + changed = true; + } + + return { ai: nextAi, agent: nextAgent, changed }; +} + export interface AIConfig { /** The AI provider ('openai' or 'localllm') */ @@ -409,7 +512,11 @@ export const createUISlice: StateCreator = ( set((s) => { const p = (s.aiConfig.savedProviders ?? []).find((x) => x.id === id); if (!p) return {}; - const nextAi = mirrorProvider(s.aiConfig, p); + // Switching provider seeds the model from the provider default; re-selecting + // the current provider keeps the surface's chosen model. + const nextAi = s.aiConfig.activeProviderId === id + ? syncConnection(s.aiConfig, p) + : mirrorProvider(s.aiConfig, p); persistAi(nextAi); return { aiConfig: nextAi }; }); @@ -420,7 +527,9 @@ export const createUISlice: StateCreator = ( // The list lives on aiConfig; read it there. const p = (s.aiConfig.savedProviders ?? []).find((x) => x.id === id); if (!p) return {}; - const nextAgent = mirrorProvider(s.agentConfig, p); + const nextAgent = s.agentConfig.activeProviderId === id + ? syncConnection(s.agentConfig, p) + : mirrorProvider(s.agentConfig, p); persistAgent(nextAgent); return { agentConfig: nextAgent }; });