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:
144
src/lib/ai.ts
Normal file
144
src/lib/ai.ts
Normal 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))];
|
||||
}
|
||||
Reference in New Issue
Block a user