feat(ai): settings storage with a browser-safe projection

This commit is contained in:
Dave 2026-09-16 15:52:12 +02:00
parent 8f1ac43e98
commit f3e4b30d46
2 changed files with 1219 additions and 0 deletions

File diff suppressed because it is too large Load diff

114
src/lib/ai/settings.ts Normal file
View file

@ -0,0 +1,114 @@
import "server-only";
import { getAdminPb } from "@/lib/pb/admin";
import type { ProviderName } from "@/lib/ai/types";
/**
* Reads and writes the single `settings` record created in Phase 1.
*
* Superuser-only, always. `ai_api_key` is a live credential and every rule on
* that collection is null for exactly that reason — no browser, the owner's
* included, may fetch it. Nothing here may be called from a client component.
*/
export interface AiSettings {
provider: ProviderName;
apiKey: string;
model: string;
systemPromptExtra: string;
}
/** Safe to send to a browser: presence of a key, never its value. */
export interface AiSettingsView {
provider: ProviderName;
hasApiKey: boolean;
model: string;
systemPromptExtra: string;
}
export const DEFAULT_MODELS: Record<ProviderName, string> = {
// Verified against the bundled claude-api skill, not recalled. The whole
// 779-commit backfill costs well under a dollar on this model, so there is no
// cost argument for a weaker default.
anthropic: "claude-opus-5",
minimax: "MiniMax-M2",
openai: "gpt-4o-mini",
};
async function settingsRecord() {
const pb = await getAdminPb();
const existing = await pb
.collection("settings")
.getFirstListItem("id != ''")
.catch(() => null);
return { pb, existing };
}
export async function loadAiSettings(): Promise<AiSettings | null> {
const { existing } = await settingsRecord();
if (!existing) return null;
const provider = (existing.ai_provider || "anthropic") as ProviderName;
return {
provider,
apiKey: existing.ai_api_key || "",
model: existing.ai_model || DEFAULT_MODELS[provider],
systemPromptExtra: existing.ai_system_prompt_extra || "",
};
}
/** The browser-safe projection. Never returns the key itself. */
export async function loadAiSettingsView(): Promise<AiSettingsView> {
const settings = await loadAiSettings();
if (!settings) {
return {
provider: "anthropic",
hasApiKey: false,
model: DEFAULT_MODELS.anthropic,
systemPromptExtra: "",
};
}
return {
provider: settings.provider,
hasApiKey: Boolean(settings.apiKey),
model: settings.model,
systemPromptExtra: settings.systemPromptExtra,
};
}
/**
* Write settings, creating the record on first save.
*
* `apiKey` is only written when a non-empty value is supplied. The settings
* form submits an empty key field when the owner is editing something else, and
* treating that as "clear the key" would silently disable the whole feature.
*/
export async function saveAiSettings(patch: {
provider?: ProviderName;
apiKey?: string;
model?: string;
systemPromptExtra?: string;
}): Promise<void> {
const { pb, existing } = await settingsRecord();
const data: Record<string, unknown> = {};
if (patch.provider !== undefined) data.ai_provider = patch.provider;
if (patch.model !== undefined) data.ai_model = patch.model.trim();
if (patch.systemPromptExtra !== undefined) {
data.ai_system_prompt_extra = patch.systemPromptExtra;
}
if (patch.apiKey) data.ai_api_key = patch.apiKey.trim();
if (existing) {
await pb.collection("settings").update(existing.id, data);
} else {
await pb.collection("settings").create(data);
}
}
export async function aiConfigured(): Promise<boolean> {
const settings = await loadAiSettings();
return Boolean(settings?.apiKey);
}