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:
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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’s for — anything the keywords don’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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}).`);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user