feat(ai): settings page with a real connection test

This commit is contained in:
Dave 2026-09-16 15:54:58 +02:00
parent e837500c6f
commit 70cb355762
3 changed files with 258 additions and 0 deletions

View file

@ -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<void> {
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",
};
}
}

View file

@ -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 (
<AppShell role="owner" currentPath="/admin/settings">
<TopBar title="AI settings" subtitle="Used to translate commits for clients" />
<div className="flex-1 overflow-y-auto p-6">
<div className="mx-auto max-w-2xl">
<AiSettingsForm initial={initial} />
</div>
</div>
</AppShell>
);
}

View file

@ -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<ProviderName>(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<Awaited<ReturnType<typeof testAiConnectionAction>> | 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 (
<div className="space-y-6">
<section className="space-y-3">
<label className="block space-y-1">
<span className="text-xs text-text-muted">Provider</span>
<select
value={provider}
onChange={(event) => setProvider(event.target.value as ProviderName)}
className={field}
>
{PROVIDERS.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
<span className="block text-[11px] text-text-muted">
{PROVIDERS.find((p) => p.value === provider)?.hint}
</span>
</label>
<label className="block space-y-1">
<span className="text-xs text-text-muted">Model</span>
<input value={model} onChange={(e) => setModel(e.target.value)} className={field} />
</label>
<label className="block space-y-1">
<span className="text-xs text-text-muted">
API key {initial.hasApiKey && "— a key is saved; leave blank to keep it"}
</span>
<input
type="password"
autoComplete="off"
placeholder={initial.hasApiKey ? "••••••••••••" : "Paste the key"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className={field}
/>
{/* The stored key is never sent to this component — only whether one
exists. There is no code path that renders its value. */}
<span className="block text-[11px] text-text-muted">
Stored server-side and never sent to a browser, including this one.
</span>
</label>
</section>
<section className="space-y-1">
<span className="text-xs text-text-muted">Additional instructions</span>
<textarea
rows={5}
value={extra}
onChange={(e) => setExtra(e.target.value)}
placeholder="e.g. Refer to the product as the Operations Portal."
className={field}
/>
<p className="text-[11px] text-text-muted">
Added <strong>after</strong> a fixed set of rules that cannot be
changed here — no invented dates, no completion claims, reverts
reported as reverts.
</p>
</section>
<div className="flex items-center gap-3">
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
await saveAiSettingsAction({ provider, apiKey, model, systemPromptExtra: extra });
setApiKey("");
setSaved(true);
setTest(null);
})
}
className="rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg hover:bg-brand-hover disabled:opacity-50"
>
{pending ? "Saving…" : "Save"}
</button>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
setSaved(false);
setTest(await testAiConnectionAction());
})
}
className="rounded-lg border border-border-subtle px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-raised disabled:opacity-50"
>
Test connection
</button>
{saved && <span className="text-xs text-text-muted">Saved.</span>}
</div>
{test && (
<div
className={`space-y-2 rounded-[var(--radius-tile)] border p-4 ${
test.ok ? "border-border-subtle bg-surface" : "border-accent bg-accent-soft"
}`}
>
<p className="flex items-center gap-2 text-sm font-medium text-text-primary">
{test.ok ? <Check size={14} strokeWidth={2} /> : <X size={14} strokeWidth={2} />}
{test.message}
</p>
{test.sample && (
<>
<p className="text-xs text-text-muted">
A real commit, translated with your current settings:
</p>
<p className="rounded-lg bg-panel p-3 text-sm text-text-secondary">
{test.sample}
</p>
</>
)}
</div>
)}
</div>
);
}