feat(ai): settings page with a real connection test
This commit is contained in:
parent
e837500c6f
commit
70cb355762
3 changed files with 258 additions and 0 deletions
85
src/app/actions/ai-settings.ts
Normal file
85
src/app/actions/ai-settings.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
25
src/app/admin/settings/page.tsx
Normal file
25
src/app/admin/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
src/components/admin/ai-settings-form.tsx
Normal file
148
src/components/admin/ai-settings-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue