botschat 0.1.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 (88) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +213 -0
  3. package/migrations/0001_initial.sql +88 -0
  4. package/migrations/0002_rename_projects_to_channels.sql +53 -0
  5. package/migrations/0003_messages.sql +14 -0
  6. package/migrations/0004_jobs.sql +15 -0
  7. package/migrations/0005_deleted_cron_jobs.sql +6 -0
  8. package/migrations/0006_tasks_add_model.sql +2 -0
  9. package/migrations/0007_sessions.sql +25 -0
  10. package/migrations/0008_remove_openclaw_fields.sql +8 -0
  11. package/package.json +53 -0
  12. package/packages/api/package.json +17 -0
  13. package/packages/api/src/do/connection-do.ts +929 -0
  14. package/packages/api/src/env.ts +8 -0
  15. package/packages/api/src/index.ts +297 -0
  16. package/packages/api/src/routes/agents.ts +68 -0
  17. package/packages/api/src/routes/auth.ts +105 -0
  18. package/packages/api/src/routes/channels.ts +185 -0
  19. package/packages/api/src/routes/jobs.ts +65 -0
  20. package/packages/api/src/routes/models.ts +22 -0
  21. package/packages/api/src/routes/pairing.ts +76 -0
  22. package/packages/api/src/routes/projects.ts +177 -0
  23. package/packages/api/src/routes/sessions.ts +171 -0
  24. package/packages/api/src/routes/tasks.ts +375 -0
  25. package/packages/api/src/routes/upload.ts +52 -0
  26. package/packages/api/src/utils/auth.ts +101 -0
  27. package/packages/api/src/utils/id.ts +19 -0
  28. package/packages/api/tsconfig.json +18 -0
  29. package/packages/plugin/dist/index.d.ts +19 -0
  30. package/packages/plugin/dist/index.d.ts.map +1 -0
  31. package/packages/plugin/dist/index.js +17 -0
  32. package/packages/plugin/dist/index.js.map +1 -0
  33. package/packages/plugin/dist/src/accounts.d.ts +12 -0
  34. package/packages/plugin/dist/src/accounts.d.ts.map +1 -0
  35. package/packages/plugin/dist/src/accounts.js +103 -0
  36. package/packages/plugin/dist/src/accounts.js.map +1 -0
  37. package/packages/plugin/dist/src/channel.d.ts +206 -0
  38. package/packages/plugin/dist/src/channel.d.ts.map +1 -0
  39. package/packages/plugin/dist/src/channel.js +1248 -0
  40. package/packages/plugin/dist/src/channel.js.map +1 -0
  41. package/packages/plugin/dist/src/runtime.d.ts +3 -0
  42. package/packages/plugin/dist/src/runtime.d.ts.map +1 -0
  43. package/packages/plugin/dist/src/runtime.js +18 -0
  44. package/packages/plugin/dist/src/runtime.js.map +1 -0
  45. package/packages/plugin/dist/src/types.d.ts +179 -0
  46. package/packages/plugin/dist/src/types.d.ts.map +1 -0
  47. package/packages/plugin/dist/src/types.js +6 -0
  48. package/packages/plugin/dist/src/types.js.map +1 -0
  49. package/packages/plugin/dist/src/ws-client.d.ts +51 -0
  50. package/packages/plugin/dist/src/ws-client.d.ts.map +1 -0
  51. package/packages/plugin/dist/src/ws-client.js +170 -0
  52. package/packages/plugin/dist/src/ws-client.js.map +1 -0
  53. package/packages/plugin/openclaw.plugin.json +11 -0
  54. package/packages/plugin/package.json +39 -0
  55. package/packages/plugin/tsconfig.json +20 -0
  56. package/packages/web/dist/assets/index-C-wI8eHy.css +1 -0
  57. package/packages/web/dist/assets/index-CbPEKHLG.js +93 -0
  58. package/packages/web/dist/index.html +17 -0
  59. package/packages/web/index.html +16 -0
  60. package/packages/web/package.json +29 -0
  61. package/packages/web/postcss.config.js +6 -0
  62. package/packages/web/src/App.tsx +827 -0
  63. package/packages/web/src/api.ts +242 -0
  64. package/packages/web/src/components/ChatWindow.tsx +864 -0
  65. package/packages/web/src/components/CronDetail.tsx +943 -0
  66. package/packages/web/src/components/CronSidebar.tsx +123 -0
  67. package/packages/web/src/components/DebugLogPanel.tsx +258 -0
  68. package/packages/web/src/components/IconRail.tsx +163 -0
  69. package/packages/web/src/components/JobList.tsx +120 -0
  70. package/packages/web/src/components/LoginPage.tsx +178 -0
  71. package/packages/web/src/components/MessageContent.tsx +1082 -0
  72. package/packages/web/src/components/ModelSelect.tsx +87 -0
  73. package/packages/web/src/components/ScheduleEditor.tsx +403 -0
  74. package/packages/web/src/components/SessionTabs.tsx +246 -0
  75. package/packages/web/src/components/Sidebar.tsx +331 -0
  76. package/packages/web/src/components/TaskBar.tsx +413 -0
  77. package/packages/web/src/components/ThreadPanel.tsx +212 -0
  78. package/packages/web/src/debug-log.ts +58 -0
  79. package/packages/web/src/index.css +170 -0
  80. package/packages/web/src/main.tsx +10 -0
  81. package/packages/web/src/store.ts +492 -0
  82. package/packages/web/src/ws.ts +99 -0
  83. package/packages/web/tailwind.config.js +65 -0
  84. package/packages/web/tsconfig.json +18 -0
  85. package/packages/web/vite.config.ts +20 -0
  86. package/scripts/dev.sh +122 -0
  87. package/tsconfig.json +18 -0
  88. package/wrangler.toml +40 -0
