@springbrand/message-panel 0.1.3-alpha.1 → 0.1.3-alpha.2

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.
@@ -0,0 +1,599 @@
1
+ import type { UIMessage } from "ai";
2
+ import {
3
+ buildToolCallGroups,
4
+ toCloudOsToolCall,
5
+ type CloudOsToolCall,
6
+ type ToolCallGroup,
7
+ } from "./tool-presentation";
8
+
9
+ /**
10
+ * UIMessage → cloud-os 展示条目。
11
+ *
12
+ * cloudflare-os-main 的 `buildChatDisplayEntries` 吃的是 gadgets 自己的
13
+ * `AiChatMessage`(离散消息类型 + 独立的 toolCalls 数组)。UIMessage 的事实是
14
+ * **一条 assistant 消息里一个有序 parts 数组**,所以这里把「消息类型分派」换成
15
+ * 「按 parts 顺序切块」:连续的工具 part 攒成一个 ToolCallGroup,遇到非工具
16
+ * part 就 flush。原文件的行间距规则(rhythmTopClass)照搬。
17
+ */
18
+
19
+ type UnknownRecord = Record<string, unknown>;
20
+
21
+ export interface CloudOsMessageMetadata extends UnknownRecord {
22
+ authorDisplayName?: string;
23
+ completedAt?: number;
24
+ createdAt?: number;
25
+ error?: string;
26
+ interruptedByUser?: boolean;
27
+ turnDurationMs?: number;
28
+ turnStartedAt?: number;
29
+ turnStatus?: string;
30
+ }
31
+
32
+ export interface CloudOsAttachment {
33
+ url: string;
34
+ filename?: string;
35
+ mediaType?: string;
36
+ }
37
+
38
+ export interface PlanStep {
39
+ text: string;
40
+ status: "pending" | "in_progress" | "done";
41
+ }
42
+
43
+ export type AssistantBlock =
44
+ | { kind: "reasoning"; key: string; text: string }
45
+ | { kind: "text"; key: string; text: string }
46
+ | { kind: "toolGroup"; key: string; group: ToolCallGroup }
47
+ | { kind: "plan"; key: string; steps: PlanStep[]; running: boolean }
48
+ // answeredText 取的是**这条消息之后的第一条用户直答**,不是工具 output ——
49
+ // ask_user 的 output 往往只是 `{ asked: true }` 之类的回执。
50
+ | { kind: "askUser"; key: string; call: CloudOsToolCall; answeredText?: string }
51
+ | { kind: "suggestions"; key: string; items: string[] }
52
+ | { kind: "schedule"; key: string; call: CloudOsToolCall }
53
+ | { kind: "approval"; key: string; call: CloudOsToolCall; approvalId: string }
54
+ | { kind: "parallel"; key: string; label: string; tools: ParallelTool[] }
55
+ | { kind: "subagents"; key: string; agents: SubAgentView[] }
56
+ | { kind: "image"; key: string; src: string; alt: string }
57
+ | { kind: "error"; key: string; title: string; body?: string };
58
+
59
+ export interface ParallelTool {
60
+ label: string;
61
+ status: "pending" | "running" | "done" | "failed";
62
+ meta?: string;
63
+ }
64
+
65
+ export interface SubAgentView {
66
+ name: string;
67
+ summary?: string;
68
+ status: "pending" | "running" | "done" | "failed";
69
+ }
70
+
71
+ export type CloudOsEntry =
72
+ | {
73
+ type: "user";
74
+ key: string;
75
+ messageId: string;
76
+ text: string;
77
+ attachments: CloudOsAttachment[];
78
+ authorName?: string;
79
+ timestamp?: number;
80
+ }
81
+ | {
82
+ type: "slashCommand";
83
+ key: string;
84
+ messageId: string;
85
+ text: string;
86
+ timestamp?: number;
87
+ }
88
+ | { type: "localCommandOutput"; key: string; messageId: string; text: string }
89
+ | { type: "notice"; key: string; messageId: string; text: string }
90
+ | { type: "compactionBoundary"; key: string; messageId: string; summary: string }
91
+ | {
92
+ type: "assistant";
93
+ key: string;
94
+ messageId: string;
95
+ blocks: AssistantBlock[];
96
+ copyText: string;
97
+ timestamp?: number;
98
+ terminal?: { kind: "interrupted" | "error"; title: string; body?: string };
99
+ };
100
+
101
+ // ── 小工具 ──────────────────────────────────────────────────────────────────
102
+
103
+ function recordOf(value: unknown): UnknownRecord {
104
+ return value != null && typeof value === "object"
105
+ ? (value as UnknownRecord)
106
+ : {};
107
+ }
108
+
109
+ function dataOf(part: unknown): UnknownRecord {
110
+ const record = recordOf(part);
111
+ return recordOf(record.data ?? record.input ?? record);
112
+ }
113
+
114
+ function nonNegativeNumber(value: unknown): number | undefined {
115
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
116
+ ? value
117
+ : undefined;
118
+ }
119
+
120
+ export function cloudOsMetadata(message: UIMessage): CloudOsMessageMetadata {
121
+ return recordOf(message.metadata) as CloudOsMessageMetadata;
122
+ }
123
+
124
+ export function messageText(message: UIMessage): string {
125
+ return message.parts
126
+ .filter(
127
+ (part): part is { type: "text"; text: string } =>
128
+ part.type === "text" && typeof part.text === "string" &&
129
+ part.text.trim().length > 0,
130
+ )
131
+ .map((part) => part.text)
132
+ .join("\n");
133
+ }
134
+
135
+ export function formatClockTime(timestamp: number): string {
136
+ return new Date(timestamp).toLocaleTimeString([], {
137
+ hour: "2-digit",
138
+ minute: "2-digit",
139
+ });
140
+ }
141
+
142
+ export function formatFullTimestamp(timestamp: number): string {
143
+ return new Date(timestamp).toLocaleString([], {
144
+ dateStyle: "medium",
145
+ timeStyle: "short",
146
+ });
147
+ }
148
+
149
+ function toolNameOf(part: unknown): string | null {
150
+ const type = String(recordOf(part).type ?? "");
151
+ if (type === "dynamic-tool") {
152
+ const name = recordOf(part).toolName;
153
+ return typeof name === "string" ? name : "dynamic";
154
+ }
155
+ return type.startsWith("tool-") ? type.slice(5) : null;
156
+ }
157
+
158
+ function dataKindOf(part: unknown): string | null {
159
+ const type = String(recordOf(part).type ?? "");
160
+ return type.startsWith("data-") ? type.slice(5).replaceAll("-", "_") : null;
161
+ }
162
+
163
+ function planStepsOf(input: UnknownRecord): PlanStep[] {
164
+ const raw = Array.isArray(input.steps)
165
+ ? input.steps
166
+ : Array.isArray(input.todos)
167
+ ? input.todos
168
+ : [];
169
+ return raw.map((item) => {
170
+ const step = recordOf(item);
171
+ const status = String(step.status ?? "pending");
172
+ return {
173
+ text: String(step.text ?? step.content ?? step.activeForm ?? "Untitled step"),
174
+ status:
175
+ status === "in_progress"
176
+ ? "in_progress"
177
+ : status === "done" || status === "completed"
178
+ ? "done"
179
+ : "pending",
180
+ };
181
+ });
182
+ }
183
+
184
+ function stringList(value: unknown): string[] {
185
+ return Array.isArray(value)
186
+ ? value.filter((item): item is string => typeof item === "string")
187
+ : [];
188
+ }
189
+
190
+ function normalizeStatus(value: unknown): ParallelTool["status"] {
191
+ const status = String(value ?? "");
192
+ if (status === "running" || status === "done" || status === "failed") return status;
193
+ return "pending";
194
+ }
195
+
196
+ // ── 独立消息(非 assistant 回合)判定 ────────────────────────────────────────
197
+
198
+ function standaloneEntry(
199
+ message: UIMessage,
200
+ index: number,
201
+ ): CloudOsEntry | null {
202
+ const metadata = cloudOsMetadata(message);
203
+ for (const part of message.parts) {
204
+ const kind = dataKindOf(part);
205
+ if (kind === null) continue;
206
+ const data = dataOf(part);
207
+ const text = String(data.text ?? data.command ?? data.output ?? "").trim();
208
+ if (kind === "slash_command") {
209
+ return {
210
+ type: "slashCommand",
211
+ key: `${message.id}-${index}`,
212
+ messageId: message.id,
213
+ text,
214
+ timestamp: nonNegativeNumber(metadata.createdAt),
215
+ };
216
+ }
217
+ if (kind === "local_command_output") {
218
+ return {
219
+ type: "localCommandOutput",
220
+ key: `${message.id}-${index}`,
221
+ messageId: message.id,
222
+ text,
223
+ };
224
+ }
225
+ if (kind === "compact_summary") {
226
+ return {
227
+ type: "compactionBoundary",
228
+ key: `${message.id}-${index}`,
229
+ messageId: message.id,
230
+ summary: text,
231
+ };
232
+ }
233
+ if (kind === "notice") {
234
+ return {
235
+ type: "notice",
236
+ key: `${message.id}-${index}`,
237
+ messageId: message.id,
238
+ text,
239
+ };
240
+ }
241
+ }
242
+ return null;
243
+ }
244
+
245
+ // ── assistant 回合切块 ──────────────────────────────────────────────────────
246
+
247
+ function assistantBlocks(
248
+ message: UIMessage,
249
+ isActive: boolean,
250
+ showThinkingTraces: boolean,
251
+ replyText: string | undefined,
252
+ approvalIdOf: (part: unknown) => string | undefined,
253
+ ): AssistantBlock[] {
254
+ const blocks: AssistantBlock[] = [];
255
+ let pendingCalls: CloudOsToolCall[] = [];
256
+
257
+ const flush = () => {
258
+ if (pendingCalls.length === 0) return;
259
+ for (const group of buildToolCallGroups(pendingCalls)) {
260
+ blocks.push({ kind: "toolGroup", key: group.key, group });
261
+ }
262
+ pendingCalls = [];
263
+ };
264
+
265
+ message.parts.forEach((part, partIndex) => {
266
+ const key = `${message.id}:${partIndex}`;
267
+ const record = recordOf(part);
268
+ const type = String(record.type ?? "");
269
+
270
+ if (type === "step-start") return;
271
+
272
+ if (type === "text") {
273
+ const text = String(record.text ?? "").trim();
274
+ if (!text) return;
275
+ flush();
276
+ blocks.push({ kind: "text", key, text });
277
+ return;
278
+ }
279
+
280
+ if (type === "reasoning") {
281
+ const text = String(record.text ?? "").trim();
282
+ if (!text || !showThinkingTraces) return;
283
+ flush();
284
+ blocks.push({ kind: "reasoning", key, text });
285
+ return;
286
+ }
287
+
288
+ const dataKind = dataKindOf(part);
289
+ if (dataKind === "error") {
290
+ const data = dataOf(part);
291
+ flush();
292
+ blocks.push({
293
+ kind: "error",
294
+ key,
295
+ title: String(data.title ?? "Error"),
296
+ body: String(data.body ?? data.message ?? "") || undefined,
297
+ });
298
+ return;
299
+ }
300
+ if (dataKind === "parallel") {
301
+ const data = dataOf(part);
302
+ flush();
303
+ blocks.push({
304
+ kind: "parallel",
305
+ key,
306
+ label: String(data.label ?? "Parallel tasks"),
307
+ tools: (Array.isArray(data.tools) ? data.tools : []).map((item) => {
308
+ const tool = recordOf(item);
309
+ return {
310
+ label: String(tool.label ?? "Task"),
311
+ status: normalizeStatus(tool.status),
312
+ meta: typeof tool.meta === "string" ? tool.meta : undefined,
313
+ };
314
+ }),
315
+ });
316
+ return;
317
+ }
318
+ if (dataKind === "subagents") {
319
+ const data = dataOf(part);
320
+ flush();
321
+ blocks.push({
322
+ kind: "subagents",
323
+ key,
324
+ agents: (Array.isArray(data.agents) ? data.agents : []).map((item) => {
325
+ const agent = recordOf(item);
326
+ return {
327
+ name: String(agent.name ?? "Sub-agent"),
328
+ summary:
329
+ typeof agent.summary === "string" && agent.summary
330
+ ? agent.summary
331
+ : undefined,
332
+ status: normalizeStatus(agent.status),
333
+ };
334
+ }),
335
+ });
336
+ return;
337
+ }
338
+ if (dataKind === "image" || type === "file") {
339
+ const data = dataOf(part);
340
+ const src = String(data.src ?? data.url ?? record.url ?? "");
341
+ const mediaType = String(record.mediaType ?? data.mediaType ?? "");
342
+ if (src && (dataKind === "image" || mediaType.startsWith("image/"))) {
343
+ flush();
344
+ blocks.push({
345
+ kind: "image",
346
+ key,
347
+ src,
348
+ alt: String(data.alt ?? data.filename ?? record.filename ?? "Image"),
349
+ });
350
+ }
351
+ return;
352
+ }
353
+
354
+ const toolName = toolNameOf(part);
355
+ if (toolName === null) return;
356
+
357
+ const call = toCloudOsToolCall(part, key, isActive);
358
+
359
+ if (call.awaitingApproval) {
360
+ const approvalId = approvalIdOf(part);
361
+ if (approvalId !== undefined) {
362
+ flush();
363
+ blocks.push({ kind: "approval", key, call, approvalId });
364
+ return;
365
+ }
366
+ }
367
+
368
+ if (call.kind === "tasks") {
369
+ const steps = planStepsOf(call.input);
370
+ if (steps.length > 0) {
371
+ flush();
372
+ blocks.push({ kind: "plan", key, steps, running: call.running });
373
+ return;
374
+ }
375
+ }
376
+
377
+ if (call.kind === "ask-user") {
378
+ flush();
379
+ blocks.push({
380
+ kind: "askUser",
381
+ key,
382
+ call,
383
+ answeredText:
384
+ replyText?.trim() ||
385
+ (typeof call.output === "string" ? call.output.trim() : "") ||
386
+ undefined,
387
+ });
388
+ return;
389
+ }
390
+
391
+ if (call.kind === "suggestions") {
392
+ const items = stringList(call.input.items ?? dataOf(part).items);
393
+ if (items.length > 0) {
394
+ flush();
395
+ blocks.push({ kind: "suggestions", key, items });
396
+ return;
397
+ }
398
+ }
399
+
400
+ if (call.kind === "schedule" && call.toolName === "schedule") {
401
+ flush();
402
+ blocks.push({ kind: "schedule", key, call });
403
+ return;
404
+ }
405
+
406
+ pendingCalls.push(call);
407
+ });
408
+
409
+ flush();
410
+ return blocks;
411
+ }
412
+
413
+ const USER_STOP_REASON = "Stopped by user";
414
+
415
+ function terminalOf(
416
+ message: UIMessage,
417
+ ): { kind: "interrupted" | "error"; title: string; body?: string } | undefined {
418
+ const metadata = cloudOsMetadata(message);
419
+ if (metadata.interruptedByUser === true) {
420
+ return { kind: "interrupted", title: USER_STOP_REASON };
421
+ }
422
+ if (metadata.turnStatus === "aborted" && metadata.error === USER_STOP_REASON) {
423
+ return undefined;
424
+ }
425
+ if (metadata.turnStatus === "error" || metadata.error) {
426
+ return {
427
+ kind: "error",
428
+ title: "Error",
429
+ body: metadata.error ?? "The response failed.",
430
+ };
431
+ }
432
+ return undefined;
433
+ }
434
+
435
+ function isDirectUserMessage(message: UIMessage): boolean {
436
+ return (
437
+ message.role === "user" &&
438
+ !message.parts.some((part) => dataKindOf(part) !== null)
439
+ );
440
+ }
441
+
442
+ export interface BuildEntriesOptions {
443
+ messages: readonly UIMessage[];
444
+ /** streaming/submitted 时最后一条 assistant 消息才算「活着」。 */
445
+ isActive: boolean;
446
+ showThinkingTraces: boolean;
447
+ /** 把某个 part 映射成待授权 id;返回 undefined 表示这条不走原位授权。 */
448
+ approvalIdOf?: (part: unknown) => string | undefined;
449
+ }
450
+
451
+ export function buildCloudOsEntries({
452
+ messages,
453
+ isActive,
454
+ showThinkingTraces,
455
+ approvalIdOf = () => undefined,
456
+ }: BuildEntriesOptions): CloudOsEntry[] {
457
+ const entries: CloudOsEntry[] = [];
458
+ const lastAssistantIndex = (() => {
459
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
460
+ if (messages[index].role === "assistant") return index;
461
+ }
462
+ return -1;
463
+ })();
464
+
465
+ messages.forEach((message, index) => {
466
+ if (message.role === "system") return;
467
+
468
+ const standalone = standaloneEntry(message, index);
469
+ if (standalone) {
470
+ entries.push(standalone);
471
+ return;
472
+ }
473
+
474
+ const metadata = cloudOsMetadata(message);
475
+
476
+ if (message.role === "user") {
477
+ const text = messageText(message);
478
+ const attachments = message.parts
479
+ .filter((part) => part.type === "file")
480
+ .map((part) => {
481
+ const record = recordOf(part);
482
+ return {
483
+ url: String(record.url ?? ""),
484
+ filename:
485
+ typeof record.filename === "string" ? record.filename : undefined,
486
+ mediaType:
487
+ typeof record.mediaType === "string" ? record.mediaType : undefined,
488
+ };
489
+ });
490
+ if (!text && attachments.length === 0) return;
491
+ entries.push({
492
+ type: "user",
493
+ key: `user-${message.id}-${index}`,
494
+ messageId: message.id,
495
+ text,
496
+ attachments,
497
+ authorName: metadata.authorDisplayName,
498
+ timestamp:
499
+ nonNegativeNumber(metadata.createdAt) ??
500
+ nonNegativeNumber(metadata.completedAt),
501
+ });
502
+ return;
503
+ }
504
+
505
+ const reply = messages.slice(index + 1).find(isDirectUserMessage);
506
+ const blocks = assistantBlocks(
507
+ message,
508
+ isActive && index === lastAssistantIndex,
509
+ showThinkingTraces,
510
+ reply ? messageText(reply).trim() || undefined : undefined,
511
+ approvalIdOf,
512
+ );
513
+ const terminal = terminalOf(message);
514
+ if (blocks.length === 0 && terminal === undefined) return;
515
+ entries.push({
516
+ type: "assistant",
517
+ key: `assistant-${message.id}-${index}`,
518
+ messageId: message.id,
519
+ blocks,
520
+ copyText: messageText(message),
521
+ timestamp:
522
+ nonNegativeNumber(metadata.completedAt) ??
523
+ nonNegativeNumber(metadata.createdAt),
524
+ terminal,
525
+ });
526
+ });
527
+
528
+ return dropSupersededPlans(entries);
529
+ }
530
+
531
+ /**
532
+ * update_plan 是**整份快照覆盖**,不是增量事件 —— 一次任务里每推进一步就会再发一份
533
+ * 完整计划。原样按序渲染会把同一份计划画四遍(0/3、1/3、2/3、3/3),只有最后那份
534
+ * 是当前事实。所以整条会话里只保留最后一份计划快照。
535
+ */
536
+ function dropSupersededPlans(entries: CloudOsEntry[]): CloudOsEntry[] {
537
+ let lastPlanEntry = -1;
538
+ let lastPlanBlock = -1;
539
+ entries.forEach((entry, entryIndex) => {
540
+ if (entry.type !== "assistant") return;
541
+ entry.blocks.forEach((block, blockIndex) => {
542
+ if (block.kind !== "plan") return;
543
+ lastPlanEntry = entryIndex;
544
+ lastPlanBlock = blockIndex;
545
+ });
546
+ });
547
+ if (lastPlanEntry === -1) return entries;
548
+
549
+ return entries.map((entry, entryIndex) =>
550
+ entry.type === "assistant"
551
+ ? {
552
+ ...entry,
553
+ blocks: entry.blocks.filter(
554
+ (block, blockIndex) =>
555
+ block.kind !== "plan" ||
556
+ (entryIndex === lastPlanEntry && blockIndex === lastPlanBlock),
557
+ ),
558
+ }
559
+ : entry,
560
+ );
561
+ }
562
+
563
+ // ── 行间距节奏(照搬原 rhythmTopClass)──────────────────────────────────────
564
+
565
+ function isUserEntry(entry: CloudOsEntry): boolean {
566
+ return entry.type === "user" || entry.type === "slashCommand";
567
+ }
568
+
569
+ function startsWithWorkRow(entry: CloudOsEntry): boolean {
570
+ return (
571
+ entry.type === "assistant" &&
572
+ entry.blocks[0] !== undefined &&
573
+ entry.blocks[0].kind === "toolGroup"
574
+ );
575
+ }
576
+
577
+ function endsInWorkRow(entry: CloudOsEntry): boolean {
578
+ return (
579
+ entry.type === "assistant" &&
580
+ entry.blocks.at(-1) !== undefined &&
581
+ entry.blocks.at(-1)!.kind === "toolGroup"
582
+ );
583
+ }
584
+
585
+ /**
586
+ * 相邻两条之间的上间距。原则(原文件注释):用户消息前后留最大的气口;
587
+ * 两条工作行之间贴紧;其余取中间档。
588
+ */
589
+ export function rhythmTopClass(
590
+ previous: CloudOsEntry | null,
591
+ entry: CloudOsEntry,
592
+ ): string {
593
+ if (!previous) return "";
594
+ if (isUserEntry(entry)) return "mt-8";
595
+ if (isUserEntry(previous)) return "mt-5";
596
+ if (endsInWorkRow(previous) && startsWithWorkRow(entry)) return "mt-2";
597
+ if (previous.type === "notice" || entry.type === "notice") return "mt-4";
598
+ return "mt-4";
599
+ }