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.
- package/LICENSE +201 -0
- package/README.md +213 -0
- package/migrations/0001_initial.sql +88 -0
- package/migrations/0002_rename_projects_to_channels.sql +53 -0
- package/migrations/0003_messages.sql +14 -0
- package/migrations/0004_jobs.sql +15 -0
- package/migrations/0005_deleted_cron_jobs.sql +6 -0
- package/migrations/0006_tasks_add_model.sql +2 -0
- package/migrations/0007_sessions.sql +25 -0
- package/migrations/0008_remove_openclaw_fields.sql +8 -0
- package/package.json +53 -0
- package/packages/api/package.json +17 -0
- package/packages/api/src/do/connection-do.ts +929 -0
- package/packages/api/src/env.ts +8 -0
- package/packages/api/src/index.ts +297 -0
- package/packages/api/src/routes/agents.ts +68 -0
- package/packages/api/src/routes/auth.ts +105 -0
- package/packages/api/src/routes/channels.ts +185 -0
- package/packages/api/src/routes/jobs.ts +65 -0
- package/packages/api/src/routes/models.ts +22 -0
- package/packages/api/src/routes/pairing.ts +76 -0
- package/packages/api/src/routes/projects.ts +177 -0
- package/packages/api/src/routes/sessions.ts +171 -0
- package/packages/api/src/routes/tasks.ts +375 -0
- package/packages/api/src/routes/upload.ts +52 -0
- package/packages/api/src/utils/auth.ts +101 -0
- package/packages/api/src/utils/id.ts +19 -0
- package/packages/api/tsconfig.json +18 -0
- package/packages/plugin/dist/index.d.ts +19 -0
- package/packages/plugin/dist/index.d.ts.map +1 -0
- package/packages/plugin/dist/index.js +17 -0
- package/packages/plugin/dist/index.js.map +1 -0
- package/packages/plugin/dist/src/accounts.d.ts +12 -0
- package/packages/plugin/dist/src/accounts.d.ts.map +1 -0
- package/packages/plugin/dist/src/accounts.js +103 -0
- package/packages/plugin/dist/src/accounts.js.map +1 -0
- package/packages/plugin/dist/src/channel.d.ts +206 -0
- package/packages/plugin/dist/src/channel.d.ts.map +1 -0
- package/packages/plugin/dist/src/channel.js +1248 -0
- package/packages/plugin/dist/src/channel.js.map +1 -0
- package/packages/plugin/dist/src/runtime.d.ts +3 -0
- package/packages/plugin/dist/src/runtime.d.ts.map +1 -0
- package/packages/plugin/dist/src/runtime.js +18 -0
- package/packages/plugin/dist/src/runtime.js.map +1 -0
- package/packages/plugin/dist/src/types.d.ts +179 -0
- package/packages/plugin/dist/src/types.d.ts.map +1 -0
- package/packages/plugin/dist/src/types.js +6 -0
- package/packages/plugin/dist/src/types.js.map +1 -0
- package/packages/plugin/dist/src/ws-client.d.ts +51 -0
- package/packages/plugin/dist/src/ws-client.d.ts.map +1 -0
- package/packages/plugin/dist/src/ws-client.js +170 -0
- package/packages/plugin/dist/src/ws-client.js.map +1 -0
- package/packages/plugin/openclaw.plugin.json +11 -0
- package/packages/plugin/package.json +39 -0
- package/packages/plugin/tsconfig.json +20 -0
- package/packages/web/dist/assets/index-C-wI8eHy.css +1 -0
- package/packages/web/dist/assets/index-CbPEKHLG.js +93 -0
- package/packages/web/dist/index.html +17 -0
- package/packages/web/index.html +16 -0
- package/packages/web/package.json +29 -0
- package/packages/web/postcss.config.js +6 -0
- package/packages/web/src/App.tsx +827 -0
- package/packages/web/src/api.ts +242 -0
- package/packages/web/src/components/ChatWindow.tsx +864 -0
- package/packages/web/src/components/CronDetail.tsx +943 -0
- package/packages/web/src/components/CronSidebar.tsx +123 -0
- package/packages/web/src/components/DebugLogPanel.tsx +258 -0
- package/packages/web/src/components/IconRail.tsx +163 -0
- package/packages/web/src/components/JobList.tsx +120 -0
- package/packages/web/src/components/LoginPage.tsx +178 -0
- package/packages/web/src/components/MessageContent.tsx +1082 -0
- package/packages/web/src/components/ModelSelect.tsx +87 -0
- package/packages/web/src/components/ScheduleEditor.tsx +403 -0
- package/packages/web/src/components/SessionTabs.tsx +246 -0
- package/packages/web/src/components/Sidebar.tsx +331 -0
- package/packages/web/src/components/TaskBar.tsx +413 -0
- package/packages/web/src/components/ThreadPanel.tsx +212 -0
- package/packages/web/src/debug-log.ts +58 -0
- package/packages/web/src/index.css +170 -0
- package/packages/web/src/main.tsx +10 -0
- package/packages/web/src/store.ts +492 -0
- package/packages/web/src/ws.ts +99 -0
- package/packages/web/tailwind.config.js +65 -0
- package/packages/web/tsconfig.json +18 -0
- package/packages/web/vite.config.ts +20 -0
- package/scripts/dev.sh +122 -0
- package/tsconfig.json +18 -0
- 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)" }}>•</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
|
+
}
|