@m13v/seo-components 0.6.0 → 0.7.1
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 +11 -3
- package/src/components/GuideChatPanel.tsx +541 -0
- package/src/index.ts +14 -0
- package/src/lib/ai-usage.ts +54 -0
- package/src/lib/discover-guides.ts +80 -0
- package/src/lib/guide-chat-route.ts +351 -0
- package/src/lib/guide-context.ts +112 -0
- package/src/lib/supabase-admin.ts +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/seo-components",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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
|
}
|
|
@@ -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 unknown 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 unknown 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
|
+
}
|