rift-ds 1.7.0 → 1.8.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.
Files changed (107) hide show
  1. package/README.md +28 -5
  2. package/bin/rift-ds.mjs +142 -29
  3. package/bin/templates/chat/.env.chat.example +21 -0
  4. package/bin/templates/chat/app/api/chat/feedback/route.ts +12 -0
  5. package/bin/templates/chat/app/api/chat/followups/route.ts +23 -0
  6. package/bin/templates/chat/app/api/chat/guardrails.ts +18 -0
  7. package/bin/templates/chat/app/api/chat/route.ts +31 -0
  8. package/bin/templates/chat/components/chat/ChatMount.tsx +52 -0
  9. package/bin/templates/chat/components/chat/models.ts +29 -0
  10. package/chat/AssistantTurn.d.ts +5 -0
  11. package/chat/AssistantTurn.js +82 -0
  12. package/chat/ChatAgentPane.css +56 -0
  13. package/chat/ChatAgentPane.d.ts +21 -0
  14. package/chat/ChatAgentPane.js +25 -0
  15. package/chat/ChatLauncher.d.ts +64 -0
  16. package/chat/ChatLauncher.js +347 -0
  17. package/chat/ChatProvider.d.ts +53 -0
  18. package/chat/ChatProvider.js +181 -0
  19. package/chat/ChatTabs.d.ts +13 -0
  20. package/chat/ChatTabs.js +24 -0
  21. package/chat/ChatThreads.d.ts +26 -0
  22. package/chat/ChatThreads.js +55 -0
  23. package/chat/ChatWidget.css +1072 -0
  24. package/chat/ChatWidget.d.ts +113 -0
  25. package/chat/ChatWidget.js +495 -0
  26. package/chat/DemoWelcome.d.ts +13 -0
  27. package/chat/DemoWelcome.js +131 -0
  28. package/chat/FollowupSuggestions.d.ts +23 -0
  29. package/chat/FollowupSuggestions.js +34 -0
  30. package/chat/ResponseActions.d.ts +18 -0
  31. package/chat/ResponseActions.js +79 -0
  32. package/chat/TurnContent.d.ts +9 -0
  33. package/chat/TurnContent.js +12 -0
  34. package/chat/context.d.ts +65 -0
  35. package/chat/context.js +27 -0
  36. package/chat/demo-agent-panes.d.ts +5 -0
  37. package/chat/demo-agent-panes.js +49 -0
  38. package/chat/demo-agent.d.ts +3 -0
  39. package/chat/demo-agent.js +28 -0
  40. package/chat/demo-card-components.d.ts +46 -0
  41. package/chat/demo-card-components.js +59 -0
  42. package/chat/demo-cards.d.ts +32 -0
  43. package/chat/demo-cards.js +59 -0
  44. package/chat/demo-fixtures.d.ts +20 -0
  45. package/chat/demo-fixtures.js +113 -0
  46. package/chat/demo-script.d.ts +6 -0
  47. package/chat/demo-script.js +215 -0
  48. package/chat/demo.css +46 -0
  49. package/chat/demo.d.ts +9 -0
  50. package/chat/demo.js +24 -0
  51. package/chat/followups.d.ts +41 -0
  52. package/chat/followups.js +55 -0
  53. package/chat/greeting.d.ts +11 -0
  54. package/chat/greeting.js +21 -0
  55. package/chat/index.d.ts +34 -0
  56. package/chat/index.js +80 -0
  57. package/chat/placement.d.ts +48 -0
  58. package/chat/placement.js +39 -0
  59. package/chat/queries.d.ts +9 -0
  60. package/chat/queries.js +22 -0
  61. package/chat/registry.d.ts +25 -0
  62. package/chat/registry.js +9 -0
  63. package/chat/registry.json.d.ts +69 -0
  64. package/chat/registry.json.js +11 -0
  65. package/chat/server.d.ts +217 -0
  66. package/chat/server.js +488 -0
  67. package/chat/sim.d.ts +71 -0
  68. package/chat/sim.js +125 -0
  69. package/chat/starters.d.ts +15 -0
  70. package/chat/starters.js +38 -0
  71. package/chat/suggestions.d.ts +22 -0
  72. package/chat/suggestions.js +9 -0
  73. package/chat/threads.d.ts +73 -0
  74. package/chat/threads.js +270 -0
  75. package/chat/transport.d.ts +49 -0
  76. package/chat/transport.js +105 -0
  77. package/chat/types.d.ts +210 -0
  78. package/chat/types.js +16 -0
  79. package/chat/upstash.d.ts +63 -0
  80. package/chat/upstash.js +147 -0
  81. package/chat/useChat.d.ts +64 -0
  82. package/chat/useChat.js +241 -0
  83. package/cli/templates/chat/app/api/chat/feedback/route.d.ts +3 -0
  84. package/cli/templates/chat/app/api/chat/followups/route.d.ts +3 -0
  85. package/cli/templates/chat/app/api/chat/guardrails.d.ts +10 -0
  86. package/cli/templates/chat/app/api/chat/route.d.ts +4 -0
  87. package/cli/templates/chat/components/chat/ChatMount.d.ts +1 -0
  88. package/cli/templates/chat/components/chat/models.d.ts +11 -0
  89. package/components/ShaderField/ShaderField.d.ts +3 -2
  90. package/components/ShaderField/useShaderField.d.ts +2 -1
  91. package/components/ShaderField/useShaderField.js +6 -6
  92. package/fonts/material-symbols.css +2 -1
  93. package/package.json +39 -2
  94. package/tokens/presets/coral.css +18 -2
  95. package/tokens/presets/forest.css +18 -2
  96. package/tokens/presets/gold.css +18 -2
  97. package/tokens/presets/mono.css +18 -2
  98. package/tokens/presets/pink.css +18 -2
  99. package/tokens/presets/terminal.css +18 -2
  100. package/tokens/presets/violet.css +18 -2
  101. package/tokens/presets/warm.css +18 -2
  102. package/tokens/presets/zest.css +18 -2
  103. package/tokens/registry.json +6 -0
  104. package/tokens/registry.json.d.ts +6 -0
  105. package/tokens/registry.json.js +1 -1
  106. package/tokens/tokens-dark.css +10 -0
  107. package/tokens/tokens-light.css +10 -0
