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 +117 -0
- package/dist/components/AdvertiserPanel.d.ts +25 -0
- package/dist/components/BrandMark.d.ts +16 -0
- package/dist/components/ChatAnswerAd.d.ts +27 -0
- package/dist/components/SponsoredFollowUps.d.ts +24 -0
- package/dist/index.cjs +1123 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +1066 -0
- package/dist/index.js.map +1 -0
- package/dist/keywords.d.ts +52 -0
- package/dist/matching.d.ts +78 -0
- package/dist/server/index.cjs +412 -0
- package/dist/server/index.cjs.map +1 -0
- package/dist/server/index.d.ts +47 -0
- package/dist/server/index.js +374 -0
- package/dist/server/index.js.map +1 -0
- package/dist/styles.css +144 -0
- package/dist/types.d.ts +109 -0
- package/package.json +70 -0
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;
|