// Optional AI name generation, using the VISITOR'S OWN API key. (The other AI // path — the platform's metered gateway for signed-in OTM accounts — is // src/lib/platform-ai.ts + app/api/ai-names; both share src/lib/ai-prompt.ts.) // // The key lives in this browser's localStorage and the request goes straight // from the browser to Anthropic/Google. It never touches our server, never // appears in our logs, and we never hold a metered credential for a free public // tool. Same pattern rpo uses for its Google Vision key. // // Anthropic normally refuses browser-origin calls to protect people from // shipping a secret key in a web bundle; `anthropic-dangerous-direct-browser- // access` opts out. That header is correct HERE and would be wrong in a product // that owns the key — the distinction is whose key it is. The user pasted their // own, knowingly, into their own browser. import { SYSTEM_PROMPT, userPrompt, parseNames } from "@/lib/ai-prompt"; export type AiProvider = "anthropic" | "gemini"; export interface AiSettings { provider: AiProvider; key: string; } const STORAGE_KEY = "names.aiKey"; export function loadAiSettings(): AiSettings | null { if (typeof window === "undefined") return null; try { const raw = window.localStorage.getItem(STORAGE_KEY); if (!raw) return null; const parsed = JSON.parse(raw) as Partial; if (!parsed.key || (parsed.provider !== "anthropic" && parsed.provider !== "gemini")) return null; return { provider: parsed.provider, key: parsed.key }; } catch { return null; } } export function saveAiSettings(settings: AiSettings | null): void { if (typeof window === "undefined") return; if (!settings) window.localStorage.removeItem(STORAGE_KEY); else window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); } async function callAnthropic(key: string, prompt: string): Promise { const res = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", headers: { "content-type": "application/json", "x-api-key": key, "anthropic-version": "2023-06-01", "anthropic-dangerous-direct-browser-access": "true", }, body: JSON.stringify({ model: "claude-sonnet-5", max_tokens: 1024, system: SYSTEM_PROMPT, messages: [{ role: "user", content: prompt }], }), }); if (!res.ok) { const detail = await res.text().catch(() => ""); throw new Error(`Anthropic returned ${res.status}. ${detail.slice(0, 200)}`); } const body = (await res.json()) as { content?: Array<{ type: string; text?: string }> }; return (body.content ?? []).map((b) => (b.type === "text" ? (b.text ?? "") : "")).join(""); } async function callGemini(key: string, prompt: string): Promise { const url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=" + encodeURIComponent(key); const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ systemInstruction: { parts: [{ text: SYSTEM_PROMPT }] }, contents: [{ role: "user", parts: [{ text: prompt }] }], generationConfig: { temperature: 1, maxOutputTokens: 1024 }, }), }); if (!res.ok) { const detail = await res.text().catch(() => ""); throw new Error(`Gemini returned ${res.status}. ${detail.slice(0, 200)}`); } const body = (await res.json()) as { candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>; }; return (body.candidates?.[0]?.content?.parts ?? []).map((p) => p.text ?? "").join(""); } /** Ask the visitor's chosen model for names. Throws with a readable message on * a bad key, a rate limit, or a network failure — the UI shows it and falls * back to the built-in generator. */ export async function generateWithAi( settings: AiSettings, keywords: string[], style: string, count = 24, ): Promise { const prompt = userPrompt(keywords, style, count); const text = settings.provider === "anthropic" ? await callAnthropic(settings.key, prompt) : await callGemini(settings.key, prompt); return [...new Set(parseNames(text))]; }