diff --git a/README.md b/README.md index 495e5fc..f1d9f26 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/package.json b/package.json index 5a94d6b..bbc1060 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "names-poweredbyotm", - "version": "0.4.1", + "version": "0.5.0", "private": true, "scripts": { "dev": "next dev --port 3000", diff --git a/src/app/api/ai-names/route.ts b/src/app/api/ai-names/route.ts index e56a0b7..da1ae08 100644 --- a/src/app/api/ai-names/route.ts +++ b/src/app/api/ai-names/route.ts @@ -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), }); diff --git a/src/app/globals.css b/src/app/globals.css index a550c8f..2dd1de6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -398,6 +398,10 @@ h2 { text-decoration: underline; } +.field.brief { + margin-top: 4px; +} + .signin { font-size: 14px; text-decoration: none; diff --git a/src/components/Finder.tsx b/src/components/Finder.tsx index efc26d4..9a029c8 100644 --- a/src/components/Finder.tsx +++ b/src/components/Finder.tsx @@ -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(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() { /> + {aiOn ? ( +
+ + onBrief(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") onGenerate(); + }} + /> +

+ Tone, words to include or avoid, who it’s for — anything the keywords don’t say. + Only the AI sees this; the built-in generator ignores it. +

+
+ ) : null} + {tlds.length === 0 ? (

Pick at least one extension to check.

) : null} diff --git a/src/lib/ai-prompt.ts b/src/lib/ai-prompt.ts index e7fa9ce..28c54d1 100644 --- a/src/lib/ai-prompt.ts +++ b/src/lib/ai-prompt.ts @@ -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 diff --git a/src/lib/ai.ts b/src/lib/ai.ts index a68fc86..2aa3a88 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -98,8 +98,9 @@ export async function generateWithAi( keywords: string[], style: string, count = 24, + brief = "", ): Promise { - const prompt = userPrompt(keywords, style, count); + const prompt = userPrompt(keywords, style, count, brief); const text = settings.provider === "anthropic" ? await callAnthropic(settings.key, prompt) diff --git a/src/lib/platform-ai.ts b/src/lib/platform-ai.ts index 65c9085..33798a9 100644 --- a/src/lib/platform-ai.ts +++ b/src/lib/platform-ai.ts @@ -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 { +export async function generateWithPlatformAi( + keywords: string[], + style: string, + count = 16, + brief = "", +): Promise { 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}).`); diff --git a/src/lib/version.ts b/src/lib/version.ts index d44cb3e..483bdd9 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -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",