package/chat/server.js ADDED
@@ -0,0 +1,488 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import { SUGGESTION_MAX_CHARS, fitsChip } from "./suggestions.js";
3
+ const DEFAULT_CHAT_LIMITS = {
4
+ maxTurns: 10,
5
+ maxMessageChars: 4e3,
6
+ maxTotalChars: 4e4,
7
+ maxBodyChars: 64 * 1024,
8
+ maxTracePoints: 8
9
+ };
10
+ const DEFAULT_CHAT_COPY = {
11
+ disabled: "This chat is switched off at the moment.",
12
+ tooLong: "That message is longer than this chat can take. Trim it to the essentials and send it again.",
13
+ refusal: "That one is outside what this chat covers.",
14
+ cutOff: "\n\n(Cut off at the length limit.)",
15
+ reading: "Reading",
16
+ thinking: "Thinking",
17
+ lookingUp: "Looking it up",
18
+ busy: "The model is busy right now. Give it a moment and ask again.",
19
+ unauthorised: "This chat is not configured correctly at the moment.",
20
+ failed: "Something went wrong reaching the model. Try that again in a moment."
21
+ };
22
+ const NDJSON_HEADERS = {
23
+ "Content-Type": "application/x-ndjson; charset=utf-8",
24
+ "Cache-Control": "no-store",
25
+ // Proxies that buffer by default would hold the whole reply and destroy the
26
+ // streaming choreography the widget exists to show.
27
+ "X-Accel-Buffering": "no"
28
+ };
29
+ const encoder = new TextEncoder();
30
+ const serialise = (event) => encoder.encode(`${JSON.stringify(event)}
31
+ `);
32
+ function ndjson(...events) {
33
+ return new Response(events.map((event) => `${JSON.stringify(event)}
34
+ `).join(""), {
35
+ headers: NDJSON_HEADERS
36
+ });
37
+ }
38
+ function sanitisePath(value) {
39
+ if (typeof value !== "string") return null;
40
+ if (value === "/") return "/";
41
+ return /^\/[a-z0-9-]+(?:\/[a-z0-9-]+){0,3}$/.test(value) && value.length <= 64 ? value : null;
42
+ }
43
+ function parseBody(payload, limits, models, sanitise) {
44
+ if (typeof payload !== "object" || payload === null) {
45
+ return { ok: false, status: 400, reason: "body must be an object" };
46
+ }
47
+ const { messages, path, model } = payload;
48
+ if (!Array.isArray(messages) || messages.length === 0) {
49
+ return { ok: false, status: 400, reason: "messages must be a non-empty array" };
50
+ }
51
+ const parsed = [];
52
+ for (const entry of messages) {
53
+ if (typeof entry !== "object" || entry === null) {
54
+ return { ok: false, status: 400, reason: "each message must be an object" };
55
+ }
56
+ const { role, content } = entry;
57
+ if (role !== "user" && role !== "assistant") {
58
+ return { ok: false, status: 400, reason: "role must be 'user' or 'assistant'" };
59
+ }
60
+ if (typeof content !== "string") {
61
+ return { ok: false, status: 400, reason: "content must be a string" };
62
+ }
63
+ if (content.trim()) parsed.push({ role, content });
64
+ }
65
+ if (parsed.length === 0) return { ok: false, status: 400, reason: "no messages with content" };
66
+ const trimmed = parsed.slice(-limits.maxTurns);
67
+ while (trimmed.length > 0 && trimmed[0].role === "assistant") trimmed.shift();
68
+ if (trimmed.length === 0) {
69
+ return { ok: false, status: 400, reason: "no user message in the recent turns" };
70
+ }
71
+ const totalChars = trimmed.reduce((sum, message) => sum + message.content.length, 0);
72
+ if (totalChars > limits.maxTotalChars) {
73
+ return { ok: false, status: 413, reason: "conversation is too large" };
74
+ }
75
+ const requested = typeof model === "string" ? models.find((option) => option.value === model) ?? null : null;
76
+ return { ok: true, messages: trimmed, path: sanitise(path), model: requested };
77
+ }
78
+ function cleanPoint(text) {
79
+ const flat = text.replace(/\*\*/g, "").replace(/\s+/g, " ").trim();
80
+ if (flat.length <= 200) return flat;
81
+ const cut = flat.slice(0, 200);
82
+ return `${cut.slice(0, cut.lastIndexOf(" ")) || cut}…`;
83
+ }
84
+ function createReasoningBuffer(limit, emit) {
85
+ let buffer = "";
86
+ let emitted = 0;
87
+ const flush = (final) => {
88
+ if (emitted >= limit) {
89
+ buffer = "";
90
+ return;
91
+ }
92
+ const sentences = buffer.split(new RegExp("(?<=[.!?])\\s+"));
93
+ buffer = final ? "" : sentences.pop() ?? "";
94
+ for (const sentence of sentences) {
95
+ if (emitted >= limit) break;
96
+ const point = cleanPoint(sentence);
97
+ if (point.length < 12) continue;
98
+ emit(point);
99
+ emitted += 1;
100
+ }
101
+ };
102
+ return {
103
+ push(text) {
104
+ buffer += text;
105
+ flush(false);
106
+ },
107
+ close() {
108
+ flush(true);
109
+ }
110
+ };
111
+ }
112
+ function friendlyError(error, copy) {
113
+ const status = error?.status;
114
+ if (status === 429 || status === 529) return copy.busy;
115
+ if (status === 401 || status === 403) return copy.unauthorised;
116
+ return copy.failed;
117
+ }
118
+ const toBlocks = (system) => typeof system === "string" ? [{ type: "text", text: system }] : system;
119
+ function createChatHandler(options) {
120
+ const limits = { ...DEFAULT_CHAT_LIMITS, ...options.limits };
121
+ const copy = { ...DEFAULT_CHAT_COPY, ...options.copy };
122
+ const sanitise = options.sanitiseContext ?? sanitisePath;
123
+ const tools = options.tools ?? [];
124
+ const toolDefinitions = tools.map((tool) => tool.definition);
125
+ const toolByName = new Map(tools.map((tool) => [tool.definition.name, tool]));
126
+ const maxModelCalls = options.maxModelCalls ?? 3;
127
+ const maxTokens = options.maxTokens ?? 3e3;
128
+ const effort = options.effort ?? "low";
129
+ const guardrails = options.guardrails ?? {};
130
+ const contextBlock = options.contextBlock ?? ((path) => `The visitor is currently on the page ${path}.`);
131
+ const client = options.client ?? (options.apiKey ? new Anthropic({ apiKey: options.apiKey }) : null);
132
+ const enabled = options.enabled ?? client !== null;
133
+ const defaultModel = options.models.find((option) => option.value === options.defaultModel) ?? options.models[0];
134
+ const budgetModel = options.models.find((option) => option.value === options.budgetModel) ?? defaultModel;
135
+ if (!defaultModel) throw new Error("createChatHandler: `models` must name at least one model.");
136
+ const resolveServingModel = (requested, tier) => {
137
+ if (tier === "locked") return budgetModel;
138
+ if (requested) return requested;
139
+ return tier === "reduced" ? budgetModel : defaultModel;
140
+ };
141
+ const runTool = async (name, input) => {
142
+ const args = typeof input === "object" && input !== null ? input : {};
143
+ const tool = toolByName.get(name);
144
+ if (!tool) return { text: JSON.stringify({ error: `Unknown tool ${JSON.stringify(name)}.` }) };
145
+ try {
146
+ const result = await tool.execute(args);
147
+ return typeof result === "string" ? { text: result } : result;
148
+ } catch (error) {
149
+ return { text: JSON.stringify({ error: String(error?.message ?? error) }) };
150
+ }
151
+ };
152
+ return async function chatHandler(request) {
153
+ if (!enabled || !client) {
154
+ return ndjson({ type: "notice", text: copy.disabled }, { type: "done" });
155
+ }
156
+ let payload;
157
+ try {
158
+ const raw = await request.text();
159
+ if (raw.length > limits.maxBodyChars) {
160
+ return Response.json({ error: "request body too large" }, { status: 413 });
161
+ }
162
+ payload = JSON.parse(raw);
163
+ } catch {
164
+ return Response.json({ error: "invalid JSON" }, { status: 400 });
165
+ }
166
+ const parsed = parseBody(payload, limits, options.models, sanitise);
167
+ if (!parsed.ok) return Response.json({ error: parsed.reason }, { status: parsed.status });
168
+ const latest = parsed.messages[parsed.messages.length - 1];
169
+ if (latest.content.length > limits.maxMessageChars) {
170
+ return ndjson({ type: "notice", text: copy.tooLong }, { type: "done" });
171
+ }
172
+ const startedAt = Date.now();
173
+ const visitor = await guardrails.visitorKey?.(request) ?? "anonymous";
174
+ const question = latest.content;
175
+ const exchangeId = crypto.randomUUID();
176
+ const log = (entry) => {
177
+ if (!guardrails.onExchange) return Promise.resolve();
178
+ return guardrails.onExchange(entry).catch(() => {
179
+ });
180
+ };
181
+ const verdict = await guardrails.check?.(request) ?? { allowed: true, tier: "open" };
182
+ if (!verdict.allowed) {
183
+ const logged = log({
184
+ id: exchangeId,
185
+ question,
186
+ answer: verdict.notice,
187
+ path: parsed.path,
188
+ latencyMs: Date.now() - startedAt,
189
+ firstTextMs: null,
190
+ notice: true,
191
+ visitor
192
+ });
193
+ options.waitUntil?.(logged);
194
+ return ndjson({ type: "notice", text: verdict.notice }, { type: "done" });
195
+ }
196
+ const serving = resolveServingModel(parsed.model, verdict.tier);
197
+ const tierDefault = verdict.tier === "open" ? defaultModel.value : budgetModel.value;
198
+ const lockedValues = verdict.tier === "locked" ? options.models.filter((option) => option !== budgetModel).map((option) => option.value) : [];
199
+ const upstream = new AbortController();
200
+ request.signal.addEventListener("abort", () => upstream.abort());
201
+ const systemBase = toBlocks(
202
+ typeof options.system === "function" ? options.system({ path: parsed.path }) : options.system
203
+ );
204
+ const trailing = parsed.path ? contextBlock(parsed.path) : null;
205
+ const system = trailing ? [...systemBase, { type: "text", text: trailing }] : systemBase;
206
+ const body = new ReadableStream({
207
+ async start(controller) {
208
+ let closed = false;
209
+ const send = (event) => {
210
+ if (closed) return;
211
+ try {
212
+ controller.enqueue(serialise(event));
213
+ } catch {
214
+ closed = true;
215
+ }
216
+ };
217
+ let streamedText = false;
218
+ let answerText = "";
219
+ let noticeText = null;
220
+ let firstTextMs = null;
221
+ try {
222
+ send({ type: "status", label: copy.reading });
223
+ send({ type: "model", label: serving.label });
224
+ send({ type: "models", default: tierDefault, locked: lockedValues });
225
+ const reasoning = createReasoningBuffer(
226
+ limits.maxTracePoints,
227
+ (point) => send({ type: "status", label: copy.thinking, point })
228
+ );
229
+ const working = parsed.messages.map(({ role, content }) => ({
230
+ role,
231
+ content
232
+ }));
233
+ let final;
234
+ for (let call = 0; ; call += 1) {
235
+ const lastCall = call === maxModelCalls - 1;
236
+ const stream = client.messages.stream(
237
+ {
238
+ model: serving.id,
239
+ max_tokens: maxTokens,
240
+ // A model that predates adaptive thinking and the effort dial
241
+ // rejects both, so it gets a plain request.
242
+ ...serving.adaptiveThinking ? {
243
+ thinking: { type: "adaptive", display: "summarized" },
244
+ output_config: { effort }
245
+ } : {},
246
+ ...toolDefinitions.length > 0 ? { tools: toolDefinitions } : {},
247
+ ...lastCall && toolDefinitions.length > 0 ? { tool_choice: { type: "none" } } : {},
248
+ system,
249
+ messages: working
250
+ },
251
+ { signal: upstream.signal }
252
+ );
253
+ for await (const event of stream) {
254
+ if (upstream.signal.aborted) break;
255
+ if (event.type !== "content_block_delta") continue;
256
+ if (event.delta.type === "text_delta") {
257
+ if (!streamedText) {
258
+ reasoning.close();
259
+ streamedText = true;
260
+ firstTextMs = Date.now() - startedAt;
261
+ }
262
+ answerText += event.delta.text;
263
+ send({ type: "delta", text: event.delta.text });
264
+ } else if (event.delta.type === "thinking_delta") {
265
+ reasoning.push(event.delta.thinking);
266
+ }
267
+ }
268
+ final = await stream.finalMessage();
269
+ await guardrails.onUsage?.(final.usage, serving.id).catch(() => {
270
+ });
271
+ if (final.stop_reason !== "tool_use" || lastCall || upstream.signal.aborted) break;
272
+ const toolUses = final.content.filter(
273
+ (block) => block.type === "tool_use"
274
+ );
275
+ const results = [];
276
+ for (const block of toolUses) {
277
+ const args = typeof block.input === "object" && block.input !== null ? block.input : {};
278
+ send({
279
+ type: "status",
280
+ label: copy.lookingUp,
281
+ point: toolByName.get(block.name)?.tracePoint?.(args) ?? `Used ${block.name}`
282
+ });
283
+ const result = await runTool(block.name, block.input);
284
+ if (result.card) send({ type: "card", card: result.card });
285
+ results.push({ type: "tool_result", tool_use_id: block.id, content: result.text });
286
+ }
287
+ working.push(
288
+ // The full content blocks go back, thinking included: the API
289
+ // requires the thinking blocks intact on a tool-using turn.
290
+ { role: "assistant", content: final.content },
291
+ { role: "user", content: results }
292
+ );
293
+ }
294
+ if (final.stop_reason === "refusal" && !streamedText) {
295
+ noticeText = copy.refusal;
296
+ send({ type: "notice", text: noticeText });
297
+ } else if (final.stop_reason === "max_tokens") {
298
+ send({ type: "delta", text: copy.cutOff });
299
+ }
300
+ if (guardrails.onExchange) {
301
+ await log({
302
+ id: exchangeId,
303
+ question,
304
+ answer: noticeText ?? answerText,
305
+ path: parsed.path,
306
+ model: serving.id,
307
+ usage: final.usage,
308
+ latencyMs: Date.now() - startedAt,
309
+ firstTextMs,
310
+ notice: noticeText !== null,
311
+ visitor
312
+ });
313
+ send({ type: "exchange", id: exchangeId });
314
+ }
315
+ } catch (error) {
316
+ const aborted = upstream.signal.aborted || error instanceof Error && error.name === "AbortError";
317
+ if (!aborted && !streamedText) {
318
+ console.error("[chat] upstream failed before any text:", error);
319
+ send({ type: "error", message: friendlyError(error, copy) });
320
+ } else if (!aborted) {
321
+ console.error("[chat] upstream failed mid-stream, keeping the partial:", error);
322
+ }
323
+ } finally {
324
+ send({ type: "done" });
325
+ if (!closed) {
326
+ try {
327
+ controller.close();
328
+ } catch {
329
+ }
330
+ }
331
+ }
332
+ },
333
+ cancel() {
334
+ upstream.abort();
335
+ }
336
+ });
337
+ return new Response(body, { headers: NDJSON_HEADERS });
338
+ };
339
+ }
340
+ const DEFAULT_FOLLOWUP_EXAMPLES = [
341
+ "How do I get started?",
342
+ "What does the Team plan include?",
343
+ "Can you show me an example?"
344
+ ];
345
+ function followupsSystemPrompt({
346
+ about,
347
+ candidates,
348
+ examples
349
+ }) {
350
+ return `You write the follow-up questions offered under an answer from a chat assistant about ${about}.
351
+
352
+ You are given the visitor's last question and the answer they just read. Write the ${candidates} questions that visitor is most likely to want to ask next, best first.
353
+
354
+ Length matters more than anything else here. Each question is shown on a chip, on one line, in a narrow panel. It must be at most ${SUGGESTION_MAX_CHARS} characters including spaces, which is about six words. A question over that length is thrown away and never shown, so write the short version of the question rather than the complete one. Count the characters before you write each line.
355
+
356
+ Ask the plain question. Cut any phrase that does not change what is being asked: how long the answer should take, how many points it should have, how simple it should be, and the polite run-up to the question itself. A chip is a question, not a request.
357
+
358
+ Questions in the length and plainness wanted:
359
+ ${examples.join("\n")}
360
+
361
+ The rest of the rules:
362
+ - Each question must be answerable by the assistant, about ${about}.
363
+ - Move the conversation forward. Never re-ask the question just asked, and never ask something the answer already covered.
364
+ - The visitor is asking the assistant: "What does the plan include?", never "What did you build?".
365
+ - Plain words, sentence case. No em dashes, no emoji, no quotation marks, no numbering, no markdown.
366
+ - Different angles on what was just said, not one question phrased several ways.
367
+
368
+ Output exactly ${candidates} lines, one question per line, and nothing else.`;
369
+ }
370
+ function parseFollowups(text, wanted = 3) {
371
+ const seen = /* @__PURE__ */ new Set();
372
+ const suggestions = [];
373
+ for (const line of text.split("\n")) {
374
+ const cleaned = line.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, "").replace(/[*_`#]/g, "").replace(/^["'“”‘’]+|["'“”‘’]+$/g, "").replace(/\s+/g, " ").trim();
375
+ if (cleaned.length < 12 || !fitsChip(cleaned)) continue;
376
+ if (!cleaned.endsWith("?")) continue;
377
+ const key = cleaned.toLowerCase();
378
+ if (seen.has(key)) continue;
379
+ seen.add(key);
380
+ suggestions.push(cleaned);
381
+ if (suggestions.length === wanted) break;
382
+ }
383
+ return suggestions;
384
+ }
385
+ function createFollowupsHandler(options) {
386
+ const wanted = options.wanted ?? 3;
387
+ const candidates = options.candidates ?? 5;
388
+ const timeoutMs = options.timeoutMs ?? 8e3;
389
+ const client = options.client ?? (options.apiKey ? new Anthropic({ apiKey: options.apiKey, timeout: timeoutMs, maxRetries: 0 }) : null);
390
+ const enabled = options.enabled ?? client !== null;
391
+ const system = options.system ?? followupsSystemPrompt({
392
+ about: options.about ?? "this product",
393
+ candidates,
394
+ examples: options.examples ?? DEFAULT_FOLLOWUP_EXAMPLES
395
+ });
396
+ const empty = () => Response.json({ suggestions: [] });
397
+ return async function followupsHandler(request) {
398
+ if (!enabled || !client) return empty();
399
+ let payload;
400
+ try {
401
+ payload = await request.json();
402
+ } catch {
403
+ return Response.json({ error: "invalid JSON" }, { status: 400 });
404
+ }
405
+ if (typeof payload !== "object" || payload === null) {
406
+ return Response.json({ error: "body must be an object" }, { status: 400 });
407
+ }
408
+ const { question, answer } = payload;
409
+ if (typeof question !== "string" || question.trim() === "") {
410
+ return Response.json({ error: "question must be a non-empty string" }, { status: 400 });
411
+ }
412
+ if (typeof answer !== "string" || answer.trim() === "") {
413
+ return Response.json({ error: "answer must be a non-empty string" }, { status: 400 });
414
+ }
415
+ if (options.guardrails?.checkFollowups && !await options.guardrails.checkFollowups(request)) {
416
+ return empty();
417
+ }
418
+ try {
419
+ const message = await client.messages.create(
420
+ {
421
+ model: options.model,
422
+ max_tokens: 200,
423
+ // Thinking would spend the small token cap before the questions are written.
424
+ thinking: { type: "disabled" },
425
+ system,
426
+ messages: [
427
+ {
428
+ role: "user",
429
+ content: `The visitor asked:
430
+ ${question.slice(0, 4e3)}
431
+
432
+ The assistant answered:
433
+ ${answer.slice(0, 4e3)}`
434
+ }
435
+ ]
436
+ },
437
+ // The visitor moving on stops this being billed; nothing waits on it.
438
+ { signal: request.signal }
439
+ );
440
+ await options.guardrails?.onUsage?.(message.usage, options.model).catch(() => {
441
+ });
442
+ const text = message.content.map((block) => block.type === "text" ? block.text : "").join("");
443
+ return Response.json({ suggestions: parseFollowups(text, wanted) });
444
+ } catch (error) {
445
+ if (!request.signal.aborted) console.error("[chat] follow-up suggestions failed:", error);
446
+ return empty();
447
+ }
448
+ };
449
+ }
450
+ const EXCHANGE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
451
+ function createFeedbackHandler(options = {}) {
452
+ const guardrails = options.guardrails ?? {};
453
+ return async function feedbackHandler(request) {
454
+ let payload;
455
+ try {
456
+ payload = await request.json();
457
+ } catch {
458
+ return Response.json({ error: "invalid JSON" }, { status: 400 });
459
+ }
460
+ if (typeof payload !== "object" || payload === null) {
461
+ return Response.json({ error: "body must be an object" }, { status: 400 });
462
+ }
463
+ const { id, verdict } = payload;
464
+ if (typeof id !== "string" || !EXCHANGE_ID.test(id)) {
465
+ return Response.json({ error: "id must be an exchange id" }, { status: 400 });
466
+ }
467
+ if (verdict !== "up" && verdict !== "down") {
468
+ return Response.json({ error: "verdict must be 'up' or 'down'" }, { status: 400 });
469
+ }
470
+ if (guardrails.checkFeedback && !await guardrails.checkFeedback(request)) {
471
+ return new Response(null, { status: 429 });
472
+ }
473
+ const visitor = await guardrails.visitorKey?.(request) ?? "anonymous";
474
+ await guardrails.onFeedback?.(id, verdict, visitor).catch(() => {
475
+ });
476
+ return new Response(null, { status: 204 });
477
+ };
478
+ }
479
+ export {
480
+ DEFAULT_CHAT_COPY,
481
+ DEFAULT_CHAT_LIMITS,
482
+ createChatHandler,
483
+ createFeedbackHandler,
484
+ createFollowupsHandler,
485
+ followupsSystemPrompt,
486
+ parseFollowups,
487
+ sanitisePath
488
+ };
package/chat/sim.d.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { ReactNode } from 'react';
2
+ import { ChatCard, ChatTransport } from './types';
3
+ /** One status beat shown while the scripted reply "thinks". */
4
+ export interface ScriptStep {
5
+ /** The AgentStatus label. */
6
+ status: string;
7
+ /** The reasoning trace point that collects under it. */
8
+ point?: string;
9
+ }
10
+ /** What a scripted assistant says back, and what rides with it. */
11
+ export interface ScriptedReply {
12
+ /** The status beats before the text, one per `statusMs`. */
13
+ steps?: ScriptStep[];
14
+ /** The reply, as markdown. */
15
+ text: string;
16
+ /** Rich content the provider's renderers draw with the turn. */
17
+ card?: ChatCard;
18
+ /** Rich content as a React node, for a script that lives beside its components. */
19
+ content?: ReactNode;
20
+ /** The chips offered under the reply. Each should be a scripted prompt so no path dead-ends. */
21
+ followups?: string[];
22
+ }
23
+ /** A prompt the visitor can send, and the reply it routes to. */
24
+ export interface ScriptNode extends ScriptedReply {
25
+ /** The exact chip text that routes here; matching forgives case and punctuation. */
26
+ prompt: string;
27
+ }
28
+ /** A whole scripted conversation, the input to `createSimTransport`. */
29
+ export interface ChatScript {
30
+ nodes: ScriptNode[];
31
+ /** Replies for free text that matches no prompt, rotated per exchange. */
32
+ fallbacks: ScriptedReply[];
33
+ /** The reply to a turn that carried files and matched no prompt. */
34
+ filesReply?: (names: string[]) => ScriptedReply;
35
+ }
36
+ /** The beats the player keeps. Schedule timings, not animation: reduced motion leaves them be. */
37
+ export interface SimTiming {
38
+ /** Milliseconds per status step. */
39
+ statusMs: number;
40
+ /** The pause between the last step and the first word. */
41
+ handoffMs: number;
42
+ /** Milliseconds per word of the reply. */
43
+ streamMs: number;
44
+ }
45
+ export declare const DEFAULT_SIM_TIMING: SimTiming;
46
+ /** The reply to a turn that carried files and matched no scripted prompt. */
47
+ export declare function defaultFilesReply(names: string[]): ScriptedReply;
48
+ /** What a script with no fallbacks says to free text. */
49
+ export declare const DEFAULT_FALLBACK_REPLY: ScriptedReply;
50
+ /**
51
+ * Plays a `ChatScript` as a transport.
52
+ *
53
+ * `followups` on a reply default to the first few scripted prompts that fit
54
+ * a chip, so a script never ends on a reply with nowhere to go.
55
+ */
56
+ export declare function createSimTransport(script: ChatScript, timing?: Partial<SimTiming>): ChatTransport;
57
+ export interface ChatScriptBuilder {
58
+ /** The next prompt the visitor can send. Must be followed by `.assistant()`. */
59
+ user(prompt: string): ChatScriptBuilder;
60
+ /** The reply to the last `.user()` prompt. */
61
+ assistant(text: string, options?: Omit<ScriptedReply, 'text'>): ChatScriptBuilder;
62
+ /** A reply for typed text that matches no prompt. Several rotate per exchange. */
63
+ fallback(text: string, options?: Omit<ScriptedReply, 'text'>): ChatScriptBuilder;
64
+ /** The reply to a turn that carried files and matched no prompt. */
65
+ onFiles(reply: (names: string[]) => ScriptedReply): ChatScriptBuilder;
66
+ /** The finished script, for `createSimTransport` or inspection. */
67
+ build(): ChatScript;
68
+ /** The finished script as a transport, ready for `ChatProvider`. */
69
+ transport(timing?: Partial<SimTiming>): ChatTransport;
70
+ }
71
+ export declare function scriptChat(): ChatScriptBuilder;
package/chat/sim.js ADDED
@@ -0,0 +1,125 @@
1
+ import { STARTER_COUNT } from "./starters.js";
2
+ import { fitsChip } from "./suggestions.js";
3
+ const DEFAULT_SIM_TIMING = {
4
+ statusMs: 1600,
5
+ handoffMs: 350,
6
+ streamMs: 30
7
+ };
8
+ const normalize = (text) => text.toLowerCase().replace(/[^a-z0-9 ]/g, "").replace(/\s+/g, " ").trim();
9
+ class SimAbortError extends Error {
10
+ constructor() {
11
+ super("Scripted response aborted");
12
+ this.name = "AbortError";
13
+ }
14
+ }
15
+ const delay = (ms, signal) => new Promise((resolve, reject) => {
16
+ if (signal.aborted) {
17
+ reject(new SimAbortError());
18
+ return;
19
+ }
20
+ const timer = setTimeout(() => {
21
+ signal.removeEventListener("abort", onAbort);
22
+ resolve();
23
+ }, ms);
24
+ const onAbort = () => {
25
+ clearTimeout(timer);
26
+ reject(new SimAbortError());
27
+ };
28
+ signal.addEventListener("abort", onAbort, { once: true });
29
+ });
30
+ function defaultFilesReply(names) {
31
+ const shown = names.slice(0, 3).join(", ");
32
+ const rest = names.length > 3 ? ` and ${names.length - 3} more` : "";
33
+ const one = names.length === 1;
34
+ return {
35
+ steps: [
36
+ {
37
+ status: one ? "Looking at the file" : "Looking at the files",
38
+ point: `${names.length} attached; note what each one is before answering.`
39
+ }
40
+ ],
41
+ text: `Got ${one ? "it" : "them"}: ${shown}${rest}. This chat is scripted, so ${one ? "the file" : "the files"} stayed in your browser and nothing was uploaded or read. A live assistant would answer from the contents here.`
42
+ };
43
+ }
44
+ const DEFAULT_FALLBACK_REPLY = {
45
+ steps: [{ status: "Reading the question", point: "Nothing scripted matches this one." }],
46
+ text: "This chat is a scripted preview, so it only has answers written for the suggestions it offers. Pick one of them to see the conversation play out."
47
+ };
48
+ function createSimTransport(script, timing = {}) {
49
+ const beats = { ...DEFAULT_SIM_TIMING, ...timing };
50
+ const byPrompt = new Map(script.nodes.map((node) => [normalize(node.prompt), node]));
51
+ const fallbacks = script.fallbacks.length > 0 ? script.fallbacks : [DEFAULT_FALLBACK_REPLY];
52
+ const filesReply = script.filesReply ?? defaultFilesReply;
53
+ const defaultFollowups = script.nodes.map((node) => node.prompt).filter(fitsChip).slice(0, STARTER_COUNT);
54
+ let exchangeCount = 0;
55
+ return async function* simTransport(messages, signal) {
56
+ const lastUser = [...messages].reverse().find((m) => m.role === "user");
57
+ const node = lastUser ? byPrompt.get(normalize(lastUser.content)) : void 0;
58
+ const attached = !node ? lastUser?.attachments ?? [] : [];
59
+ const reply = node ?? (attached.length > 0 ? filesReply(attached.map((file) => file.name)) : fallbacks[exchangeCount % fallbacks.length]);
60
+ if (!node && attached.length === 0) exchangeCount += 1;
61
+ for (const step of reply.steps ?? []) {
62
+ yield { type: "status", label: step.status, point: step.point };
63
+ await delay(beats.statusMs, signal);
64
+ }
65
+ await delay(beats.handoffMs, signal);
66
+ const words = reply.text.split(" ");
67
+ for (let i = 0; i < words.length; i += 1) {
68
+ yield { type: "delta", text: i === 0 ? words[i] : ` ${words[i]}` };
69
+ await delay(beats.streamMs, signal);
70
+ }
71
+ if (reply.content) yield { type: "content", node: reply.content };
72
+ if (reply.card) yield { type: "card", card: reply.card };
73
+ const followups = (reply.followups ?? defaultFollowups).filter(fitsChip);
74
+ if (followups.length > 0) yield { type: "followups", items: followups };
75
+ yield { type: "done" };
76
+ };
77
+ }
78
+ function scriptChat() {
79
+ const nodes = [];
80
+ const fallbacks = [];
81
+ let filesReply;
82
+ let pendingPrompt = null;
83
+ const builder = {
84
+ user(prompt) {
85
+ if (pendingPrompt !== null) {
86
+ throw new Error(`scriptChat: "${pendingPrompt}" has no .assistant() reply.`);
87
+ }
88
+ pendingPrompt = prompt;
89
+ return builder;
90
+ },
91
+ assistant(text, options = {}) {
92
+ if (pendingPrompt === null) {
93
+ throw new Error("scriptChat: .assistant() needs a .user() prompt before it.");
94
+ }
95
+ nodes.push({ prompt: pendingPrompt, text, ...options });
96
+ pendingPrompt = null;
97
+ return builder;
98
+ },
99
+ fallback(text, options = {}) {
100
+ fallbacks.push({ text, ...options });
101
+ return builder;
102
+ },
103
+ onFiles(reply) {
104
+ filesReply = reply;
105
+ return builder;
106
+ },
107
+ build() {
108
+ if (pendingPrompt !== null) {
109
+ throw new Error(`scriptChat: "${pendingPrompt}" has no .assistant() reply.`);
110
+ }
111
+ return { nodes: [...nodes], fallbacks: [...fallbacks], filesReply };
112
+ },
113
+ transport(timing) {
114
+ return createSimTransport(builder.build(), timing);
115
+ }
116
+ };
117
+ return builder;
118
+ }
119
+ export {
120
+ DEFAULT_FALLBACK_REPLY,
121
+ DEFAULT_SIM_TIMING,
122
+ createSimTransport,
123
+ defaultFilesReply,
124
+ scriptChat
125
+ };