@@ -0,0 +1,1082 @@
1
+ import React, { useState, useCallback, useMemo } from "react";
2
+ import ReactMarkdown from "react-markdown";
3
+ import remarkGfm from "remark-gfm";
4
+ import rehypeHighlight from "rehype-highlight";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Types
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /** A single option in an action card (button or select item). */
11
+ type ActionItem = {
12
+ label: string;
13
+ value: string;
14
+ style?: "primary" | "secondary" | "danger";
15
+ };
16
+
17
+ /** Parsed action block data from ```action fenced blocks. */
18
+ type ParsedAction = {
19
+ kind: "buttons" | "confirm" | "select" | "input";
20
+ prompt?: string;
21
+ items?: ActionItem[];
22
+ placeholder?: string;
23
+ };
24
+
25
+ type MessageContentProps = {
26
+ text: string;
27
+ mediaUrl?: string;
28
+ a2ui?: string;
29
+ className?: string;
30
+ isStreaming?: boolean;
31
+ /** Called when user clicks an A2UI action button */
32
+ onAction?: (action: string, payload?: Record<string, unknown>) => void;
33
+ /** Called when user resolves an action card */
34
+ onResolveAction?: (value: string, label: string) => void;
35
+ /** Already-resolved actions keyed by prompt hash */
36
+ resolvedActions?: Record<string, { value: string; label: string }>;
37
+ };
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // A2UI types (subset of v0.8 spec we render)
41
+ // ---------------------------------------------------------------------------
42
+
43
+ type A2UIComponent =
44
+ | { Text: { text: A2UIValue; usageHint?: string } }
45
+ | { Button: { label: A2UIValue; action?: A2UIAction; style?: string } }
46
+ | { Column: { children: A2UIChildren; gap?: number } }
47
+ | { Row: { children: A2UIChildren; gap?: number } }
48
+ | { Card: { children: A2UIChildren; title?: A2UIValue } }
49
+ | { List: { children: A2UIChildren } }
50
+ | { Image: { url: A2UIValue; alt?: A2UIValue; usageHint?: string } }
51
+ | { Divider: Record<string, unknown> }
52
+ | { Icon: { name: A2UIValue } };
53
+
54
+ type A2UIValue = { literalString: string } | { dataPath: string } | string;
55
+ type A2UIAction = { sendMessage?: string; [key: string]: unknown };
56
+ type A2UIChildren = { explicitList: string[] } | string[];
57
+
58
+ type A2UIComponentEntry = {
59
+ id: string;
60
+ component: A2UIComponent;
61
+ };
62
+
63
+ type A2UISurfaceUpdate = {
64
+ surfaceUpdate: {
65
+ surfaceId: string;
66
+ components: A2UIComponentEntry[];
67
+ };
68
+ };
69
+
70
+ type A2UIBeginRendering = {
71
+ beginRendering: {
72
+ surfaceId: string;
73
+ root: string;
74
+ };
75
+ };
76
+
77
+ type A2UIDataModelUpdate = {
78
+ dataModelUpdate: {
79
+ surfaceId: string;
80
+ updates: { path: string; value: A2UIValue }[];
81
+ };
82
+ };
83
+
84
+ type A2UIMessage = A2UISurfaceUpdate | A2UIBeginRendering | A2UIDataModelUpdate;
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Helpers
88
+ // ---------------------------------------------------------------------------
89
+
90
+ function resolveValue(val: A2UIValue | undefined): string {
91
+ if (!val) return "";
92
+ if (typeof val === "string") return val;
93
+ if ("literalString" in val) return val.literalString;
94
+ if ("dataPath" in val) return `{{${val.dataPath}}}`;
95
+ return "";
96
+ }
97
+
98
+ function resolveChildren(children: A2UIChildren | undefined): string[] {
99
+ if (!children) return [];
100
+ if (Array.isArray(children)) return children;
101
+ if ("explicitList" in children) return children.explicitList;
102
+ return [];
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Code block with copy button + syntax highlighting
107
+ // ---------------------------------------------------------------------------
108
+
109
+ function CodeBlock({
110
+ className,
111
+ children,
112
+ }: {
113
+ className?: string;
114
+ children: React.ReactNode;
115
+ }) {
116
+ const [copied, setCopied] = useState(false);
117
+
118
+ const code = String(children).replace(/\n$/, "");
119
+ // Extract language from className (e.g. "language-python" -> "python")
120
+ const lang = className?.replace(/^language-/, "") ?? "";
121
+
122
+ const handleCopy = useCallback(() => {
123
+ navigator.clipboard.writeText(code).then(() => {
124
+ setCopied(true);
125
+ setTimeout(() => setCopied(false), 2000);
126
+ });
127
+ }, [code]);
128
+
129
+ return (
130
+ <div className="group/code relative my-2 rounded-md overflow-hidden" style={{ border: "1px solid var(--border)" }}>
131
+ {/* Header bar */}
132
+ <div
133
+ className="flex items-center justify-between px-3 py-1.5"
134
+ style={{ background: "var(--bg-hover)", borderBottom: "1px solid var(--border)" }}
135
+ >
136
+ <span className="text-tiny font-mono uppercase" style={{ color: "var(--text-muted)" }}>
137
+ {lang || "code"}
138
+ </span>
139
+ <button
140
+ onClick={handleCopy}
141
+ className="flex items-center gap-1 px-2 py-0.5 rounded text-tiny transition-colors"
142
+ style={{
143
+ color: copied ? "var(--accent-green)" : "var(--text-muted)",
144
+ background: "transparent",
145
+ }}
146
+ title="Copy code"
147
+ >
148
+ {copied ? (
149
+ <>
150
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
151
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
152
+ </svg>
153
+ Copied
154
+ </>
155
+ ) : (
156
+ <>
157
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
158
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
159
+ </svg>
160
+ Copy
161
+ </>
162
+ )}
163
+ </button>
164
+ </div>
165
+ {/* Code content — rehype-highlight adds classes to <code> */}
166
+ <pre
167
+ className="overflow-x-auto p-3 text-[13px] leading-[1.5]"
168
+ style={{ background: "var(--code-bg)", color: "var(--text-primary)", margin: 0 }}
169
+ >
170
+ <code className={className} style={{ fontFamily: "var(--font-mono)" }}>
171
+ {children}
172
+ </code>
173
+ </pre>
174
+ </div>
175
+ );
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Enhanced table
180
+ // ---------------------------------------------------------------------------
181
+
182
+ function DataTable({ children }: { children: React.ReactNode }) {
183
+ return (
184
+ <div className="my-2 overflow-x-auto rounded-md" style={{ border: "1px solid var(--border)" }}>
185
+ <table
186
+ className="min-w-full text-[13px]"
187
+ style={{ borderCollapse: "collapse" }}
188
+ >
189
+ {children}
190
+ </table>
191
+ </div>
192
+ );
193
+ }
194
+
195
+ function TableHead({ children }: { children: React.ReactNode }) {
196
+ return (
197
+ <thead style={{ background: "var(--bg-hover)" }}>
198
+ {children}
199
+ </thead>
200
+ );
201
+ }
202
+
203
+ function TableRow({ children, ...props }: React.HTMLAttributes<HTMLTableRowElement>) {
204
+ return (
205
+ <tr
206
+ {...props}
207
+ style={{ borderBottom: "1px solid var(--border)" }}
208
+ className="hover:bg-[--bg-hover] transition-colors"
209
+ >
210
+ {children}
211
+ </tr>
212
+ );
213
+ }
214
+
215
+ function TableCell({
216
+ children,
217
+ isHeader = false,
218
+ style,
219
+ }: {
220
+ children: React.ReactNode;
221
+ isHeader?: boolean;
222
+ style?: React.CSSProperties;
223
+ }) {
224
+ const Tag = isHeader ? "th" : "td";
225
+ return (
226
+ <Tag
227
+ className={`px-3 py-2 text-left ${isHeader ? "font-bold" : ""}`}
228
+ style={{
229
+ color: isHeader ? "var(--text-primary)" : "var(--text-secondary)",
230
+ whiteSpace: "nowrap",
231
+ ...style,
232
+ }}
233
+ >
234
+ {children}
235
+ </Tag>
236
+ );
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // A2UI Renderer
241
+ // ---------------------------------------------------------------------------
242
+
243
+ function A2UIRenderer({
244
+ jsonl,
245
+ onAction,
246
+ }: {
247
+ jsonl: string;
248
+ onAction?: (action: string, payload?: Record<string, unknown>) => void;
249
+ }) {
250
+ const parsed = useMemo(() => parseA2UI(jsonl), [jsonl]);
251
+
252
+ if (!parsed) {
253
+ // Fallback: show raw JSONL in a code block
254
+ return (
255
+ <div
256
+ className="mt-2 px-2 py-1.5 rounded-sm text-caption"
257
+ style={{ background: "var(--code-bg)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
258
+ >
259
+ <span className="font-bold text-tiny" style={{ color: "var(--text-muted)" }}>A2UI</span>
260
+ <pre className="mt-1 overflow-x-auto whitespace-pre-wrap break-words max-h-32" style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
261
+ {jsonl}
262
+ </pre>
263
+ </div>
264
+ );
265
+ }
266
+
267
+ return (
268
+ <div className="mt-1">
269
+ {renderA2UIComponent(parsed.rootId, parsed.components, onAction)}
270
+ </div>
271
+ );
272
+ }
273
+
274
+ type ParsedA2UI = {
275
+ rootId: string;
276
+ components: Map<string, A2UIComponent>;
277
+ };
278
+
279
+ function parseA2UI(jsonl: string): ParsedA2UI | null {
280
+ try {
281
+ const lines = jsonl.trim().split("\n").filter(Boolean);
282
+ const components = new Map<string, A2UIComponent>();
283
+ let rootId = "";
284
+
285
+ for (const line of lines) {
286
+ const msg = JSON.parse(line) as A2UIMessage;
287
+
288
+ if ("surfaceUpdate" in msg) {
289
+ for (const entry of msg.surfaceUpdate.components) {
290
+ components.set(entry.id, entry.component);
291
+ }
292
+ }
293
+ if ("beginRendering" in msg) {
294
+ rootId = msg.beginRendering.root;
295
+ }
296
+ }
297
+
298
+ if (!rootId || components.size === 0) return null;
299
+ return { rootId, components };
300
+ } catch {
301
+ return null;
302
+ }
303
+ }
304
+
305
+ function renderA2UIComponent(
306
+ id: string,
307
+ components: Map<string, A2UIComponent>,
308
+ onAction?: (action: string, payload?: Record<string, unknown>) => void,
309
+ ): React.ReactNode {
310
+ const comp = components.get(id);
311
+ if (!comp) return null;
312
+
313
+ // Text
314
+ if ("Text" in comp) {
315
+ const { text, usageHint } = comp.Text;
316
+ const content = resolveValue(text);
317
+ const tag = usageHint ?? "body";
318
+
319
+ const styleMap: Record<string, string> = {
320
+ h1: "text-h1 font-bold",
321
+ h2: "text-h2 font-bold",
322
+ h3: "text-[14px] font-bold",
323
+ h4: "text-caption font-bold",
324
+ h5: "text-tiny font-bold uppercase tracking-wide",
325
+ caption: "text-caption",
326
+ body: "text-body",
327
+ };
328
+
329
+ return (
330
+ <p
331
+ key={id}
332
+ className={`${styleMap[tag] ?? "text-body"} my-0.5`}
333
+ style={{ color: tag.startsWith("h") ? "var(--text-primary)" : "var(--text-secondary)" }}
334
+ >
335
+ {content}
336
+ </p>
337
+ );
338
+ }
339
+
340
+ // Button
341
+ if ("Button" in comp) {
342
+ const { label, action, style: btnStyle } = comp.Button;
343
+ const text = resolveValue(label);
344
+ const isPrimary = btnStyle === "primary" || btnStyle === "filled";
345
+
346
+ return (
347
+ <button
348
+ key={id}
349
+ onClick={() => {
350
+ if (action?.sendMessage && onAction) {
351
+ onAction(action.sendMessage, action);
352
+ }
353
+ }}
354
+ className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-sm text-caption font-bold transition-colors ${
355
+ isPrimary
356
+ ? "text-white"
357
+ : "hover:bg-[--bg-hover]"
358
+ }`}
359
+ style={{
360
+ background: isPrimary ? "var(--bg-active)" : "transparent",
361
+ color: isPrimary ? "#fff" : "var(--text-link)",
362
+ border: isPrimary ? "none" : "1px solid var(--border)",
363
+ }}
364
+ >
365
+ {text}
366
+ </button>
367
+ );
368
+ }
369
+
370
+ // Column
371
+ if ("Column" in comp) {
372
+ const childIds = resolveChildren(comp.Column.children);
373
+ const gap = comp.Column.gap ?? 4;
374
+ return (
375
+ <div key={id} className="flex flex-col" style={{ gap }}>
376
+ {childIds.map((cid) => renderA2UIComponent(cid, components, onAction))}
377
+ </div>
378
+ );
379
+ }
380
+
381
+ // Row
382
+ if ("Row" in comp) {
383
+ const childIds = resolveChildren(comp.Row.children);
384
+ const gap = comp.Row.gap ?? 8;
385
+ return (
386
+ <div key={id} className="flex flex-row flex-wrap items-center" style={{ gap }}>
387
+ {childIds.map((cid) => renderA2UIComponent(cid, components, onAction))}
388
+ </div>
389
+ );
390
+ }
391
+
392
+ // Card
393
+ if ("Card" in comp) {
394
+ const childIds = resolveChildren(comp.Card.children);
395
+ const title = comp.Card.title ? resolveValue(comp.Card.title) : null;
396
+ return (
397
+ <div
398
+ key={id}
399
+ className="rounded-md p-3 my-1"
400
+ style={{ background: "var(--bg-hover)", border: "1px solid var(--border)" }}
401
+ >
402
+ {title && (
403
+ <p className="text-h2 font-bold mb-2" style={{ color: "var(--text-primary)" }}>{title}</p>
404
+ )}
405
+ <div className="flex flex-col gap-1">
406
+ {childIds.map((cid) => renderA2UIComponent(cid, components, onAction))}
407
+ </div>
408
+ </div>
409
+ );
410
+ }
411
+
412
+ // List
413
+ if ("List" in comp) {
414
+ const childIds = resolveChildren(comp.List.children);
415
+ return (
416
+ <div key={id} className="flex flex-col gap-0.5 my-1">
417
+ {childIds.map((cid) => (
418
+ <div
419
+ key={cid}
420
+ className="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-[--bg-hover] transition-colors"
421
+ >
422
+ <span className="text-tiny mt-1" style={{ color: "var(--text-muted)" }}>&#x2022;</span>
423
+ {renderA2UIComponent(cid, components, onAction)}
424
+ </div>
425
+ ))}
426
+ </div>
427
+ );
428
+ }
429
+
430
+ // Image
431
+ if ("Image" in comp) {
432
+ const url = resolveValue(comp.Image.url);
433
+ const alt = comp.Image.alt ? resolveValue(comp.Image.alt) : "";
434
+ return (
435
+ <img
436
+ key={id}
437
+ src={url}
438
+ alt={alt}
439
+ className="max-w-[360px] max-h-64 rounded-md object-contain my-1"
440
+ style={{ border: "1px solid var(--border)" }}
441
+ />
442
+ );
443
+ }
444
+
445
+ // Divider
446
+ if ("Divider" in comp) {
447
+ return (
448
+ <hr
449
+ key={id}
450
+ className="my-2"
451
+ style={{ border: "none", borderTop: "1px solid var(--border)" }}
452
+ />
453
+ );
454
+ }
455
+
456
+ // Icon
457
+ if ("Icon" in comp) {
458
+ const name = resolveValue(comp.Icon.name);
459
+ return (
460
+ <span key={id} className="text-body" style={{ color: "var(--text-secondary)" }}>
461
+ [{name}]
462
+ </span>
463
+ );
464
+ }
465
+
466
+ return null;
467
+ }
468
+
469
+ // ---------------------------------------------------------------------------
470
+ // Markdown component overrides
471
+ // ---------------------------------------------------------------------------
472
+
473
+ // Build markdown components dynamically so ActionCard can access resolve callbacks
474
+ function buildMarkdownComponents(
475
+ onResolveAction?: (value: string, label: string) => void,
476
+ resolvedActions?: Record<string, { value: string; label: string }>,
477
+ ): Record<string, React.FC<any>> {
478
+ return {
479
+ // Fenced code blocks: <pre> wraps <code>, we render CodeBlock inside pre
480
+ pre({ children, node }: { children: React.ReactNode; node?: any }) {
481
+ // react-markdown wraps fenced code in <pre><code>…</code></pre>.
482
+ // Extract the <code> child's props and render our CodeBlock directly.
483
+ const codeChild = node?.children?.[0];
484
+ if (codeChild?.tagName === "code") {
485
+ const className = codeChild.properties?.className
486
+ ? Array.isArray(codeChild.properties.className)
487
+ ? codeChild.properties.className.join(" ")
488
+ : String(codeChild.properties.className)
489
+ : undefined;
490
+
491
+ // Intercept ```action blocks — render as ActionCard instead of CodeBlock
492
+ if (className?.includes("language-action")) {
493
+ const raw = String((children as any)?.props?.children ?? children).trim();
494
+ try {
495
+ const parsed: ParsedAction = JSON.parse(raw);
496
+ if (parsed && typeof parsed === "object" && parsed.kind) {
497
+ const promptKey = simpleHash(parsed.prompt ?? raw);
498
+ const resolved = resolvedActions?.[promptKey];
499
+ return (
500
+ <ActionCard
501
+ action={parsed}
502
+ resolved={resolved}
503
+ onResolve={onResolveAction
504
+ ? (v, l) => onResolveAction(v, l)
505
+ : undefined
506
+ }
507
+ />
508
+ );
509
+ }
510
+ } catch {
511
+ // JSON parse failed — fall through to CodeBlock
512
+ }
513
+ }
514
+
515
+ // Get text content from the React children (the rendered <code> element)
516
+ return <CodeBlock className={className}>{(children as any)?.props?.children ?? children}</CodeBlock>;
517
+ }
518
+ return <pre>{children}</pre>;
519
+ },
520
+ // Inline code: all <code> not inside <pre> arrive here
521
+ code({
522
+ className,
523
+ children,
524
+ ...props
525
+ }: {
526
+ className?: string;
527
+ children: React.ReactNode;
528
+ }) {
529
+ // Inline code — simple styled span
530
+ return (
531
+ <code
532
+ className="px-1 py-0.5 rounded text-[0.85em]"
533
+ style={{
534
+ background: "var(--code-bg)",
535
+ color: "var(--code-text)",
536
+ fontFamily: "var(--font-mono)",
537
+ }}
538
+ {...props}
539
+ >
540
+ {children}
541
+ </code>
542
+ );
543
+ },
544
+ // Enhanced tables
545
+ table({ children }: { children: React.ReactNode }) {
546
+ return <DataTable>{children}</DataTable>;
547
+ },
548
+ thead({ children }: { children: React.ReactNode }) {
549
+ return <TableHead>{children}</TableHead>;
550
+ },
551
+ tr({ children, ...props }: React.HTMLAttributes<HTMLTableRowElement>) {
552
+ return <TableRow {...props}>{children}</TableRow>;
553
+ },
554
+ th({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) {
555
+ return <TableCell isHeader style={style}>{children}</TableCell>;
556
+ },
557
+ td({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) {
558
+ return <TableCell style={style}>{children}</TableCell>;
559
+ },
560
+ // Links — open in new tab
561
+ a({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
562
+ return (
563
+ <a
564
+ href={href}
565
+ target="_blank"
566
+ rel="noopener noreferrer"
567
+ style={{ color: "var(--text-link)" }}
568
+ className="underline underline-offset-2 hover:opacity-80 transition-opacity"
569
+ {...props}
570
+ >
571
+ {children}
572
+ </a>
573
+ );
574
+ },
575
+ // Blockquotes
576
+ blockquote({ children }: { children: React.ReactNode }) {
577
+ return (
578
+ <blockquote
579
+ className="my-2 pl-3 py-0.5"
580
+ style={{
581
+ borderLeft: "3px solid var(--bg-active)",
582
+ color: "var(--text-secondary)",
583
+ }}
584
+ >
585
+ {children}
586
+ </blockquote>
587
+ );
588
+ },
589
+ };
590
+ }
591
+
592
+ // ---------------------------------------------------------------------------
593
+ // Simple hash for action prompt keys
594
+ // ---------------------------------------------------------------------------
595
+
596
+ function simpleHash(str: string): string {
597
+ let hash = 0;
598
+ for (let i = 0; i < str.length; i++) {
599
+ hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
600
+ }
601
+ return hash.toString(36);
602
+ }
603
+
604
+ // ---------------------------------------------------------------------------
605
+ // Streaming-aware text preprocessor: splits text around ```action blocks,
606
+ // hides incomplete blocks behind a pulsing placeholder, and passes complete
607
+ // blocks through for the markdown renderer to handle.
608
+ // ---------------------------------------------------------------------------
609
+
610
+ function preprocessActionBlocks(text: string, isStreaming?: boolean): string {
611
+ // Fast path – no action blocks at all
612
+ if (!text.includes("```action")) return text;
613
+
614
+ // Split by ```action ... ``` boundaries.
615
+ // We keep complete blocks intact (markdown renderer handles them),
616
+ // and hide any trailing incomplete block while streaming.
617
+ const parts: string[] = [];
618
+ let remaining = text;
619
+
620
+ // Match complete ```action ... ``` blocks
621
+ const completeRe = /```action\s*\n[\s\S]*?```/g;
622
+ let lastIndex = 0;
623
+ let match: RegExpExecArray | null;
624
+
625
+ while ((match = completeRe.exec(remaining)) !== null) {
626
+ // Text before this block
627
+ if (match.index > lastIndex) {
628
+ parts.push(remaining.slice(lastIndex, match.index));
629
+ }
630
+ // The complete block itself — keep it
631
+ parts.push(match[0]);
632
+ lastIndex = match.index + match[0].length;
633
+ }
634
+
635
+ // Remaining text after the last complete block
636
+ const tail = remaining.slice(lastIndex);
637
+
638
+ // Check if tail contains an incomplete ```action block (started but not closed)
639
+ const incompleteStart = tail.indexOf("```action");
640
+ if (incompleteStart !== -1 && isStreaming) {
641
+ // Text before the incomplete block
642
+ parts.push(tail.slice(0, incompleteStart));
643
+ // Don't include the incomplete block — it will show as a placeholder
644
+ // (handled in MessageContent render)
645
+ } else {
646
+ parts.push(tail);
647
+ }
648
+
649
+ return parts.join("");
650
+ }
651
+
652
+ /** Check if text has an incomplete (unclosed) ```action block */
653
+ function hasIncompleteActionBlock(text: string): boolean {
654
+ if (!text.includes("```action")) return false;
655
+ // Remove all complete blocks
656
+ const stripped = text.replace(/```action\s*\n[\s\S]*?```/g, "");
657
+ // Check if there's still a ```action tag left (unclosed)
658
+ return stripped.includes("```action");
659
+ }
660
+
661
+ // ---------------------------------------------------------------------------
662
+ // ActionCard — interactive decision widget (rendered from parsed action JSON)
663
+ // ---------------------------------------------------------------------------
664
+
665
+ function ActionCard({
666
+ action,
667
+ resolved,
668
+ onResolve,
669
+ }: {
670
+ action: ParsedAction;
671
+ resolved?: { value: string; label: string };
672
+ onResolve?: (value: string, label: string) => void;
673
+ }) {
674
+ const [inputValue, setInputValue] = useState("");
675
+ const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
676
+
677
+ // ---- Resolved state: show what was selected ----
678
+ if (resolved) {
679
+ return (
680
+ <div
681
+ className="mt-2 rounded-lg px-4 py-3 flex items-center gap-2"
682
+ style={{
683
+ background: "var(--bg-hover)",
684
+ border: "1px solid var(--border)",
685
+ opacity: 0.85,
686
+ }}
687
+ >
688
+ <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5} style={{ color: "var(--accent-green, #34d399)" }}>
689
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
690
+ </svg>
691
+ <span className="text-caption" style={{ color: "var(--text-secondary)" }}>
692
+ Selected: <strong style={{ color: "var(--text-primary)" }}>{resolved.label ?? resolved.value}</strong>
693
+ </span>
694
+ </div>
695
+ );
696
+ }
697
+
698
+ const handleClick = (item: ActionItem) => {
699
+ if (onResolve) onResolve(item.value, item.label);
700
+ };
701
+
702
+ const handleSubmitInput = () => {
703
+ const trimmed = inputValue.trim();
704
+ if (trimmed && onResolve) onResolve(trimmed, trimmed);
705
+ };
706
+
707
+ // ---- Style helpers for buttons ----
708
+ const btnColor = (item: ActionItem, idx: number) => {
709
+ const isHover = hoveredIdx === idx;
710
+ if (item.style === "primary") {
711
+ return {
712
+ background: isHover ? "var(--bg-active)" : "var(--bg-active)",
713
+ color: "#fff",
714
+ border: "none",
715
+ opacity: isHover ? 0.9 : 1,
716
+ };
717
+ }
718
+ if (item.style === "danger") {
719
+ return {
720
+ background: isHover ? "rgba(239,68,68,0.15)" : "transparent",
721
+ color: "var(--accent-red, #ef4444)",
722
+ border: "1px solid var(--accent-red, #ef4444)",
723
+ };
724
+ }
725
+ // secondary / default
726
+ return {
727
+ background: isHover ? "var(--bg-hover)" : "transparent",
728
+ color: "var(--text-link)",
729
+ border: "1px solid var(--border)",
730
+ };
731
+ };
732
+
733
+ // ---- Render confirm (Yes / No) ----
734
+ if (action.kind === "confirm") {
735
+ const yesItem: ActionItem = action.items?.[0] ?? { label: "Yes", value: "yes", style: "primary" };
736
+ const noItem: ActionItem = action.items?.[1] ?? { label: "No", value: "no", style: "secondary" };
737
+ return (
738
+ <div
739
+ className="mt-2 rounded-lg overflow-hidden"
740
+ style={{ border: "1px solid var(--border)", background: "var(--bg-secondary, var(--bg-hover))" }}
741
+ >
742
+ {action.prompt && (
743
+ <div className="px-4 py-3" style={{ borderBottom: "1px solid var(--border)" }}>
744
+ <p className="text-body font-bold" style={{ color: "var(--text-primary)" }}>{action.prompt}</p>
745
+ </div>
746
+ )}
747
+ <div className="flex">
748
+ <button
749
+ onClick={() => handleClick(noItem)}
750
+ onMouseEnter={() => setHoveredIdx(0)}
751
+ onMouseLeave={() => setHoveredIdx(null)}
752
+ className="flex-1 px-4 py-2.5 text-caption font-bold transition-all cursor-pointer"
753
+ style={{
754
+ background: hoveredIdx === 0 ? "var(--bg-hover)" : "transparent",
755
+ color: "var(--text-secondary)",
756
+ borderRight: "1px solid var(--border)",
757
+ border: "none",
758
+ borderRightWidth: 1,
759
+ borderRightStyle: "solid",
760
+ borderRightColor: "var(--border)",
761
+ }}
762
+ >
763
+ {noItem.label}
764
+ </button>
765
+ <button
766
+ onClick={() => handleClick(yesItem)}
767
+ onMouseEnter={() => setHoveredIdx(1)}
768
+ onMouseLeave={() => setHoveredIdx(null)}
769
+ className="flex-1 px-4 py-2.5 text-caption font-bold transition-all cursor-pointer"
770
+ style={{
771
+ background: hoveredIdx === 1 ? "var(--bg-active)" : "var(--bg-active)",
772
+ color: "#fff",
773
+ border: "none",
774
+ opacity: hoveredIdx === 1 ? 0.9 : 1,
775
+ }}
776
+ >
777
+ {yesItem.label}
778
+ </button>
779
+ </div>
780
+ </div>
781
+ );
782
+ }
783
+
784
+ // ---- Render buttons (multiple options) ----
785
+ if (action.kind === "buttons" && action.items) {
786
+ return (
787
+ <div
788
+ className="mt-2 rounded-lg overflow-hidden"
789
+ style={{ border: "1px solid var(--border)", background: "var(--bg-secondary, var(--bg-hover))" }}
790
+ >
791
+ {action.prompt && (
792
+ <div className="px-4 py-3" style={{ borderBottom: "1px solid var(--border)" }}>
793
+ <p className="text-body font-bold" style={{ color: "var(--text-primary)" }}>{action.prompt}</p>
794
+ </div>
795
+ )}
796
+ <div className="flex flex-wrap gap-2 px-4 py-3">
797
+ {action.items.map((item, idx) => (
798
+ <button
799
+ key={item.value}
800
+ onClick={() => handleClick(item)}
801
+ onMouseEnter={() => setHoveredIdx(idx)}
802
+ onMouseLeave={() => setHoveredIdx(null)}
803
+ className="inline-flex items-center gap-1.5 px-4 py-2 rounded-md text-caption font-bold transition-all cursor-pointer"
804
+ style={btnColor(item, idx)}
805
+ >
806
+ {item.label}
807
+ </button>
808
+ ))}
809
+ </div>
810
+ </div>
811
+ );
812
+ }
813
+
814
+ // ---- Render select (vertical option list) ----
815
+ if (action.kind === "select" && action.items) {
816
+ return (
817
+ <div
818
+ className="mt-2 rounded-lg overflow-hidden"
819
+ style={{ border: "1px solid var(--border)", background: "var(--bg-secondary, var(--bg-hover))" }}
820
+ >
821
+ {action.prompt && (
822
+ <div className="px-4 py-3" style={{ borderBottom: "1px solid var(--border)" }}>
823
+ <p className="text-body font-bold" style={{ color: "var(--text-primary)" }}>{action.prompt}</p>
824
+ </div>
825
+ )}
826
+ <div className="flex flex-col">
827
+ {action.items.map((item, idx) => (
828
+ <button
829
+ key={item.value}
830
+ onClick={() => handleClick(item)}
831
+ onMouseEnter={() => setHoveredIdx(idx)}
832
+ onMouseLeave={() => setHoveredIdx(null)}
833
+ className="flex items-center gap-3 px-4 py-2.5 text-left transition-all cursor-pointer"
834
+ style={{
835
+ background: hoveredIdx === idx ? "var(--bg-hover)" : "transparent",
836
+ color: "var(--text-primary)",
837
+ borderBottom: idx < action.items!.length - 1 ? "1px solid var(--border)" : "none",
838
+ border: "none",
839
+ borderBottomWidth: idx < action.items!.length - 1 ? 1 : 0,
840
+ borderBottomStyle: "solid",
841
+ borderBottomColor: "var(--border)",
842
+ }}
843
+ >
844
+ <span
845
+ className="w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0"
846
+ style={{
847
+ border: "2px solid var(--border)",
848
+ background: hoveredIdx === idx ? "var(--bg-active)" : "transparent",
849
+ }}
850
+ >
851
+ {hoveredIdx === idx && (
852
+ <span className="w-2 h-2 rounded-full" style={{ background: "#fff" }} />
853
+ )}
854
+ </span>
855
+ <span className="text-caption">{item.label}</span>
856
+ </button>
857
+ ))}
858
+ </div>
859
+ </div>
860
+ );
861
+ }
862
+
863
+ // ---- Render input (free text entry) ----
864
+ if (action.kind === "input") {
865
+ return (
866
+ <div
867
+ className="mt-2 rounded-lg overflow-hidden"
868
+ style={{ border: "1px solid var(--border)", background: "var(--bg-secondary, var(--bg-hover))" }}
869
+ >
870
+ {action.prompt && (
871
+ <div className="px-4 py-3" style={{ borderBottom: "1px solid var(--border)" }}>
872
+ <p className="text-body font-bold" style={{ color: "var(--text-primary)" }}>{action.prompt}</p>
873
+ </div>
874
+ )}
875
+ <div className="flex items-center gap-2 px-4 py-3">
876
+ <input
877
+ type="text"
878
+ value={inputValue}
879
+ onChange={(e) => setInputValue(e.target.value)}
880
+ onKeyDown={(e) => { if (e.key === "Enter") handleSubmitInput(); }}
881
+ placeholder={action.placeholder ?? "Type your answer..."}
882
+ className="flex-1 px-3 py-2 rounded-md text-caption outline-none"
883
+ style={{
884
+ background: "var(--bg-primary, var(--bg))",
885
+ color: "var(--text-primary)",
886
+ border: "1px solid var(--border)",
887
+ }}
888
+ />
889
+ <button
890
+ onClick={handleSubmitInput}
891
+ disabled={!inputValue.trim()}
892
+ className="px-4 py-2 rounded-md text-caption font-bold transition-all cursor-pointer"
893
+ style={{
894
+ background: inputValue.trim() ? "var(--bg-active)" : "var(--bg-hover)",
895
+ color: inputValue.trim() ? "#fff" : "var(--text-muted)",
896
+ border: "none",
897
+ }}
898
+ >
899
+ Submit
900
+ </button>
901
+ </div>
902
+ </div>
903
+ );
904
+ }
905
+
906
+ return null;
907
+ }
908
+
909
+ // ---------------------------------------------------------------------------
910
+ // Streaming placeholder for incomplete action blocks
911
+ // ---------------------------------------------------------------------------
912
+
913
+ function ActionBlockPlaceholder() {
914
+ return (
915
+ <div
916
+ className="mt-2 rounded-lg px-4 py-3 flex items-center gap-3"
917
+ style={{
918
+ background: "var(--bg-secondary, var(--bg-hover))",
919
+ border: "1px solid var(--border)",
920
+ }}
921
+ >
922
+ <span
923
+ className="inline-block w-2 h-2 rounded-full animate-pulse"
924
+ style={{ background: "var(--text-link)" }}
925
+ />
926
+ <span className="text-caption" style={{ color: "var(--text-muted)" }}>
927
+ Preparing options...
928
+ </span>
929
+ </div>
930
+ );
931
+ }
932
+
933
+ // ---------------------------------------------------------------------------
934
+ // Main component
935
+ // ---------------------------------------------------------------------------
936
+
937
+ /** Renders message body: optional image, Markdown text, A2UI blocks, action widgets. */
938
+ export function MessageContent({
939
+ text,
940
+ mediaUrl,
941
+ a2ui,
942
+ className = "",
943
+ isStreaming,
944
+ onAction,
945
+ onResolveAction,
946
+ resolvedActions,
947
+ }: MessageContentProps) {
948
+ // Build markdown components with action-resolve callbacks baked in
949
+ const markdownComponents = useMemo(
950
+ () => buildMarkdownComponents(onResolveAction, resolvedActions),
951
+ [onResolveAction, resolvedActions],
952
+ );
953
+
954
+ // Preprocess text: strip incomplete ```action blocks during streaming
955
+ const processedText = useMemo(
956
+ () => preprocessActionBlocks(text, isStreaming),
957
+ [text, isStreaming],
958
+ );
959
+
960
+ // Show placeholder while an action block is being streamed
961
+ const showActionPlaceholder = isStreaming && hasIncompleteActionBlock(text);
962
+
963
+ return (
964
+ <div className={className}>
965
+ {/* Media preview */}
966
+ {mediaUrl && (
967
+ <div className="mb-2">
968
+ <MediaPreview url={mediaUrl} />
969
+ </div>
970
+ )}
971
+
972
+ {/* Markdown text with enhanced rendering */}
973
+ {processedText ? (
974
+ <div
975
+ className="prose prose-sm max-w-none prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-pre:my-0 prose-code:before:content-none prose-code:after:content-none prose-headings:my-2"
976
+ style={{
977
+ color: "var(--text-primary)",
978
+ "--tw-prose-headings": "var(--text-primary)",
979
+ "--tw-prose-bold": "var(--text-primary)",
980
+ "--tw-prose-code": "var(--code-text)",
981
+ "--tw-prose-pre-code": "var(--text-primary)",
982
+ "--tw-prose-pre-bg": "var(--code-bg)",
983
+ "--tw-prose-bullets": "var(--text-muted)",
984
+ "--tw-prose-counters": "var(--text-muted)",
985
+ } as React.CSSProperties}
986
+ >
987
+ <ReactMarkdown
988
+ remarkPlugins={[remarkGfm]}
989
+ rehypePlugins={[rehypeHighlight]}
990
+ components={markdownComponents}
991
+ >
992
+ {processedText}
993
+ </ReactMarkdown>
994
+ </div>
995
+ ) : null}
996
+
997
+ {/* Pulsing placeholder for incomplete action block during streaming */}
998
+ {showActionPlaceholder && <ActionBlockPlaceholder />}
999
+
1000
+ {/* A2UI structured rendering */}
1001
+ {a2ui && <A2UIRenderer jsonl={a2ui} onAction={onAction} />}
1002
+ </div>
1003
+ );
1004
+ }
1005
+
1006
+ // ---------------------------------------------------------------------------
1007
+ // Media preview — handles images, audio, video, and file downloads
1008
+ // ---------------------------------------------------------------------------
1009
+
1010
+ function MediaPreview({ url }: { url: string }) {
1011
+ const ext = url.split(".").pop()?.toLowerCase().split("?")[0] ?? "";
1012
+
1013
+ // Audio
1014
+ if (["mp3", "wav", "ogg", "m4a", "aac", "webm"].includes(ext)) {
1015
+ return (
1016
+ <div
1017
+ className="flex items-center gap-3 px-3 py-2 rounded-md max-w-[360px]"
1018
+ style={{ background: "var(--bg-hover)", border: "1px solid var(--border)" }}
1019
+ >
1020
+ <svg className="w-8 h-8 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} style={{ color: "var(--text-link)" }}>
1021
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z" />
1022
+ </svg>
1023
+ <audio controls className="flex-1 h-8" style={{ maxWidth: 280 }}>
1024
+ <source src={url} />
1025
+ </audio>
1026
+ </div>
1027
+ );
1028
+ }
1029
+
1030
+ // Video
1031
+ if (["mp4", "mov", "avi", "mkv"].includes(ext)) {
1032
+ return (
1033
+ <video
1034
+ controls
1035
+ className="max-w-[360px] max-h-64 rounded-md"
1036
+ style={{ border: "1px solid var(--border)" }}
1037
+ >
1038
+ <source src={url} />
1039
+ </video>
1040
+ );
1041
+ }
1042
+
1043
+ // PDF / downloadable file
1044
+ if (["pdf", "zip", "tar", "gz", "doc", "docx", "xls", "xlsx", "csv"].includes(ext)) {
1045
+ const filename = url.split("/").pop()?.split("?")[0] ?? "file";
1046
+ return (
1047
+ <a
1048
+ href={url}
1049
+ target="_blank"
1050
+ rel="noopener noreferrer"
1051
+ className="flex items-center gap-3 px-3 py-2.5 rounded-md max-w-[360px] hover:opacity-90 transition-opacity"
1052
+ style={{ background: "var(--bg-hover)", border: "1px solid var(--border)", textDecoration: "none" }}
1053
+ >
1054
+ <svg className="w-8 h-8 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} style={{ color: "var(--text-link)" }}>
1055
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
1056
+ </svg>
1057
+ <div className="flex-1 min-w-0">
1058
+ <p className="text-caption font-bold truncate" style={{ color: "var(--text-primary)" }}>
1059
+ {filename}
1060
+ </p>
1061
+ <p className="text-tiny" style={{ color: "var(--text-muted)" }}>
1062
+ {ext.toUpperCase()} — Click to open
1063
+ </p>
1064
+ </div>
1065
+ <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: "var(--text-muted)" }}>
1066
+ <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
1067
+ </svg>
1068
+ </a>
1069
+ );
1070
+ }
1071
+
1072
+ // Default: image
1073
+ return (
1074
+ <img
1075
+ src={url}
1076
+ alt=""
1077
+ className="max-w-[360px] max-h-64 rounded-md object-contain cursor-pointer hover:opacity-90 transition-opacity"
1078
+ style={{ border: "1px solid var(--border)" }}
1079
+ onClick={() => window.open(url, "_blank")}
1080
+ />
1081
+ );
1082
+ }