rift-ds 1.7.0 → 1.9.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 (131) hide show
  1. package/README.md +31 -8
  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 +24 -0
  14. package/chat/ChatAgentPane.js +28 -0
  15. package/chat/ChatArtifactCard.d.ts +13 -0
  16. package/chat/ChatArtifactCard.js +21 -0
  17. package/chat/ChatArtifactPane.d.ts +18 -0
  18. package/chat/ChatArtifactPane.js +43 -0
  19. package/chat/ChatLauncher.d.ts +64 -0
  20. package/chat/ChatLauncher.js +347 -0
  21. package/chat/ChatProvider.d.ts +55 -0
  22. package/chat/ChatProvider.js +196 -0
  23. package/chat/ChatTabs.d.ts +13 -0
  24. package/chat/ChatTabs.js +24 -0
  25. package/chat/ChatThreads.d.ts +26 -0
  26. package/chat/ChatThreads.js +55 -0
  27. package/chat/ChatWidget.css +1259 -0
  28. package/chat/ChatWidget.d.ts +115 -0
  29. package/chat/ChatWidget.js +709 -0
  30. package/chat/DemoWelcome.d.ts +13 -0
  31. package/chat/DemoWelcome.js +131 -0
  32. package/chat/FollowupSuggestions.d.ts +23 -0
  33. package/chat/FollowupSuggestions.js +34 -0
  34. package/chat/ResponseActions.d.ts +18 -0
  35. package/chat/ResponseActions.js +79 -0
  36. package/chat/TurnContent.d.ts +11 -0
  37. package/chat/TurnContent.js +19 -0
  38. package/chat/artifacts.d.ts +5 -0
  39. package/chat/artifacts.js +14 -0
  40. package/chat/context.d.ts +82 -0
  41. package/chat/context.js +29 -0
  42. package/chat/demo-agent-panes.d.ts +5 -0
  43. package/chat/demo-agent-panes.js +49 -0
  44. package/chat/demo-agent.d.ts +3 -0
  45. package/chat/demo-agent.js +28 -0
  46. package/chat/demo-artifacts.d.ts +6 -0
  47. package/chat/demo-artifacts.js +80 -0
  48. package/chat/demo-card-components.d.ts +48 -0
  49. package/chat/demo-card-components.js +66 -0
  50. package/chat/demo-cards.d.ts +32 -0
  51. package/chat/demo-cards.js +59 -0
  52. package/chat/demo-fixtures.d.ts +20 -0
  53. package/chat/demo-fixtures.js +113 -0
  54. package/chat/demo-script.d.ts +6 -0
  55. package/chat/demo-script.js +237 -0
  56. package/chat/demo.css +52 -0
  57. package/chat/demo.d.ts +10 -0
  58. package/chat/demo.js +28 -0
  59. package/chat/followups.d.ts +41 -0
  60. package/chat/followups.js +55 -0
  61. package/chat/greeting.d.ts +11 -0
  62. package/chat/greeting.js +21 -0
  63. package/chat/index.d.ts +37 -0
  64. package/chat/index.js +90 -0
  65. package/chat/placement.d.ts +48 -0
  66. package/chat/placement.js +39 -0
  67. package/chat/queries.d.ts +18 -0
  68. package/chat/queries.js +26 -0
  69. package/chat/registry.d.ts +25 -0
  70. package/chat/registry.js +9 -0
  71. package/chat/registry.json.d.ts +74 -0
  72. package/chat/registry.json.js +11 -0
  73. package/chat/server.d.ts +217 -0
  74. package/chat/server.js +488 -0
  75. package/chat/sim.d.ts +71 -0
  76. package/chat/sim.js +125 -0
  77. package/chat/starters.d.ts +15 -0
  78. package/chat/starters.js +38 -0
  79. package/chat/suggestions.d.ts +22 -0
  80. package/chat/suggestions.js +9 -0
  81. package/chat/threads.d.ts +73 -0
  82. package/chat/threads.js +270 -0
  83. package/chat/transport.d.ts +49 -0
  84. package/chat/transport.js +105 -0
  85. package/chat/types.d.ts +246 -0
  86. package/chat/types.js +18 -0
  87. package/chat/upstash.d.ts +63 -0
  88. package/chat/upstash.js +147 -0
  89. package/chat/useChat.d.ts +64 -0
  90. package/chat/useChat.js +241 -0
  91. package/cli/templates/chat/app/api/chat/feedback/route.d.ts +3 -0
  92. package/cli/templates/chat/app/api/chat/followups/route.d.ts +3 -0
  93. package/cli/templates/chat/app/api/chat/guardrails.d.ts +10 -0
  94. package/cli/templates/chat/app/api/chat/route.d.ts +4 -0
  95. package/cli/templates/chat/components/chat/ChatMount.d.ts +1 -0
  96. package/cli/templates/chat/components/chat/models.d.ts +11 -0
  97. package/components/AgentRail/AgentRail.css +14 -5
  98. package/components/AgentRail/AgentRail.d.ts +2 -0
  99. package/components/AgentRail/AgentRail.js +3 -2
  100. package/components/ArtifactCard/ArtifactCard.css +106 -0
  101. package/components/ArtifactCard/ArtifactCard.d.ts +27 -0
  102. package/components/ArtifactCard/ArtifactCard.js +29 -0
  103. package/components/ArtifactPanel/ArtifactPanel.css +159 -0
  104. package/components/ArtifactPanel/ArtifactPanel.d.ts +70 -0
  105. package/components/ArtifactPanel/ArtifactPanel.js +131 -0
  106. package/components/ShaderField/ShaderField.d.ts +3 -2
  107. package/components/ShaderField/useShaderField.d.ts +2 -1
  108. package/components/ShaderField/useShaderField.js +6 -6
  109. package/components/ThreadPanel/ThreadPanel.css +87 -33
  110. package/components/ThreadPanel/ThreadPanel.js +21 -10
  111. package/components/registry.json +16 -0
  112. package/components/registry.json.d.ts +16 -0
  113. package/components/registry.json.js +1 -1
  114. package/fonts/material-symbols.css +2 -1
  115. package/index.d.ts +2 -0
  116. package/index.js +4 -0
  117. package/package.json +39 -2
  118. package/tokens/presets/coral.css +18 -2
  119. package/tokens/presets/forest.css +18 -2
  120. package/tokens/presets/gold.css +18 -2
  121. package/tokens/presets/mono.css +18 -2
  122. package/tokens/presets/pink.css +18 -2
  123. package/tokens/presets/terminal.css +18 -2
  124. package/tokens/presets/violet.css +18 -2
  125. package/tokens/presets/warm.css +18 -2
  126. package/tokens/presets/zest.css +18 -2
  127. package/tokens/registry.json +6 -0
  128. package/tokens/registry.json.d.ts +6 -0
  129. package/tokens/registry.json.js +1 -1
  130. package/tokens/tokens-dark.css +10 -0
  131. package/tokens/tokens-light.css +10 -0
