@m13v/seo-components 0.5.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/seo-components",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "39 animated React components for programmatic SEO pages. Remotion video, Magic UI style animations, trust signals, JSON-LD helpers. Teal/cyan brand, light-theme only.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -43,11 +43,19 @@
43
43
  "framer-motion": ">=11",
44
44
  "remotion": ">=4",
45
45
  "@remotion/player": ">=4",
46
- "lottie-react": ">=2"
46
+ "lottie-react": ">=2",
47
+ "@google/generative-ai": ">=0.24",
48
+ "@supabase/supabase-js": ">=2",
49
+ "@assistant-ui/react": ">=0.7",
50
+ "posthog-js": ">=1.100"
47
51
  },
48
52
  "peerDependenciesMeta": {
49
53
  "remotion": { "optional": true },
50
54
  "@remotion/player": { "optional": true },
51
- "lottie-react": { "optional": true }
55
+ "lottie-react": { "optional": true },
56
+ "@google/generative-ai": { "optional": true },
57
+ "@supabase/supabase-js": { "optional": true },
58
+ "@assistant-ui/react": { "optional": true },
59
+ "posthog-js": { "optional": true }
52
60
  }
53
61
  }
@@ -15,6 +15,8 @@ interface AnimatedBeamProps {
15
15
  /** Right side nodes (destinations) */
16
16
  to: BeamNode[];
17
17
  title?: string;
18
+ /** Accent hex color for the hub fill and beam glow. Defaults to teal (#14b8a6). */
19
+ accentColor?: string;
18
20
  className?: string;
19
21
  }
20
22
 
