feat: 'Tell the AI more' free-text brief for AI names (v0.5.0)

Appended to the shared prompt (lib/ai-prompt.ts) on both AI paths; capped at
300 chars client- and server-side; shown only when AI is on; kept in
sessionStorage so it survives 'Show me more'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XStQKxPfEjrTvWo83KFCxG
This commit is contained in:
2026-08-16 23:58:07 -05:00
parent 575feb7bdd
commit 810b357044
9 changed files with 84 additions and 14 deletions

View File

@@ -70,6 +70,13 @@ holds a per-app gateway token, never a real Anthropic key, and the route refuses
anonymous requests and caps each account at roughly 20 requests an hour. The anonymous requests and caps each account at roughly 20 requests an hour. The
platform's monthly AI cap is the backstop. platform's monthly AI cap is the backstop.
There's no chat: the AI is briefed from the same keywords box and Style chip
as the built-in generator (`src/lib/ai-prompt.ts` is the rulebook), and its
names are merged in front of the built-in list, tagged **AI**, and checked like
everything else. When AI is on, a **Tell the AI more** field appears — tone,
words to include or avoid, audience — which is appended to that brief (capped
at 300 chars, enforced client- and server-side). The built-in generator ignores it.
Sign-in is OTM SSO: `/sso/authorize?aud=app:names` on the control plane hands Sign-in is OTM SSO: `/sso/authorize?aud=app:names` on the control plane hands
back a 60-second single-use ticket to `/api/auth/otm-sso`, which mints a small back a 60-second single-use ticket to `/api/auth/otm-sso`, which mints a small
HMAC-signed cookie (`names.session`). There are no user accounts in this app. HMAC-signed cookie (`names.session`). There are no user accounts in this app.

View File

@@ -1,6 +1,6 @@
{ {
"name": "names-poweredbyotm", "name": "names-poweredbyotm",
"version": "0.4.1", "version": "0.5.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --port 3000", "dev": "next dev --port 3000",

View File

@@ -13,7 +13,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { readSession, platformAiEnabled } from "@/lib/session"; import { readSession, platformAiEnabled } from "@/lib/session";
import { SYSTEM_PROMPT, userPrompt, parseNames } from "@/lib/ai-prompt"; import { SYSTEM_PROMPT, userPrompt, parseNames, BRIEF_MAX } from "@/lib/ai-prompt";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -47,6 +47,7 @@ interface Body {
keywords?: unknown; keywords?: unknown;
style?: unknown; style?: unknown;
count?: unknown; count?: unknown;
brief?: unknown;
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
@@ -79,6 +80,7 @@ export async function POST(req: NextRequest) {
: []; : [];
const style = typeof body.style === "string" ? body.style.slice(0, 20) : "balanced"; const style = typeof body.style === "string" ? body.style.slice(0, 20) : "balanced";
const count = Math.max(4, Math.min(MAX_COUNT, typeof body.count === "number" ? Math.floor(body.count) : 16)); const count = Math.max(4, Math.min(MAX_COUNT, typeof body.count === "number" ? Math.floor(body.count) : 16));
const brief = typeof body.brief === "string" ? body.brief.slice(0, BRIEF_MAX) : "";
const base = process.env.ANTHROPIC_BASE_URL!.replace(/\/+$/, ""); const base = process.env.ANTHROPIC_BASE_URL!.replace(/\/+$/, "");
let upstream: Response; let upstream: Response;
@@ -94,7 +96,7 @@ export async function POST(req: NextRequest) {
model: MODEL, model: MODEL,
max_tokens: 1024, max_tokens: 1024,
system: SYSTEM_PROMPT, system: SYSTEM_PROMPT,
messages: [{ role: "user", content: userPrompt(keywords.length ? keywords : ["a new business"], style, count) }], messages: [{ role: "user", content: userPrompt(keywords.length ? keywords : ["a new business"], style, count, brief) }],
}), }),
signal: AbortSignal.timeout(30_000), signal: AbortSignal.timeout(30_000),
}); });

View File

