feat(ai): settings storage with a browser-safe projection
This commit is contained in:
parent
8f1ac43e98
commit
f3e4b30d46
2 changed files with 1219 additions and 0 deletions
1105
docs/superpowers/plans/2026-09-16-phase-3c-narratives-and-display.md
Normal file
1105
docs/superpowers/plans/2026-09-16-phase-3c-narratives-and-display.md
Normal file
File diff suppressed because it is too large
Load diff
114
src/lib/ai/settings.ts
Normal file
114
src/lib/ai/settings.ts
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue