@springbrand/message-panel 0.1.3-alpha.1 → 0.1.3-alpha.3
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/cloud-os/README.md +71 -0
- package/cloud-os/chat/activity-indicator.tsx +29 -0
- package/cloud-os/chat/cloud-os-chat-messages.tsx +632 -0
- package/cloud-os/chat/markdown-message.tsx +78 -0
- package/cloud-os/chat/rich-blocks.tsx +672 -0
- package/cloud-os/chat/tool-presentation.ts +584 -0
- package/cloud-os/chat/tool-rows.tsx +216 -0
- package/cloud-os/chat/transcript-model.ts +672 -0
- package/cloud-os/composer/cloud-os-chat-input.tsx +541 -0
- package/cloud-os/index.ts +107 -0
- package/cloud-os/internal/cn.ts +6 -0
- package/cloud-os/internal/theme-context.tsx +16 -0
- package/cloud-os/layout/cloud-os-root.tsx +34 -0
- package/cloud-os/layout/cloud-os-workspace-split.tsx +242 -0
- package/cloud-os/primitives/dropdown-menu.tsx +82 -0
- package/cloud-os/primitives/tooltip.tsx +68 -0
- package/cloud-os/primitives/workshop-controls.tsx +114 -0
- package/cloud-os/styles/cloud-os.css +573 -0
- package/cloud-os/workspace/cloud-os-workspace-panel.tsx +272 -0
- package/demo/camel-chat-showcase.tsx +13 -917
- package/demo/chat-scenarios.ts +926 -0
- package/demo/cloud-os-chat-showcase.tsx +471 -0
- package/demo/index.ts +2 -0
- package/package.json +16 -4
- package/src/camel/camel-chat-messages.tsx +41 -18
- package/src/camel/camel-prompt-input.tsx +2 -1
- package/src/chat-summary-panel.tsx +1 -1
- package/src/composer/chat-composer.tsx +2 -1
- package/src/composer/composer.tsx +2 -1
- package/src/composer/index.ts +1 -0
- package/src/composer/key-rules.ts +12 -0
- package/src/message.tsx +0 -8
- package/src/styles/index.css +4 -1
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
import type { UIMessage } from "ai";
|
|
2
|
+
import type { OrbState } from "thinking-orbs";
|
|
3
|
+
import {
|
|
4
|
+
askUserOutcome,
|
|
5
|
+
buildToolCallGroups,
|
|
6
|
+
toCloudOsToolCall,
|
|
7
|
+
type CloudOsToolCall,
|
|
8
|
+
type ToolCallGroup,
|
|
9
|
+
} from "./tool-presentation";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* UIMessage → cloud-os 展示条目。
|
|
13
|
+
*
|
|
14
|
+
* cloudflare-os-main 的 `buildChatDisplayEntries` 吃的是 gadgets 自己的
|
|
15
|
+
* `AiChatMessage`(离散消息类型 + 独立的 toolCalls 数组)。UIMessage 的事实是
|
|
16
|
+
* **一条 assistant 消息里一个有序 parts 数组**,所以这里把「消息类型分派」换成
|
|
17
|
+
* 「按 parts 顺序切块」:连续的工具 part 攒成一个 ToolCallGroup,遇到非工具
|
|
18
|
+
* part 就 flush。原文件的行间距规则(rhythmTopClass)照搬。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
type UnknownRecord = Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
export interface CloudOsMessageMetadata extends UnknownRecord {
|
|
24
|
+
authorDisplayName?: string;
|
|
25
|
+
completedAt?: number;
|
|
26
|
+
createdAt?: number;
|
|
27
|
+
error?: string;
|
|
28
|
+
interruptedByUser?: boolean;
|
|
29
|
+
turnDurationMs?: number;
|
|
30
|
+
turnStartedAt?: number;
|
|
31
|
+
turnStatus?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CloudOsAttachment {
|
|
35
|
+
url: string;
|
|
36
|
+
filename?: string;
|
|
37
|
+
mediaType?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PlanStep {
|
|
41
|
+
text: string;
|
|
42
|
+
status: "pending" | "in_progress" | "done";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type AssistantBlock =
|
|
46
|
+
| { kind: "reasoning"; key: string; text: string }
|
|
47
|
+
| { kind: "text"; key: string; text: string }
|
|
48
|
+
| { kind: "toolGroup"; key: string; group: ToolCallGroup }
|
|
49
|
+
| { kind: "plan"; key: string; steps: PlanStep[]; running: boolean }
|
|
50
|
+
// 答案就在 call.output 里(`ask_user` 是 client-settled tool,用户点选后
|
|
51
|
+
// 经 respondToolInteraction 回写成这次调用的结果)。曾经靠扫下一条用户消息
|
|
52
|
+
// 推断,那是有损且会误认无关消息的,已彻底删除。
|
|
53
|
+
// `pending` 是这张卡还在等人 —— 卡片据此决定能不能点,活动指示器据此决定
|
|
54
|
+
// 该不该出现,同一条判据(askUserOutcome)算一次。
|
|
55
|
+
| { kind: "askUser"; key: string; call: CloudOsToolCall; pending: boolean }
|
|
56
|
+
| { kind: "suggestions"; key: string; items: string[] }
|
|
57
|
+
| { kind: "schedule"; key: string; call: CloudOsToolCall }
|
|
58
|
+
| { kind: "approval"; key: string; call: CloudOsToolCall; approvalId: string }
|
|
59
|
+
| { kind: "parallel"; key: string; label: string; tools: ParallelTool[] }
|
|
60
|
+
| { kind: "subagents"; key: string; agents: SubAgentView[] }
|
|
61
|
+
| { kind: "image"; key: string; src: string; alt: string }
|
|
62
|
+
| { kind: "error"; key: string; title: string; body?: string };
|
|
63
|
+
|
|
64
|
+
export interface ParallelTool {
|
|
65
|
+
label: string;
|
|
66
|
+
status: "pending" | "running" | "done" | "failed";
|
|
67
|
+
meta?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SubAgentView {
|
|
71
|
+
name: string;
|
|
72
|
+
summary?: string;
|
|
73
|
+
status: "pending" | "running" | "done" | "failed";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type CloudOsEntry =
|
|
77
|
+
| {
|
|
78
|
+
type: "user";
|
|
79
|
+
key: string;
|
|
80
|
+
messageId: string;
|
|
81
|
+
text: string;
|
|
82
|
+
attachments: CloudOsAttachment[];
|
|
83
|
+
authorName?: string;
|
|
84
|
+
timestamp?: number;
|
|
85
|
+
}
|
|
86
|
+
| {
|
|
87
|
+
type: "slashCommand";
|
|
88
|
+
key: string;
|
|
89
|
+
messageId: string;
|
|
90
|
+
text: string;
|
|
91
|
+
timestamp?: number;
|
|
92
|
+
}
|
|
93
|
+
| { type: "localCommandOutput"; key: string; messageId: string; text: string }
|
|
94
|
+
| { type: "notice"; key: string; messageId: string; text: string }
|
|
95
|
+
| { type: "compactionBoundary"; key: string; messageId: string; summary: string }
|
|
96
|
+
| {
|
|
97
|
+
type: "assistant";
|
|
98
|
+
key: string;
|
|
99
|
+
messageId: string;
|
|
100
|
+
blocks: AssistantBlock[];
|
|
101
|
+
copyText: string;
|
|
102
|
+
timestamp?: number;
|
|
103
|
+
terminal?: { kind: "interrupted" | "error"; title: string; body?: string };
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// ── 小工具 ──────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
function recordOf(value: unknown): UnknownRecord {
|
|
109
|
+
return value != null && typeof value === "object"
|
|
110
|
+
? (value as UnknownRecord)
|
|
111
|
+
: {};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function dataOf(part: unknown): UnknownRecord {
|
|
115
|
+
const record = recordOf(part);
|
|
116
|
+
return recordOf(record.data ?? record.input ?? record);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function nonNegativeNumber(value: unknown): number | undefined {
|
|
120
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
121
|
+
? value
|
|
122
|
+
: undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function cloudOsMetadata(message: UIMessage): CloudOsMessageMetadata {
|
|
126
|
+
return recordOf(message.metadata) as CloudOsMessageMetadata;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function messageText(message: UIMessage): string {
|
|
130
|
+
return message.parts
|
|
131
|
+
.filter(
|
|
132
|
+
(part): part is { type: "text"; text: string } =>
|
|
133
|
+
part.type === "text" && typeof part.text === "string" &&
|
|
134
|
+
part.text.trim().length > 0,
|
|
135
|
+
)
|
|
136
|
+
.map((part) => part.text)
|
|
137
|
+
.join("\n");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function formatClockTime(timestamp: number): string {
|
|
141
|
+
return new Date(timestamp).toLocaleTimeString([], {
|
|
142
|
+
hour: "2-digit",
|
|
143
|
+
minute: "2-digit",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function formatFullTimestamp(timestamp: number): string {
|
|
148
|
+
return new Date(timestamp).toLocaleString([], {
|
|
149
|
+
dateStyle: "medium",
|
|
150
|
+
timeStyle: "short",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function toolNameOf(part: unknown): string | null {
|
|
155
|
+
const type = String(recordOf(part).type ?? "");
|
|
156
|
+
if (type === "dynamic-tool") {
|
|
157
|
+
const name = recordOf(part).toolName;
|
|
158
|
+
return typeof name === "string" ? name : "dynamic";
|
|
159
|
+
}
|
|
160
|
+
return type.startsWith("tool-") ? type.slice(5) : null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function dataKindOf(part: unknown): string | null {
|
|
164
|
+
const type = String(recordOf(part).type ?? "");
|
|
165
|
+
return type.startsWith("data-") ? type.slice(5).replaceAll("-", "_") : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function planStepsOf(input: UnknownRecord): PlanStep[] {
|
|
169
|
+
const raw = Array.isArray(input.steps)
|
|
170
|
+
? input.steps
|
|
171
|
+
: Array.isArray(input.todos)
|
|
172
|
+
? input.todos
|
|
173
|
+
: [];
|
|
174
|
+
return raw.map((item) => {
|
|
175
|
+
const step = recordOf(item);
|
|
176
|
+
const status = String(step.status ?? "pending");
|
|
177
|
+
return {
|
|
178
|
+
text: String(step.text ?? step.content ?? step.activeForm ?? "Untitled step"),
|
|
179
|
+
status:
|
|
180
|
+
status === "in_progress"
|
|
181
|
+
? "in_progress"
|
|
182
|
+
: status === "done" || status === "completed"
|
|
183
|
+
? "done"
|
|
184
|
+
: "pending",
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function stringList(value: unknown): string[] {
|
|
190
|
+
return Array.isArray(value)
|
|
191
|
+
? value.filter((item): item is string => typeof item === "string")
|
|
192
|
+
: [];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function normalizeStatus(value: unknown): ParallelTool["status"] {
|
|
196
|
+
const status = String(value ?? "");
|
|
197
|
+
if (status === "running" || status === "done" || status === "failed") return status;
|
|
198
|
+
return "pending";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── 独立消息(非 assistant 回合)判定 ────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
function standaloneEntry(
|
|
204
|
+
message: UIMessage,
|
|
205
|
+
index: number,
|
|
206
|
+
): CloudOsEntry | null {
|
|
207
|
+
const metadata = cloudOsMetadata(message);
|
|
208
|
+
for (const part of message.parts) {
|
|
209
|
+
const kind = dataKindOf(part);
|
|
210
|
+
if (kind === null) continue;
|
|
211
|
+
const data = dataOf(part);
|
|
212
|
+
const text = String(data.text ?? data.command ?? data.output ?? "").trim();
|
|
213
|
+
if (kind === "slash_command") {
|
|
214
|
+
return {
|
|
215
|
+
type: "slashCommand",
|
|
216
|
+
key: `${message.id}-${index}`,
|
|
217
|
+
messageId: message.id,
|
|
218
|
+
text,
|
|
219
|
+
timestamp: nonNegativeNumber(metadata.createdAt),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (kind === "local_command_output") {
|
|
223
|
+
return {
|
|
224
|
+
type: "localCommandOutput",
|
|
225
|
+
key: `${message.id}-${index}`,
|
|
226
|
+
messageId: message.id,
|
|
227
|
+
text,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
if (kind === "compact_summary") {
|
|
231
|
+
return {
|
|
232
|
+
type: "compactionBoundary",
|
|
233
|
+
key: `${message.id}-${index}`,
|
|
234
|
+
messageId: message.id,
|
|
235
|
+
summary: text,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
if (kind === "notice") {
|
|
239
|
+
return {
|
|
240
|
+
type: "notice",
|
|
241
|
+
key: `${message.id}-${index}`,
|
|
242
|
+
messageId: message.id,
|
|
243
|
+
text,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── assistant 回合切块 ──────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
function assistantBlocks(
|
|
253
|
+
message: UIMessage,
|
|
254
|
+
isActive: boolean,
|
|
255
|
+
showThinkingTraces: boolean,
|
|
256
|
+
approvalIdOf: (part: unknown) => string | undefined,
|
|
257
|
+
): AssistantBlock[] {
|
|
258
|
+
const blocks: AssistantBlock[] = [];
|
|
259
|
+
let pendingCalls: CloudOsToolCall[] = [];
|
|
260
|
+
|
|
261
|
+
const flush = () => {
|
|
262
|
+
if (pendingCalls.length === 0) return;
|
|
263
|
+
for (const group of buildToolCallGroups(pendingCalls)) {
|
|
264
|
+
blocks.push({ kind: "toolGroup", key: group.key, group });
|
|
265
|
+
}
|
|
266
|
+
pendingCalls = [];
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
message.parts.forEach((part, partIndex) => {
|
|
270
|
+
const key = `${message.id}:${partIndex}`;
|
|
271
|
+
const record = recordOf(part);
|
|
272
|
+
const type = String(record.type ?? "");
|
|
273
|
+
|
|
274
|
+
if (type === "step-start") return;
|
|
275
|
+
|
|
276
|
+
if (type === "text") {
|
|
277
|
+
const text = String(record.text ?? "").trim();
|
|
278
|
+
if (!text) return;
|
|
279
|
+
flush();
|
|
280
|
+
blocks.push({ kind: "text", key, text });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (type === "reasoning") {
|
|
285
|
+
const text = String(record.text ?? "").trim();
|
|
286
|
+
if (!text || !showThinkingTraces) return;
|
|
287
|
+
flush();
|
|
288
|
+
blocks.push({ kind: "reasoning", key, text });
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const dataKind = dataKindOf(part);
|
|
293
|
+
if (dataKind === "error") {
|
|
294
|
+
const data = dataOf(part);
|
|
295
|
+
flush();
|
|
296
|
+
blocks.push({
|
|
297
|
+
kind: "error",
|
|
298
|
+
key,
|
|
299
|
+
title: String(data.title ?? "Error"),
|
|
300
|
+
body: String(data.body ?? data.message ?? "") || undefined,
|
|
301
|
+
});
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (dataKind === "parallel") {
|
|
305
|
+
const data = dataOf(part);
|
|
306
|
+
flush();
|
|
307
|
+
blocks.push({
|
|
308
|
+
kind: "parallel",
|
|
309
|
+
key,
|
|
310
|
+
label: String(data.label ?? "Parallel tasks"),
|
|
311
|
+
tools: (Array.isArray(data.tools) ? data.tools : []).map((item) => {
|
|
312
|
+
const tool = recordOf(item);
|
|
313
|
+
return {
|
|
314
|
+
label: String(tool.label ?? "Task"),
|
|
315
|
+
status: normalizeStatus(tool.status),
|
|
316
|
+
meta: typeof tool.meta === "string" ? tool.meta : undefined,
|
|
317
|
+
};
|
|
318
|
+
}),
|
|
319
|
+
});
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (dataKind === "subagents") {
|
|
323
|
+
const data = dataOf(part);
|
|
324
|
+
flush();
|
|
325
|
+
blocks.push({
|
|
326
|
+
kind: "subagents",
|
|
327
|
+
key,
|
|
328
|
+
agents: (Array.isArray(data.agents) ? data.agents : []).map((item) => {
|
|
329
|
+
const agent = recordOf(item);
|
|
330
|
+
return {
|
|
331
|
+
name: String(agent.name ?? "Sub-agent"),
|
|
332
|
+
summary:
|
|
333
|
+
typeof agent.summary === "string" && agent.summary
|
|
334
|
+
? agent.summary
|
|
335
|
+
: undefined,
|
|
336
|
+
status: normalizeStatus(agent.status),
|
|
337
|
+
};
|
|
338
|
+
}),
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (dataKind === "image" || type === "file") {
|
|
343
|
+
const data = dataOf(part);
|
|
344
|
+
const src = String(data.src ?? data.url ?? record.url ?? "");
|
|
345
|
+
const mediaType = String(record.mediaType ?? data.mediaType ?? "");
|
|
346
|
+
if (src && (dataKind === "image" || mediaType.startsWith("image/"))) {
|
|
347
|
+
flush();
|
|
348
|
+
blocks.push({
|
|
349
|
+
kind: "image",
|
|
350
|
+
key,
|
|
351
|
+
src,
|
|
352
|
+
alt: String(data.alt ?? data.filename ?? record.filename ?? "Image"),
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const toolName = toolNameOf(part);
|
|
359
|
+
if (toolName === null) return;
|
|
360
|
+
|
|
361
|
+
const call = toCloudOsToolCall(part, key, isActive);
|
|
362
|
+
|
|
363
|
+
if (call.awaitingApproval) {
|
|
364
|
+
const approvalId = approvalIdOf(part);
|
|
365
|
+
if (approvalId !== undefined) {
|
|
366
|
+
flush();
|
|
367
|
+
blocks.push({ kind: "approval", key, call, approvalId });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (call.kind === "tasks") {
|
|
373
|
+
const steps = planStepsOf(call.input);
|
|
374
|
+
if (steps.length > 0) {
|
|
375
|
+
flush();
|
|
376
|
+
blocks.push({ kind: "plan", key, steps, running: call.running });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (call.kind === "ask-user") {
|
|
382
|
+
flush();
|
|
383
|
+
blocks.push({
|
|
384
|
+
kind: "askUser",
|
|
385
|
+
key,
|
|
386
|
+
call,
|
|
387
|
+
pending: askUserOutcome(call).kind === "pending",
|
|
388
|
+
});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (call.kind === "suggestions") {
|
|
393
|
+
const items = stringList(call.input.items ?? dataOf(part).items);
|
|
394
|
+
if (items.length > 0) {
|
|
395
|
+
flush();
|
|
396
|
+
blocks.push({ kind: "suggestions", key, items });
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (call.kind === "schedule" && call.toolName === "schedule") {
|
|
402
|
+
flush();
|
|
403
|
+
blocks.push({ kind: "schedule", key, call });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
pendingCalls.push(call);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
flush();
|
|
411
|
+
return blocks;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const USER_STOP_REASON = "Stopped by user";
|
|
415
|
+
|
|
416
|
+
function terminalOf(
|
|
417
|
+
message: UIMessage,
|
|
418
|
+
): { kind: "interrupted" | "error"; title: string; body?: string } | undefined {
|
|
419
|
+
const metadata = cloudOsMetadata(message);
|
|
420
|
+
if (metadata.interruptedByUser === true) {
|
|
421
|
+
return { kind: "interrupted", title: USER_STOP_REASON };
|
|
422
|
+
}
|
|
423
|
+
if (metadata.turnStatus === "aborted" && metadata.error === USER_STOP_REASON) {
|
|
424
|
+
return undefined;
|
|
425
|
+
}
|
|
426
|
+
if (metadata.turnStatus === "error" || metadata.error) {
|
|
427
|
+
return {
|
|
428
|
+
kind: "error",
|
|
429
|
+
title: "Error",
|
|
430
|
+
body: metadata.error ?? "The response failed.",
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
return undefined;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export interface BuildEntriesOptions {
|
|
437
|
+
messages: readonly UIMessage[];
|
|
438
|
+
/** streaming/submitted 时最后一条 assistant 消息才算「活着」。 */
|
|
439
|
+
isActive: boolean;
|
|
440
|
+
showThinkingTraces: boolean;
|
|
441
|
+
/** 把某个 part 映射成待授权 id;返回 undefined 表示这条不走原位授权。 */
|
|
442
|
+
approvalIdOf?: (part: unknown) => string | undefined;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function buildCloudOsEntries({
|
|
446
|
+
messages,
|
|
447
|
+
isActive,
|
|
448
|
+
showThinkingTraces,
|
|
449
|
+
approvalIdOf = () => undefined,
|
|
450
|
+
}: BuildEntriesOptions): CloudOsEntry[] {
|
|
451
|
+
const entries: CloudOsEntry[] = [];
|
|
452
|
+
const lastAssistantIndex = (() => {
|
|
453
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
454
|
+
if (messages[index].role === "assistant") return index;
|
|
455
|
+
}
|
|
456
|
+
return -1;
|
|
457
|
+
})();
|
|
458
|
+
|
|
459
|
+
messages.forEach((message, index) => {
|
|
460
|
+
if (message.role === "system") return;
|
|
461
|
+
|
|
462
|
+
const standalone = standaloneEntry(message, index);
|
|
463
|
+
if (standalone) {
|
|
464
|
+
entries.push(standalone);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const metadata = cloudOsMetadata(message);
|
|
469
|
+
|
|
470
|
+
if (message.role === "user") {
|
|
471
|
+
const text = messageText(message);
|
|
472
|
+
const attachments = message.parts
|
|
473
|
+
.filter((part) => part.type === "file")
|
|
474
|
+
.map((part) => {
|
|
475
|
+
const record = recordOf(part);
|
|
476
|
+
return {
|
|
477
|
+
url: String(record.url ?? ""),
|
|
478
|
+
filename:
|
|
479
|
+
typeof record.filename === "string" ? record.filename : undefined,
|
|
480
|
+
mediaType:
|
|
481
|
+
typeof record.mediaType === "string" ? record.mediaType : undefined,
|
|
482
|
+
};
|
|
483
|
+
});
|
|
484
|
+
if (!text && attachments.length === 0) return;
|
|
485
|
+
entries.push({
|
|
486
|
+
type: "user",
|
|
487
|
+
key: `user-${message.id}-${index}`,
|
|
488
|
+
messageId: message.id,
|
|
489
|
+
text,
|
|
490
|
+
attachments,
|
|
491
|
+
authorName: metadata.authorDisplayName,
|
|
492
|
+
timestamp:
|
|
493
|
+
nonNegativeNumber(metadata.createdAt) ??
|
|
494
|
+
nonNegativeNumber(metadata.completedAt),
|
|
495
|
+
});
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const blocks = assistantBlocks(
|
|
500
|
+
message,
|
|
501
|
+
isActive && index === lastAssistantIndex,
|
|
502
|
+
showThinkingTraces,
|
|
503
|
+
approvalIdOf,
|
|
504
|
+
);
|
|
505
|
+
const terminal = terminalOf(message);
|
|
506
|
+
if (blocks.length === 0 && terminal === undefined) return;
|
|
507
|
+
entries.push({
|
|
508
|
+
type: "assistant",
|
|
509
|
+
key: `assistant-${message.id}-${index}`,
|
|
510
|
+
messageId: message.id,
|
|
511
|
+
blocks,
|
|
512
|
+
copyText: messageText(message),
|
|
513
|
+
timestamp:
|
|
514
|
+
nonNegativeNumber(metadata.completedAt) ??
|
|
515
|
+
nonNegativeNumber(metadata.createdAt),
|
|
516
|
+
terminal,
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
return dropSupersededPlans(entries);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* update_plan 是**整份快照覆盖**,不是增量事件 —— 一次任务里每推进一步就会再发一份
|
|
525
|
+
* 完整计划。原样按序渲染会把同一份计划画四遍(0/3、1/3、2/3、3/3),只有最后那份
|
|
526
|
+
* 是当前事实。所以整条会话里只保留最后一份计划快照。
|
|
527
|
+
*/
|
|
528
|
+
function dropSupersededPlans(entries: CloudOsEntry[]): CloudOsEntry[] {
|
|
529
|
+
let lastPlanEntry = -1;
|
|
530
|
+
let lastPlanBlock = -1;
|
|
531
|
+
entries.forEach((entry, entryIndex) => {
|
|
532
|
+
if (entry.type !== "assistant") return;
|
|
533
|
+
entry.blocks.forEach((block, blockIndex) => {
|
|
534
|
+
if (block.kind !== "plan") return;
|
|
535
|
+
lastPlanEntry = entryIndex;
|
|
536
|
+
lastPlanBlock = blockIndex;
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
if (lastPlanEntry === -1) return entries;
|
|
540
|
+
|
|
541
|
+
return entries.map((entry, entryIndex) =>
|
|
542
|
+
entry.type === "assistant"
|
|
543
|
+
? {
|
|
544
|
+
...entry,
|
|
545
|
+
blocks: entry.blocks.filter(
|
|
546
|
+
(block, blockIndex) =>
|
|
547
|
+
block.kind !== "plan" ||
|
|
548
|
+
(entryIndex === lastPlanEntry && blockIndex === lastPlanBlock),
|
|
549
|
+
),
|
|
550
|
+
}
|
|
551
|
+
: entry,
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ── 活动指示(本回合此刻在干什么)────────────────────────────────────────────
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* 底部活动指示器要显示的动画与文案。
|
|
559
|
+
*/
|
|
560
|
+
export interface CloudOsActivity {
|
|
561
|
+
state: OrbState;
|
|
562
|
+
label: string;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* 尾巴是否**自己会说话** —— 有它在动,就不该再挂一个活动指示器。
|
|
567
|
+
*
|
|
568
|
+
* 正在流的正文/思考、还在跑的工具组或计划,以及一张**还等着你作答**的
|
|
569
|
+
* `ask_user` 卡都算:前三者自身就有动效,最后一个等的是人不是模型,这时候
|
|
570
|
+
* 显示「Thinking」是在撒谎。
|
|
571
|
+
*/
|
|
572
|
+
function isLiveBlock(block: AssistantBlock): boolean {
|
|
573
|
+
switch (block.kind) {
|
|
574
|
+
case "text":
|
|
575
|
+
case "reasoning":
|
|
576
|
+
return true;
|
|
577
|
+
case "toolGroup":
|
|
578
|
+
return block.group.hasRunning;
|
|
579
|
+
case "plan":
|
|
580
|
+
return block.running;
|
|
581
|
+
case "askUser":
|
|
582
|
+
return block.pending;
|
|
583
|
+
// approval 块只在 awaitingApproval 时才建出来,所以它在场就是在等人。
|
|
584
|
+
case "approval":
|
|
585
|
+
return true;
|
|
586
|
+
case "parallel":
|
|
587
|
+
return block.tools.some((tool) => tool.status === "running");
|
|
588
|
+
case "subagents":
|
|
589
|
+
return block.agents.some((agent) => agent.status === "running");
|
|
590
|
+
default:
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// 已结算的尾巴 → 此刻真正在发生的事。措辞刻意只说得出口的那部分:
|
|
596
|
+
// 工具跑完了,模型在读它的输出;卡答完了,模型在接你的答案。
|
|
597
|
+
function activityForTail(block: AssistantBlock | undefined): CloudOsActivity {
|
|
598
|
+
if (!block) return { state: "solving", label: "Thinking…" };
|
|
599
|
+
if (block.kind === "askUser") {
|
|
600
|
+
return { state: "solving", label: "Picking up your answer…" };
|
|
601
|
+
}
|
|
602
|
+
if (block.kind === "toolGroup") {
|
|
603
|
+
const kind = block.group.calls.at(-1)?.kind;
|
|
604
|
+
return kind !== undefined &&
|
|
605
|
+
["web-search", "web-fetch", "grep", "glob", "list", "read"].includes(
|
|
606
|
+
kind,
|
|
607
|
+
)
|
|
608
|
+
? { state: "searching", label: "Reading results…" }
|
|
609
|
+
: { state: "working", label: "Working…" };
|
|
610
|
+
}
|
|
611
|
+
return { state: "working", label: "Working…" };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* 本回合此刻该显示什么活动;`null` = 尾巴自己会说话,别再叠一个。
|
|
616
|
+
*
|
|
617
|
+
* @remarks
|
|
618
|
+
* 判据是「**尾巴有没有活口**」,不是「本回合有没有出过内容」。后者曾经是这里的
|
|
619
|
+
* 写法,于是答完 `ask_user`(卡片变成 Submitted,读起来就是"结束了")到模型下一
|
|
620
|
+
* 个 chunk 之间整屏没有任何在跑的迹象 —— 而那段恰好是最长的一次 TTFT,看起来
|
|
621
|
+
* 就是卡死。凡是尾巴为**已结算卡片**的断档都是同一个洞,这里一并补上。
|
|
622
|
+
*/
|
|
623
|
+
export function deriveTurnActivity(
|
|
624
|
+
entries: readonly CloudOsEntry[],
|
|
625
|
+
options: { isActive: boolean; hasPendingSteer?: boolean },
|
|
626
|
+
): CloudOsActivity | null {
|
|
627
|
+
if (!options.isActive) return null;
|
|
628
|
+
const tail = entries.at(-1) ?? null;
|
|
629
|
+
const block = tail?.type === "assistant" ? tail.blocks.at(-1) : undefined;
|
|
630
|
+
if (block && isLiveBlock(block)) return null;
|
|
631
|
+
return options.hasPendingSteer
|
|
632
|
+
? { state: "solving", label: "Waiting for the current step to finish…" }
|
|
633
|
+
: activityForTail(block);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ── 行间距节奏(照搬原 rhythmTopClass)──────────────────────────────────────
|
|
637
|
+
|
|
638
|
+
function isUserEntry(entry: CloudOsEntry): boolean {
|
|
639
|
+
return entry.type === "user" || entry.type === "slashCommand";
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function startsWithWorkRow(entry: CloudOsEntry): boolean {
|
|
643
|
+
return (
|
|
644
|
+
entry.type === "assistant" &&
|
|
645
|
+
entry.blocks[0] !== undefined &&
|
|
646
|
+
entry.blocks[0].kind === "toolGroup"
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function endsInWorkRow(entry: CloudOsEntry): boolean {
|
|
651
|
+
return (
|
|
652
|
+
entry.type === "assistant" &&
|
|
653
|
+
entry.blocks.at(-1) !== undefined &&
|
|
654
|
+
entry.blocks.at(-1)!.kind === "toolGroup"
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* 相邻两条之间的上间距。原则(原文件注释):用户消息前后留最大的气口;
|
|
660
|
+
* 两条工作行之间贴紧;其余取中间档。
|
|
661
|
+
*/
|
|
662
|
+
export function rhythmTopClass(
|
|
663
|
+
previous: CloudOsEntry | null,
|
|
664
|
+
entry: CloudOsEntry,
|
|
665
|
+
): string {
|
|
666
|
+
if (!previous) return "";
|
|
667
|
+
if (isUserEntry(entry)) return "mt-8";
|
|
668
|
+
if (isUserEntry(previous)) return "mt-5";
|
|
669
|
+
if (endsInWorkRow(previous) && startsWithWorkRow(entry)) return "mt-2";
|
|
670
|
+
if (previous.type === "notice" || entry.type === "notice") return "mt-4";
|
|
671
|
+
return "mt-4";
|
|
672
|
+
}
|