@springbrand/message-panel 0.1.3-alpha.8 → 0.2.0-alpha.37
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 +4 -3
- package/cloud-os/assets/followup-arrow.svg +3 -0
- package/cloud-os/assets/loading-corner.svg +3 -0
- package/cloud-os/assets/loading-mark.svg +16 -0
- package/cloud-os/assets/loading-spark.svg +3 -0
- package/cloud-os/assets/model-selected.svg +5 -0
- package/cloud-os/assets/thinking-star.svg +9 -0
- package/cloud-os/capability-chip.tsx +1 -1
- package/cloud-os/chat/cloud-os-chat-messages.tsx +441 -98
- package/cloud-os/chat/code-runner.tsx +106 -0
- package/cloud-os/chat/image-generation.tsx +76 -0
- package/cloud-os/chat/loading-state.tsx +25 -0
- package/cloud-os/chat/markdown-message.tsx +144 -41
- package/cloud-os/chat/range.ts +8 -0
- package/cloud-os/chat/rich-blocks.tsx +285 -227
- package/cloud-os/chat/surfaces.tsx +90 -0
- package/cloud-os/chat/tool-call.tsx +94 -0
- package/cloud-os/chat/tool-presentation.ts +31 -22
- package/cloud-os/chat/tool-rows.tsx +195 -143
- package/cloud-os/chat/tool-timeline.tsx +123 -0
- package/cloud-os/chat/transcript-model.ts +258 -20
- package/cloud-os/chat/web-search.tsx +135 -0
- package/cloud-os/composer/cloud-os-chat-input.tsx +38 -88
- package/cloud-os/composer/cloud-os-model-select.tsx +105 -0
- package/cloud-os/file-view.tsx +195 -12
- package/cloud-os/index.ts +24 -3
- package/cloud-os/layout/cloud-os-workspace-split.tsx +60 -3
- package/cloud-os/primitives/collapsible.tsx +21 -0
- package/cloud-os/primitives/workshop-controls.tsx +2 -2
- package/cloud-os/styles/cloud-os.css +132 -1
- package/demo/camel-chat-showcase.tsx +21 -13
- package/demo/chat-scenarios.ts +214 -40
- package/demo/cloud-os-chat-showcase.tsx +37 -14
- package/demo/fixtures.ts +4 -5
- package/demo/message-panel-gallery.tsx +16 -2
- package/package.json +8 -4
- package/springbrand-pet/LICENSE.bloub +21 -0
- package/springbrand-pet/bot/cycles.test.ts +221 -0
- package/springbrand-pet/bot/cycles.ts +225 -0
- package/springbrand-pet/bot/decor.ts +285 -0
- package/springbrand-pet/bot/engine.test.ts +543 -0
- package/springbrand-pet/bot/engine.ts +558 -0
- package/springbrand-pet/bot/expressions.test.ts +135 -0
- package/springbrand-pet/bot/expressions.ts +192 -0
- package/springbrand-pet/bot/eyefit.ts +455 -0
- package/springbrand-pet/bot/face.test.ts +101 -0
- package/springbrand-pet/bot/face.ts +179 -0
- package/springbrand-pet/bot/math.ts +42 -0
- package/springbrand-pet/bot/profiles.ts +22 -0
- package/springbrand-pet/bot/repere.ts +31 -0
- package/springbrand-pet/bot/shape.test.ts +97 -0
- package/springbrand-pet/bot/shape.ts +310 -0
- package/springbrand-pet/bot/skins.test.ts +325 -0
- package/springbrand-pet/bot/skins.ts +161 -0
- package/springbrand-pet/bot/states.ts +616 -0
- package/springbrand-pet/drag.test.ts +19 -0
- package/springbrand-pet/index.tsx +569 -0
- package/springbrand-pet/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/springbrand-pet/vitest.config.ts +9 -0
- package/src/camel/camel-chat-messages.tsx +78 -78
- package/src/camel/camel-tool-presentation.tsx +19 -15
- package/src/camel/camel-turn.ts +1 -17
- package/src/composer/composer-attachments.tsx +1 -1
- package/src/composer/composer.tsx +2 -2
- package/src/contracts.ts +0 -2
- package/src/message-panel.tsx +1 -24
- package/src/parts/index.tsx +103 -64
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ChevronRightIcon, type LucideIcon } from "lucide-react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import {
|
|
6
|
+
Collapsible,
|
|
7
|
+
CollapsibleContent,
|
|
8
|
+
CollapsibleTrigger,
|
|
9
|
+
} from "../primitives/collapsible";
|
|
10
|
+
import { cn } from "../internal/cn";
|
|
11
|
+
import { ShimmerLabel, SwapLabel } from "./surfaces";
|
|
12
|
+
import { take } from "./range";
|
|
13
|
+
|
|
14
|
+
export interface TimelineStep {
|
|
15
|
+
verb: string;
|
|
16
|
+
chip: string;
|
|
17
|
+
icon: LucideIcon;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TimelineStat {
|
|
21
|
+
file: string;
|
|
22
|
+
added?: number;
|
|
23
|
+
removed?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ToolTimelineProps {
|
|
27
|
+
steps: readonly TimelineStep[];
|
|
28
|
+
visibleSteps: number;
|
|
29
|
+
streaming: boolean;
|
|
30
|
+
open: boolean;
|
|
31
|
+
onOpenChange: (open: boolean) => void;
|
|
32
|
+
restingLabel: string;
|
|
33
|
+
activeLabel: string;
|
|
34
|
+
stats: TimelineStat[];
|
|
35
|
+
className?: string;
|
|
36
|
+
children?: ReactNode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ToolTimeline({
|
|
40
|
+
steps,
|
|
41
|
+
visibleSteps,
|
|
42
|
+
streaming,
|
|
43
|
+
open,
|
|
44
|
+
onOpenChange,
|
|
45
|
+
restingLabel,
|
|
46
|
+
activeLabel,
|
|
47
|
+
stats,
|
|
48
|
+
className,
|
|
49
|
+
children,
|
|
50
|
+
}: ToolTimelineProps) {
|
|
51
|
+
return (
|
|
52
|
+
<Collapsible
|
|
53
|
+
data-slot="tool-timeline"
|
|
54
|
+
open={open}
|
|
55
|
+
onOpenChange={onOpenChange}
|
|
56
|
+
className={cn("w-full max-w-sm", className)}
|
|
57
|
+
>
|
|
58
|
+
<CollapsibleTrigger className="group/trigger text-foreground/55 hover:text-foreground/90 flex items-center gap-1.5 rounded-md py-1 text-[13.5px] transition-colors outline-none">
|
|
59
|
+
<ChevronRightIcon className="size-3.5 shrink-0 opacity-60 transition-transform duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-open/trigger:rotate-90 group-data-panel-open/trigger:rotate-90 motion-reduce:transition-none" />
|
|
60
|
+
<SwapLabel
|
|
61
|
+
active={streaming ? 0 : 1}
|
|
62
|
+
className="text-start tabular-nums"
|
|
63
|
+
>
|
|
64
|
+
<ShimmerLabel
|
|
65
|
+
active={streaming}
|
|
66
|
+
className="relative inline-block leading-none"
|
|
67
|
+
>
|
|
68
|
+
{activeLabel}
|
|
69
|
+
</ShimmerLabel>
|
|
70
|
+
<>{restingLabel}</>
|
|
71
|
+
</SwapLabel>
|
|
72
|
+
</CollapsibleTrigger>
|
|
73
|
+
<CollapsibleContent className="outline-none">
|
|
74
|
+
<div className="flex flex-col gap-2.5 ps-4 pt-2.5">
|
|
75
|
+
{children ?? take(steps, visibleSteps).map((step, index, shown) => {
|
|
76
|
+
const Icon = step.icon;
|
|
77
|
+
const active = streaming && index === shown.length - 1;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
key={step.chip}
|
|
82
|
+
className="fade-in slide-in-from-bottom-1 animate-in fill-mode-both text-foreground/55 flex items-center gap-2 text-[13.5px] duration-300"
|
|
83
|
+
>
|
|
84
|
+
<Icon className="text-foreground/35 size-3.5 shrink-0" />
|
|
85
|
+
<ShimmerLabel
|
|
86
|
+
active={active}
|
|
87
|
+
className="relative inline-block leading-none"
|
|
88
|
+
>
|
|
89
|
+
{step.verb}
|
|
90
|
+
</ShimmerLabel>
|
|
91
|
+
<span className="bg-foreground/[0.06] text-foreground/70 rounded-md px-1.5 py-0.5 font-mono text-[11px]">
|
|
92
|
+
{step.chip}
|
|
93
|
+
</span>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
})}
|
|
97
|
+
{stats.length > 0 && (
|
|
98
|
+
<div className="flex flex-wrap gap-1.5 pt-1">
|
|
99
|
+
{stats.map((stat) => (
|
|
100
|
+
<span
|
|
101
|
+
key={stat.file}
|
|
102
|
+
className="bg-foreground/[0.06] text-foreground/70 inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-mono text-[11px]"
|
|
103
|
+
>
|
|
104
|
+
<span>{stat.file}</span>
|
|
105
|
+
{stat.added !== undefined && (
|
|
106
|
+
<span className="text-emerald-600 dark:text-emerald-400">
|
|
107
|
+
+{stat.added}
|
|
108
|
+
</span>
|
|
109
|
+
)}
|
|
110
|
+
{stat.removed !== undefined && (
|
|
111
|
+
<span className="text-red-600 dark:text-red-400">
|
|
112
|
+
−{stat.removed}
|
|
113
|
+
</span>
|
|
114
|
+
)}
|
|
115
|
+
</span>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
</CollapsibleContent>
|
|
121
|
+
</Collapsible>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -28,13 +28,14 @@ export interface CloudOsMessageMetadata extends UnknownRecord {
|
|
|
28
28
|
error?: string;
|
|
29
29
|
interruptedByUser?: boolean;
|
|
30
30
|
turnDurationMs?: number;
|
|
31
|
+
turnId?: string;
|
|
31
32
|
turnStartedAt?: number;
|
|
32
33
|
turnStatus?: string;
|
|
33
34
|
requestedCapabilities?: unknown;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
export interface CloudOsRequestedCapability {
|
|
37
|
-
kind: "skill" | "plan";
|
|
38
|
+
kind: "skill" | "tool" | "plan";
|
|
38
39
|
name: string;
|
|
39
40
|
label: string;
|
|
40
41
|
}
|
|
@@ -45,14 +46,39 @@ export interface CloudOsAttachment {
|
|
|
45
46
|
mediaType?: string;
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
interface CloudOsActionPresentationFile {
|
|
50
|
+
type: "file";
|
|
51
|
+
url: string;
|
|
52
|
+
mediaType?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface CloudOsActionProduces {
|
|
56
|
+
type: "image" | "audio" | "video" | "document" | "archive" | "data" | "other";
|
|
57
|
+
mediaType?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface CloudOsActionPresentation {
|
|
61
|
+
type: "action-execution";
|
|
62
|
+
executionId: string;
|
|
63
|
+
state: "running" | "ready" | "failed" | "outcome_unknown";
|
|
64
|
+
failureCode?: "insufficient_credits";
|
|
65
|
+
produces?: CloudOsActionProduces;
|
|
66
|
+
content: CloudOsActionPresentationFile[];
|
|
67
|
+
}
|
|
68
|
+
|
|
48
69
|
export interface PlanStep {
|
|
49
70
|
text: string;
|
|
50
71
|
status: "pending" | "in_progress" | "done";
|
|
51
72
|
}
|
|
52
73
|
|
|
53
74
|
export type AssistantBlock =
|
|
54
|
-
| {
|
|
55
|
-
|
|
75
|
+
| {
|
|
76
|
+
kind: "reasoning";
|
|
77
|
+
key: string;
|
|
78
|
+
text: string;
|
|
79
|
+
running: boolean;
|
|
80
|
+
}
|
|
81
|
+
| { kind: "text"; key: string; text: string; final: boolean }
|
|
56
82
|
| { kind: "toolGroup"; key: string; group: ToolCallGroup }
|
|
57
83
|
| { kind: "plan"; key: string; steps: PlanStep[]; running: boolean }
|
|
58
84
|
// 答案就在 call.output 里(`ask_user` 是 client-settled tool,用户点选后
|
|
@@ -74,6 +100,11 @@ export type AssistantBlock =
|
|
|
74
100
|
mediaType?: string;
|
|
75
101
|
}
|
|
76
102
|
| { kind: "file"; key: string; file: CloudOsAttachment }
|
|
103
|
+
| {
|
|
104
|
+
kind: "actionPresentation";
|
|
105
|
+
key: string;
|
|
106
|
+
presentation: CloudOsActionPresentation;
|
|
107
|
+
}
|
|
77
108
|
| { kind: "error"; key: string; title: string; body?: string };
|
|
78
109
|
|
|
79
110
|
export interface ParallelTool {
|
|
@@ -113,6 +144,7 @@ export type CloudOsEntry =
|
|
|
113
144
|
type: "assistant";
|
|
114
145
|
key: string;
|
|
115
146
|
messageId: string;
|
|
147
|
+
turnId: string;
|
|
116
148
|
blocks: AssistantBlock[];
|
|
117
149
|
copyText: string;
|
|
118
150
|
timestamp?: number;
|
|
@@ -150,7 +182,7 @@ export function requestedCapabilitiesOf(
|
|
|
150
182
|
return metadata.requestedCapabilities.flatMap((value) => {
|
|
151
183
|
const capability = recordOf(value);
|
|
152
184
|
if (
|
|
153
|
-
(capability.kind !== "skill" && capability.kind !== "plan") ||
|
|
185
|
+
(capability.kind !== "skill" && capability.kind !== "tool" && capability.kind !== "plan") ||
|
|
154
186
|
typeof capability.name !== "string" ||
|
|
155
187
|
!capability.name.trim() ||
|
|
156
188
|
typeof capability.label !== "string" ||
|
|
@@ -239,6 +271,74 @@ function normalizeStatus(value: unknown): ParallelTool["status"] {
|
|
|
239
271
|
return "pending";
|
|
240
272
|
}
|
|
241
273
|
|
|
274
|
+
function actionPresentationOf(value: unknown): CloudOsActionPresentation | null {
|
|
275
|
+
const presentation = recordOf(recordOf(value).presentation);
|
|
276
|
+
const state = presentation.state;
|
|
277
|
+
if (
|
|
278
|
+
presentation.type !== "action-execution" ||
|
|
279
|
+
typeof presentation.executionId !== "string" ||
|
|
280
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
|
281
|
+
presentation.executionId,
|
|
282
|
+
) ||
|
|
283
|
+
(state !== "running" &&
|
|
284
|
+
state !== "ready" &&
|
|
285
|
+
state !== "failed" &&
|
|
286
|
+
state !== "outcome_unknown") ||
|
|
287
|
+
!Array.isArray(presentation.content)
|
|
288
|
+
) return null;
|
|
289
|
+
|
|
290
|
+
let produces: CloudOsActionProduces | undefined;
|
|
291
|
+
if (presentation.produces !== undefined) {
|
|
292
|
+
const hint = recordOf(presentation.produces);
|
|
293
|
+
if (
|
|
294
|
+
!["image", "audio", "video", "document", "archive", "data", "other"].includes(
|
|
295
|
+
String(hint.type),
|
|
296
|
+
) ||
|
|
297
|
+
(hint.mediaType !== undefined &&
|
|
298
|
+
(typeof hint.mediaType !== "string" || !/^[^\s/]+\/[^\s/]+$/.test(hint.mediaType)))
|
|
299
|
+
) return null;
|
|
300
|
+
produces = {
|
|
301
|
+
type: hint.type as CloudOsActionProduces["type"],
|
|
302
|
+
...(hint.mediaType === undefined ? {} : { mediaType: hint.mediaType as string }),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
if (state !== "running" && produces) return null;
|
|
306
|
+
|
|
307
|
+
const content: CloudOsActionPresentationFile[] = [];
|
|
308
|
+
for (const value of presentation.content) {
|
|
309
|
+
const file = recordOf(value);
|
|
310
|
+
if (file.type !== "file" || typeof file.url !== "string") return null;
|
|
311
|
+
try {
|
|
312
|
+
if (new URL(file.url).protocol !== "https:") return null;
|
|
313
|
+
} catch {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
if (
|
|
317
|
+
file.mediaType !== undefined &&
|
|
318
|
+
(typeof file.mediaType !== "string" || !file.mediaType.trim())
|
|
319
|
+
) return null;
|
|
320
|
+
content.push({
|
|
321
|
+
type: "file" as const,
|
|
322
|
+
url: file.url,
|
|
323
|
+
...(file.mediaType !== undefined
|
|
324
|
+
? { mediaType: file.mediaType }
|
|
325
|
+
: {}),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
if (state !== "ready" && content.length > 0) return null;
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
type: "action-execution",
|
|
332
|
+
executionId: presentation.executionId,
|
|
333
|
+
state,
|
|
334
|
+
...(state === "failed" && presentation.failureCode === "insufficient_credits"
|
|
335
|
+
? { failureCode: presentation.failureCode }
|
|
336
|
+
: {}),
|
|
337
|
+
...(produces ? { produces } : {}),
|
|
338
|
+
content,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
242
342
|
// ── 独立消息(非 assistant 回合)判定 ────────────────────────────────────────
|
|
243
343
|
|
|
244
344
|
function standaloneEntry(
|
|
@@ -318,7 +418,7 @@ function assistantBlocks(
|
|
|
318
418
|
const text = String(record.text ?? "").trim();
|
|
319
419
|
if (!text) return;
|
|
320
420
|
flush();
|
|
321
|
-
blocks.push({ kind: "text", key, text });
|
|
421
|
+
blocks.push({ kind: "text", key, text, final: false });
|
|
322
422
|
return;
|
|
323
423
|
}
|
|
324
424
|
|
|
@@ -326,7 +426,12 @@ function assistantBlocks(
|
|
|
326
426
|
const text = String(record.text ?? "").trim();
|
|
327
427
|
if (!text || !showThinkingTraces) return;
|
|
328
428
|
flush();
|
|
329
|
-
blocks.push({
|
|
429
|
+
blocks.push({
|
|
430
|
+
kind: "reasoning",
|
|
431
|
+
key,
|
|
432
|
+
text,
|
|
433
|
+
running: record.state === "streaming",
|
|
434
|
+
});
|
|
330
435
|
return;
|
|
331
436
|
}
|
|
332
437
|
|
|
@@ -417,6 +522,13 @@ function assistantBlocks(
|
|
|
417
522
|
if (toolName === null) return;
|
|
418
523
|
|
|
419
524
|
const call = toCloudOsToolCall(part, key, isActive);
|
|
525
|
+
const presentation = actionPresentationOf(call.output);
|
|
526
|
+
if (presentation) {
|
|
527
|
+
pendingCalls.push(call);
|
|
528
|
+
flush();
|
|
529
|
+
blocks.push({ kind: "actionPresentation", key, presentation });
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
420
532
|
|
|
421
533
|
if (call.awaitingApproval) {
|
|
422
534
|
const approvalId = approvalIdOf(part);
|
|
@@ -437,6 +549,7 @@ function assistantBlocks(
|
|
|
437
549
|
}
|
|
438
550
|
|
|
439
551
|
if (call.kind === "ask-user") {
|
|
552
|
+
if (call.failed) return;
|
|
440
553
|
flush();
|
|
441
554
|
blocks.push({
|
|
442
555
|
kind: "askUser",
|
|
@@ -470,6 +583,8 @@ function assistantBlocks(
|
|
|
470
583
|
}
|
|
471
584
|
|
|
472
585
|
const USER_STOP_REASON = "Stopped by user";
|
|
586
|
+
const REQUEST_UNAVAILABLE_ERROR =
|
|
587
|
+
"We’re unable to complete your request right now. Please try again in a few moments.";
|
|
473
588
|
|
|
474
589
|
function terminalOf(
|
|
475
590
|
message: UIMessage,
|
|
@@ -484,7 +599,9 @@ function terminalOf(
|
|
|
484
599
|
if (metadata.turnStatus === "error" || metadata.error) {
|
|
485
600
|
return {
|
|
486
601
|
kind: "error",
|
|
487
|
-
title:
|
|
602
|
+
title: metadata.error === REQUEST_UNAVAILABLE_ERROR
|
|
603
|
+
? "Unable to complete your request"
|
|
604
|
+
: "Error",
|
|
488
605
|
body: metadata.error ?? "The response failed.",
|
|
489
606
|
};
|
|
490
607
|
}
|
|
@@ -507,6 +624,7 @@ export function buildCloudOsEntries({
|
|
|
507
624
|
approvalIdOf = () => undefined,
|
|
508
625
|
}: BuildEntriesOptions): CloudOsEntry[] {
|
|
509
626
|
const entries: CloudOsEntry[] = [];
|
|
627
|
+
let fallbackTurnId = "turn:initial";
|
|
510
628
|
const lastAssistantIndex = (() => {
|
|
511
629
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
512
630
|
if (messages[index].role === "assistant") return index;
|
|
@@ -540,6 +658,9 @@ export function buildCloudOsEntries({
|
|
|
540
658
|
};
|
|
541
659
|
});
|
|
542
660
|
if (!text && attachments.length === 0) return;
|
|
661
|
+
fallbackTurnId = typeof metadata.turnId === "string" && metadata.turnId
|
|
662
|
+
? metadata.turnId
|
|
663
|
+
: `turn:user:${message.id}`;
|
|
543
664
|
entries.push({
|
|
544
665
|
type: "user",
|
|
545
666
|
key: `user-${message.id}-${index}`,
|
|
@@ -567,6 +688,9 @@ export function buildCloudOsEntries({
|
|
|
567
688
|
type: "assistant",
|
|
568
689
|
key: `assistant-${message.id}-${index}`,
|
|
569
690
|
messageId: message.id,
|
|
691
|
+
turnId: typeof metadata.turnId === "string" && metadata.turnId
|
|
692
|
+
? metadata.turnId
|
|
693
|
+
: fallbackTurnId,
|
|
570
694
|
blocks,
|
|
571
695
|
copyText: messageText(message),
|
|
572
696
|
timestamp:
|
|
@@ -576,35 +700,144 @@ export function buildCloudOsEntries({
|
|
|
576
700
|
});
|
|
577
701
|
});
|
|
578
702
|
|
|
579
|
-
return
|
|
703
|
+
return markFinalTextBlocks(mergeConsecutiveAssistantTurns(
|
|
704
|
+
dropSupersededActionPresentations(dropSupersededPlans(entries)),
|
|
705
|
+
));
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function isFinalOutputBlock(block: AssistantBlock): boolean {
|
|
709
|
+
return block.kind === "suggestions" ||
|
|
710
|
+
block.kind === "image" ||
|
|
711
|
+
block.kind === "file" ||
|
|
712
|
+
(block.kind === "actionPresentation" && block.presentation.state === "ready");
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function isWorkBoundaryBlock(block: AssistantBlock): boolean {
|
|
716
|
+
return block.kind !== "text" && !isFinalOutputBlock(block);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function markFinalTextBlocks(entries: CloudOsEntry[]): CloudOsEntry[] {
|
|
720
|
+
return entries.map((entry) => {
|
|
721
|
+
if (entry.type !== "assistant") return entry;
|
|
722
|
+
|
|
723
|
+
const finalOutputBoundary = entry.blocks.findLastIndex(isFinalOutputBlock);
|
|
724
|
+
// Provider cleanup after final output must not pull the answer back into Work.
|
|
725
|
+
const searchBefore = finalOutputBoundary === -1
|
|
726
|
+
? entry.blocks.length
|
|
727
|
+
: finalOutputBoundary;
|
|
728
|
+
let lastWorkBoundary = -1;
|
|
729
|
+
for (let index = searchBefore - 1; index >= 0; index -= 1) {
|
|
730
|
+
if (!isWorkBoundaryBlock(entry.blocks[index]!)) continue;
|
|
731
|
+
lastWorkBoundary = index;
|
|
732
|
+
break;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
return {
|
|
736
|
+
...entry,
|
|
737
|
+
blocks: entry.blocks.map((block, index) =>
|
|
738
|
+
block.kind === "text"
|
|
739
|
+
? { ...block, final: index > lastWorkBoundary }
|
|
740
|
+
: block
|
|
741
|
+
),
|
|
742
|
+
};
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function mergeConsecutiveAssistantTurns(entries: CloudOsEntry[]): CloudOsEntry[] {
|
|
747
|
+
return entries.reduce<CloudOsEntry[]>((merged, entry) => {
|
|
748
|
+
const previous = merged.at(-1);
|
|
749
|
+
if (
|
|
750
|
+
entry.type !== "assistant" ||
|
|
751
|
+
previous?.type !== "assistant" ||
|
|
752
|
+
previous.turnId !== entry.turnId
|
|
753
|
+
) {
|
|
754
|
+
merged.push(entry);
|
|
755
|
+
return merged;
|
|
756
|
+
}
|
|
757
|
+
merged[merged.length - 1] = {
|
|
758
|
+
...previous,
|
|
759
|
+
messageId: entry.messageId,
|
|
760
|
+
blocks: [...previous.blocks, ...entry.blocks],
|
|
761
|
+
copyText: [previous.copyText, entry.copyText].filter(Boolean).join("\n\n"),
|
|
762
|
+
timestamp: entry.timestamp ?? previous.timestamp,
|
|
763
|
+
terminal: entry.terminal ?? previous.terminal,
|
|
764
|
+
};
|
|
765
|
+
return merged;
|
|
766
|
+
}, []);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function dropSupersededActionPresentations(entries: CloudOsEntry[]): CloudOsEntry[] {
|
|
770
|
+
const snapshots = new Map<string, {
|
|
771
|
+
entryIndex: number;
|
|
772
|
+
blockIndex: number;
|
|
773
|
+
presentation: CloudOsActionPresentation;
|
|
774
|
+
}>();
|
|
775
|
+
entries.forEach((entry, entryIndex) => {
|
|
776
|
+
if (entry.type !== "assistant") return;
|
|
777
|
+
entry.blocks.forEach((block, blockIndex) => {
|
|
778
|
+
if (block.kind !== "actionPresentation") return;
|
|
779
|
+
const current = snapshots.get(block.presentation.executionId);
|
|
780
|
+
if (!current) {
|
|
781
|
+
snapshots.set(block.presentation.executionId, {
|
|
782
|
+
entryIndex,
|
|
783
|
+
blockIndex,
|
|
784
|
+
presentation: block.presentation,
|
|
785
|
+
});
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (block.presentation.state !== "running") {
|
|
789
|
+
current.presentation = block.presentation;
|
|
790
|
+
} else if (current.presentation.state === "running") {
|
|
791
|
+
current.presentation = {
|
|
792
|
+
...block.presentation,
|
|
793
|
+
produces: block.presentation.produces ?? current.presentation.produces,
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
return entries.map((entry, entryIndex) =>
|
|
800
|
+
entry.type !== "assistant"
|
|
801
|
+
? entry
|
|
802
|
+
: {
|
|
803
|
+
...entry,
|
|
804
|
+
blocks: entry.blocks.flatMap<AssistantBlock>((block, blockIndex) => {
|
|
805
|
+
if (block.kind !== "actionPresentation") return [block];
|
|
806
|
+
const snapshot = snapshots.get(block.presentation.executionId)!;
|
|
807
|
+
return snapshot.entryIndex === entryIndex && snapshot.blockIndex === blockIndex
|
|
808
|
+
? [{ ...block, presentation: snapshot.presentation }]
|
|
809
|
+
: [];
|
|
810
|
+
}),
|
|
811
|
+
}
|
|
812
|
+
);
|
|
580
813
|
}
|
|
581
814
|
|
|
582
815
|
/**
|
|
583
|
-
* update_plan 是**整份快照覆盖**,不是增量事件 ——
|
|
584
|
-
* 完整计划。原样按序渲染会把同一份计划画四遍(0/3、1/3、2/3、3/3)
|
|
585
|
-
*
|
|
816
|
+
* update_plan 是**整份快照覆盖**,不是增量事件 —— 一次 Turn 里每推进一步就会再发一份
|
|
817
|
+
* 完整计划。原样按序渲染会把同一份计划画四遍(0/3、1/3、2/3、3/3),只有该 Turn 的
|
|
818
|
+
* 最后一份是当前事实。所以每个 turnId 各自保留最后一份计划快照。
|
|
586
819
|
*/
|
|
587
820
|
function dropSupersededPlans(entries: CloudOsEntry[]): CloudOsEntry[] {
|
|
588
|
-
|
|
589
|
-
let lastPlanBlock = -1;
|
|
821
|
+
const lastPlanByTurn = new Map<string, { entryIndex: number; blockIndex: number }>();
|
|
590
822
|
entries.forEach((entry, entryIndex) => {
|
|
591
823
|
if (entry.type !== "assistant") return;
|
|
592
824
|
entry.blocks.forEach((block, blockIndex) => {
|
|
593
825
|
if (block.kind !== "plan") return;
|
|
594
|
-
|
|
595
|
-
lastPlanBlock = blockIndex;
|
|
826
|
+
lastPlanByTurn.set(entry.turnId, { entryIndex, blockIndex });
|
|
596
827
|
});
|
|
597
828
|
});
|
|
598
|
-
if (
|
|
829
|
+
if (lastPlanByTurn.size === 0) return entries;
|
|
599
830
|
|
|
600
831
|
return entries.map((entry, entryIndex) =>
|
|
601
832
|
entry.type === "assistant"
|
|
602
833
|
? {
|
|
603
834
|
...entry,
|
|
604
835
|
blocks: entry.blocks.filter(
|
|
605
|
-
(block, blockIndex) =>
|
|
606
|
-
block.kind !== "plan"
|
|
607
|
-
|
|
836
|
+
(block, blockIndex) => {
|
|
837
|
+
if (block.kind !== "plan") return true;
|
|
838
|
+
const last = lastPlanByTurn.get(entry.turnId);
|
|
839
|
+
return last?.entryIndex === entryIndex && last.blockIndex === blockIndex;
|
|
840
|
+
},
|
|
608
841
|
),
|
|
609
842
|
}
|
|
610
843
|
: entry,
|
|
@@ -715,6 +948,8 @@ export function deriveTurnActivity(
|
|
|
715
948
|
options: {
|
|
716
949
|
isActive: boolean;
|
|
717
950
|
isRecovering?: boolean;
|
|
951
|
+
recoveryAttempt?: number;
|
|
952
|
+
recoveryMax?: number;
|
|
718
953
|
recoveryStatusLabel?: string;
|
|
719
954
|
awaitingApproval?: boolean;
|
|
720
955
|
hasPendingSteer?: boolean;
|
|
@@ -723,7 +958,10 @@ export function deriveTurnActivity(
|
|
|
723
958
|
if (options.isRecovering) {
|
|
724
959
|
return {
|
|
725
960
|
state: "connecting",
|
|
726
|
-
label: options.recoveryStatusLabel ??
|
|
961
|
+
label: options.recoveryStatusLabel ??
|
|
962
|
+
(options.recoveryAttempt === undefined || options.recoveryMax === undefined
|
|
963
|
+
? "Recovering model response…"
|
|
964
|
+
: `Recovering model response ${options.recoveryAttempt}/${options.recoveryMax}…`),
|
|
727
965
|
};
|
|
728
966
|
}
|
|
729
967
|
if (!options.isActive) return null;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ComponentProps } from "react";
|
|
4
|
+
import { SearchIcon } from "lucide-react";
|
|
5
|
+
import { cn } from "../internal/cn";
|
|
6
|
+
import { field, mono, ShimmerLabel } from "./surfaces";
|
|
7
|
+
import { take } from "./range";
|
|
8
|
+
import type { CloudOsToolCall } from "./tool-presentation";
|
|
9
|
+
|
|
10
|
+
/** Adapted from assistant-ui's MIT-licensed Web Search element. */
|
|
11
|
+
export interface WebSearchResult {
|
|
12
|
+
title: string;
|
|
13
|
+
domain: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function WebSearch({
|
|
17
|
+
query,
|
|
18
|
+
results,
|
|
19
|
+
visibleResults,
|
|
20
|
+
searching,
|
|
21
|
+
cycle,
|
|
22
|
+
className,
|
|
23
|
+
...props
|
|
24
|
+
}: Omit<
|
|
25
|
+
ComponentProps<"div">,
|
|
26
|
+
"children" | "results"
|
|
27
|
+
> & {
|
|
28
|
+
query: string;
|
|
29
|
+
results: readonly WebSearchResult[];
|
|
30
|
+
visibleResults: number;
|
|
31
|
+
searching: boolean;
|
|
32
|
+
cycle: number;
|
|
33
|
+
}) {
|
|
34
|
+
const revealedResults = take(results, visibleResults);
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
data-slot="web-search"
|
|
38
|
+
className={cn("flex w-full max-w-sm flex-col gap-2.5", className)}
|
|
39
|
+
{...props}
|
|
40
|
+
>
|
|
41
|
+
<span
|
|
42
|
+
className={cn(
|
|
43
|
+
field,
|
|
44
|
+
"text-foreground/70 inline-flex w-fit items-center gap-1.5 rounded-full px-3.5 py-2 text-xs",
|
|
45
|
+
)}
|
|
46
|
+
>
|
|
47
|
+
<SearchIcon className="text-foreground/40 size-3" />
|
|
48
|
+
{query}
|
|
49
|
+
</span>
|
|
50
|
+
<div className="text-foreground/45 text-xs">
|
|
51
|
+
{searching ? (
|
|
52
|
+
<ShimmerLabel className="relative inline-block leading-none">
|
|
53
|
+
Searching
|
|
54
|
+
</ShimmerLabel>
|
|
55
|
+
) : (
|
|
56
|
+
<span className="fade-in animate-in duration-300">
|
|
57
|
+
Read {results.length} {results.length === 1 ? "source" : "sources"}
|
|
58
|
+
</span>
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
<div className="flex min-h-[5.75rem] max-h-48 flex-col overflow-y-auto overscroll-contain pr-1">
|
|
62
|
+
{revealedResults.map((result, index) => (
|
|
63
|
+
<div
|
|
64
|
+
key={`${cycle}-${result.domain}-${index}`}
|
|
65
|
+
className="fade-in slide-in-from-bottom-1 animate-in fill-mode-both hover:bg-foreground/[0.03] -mx-2.5 flex items-center gap-2.5 rounded-xl px-2.5 py-1.5 transition-colors duration-300"
|
|
66
|
+
>
|
|
67
|
+
<span className="bg-foreground/[0.06] text-foreground/45 flex size-4 shrink-0 items-center justify-center rounded text-[9px] font-medium">
|
|
68
|
+
{result.domain.charAt(0).toUpperCase()}
|
|
69
|
+
</span>
|
|
70
|
+
<span className="text-foreground/90 min-w-0 flex-1 truncate text-[13.5px]">
|
|
71
|
+
{result.title}
|
|
72
|
+
</span>
|
|
73
|
+
<span className={cn(mono, "text-foreground/35 shrink-0")}>
|
|
74
|
+
{result.domain}
|
|
75
|
+
</span>
|
|
76
|
+
</div>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type UnknownRecord = Record<string, unknown>;
|
|
84
|
+
|
|
85
|
+
function recordOf(value: unknown): UnknownRecord {
|
|
86
|
+
return value != null && typeof value === "object" && !Array.isArray(value)
|
|
87
|
+
? value as UnknownRecord
|
|
88
|
+
: {};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function webSearchResults(output: unknown): WebSearchResult[] {
|
|
92
|
+
const record = recordOf(output);
|
|
93
|
+
const candidates = [record.sources, record.searchResults]
|
|
94
|
+
.flatMap((value) => Array.isArray(value) ? value : []);
|
|
95
|
+
const seen = new Set<string>();
|
|
96
|
+
return candidates.flatMap((value) => {
|
|
97
|
+
const result = recordOf(value);
|
|
98
|
+
if (typeof result.url !== "string") return [];
|
|
99
|
+
try {
|
|
100
|
+
const url = new URL(result.url);
|
|
101
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return [];
|
|
102
|
+
if (seen.has(url.href)) return [];
|
|
103
|
+
seen.add(url.href);
|
|
104
|
+
return [{
|
|
105
|
+
title: typeof result.title === "string" && result.title.trim()
|
|
106
|
+
? result.title.trim()
|
|
107
|
+
: url.hostname,
|
|
108
|
+
domain: url.hostname,
|
|
109
|
+
}];
|
|
110
|
+
} catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function WebSearchToolCall({
|
|
117
|
+
toolCall,
|
|
118
|
+
}: {
|
|
119
|
+
toolCall: CloudOsToolCall;
|
|
120
|
+
}) {
|
|
121
|
+
const query = typeof toolCall.input.query === "string" &&
|
|
122
|
+
toolCall.input.query.trim()
|
|
123
|
+
? toolCall.input.query.trim()
|
|
124
|
+
: "Web search";
|
|
125
|
+
const results = webSearchResults(toolCall.output);
|
|
126
|
+
return (
|
|
127
|
+
<WebSearch
|
|
128
|
+
query={query}
|
|
129
|
+
results={results}
|
|
130
|
+
visibleResults={toolCall.running ? 0 : results.length}
|
|
131
|
+
searching={toolCall.running}
|
|
132
|
+
cycle={0}
|
|
133
|
+
/>
|
|
134
|
+
);
|
|
135
|
+
}
|