@@ -29,6 +31,7 @@ export function AnimatedBeam({
29
31
  hub,
30
32
  to,
31
33
  title,
34
+ accentColor = "#14b8a6",
32
35
  className = "",
33
36
  }: AnimatedBeamProps) {
34
37
  const width = 720;
@@ -59,9 +62,9 @@ export function AnimatedBeam({
59
62
  >
60
63
  <defs>
61
64
  <linearGradient id="beam-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
62
- <stop offset="0%" stopColor="var(--seo-accent)" stopOpacity="0" />
63
- <stop offset="50%" stopColor="var(--seo-accent)" stopOpacity="1" />
64
- <stop offset="100%" stopColor="var(--seo-accent)" stopOpacity="0" />
65
+ <stop offset="0%" stopColor={accentColor} stopOpacity="0" />
66
+ <stop offset="50%" stopColor={accentColor} stopOpacity="1" />
67
+ <stop offset="100%" stopColor={accentColor} stopOpacity="0" />
65
68
  </linearGradient>
66
69
  <filter id="glow">
67
70
  <feGaussianBlur stdDeviation="3" result="blur" />
@@ -183,7 +186,7 @@ export function AnimatedBeam({
183
186
  width="144"
184
187
  height="72"
185
188
  rx="36"
186
- fill="var(--seo-accent)"
189
+ fill={accentColor}
187
190
  filter="url(#glow)"
188
191
  />
189
192
  <rect
@@ -0,0 +1,541 @@
1
+ "use client";
2
+
3
+ import {
4
+ AssistantRuntimeProvider,
5
+ ComposerPrimitive,
6
+ MessagePrimitive,
7
+ ThreadPrimitive,
8
+ useLocalRuntime,
9
+ useThreadRuntime,
10
+ type ChatModelAdapter,
11
+ } from "@assistant-ui/react";
12
+ import { usePathname } from "next/navigation";
13
+ import { useCallback, useEffect, useMemo, useState } from "react";
14
+
15
+ /* ------------------------------------------------------------------ */
16
+ /* PostHog helper (works with any posthog setup on window or global) */
17
+ /* ------------------------------------------------------------------ */
18
+
19
+ function capture(event: string, props?: Record<string, unknown>) {
20
+ const w = typeof window !== "undefined" ? (window as Record<string, unknown>) : null;
21
+ const ph = w?.posthog as
22
+ | { capture?: (e: string, p?: Record<string, unknown>) => void; __loaded?: boolean }
23
+ | undefined;
24
+ ph?.capture?.(event, props);
25
+ }
26
+
27
+ function onPosthogLoaded(fn: () => void) {
28
+ if (typeof window === "undefined") return;
29
+ const w = window as Record<string, unknown>;
30
+ const ph = w.posthog as { __loaded?: boolean } | undefined;
31
+ if (ph?.__loaded) {
32
+ fn();
33
+ } else {
34
+ window.addEventListener("posthog:loaded", fn, { once: true });
35
+ }
36
+ }
37
+
38
+ /* ------------------------------------------------------------------ */
39
+ /* Slug extraction */
40
+ /* ------------------------------------------------------------------ */
41
+
42
+ function slugFromPath(pathname: string | null, pattern?: RegExp): string {
43
+ if (!pathname) return "";
44
+ const re = pattern ?? /^\/t\/([^/]+)/;
45
+ const m = pathname.match(re);
46
+ return m ? m[1] : "";
47
+ }
48
+
49
+ /* ------------------------------------------------------------------ */
50
+ /* Chat adapter */
51
+ /* ------------------------------------------------------------------ */
52
+
53
+ function makeAdapter(
54
+ getSlug: () => string,
55
+ apiEndpoint: string,
56
+ app: string,
57
+ ): ChatModelAdapter {
58
+ return {
59
+ async *run({ messages, abortSignal }) {
60
+ const slug = getSlug();
61
+ const lastUserMsg = messages[messages.length - 1];
62
+ const userQuery =
63
+ lastUserMsg?.content
64
+ .map((p) => (p.type === "text" ? p.text : ""))
65
+ .join("") ?? "";
66
+
67
+ const payload = {
68
+ slug,
69
+ messages: messages.map((m) => ({
70
+ role: m.role === "assistant" ? "assistant" : "user",
71
+ content: m.content
72
+ .map((p) => (p.type === "text" ? p.text : ""))
73
+ .join(""),
74
+ })),
75
+ };
76
+
77
+ capture("guide_chat_message_sent", {
78
+ app,
79
+ slug,
80
+ query: userQuery,
81
+ query_length: userQuery.length,
82
+ message_count: messages.length,
83
+ });
84
+
85
+ const startedAt = Date.now();
86
+ const res = await fetch(apiEndpoint, {
87
+ method: "POST",
88
+ headers: { "content-type": "application/json" },
89
+ body: JSON.stringify(payload),
90
+ signal: abortSignal,
91
+ });
92
+
93
+ if (!res.ok || !res.body) {
94
+ const errText = await res.text().catch(() => "");
95
+ capture("guide_chat_response_failed", {
96
+ app,
97
+ slug,
98
+ query: userQuery,
99
+ status: res.status,
100
+ error: errText || "no body",
101
+ latency_ms: Date.now() - startedAt,
102
+ });
103
+ throw new Error(`guide-chat ${res.status}: ${errText || "no body"}`);
104
+ }
105
+
106
+ const reader = res.body.getReader();
107
+ const decoder = new TextDecoder();
108
+ let buf = "";
109
+ let accumulated = "";
110
+ let toolRounds = 0;
111
+ let inputTokens = 0;
112
+ let outputTokens = 0;
113
+ let model = "";
114
+ let requestId = "";
115
+
116
+ while (true) {
117
+ const { value, done } = await reader.read();
118
+ if (done) break;
119
+ buf += decoder.decode(value, { stream: true });
120
+ const lines = buf.split("\n");
121
+ buf = lines.pop() ?? "";
122
+ for (const line of lines) {
123
+ const trimmed = line.trim();
124
+ if (!trimmed) continue;
125
+ try {
126
+ const evt = JSON.parse(trimmed) as
127
+ | { type: "delta"; text: string }
128
+ | {
129
+ type: "done";
130
+ usage: { inputTokens?: number; outputTokens?: number };
131
+ requestId: string;
132
+ toolRounds?: number;
133
+ model?: string;
134
+ }
135
+ | { type: "error"; error: string };
136
+ if (evt.type === "delta") {
137
+ accumulated += evt.text;
138
+ yield { content: [{ type: "text", text: accumulated }] };
139
+ } else if (evt.type === "done") {
140
+ toolRounds = evt.toolRounds ?? 0;
141
+ inputTokens = evt.usage?.inputTokens ?? 0;
142
+ outputTokens = evt.usage?.outputTokens ?? 0;
143
+ model = evt.model ?? "";
144
+ requestId = evt.requestId ?? "";
145
+ } else if (evt.type === "error") {
146
+ throw new Error(evt.error);
147
+ }
148
+ } catch {
149
+ // ignore malformed line
150
+ }
151
+ }
152
+ }
153
+
154
+ capture("guide_chat_response_received", {
155
+ app,
156
+ slug,
157
+ query: userQuery,
158
+ response: accumulated,
159
+ response_length: accumulated.length,
160
+ latency_ms: Date.now() - startedAt,
161
+ tool_rounds: toolRounds,
162
+ input_tokens: inputTokens,
163
+ output_tokens: outputTokens,
164
+ model,
165
+ request_id: requestId,
166
+ });
167
+
168
+ yield {
169
+ content: [{ type: "text", text: accumulated }],
170
+ status: { type: "complete", reason: "stop" },
171
+ };
172
+ },
173
+ };
174
+ }
175
+
176
+ /* ------------------------------------------------------------------ */
177
+ /* Summary hook */
178
+ /* ------------------------------------------------------------------ */
179
+
180
+ interface SummaryData {
181
+ text: string | null;
182
+ questions: string[];
183
+ loading: boolean;
184
+ slug: string;
185
+ }
186
+
187
+ function useSummary(
188
+ slug: string,
189
+ apiEndpoint: string,
190
+ app: string,
191
+ ): SummaryData {
192
+ const [text, setText] = useState<string | null>(null);
193
+ const [questions, setQuestions] = useState<string[]>([]);
194
+ const [loading, setLoading] = useState(true);
195
+
196
+ useEffect(() => {
197
+ if (!slug) {
198
+ setLoading(false);
199
+ return;
200
+ }
201
+ setLoading(true);
202
+ setText(null);
203
+ setQuestions([]);
204
+
205
+ const ac = new AbortController();
206
+ const startedAt = Date.now();
207
+
208
+ (async () => {
209
+ try {
210
+ const res = await fetch(apiEndpoint, {
211
+ method: "POST",
212
+ headers: { "content-type": "application/json" },
213
+ body: JSON.stringify({
214
+ slug,
215
+ messages: [
216
+ {
217
+ role: "user",
218
+ content:
219
+ "Briefly summarize what this article covers and what the reader will learn from it in 2-3 sentences. Focus on the article's content and key takeaways, not on describing the product itself. Then write a line containing only three dashes (---). Then list exactly 3 follow-up questions a reader of this article might ask, one per line.",
220
+ },
221
+ ],
222
+ }),
223
+ signal: ac.signal,
224
+ });
225
+
226
+ if (!res.ok || !res.body) {
227
+ setLoading(false);
228
+ return;
229
+ }
230
+
231
+ const reader = res.body.getReader();
232
+ const decoder = new TextDecoder();
233
+ let buf = "";
234
+ let accumulated = "";
235
+
236
+ while (true) {
237
+ const { value, done } = await reader.read();
238
+ if (done) break;
239
+ buf += decoder.decode(value, { stream: true });
240
+ const lines = buf.split("\n");
241
+ buf = lines.pop() ?? "";
242
+ for (const line of lines) {
243
+ const trimmed = line.trim();
244
+ if (!trimmed) continue;
245
+ try {
246
+ const evt = JSON.parse(trimmed) as
247
+ | { type: "delta"; text: string }
248
+ | { type: "done" }
249
+ | { type: "error" };
250
+ if (evt.type === "delta") accumulated += evt.text;
251
+ } catch {
252
+ /* skip */
253
+ }
254
+ }
255
+ }
256
+
257
+ const halves = accumulated.split("---");
258
+ const summaryText = halves[0].trim();
259
+ const qRaw = halves.slice(1).join("---").trim();
260
+ const qs = qRaw
261
+ .split("\n")
262
+ .map((l) =>
263
+ l
264
+ .replace(/^\d+[\.\)]\s*/, "")
265
+ .replace(/^\*+\s*/, "")
266
+ .replace(/\*+$/, "")
267
+ .trim(),
268
+ )
269
+ .filter((l) => l.length > 10)
270
+ .slice(0, 3);
271
+
272
+ setText(summaryText || null);
273
+ setQuestions(qs);
274
+
275
+ capture("guide_chat_summary_loaded", {
276
+ app,
277
+ slug,
278
+ summary: summaryText || "",
279
+ summary_length: summaryText.length,
280
+ questions: qs,
281
+ question_count: qs.length,
282
+ latency_ms: Date.now() - startedAt,
283
+ });
284
+ } catch (e) {
285
+ if ((e as Error).name !== "AbortError") {
286
+ console.error("[guide-chat] summary fetch:", e);
287
+ }
288
+ } finally {
289
+ setLoading(false);
290
+ }
291
+ })();
292
+
293
+ return () => ac.abort();
294
+ }, [slug, apiEndpoint, app]);
295
+
296
+ return { text, questions, loading, slug };
297
+ }
298
+
299
+ /* ------------------------------------------------------------------ */
300
+ /* Public API */
301
+ /* ------------------------------------------------------------------ */
302
+
303
+ export interface GuideChatPanelProps {
304
+ /** App identifier for analytics (e.g. "cyrano") */
305
+ app?: string;
306
+ /** API endpoint for the guide chat. Defaults to "/api/guide-chat" */
307
+ apiEndpoint?: string;
308
+ /** Regex to extract slug from pathname. Must have one capture group. Defaults to /^\\/t\\/([^/]+)/ */
309
+ slugPattern?: RegExp;
310
+ /** Panel header label. Defaults to "page assistant" */
311
+ label?: string;
312
+ }
313
+
314
+ export function GuideChatPanel({
315
+ app = "default",
316
+ apiEndpoint = "/api/guide-chat",
317
+ slugPattern,
318
+ label = "page assistant",
319
+ }: GuideChatPanelProps) {
320
+ const pathname = usePathname();
321
+ const slug = slugFromPath(pathname, slugPattern);
322
+
323
+ useEffect(() => {
324
+ if (!slug) return;
325
+ onPosthogLoaded(() =>
326
+ capture("guide_chat_panel_viewed", { app, slug }),
327
+ );
328
+ }, [slug, app]);
329
+
330
+ if (!slug) return null;
331
+
332
+ return (
333
+ <aside className="hidden lg:flex flex-col sticky top-0 h-screen w-80 xl:w-96 bg-white border-l border-zinc-200">
334
+ <div className="flex items-center gap-2 px-4 py-3 border-b border-zinc-100">
335
+ <span className="w-1.5 h-1.5 rounded-full bg-teal-500 inline-block" />
336
+ <span className="font-mono text-xs tracking-tight text-zinc-900">
337
+ {label}
338
+ </span>
339
+ </div>
340
+ <ChatThread
341
+ key={slug}
342
+ slug={slug}
343
+ apiEndpoint={apiEndpoint}
344
+ app={app}
345
+ />
346
+ </aside>
347
+ );
348
+ }
349
+
350
+ /* ------------------------------------------------------------------ */
351
+ /* Chat thread */
352
+ /* ------------------------------------------------------------------ */
353
+
354
+ function ChatThread({
355
+ slug,
356
+ apiEndpoint,
357
+ app,
358
+ }: {
359
+ slug: string;
360
+ apiEndpoint: string;
361
+ app: string;
362
+ }) {
363
+ const adapter = useMemo(
364
+ () => makeAdapter(() => slug, apiEndpoint, app),
365
+ [slug, apiEndpoint, app],
366
+ );
367
+ const runtime = useLocalRuntime(adapter);
368
+ const summary = useSummary(slug, apiEndpoint, app);
369
+
370
+ return (
371
+ <AssistantRuntimeProvider runtime={runtime}>
372
+ <ThreadPrimitive.Root className="flex-1 flex flex-col min-h-0">
373
+ <ThreadPrimitive.Viewport className="flex-1 overflow-y-auto px-4 py-4">
374
+ <SummarySection summary={summary} app={app} />
375
+ <ThreadPrimitive.Messages
376
+ components={{ UserMessage, AssistantMessage }}
377
+ />
378
+ <ThreadPrimitive.If running>
379
+ <TypingIndicator />
380
+ </ThreadPrimitive.If>
381
+ </ThreadPrimitive.Viewport>
382
+ <div className="px-3 py-3 border-t border-zinc-100">
383
+ <Composer />
384
+ </div>
385
+ </ThreadPrimitive.Root>
386
+ </AssistantRuntimeProvider>
387
+ );
388
+ }
389
+
390
+ /* ------------------------------------------------------------------ */
391
+ /* Summary + question chips */
392
+ /* ------------------------------------------------------------------ */
393
+
394
+ function SummarySection({
395
+ summary,
396
+ app,
397
+ }: {
398
+ summary: SummaryData;
399
+ app: string;
400
+ }) {
401
+ if (summary.loading) {
402
+ return (
403
+ <div className="mb-4 space-y-2 animate-pulse">
404
+ <div className="h-3 bg-zinc-100 rounded w-3/4" />
405
+ <div className="h-3 bg-zinc-100 rounded w-full" />
406
+ <div className="h-3 bg-zinc-100 rounded w-5/6" />
407
+ <div className="mt-3 h-8 bg-zinc-50 rounded w-full" />
408
+ <div className="h-8 bg-zinc-50 rounded w-full" />
409
+ <div className="h-8 bg-zinc-50 rounded w-full" />
410
+ </div>
411
+ );
412
+ }
413
+
414
+ if (!summary.text) return null;
415
+
416
+ return (
417
+ <div className="mb-4 space-y-3">
418
+ <div className="rounded-lg px-3 py-2.5 bg-zinc-50 border border-zinc-100 text-[13px] text-zinc-900 font-mono leading-relaxed whitespace-pre-wrap">
419
+ {summary.text}
420
+ </div>
421
+ {summary.questions.length > 0 && (
422
+ <QuestionChips
423
+ questions={summary.questions}
424
+ slug={summary.slug}
425
+ app={app}
426
+ />
427
+ )}
428
+ </div>
429
+ );
430
+ }
431
+
432
+ function QuestionChips({
433
+ questions,
434
+ slug,
435
+ app,
436
+ }: {
437
+ questions: string[];
438
+ slug: string;
439
+ app: string;
440
+ }) {
441
+ const threadRuntime = useThreadRuntime();
442
+
443
+ const send = useCallback(
444
+ (q: string, index: number) => {
445
+ capture("guide_chat_question_chip_clicked", {
446
+ app,
447
+ slug,
448
+ question: q,
449
+ chip_index: index,
450
+ question_count: questions.length,
451
+ });
452
+ threadRuntime.append({
453
+ role: "user",
454
+ content: [{ type: "text" as const, text: q }],
455
+ });
456
+ },
457
+ [threadRuntime, slug, app, questions.length],
458
+ );
459
+
460
+ return (
461
+ <div className="space-y-1.5">
462
+ {questions.map((q, i) => (
463
+ <button
464
+ key={i}
465
+ onClick={() => send(q, i)}
466
+ className="w-full text-left px-3 py-2 rounded-lg border border-zinc-200 bg-white hover:border-teal-300 hover:text-teal-700 transition-colors text-[12px] font-mono text-zinc-600 leading-snug"
467
+ >
468
+ {q}
469
+ </button>
470
+ ))}
471
+ </div>
472
+ );
473
+ }
474
+
475
+ /* ------------------------------------------------------------------ */
476
+ /* Chat primitives */
477
+ /* ------------------------------------------------------------------ */
478
+
479
+ function UserMessage() {
480
+ return (
481
+ <MessagePrimitive.Root className="mb-3 flex justify-end">
482
+ <div className="max-w-[85%] rounded-lg px-3 py-2 bg-teal-50 border border-teal-100 text-[13px] text-zinc-900 font-mono whitespace-pre-wrap">
483
+ <MessagePrimitive.Parts />
484
+ </div>
485
+ </MessagePrimitive.Root>
486
+ );
487
+ }
488
+
489
+ function AssistantMessage() {
490
+ return (
491
+ <MessagePrimitive.Root className="mb-3 flex justify-start">
492
+ <div className="max-w-[90%] rounded-lg px-3 py-2 bg-zinc-50 border border-zinc-100 text-[13px] text-zinc-900 font-mono leading-relaxed whitespace-pre-wrap">
493
+ <MessagePrimitive.Parts />
494
+ </div>
495
+ </MessagePrimitive.Root>
496
+ );
497
+ }
498
+
499
+ function TypingIndicator() {
500
+ return (
501
+ <div className="mb-3 flex justify-start">
502
+ <div className="rounded-lg px-3 py-2 bg-zinc-50 border border-zinc-100 text-[13px] text-zinc-400 font-mono">
503
+ <span className="inline-flex gap-1">
504
+ <span className="w-1 h-1 bg-zinc-400 rounded-full animate-pulse" />
505
+ <span className="w-1 h-1 bg-zinc-400 rounded-full animate-pulse [animation-delay:150ms]" />
506
+ <span className="w-1 h-1 bg-zinc-400 rounded-full animate-pulse [animation-delay:300ms]" />
507
+ </span>
508
+ </div>
509
+ </div>
510
+ );
511
+ }
512
+
513
+ function Composer() {
514
+ return (
515
+ <ComposerPrimitive.Root className="flex items-end gap-2 rounded-lg border border-zinc-200 bg-white focus-within:border-teal-300 focus-within:ring-2 focus-within:ring-teal-500/20 transition">
516
+ <ComposerPrimitive.Input
517
+ placeholder="ask a question..."
518
+ className="flex-1 bg-transparent px-3 py-2 text-[13px] font-mono text-zinc-900 placeholder:text-zinc-400 focus:outline-none resize-none max-h-32"
519
+ rows={1}
520
+ />
521
+ <ComposerPrimitive.Send
522
+ className="m-1 p-1.5 rounded-md bg-teal-600 text-white hover:bg-teal-700 disabled:bg-zinc-300 disabled:cursor-not-allowed transition-colors"
523
+ aria-label="Send"
524
+ >
525
+ <svg
526
+ width="14"
527
+ height="14"
528
+ viewBox="0 0 24 24"
529
+ fill="none"
530
+ stroke="currentColor"
531
+ strokeWidth="2"
532
+ strokeLinecap="round"
533
+ strokeLinejoin="round"
534
+ >
535
+ <line x1="22" y1="2" x2="11" y2="13" />
536
+ <polygon points="22 2 15 22 11 13 2 9 22 2" />
537
+ </svg>
538
+ </ComposerPrimitive.Send>
539
+ </ComposerPrimitive.Root>
540
+ );
541
+ }
package/src/index.ts CHANGED
@@ -54,6 +54,20 @@ export { MotionSequence } from "./components/MotionSequence";
54
54
  export { RemotionClip, ConceptReveal } from "./components/RemotionClip";
55
55
  export { LottiePlayer } from "./components/LottiePlayer";
56
56
 
57
+ // Guide chat (AI page assistant)
58
+ export { GuideChatPanel } from "./components/GuideChatPanel";
59
+ export type { GuideChatPanelProps } from "./components/GuideChatPanel";
60
+
61
+ // Guide chat server utilities (import via @seo/components/lib/*)
62
+ export { createGuideChatHandler } from "./lib/guide-chat-route";
63
+ export type { GuideChatConfig } from "./lib/guide-chat-route";
64
+ export { logAiUsage, computeCostUsd } from "./lib/ai-usage";
65
+ export type { LogAiUsageArgs } from "./lib/ai-usage";
66
+ export { discoverGuides } from "./lib/discover-guides";
67
+ export type { GuideEntry } from "./lib/discover-guides";
68
+ export { getGuideContext, buildGuideIndex, buildSystemPrompt } from "./lib/guide-context";
69
+ export type { GuideContext, BuildSystemPromptOptions } from "./lib/guide-context";
70
+
57
71
  // Magic UI style components
58
72
  export { Marquee } from "./components/Marquee";
59
73
  export { AnimatedBeam } from "./components/AnimatedBeam";
@@ -0,0 +1,54 @@
1
+ import { getSupabaseAdmin } from "./supabase-admin";
2
+
3
+ const PRICING_USD_PER_MTOK: Record<string, { input: number; output: number }> = {
4
+ "gemini-flash-latest": { input: 0.30, output: 2.50 },
5
+ "gemini-2.5-flash": { input: 0.30, output: 2.50 },
6
+ "gemini-pro-latest": { input: 1.25, output: 10.00 },
7
+ };
8
+
9
+ function pricing(model: string) {
10
+ return PRICING_USD_PER_MTOK[model] ?? { input: 0, output: 0 };
11
+ }
12
+
13
+ export function computeCostUsd(
14
+ model: string,
15
+ inputTokens: number,
16
+ outputTokens: number,
17
+ ): number {
18
+ const p = pricing(model);
19
+ const cost = (inputTokens * p.input + outputTokens * p.output) / 1_000_000;
20
+ return Math.round(cost * 1_000_000) / 1_000_000;
21
+ }
22
+
23
+ export interface LogAiUsageArgs {
24
+ app: string;
25
+ model: string;
26
+ inputTokens: number;
27
+ outputTokens: number;
28
+ totalTokens?: number;
29
+ requestId?: string;
30
+ metadata?: Record<string, unknown>;
31
+ }
32
+
33
+ export async function logAiUsage(args: LogAiUsageArgs): Promise<void> {
34
+ const total = args.totalTokens ?? args.inputTokens + args.outputTokens;
35
+ const cost = computeCostUsd(args.model, args.inputTokens, args.outputTokens);
36
+ try {
37
+ const supabase = getSupabaseAdmin();
38
+ const { error } = await supabase.from("ai_usage").insert({
39
+ app: args.app,
40
+ model: args.model,
41
+ input_tokens: args.inputTokens,
42
+ output_tokens: args.outputTokens,
43
+ total_tokens: total,
44
+ cost_usd: cost,
45
+ request_id: args.requestId ?? null,
46
+ metadata: args.metadata ?? null,
47
+ });
48
+ if (error) {
49
+ console.error("[ai-usage] insert failed:", error.message);
50
+ }
51
+ } catch (e) {
52
+ console.error("[ai-usage] unexpected error:", e);
53
+ }
54
+ }
@@ -0,0 +1,80 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const TITLE_RE = /const\s+TITLE\s*=\s*"([^"]+)"/;
5
+ const DESC_RE = /const\s+DESCRIPTION\s*=\s*"([^"]+)"/;
6
+ const DATE_RE = /const\s+DATE_PUBLISHED\s*=\s*"([^"]+)"/;
7
+ const H2_RE = /<h2\b[^>]*>([\s\S]*?)<\/h2>/g;
8
+
9
+ export interface GuideEntry {
10
+ slug: string;
11
+ title: string;
12
+ description: string;
13
+ datePublished: string;
14
+ sections: { id: string; title: string }[];
15
+ hasFaq: boolean;
16
+ }
17
+
18
+ let cachedDir: string | null = null;
19
+ let cachedGuides: GuideEntry[] | null = null;
20
+
21
+ export function discoverGuides(contentDir?: string): GuideEntry[] {
22
+ const dir = contentDir ?? path.join(process.cwd(), "src/app/(content)/t");
23
+
24
+ if (cachedDir === dir && cachedGuides) return cachedGuides;
25
+
26
+ if (!fs.existsSync(dir)) return [];
27
+
28
+ const slugs = fs
29
+ .readdirSync(dir, { withFileTypes: true })
30
+ .filter((d) => d.isDirectory() && !d.name.startsWith("["))
31
+ .map((d) => d.name);
32
+
33
+ const guides: GuideEntry[] = [];
34
+ for (const slug of slugs) {
35
+ const pagePath = path.join(dir, slug, "page.tsx");
36
+ let src: string;
37
+ try {
38
+ src = fs.readFileSync(pagePath, "utf-8");
39
+ } catch {
40
+ continue;
41
+ }
42
+
43
+ const title = src.match(TITLE_RE)?.[1] ?? "";
44
+ const description = src.match(DESC_RE)?.[1] ?? "";
45
+ const datePublished = src.match(DATE_RE)?.[1] ?? "";
46
+ if (!title) continue;
47
+
48
+ const sections: { id: string; title: string }[] = [];
49
+ let m: RegExpExecArray | null;
50
+ while ((m = H2_RE.exec(src)) !== null) {
51
+ const raw = m[1].replace(/<[^>]+>/g, "").trim();
52
+ if (raw) {
53
+ const id = raw
54
+ .toLowerCase()
55
+ .replace(/[^a-z0-9]+/g, "-")
56
+ .replace(/^-|-$/g, "");
57
+ sections.push({ id, title: raw });
58
+ }
59
+ }
60
+
61
+ guides.push({
62
+ slug,
63
+ title,
64
+ description,
65
+ datePublished,
66
+ sections,
67
+ hasFaq: /<FaqSection\b/.test(src),
68
+ });
69
+ }
70
+
71
+ guides.sort(
72
+ (a, b) =>
73
+ (b.datePublished || "").localeCompare(a.datePublished || "") ||
74
+ a.title.localeCompare(b.title),
75
+ );
76
+
77
+ cachedDir = dir;
78
+ cachedGuides = guides;
79
+ return guides;
80
+ }
@@ -0,0 +1,351 @@
1
+ import { NextRequest } from "next/server";
2
+ import {
3
+ GoogleGenerativeAI,
4
+ SchemaType,
5
+ type FunctionDeclaration,
6
+ type FunctionDeclarationsTool,
7
+ type Part,
8
+ } from "@google/generative-ai";
9
+ import {
10
+ buildSystemPrompt,
11
+ getGuideContext,
12
+ buildGuideIndex,
13
+ } from "./guide-context";
14
+ import { discoverGuides } from "./discover-guides";
15
+ import { logAiUsage } from "./ai-usage";
16
+
17
+ const MODEL_ID = "gemini-flash-latest";
18
+ const MAX_TOOL_ROUNDS = 3;
19
+ const RATE_LIMIT_MAX = 20;
20
+ const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
21
+
22
+ const ipHits = new Map<string, number[]>();
23
+
24
+ function ratelimit(ip: string): { ok: boolean; remaining: number } {
25
+ const now = Date.now();
26
+ const hits = (ipHits.get(ip) ?? []).filter(
27
+ (t) => now - t < RATE_LIMIT_WINDOW_MS,
28
+ );
29
+ if (hits.length >= RATE_LIMIT_MAX) {
30
+ ipHits.set(ip, hits);
31
+ return { ok: false, remaining: 0 };
32
+ }
33
+ hits.push(now);
34
+ ipHits.set(ip, hits);
35
+ return { ok: true, remaining: RATE_LIMIT_MAX - hits.length };
36
+ }
37
+
38
+ /* ------------------------------------------------------------------ */
39
+ /* Tool declarations */
40
+ /* ------------------------------------------------------------------ */
41
+
42
+ function buildToolDeclarations(brand: string): FunctionDeclarationsTool[] {
43
+ const getGuideContentDecl: FunctionDeclaration = {
44
+ name: "get_guide_content",
45
+ description: `Load the full text content of any guide page on the ${brand} site by its slug. Use this when the visitor asks about a topic covered in another guide, or when you need to cross-reference information.`,
46
+ parameters: {
47
+ type: SchemaType.OBJECT,
48
+ properties: {
49
+ slug: {
50
+ type: SchemaType.STRING,
51
+ description:
52
+ "The guide slug (e.g. 'some-topic'). See the guide index in the system prompt for available slugs.",
53
+ },
54
+ },
55
+ required: ["slug"],
56
+ },
57
+ };
58
+
59
+ const searchGuidesDecl: FunctionDeclaration = {
60
+ name: "search_guides",
61
+ description:
62
+ "Search across all guide pages for a keyword or topic. Returns matching guide titles and the sections that mention the query. Use this when you need to find which guide covers a specific topic.",
63
+ parameters: {
64
+ type: SchemaType.OBJECT,
65
+ properties: {
66
+ query: {
67
+ type: SchemaType.STRING,
68
+ description: "The search term or topic to find across guides.",
69
+ },
70
+ },
71
+ required: ["query"],
72
+ },
73
+ };
74
+
75
+ return [{ functionDeclarations: [getGuideContentDecl, searchGuidesDecl] }];
76
+ }
77
+
78
+ /* ------------------------------------------------------------------ */
79
+ /* Tool execution */
80
+ /* ------------------------------------------------------------------ */
81
+
82
+ function executeGetGuideContent(
83
+ args: { slug: string },
84
+ contentDir: string,
85
+ ): Record<string, unknown> {
86
+ const ctx = getGuideContext(args.slug, contentDir);
87
+ if (!ctx) {
88
+ return {
89
+ error: `Guide "${args.slug}" not found. Check the guide index for valid slugs.`,
90
+ };
91
+ }
92
+ return {
93
+ slug: ctx.slug,
94
+ title: ctx.title,
95
+ description: ctx.description,
96
+ sections: ctx.sections.map((s) => s.title),
97
+ content: ctx.body,
98
+ };
99
+ }
100
+
101
+ function executeSearchGuides(
102
+ args: { query: string },
103
+ contentDir: string,
104
+ ): Record<string, unknown> {
105
+ const query = args.query.toLowerCase();
106
+ const guides = discoverGuides(contentDir);
107
+ const results: { slug: string; title: string; matchingSections: string[] }[] =
108
+ [];
109
+
110
+ for (const g of guides) {
111
+ const titleMatch = g.title.toLowerCase().includes(query);
112
+ const descMatch = g.description.toLowerCase().includes(query);
113
+ const matchingSections = g.sections
114
+ .filter((s) => s.title.toLowerCase().includes(query))
115
+ .map((s) => s.title);
116
+
117
+ if (titleMatch || descMatch || matchingSections.length > 0) {
118
+ results.push({ slug: g.slug, title: g.title, matchingSections });
119
+ }
120
+ }
121
+
122
+ if (results.length === 0) {
123
+ return {
124
+ results: [],
125
+ message: `No guides found matching "${args.query}". Try a broader term.`,
126
+ };
127
+ }
128
+ return { results };
129
+ }
130
+
131
+ function executeTool(
132
+ name: string,
133
+ args: Record<string, unknown>,
134
+ contentDir: string,
135
+ ): Record<string, unknown> {
136
+ switch (name) {
137
+ case "get_guide_content":
138
+ return executeGetGuideContent(args as { slug: string }, contentDir);
139
+ case "search_guides":
140
+ return executeSearchGuides(args as { query: string }, contentDir);
141
+ default:
142
+ return { error: `Unknown tool: ${name}` };
143
+ }
144
+ }
145
+
146
+ /* ------------------------------------------------------------------ */
147
+ /* Config & factory */
148
+ /* ------------------------------------------------------------------ */
149
+
150
+ export interface GuideChatConfig {
151
+ /** App identifier for token accounting (e.g. "cyrano", "pieline") */
152
+ app: string;
153
+ /** Brand name shown in system prompt (e.g. "Cyrano", "PieLine") */
154
+ brand: string;
155
+ /** Short site description for system prompt context */
156
+ siteDescription?: string;
157
+ /** Path to content directory relative to project root. Defaults to "src/app/t" */
158
+ contentDir?: string;
159
+ }
160
+
161
+ interface ChatMessage {
162
+ role: "user" | "assistant";
163
+ content: string;
164
+ }
165
+
166
+ export function createGuideChatHandler(config: GuideChatConfig) {
167
+ const {
168
+ app,
169
+ brand,
170
+ siteDescription,
171
+ contentDir: relDir,
172
+ } = config;
173
+
174
+ const contentDir = relDir
175
+ ? `${process.cwd()}/${relDir}`
176
+ : `${process.cwd()}/src/app/t`;
177
+
178
+ return async function POST(req: NextRequest) {
179
+ const ip =
180
+ req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
181
+ req.headers.get("x-real-ip") ||
182
+ "unknown";
183
+
184
+ const rl = ratelimit(ip);
185
+ if (!rl.ok) {
186
+ return new Response(
187
+ JSON.stringify({ error: "rate_limit_exceeded", retry_after_minutes: 60 }),
188
+ { status: 429, headers: { "content-type": "application/json" } },
189
+ );
190
+ }
191
+
192
+ let body: { messages?: ChatMessage[]; slug?: string };
193
+ try {
194
+ body = await req.json();
195
+ } catch {
196
+ return new Response(JSON.stringify({ error: "invalid_json" }), {
197
+ status: 400,
198
+ headers: { "content-type": "application/json" },
199
+ });
200
+ }
201
+
202
+ const messages = Array.isArray(body.messages) ? body.messages : [];
203
+ const slug = typeof body.slug === "string" ? body.slug : "";
204
+
205
+ if (messages.length === 0) {
206
+ return new Response(JSON.stringify({ error: "no_messages" }), {
207
+ status: 400,
208
+ headers: { "content-type": "application/json" },
209
+ });
210
+ }
211
+
212
+ const ctx = slug ? getGuideContext(slug, contentDir) : null;
213
+ const systemPrompt = ctx
214
+ ? buildSystemPrompt({ ctx, brand, siteDescription, contentDir })
215
+ : `You are an assistant for the ${brand} website. Help the user with general questions.\n\nAll guides on this site:\n${buildGuideIndex(contentDir)}`;
216
+
217
+ const apiKey = process.env.GEMINI_API_KEY;
218
+ if (!apiKey) {
219
+ return new Response(JSON.stringify({ error: "missing_gemini_key" }), {
220
+ status: 500,
221
+ headers: { "content-type": "application/json" },
222
+ });
223
+ }
224
+
225
+ const requestId = crypto.randomUUID();
226
+ const tools = buildToolDeclarations(brand);
227
+
228
+ const genAI = new GoogleGenerativeAI(apiKey);
229
+ const model = genAI.getGenerativeModel({
230
+ model: MODEL_ID,
231
+ systemInstruction: systemPrompt,
232
+ tools,
233
+ });
234
+
235
+ const history = messages.slice(0, -1).map((m) => ({
236
+ role: m.role === "assistant" ? ("model" as const) : ("user" as const),
237
+ parts: [{ text: m.content }],
238
+ }));
239
+ const lastUser = messages[messages.length - 1];
240
+ const chat = model.startChat({ history });
241
+
242
+ let toolRounds = 0;
243
+ let currentRequest: string | Part[] = lastUser.content;
244
+ let finalText: string | null = null;
245
+ let totalInputTokens = 0;
246
+ let totalOutputTokens = 0;
247
+
248
+ while (toolRounds <= MAX_TOOL_ROUNDS) {
249
+ let result;
250
+ try {
251
+ result = await chat.sendMessage(currentRequest);
252
+ } catch (e) {
253
+ const msg = e instanceof Error ? e.message : String(e);
254
+ console.error(`[guide-chat:${app}] gemini error:`, msg);
255
+ return new Response(
256
+ JSON.stringify({ error: "gemini_failed", detail: msg }),
257
+ { status: 502, headers: { "content-type": "application/json" } },
258
+ );
259
+ }
260
+
261
+ const response = result.response;
262
+ const fnCalls = response.functionCalls();
263
+
264
+ if (!fnCalls || fnCalls.length === 0) {
265
+ finalText = response.text();
266
+
267
+ const usage = response.usageMetadata;
268
+ const inputTokens = usage?.promptTokenCount ?? 0;
269
+ const outputTokens = usage?.candidatesTokenCount ?? 0;
270
+ totalInputTokens += inputTokens;
271
+ totalOutputTokens += outputTokens;
272
+ const totalTokens =
273
+ usage?.totalTokenCount ?? inputTokens + outputTokens;
274
+
275
+ logAiUsage({
276
+ app,
277
+ model: MODEL_ID,
278
+ inputTokens,
279
+ outputTokens,
280
+ totalTokens,
281
+ requestId,
282
+ metadata: {
283
+ feature: "guide-chat",
284
+ slug: slug || null,
285
+ ip,
286
+ toolRounds,
287
+ },
288
+ }).catch((err) =>
289
+ console.error(`[guide-chat:${app}] logAiUsage:`, err),
290
+ );
291
+
292
+ break;
293
+ }
294
+
295
+ const functionResponses: Part[] = fnCalls.map((fc) => ({
296
+ functionResponse: {
297
+ name: fc.name,
298
+ response: executeTool(
299
+ fc.name,
300
+ fc.args as Record<string, unknown>,
301
+ contentDir,
302
+ ),
303
+ },
304
+ }));
305
+
306
+ currentRequest = functionResponses;
307
+ toolRounds++;
308
+ }
309
+
310
+ if (finalText === null) {
311
+ return new Response(
312
+ JSON.stringify({ error: "max_tool_rounds_exceeded" }),
313
+ { status: 502, headers: { "content-type": "application/json" } },
314
+ );
315
+ }
316
+
317
+ const encoder = new TextEncoder();
318
+ const stream = new ReadableStream({
319
+ start(controller) {
320
+ controller.enqueue(
321
+ encoder.encode(
322
+ JSON.stringify({ type: "delta", text: finalText }) + "\n",
323
+ ),
324
+ );
325
+ controller.enqueue(
326
+ encoder.encode(
327
+ JSON.stringify({
328
+ type: "done",
329
+ usage: {
330
+ inputTokens: totalInputTokens,
331
+ outputTokens: totalOutputTokens,
332
+ },
333
+ requestId,
334
+ toolRounds,
335
+ model: MODEL_ID,
336
+ }) + "\n",
337
+ ),
338
+ );
339
+ controller.close();
340
+ },
341
+ });
342
+
343
+ return new Response(stream, {
344
+ headers: {
345
+ "content-type": "application/x-ndjson; charset=utf-8",
346
+ "cache-control": "no-store",
347
+ "x-request-id": requestId,
348
+ },
349
+ });
350
+ };
351
+ }
@@ -0,0 +1,112 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { discoverGuides, type GuideEntry } from "./discover-guides";
4
+
5
+ export interface GuideContext {
6
+ slug: string;
7
+ title: string;
8
+ description: string;
9
+ datePublished: string;
10
+ sections: { id: string; title: string }[];
11
+ body: string;
12
+ }
13
+
14
+ export function getGuideContext(
15
+ slug: string,
16
+ contentDir?: string,
17
+ ): GuideContext | null {
18
+ const dir = contentDir ?? path.join(process.cwd(), "src/app/(content)/t");
19
+ const guide = discoverGuides(dir).find((g) => g.slug === slug);
20
+ if (!guide) return null;
21
+
22
+ const pagePath = path.join(dir, slug, "page.tsx");
23
+ let rawSource = "";
24
+ try {
25
+ rawSource = fs.readFileSync(pagePath, "utf-8");
26
+ } catch {
27
+ return null;
28
+ }
29
+
30
+ const body = extractReadableText(rawSource).slice(0, 12_000);
31
+ return {
32
+ slug: guide.slug,
33
+ title: guide.title,
34
+ description: guide.description,
35
+ datePublished: guide.datePublished,
36
+ sections: guide.sections,
37
+ body,
38
+ };
39
+ }
40
+
41
+ function extractReadableText(src: string): string {
42
+ const lines = src.split("\n");
43
+ const out: string[] = [];
44
+ let depth = 0;
45
+ let inReturn = false;
46
+ for (const line of lines) {
47
+ if (!inReturn && /return\s*\(/.test(line)) {
48
+ inReturn = true;
49
+ }
50
+ if (!inReturn) continue;
51
+ depth += (line.match(/\(/g) || []).length;
52
+ depth -= (line.match(/\)/g) || []).length;
53
+ out.push(line);
54
+ if (inReturn && depth <= 0) break;
55
+ }
56
+ const jsx = out.join("\n");
57
+ return jsx
58
+ .replace(/<[^>]+>/g, " ")
59
+ .replace(/\{[^{}]*\}/g, " ")
60
+ .replace(/\s+/g, " ")
61
+ .trim();
62
+ }
63
+
64
+ export function buildGuideIndex(contentDir?: string): string {
65
+ const guides = discoverGuides(contentDir);
66
+ if (guides.length === 0) return "No guides available.";
67
+ return guides
68
+ .map((g) => {
69
+ const sections = g.sections.map((s) => s.title).join(", ");
70
+ return `- slug: "${g.slug}" | title: "${g.title}" | sections: [${sections}]`;
71
+ })
72
+ .join("\n");
73
+ }
74
+
75
+ export interface BuildSystemPromptOptions {
76
+ ctx: GuideContext;
77
+ brand: string;
78
+ siteDescription?: string;
79
+ contentDir?: string;
80
+ }
81
+
82
+ export function buildSystemPrompt(opts: BuildSystemPromptOptions): string {
83
+ const { ctx, brand, siteDescription, contentDir } = opts;
84
+ const sectionList = ctx.sections.map((s) => `- ${s.title}`).join("\n");
85
+ const guideIndex = buildGuideIndex(contentDir);
86
+
87
+ return [
88
+ `You are an assistant embedded in the ${brand} website.`,
89
+ siteDescription ? `Site context: ${siteDescription}` : "",
90
+ "The visitor is reading a specific article (guide page). Your job is to help them understand the article they are reading.",
91
+ "",
92
+ "IMPORTANT: When summarizing or answering, focus on what the ARTICLE covers and what the reader will learn from it. Do NOT just describe the product. Describe the article's content, structure, and key takeaways.",
93
+ "",
94
+ "Ground answers in the provided page content. If the question is outside the page, use the get_guide_content tool to look up other pages, or say so briefly and answer from general knowledge.",
95
+ "Keep answers concise and use plain text. No markdown headings. Inline code is fine.",
96
+ "",
97
+ `CURRENT ARTICLE: "${ctx.title}"`,
98
+ `Slug: ${ctx.slug}`,
99
+ ctx.description ? `Description: ${ctx.description}` : "",
100
+ "",
101
+ "Sections in this article:",
102
+ sectionList,
103
+ "",
104
+ "Article content (trimmed):",
105
+ ctx.body,
106
+ "",
107
+ "ALL GUIDES ON THIS SITE (use get_guide_content tool to read any of them):",
108
+ guideIndex,
109
+ ]
110
+ .filter(Boolean)
111
+ .join("\n");
112
+ }
@@ -0,0 +1,15 @@
1
+ import { createClient, type SupabaseClient } from "@supabase/supabase-js";
2
+
3
+ let cached: SupabaseClient | null = null;
4
+
5
+ export function getSupabaseAdmin(): SupabaseClient {
6
+ if (cached) return cached;
7
+ const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
8
+ const key = process.env.SUPABASE_SERVICE_KEY;
9
+ if (!url) throw new Error("Missing env.NEXT_PUBLIC_SUPABASE_URL");
10
+ if (!key) throw new Error("Missing env.SUPABASE_SERVICE_KEY");
11
+ cached = createClient(url, key, {
12
+ auth: { persistSession: false, autoRefreshToken: false },
13
+ });
14
+ return cached;
15
+ }