@@ -0,0 +1,246 @@
1
+ import { ReactNode } from 'react';
2
+ import { AttachmentItem } from '../components/Attachment/fileTypes';
3
+ /**
4
+ * Rich content that can cross a network: a kind the host knows how to draw
5
+ * and the data to draw it from. The provider's `renderers` map turns one
6
+ * into a React node when the turn renders. A tool can answer with one from
7
+ * the server handler, and a script can attach one to a reply.
8
+ */
9
+ export interface ChatCard {
10
+ /** The renderer key, e.g. "chart" or "plan". Unknown kinds render nothing. */
11
+ kind: string;
12
+ /** Whatever the renderer for `kind` expects. Must survive JSON. */
13
+ data: unknown;
14
+ }
15
+ /**
16
+ * Something the chat made that is worth more room than a turn can give it: a
17
+ * dashboard, a document, a page, a site. It travels as an ordinary card of
18
+ * kind `ARTIFACT_CARD_KIND`, so any transport that can send a card can send
19
+ * one; the thread draws it as a tile, and the tile opens the artifact panel
20
+ * beside the conversation.
21
+ */
22
+ export interface ChatArtifact {
23
+ /** Stable identity: the same id reopened is the same artifact. */
24
+ id: string;
25
+ /** The artifact's name, on the tile and in the panel's header. */
26
+ title: string;
27
+ /**
28
+ * How to draw it. `html` frames the `html` document, `url` frames the
29
+ * `url`, `code` shows the `code` alone; any other kind is drawn by the
30
+ * provider's `artifactRenderers`. An unknown kind with no renderer falls
31
+ * back to whichever of `html`, `url` and `code` it carries.
32
+ */
33
+ kind: string;
34
+ /** A quiet line beside the name: what it is, or which version. */
35
+ meta?: string;
36
+ /** A Material Symbol name for the tile and the header. */
37
+ icon?: string;
38
+ /** A complete HTML document, drawn in a sandboxed frame. */
39
+ html?: string;
40
+ /** A page to frame. Only http(s) URLs and same-site paths are framed. */
41
+ url?: string;
42
+ /** The source, offered as the panel's Code view. An `html` artifact offers its document when this is absent. */
43
+ code?: string;
44
+ /** The source's language. */
45
+ language?: string;
46
+ /** Whatever a custom renderer for `kind` expects. Must survive JSON. */
47
+ data?: unknown;
48
+ }
49
+ /** The card kind an artifact travels as. Its `data` is a `ChatArtifact`. */
50
+ export declare const ARTIFACT_CARD_KIND = "artifact";
51
+ export type ChatEvent =
52
+ /** A live status step: the AgentStatus label, with an optional reasoning trace point. */
53
+ {
54
+ type: 'status';
55
+ label: string;
56
+ point?: string;
57
+ }
58
+ /** The model serving this exchange, as a display label ("Sonnet 5").
59
+ The server's per-answer ground truth; kept in state but not rendered
60
+ by the widget today, whose picker follows the `models` offer event. */
61
+ | {
62
+ type: 'model';
63
+ label: string;
64
+ }
65
+ /** What the server is offering right now: the wire value it defaults to,
66
+ and any values the day's budget has locked out. The picker follows
67
+ this: the server owns the default and the lock, the client only
68
+ expresses a preference. */
69
+ | {
70
+ type: 'models';
71
+ default: string;
72
+ locked: string[];
73
+ }
74
+ /** A chunk of response text. The first delta ends the thinking phase. */
75
+ | {
76
+ type: 'delta';
77
+ text: string;
78
+ }
79
+ /** The server-side log id for this exchange. Feedback references it, so a
80
+ thumbs verdict can be joined back to the logged question and answer.
81
+ A transport that never sends one hides the thumbs, which is what a
82
+ script does. */
83
+ | {
84
+ type: 'exchange';
85
+ id: string;
86
+ }
87
+ /** Follow-up questions to offer under the finished answer. A transport that
88
+ can produce them yields this before `done`; the fetch transport cannot
89
+ (they are written from the completed answer, so waiting for them would
90
+ hold the turn open) and the provider fetches them after the turn
91
+ commits instead. */
92
+ | {
93
+ type: 'followups';
94
+ items: string[];
95
+ }
96
+ /** Rich content to render with the committed turn, as a React node. Only
97
+ an in-browser transport can yield one (a script, or `injectTurn`); the
98
+ network form is `card`. */
99
+ | {
100
+ type: 'content';
101
+ node: ReactNode;
102
+ }
103
+ /** Rich content to render with the committed turn, as serialisable data
104
+ the provider's `renderers` draw. The form a server can send. */
105
+ | {
106
+ type: 'card';
107
+ card: ChatCard;
108
+ }
109
+ /** Terminal: the response is complete. */
110
+ | {
111
+ type: 'done';
112
+ }
113
+ /** A guardrail response (rate limit, circuit breaker): rendered as a normal assistant message. */
114
+ | {
115
+ type: 'notice';
116
+ text: string;
117
+ }
118
+ /** A failure the user should see, phrased for humans. */
119
+ | {
120
+ type: 'error';
121
+ message: string;
122
+ };
123
+ /** Every event kind, for documentation that enumerates the protocol. */
124
+ export declare const CHAT_EVENT_KINDS: readonly ["status", "model", "models", "delta", "exchange", "followups", "content", "card", "done", "notice", "error"];
125
+ export interface ChatTransportMessage {
126
+ role: 'user' | 'assistant';
127
+ content: string;
128
+ /**
129
+ * What was attached to a user message: names and kinds, never bytes. The
130
+ * scripted transport reads it to answer about the files. The fetch
131
+ * transport drops it before the request is built, so nothing about an
132
+ * attachment reaches the server.
133
+ */
134
+ attachments?: {
135
+ name: string;
136
+ kind: AttachmentItem['kind'];
137
+ size?: number;
138
+ }[];
139
+ }
140
+ /**
141
+ * A transport streams events for one exchange. Abort the signal to stop it.
142
+ *
143
+ * Contract notes for implementations:
144
+ * - The consumer `break`s out of its `for await` loop on `done` (and on
145
+ * abort), which invokes the iterator's `return()`: close any underlying
146
+ * reader or connection in a `try/finally`.
147
+ * - Check the signal between yields; the consumer also guards its own loop,
148
+ * but a transport that ignores abort keeps its own work running.
149
+ * - There is no first-event timeout on the consumer side: a transport that
150
+ * can hang before its first yield should implement its own watchdog.
151
+ */
152
+ export type ChatTransport = (messages: ChatTransportMessage[], signal: AbortSignal) => AsyncIterable<ChatEvent>;
153
+ /** One committed turn. Assistant turns keep their reasoning for the collapsed disclosure. */
154
+ export interface ChatTurn {
155
+ id: string;
156
+ role: 'user' | 'assistant';
157
+ text: string;
158
+ /** Reasoning trace points gathered while the response was live (assistant turns). */
159
+ tracePoints?: string[];
160
+ /** Seconds spent before the first response text (assistant turns). */
161
+ durationSeconds?: number;
162
+ /** The server's log id for the exchange, absent on scripted and placeholder turns. */
163
+ exchangeId?: string;
164
+ /** The visitor's thumbs verdict on this turn, kept for the session only. */
165
+ feedback?: 'up' | 'down';
166
+ /**
167
+ * Follow-up questions offered under this answer. Undefined means none have
168
+ * been asked for yet; an empty array means they were and none came back,
169
+ * which is what stops a failed attempt being retried on every render.
170
+ */
171
+ followups?: string[];
172
+ /**
173
+ * Files sent with a user turn, drawn above its bubble. Their picture
174
+ * previews are object URLs the hook owns from the moment of the send:
175
+ * they are revoked on reset and on unmount.
176
+ */
177
+ attachments?: AttachmentItem[];
178
+ /**
179
+ * Rich content rendered with the turn as a React node: a tool call, a
180
+ * card. Set by `injectTurn` or carried from a transport's `content` event.
181
+ * Assistant turns render it between the reasoning and the prose; user
182
+ * turns stack it above the bubble, like an attachment.
183
+ */
184
+ content?: ReactNode;
185
+ /**
186
+ * Rich content rendered with the turn through the provider's `renderers`,
187
+ * carried from a transport's `card` event. Drawn where `content` is.
188
+ */
189
+ card?: ChatCard;
190
+ /**
191
+ * Render `content` on its own in the thread, outside any message chrome,
192
+ * for conversation furniture like a day marker.
193
+ */
194
+ bare?: boolean;
195
+ }
196
+ export type LivePhase = 'thinking' | 'streaming';
197
+ /** The assistant turn currently in flight, rendered after the committed turns. */
198
+ export interface LiveResponse {
199
+ /**
200
+ * The turn's stable id, minted at send time and kept when the turn
201
+ * commits, so the rendered element keeps its identity and React
202
+ * reconciles the finished response in place instead of remounting it.
203
+ */
204
+ id: string;
205
+ phase: LivePhase;
206
+ /** Current AgentStatus label while thinking. */
207
+ statusLabel: string;
208
+ tracePoints: string[];
209
+ text: string;
210
+ durationSeconds?: number;
211
+ /** Carried from the transport's `exchange` event into the committed turn. */
212
+ exchangeId?: string;
213
+ /** Carried from the transport's `followups` event into the committed turn. */
214
+ followups?: string[];
215
+ /** Carried from the transport's `content` event into the committed turn. */
216
+ content?: ReactNode;
217
+ /** Carried from the transport's `card` event into the committed turn. */
218
+ card?: ChatCard;
219
+ }
220
+ /**
221
+ * One model the widget can offer in its picker and the server handler can
222
+ * serve. Shared by both halves so the wire value the client sends is one the
223
+ * server knows.
224
+ */
225
+ export interface ChatModelOption {
226
+ /** Stable wire value: what the picker holds and the client sends. */
227
+ value: string;
228
+ /** The provider's model id the server calls. */
229
+ id: string;
230
+ /** Display name in the picker. */
231
+ label: string;
232
+ /** One line under the name in the picker. */
233
+ description: string;
234
+ /**
235
+ * Whether the model takes adaptive thinking and the effort dial. A model
236
+ * that predates them rejects both, so the handler sends it a plain request.
237
+ */
238
+ adaptiveThinking: boolean;
239
+ }
240
+ /** One conversation starter on the welcome screen. */
241
+ export interface Starter {
242
+ id: string;
243
+ label: string;
244
+ }
245
+ /** The thumbs verdict a visitor can give an answer. */
246
+ export type ChatFeedbackVerdict = 'up' | 'down';
package/chat/types.js ADDED
@@ -0,0 +1,18 @@
1
+ const ARTIFACT_CARD_KIND = "artifact";
2
+ const CHAT_EVENT_KINDS = [
3
+ "status",
4
+ "model",
5
+ "models",
6
+ "delta",
7
+ "exchange",
8
+ "followups",
9
+ "content",
10
+ "card",
11
+ "done",
12
+ "notice",
13
+ "error"
14
+ ];
15
+ export {
16
+ ARTIFACT_CARD_KIND,
17
+ CHAT_EVENT_KINDS
18
+ };
@@ -0,0 +1,63 @@
1
+ import { ChatGuardrails } from './server';
2
+ /** List prices in USD per million tokens for one model id. */
3
+ export interface PriceRow {
4
+ input: number;
5
+ output: number;
6
+ /** Cache writes; 2x input for a 1-hour cache TTL, 1.25x for the default. */
7
+ cacheWrite: number;
8
+ cacheRead: number;
9
+ }
10
+ /**
11
+ * The built-in price table, keyed by model id. Over-estimating trips the
12
+ * breaker early, which is the safe direction to be wrong in; real billing
13
+ * comes from the provider's console. An unknown id prices at `fallbackPrice`.
14
+ */
15
+ export declare const DEFAULT_PRICES: Record<string, PriceRow>;
16
+ export interface UpstashGuardrailsCopy {
17
+ /** Too many messages in a minute. */
18
+ burst: string;
19
+ /** The per-visitor daily limit. */
20
+ daily: string;
21
+ /** The site-wide breaker. */
22
+ paused: string;
23
+ }
24
+ export declare const DEFAULT_GUARDRAILS_COPY: UpstashGuardrailsCopy;
25
+ export interface UpstashGuardrailsOptions {
26
+ /** The Upstash REST URL. Missing means every check fails open. */
27
+ url?: string;
28
+ /** The Upstash REST token. */
29
+ token?: string;
30
+ /** Salts the hashed visitor address. Set it: an unsalted hash over the IPv4 space is reversible. */
31
+ salt?: string;
32
+ /** Redis key prefix, so several chats can share one database. */
33
+ prefix?: string;
34
+ /** Messages per visitor per minute. */
35
+ perMinute?: number;
36
+ /** Messages per visitor per day. */
37
+ perDay?: number;
38
+ /** Follow-up requests per visitor per minute. */
39
+ followupsPerMinute?: number;
40
+ /** Feedback posts per visitor per minute. */
41
+ feedbackPerMinute?: number;
42
+ /** The site-wide daily message cap. */
43
+ dailyMessageCap?: number;
44
+ /** The site-wide daily spend cap, in US cents. */
45
+ dailySpendCapCents?: number;
46
+ /** Past this fraction of the spend cap the default model steps down. */
47
+ stepdownFraction?: number;
48
+ /** Past this fraction only the budget model serves. */
49
+ lockFraction?: number;
50
+ /** Price rows by model id, merged over the built-in table. */
51
+ prices?: Record<string, PriceRow>;
52
+ /** The row an unknown model id prices at. Defaults to the dearest built-in row. */
53
+ fallbackPrice?: PriceRow;
54
+ /** How long an exchange log line lives, in seconds. */
55
+ logTtlSeconds?: number;
56
+ copy?: Partial<UpstashGuardrailsCopy>;
57
+ }
58
+ /**
59
+ * Guardrails over Upstash Redis: per-visitor rate limits, a daily breaker
60
+ * with model tiers, the exchange log (30 days by default) and the feedback
61
+ * store. Pass the result as `guardrails` to all three handlers.
62
+ */
63
+ export declare function createUpstashGuardrails(options?: UpstashGuardrailsOptions): ChatGuardrails;
@@ -0,0 +1,147 @@
1
+ import { Ratelimit } from "@upstash/ratelimit";
2
+ import { Redis } from "@upstash/redis";
3
+ const DEFAULT_PRICES = {
4
+ "claude-sonnet-5": { input: 3, output: 15, cacheWrite: 6, cacheRead: 0.3 },
5
+ "claude-haiku-5-5": { input: 0.5, output: 2.5, cacheWrite: 1, cacheRead: 0.05 }
6
+ };
7
+ const DEFAULT_GUARDRAILS_COPY = {
8
+ burst: "That is a lot of questions at once. Give it a minute and ask again.",
9
+ daily: "You have reached the daily limit for this chat. It resets tomorrow.",
10
+ paused: "This chat has hit its budget for today and is paused until midnight UTC."
11
+ };
12
+ const dayKey = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
13
+ const COUNTER_TTL_SECONDS = 60 * 60 * 48;
14
+ async function sha256Hex(text) {
15
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
16
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
17
+ }
18
+ function createUpstashGuardrails(options = {}) {
19
+ const prefix = options.prefix ?? "chat";
20
+ const perMinute = options.perMinute ?? 10;
21
+ const perDay = options.perDay ?? 60;
22
+ const followupsPerMinute = options.followupsPerMinute ?? 15;
23
+ const feedbackPerMinute = options.feedbackPerMinute ?? 30;
24
+ const dailyMessageCap = options.dailyMessageCap ?? 300;
25
+ const dailySpendCapTenths = (options.dailySpendCapCents ?? 500) * 10;
26
+ const stepdownFraction = options.stepdownFraction ?? 0.5;
27
+ const lockFraction = options.lockFraction ?? 0.85;
28
+ const prices = { ...DEFAULT_PRICES, ...options.prices };
29
+ const fallbackPrice = options.fallbackPrice ?? DEFAULT_PRICES["claude-sonnet-5"];
30
+ const logTtl = options.logTtlSeconds ?? 60 * 60 * 24 * 30;
31
+ const copy = { ...DEFAULT_GUARDRAILS_COPY, ...options.copy };
32
+ const salt = options.salt ?? "";
33
+ let warned = false;
34
+ const redis = options.url && options.token ? new Redis({ url: options.url, token: options.token }) : null;
35
+ const getRedis = () => {
36
+ if (!redis && !warned) {
37
+ warned = true;
38
+ console.warn("[chat] Upstash url/token unset: rate limits and the daily breaker are inactive.");
39
+ }
40
+ return redis;
41
+ };
42
+ const limiter = (instance, name, limit, window) => new Ratelimit({
43
+ redis: instance,
44
+ limiter: Ratelimit.slidingWindow(limit, window),
45
+ prefix: `${prefix}:rl:${name}`,
46
+ analytics: false
47
+ });
48
+ const messagesKey = () => `${prefix}:day:${dayKey()}:messages`;
49
+ const spendKey = () => `${prefix}:day:${dayKey()}:tenths-cents`;
50
+ const logKey = () => `${prefix}:log:${dayKey()}`;
51
+ const feedbackKey = (id) => `${prefix}:feedback:${id}`;
52
+ const visitorKey = async (request) => {
53
+ const forwarded = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
54
+ const ip = forwarded || request.headers.get("x-real-ip")?.trim() || "local";
55
+ return (await sha256Hex(salt + ip)).slice(0, 32);
56
+ };
57
+ const ALLOWED = { allowed: true, tier: "open" };
58
+ const check = async (request) => {
59
+ const instance = getRedis();
60
+ if (!instance) return ALLOWED;
61
+ try {
62
+ const visitor = await visitorKey(request);
63
+ const [burst, daily] = await Promise.all([
64
+ limiter(instance, "burst", perMinute, "60 s").limit(visitor),
65
+ limiter(instance, "day", perDay, "24 h").limit(visitor)
66
+ ]);
67
+ if (!burst.success) return { allowed: false, notice: copy.burst };
68
+ if (!daily.success) return { allowed: false, notice: copy.daily };
69
+ const [messages, spendTenths] = await Promise.all([
70
+ instance.get(messagesKey()),
71
+ instance.get(spendKey())
72
+ ]);
73
+ if ((messages ?? 0) >= dailyMessageCap || (spendTenths ?? 0) >= dailySpendCapTenths) {
74
+ return { allowed: false, notice: copy.paused };
75
+ }
76
+ const count = await instance.incr(messagesKey());
77
+ if (count === 1) await instance.expire(messagesKey(), COUNTER_TTL_SECONDS);
78
+ const spentFraction = (spendTenths ?? 0) / dailySpendCapTenths;
79
+ const tier = spentFraction >= lockFraction ? "locked" : spentFraction >= stepdownFraction ? "reduced" : "open";
80
+ return { allowed: true, tier };
81
+ } catch (error) {
82
+ console.error("[chat] guardrail check failed, allowing the request:", error);
83
+ return ALLOWED;
84
+ }
85
+ };
86
+ const onUsage = async (usage, modelId) => {
87
+ const instance = getRedis();
88
+ if (!instance) return;
89
+ const price = prices[modelId] ?? fallbackPrice;
90
+ const usd = ((usage.input_tokens ?? 0) * price.input + (usage.output_tokens ?? 0) * price.output + (usage.cache_creation_input_tokens ?? 0) * price.cacheWrite + (usage.cache_read_input_tokens ?? 0) * price.cacheRead) / 1e6;
91
+ const tenths = Math.ceil(usd * 1e3);
92
+ if (tenths <= 0) return;
93
+ try {
94
+ const total = await instance.incrby(spendKey(), tenths);
95
+ if (total === tenths) await instance.expire(spendKey(), COUNTER_TTL_SECONDS);
96
+ } catch (error) {
97
+ console.error("[chat] recording spend failed:", error);
98
+ }
99
+ };
100
+ const onExchange = async (entry) => {
101
+ const instance = getRedis();
102
+ if (!instance) return;
103
+ try {
104
+ const line = JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
105
+ const length = await instance.rpush(logKey(), line);
106
+ if (length === 1) await instance.expire(logKey(), logTtl);
107
+ } catch (error) {
108
+ console.error("[chat] recording exchange failed:", error);
109
+ }
110
+ };
111
+ const perVisitorLimit = (name, limit) => async (request) => {
112
+ const instance = getRedis();
113
+ if (!instance) return true;
114
+ try {
115
+ const { success } = await limiter(instance, name, limit, "60 s").limit(await visitorKey(request));
116
+ return success;
117
+ } catch (error) {
118
+ console.error(`[chat] ${name} limit check failed, allowing:`, error);
119
+ return true;
120
+ }
121
+ };
122
+ const onFeedback = async (id, verdict, visitor) => {
123
+ const instance = getRedis();
124
+ if (!instance) return;
125
+ try {
126
+ await instance.set(feedbackKey(id), JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), verdict, visitor }), {
127
+ ex: logTtl
128
+ });
129
+ } catch (error) {
130
+ console.error("[chat] recording feedback failed:", error);
131
+ }
132
+ };
133
+ return {
134
+ check,
135
+ visitorKey,
136
+ onUsage,
137
+ onExchange,
138
+ checkFollowups: perVisitorLimit("followups", followupsPerMinute),
139
+ checkFeedback: perVisitorLimit("feedback", feedbackPerMinute),
140
+ onFeedback
141
+ };
142
+ }
143
+ export {
144
+ DEFAULT_GUARDRAILS_COPY,
145
+ DEFAULT_PRICES,
146
+ createUpstashGuardrails
147
+ };
@@ -0,0 +1,64 @@
1
+ import { ReactNode } from 'react';
2
+ import { AttachmentItem } from '../components/Attachment/fileTypes';
3
+ import { ChatTransport, ChatTurn, LiveResponse } from './types';
4
+ /** The copy the hook shows when a transport ends a turn with nothing to show. */
5
+ export interface ChatPlaceholderCopy {
6
+ /** A completion that produced no text. */
7
+ empty: string;
8
+ /** The reader stopped the response before any text arrived. */
9
+ stopped: string;
10
+ /** The transport failed before any text arrived. */
11
+ failed: string;
12
+ }
13
+ export declare const DEFAULT_PLACEHOLDER_COPY: ChatPlaceholderCopy;
14
+ /** What `useChat` returns: the conversation and the actions that move it. */
15
+ export interface ChatState {
16
+ /** The committed turns, oldest first. */
17
+ turns: ChatTurn[];
18
+ /** The assistant turn in flight, or null between exchanges. */
19
+ live: LiveResponse | null;
20
+ /** Whether a response is in flight. */
21
+ streaming: boolean;
22
+ /** Display label of the model the transport last reported, null before the first exchange. */
23
+ modelLabel: string | null;
24
+ /** The transport's current model offer (default + locked values), null before it first reports. */
25
+ modelPolicy: {
26
+ default: string;
27
+ locked: string[];
28
+ } | null;
29
+ /**
30
+ * Send a message. Returns whether the send was accepted: false while a
31
+ * response is in flight or for an empty draft, so the caller knows not to
32
+ * clear its input. Files alone are a whole message, so an empty draft is
33
+ * accepted when there are some.
34
+ */
35
+ send: (text: string, options?: {
36
+ content?: ReactNode;
37
+ attachments?: AttachmentItem[];
38
+ }) => boolean;
39
+ /** Stop the in-flight response, keeping the text that already arrived. */
40
+ stop: () => void;
41
+ /** New chat: abort anything in flight and clear the conversation. */
42
+ reset: () => void;
43
+ /**
44
+ * Append a committed turn directly, bypassing the transport. Injected turns
45
+ * are stage dressing: they never enter the transport history, so a live
46
+ * model is never fed words it did not say.
47
+ */
48
+ injectTurn: (turn: Omit<ChatTurn, 'id'>) => void;
49
+ /** Record the visitor's thumbs verdict on a committed turn. State only: the caller tells the server. */
50
+ setTurnFeedback: (turnId: string, feedback: 'up' | 'down') => void;
51
+ /**
52
+ * Attach follow-up questions to a committed turn. State only: the caller
53
+ * produces them. Writing an empty array records that the attempt was made
54
+ * and came back with nothing, so it is never repeated for that turn.
55
+ */
56
+ setTurnFollowups: (turnId: string, followups: string[]) => void;
57
+ }
58
+ /**
59
+ * useChat owns the conversation: the committed turns, the assistant turn in
60
+ * flight, and the phase machine that drives the AgentStatus, Reasoning and
61
+ * streamed-text choreography. It knows nothing about rendering or about
62
+ * where events come from; the transport decides that.
63
+ */
64
+ export declare function useChat(transport: ChatTransport, placeholders?: ChatPlaceholderCopy): ChatState;