From 70cb355762a07e5bd99ee79a6383fb2060a6244c Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 15:54:58 +0200 Subject: [PATCH] feat(ai): settings page with a real connection test --- src/app/actions/ai-settings.ts | 85 +++++++++++++ src/app/admin/settings/page.tsx | 25 ++++ src/components/admin/ai-settings-form.tsx | 148 ++++++++++++++++++++++ 3 files changed, 258 insertions(+) create mode 100644 src/app/actions/ai-settings.ts create mode 100644 src/app/admin/settings/page.tsx create mode 100644 src/components/admin/ai-settings-form.tsx diff --git a/src/app/actions/ai-settings.ts b/src/app/actions/ai-settings.ts new file mode 100644 index 0000000..e149dea --- /dev/null +++ b/src/app/actions/ai-settings.ts @@ -0,0 +1,85 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { requireSession } from "@/lib/auth/session"; +import { saveAiSettings } from "@/lib/ai/settings"; +import { getProvider, getSystemPrompt, AiError } from "@/lib/ai"; +import type { ProviderName } from "@/lib/ai/types"; + +/** See the security note at the top of src/app/actions/cards.ts. */ +async function requireOwner() { + const session = await requireSession(); + if (session.role !== "owner") throw new Error("Not found"); + return session; +} + +export async function saveAiSettingsAction(input: { + provider: ProviderName; + apiKey: string; + model: string; + systemPromptExtra: string; +}): Promise { + await requireOwner(); + + await saveAiSettings({ + provider: input.provider, + // Empty means "leave it alone", not "clear it" — see saveAiSettings. + apiKey: input.apiKey, + model: input.model, + systemPromptExtra: input.systemPromptExtra, + }); + + revalidatePath("/admin/settings"); +} + +/** + * Prove the configuration works before anything depends on it. + * + * Sends a real commit message through the real provider and returns what came + * back, so the owner can see both that it connects AND that the output reads + * the way they want. A test that only checks connectivity would pass with a + * prompt that produces unusable summaries. + */ +export async function testAiConnectionAction(): Promise<{ + ok: boolean; + message: string; + sample?: string; + model?: string; +}> { + await requireOwner(); + + try { + const provider = await getProvider(); + const system = await getSystemPrompt(); + + const result = await provider.complete({ + system, + user: `Translate this commit into one plain-English sentence for the client: + +fix(quotes): persist operator's letterhead choice to prevent silent letterhead-flip + +Reply with the sentence only.`, + maxTokens: 512, + }); + + if (!result.text) { + return { ok: false, message: "The provider returned an empty response." }; + } + + return { + ok: true, + message: `Connected. ${result.inputTokens} in, ${result.outputTokens} out.`, + sample: result.text, + model: result.model, + }; + } catch (error) { + if (error instanceof AiError) { + return { ok: false, message: error.message }; + } + return { + ok: false, + message: error instanceof Error ? error.message : "The test failed", + }; + } +} \ No newline at end of file diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx new file mode 100644 index 0000000..112848a --- /dev/null +++ b/src/app/admin/settings/page.tsx @@ -0,0 +1,25 @@ +import { AppShell } from "@/components/shell/app-shell"; +import { TopBar } from "@/components/shell/top-bar"; +import { AiSettingsForm } from "@/components/admin/ai-settings-form"; +import { loadAiSettingsView } from "@/lib/ai/settings"; + +/** + * The admin layout has already refused anyone who is not the owner. + * + * `loadAiSettingsView` returns `hasApiKey`, never the key. The value is not in + * this page's props, so it cannot reach the browser even by accident. + */ +export default async function AiSettingsPage() { + const initial = await loadAiSettingsView(); + + return ( + + +
+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/admin/ai-settings-form.tsx b/src/components/admin/ai-settings-form.tsx new file mode 100644 index 0000000..3ef3908 --- /dev/null +++ b/src/components/admin/ai-settings-form.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Check, X } from "lucide-react"; + +import { saveAiSettingsAction, testAiConnectionAction } from "@/app/actions/ai-settings"; +import type { AiSettingsView } from "@/lib/ai/settings"; +import type { ProviderName } from "@/lib/ai/types"; + +const PROVIDERS: Array<{ value: ProviderName; label: string; hint: string }> = [ + { value: "anthropic", label: "Anthropic", hint: "claude-opus-5 — about $0.90 to summarise the full history" }, + { value: "minimax", label: "MiniMax", hint: "Needs an Open Platform key, not a Subscription Key" }, + { value: "openai", label: "OpenAI-compatible", hint: "Any endpoint speaking the OpenAI chat API" }, +]; + +export function AiSettingsForm({ initial }: { initial: AiSettingsView }) { + const [provider, setProvider] = useState(initial.provider); + const [apiKey, setApiKey] = useState(""); + const [model, setModel] = useState(initial.model); + const [extra, setExtra] = useState(initial.systemPromptExtra); + const [pending, startTransition] = useTransition(); + const [saved, setSaved] = useState(false); + const [test, setTest] = useState> | null>(null); + + const field = + "w-full rounded-lg border border-border-subtle bg-surface px-3 py-2 text-sm text-text-primary outline-none focus:border-brand"; + + return ( +
+
+ + + + + +
+ +
+ Additional instructions +