@elabs-ai/components-ai 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +133 -0
- package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
- package/dist/_flow-boundary-D63PJ65S.js +186 -0
- package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
- package/dist/_persona-rive-RFR2EUWP.js +164 -0
- package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
- package/dist/index.d.ts +4079 -0
- package/dist/index.js +11770 -0
- package/dist/index.js.map +1 -0
- package/package.json +87 -0
- package/src/_audio-player-media-chrome.tsx +146 -0
- package/src/_chat-shell-rail.tsx +18 -0
- package/src/_code-block-theme.test.ts +134 -0
- package/src/_code-block-theme.ts +184 -0
- package/src/_flow-boundary.tsx +237 -0
- package/src/_flow-lazy.ts +34 -0
- package/src/_interactive-terminal-xterm.ts +32 -0
- package/src/_lazy-mermaid.test.ts +101 -0
- package/src/_lazy-mermaid.ts +117 -0
- package/src/_persona-rive.tsx +252 -0
- package/src/_streamdown-i18n.ts +119 -0
- package/src/agent-timeline.stories.tsx +66 -0
- package/src/agent-timeline.test.tsx +74 -0
- package/src/agent-timeline.tsx +106 -0
- package/src/agent.tsx +113 -0
- package/src/agentic-workspace.stories.tsx +1099 -0
- package/src/artifact.stories.tsx +62 -0
- package/src/artifact.test.tsx +112 -0
- package/src/artifact.tsx +167 -0
- package/src/asset-preview.stories.tsx +100 -0
- package/src/asset-preview.test.tsx +126 -0
- package/src/asset-preview.tsx +218 -0
- package/src/attachments.tsx +393 -0
- package/src/audio-player.stories.tsx +99 -0
- package/src/audio-player.tsx +170 -0
- package/src/blocks-ai-composer.stories.tsx +83 -0
- package/src/canvas.stories.tsx +142 -0
- package/src/canvas.tsx +29 -0
- package/src/chain-of-thought.tsx +193 -0
- package/src/chat-greeting.stories.tsx +96 -0
- package/src/chat-greeting.test.tsx +91 -0
- package/src/chat-greeting.tsx +91 -0
- package/src/chat-shell.stories.tsx +123 -0
- package/src/chat-shell.test.tsx +113 -0
- package/src/chat-shell.tsx +97 -0
- package/src/chat.stories.tsx +99 -0
- package/src/checkpoint.tsx +54 -0
- package/src/code-block.stories.tsx +81 -0
- package/src/code-block.test.tsx +191 -0
- package/src/code-block.tsx +673 -0
- package/src/commit.tsx +343 -0
- package/src/composer.stories.tsx +188 -0
- package/src/composer.test.tsx +182 -0
- package/src/composer.tsx +202 -0
- package/src/confirmation.stories.tsx +61 -0
- package/src/confirmation.test.tsx +136 -0
- package/src/confirmation.tsx +273 -0
- package/src/connection.tsx +23 -0
- package/src/context-panel.stories.tsx +137 -0
- package/src/context-panel.test.tsx +198 -0
- package/src/context-panel.tsx +609 -0
- package/src/context.stories.tsx +16 -0
- package/src/context.tsx +363 -0
- package/src/controls.tsx +20 -0
- package/src/conversation.stories.tsx +26 -0
- package/src/conversation.test.tsx +123 -0
- package/src/conversation.tsx +143 -0
- package/src/dark-theme-variant.test.ts +47 -0
- package/src/edge.tsx +33 -0
- package/src/environment-variables.tsx +304 -0
- package/src/file-tree.stories.tsx +55 -0
- package/src/file-tree.test.tsx +101 -0
- package/src/file-tree.tsx +390 -0
- package/src/gallery.stories.tsx +182 -0
- package/src/gallery.test.tsx +150 -0
- package/src/gallery.tsx +737 -0
- package/src/grouped-parts.stories.tsx +104 -0
- package/src/grouped-parts.test.tsx +167 -0
- package/src/grouped-parts.tsx +247 -0
- package/src/image.stories.tsx +67 -0
- package/src/image.test.tsx +105 -0
- package/src/image.tsx +115 -0
- package/src/index.ts +93 -0
- package/src/inline-citation.stories.tsx +91 -0
- package/src/inline-citation.test.tsx +96 -0
- package/src/inline-citation.tsx +314 -0
- package/src/interactive-terminal.stories.tsx +165 -0
- package/src/interactive-terminal.test.tsx +448 -0
- package/src/interactive-terminal.tsx +444 -0
- package/src/jsx-preview.stories.tsx +265 -0
- package/src/jsx-preview.test.tsx +75 -0
- package/src/jsx-preview.tsx +409 -0
- package/src/markdown-view.stories.tsx +57 -0
- package/src/markdown-view.test.tsx +36 -0
- package/src/markdown-view.tsx +113 -0
- package/src/message-edit.stories.tsx +150 -0
- package/src/message-edit.test.tsx +115 -0
- package/src/message-edit.tsx +336 -0
- package/src/message-feedback.stories.tsx +53 -0
- package/src/message-feedback.test.tsx +62 -0
- package/src/message-feedback.tsx +117 -0
- package/src/message-form-spec.ts +349 -0
- package/src/message-form.stories.tsx +182 -0
- package/src/message-form.test.tsx +227 -0
- package/src/message-form.tsx +845 -0
- package/src/message-table-spec.ts +281 -0
- package/src/message-table.stories.tsx +176 -0
- package/src/message-table.test.tsx +144 -0
- package/src/message-table.tsx +366 -0
- package/src/message.stories.tsx +262 -0
- package/src/message.test.tsx +508 -0
- package/src/message.tsx +619 -0
- package/src/mic-selector.tsx +339 -0
- package/src/microcopy.test.tsx +123 -0
- package/src/model-selector.stories.tsx +73 -0
- package/src/model-selector.test.tsx +55 -0
- package/src/model-selector.tsx +225 -0
- package/src/motion-config.tsx +49 -0
- package/src/node.tsx +71 -0
- package/src/open-in-chat.tsx +340 -0
- package/src/package-info.tsx +205 -0
- package/src/panel.tsx +20 -0
- package/src/part-groups.ts +258 -0
- package/src/persona-sources.ts +59 -0
- package/src/persona.stories.tsx +111 -0
- package/src/persona.test.tsx +67 -0
- package/src/persona.tsx +148 -0
- package/src/plan.tsx +135 -0
- package/src/prompt-input.stories.tsx +261 -0
- package/src/prompt-input.test.tsx +392 -0
- package/src/prompt-input.tsx +1668 -0
- package/src/queue.tsx +237 -0
- package/src/reasoning.stories.tsx +81 -0
- package/src/reasoning.test.tsx +50 -0
- package/src/reasoning.tsx +229 -0
- package/src/sandbox.stories.tsx +62 -0
- package/src/sandbox.test.tsx +42 -0
- package/src/sandbox.tsx +149 -0
- package/src/schema-display.tsx +412 -0
- package/src/selection-toolbar.stories.tsx +86 -0
- package/src/selection-toolbar.test.tsx +89 -0
- package/src/selection-toolbar.tsx +192 -0
- package/src/shimmer.stories.tsx +10 -0
- package/src/shimmer.tsx +81 -0
- package/src/snippet.stories.tsx +19 -0
- package/src/snippet.test.tsx +116 -0
- package/src/snippet.tsx +138 -0
- package/src/sources.stories.tsx +34 -0
- package/src/sources.test.tsx +129 -0
- package/src/sources.tsx +93 -0
- package/src/speech-input.tsx +312 -0
- package/src/stack-trace.tsx +479 -0
- package/src/streamdown-i18n.test.tsx +163 -0
- package/src/suggestion.stories.tsx +75 -0
- package/src/suggestion.test.tsx +72 -0
- package/src/suggestion.tsx +120 -0
- package/src/task.stories.tsx +22 -0
- package/src/task.test.tsx +36 -0
- package/src/task.tsx +89 -0
- package/src/templates-ai-assistant.stories.tsx +142 -0
- package/src/terminal.tsx +244 -0
- package/src/test-results.stories.tsx +177 -0
- package/src/test-results.test.tsx +241 -0
- package/src/test-results.tsx +408 -0
- package/src/tool-result-card.stories.tsx +143 -0
- package/src/tool-result-card.test.tsx +72 -0
- package/src/tool-result-card.tsx +86 -0
- package/src/tool.stories.tsx +60 -0
- package/src/tool.test.tsx +63 -0
- package/src/tool.tsx +235 -0
- package/src/toolbar.tsx +44 -0
- package/src/transcription.tsx +118 -0
- package/src/voice-selector.tsx +469 -0
- package/src/web-preview.stories.tsx +67 -0
- package/src/web-preview.test.tsx +117 -0
- package/src/web-preview.tsx +283 -0
package/src/context.tsx
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@elabs-ai/components-ui";
|
|
4
|
+
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@elabs-ai/components-ui";
|
|
5
|
+
import { Progress } from "@elabs-ai/components-ui";
|
|
6
|
+
import { useLocale } from "@elabs-ai/components-ui";
|
|
7
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
8
|
+
import type { LanguageModelUsage } from "ai";
|
|
9
|
+
import type { ComponentProps } from "react";
|
|
10
|
+
import { createContext, useContext, useMemo } from "react";
|
|
11
|
+
import { getUsage } from "tokenlens";
|
|
12
|
+
|
|
13
|
+
const PERCENT_MAX = 100;
|
|
14
|
+
const ICON_RADIUS = 10;
|
|
15
|
+
const ICON_VIEWBOX = 24;
|
|
16
|
+
const ICON_CENTER = 12;
|
|
17
|
+
const ICON_STROKE_WIDTH = 2;
|
|
18
|
+
|
|
19
|
+
type ModelId = string;
|
|
20
|
+
|
|
21
|
+
interface ContextSchema {
|
|
22
|
+
usedTokens: number;
|
|
23
|
+
maxTokens: number;
|
|
24
|
+
usage?: LanguageModelUsage;
|
|
25
|
+
modelId?: ModelId;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ContextContext = createContext<ContextSchema | null>(null);
|
|
29
|
+
|
|
30
|
+
const useContextValue = () => {
|
|
31
|
+
const context = useContext(ContextContext);
|
|
32
|
+
|
|
33
|
+
if (!context) {
|
|
34
|
+
throw new Error("Context components must be used within Context");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return context;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema;
|
|
41
|
+
|
|
42
|
+
export const Context = ({ usedTokens, maxTokens, usage, modelId, ...props }: ContextProps) => {
|
|
43
|
+
const contextValue = useMemo(
|
|
44
|
+
() => ({ maxTokens, modelId, usage, usedTokens }),
|
|
45
|
+
[maxTokens, modelId, usage, usedTokens],
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<ContextContext.Provider value={contextValue}>
|
|
50
|
+
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
|
51
|
+
</ContextContext.Provider>
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const ContextIcon = () => {
|
|
56
|
+
const { t } = useLocale();
|
|
57
|
+
const { usedTokens, maxTokens } = useContextValue();
|
|
58
|
+
const circumference = 2 * Math.PI * ICON_RADIUS;
|
|
59
|
+
const usedPercent = usedTokens / maxTokens;
|
|
60
|
+
const dashOffset = circumference * (1 - usedPercent);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<svg
|
|
64
|
+
aria-label={t("ai.context.usage")}
|
|
65
|
+
height="20"
|
|
66
|
+
role="img"
|
|
67
|
+
style={{ color: "currentcolor" }}
|
|
68
|
+
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
|
|
69
|
+
width="20"
|
|
70
|
+
>
|
|
71
|
+
<circle
|
|
72
|
+
cx={ICON_CENTER}
|
|
73
|
+
cy={ICON_CENTER}
|
|
74
|
+
fill="none"
|
|
75
|
+
opacity="0.25"
|
|
76
|
+
r={ICON_RADIUS}
|
|
77
|
+
stroke="currentColor"
|
|
78
|
+
strokeWidth={ICON_STROKE_WIDTH}
|
|
79
|
+
/>
|
|
80
|
+
<circle
|
|
81
|
+
cx={ICON_CENTER}
|
|
82
|
+
cy={ICON_CENTER}
|
|
83
|
+
fill="none"
|
|
84
|
+
opacity="0.7"
|
|
85
|
+
r={ICON_RADIUS}
|
|
86
|
+
stroke="currentColor"
|
|
87
|
+
strokeDasharray={`${circumference} ${circumference}`}
|
|
88
|
+
strokeDashoffset={dashOffset}
|
|
89
|
+
strokeLinecap="round"
|
|
90
|
+
strokeWidth={ICON_STROKE_WIDTH}
|
|
91
|
+
style={{ transform: "rotate(-90deg)", transformOrigin: "center" }}
|
|
92
|
+
/>
|
|
93
|
+
</svg>
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export type ContextTriggerProps = ComponentProps<typeof Button>;
|
|
98
|
+
|
|
99
|
+
export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
|
|
100
|
+
const { formatNumber } = useLocale();
|
|
101
|
+
const { usedTokens, maxTokens } = useContextValue();
|
|
102
|
+
const usedPercent = usedTokens / maxTokens;
|
|
103
|
+
const renderedPercent = formatNumber(usedPercent, {
|
|
104
|
+
maximumFractionDigits: 1,
|
|
105
|
+
style: "percent",
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<HoverCardTrigger asChild>
|
|
110
|
+
{children ?? (
|
|
111
|
+
<Button type="button" variant="ghost" {...props}>
|
|
112
|
+
<span className="font-medium text-muted-foreground">{renderedPercent}</span>
|
|
113
|
+
<ContextIcon />
|
|
114
|
+
</Button>
|
|
115
|
+
)}
|
|
116
|
+
</HoverCardTrigger>
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export type ContextContentProps = ComponentProps<typeof HoverCardContent>;
|
|
121
|
+
|
|
122
|
+
export const ContextContent = ({ className, ...props }: ContextContentProps) => (
|
|
123
|
+
<HoverCardContent className={cn("min-w-60 divide-y overflow-hidden p-0", className)} {...props} />
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
export type ContextContentHeaderProps = ComponentProps<"div">;
|
|
127
|
+
|
|
128
|
+
export const ContextContentHeader = ({
|
|
129
|
+
children,
|
|
130
|
+
className,
|
|
131
|
+
...props
|
|
132
|
+
}: ContextContentHeaderProps) => {
|
|
133
|
+
const { formatNumber } = useLocale();
|
|
134
|
+
const { usedTokens, maxTokens } = useContextValue();
|
|
135
|
+
const usedPercent = usedTokens / maxTokens;
|
|
136
|
+
const displayPct = formatNumber(usedPercent, {
|
|
137
|
+
maximumFractionDigits: 1,
|
|
138
|
+
style: "percent",
|
|
139
|
+
});
|
|
140
|
+
const used = formatNumber(usedTokens, {
|
|
141
|
+
notation: "compact",
|
|
142
|
+
});
|
|
143
|
+
const total = formatNumber(maxTokens, {
|
|
144
|
+
notation: "compact",
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div className={cn("w-full space-y-2 p-3", className)} {...props}>
|
|
149
|
+
{children ?? (
|
|
150
|
+
<>
|
|
151
|
+
<div className="flex items-center justify-between gap-3 text-xs">
|
|
152
|
+
<p>{displayPct}</p>
|
|
153
|
+
<p className="font-mono text-muted-foreground">
|
|
154
|
+
{used} / {total}
|
|
155
|
+
</p>
|
|
156
|
+
</div>
|
|
157
|
+
<div className="space-y-2">
|
|
158
|
+
<Progress className="bg-muted" value={usedPercent * PERCENT_MAX} />
|
|
159
|
+
</div>
|
|
160
|
+
</>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export type ContextContentBodyProps = ComponentProps<"div">;
|
|
167
|
+
|
|
168
|
+
export const ContextContentBody = ({ children, className, ...props }: ContextContentBodyProps) => (
|
|
169
|
+
<div className={cn("w-full p-3", className)} {...props}>
|
|
170
|
+
{children}
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
export type ContextContentFooterProps = ComponentProps<"div">;
|
|
175
|
+
|
|
176
|
+
export const ContextContentFooter = ({
|
|
177
|
+
children,
|
|
178
|
+
className,
|
|
179
|
+
...props
|
|
180
|
+
}: ContextContentFooterProps) => {
|
|
181
|
+
const { formatNumber } = useLocale();
|
|
182
|
+
const { modelId, usage } = useContextValue();
|
|
183
|
+
const costUSD = modelId
|
|
184
|
+
? getUsage({
|
|
185
|
+
modelId,
|
|
186
|
+
usage: {
|
|
187
|
+
input: usage?.inputTokens ?? 0,
|
|
188
|
+
output: usage?.outputTokens ?? 0,
|
|
189
|
+
},
|
|
190
|
+
}).costUSD?.totalUSD
|
|
191
|
+
: undefined;
|
|
192
|
+
const totalCost = formatNumber(costUSD ?? 0, {
|
|
193
|
+
currency: "USD",
|
|
194
|
+
style: "currency",
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<div
|
|
199
|
+
className={cn(
|
|
200
|
+
"flex w-full items-center justify-between gap-3 bg-secondary p-3 text-xs",
|
|
201
|
+
className,
|
|
202
|
+
)}
|
|
203
|
+
{...props}
|
|
204
|
+
>
|
|
205
|
+
{children ?? (
|
|
206
|
+
<>
|
|
207
|
+
<span className="text-muted-foreground">Total cost</span>
|
|
208
|
+
<span>{totalCost}</span>
|
|
209
|
+
</>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const TokensWithCost = ({ tokens, costText }: { tokens?: number; costText?: string }) => {
|
|
216
|
+
const { formatNumber } = useLocale();
|
|
217
|
+
return (
|
|
218
|
+
<span>
|
|
219
|
+
{tokens === undefined ? "—" : formatNumber(tokens, { notation: "compact" })}
|
|
220
|
+
{costText ? <span className="ms-2 text-muted-foreground">• {costText}</span> : null}
|
|
221
|
+
</span>
|
|
222
|
+
);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
export type ContextInputUsageProps = ComponentProps<"div">;
|
|
226
|
+
|
|
227
|
+
export const ContextInputUsage = ({ className, children, ...props }: ContextInputUsageProps) => {
|
|
228
|
+
const { formatNumber } = useLocale();
|
|
229
|
+
const { usage, modelId } = useContextValue();
|
|
230
|
+
const inputTokens = usage?.inputTokens ?? 0;
|
|
231
|
+
|
|
232
|
+
if (children) {
|
|
233
|
+
return children;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!inputTokens) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const inputCost = modelId
|
|
241
|
+
? getUsage({
|
|
242
|
+
modelId,
|
|
243
|
+
usage: { input: inputTokens, output: 0 },
|
|
244
|
+
}).costUSD?.totalUSD
|
|
245
|
+
: undefined;
|
|
246
|
+
const inputCostText = formatNumber(inputCost ?? 0, {
|
|
247
|
+
currency: "USD",
|
|
248
|
+
style: "currency",
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<div className={cn("flex items-center justify-between text-xs", className)} {...props}>
|
|
253
|
+
<span className="text-muted-foreground">Input</span>
|
|
254
|
+
<TokensWithCost costText={inputCostText} tokens={inputTokens} />
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
export type ContextOutputUsageProps = ComponentProps<"div">;
|
|
260
|
+
|
|
261
|
+
export const ContextOutputUsage = ({ className, children, ...props }: ContextOutputUsageProps) => {
|
|
262
|
+
const { formatNumber } = useLocale();
|
|
263
|
+
const { usage, modelId } = useContextValue();
|
|
264
|
+
const outputTokens = usage?.outputTokens ?? 0;
|
|
265
|
+
|
|
266
|
+
if (children) {
|
|
267
|
+
return children;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!outputTokens) {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const outputCost = modelId
|
|
275
|
+
? getUsage({
|
|
276
|
+
modelId,
|
|
277
|
+
usage: { input: 0, output: outputTokens },
|
|
278
|
+
}).costUSD?.totalUSD
|
|
279
|
+
: undefined;
|
|
280
|
+
const outputCostText = formatNumber(outputCost ?? 0, {
|
|
281
|
+
currency: "USD",
|
|
282
|
+
style: "currency",
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
return (
|
|
286
|
+
<div className={cn("flex items-center justify-between text-xs", className)} {...props}>
|
|
287
|
+
<span className="text-muted-foreground">Output</span>
|
|
288
|
+
<TokensWithCost costText={outputCostText} tokens={outputTokens} />
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
export type ContextReasoningUsageProps = ComponentProps<"div">;
|
|
294
|
+
|
|
295
|
+
export const ContextReasoningUsage = ({
|
|
296
|
+
className,
|
|
297
|
+
children,
|
|
298
|
+
...props
|
|
299
|
+
}: ContextReasoningUsageProps) => {
|
|
300
|
+
const { formatNumber } = useLocale();
|
|
301
|
+
const { usage, modelId } = useContextValue();
|
|
302
|
+
const reasoningTokens = usage?.reasoningTokens ?? 0;
|
|
303
|
+
|
|
304
|
+
if (children) {
|
|
305
|
+
return children;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (!reasoningTokens) {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const reasoningCost = modelId
|
|
313
|
+
? getUsage({
|
|
314
|
+
modelId,
|
|
315
|
+
usage: { reasoningTokens },
|
|
316
|
+
}).costUSD?.totalUSD
|
|
317
|
+
: undefined;
|
|
318
|
+
const reasoningCostText = formatNumber(reasoningCost ?? 0, {
|
|
319
|
+
currency: "USD",
|
|
320
|
+
style: "currency",
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
return (
|
|
324
|
+
<div className={cn("flex items-center justify-between text-xs", className)} {...props}>
|
|
325
|
+
<span className="text-muted-foreground">Reasoning</span>
|
|
326
|
+
<TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />
|
|
327
|
+
</div>
|
|
328
|
+
);
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
export type ContextCacheUsageProps = ComponentProps<"div">;
|
|
332
|
+
|
|
333
|
+
export const ContextCacheUsage = ({ className, children, ...props }: ContextCacheUsageProps) => {
|
|
334
|
+
const { formatNumber } = useLocale();
|
|
335
|
+
const { usage, modelId } = useContextValue();
|
|
336
|
+
const cacheTokens = usage?.cachedInputTokens ?? 0;
|
|
337
|
+
|
|
338
|
+
if (children) {
|
|
339
|
+
return children;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (!cacheTokens) {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const cacheCost = modelId
|
|
347
|
+
? getUsage({
|
|
348
|
+
modelId,
|
|
349
|
+
usage: { cacheReads: cacheTokens, input: 0, output: 0 },
|
|
350
|
+
}).costUSD?.totalUSD
|
|
351
|
+
: undefined;
|
|
352
|
+
const cacheCostText = formatNumber(cacheCost ?? 0, {
|
|
353
|
+
currency: "USD",
|
|
354
|
+
style: "currency",
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
return (
|
|
358
|
+
<div className={cn("flex items-center justify-between text-xs", className)} {...props}>
|
|
359
|
+
<span className="text-muted-foreground">Cache</span>
|
|
360
|
+
<TokensWithCost costText={cacheCostText} tokens={cacheTokens} />
|
|
361
|
+
</div>
|
|
362
|
+
);
|
|
363
|
+
};
|
package/src/controls.tsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { Controls as ControlsPrimitive } from "@xyflow/react";
|
|
4
|
+
import type { ComponentProps } from "react";
|
|
5
|
+
import { Suspense } from "react";
|
|
6
|
+
|
|
7
|
+
import { lazyFlowPart } from "./_flow-lazy";
|
|
8
|
+
|
|
9
|
+
export type ControlsProps = ComponentProps<typeof ControlsPrimitive>;
|
|
10
|
+
|
|
11
|
+
/** React Flow lives behind a dynamic import — see `_flow-lazy.ts` and ADR 0019. */
|
|
12
|
+
const ControlsImpl = lazyFlowPart<ControlsProps>((m) => m.Controls);
|
|
13
|
+
|
|
14
|
+
export const Controls = (props: ControlsProps) => (
|
|
15
|
+
// An overlay inside an already-loaded `<Canvas>`, so there is no box to
|
|
16
|
+
// reserve — the engine chunk it needs is the one the canvas already fetched.
|
|
17
|
+
<Suspense fallback={null}>
|
|
18
|
+
<ControlsImpl {...props} />
|
|
19
|
+
</Suspense>
|
|
20
|
+
);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { Conversation, ConversationContent } from "./conversation";
|
|
3
|
+
import { Message, MessageContent } from "./message";
|
|
4
|
+
const meta = {
|
|
5
|
+
title: "AI/Conversation",
|
|
6
|
+
component: Conversation,
|
|
7
|
+
parameters: { layout: "padded" },
|
|
8
|
+
} satisfies Meta<typeof Conversation>;
|
|
9
|
+
export default meta;
|
|
10
|
+
type Story = StoryObj<typeof meta>;
|
|
11
|
+
export const Default: Story = {
|
|
12
|
+
render: () => (
|
|
13
|
+
<div className="flex h-72 flex-col rounded-lg border">
|
|
14
|
+
<Conversation>
|
|
15
|
+
<ConversationContent>
|
|
16
|
+
<Message from="user">
|
|
17
|
+
<MessageContent>Hello</MessageContent>
|
|
18
|
+
</Message>
|
|
19
|
+
<Message from="assistant">
|
|
20
|
+
<MessageContent>Hi! How can I help with your deploys today?</MessageContent>
|
|
21
|
+
</Message>
|
|
22
|
+
</ConversationContent>
|
|
23
|
+
</Conversation>
|
|
24
|
+
</div>
|
|
25
|
+
),
|
|
26
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* conversation.test.tsx — smoke + a11y/serialization lock for the transcript (#59).
|
|
3
|
+
*
|
|
4
|
+
* `Conversation` is the scroll container every chat message renders into. Two
|
|
5
|
+
* things must not regress: the container keeps `role="log"` (an incrementally
|
|
6
|
+
* updated region — this is what makes a streaming transcript announceable at
|
|
7
|
+
* all), and `messagesToMarkdown` — the download path's serializer — flattens
|
|
8
|
+
* `UIMessage.parts` correctly, since a broken export silently ships an empty or
|
|
9
|
+
* mangled file rather than throwing.
|
|
10
|
+
*
|
|
11
|
+
* Nothing is mocked: the real `StickToBottom`-backed component is rendered.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, it } from "vitest";
|
|
14
|
+
import { render, screen } from "@testing-library/react";
|
|
15
|
+
import type { UIMessage } from "ai";
|
|
16
|
+
import {
|
|
17
|
+
Conversation,
|
|
18
|
+
ConversationContent,
|
|
19
|
+
ConversationEmptyState,
|
|
20
|
+
messagesToMarkdown,
|
|
21
|
+
} from "./conversation";
|
|
22
|
+
|
|
23
|
+
const messages: UIMessage[] = [
|
|
24
|
+
{ id: "1", role: "user", parts: [{ type: "text", text: "What changed?" }] },
|
|
25
|
+
{
|
|
26
|
+
id: "2",
|
|
27
|
+
role: "assistant",
|
|
28
|
+
parts: [
|
|
29
|
+
{ type: "text", text: "Three services shipped." },
|
|
30
|
+
{ type: "text", text: " Billing is degraded." },
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
describe("Conversation — transcript region", () => {
|
|
36
|
+
it('exposes the transcript as role="log" (an incrementally updated region)', () => {
|
|
37
|
+
render(
|
|
38
|
+
<Conversation>
|
|
39
|
+
<ConversationContent>
|
|
40
|
+
<p>hello</p>
|
|
41
|
+
</ConversationContent>
|
|
42
|
+
</Conversation>,
|
|
43
|
+
);
|
|
44
|
+
expect(screen.getByRole("log")).toBeInTheDocument();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("renders its children inside the log, in order", () => {
|
|
48
|
+
render(
|
|
49
|
+
<Conversation>
|
|
50
|
+
<ConversationContent>
|
|
51
|
+
<p>first</p>
|
|
52
|
+
<p>second</p>
|
|
53
|
+
</ConversationContent>
|
|
54
|
+
</Conversation>,
|
|
55
|
+
);
|
|
56
|
+
const log = screen.getByRole("log");
|
|
57
|
+
expect(log).toHaveTextContent("first");
|
|
58
|
+
expect(log).toHaveTextContent("second");
|
|
59
|
+
const [first, second] = [screen.getByText("first"), screen.getByText("second")];
|
|
60
|
+
expect(first.compareDocumentPosition(second) & 4).toBeTruthy();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("merges a caller className without dropping the flex-fill layout", () => {
|
|
64
|
+
render(
|
|
65
|
+
<Conversation className="extra">
|
|
66
|
+
<ConversationContent>x</ConversationContent>
|
|
67
|
+
</Conversation>,
|
|
68
|
+
);
|
|
69
|
+
expect(screen.getByRole("log")).toHaveClass("extra");
|
|
70
|
+
expect(screen.getByRole("log")).toHaveClass("flex-1");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("ConversationEmptyState", () => {
|
|
75
|
+
it("renders a real empty state by default, not a blank region", () => {
|
|
76
|
+
render(<ConversationEmptyState />);
|
|
77
|
+
expect(screen.getByRole("heading", { name: "No messages yet" })).toBeInTheDocument();
|
|
78
|
+
expect(screen.getByText("Start a conversation to see messages here")).toBeInTheDocument();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("accepts an overriding title/description", () => {
|
|
82
|
+
render(<ConversationEmptyState title="Ask anything" description="Try a question." />);
|
|
83
|
+
expect(screen.getByRole("heading", { name: "Ask anything" })).toBeInTheDocument();
|
|
84
|
+
expect(screen.getByText("Try a question.")).toBeInTheDocument();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("lets children replace the default anatomy entirely", () => {
|
|
88
|
+
render(
|
|
89
|
+
<ConversationEmptyState>
|
|
90
|
+
<p>custom</p>
|
|
91
|
+
</ConversationEmptyState>,
|
|
92
|
+
);
|
|
93
|
+
expect(screen.getByText("custom")).toBeInTheDocument();
|
|
94
|
+
expect(screen.queryByRole("heading", { name: "No messages yet" })).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("messagesToMarkdown — the download serializer", () => {
|
|
99
|
+
it("labels each message by role and joins every text part", () => {
|
|
100
|
+
expect(messagesToMarkdown(messages)).toBe(
|
|
101
|
+
"**User:** What changed?\n\n**Assistant:** Three services shipped. Billing is degraded.",
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("skips non-text parts rather than emitting [object Object]", () => {
|
|
106
|
+
const withTool: UIMessage[] = [
|
|
107
|
+
{
|
|
108
|
+
id: "1",
|
|
109
|
+
role: "assistant",
|
|
110
|
+
parts: [{ type: "step-start" }, { type: "text", text: "Done." }],
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
expect(messagesToMarkdown(withTool)).toBe("**Assistant:** Done.");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("honours a custom formatter", () => {
|
|
117
|
+
expect(messagesToMarkdown(messages, (m) => m.role)).toBe("user\n\nassistant");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("returns an empty string for an empty transcript", () => {
|
|
121
|
+
expect(messagesToMarkdown([])).toBe("");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button, downloadBlob } from "@elabs-ai/components-ui";
|
|
4
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
5
|
+
import type { UIMessage } from "ai";
|
|
6
|
+
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
|
|
7
|
+
import type { ComponentProps } from "react";
|
|
8
|
+
import { useCallback } from "react";
|
|
9
|
+
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
|
10
|
+
|
|
11
|
+
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
|
12
|
+
|
|
13
|
+
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
|
14
|
+
<StickToBottom
|
|
15
|
+
className={cn("relative flex-1 overflow-y-hidden", className)}
|
|
16
|
+
initial="smooth"
|
|
17
|
+
resize="smooth"
|
|
18
|
+
role="log"
|
|
19
|
+
{...props}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
export type ConversationContentProps = ComponentProps<typeof StickToBottom.Content>;
|
|
24
|
+
|
|
25
|
+
export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
|
|
26
|
+
<StickToBottom.Content className={cn("flex flex-col gap-8 p-4", className)} {...props} />
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
|
30
|
+
title?: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
icon?: React.ReactNode;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const ConversationEmptyState = ({
|
|
36
|
+
className,
|
|
37
|
+
title = "No messages yet",
|
|
38
|
+
description = "Start a conversation to see messages here",
|
|
39
|
+
icon,
|
|
40
|
+
children,
|
|
41
|
+
...props
|
|
42
|
+
}: ConversationEmptyStateProps) => (
|
|
43
|
+
<div
|
|
44
|
+
className={cn(
|
|
45
|
+
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
|
|
46
|
+
className,
|
|
47
|
+
)}
|
|
48
|
+
{...props}
|
|
49
|
+
>
|
|
50
|
+
{children ?? (
|
|
51
|
+
<>
|
|
52
|
+
{icon && <div className="text-muted-foreground">{icon}</div>}
|
|
53
|
+
<div className="space-y-1">
|
|
54
|
+
<h3 className="font-medium text-sm">{title}</h3>
|
|
55
|
+
{description && <p className="text-muted-foreground text-sm">{description}</p>}
|
|
56
|
+
</div>
|
|
57
|
+
</>
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
|
63
|
+
|
|
64
|
+
export const ConversationScrollButton = ({
|
|
65
|
+
className,
|
|
66
|
+
...props
|
|
67
|
+
}: ConversationScrollButtonProps) => {
|
|
68
|
+
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
|
69
|
+
|
|
70
|
+
const handleScrollToBottom = useCallback(() => {
|
|
71
|
+
scrollToBottom();
|
|
72
|
+
}, [scrollToBottom]);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
!isAtBottom && (
|
|
76
|
+
<Button
|
|
77
|
+
className={cn(
|
|
78
|
+
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
|
|
79
|
+
className,
|
|
80
|
+
)}
|
|
81
|
+
onClick={handleScrollToBottom}
|
|
82
|
+
size="icon"
|
|
83
|
+
type="button"
|
|
84
|
+
variant="outline"
|
|
85
|
+
{...props}
|
|
86
|
+
>
|
|
87
|
+
<ArrowDownIcon className="size-4" />
|
|
88
|
+
</Button>
|
|
89
|
+
)
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const getMessageText = (message: UIMessage): string =>
|
|
94
|
+
message.parts
|
|
95
|
+
.filter((part) => part.type === "text")
|
|
96
|
+
.map((part) => part.text)
|
|
97
|
+
.join("");
|
|
98
|
+
|
|
99
|
+
export type ConversationDownloadProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
|
|
100
|
+
messages: UIMessage[];
|
|
101
|
+
filename?: string;
|
|
102
|
+
formatMessage?: (message: UIMessage, index: number) => string;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const defaultFormatMessage = (message: UIMessage): string => {
|
|
106
|
+
const roleLabel = message.role.charAt(0).toUpperCase() + message.role.slice(1);
|
|
107
|
+
return `**${roleLabel}:** ${getMessageText(message)}`;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const messagesToMarkdown = (
|
|
111
|
+
messages: UIMessage[],
|
|
112
|
+
formatMessage: (message: UIMessage, index: number) => string = defaultFormatMessage,
|
|
113
|
+
): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");
|
|
114
|
+
|
|
115
|
+
export const ConversationDownload = ({
|
|
116
|
+
messages,
|
|
117
|
+
filename = "conversation.md",
|
|
118
|
+
formatMessage = defaultFormatMessage,
|
|
119
|
+
className,
|
|
120
|
+
children,
|
|
121
|
+
...props
|
|
122
|
+
}: ConversationDownloadProps) => {
|
|
123
|
+
const handleDownload = useCallback(() => {
|
|
124
|
+
const markdown = messagesToMarkdown(messages, formatMessage);
|
|
125
|
+
downloadBlob(new Blob([markdown], { type: "text/markdown" }), filename);
|
|
126
|
+
}, [messages, filename, formatMessage]);
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<Button
|
|
130
|
+
className={cn(
|
|
131
|
+
"absolute top-4 end-4 rounded-full dark:bg-background dark:hover:bg-muted",
|
|
132
|
+
className,
|
|
133
|
+
)}
|
|
134
|
+
onClick={handleDownload}
|
|
135
|
+
size="icon"
|
|
136
|
+
type="button"
|
|
137
|
+
variant="outline"
|
|
138
|
+
{...props}
|
|
139
|
+
>
|
|
140
|
+
{children ?? <DownloadIcon className="size-4" />}
|
|
141
|
+
</Button>
|
|
142
|
+
);
|
|
143
|
+
};
|