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
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
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.

View File

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

View File

@@ -13,7 +13,7 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
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 dynamic = "force-dynamic";
@@ -47,6 +47,7 @@ interface Body {
keywords?: unknown;
style?: unknown;
count?: unknown;
brief?: unknown;
}
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 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(/\/+$/, "");
let upstream: Response;
@@ -94,7 +96,7 @@ export async function POST(req: NextRequest) {
model: MODEL,
max_tokens: 1024,
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),
});

View File

@@ -398,6 +398,10 @@ h2 {
text-decoration: underline;
}
.field.brief {
margin-top: 4px;
}
.signin {
font-size: 14px;
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 { generateWithAi, loadAiSettings, type AiSettings } from "@/lib/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 { useOtmSession } from "@/hooks/useOtmSession";
import { useWishlist } from "@/hooks/useWishlist";
@@ -43,6 +44,9 @@ export default function Finder() {
const [ai, setAi] = useState<AiSettings | null>(null);
const [useAi, setUseAi] = useState(false);
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();
// 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
@@ -59,11 +63,21 @@ export default function Finder() {
setAi(loadAiSettings());
try {
setHideTaken(window.localStorage.getItem("names.hideTaken") === "1");
setBrief(window.sessionStorage.getItem("names.brief") ?? "");
} catch {
/* private mode */
}
}, []);
const onBrief = (v: string) => {
setBrief(v);
try {
window.sessionStorage.setItem("names.brief", v);
} catch {
/* private mode */
}
};
const toggleHideTaken = (v: boolean) => {
setHideTaken(v);
try {
@@ -137,8 +151,8 @@ export default function Finder() {
if (wantsOwnKey || wantsPlatform) {
try {
const aiNames = wantsOwnKey
? await generateWithAi(ai, parsedKeywords, style, 16)
: await generateWithPlatformAi(parsedKeywords, style, 16);
? await generateWithAi(ai, parsedKeywords, style, 16, brief)
: await generateWithPlatformAi(parsedKeywords, style, 16, brief);
if (runId.current !== myRun) return;
const seen = new Set(list.map((i) => i.name));
const extra: Idea[] = aiNames
@@ -159,7 +173,7 @@ export default function Finder() {
setBusy(false);
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 = () => {
@@ -173,6 +187,7 @@ export default function Finder() {
};
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.
* Still-checking or unknown names stay visible — hiding is for certainty. */
@@ -273,6 +288,27 @@ export default function Finder() {
/>
</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 ? (
<p className="notice info">Pick at least one extension to check.</p>
) : null}

View File

@@ -17,12 +17,20 @@ export const SYSTEM_PROMPT = [
'Respond with ONLY a JSON array of strings. No prose, no code fence.',
].join("\n");
export function userPrompt(keywords: string[], style: string, count: number): string {
return [
/** Free-text guidance the visitor can add ("premium, no puns, avoid 'auto'").
* 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(", ")}`,
`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

View File

@@ -98,8 +98,9 @@ export async function generateWithAi(
keywords: string[],
style: string,
count = 24,
brief = "",
): Promise<string[]> {
const prompt = userPrompt(keywords, style, count);
const prompt = userPrompt(keywords, style, count, brief);
const text =
settings.provider === "anthropic"
? 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
// 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", {
method: "POST",
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 };
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
// change. Same convention as rpo.
export const VERSION = "0.4.1";
export const VERSION = "0.5.0";
export interface ChangelogEntry {
version: string;
@@ -11,6 +11,13 @@ export interface 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",
date: "2026-08-16",