@guuey/chat 0.4.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 +137 -0
- package/dist/history-inputs.d.ts +24 -0
- package/dist/history-inputs.d.ts.map +1 -0
- package/dist/history-inputs.js +34 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/plan.d.ts +5 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +629 -0
- package/dist/policy.d.ts +110 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +75 -0
- package/dist/react/components.d.ts +87 -0
- package/dist/react/components.d.ts.map +1 -0
- package/dist/react/components.js +270 -0
- package/dist/react/guuey-chat.d.ts +84 -0
- package/dist/react/guuey-chat.d.ts.map +1 -0
- package/dist/react/guuey-chat.js +103 -0
- package/dist/react/markdown.d.ts +32 -0
- package/dist/react/markdown.d.ts.map +1 -0
- package/dist/react/markdown.js +40 -0
- package/dist/react/theme-css.d.ts +16 -0
- package/dist/react/theme-css.d.ts.map +1 -0
- package/dist/react/theme-css.js +37 -0
- package/dist/react/transcript.d.ts +42 -0
- package/dist/react/transcript.d.ts.map +1 -0
- package/dist/react/transcript.js +88 -0
- package/dist/react/use-transcript.d.ts +39 -0
- package/dist/react/use-transcript.d.ts.map +1 -0
- package/dist/react/use-transcript.js +201 -0
- package/dist/react.d.ts +21 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +20 -0
- package/dist/strings.d.ts +74 -0
- package/dist/strings.d.ts.map +1 -0
- package/dist/strings.js +45 -0
- package/dist/theme.d.ts +99 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +182 -0
- package/dist/types.d.ts +283 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +87 -0
- package/src/corpus/README.md +40 -0
- package/src/corpus/__snapshots__/corpus.test.ts.snap +1590 -0
- package/src/corpus/capture.ts +67 -0
- package/src/corpus/captures/issue2627-render-capture.coalesced.sse.txt +173 -0
- package/src/corpus/drive.ts +184 -0
- package/src/corpus/fixtures.ts +338 -0
- package/src/history-inputs.ts +48 -0
- package/src/index.ts +58 -0
- package/src/plan.ts +740 -0
- package/src/policy.ts +146 -0
- package/src/react/components.tsx +655 -0
- package/src/react/guuey-chat.tsx +227 -0
- package/src/react/markdown.tsx +114 -0
- package/src/react/theme-css.ts +50 -0
- package/src/react/transcript.tsx +187 -0
- package/src/react/use-transcript.ts +274 -0
- package/src/react.tsx +51 -0
- package/src/strings.ts +144 -0
- package/src/theme.ts +195 -0
- package/src/types.ts +320 -0
- package/styles.css +514 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-category component kit (spec §3's override slots): one component
|
|
3
|
+
* per `DisplayItem` variant, each a THIN walk of its item — every rendering
|
|
4
|
+
* decision (states, labels, collapse defaults, copy) was already made by
|
|
5
|
+
* `planTranscript`; components translate the decided item into markup and
|
|
6
|
+
* never consult policy or invent copy.
|
|
7
|
+
*
|
|
8
|
+
* Override contract: `TranscriptComponents` is the component map
|
|
9
|
+
* (`{ tool: MyToolChip }` replaces one row's renderer without forfeiting
|
|
10
|
+
* the rest); every default is exported for composition.
|
|
11
|
+
*
|
|
12
|
+
* Accessibility (spec §3.2, acceptance criteria):
|
|
13
|
+
* - every `expanded` toggle is a real `<button aria-expanded aria-controls>`
|
|
14
|
+
* (keyboard-operable for free);
|
|
15
|
+
* - the status line is `role="status"` (a polite live region);
|
|
16
|
+
* - a streaming text bubble announces via `aria-live="polite"`;
|
|
17
|
+
* - R10 prompts take focus on appearance and return it on resolution;
|
|
18
|
+
* - shimmer/pulse is CSS-only and disabled under `prefers-reduced-motion`
|
|
19
|
+
* (see styles.css).
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
useEffect,
|
|
23
|
+
useRef,
|
|
24
|
+
useState,
|
|
25
|
+
type ComponentType,
|
|
26
|
+
type ReactNode,
|
|
27
|
+
} from "react";
|
|
28
|
+
import { GuueyView, type GuueyViewProps } from "@guuey/mcp-apps-host/react";
|
|
29
|
+
import type { ResolvedViewMount, ViewHostPhase } from "@guuey/mcp-apps-host";
|
|
30
|
+
import type { ChatStrings } from "../strings.js";
|
|
31
|
+
import type {
|
|
32
|
+
CitationsItem,
|
|
33
|
+
CodeItem,
|
|
34
|
+
CompactionItem,
|
|
35
|
+
DataResultItem,
|
|
36
|
+
DisplayItem,
|
|
37
|
+
ErrorItem,
|
|
38
|
+
HistoryBoundaryItem,
|
|
39
|
+
ItemKey,
|
|
40
|
+
MediaItem,
|
|
41
|
+
PromptItem,
|
|
42
|
+
ReasoningItem,
|
|
43
|
+
StatusLineItem,
|
|
44
|
+
ToolGroupItem,
|
|
45
|
+
ToolItem,
|
|
46
|
+
UnknownItem,
|
|
47
|
+
UserMessageItem,
|
|
48
|
+
TextItem,
|
|
49
|
+
ViewMountItem,
|
|
50
|
+
} from "../types.js";
|
|
51
|
+
import { Markdown } from "./markdown.js";
|
|
52
|
+
|
|
53
|
+
/** Everything a rendered item may need beyond itself. */
|
|
54
|
+
export interface TranscriptItemContext {
|
|
55
|
+
strings: ChatStrings;
|
|
56
|
+
/** Flip an item's collapse state (the renderer owns override state). */
|
|
57
|
+
onToggle: (key: ItemKey) => void;
|
|
58
|
+
/** R0 failed-send retry. */
|
|
59
|
+
onRetry?: (item: UserMessageItem) => void;
|
|
60
|
+
/** R10 prompt actions — the host owns what accept/decline DO. */
|
|
61
|
+
onPromptAction?: (item: PromptItem, action: "accept" | "decline" | "dismiss") => void;
|
|
62
|
+
/** R11 action slots (sign-in / upgrade / retry affordances). */
|
|
63
|
+
onErrorAction?: (item: ErrorItem) => void;
|
|
64
|
+
/** R6: locator mounts resolved by `useTranscript` ("expired" = failed). */
|
|
65
|
+
resolvedMounts: ReadonlyMap<ItemKey, ResolvedViewMount | "expired">;
|
|
66
|
+
/** R6: live phase reports wired back into the next plan. */
|
|
67
|
+
onViewPhase: (key: ItemKey, phase: ViewHostPhase) => void;
|
|
68
|
+
/** R6 pass-through (sandbox overrides etc. — policy-gated by the host). */
|
|
69
|
+
viewProps?: Pick<
|
|
70
|
+
GuueyViewProps,
|
|
71
|
+
"hostCapabilities" | "hostInfo" | "hostContext" | "onCallTool" | "negotiationTimeoutMs" | "dangerouslyAddSandboxFlags" | "allow"
|
|
72
|
+
>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface ItemProps<T> {
|
|
76
|
+
item: T;
|
|
77
|
+
ctx: TranscriptItemContext;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A collapse toggle + body pair with correct ARIA plumbing. */
|
|
81
|
+
function Collapsible({
|
|
82
|
+
itemKey,
|
|
83
|
+
expanded,
|
|
84
|
+
header,
|
|
85
|
+
ctx,
|
|
86
|
+
className,
|
|
87
|
+
children,
|
|
88
|
+
}: {
|
|
89
|
+
itemKey: ItemKey;
|
|
90
|
+
expanded: boolean;
|
|
91
|
+
header: ReactNode;
|
|
92
|
+
ctx: TranscriptItemContext;
|
|
93
|
+
className?: string;
|
|
94
|
+
children: ReactNode;
|
|
95
|
+
}): ReactNode {
|
|
96
|
+
const bodyId = `guuey-chat-body-${itemKey}`;
|
|
97
|
+
return (
|
|
98
|
+
<div className={className}>
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
className="guuey-chat-toggle"
|
|
102
|
+
aria-expanded={expanded}
|
|
103
|
+
aria-controls={bodyId}
|
|
104
|
+
onClick={() => ctx.onToggle(itemKey)}
|
|
105
|
+
>
|
|
106
|
+
{header}
|
|
107
|
+
<span aria-hidden="true" className="guuey-chat-toggle-glyph">
|
|
108
|
+
{expanded ? "▾" : "▸"}
|
|
109
|
+
</span>
|
|
110
|
+
</button>
|
|
111
|
+
{expanded ? (
|
|
112
|
+
<div id={bodyId} className="guuey-chat-body">
|
|
113
|
+
{children}
|
|
114
|
+
</div>
|
|
115
|
+
) : null}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A copy-to-clipboard affordance with a transient confirmation state. */
|
|
121
|
+
function CopyButton({ text, ctx }: { text: string; ctx: TranscriptItemContext }): ReactNode {
|
|
122
|
+
const [copied, setCopied] = useState(false);
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (!copied) return;
|
|
125
|
+
const timer = setTimeout(() => setCopied(false), 1500);
|
|
126
|
+
return () => clearTimeout(timer);
|
|
127
|
+
}, [copied]);
|
|
128
|
+
return (
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
className="guuey-chat-copy"
|
|
132
|
+
onClick={() => {
|
|
133
|
+
void navigator.clipboard?.writeText(text).then(() => setCopied(true));
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
{copied ? ctx.strings.copied : ctx.strings.copy}
|
|
137
|
+
</button>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── R0 ────────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
export function DefaultUserMessage({ item, ctx }: ItemProps<UserMessageItem>): ReactNode {
|
|
144
|
+
return (
|
|
145
|
+
<div className={`guuey-chat-user guuey-chat-user-${item.state}`}>
|
|
146
|
+
{/* User text is QUOTED, never rendered — whitespace preserved. */}
|
|
147
|
+
<div className="guuey-chat-user-bubble">{item.text}</div>
|
|
148
|
+
{item.state === "failed" ? (
|
|
149
|
+
<p className="guuey-chat-send-failed" role="status">
|
|
150
|
+
{ctx.strings.userCouldntSend}
|
|
151
|
+
{item.retry ? (
|
|
152
|
+
<button type="button" className="guuey-chat-retry" onClick={() => ctx.onRetry?.(item)}>
|
|
153
|
+
{ctx.strings.userRetry}
|
|
154
|
+
</button>
|
|
155
|
+
) : null}
|
|
156
|
+
</p>
|
|
157
|
+
) : null}
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── R1 ────────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
export function DefaultText({ item, ctx }: ItemProps<TextItem>): ReactNode {
|
|
165
|
+
return (
|
|
166
|
+
<div
|
|
167
|
+
className="guuey-chat-text"
|
|
168
|
+
{...(item.streaming ? { "aria-live": "polite" as const } : {})}
|
|
169
|
+
>
|
|
170
|
+
{item.markdown ? (
|
|
171
|
+
<Markdown text={item.text} />
|
|
172
|
+
) : (
|
|
173
|
+
<p className="guuey-chat-verbatim">{item.text}</p>
|
|
174
|
+
)}
|
|
175
|
+
{item.streaming ? (
|
|
176
|
+
<span aria-hidden="true" className="guuey-chat-cursor">
|
|
177
|
+
{" ▍"}
|
|
178
|
+
</span>
|
|
179
|
+
) : null}
|
|
180
|
+
{item.stopped ? <p className="guuey-chat-stopped">{ctx.strings.stopped}</p> : null}
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── R2 ────────────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
export function DefaultReasoning({ item, ctx }: ItemProps<ReasoningItem>): ReactNode {
|
|
188
|
+
return (
|
|
189
|
+
<Collapsible
|
|
190
|
+
itemKey={item.key}
|
|
191
|
+
expanded={item.expanded}
|
|
192
|
+
ctx={ctx}
|
|
193
|
+
className={`guuey-chat-reasoning${item.streaming ? " guuey-chat-pulse" : ""}`}
|
|
194
|
+
header={<span>{item.label}</span>}
|
|
195
|
+
>
|
|
196
|
+
<p className="guuey-chat-reasoning-text">{item.text}</p>
|
|
197
|
+
</Collapsible>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ─── R5 (also embedded inside R3's expansion) ──────────────────────────────
|
|
202
|
+
|
|
203
|
+
export function DefaultDataResult({ item, ctx }: ItemProps<DataResultItem>): ReactNode {
|
|
204
|
+
const bytes = ctx.strings.bytes(item.byteCount);
|
|
205
|
+
return (
|
|
206
|
+
<div className={`guuey-chat-data guuey-chat-data-${item.state}`}>
|
|
207
|
+
{item.state === "empty" ? (
|
|
208
|
+
<p className="guuey-chat-data-empty">{ctx.strings.noOutput}</p>
|
|
209
|
+
) : item.preview === null ? (
|
|
210
|
+
<p className="guuey-chat-data-binary">{bytes}</p>
|
|
211
|
+
) : (
|
|
212
|
+
<>
|
|
213
|
+
<pre className="guuey-chat-data-preview">{item.preview}</pre>
|
|
214
|
+
<div className="guuey-chat-data-meta">
|
|
215
|
+
{item.showBytes ? <span>{bytes}</span> : null}
|
|
216
|
+
<CopyButton text={item.preview} ctx={ctx} />
|
|
217
|
+
</div>
|
|
218
|
+
</>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ─── R3 ────────────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
const TOOL_GLYPH: Record<ToolItem["state"], string> = {
|
|
227
|
+
running: "◌",
|
|
228
|
+
done: "✓",
|
|
229
|
+
failed: "✕",
|
|
230
|
+
orphaned: "–",
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
export function DefaultTool({ item, ctx }: ItemProps<ToolItem>): ReactNode {
|
|
234
|
+
// R4's display-bearing rule: in calm this call's line lives in its view
|
|
235
|
+
// row's chrome ("via {tool}") — rendering it here too would double it.
|
|
236
|
+
if (item.attribution) return null;
|
|
237
|
+
const expandable = item.argsPreview !== null || item.result !== null;
|
|
238
|
+
const header = (
|
|
239
|
+
<span className={`guuey-chat-tool-line guuey-chat-tool-${item.state}`}>
|
|
240
|
+
<span aria-hidden="true" className="guuey-chat-tool-glyph">
|
|
241
|
+
{TOOL_GLYPH[item.state]}
|
|
242
|
+
</span>
|
|
243
|
+
<span className="guuey-chat-tool-title">{item.title}</span>
|
|
244
|
+
{item.state === "orphaned" ? (
|
|
245
|
+
<span className="guuey-chat-tool-note">{ctx.strings.toolDidntFinish}</span>
|
|
246
|
+
) : null}
|
|
247
|
+
</span>
|
|
248
|
+
);
|
|
249
|
+
if (!expandable) return <div className="guuey-chat-tool">{header}</div>;
|
|
250
|
+
return (
|
|
251
|
+
<Collapsible
|
|
252
|
+
itemKey={item.key}
|
|
253
|
+
expanded={item.expanded}
|
|
254
|
+
ctx={ctx}
|
|
255
|
+
className="guuey-chat-tool"
|
|
256
|
+
header={header}
|
|
257
|
+
>
|
|
258
|
+
{item.argsPreview !== null ? (
|
|
259
|
+
<pre className="guuey-chat-tool-args">{item.argsPreview}</pre>
|
|
260
|
+
) : null}
|
|
261
|
+
{item.result !== null ? <DefaultDataResult item={item.result} ctx={ctx} /> : null}
|
|
262
|
+
</Collapsible>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ─── R4 ────────────────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
export function DefaultToolGroup({ item, ctx }: ItemProps<ToolGroupItem>): ReactNode {
|
|
269
|
+
return (
|
|
270
|
+
<Collapsible
|
|
271
|
+
itemKey={item.key}
|
|
272
|
+
expanded={item.expanded}
|
|
273
|
+
ctx={ctx}
|
|
274
|
+
className="guuey-chat-tool-group"
|
|
275
|
+
header={
|
|
276
|
+
<span>
|
|
277
|
+
{item.label}
|
|
278
|
+
{item.failureBadge !== null ? (
|
|
279
|
+
<span className="guuey-chat-failure-badge">{item.failureBadge}</span>
|
|
280
|
+
) : null}
|
|
281
|
+
</span>
|
|
282
|
+
}
|
|
283
|
+
>
|
|
284
|
+
{item.tools.map((tool) => (
|
|
285
|
+
<DefaultTool key={tool.key} item={tool} ctx={ctx} />
|
|
286
|
+
))}
|
|
287
|
+
</Collapsible>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ─── R6 ────────────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
export function DefaultView({ item, ctx }: ItemProps<ViewMountItem>): ReactNode {
|
|
294
|
+
// Resolve what to actually mount: direct material, or the locator's
|
|
295
|
+
// resolution from `useTranscript` (renderer state — "expired" = miss).
|
|
296
|
+
const resolution = ctx.resolvedMounts.get(item.key);
|
|
297
|
+
const mount: ResolvedViewMount | null =
|
|
298
|
+
item.mount !== null && item.mount.channel !== "locator"
|
|
299
|
+
? item.mount
|
|
300
|
+
: resolution !== undefined && resolution !== "expired"
|
|
301
|
+
? resolution
|
|
302
|
+
: null;
|
|
303
|
+
const expired = item.phase === "expired" || resolution === "expired";
|
|
304
|
+
|
|
305
|
+
if (expired) {
|
|
306
|
+
return (
|
|
307
|
+
<div className="guuey-chat-view guuey-chat-view-expired">
|
|
308
|
+
<p role="status">{ctx.strings.viewExpired}</p>
|
|
309
|
+
</div>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
if (mount === null) {
|
|
313
|
+
// A locator still resolving (or a plan-level expired mount) — labeled,
|
|
314
|
+
// never blank.
|
|
315
|
+
return (
|
|
316
|
+
<div className="guuey-chat-view guuey-chat-view-negotiating guuey-chat-shimmer">
|
|
317
|
+
<p role="status">{item.label ?? ctx.strings.viewNegotiating}</p>
|
|
318
|
+
</div>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
return (
|
|
322
|
+
<div className="guuey-chat-view">
|
|
323
|
+
<GuueyView
|
|
324
|
+
mount={mount}
|
|
325
|
+
{...(ctx.viewProps ?? {})}
|
|
326
|
+
onPhaseChange={(phase) => ctx.onViewPhase(item.key, phase)}
|
|
327
|
+
/>
|
|
328
|
+
{item.attribution !== null ? (
|
|
329
|
+
<p className="guuey-chat-attribution">{item.attribution}</p>
|
|
330
|
+
) : null}
|
|
331
|
+
</div>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ─── R7 ────────────────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
/** Inline images allow https URLs and image/* base64 data — nothing else. */
|
|
338
|
+
function imageSrc(item: MediaItem): string | null {
|
|
339
|
+
const source = item.source;
|
|
340
|
+
if (source.type === "url" && /^https:\/\//i.test(source.url)) return source.url;
|
|
341
|
+
if (source.type === "base64" && /^image\//.test(source.mediaType)) {
|
|
342
|
+
return `data:${source.mediaType};base64,${source.data}`;
|
|
343
|
+
}
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function DefaultMedia({ item }: ItemProps<MediaItem>): ReactNode {
|
|
348
|
+
const [failed, setFailed] = useState(false);
|
|
349
|
+
if (item.presentation === "inline" && item.media === "image" && !failed) {
|
|
350
|
+
const src = imageSrc(item);
|
|
351
|
+
if (src !== null) {
|
|
352
|
+
return (
|
|
353
|
+
<a className="guuey-chat-media-image" href={src} target="_blank" rel="noopener noreferrer">
|
|
354
|
+
<img src={src} alt={item.name ?? ""} onError={() => setFailed(true)} />
|
|
355
|
+
</a>
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (item.media === "audio" && item.source.type === "url" && /^https:\/\//i.test(item.source.url)) {
|
|
360
|
+
return <audio className="guuey-chat-media-audio" controls src={item.source.url} />;
|
|
361
|
+
}
|
|
362
|
+
// Attachment chip: files, documents, unloadable/oversized media.
|
|
363
|
+
return (
|
|
364
|
+
<span className={`guuey-chat-media-chip guuey-chat-media-${item.media}`}>
|
|
365
|
+
{item.name ?? item.media}
|
|
366
|
+
</span>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ─── R8 ────────────────────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
export function DefaultCode({ item, ctx }: ItemProps<CodeItem>): ReactNode {
|
|
373
|
+
return (
|
|
374
|
+
<div className="guuey-chat-code">
|
|
375
|
+
<div className="guuey-chat-code-meta">
|
|
376
|
+
<span className="guuey-chat-code-lang">{item.language}</span>
|
|
377
|
+
<CopyButton text={item.code} ctx={ctx} />
|
|
378
|
+
</div>
|
|
379
|
+
<pre className={item.wrap ? "guuey-chat-code-wrap" : undefined}>{item.code}</pre>
|
|
380
|
+
</div>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ─── R9 ────────────────────────────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
/** Citation links navigate only to http(s) targets. */
|
|
387
|
+
function safeCitationUrl(url: string | null): string | null {
|
|
388
|
+
return url !== null && /^https?:\/\//i.test(url) ? url : null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function DefaultCitations({ item, ctx }: ItemProps<CitationsItem>): ReactNode {
|
|
392
|
+
return (
|
|
393
|
+
<Collapsible
|
|
394
|
+
itemKey={item.key}
|
|
395
|
+
expanded={item.expanded}
|
|
396
|
+
ctx={ctx}
|
|
397
|
+
className="guuey-chat-citations"
|
|
398
|
+
header={<span>{item.label}</span>}
|
|
399
|
+
>
|
|
400
|
+
<ul className={`guuey-chat-citations-${item.style}`}>
|
|
401
|
+
{item.sources.map((source, i) => {
|
|
402
|
+
const url = safeCitationUrl(source.url);
|
|
403
|
+
const label = source.title ?? source.url ?? "";
|
|
404
|
+
return (
|
|
405
|
+
<li key={i}>
|
|
406
|
+
{url !== null ? (
|
|
407
|
+
<a href={url} target="_blank" rel="noopener noreferrer">
|
|
408
|
+
{label}
|
|
409
|
+
</a>
|
|
410
|
+
) : (
|
|
411
|
+
<span>{label}</span>
|
|
412
|
+
)}
|
|
413
|
+
</li>
|
|
414
|
+
);
|
|
415
|
+
})}
|
|
416
|
+
</ul>
|
|
417
|
+
</Collapsible>
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ─── R10 ───────────────────────────────────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
export function DefaultPrompt({ item, ctx }: ItemProps<PromptItem>): ReactNode {
|
|
424
|
+
const firstAction = useRef<HTMLButtonElement>(null);
|
|
425
|
+
const restoreTo = useRef<Element | null>(null);
|
|
426
|
+
const wasPending = useRef(false);
|
|
427
|
+
|
|
428
|
+
// Focus management (§3.2): take focus when the ask appears, hand it back
|
|
429
|
+
// when the ask resolves.
|
|
430
|
+
useEffect(() => {
|
|
431
|
+
if (item.state === "pending" && !wasPending.current) {
|
|
432
|
+
restoreTo.current = document.activeElement;
|
|
433
|
+
firstAction.current?.focus();
|
|
434
|
+
}
|
|
435
|
+
if (item.state !== "pending" && wasPending.current) {
|
|
436
|
+
const target = restoreTo.current;
|
|
437
|
+
if (target instanceof HTMLElement && target.isConnected) target.focus();
|
|
438
|
+
}
|
|
439
|
+
wasPending.current = item.state === "pending";
|
|
440
|
+
}, [item.state]);
|
|
441
|
+
|
|
442
|
+
if (item.state !== "pending") {
|
|
443
|
+
return (
|
|
444
|
+
<p className={`guuey-chat-prompt-record guuey-chat-prompt-${item.state}`}>
|
|
445
|
+
{item.promptKind}: {item.state}
|
|
446
|
+
</p>
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
return (
|
|
450
|
+
<div className="guuey-chat-prompt" role="group">
|
|
451
|
+
<p className="guuey-chat-prompt-ask">
|
|
452
|
+
{item.promptKind === "consent"
|
|
453
|
+
? `${item.appId} requests ${item.requested} access`
|
|
454
|
+
: `Link your account to ${item.appId}`}
|
|
455
|
+
</p>
|
|
456
|
+
<div className="guuey-chat-prompt-actions">
|
|
457
|
+
<button
|
|
458
|
+
ref={firstAction}
|
|
459
|
+
type="button"
|
|
460
|
+
className="guuey-chat-prompt-accept"
|
|
461
|
+
onClick={() => ctx.onPromptAction?.(item, "accept")}
|
|
462
|
+
>
|
|
463
|
+
Allow
|
|
464
|
+
</button>
|
|
465
|
+
<button type="button" onClick={() => ctx.onPromptAction?.(item, "decline")}>
|
|
466
|
+
Decline
|
|
467
|
+
</button>
|
|
468
|
+
</div>
|
|
469
|
+
{item.raw !== null ? (
|
|
470
|
+
<pre className="guuey-chat-prompt-raw">{JSON.stringify(item.raw, null, 2)}</pre>
|
|
471
|
+
) : null}
|
|
472
|
+
</div>
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ─── R11 ───────────────────────────────────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
export function DefaultError({ item, ctx }: ItemProps<ErrorItem>): ReactNode {
|
|
479
|
+
return (
|
|
480
|
+
<div className={`guuey-chat-error guuey-chat-error-${item.family}`} role="alert">
|
|
481
|
+
<p>{item.copy}</p>
|
|
482
|
+
{ctx.onErrorAction !== undefined && (item.family === "transient" || item.family === "auth") ? (
|
|
483
|
+
<button type="button" className="guuey-chat-error-action" onClick={() => ctx.onErrorAction?.(item)}>
|
|
484
|
+
{item.family === "auth" ? ctx.strings.errorAuth : ctx.strings.userRetry}
|
|
485
|
+
</button>
|
|
486
|
+
) : null}
|
|
487
|
+
{item.verbatim !== null ? <pre className="guuey-chat-error-verbatim">{item.verbatim}</pre> : null}
|
|
488
|
+
</div>
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ─── R13 / R14 / R15 ───────────────────────────────────────────────────────
|
|
493
|
+
|
|
494
|
+
export function DefaultHistoryBoundary({ item }: ItemProps<HistoryBoundaryItem>): ReactNode {
|
|
495
|
+
return (
|
|
496
|
+
<div
|
|
497
|
+
className={`guuey-chat-history guuey-chat-history-${item.state}${item.state === "loading" ? " guuey-chat-shimmer" : ""}`}
|
|
498
|
+
role="status"
|
|
499
|
+
>
|
|
500
|
+
{item.label}
|
|
501
|
+
</div>
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export function DefaultCompaction({ item }: ItemProps<CompactionItem>): ReactNode {
|
|
506
|
+
return (
|
|
507
|
+
<div className="guuey-chat-compaction" role="separator">
|
|
508
|
+
{item.label}
|
|
509
|
+
</div>
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function DefaultUnknown({ item, ctx }: ItemProps<UnknownItem>): ReactNode {
|
|
514
|
+
return (
|
|
515
|
+
<Collapsible
|
|
516
|
+
itemKey={item.key}
|
|
517
|
+
expanded={item.expanded}
|
|
518
|
+
ctx={ctx}
|
|
519
|
+
className="guuey-chat-unknown"
|
|
520
|
+
header={
|
|
521
|
+
<span>
|
|
522
|
+
{item.label} <span className="guuey-chat-unknown-type">({item.typeName})</span>
|
|
523
|
+
</span>
|
|
524
|
+
}
|
|
525
|
+
>
|
|
526
|
+
{item.raw !== null ? (
|
|
527
|
+
<pre className="guuey-chat-unknown-raw">{JSON.stringify(item.raw, null, 2)}</pre>
|
|
528
|
+
) : (
|
|
529
|
+
<p className="guuey-chat-unknown-size">{ctx.strings.bytes(item.byteSize)}</p>
|
|
530
|
+
)}
|
|
531
|
+
</Collapsible>
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ─── R12 / §4 ──────────────────────────────────────────────────────────────
|
|
536
|
+
|
|
537
|
+
export function DefaultStatus({ item }: ItemProps<StatusLineItem>): ReactNode {
|
|
538
|
+
return (
|
|
539
|
+
<p className={`guuey-chat-status guuey-chat-status-${item.state} guuey-chat-pulse`} role="status">
|
|
540
|
+
{item.copy}
|
|
541
|
+
{item.detail !== null ? <span className="guuey-chat-status-detail"> · {item.detail}</span> : null}
|
|
542
|
+
</p>
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ─── The component map ─────────────────────────────────────────────────────
|
|
547
|
+
|
|
548
|
+
/** One component per §3 override slot. */
|
|
549
|
+
export interface TranscriptComponents {
|
|
550
|
+
userMessage: ComponentType<ItemProps<UserMessageItem>>;
|
|
551
|
+
text: ComponentType<ItemProps<TextItem>>;
|
|
552
|
+
reasoning: ComponentType<ItemProps<ReasoningItem>>;
|
|
553
|
+
tool: ComponentType<ItemProps<ToolItem>>;
|
|
554
|
+
toolGroup: ComponentType<ItemProps<ToolGroupItem>>;
|
|
555
|
+
dataResult: ComponentType<ItemProps<DataResultItem>>;
|
|
556
|
+
view: ComponentType<ItemProps<ViewMountItem>>;
|
|
557
|
+
media: ComponentType<ItemProps<MediaItem>>;
|
|
558
|
+
code: ComponentType<ItemProps<CodeItem>>;
|
|
559
|
+
citations: ComponentType<ItemProps<CitationsItem>>;
|
|
560
|
+
prompt: ComponentType<ItemProps<PromptItem>>;
|
|
561
|
+
error: ComponentType<ItemProps<ErrorItem>>;
|
|
562
|
+
history: ComponentType<ItemProps<HistoryBoundaryItem>>;
|
|
563
|
+
compaction: ComponentType<ItemProps<CompactionItem>>;
|
|
564
|
+
unknown: ComponentType<ItemProps<UnknownItem>>;
|
|
565
|
+
status: ComponentType<ItemProps<StatusLineItem>>;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export const defaultTranscriptComponents: TranscriptComponents = {
|
|
569
|
+
userMessage: DefaultUserMessage,
|
|
570
|
+
text: DefaultText,
|
|
571
|
+
reasoning: DefaultReasoning,
|
|
572
|
+
tool: DefaultTool,
|
|
573
|
+
toolGroup: DefaultToolGroup,
|
|
574
|
+
dataResult: DefaultDataResult,
|
|
575
|
+
view: DefaultView,
|
|
576
|
+
media: DefaultMedia,
|
|
577
|
+
code: DefaultCode,
|
|
578
|
+
citations: DefaultCitations,
|
|
579
|
+
prompt: DefaultPrompt,
|
|
580
|
+
error: DefaultError,
|
|
581
|
+
history: DefaultHistoryBoundary,
|
|
582
|
+
compaction: DefaultCompaction,
|
|
583
|
+
unknown: DefaultUnknown,
|
|
584
|
+
status: DefaultStatus,
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
/** Dispatch one display item through the (possibly overridden) map. */
|
|
588
|
+
export function renderItem(
|
|
589
|
+
item: DisplayItem,
|
|
590
|
+
components: TranscriptComponents,
|
|
591
|
+
ctx: TranscriptItemContext,
|
|
592
|
+
): ReactNode {
|
|
593
|
+
switch (item.kind) {
|
|
594
|
+
case "user": {
|
|
595
|
+
const C = components.userMessage;
|
|
596
|
+
return <C key={item.key} item={item} ctx={ctx} />;
|
|
597
|
+
}
|
|
598
|
+
case "text": {
|
|
599
|
+
const C = components.text;
|
|
600
|
+
return <C key={item.key} item={item} ctx={ctx} />;
|
|
601
|
+
}
|
|
602
|
+
case "reasoning": {
|
|
603
|
+
const C = components.reasoning;
|
|
604
|
+
return <C key={item.key} item={item} ctx={ctx} />;
|
|
605
|
+
}
|
|
606
|
+
case "tool": {
|
|
607
|
+
const C = components.tool;
|
|
608
|
+
return <C key={item.key} item={item} ctx={ctx} />;
|
|
609
|
+
}
|
|
610
|
+
case "tool-group": {
|
|
611
|
+
const C = components.toolGroup;
|
|
612
|
+
return <C key={item.key} item={item} ctx={ctx} />;
|
|
613
|
+
}
|
|
614
|
+
case "data-result": {
|
|
615
|
+
const C = components.dataResult;
|
|
616
|
+
return <C key={item.key} item={item} ctx={ctx} />;
|
|
617
|
+
}
|
|
618
|
+
case "view": {
|
|
619
|
+
const C = components.view;
|
|
620
|
+
return <C key={item.key} item={item} ctx={ctx} />;
|
|
621
|
+
}
|
|
622
|
+
case "media": {
|
|
623
|
+
const C = components.media;
|
|
624
|
+
return <C key={item.key} item={item} ctx={ctx} />;
|
|
625
|
+
}
|
|
626
|
+
case "code": {
|
|
627
|
+
const C = components.code;
|
|
628
|
+
return <C key={item.key} item={item} ctx={ctx} />;
|
|
629
|
+
}
|
|
630
|
+
case "citations": {
|
|
631
|
+
const C = components.citations;
|
|
632
|
+
return <C key={item.key} item={item} ctx={ctx} />;
|
|
633
|
+
}
|
|
634
|
+
case "prompt": {
|
|
635
|
+
const C = components.prompt;
|
|
636
|
+
return <C key={item.key} item={item} ctx={ctx} />;
|
|
637
|
+
}
|
|
638
|
+
case "error": {
|
|
639
|
+
const C = components.error;
|
|
640
|
+
return <C key={item.key} item={item} ctx={ctx} />;
|
|
641
|
+
}
|
|
642
|
+
case "history-boundary": {
|
|
643
|
+
const C = components.history;
|
|
644
|
+
return <C key={item.key} item={item} ctx={ctx} />;
|
|
645
|
+
}
|
|
646
|
+
case "compaction": {
|
|
647
|
+
const C = components.compaction;
|
|
648
|
+
return <C key={item.key} item={item} ctx={ctx} />;
|
|
649
|
+
}
|
|
650
|
+
case "unknown": {
|
|
651
|
+
const C = components.unknown;
|
|
652
|
+
return <C key={item.key} item={item} ctx={ctx} />;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|