@@ -398,6 +398,10 @@ h2 {
text-decoration: underline; text-decoration: underline;
} }
.field.brief {
margin-top: 4px;
}
.signin { .signin {
font-size: 14px; font-size: 14px;
text-decoration: none; text-decoration: none;

View File

@@ -10,6 +10,7 @@ import { DEFAULT_TLDS, TLDS, UNVERIFIABLE_TLDS } from "@/lib/tlds";
import { DEFAULT_REGISTRAR, REGISTRARS } from "@/lib/registrars"; import { DEFAULT_REGISTRAR, REGISTRARS } from "@/lib/registrars";
import { generateWithAi, loadAiSettings, type AiSettings } from "@/lib/ai"; import { generateWithAi, loadAiSettings, type AiSettings } from "@/lib/ai";
import { generateWithPlatformAi } from "@/lib/platform-ai"; import { generateWithPlatformAi } from "@/lib/platform-ai";
import { BRIEF_MAX } from "@/lib/ai-prompt";
import { BATCH, domainsFor, fetchAvailability, type DomainCheck } from "@/lib/availability-client"; import { BATCH, domainsFor, fetchAvailability, type DomainCheck } from "@/lib/availability-client";
import { useOtmSession } from "@/hooks/useOtmSession"; import { useOtmSession } from "@/hooks/useOtmSession";
import { useWishlist } from "@/hooks/useWishlist"; import { useWishlist } from "@/hooks/useWishlist";
@@ -43,6 +44,9 @@ export default function Finder() {
const [ai, setAi] = useState<AiSettings | null>(null); const [ai, setAi] = useState<AiSettings | null>(null);
const [useAi, setUseAi] = useState(false); const [useAi, setUseAi] = useState(false);
const [usePlatformAi, setUsePlatformAi] = useState(true); const [usePlatformAi, setUsePlatformAi] = useState(true);
// Free-text guidance for the AI ("premium, no puns"). Kept in sessionStorage
// so it survives "Show me more" and a reload, but not forever.
const [brief, setBrief] = useState("");
const { account, platformAi, signinNote, signOut } = useOtmSession(); const { account, platformAi, signinNote, signOut } = useOtmSession();
// How many names the last run got from AI vs the built-in generator — shown // How many names the last run got from AI vs the built-in generator — shown
// as a one-line summary so it's obvious whether AI actually contributed // as a one-line summary so it's obvious whether AI actually contributed
@@ -59,11 +63,21 @@ export default function Finder() {
setAi(loadAiSettings()); setAi(loadAiSettings());
try { try {
setHideTaken(window.localStorage.getItem("names.hideTaken") === "1"); setHideTaken(window.localStorage.getItem("names.hideTaken") === "1");
setBrief(window.sessionStorage.getItem("names.brief") ?? "");
} catch { } catch {
/* private mode */ /* private mode */
} }
}, []); }, []);
const onBrief = (v: string) => {
setBrief(v);
try {
window.sessionStorage.setItem("names.brief", v);
} catch {
/* private mode */
}
};
const toggleHideTaken = (v: boolean) => { const toggleHideTaken = (v: boolean) => {
setHideTaken(v); setHideTaken(v);
try { try {
@@ -137,8 +151,8 @@ export default function Finder() {
if (wantsOwnKey || wantsPlatform) { if (wantsOwnKey || wantsPlatform) {
try { try {
const aiNames = wantsOwnKey const aiNames = wantsOwnKey
? await generateWithAi(ai, parsedKeywords, style, 16) ? await generateWithAi(ai, parsedKeywords, style, 16, brief)
: await generateWithPlatformAi(parsedKeywords, style, 16); : await generateWithPlatformAi(parsedKeywords, style, 16, brief);
if (runId.current !== myRun) return; if (runId.current !== myRun) return;
const seen = new Set(list.map((i) => i.name)); const seen = new Set(list.map((i) => i.name));
const extra: Idea[] = aiNames const extra: Idea[] = aiNames
@@ -159,7 +173,7 @@ export default function Finder() {
setBusy(false); setBusy(false);
await lookup(domainsFor(list, tlds), myRun); await lookup(domainsFor(list, tlds), myRun);
}, },
[account, ai, lookup, parsedKeywords, platformAi, style, tlds, useAi, usePlatformAi], [account, ai, brief, lookup, parsedKeywords, platformAi, style, tlds, useAi, usePlatformAi],
); );
const onGenerate = () => { const onGenerate = () => {
@@ -173,6 +187,7 @@ export default function Finder() {
}; };
const showsUnverifiable = tlds.some((t) => UNVERIFIABLE_TLDS.includes(t)); const showsUnverifiable = tlds.some((t) => UNVERIFIABLE_TLDS.includes(t));
const aiOn = ai ? useAi : Boolean(platformAi && account && usePlatformAi);
/** A name is "all taken" once every domain we checked for it resolved taken. /** A name is "all taken" once every domain we checked for it resolved taken.
* Still-checking or unknown names stay visible — hiding is for certainty. */ * Still-checking or unknown names stay visible — hiding is for certainty. */
@@ -273,6 +288,27 @@ export default function Finder() {
/> />
</div> </div>
{aiOn ? (
<div className="field brief">
<label htmlFor="brief">Tell the AI more (optional)</label>
<input
id="brief"
type="text"
value={brief}
maxLength={BRIEF_MAX}
placeholder="sounds premium, no puns, avoid the word auto, could work in Spanish too"
onChange={(e) => onBrief(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onGenerate();
}}
/>
<p className="hint">
Tone, words to include or avoid, who it&rsquo;s for &mdash; anything the keywords don&rsquo;t say.
Only the AI sees this; the built-in generator ignores it.
</p>
</div>
) : null}
{tlds.length === 0 ? ( {tlds.length === 0 ? (
<p className="notice info">Pick at least one extension to check.</p> <p className="notice info">Pick at least one extension to check.</p>
) : null} ) : null}

View File

@@ -17,12 +17,20 @@ export const SYSTEM_PROMPT = [
'Respond with ONLY a JSON array of strings. No prose, no code fence.', 'Respond with ONLY a JSON array of strings. No prose, no code fence.',
].join("\n"); ].join("\n");
export function userPrompt(keywords: string[], style: string, count: number): string { /** Free-text guidance the visitor can add ("premium, no puns, avoid 'auto'").
return [ * Hard cap so a pasted essay can't balloon the request — enforced here AND in
* the server route, since the own-key path calls this straight from the browser. */
export const BRIEF_MAX = 300;
export function userPrompt(keywords: string[], style: string, count: number, brief = ""): string {
const lines = [
`Business description / keywords: ${keywords.join(", ")}`, `Business description / keywords: ${keywords.join(", ")}`,
`Preferred style: ${style}`, `Preferred style: ${style}`,
`Return exactly ${count} names as a JSON array of lowercase strings.`, ];
].join("\n"); const extra = brief.trim().slice(0, BRIEF_MAX);
if (extra) lines.push(`Extra guidance from the person naming the business: ${extra}`);
lines.push(`Return exactly ${count} names as a JSON array of lowercase strings.`);
return lines.join("\n");
} }
/** Pull a JSON array of names out of a model response that may still have prose /** Pull a JSON array of names out of a model response that may still have prose

View File

@@ -98,8 +98,9 @@ export async function generateWithAi(
keywords: string[], keywords: string[],
style: string, style: string,
count = 24, count = 24,
brief = "",
): Promise<string[]> { ): Promise<string[]> {
const prompt = userPrompt(keywords, style, count); const prompt = userPrompt(keywords, style, count, brief);
const text = const text =
settings.provider === "anthropic" settings.provider === "anthropic"
? await callAnthropic(settings.key, prompt) ? await callAnthropic(settings.key, prompt)

View File

@@ -1,11 +1,16 @@
// Client helper for the platform-gateway AI path: POST /api/ai-names and get // Client helper for the platform-gateway AI path: POST /api/ai-names and get
// names back. Requires the OTM sign-in cookie; the route does the gating. // names back. Requires the OTM sign-in cookie; the route does the gating.
export async function generateWithPlatformAi(keywords: string[], style: string, count = 16): Promise<string[]> { export async function generateWithPlatformAi(
keywords: string[],
style: string,
count = 16,
brief = "",
): Promise<string[]> {
const res = await fetch("/api/ai-names", { const res = await fetch("/api/ai-names", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ keywords, style, count }), body: JSON.stringify({ keywords, style, count, brief }),
}); });
const body = (await res.json().catch(() => ({}))) as { names?: string[]; error?: string }; const body = (await res.json().catch(() => ({}))) as { names?: string[]; error?: string };
if (!res.ok) throw new Error(body.error ?? `AI request failed (${res.status}).`); if (!res.ok) throw new Error(body.error ?? `AI request failed (${res.status}).`);

View File

@@ -2,7 +2,7 @@
// /changelog. Bump VERSION and prepend an entry when shipping a user-facing // /changelog. Bump VERSION and prepend an entry when shipping a user-facing
// change. Same convention as rpo. // change. Same convention as rpo.
export const VERSION = "0.4.1"; export const VERSION = "0.5.0";
export interface ChangelogEntry { export interface ChangelogEntry {
version: string; version: string;
@@ -11,6 +11,13 @@ export interface ChangelogEntry {
} }
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.5.0",
date: "2026-08-16",
changes: [
"Tell the AI more: when AI names are on, an extra line lets you steer them — tone, words to include or avoid, who it's for. It's added to the AI brief (the keywords and style still lead); the built-in generator ignores it.",
],
},
{ {
version: "0.4.1", version: "0.4.1",
date: "2026-08-16", date: "2026-08-16",