ads-in-chat-answers 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # ads-in-chat-answers
2
+
3
+ Sponsored placements for AI chat answers, in the style of the product ads
4
+ that appear under an assistant's reply:
5
+
6
+ - **Answer carousel** — an advertiser header, an always-visible **Ad** label,
7
+ a ⋯ menu (*Hide this ad*, *About this ad*, *Ask about this ad*, *Report this
8
+ ad*) and a scrolling row of product cards, rendered *after* the answer.
9
+ - **Sponsored follow-up** — one row in the "Related" questions list, worded
10
+ only as **"Learn more about ‹topic›"** and labelled *Sponsored · Advertiser*.
11
+ It is never the first suggestion.
12
+ - **Keyword auction** — campaigns are scored against the question (full
13
+ weight) and the answer (half weight); negative keywords veto; anything under
14
+ 30% relevance is dropped whatever it bids; the rest rank by relevance × max
15
+ CPC and the winner pays a second price. One advertiser cannot take both
16
+ placements on the same answer.
17
+ - **Advertiser panel** — describe the business, press *Generate keywords with
18
+ AI* (an LLM proposes keywords, negative keywords and a follow-up topic),
19
+ pick placements and products, set a bid and budget, and test any question
20
+ against the live auction before launching.
21
+
22
+ The answer is generated before, and without knowledge of, any ad. Ads never
23
+ appear inside the answer text.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ bun add ads-in-chat-answers
29
+ ```
30
+
31
+ ## Showing ads in a chat
32
+
33
+ ```tsx
34
+ import { ChatAnswerAd, SponsoredFollowUps, selectChatAds } from 'ads-in-chat-answers';
35
+ import 'ads-in-chat-answers/styles.css';
36
+
37
+ const { answer, followUp } = selectChatAds(campaigns, { query, answer: text });
38
+
39
+ <Answer text={text} />
40
+ {answer && (
41
+ <ChatAnswerAd
42
+ selection={answer}
43
+ onImpression={logImpression}
44
+ onProductClick={(product, sel) => chargeClick(sel.campaign.id, sel.costPerClickCents)}
45
+ onReport={reportAd}
46
+ />
47
+ )}
48
+ <SponsoredFollowUps questions={related} sponsored={followUp} onSelect={(q, sel) => ask(q)} />
49
+ ```
50
+
51
+ `selectChatAds` is pure — run it in the browser, a Worker or a route. Charge a
52
+ click with `recordClick(campaign, cost)`; a campaign at its daily budget drops
53
+ out of the auction.
54
+
55
+ ## Keyword generation (server)
56
+
57
+ ```ts
58
+ import { createOpenAICompatibleComplete, handleAdsRequest } from 'ads-in-chat-answers/server';
59
+
60
+ const complete = createOpenAICompatibleComplete({ apiKey: env.OPENROUTER_API_KEY }); // OpenRouter by default
61
+
62
+ export default {
63
+ fetch: (request: Request) =>
64
+ handleAdsRequest(request, { complete, getCampaigns: () => loadCampaigns() }),
65
+ };
66
+ ```
67
+
68
+ | Route | Body | Returns |
69
+ | --- | --- | --- |
70
+ | `POST …/keywords` | `{ advertiser, description, website?, products?, existingKeywords? }` | `KeywordPlan` |
71
+ | `POST …/select` | `{ query, answer? }` | `{ answer, followUp }` (only when `getCampaigns` is given) |
72
+
73
+ Without a model, or when the model's reply isn't the requested JSON, the plan
74
+ falls back to a deterministic heuristic (`source: "heuristic"`) instead of
75
+ failing.
76
+
77
+ ## The advertiser panel
78
+
79
+ ```tsx
80
+ <AdvertiserPanel
81
+ campaigns={campaigns}
82
+ stats={statsByCampaignId}
83
+ onSave={saveCampaign}
84
+ onDelete={deleteCampaign}
85
+ generateKeywords={(brief) => fetch('/api/ads/keywords', { method: 'POST', body: JSON.stringify(brief) }).then((r) => r.json())}
86
+ />
87
+ ```
88
+
89
+ ## Theming
90
+
91
+ Every rule is scoped under `.qads-root` and every colour is a `--qads-*`
92
+ custom property. Dark by default; light under `prefers-color-scheme: light`,
93
+ `[data-theme="light"]` or `.light`.
94
+
95
+ ## Demo (Vercel)
96
+
97
+ `demo/` is a chat UI with the placements, an auction inspector showing every
98
+ campaign's score, and the advertiser panel. It runs offline with seed
99
+ campaigns and canned answers; a model key makes the answers and keyword plans
100
+ live.
101
+
102
+ ```bash
103
+ cd demo && bun install && bun run dev # http://localhost:5173, /api included
104
+ ```
105
+
106
+ Deploy: import the repo in Vercel and set **Root Directory** to
107
+ `packages/ads-in-chat-answers/demo` (keep "Include files outside the root
108
+ directory" on — the demo builds the package from `../src`). Optional env vars:
109
+
110
+ | Variable | Default |
111
+ | --- | --- |
112
+ | `LLM_API_KEY` (or `OPENROUTER_API_KEY` / `OPENAI_API_KEY`) | none — canned answers and heuristic keywords |
113
+ | `LLM_BASE_URL` | `https://openrouter.ai/api/v1` |
114
+ | `LLM_MODEL` | `openai/gpt-4o-mini` |
115
+
116
+ The build writes Vercel's Build Output directly (`scripts/vercel-output.mjs`),
117
+ bundling `server/*.js` into Edge functions at `/api/answer` and `/api/keywords`.
@@ -0,0 +1,25 @@
1
+ import { type AdvertiserBrief } from '../keywords';
2
+ import { type Campaign, type KeywordPlan } from '../types';
3
+ export interface CampaignStats {
4
+ impressions: number;
5
+ clicks: number;
6
+ spendCents: number;
7
+ }
8
+ export interface AdvertiserPanelProps {
9
+ campaigns: Campaign[];
10
+ onSave: (campaign: Campaign) => void;
11
+ onDelete?: (id: string) => void;
12
+ /** Calls the keyword generator — usually `POST /keywords` on the server half. */
13
+ generateKeywords: (brief: AdvertiserBrief) => Promise<KeywordPlan>;
14
+ stats?: Record<string, CampaignStats>;
15
+ className?: string;
16
+ }
17
+ export declare function emptyCampaign(): Campaign;
18
+ /** Why a draft cannot be saved yet, or null when it can. */
19
+ export declare function validateCampaign(c: Campaign): string | null;
20
+ /**
21
+ * Self-serve campaign builder: describe the business, let the model propose
22
+ * keywords, pick placements and products, set a bid and budget, and test any
23
+ * question against the live auction before saving.
24
+ */
25
+ export declare function AdvertiserPanel({ campaigns, onSave, onDelete, generateKeywords, stats, className, }: AdvertiserPanelProps): import("react").JSX.Element;
@@ -0,0 +1,16 @@
1
+ /** A stable hue per string, so a brand's fallback mark keeps its colour. */
2
+ export declare function hueFor(text: string): number;
3
+ /** The round logo in the ad header; the advertiser's initial when there is no logo. */
4
+ export declare function BrandMark({ name, logoUrl, size }: {
5
+ name: string;
6
+ logoUrl?: string;
7
+ size?: number;
8
+ }): import("react").JSX.Element;
9
+ /**
10
+ * The product image, or a typographic cover drawn from the title when the
11
+ * advertiser gave none — a broken image in an ad looks worse than no ad.
12
+ */
13
+ export declare function ProductImage({ title, imageUrl }: {
14
+ title: string;
15
+ imageUrl?: string;
16
+ }): import("react").JSX.Element;
@@ -0,0 +1,27 @@
1
+ import type { AdProduct, AdSelection } from '../types';
2
+ export interface ChatAnswerAdProps {
3
+ selection: AdSelection;
4
+ /** Fired once when the ad first renders. */
5
+ onImpression?: (selection: AdSelection) => void;
6
+ /** A product card was clicked. The card is also a real link to `product.url`. */
7
+ onProductClick?: (product: AdProduct, selection: AdSelection) => void;
8
+ /** "Hide this ad". The component hides itself either way. */
9
+ onHide?: (selection: AdSelection) => void;
10
+ /** "Ask about this ad" — the host turns it into a chat question. */
11
+ onAsk?: (selection: AdSelection) => void;
12
+ /** "Report this ad". */
13
+ onReport?: (selection: AdSelection) => void;
14
+ /** Show the auction numbers in "About this ad" (for the advertiser demo). */
15
+ showAuctionDetails?: boolean;
16
+ className?: string;
17
+ }
18
+ /**
19
+ * The sponsored product carousel shown under an assistant answer: advertiser
20
+ * header, an always-visible "Ad" label, an overflow menu (hide, about, ask,
21
+ * report) and a horizontally scrolling row of product cards.
22
+ *
23
+ * It is rendered *after* the answer and visually separated from it — the ad
24
+ * never sits inside the answer text, so it cannot be mistaken for the
25
+ * assistant's recommendation.
26
+ */
27
+ export declare function ChatAnswerAd({ selection, onImpression, onProductClick, onHide, onAsk, onReport, showAuctionDetails, className, }: ChatAnswerAdProps): import("react").JSX.Element;
@@ -0,0 +1,24 @@
1
+ import type { AdSelection } from '../types';
2
+ export interface SponsoredFollowUpsProps {
3
+ /** The assistant's own follow-up questions. */
4
+ questions: string[];
5
+ /** The follow-up auction winner, if any. */
6
+ sponsored?: AdSelection | null;
7
+ /**
8
+ * Where the sponsored question goes in the list (default 1, second row).
9
+ * Never first: the top suggestion should be the most useful one, not the
10
+ * one that paid.
11
+ */
12
+ sponsoredIndex?: number;
13
+ /** A question was picked. `selection` is set for the sponsored one. */
14
+ onSelect: (question: string, selection?: AdSelection) => void;
15
+ /** Fired once when the sponsored question first renders. */
16
+ onImpression?: (selection: AdSelection) => void;
17
+ title?: string;
18
+ className?: string;
19
+ }
20
+ /**
21
+ * The "Related" list after an answer, with at most one sponsored row:
22
+ * "Learn more about <topic>", labelled Sponsored with the advertiser's name.
23
+ */
24
+ export declare function SponsoredFollowUps({ questions, sponsored, sponsoredIndex, onSelect, onImpression, title, className, }: SponsoredFollowUpsProps): import("react").JSX.Element | null;