Initial release: business-name finder with live domain availability

Free, no-account tool for naming a business and finding a domain you can
actually register.

Availability uses no paid API and no key. IANA's RDAP bootstrap maps ~1,200
TLDs to their authoritative registry servers (404 = free, 200 = taken); the
handful with no RDAP at all (.io, .co, .me, .sh, .gg, .us) fall back to a DNS
NS lookup and are reported as "probably free" rather than confirmed, because a
registered-but-undelegated domain is indistinguishable that way.

Name generation is pure, deterministic, and client-side — eight strategies over
a curated word bank, ranked by a scorer that does the real quality work. Three
of its guards (prefix-only hint matching, -er-only syncopation, truncation
rejection) exist because of specific bad output and should not be relaxed.

Optional AI suggestions use the visitor's OWN Anthropic/Gemini key from
localStorage, called browser-direct, so this stays free to run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JChWdFJPCRxMBxErb8kUVK
This commit is contained in:
2026-08-10 13:40:26 -05:00
commit 78460d35c9
29 changed files with 3644 additions and 0 deletions

144
src/lib/ai.ts Normal file
View File

@@ -0,0 +1,144 @@
// Optional AI name generation, using the VISITOR'S OWN API key.
//
// 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.
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<AiSettings>;
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));
}
const SYSTEM_PROMPT = [
"You name businesses. Given a description, return brandable company names.",
"",
"Rules:",
"- 4 to 12 letters, lowercase, letters only (no spaces, digits, or hyphens).",
"- Easy to say out loud and to spell after hearing it once.",
"- Prefer real words, evocative compounds, and clean coinages.",
"- Avoid: the literal category word alone, startup filler (-ly on everything,",
" 'solutions', 'synergy', 'hub' overuse), and misspellings of common words.",
"- Vary the register: some grounded and concrete, some abstract and premium.",
"",
'Respond with ONLY a JSON array of strings. No prose, no code fence.',
].join("\n");
function userPrompt(keywords: string[], style: string, count: number): string {
return [
`Business description / keywords: ${keywords.join(", ")}`,
`Preferred style: ${style}`,
`Return exactly ${count} names as a JSON array of lowercase strings.`,
].join("\n");
}
/** Pull a JSON array of names out of a model response that may still have prose
* or a code fence around it. */
function parseNames(text: string): string[] {
const start = text.indexOf("[");
const end = text.lastIndexOf("]");
if (start === -1 || end === -1 || end <= start) return [];
try {
const parsed = JSON.parse(text.slice(start, end + 1)) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed
.filter((n): n is string => typeof n === "string")
.map((n) => n.toLowerCase().replace(/[^a-z]/g, ""))
.filter((n) => n.length >= 3 && n.length <= 18);
} catch {
return [];
}
}
async function callAnthropic(key: string, prompt: string): Promise<string> {
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<string> {
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<string[]> {
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))];
}