@springbrand/message-panel 0.1.3-alpha.33 → 0.1.3-alpha.36
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 +2 -1
- package/cloud-os/assets/thinking-star.svg +9 -0
- package/cloud-os/chat/cloud-os-chat-messages.tsx +290 -239
- package/cloud-os/chat/code-runner.tsx +106 -0
- package/cloud-os/chat/range.ts +8 -0
- package/cloud-os/chat/rich-blocks.tsx +186 -256
- package/cloud-os/chat/surfaces.tsx +90 -0
- package/cloud-os/chat/tool-call.tsx +94 -0
- package/cloud-os/chat/tool-presentation.ts +3 -11
- package/cloud-os/chat/tool-rows.tsx +145 -378
- package/cloud-os/chat/tool-timeline.tsx +123 -0
- package/cloud-os/chat/transcript-model.ts +86 -116
- package/cloud-os/chat/web-search.tsx +135 -0
- package/cloud-os/composer/cloud-os-chat-input.tsx +1 -1
- package/cloud-os/index.ts +10 -5
- package/cloud-os/primitives/collapsible.tsx +21 -0
- package/cloud-os/styles/cloud-os.css +18 -12
- package/demo/chat-scenarios.ts +3 -9
- package/package.json +3 -1
- package/cloud-os/assets/thinking-spark.svg +0 -9
- package/cloud-os/chat/use-lifecycle-disclosure.ts +0 -65
|
@@ -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
|
+
}
|
|
@@ -27,8 +27,8 @@ export interface CloudOsMessageMetadata extends UnknownRecord {
|
|
|
27
27
|
createdAt?: number;
|
|
28
28
|
error?: string;
|
|
29
29
|
interruptedByUser?: boolean;
|
|
30
|
-
turnId?: string;
|
|
31
30
|
turnDurationMs?: number;
|
|
31
|
+
turnId?: string;
|
|
32
32
|
turnStartedAt?: number;
|
|
33
33
|
turnStatus?: string;
|
|
34
34
|
requestedCapabilities?: unknown;
|
|
@@ -78,7 +78,7 @@ export type AssistantBlock =
|
|
|
78
78
|
text: string;
|
|
79
79
|
running: boolean;
|
|
80
80
|
}
|
|
81
|
-
| { kind: "text"; key: string; text: string }
|
|
81
|
+
| { kind: "text"; key: string; text: string; final: boolean }
|
|
82
82
|
| { kind: "toolGroup"; key: string; group: ToolCallGroup }
|
|
83
83
|
| { kind: "plan"; key: string; steps: PlanStep[]; running: boolean }
|
|
84
84
|
// 答案就在 call.output 里(`ask_user` 是 client-settled tool,用户点选后
|
|
@@ -144,10 +144,9 @@ export type CloudOsEntry =
|
|
|
144
144
|
type: "assistant";
|
|
145
145
|
key: string;
|
|
146
146
|
messageId: string;
|
|
147
|
-
turnId
|
|
147
|
+
turnId: string;
|
|
148
148
|
blocks: AssistantBlock[];
|
|
149
149
|
copyText: string;
|
|
150
|
-
durationMs?: number;
|
|
151
150
|
timestamp?: number;
|
|
152
151
|
terminal?: { kind: "interrupted" | "error"; title: string; body?: string };
|
|
153
152
|
};
|
|
@@ -171,10 +170,6 @@ function nonNegativeNumber(value: unknown): number | undefined {
|
|
|
171
170
|
: undefined;
|
|
172
171
|
}
|
|
173
172
|
|
|
174
|
-
function nonEmptyString(value: unknown): string | undefined {
|
|
175
|
-
return typeof value === "string" && value.trim() ? value : undefined;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
173
|
export function cloudOsMetadata(message: UIMessage): CloudOsMessageMetadata {
|
|
179
174
|
return recordOf(message.metadata) as CloudOsMessageMetadata;
|
|
180
175
|
}
|
|
@@ -229,54 +224,6 @@ export function formatFullTimestamp(timestamp: number): string {
|
|
|
229
224
|
});
|
|
230
225
|
}
|
|
231
226
|
|
|
232
|
-
function turnDuration(metadata: CloudOsMessageMetadata): number | undefined {
|
|
233
|
-
const explicit = nonNegativeNumber(metadata.turnDurationMs);
|
|
234
|
-
if (explicit !== undefined) return explicit;
|
|
235
|
-
const startedAt = nonNegativeNumber(metadata.turnStartedAt);
|
|
236
|
-
const completedAt = nonNegativeNumber(metadata.completedAt);
|
|
237
|
-
return startedAt !== undefined && completedAt !== undefined
|
|
238
|
-
? Math.max(0, completedAt - startedAt)
|
|
239
|
-
: undefined;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function isWorkBlock(block: AssistantBlock): boolean {
|
|
243
|
-
return (
|
|
244
|
-
block.kind === "reasoning" ||
|
|
245
|
-
block.kind === "toolGroup" ||
|
|
246
|
-
block.kind === "plan" ||
|
|
247
|
-
block.kind === "parallel" ||
|
|
248
|
-
block.kind === "subagents"
|
|
249
|
-
);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* 把一条 assistant 消息拆成可折叠的工作过程与始终可见的最终内容。
|
|
254
|
-
* 最后一段工作之前的 text 是过程旁白;最后一次工作之后的 text 才是正文。
|
|
255
|
-
*/
|
|
256
|
-
export function partitionAssistantBlocks(blocks: readonly AssistantBlock[]): {
|
|
257
|
-
work: AssistantBlock[];
|
|
258
|
-
content: AssistantBlock[];
|
|
259
|
-
} {
|
|
260
|
-
let lastWorkIndex = -1;
|
|
261
|
-
blocks.forEach((block, index) => {
|
|
262
|
-
if (isWorkBlock(block)) lastWorkIndex = index;
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
const work: AssistantBlock[] = [];
|
|
266
|
-
const content: AssistantBlock[] = [];
|
|
267
|
-
blocks.forEach((block, index) => {
|
|
268
|
-
if (
|
|
269
|
-
isWorkBlock(block) ||
|
|
270
|
-
(block.kind === "text" && index < lastWorkIndex)
|
|
271
|
-
) {
|
|
272
|
-
work.push(block);
|
|
273
|
-
} else {
|
|
274
|
-
content.push(block);
|
|
275
|
-
}
|
|
276
|
-
});
|
|
277
|
-
return { work, content };
|
|
278
|
-
}
|
|
279
|
-
|
|
280
227
|
function toolNameOf(part: unknown): string | null {
|
|
281
228
|
const type = String(recordOf(part).type ?? "");
|
|
282
229
|
if (type === "dynamic-tool") {
|
|
@@ -471,7 +418,7 @@ function assistantBlocks(
|
|
|
471
418
|
const text = String(record.text ?? "").trim();
|
|
472
419
|
if (!text) return;
|
|
473
420
|
flush();
|
|
474
|
-
blocks.push({ kind: "text", key, text });
|
|
421
|
+
blocks.push({ kind: "text", key, text, final: false });
|
|
475
422
|
return;
|
|
476
423
|
}
|
|
477
424
|
|
|
@@ -677,15 +624,13 @@ export function buildCloudOsEntries({
|
|
|
677
624
|
approvalIdOf = () => undefined,
|
|
678
625
|
}: BuildEntriesOptions): CloudOsEntry[] {
|
|
679
626
|
const entries: CloudOsEntry[] = [];
|
|
627
|
+
let fallbackTurnId = "turn:initial";
|
|
680
628
|
const lastAssistantIndex = (() => {
|
|
681
629
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
682
630
|
if (messages[index].role === "assistant") return index;
|
|
683
631
|
}
|
|
684
632
|
return -1;
|
|
685
633
|
})();
|
|
686
|
-
const activeTurnId = isActive && lastAssistantIndex >= 0
|
|
687
|
-
? nonEmptyString(cloudOsMetadata(messages[lastAssistantIndex]!).turnId)
|
|
688
|
-
: undefined;
|
|
689
634
|
|
|
690
635
|
messages.forEach((message, index) => {
|
|
691
636
|
if (message.role === "system") return;
|
|
@@ -713,6 +658,9 @@ export function buildCloudOsEntries({
|
|
|
713
658
|
};
|
|
714
659
|
});
|
|
715
660
|
if (!text && attachments.length === 0) return;
|
|
661
|
+
fallbackTurnId = typeof metadata.turnId === "string" && metadata.turnId
|
|
662
|
+
? metadata.turnId
|
|
663
|
+
: `turn:user:${message.id}`;
|
|
716
664
|
entries.push({
|
|
717
665
|
type: "user",
|
|
718
666
|
key: `user-${message.id}-${index}`,
|
|
@@ -740,10 +688,11 @@ export function buildCloudOsEntries({
|
|
|
740
688
|
type: "assistant",
|
|
741
689
|
key: `assistant-${message.id}-${index}`,
|
|
742
690
|
messageId: message.id,
|
|
743
|
-
turnId:
|
|
691
|
+
turnId: typeof metadata.turnId === "string" && metadata.turnId
|
|
692
|
+
? metadata.turnId
|
|
693
|
+
: fallbackTurnId,
|
|
744
694
|
blocks,
|
|
745
695
|
copyText: messageText(message),
|
|
746
|
-
durationMs: turnDuration(metadata),
|
|
747
696
|
timestamp:
|
|
748
697
|
nonNegativeNumber(metadata.completedAt) ??
|
|
749
698
|
nonNegativeNumber(metadata.createdAt),
|
|
@@ -751,54 +700,70 @@ export function buildCloudOsEntries({
|
|
|
751
700
|
});
|
|
752
701
|
});
|
|
753
702
|
|
|
754
|
-
return
|
|
703
|
+
return markFinalTextBlocks(mergeConsecutiveAssistantTurns(
|
|
755
704
|
dropSupersededActionPresentations(dropSupersededPlans(entries)),
|
|
756
|
-
|
|
757
|
-
);
|
|
705
|
+
));
|
|
758
706
|
}
|
|
759
707
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
entries.forEach((entry, index) => {
|
|
767
|
-
if (entry.type !== "assistant" || !entry.turnId) return;
|
|
768
|
-
const indexes = indexesByTurn.get(entry.turnId) ?? [];
|
|
769
|
-
indexes.push(index);
|
|
770
|
-
indexesByTurn.set(entry.turnId, indexes);
|
|
771
|
-
});
|
|
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
|
+
}
|
|
772
714
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
if (turnId === activeTurnId || indexes.length < 2) continue;
|
|
777
|
-
const assistantEntries = indexes.map(
|
|
778
|
-
(index) => entries[index] as Extract<CloudOsEntry, { type: "assistant" }>,
|
|
779
|
-
);
|
|
780
|
-
const anchorIndex = indexes[indexes.length - 1]!;
|
|
781
|
-
const anchor = assistantEntries[assistantEntries.length - 1]!;
|
|
782
|
-
const durations = assistantEntries.flatMap((entry) =>
|
|
783
|
-
entry.durationMs === undefined ? [] : [entry.durationMs]
|
|
784
|
-
);
|
|
785
|
-
const timestamps = assistantEntries.flatMap((entry) =>
|
|
786
|
-
entry.timestamp === undefined ? [] : [entry.timestamp]
|
|
787
|
-
);
|
|
715
|
+
function isWorkBoundaryBlock(block: AssistantBlock): boolean {
|
|
716
|
+
return block.kind !== "text" && !isFinalOutputBlock(block);
|
|
717
|
+
}
|
|
788
718
|
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
key: `assistant-turn-${turnId}`,
|
|
793
|
-
blocks: assistantEntries.flatMap((entry) => entry.blocks),
|
|
794
|
-
...(durations.length > 0 ? { durationMs: Math.max(...durations) } : {}),
|
|
795
|
-
...(timestamps.length > 0 ? { timestamp: Math.max(...timestamps) } : {}),
|
|
796
|
-
});
|
|
797
|
-
}
|
|
719
|
+
function markFinalTextBlocks(entries: CloudOsEntry[]): CloudOsEntry[] {
|
|
720
|
+
return entries.map((entry) => {
|
|
721
|
+
if (entry.type !== "assistant") return entry;
|
|
798
722
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
+
}, []);
|
|
802
767
|
}
|
|
803
768
|
|
|
804
769
|
function dropSupersededActionPresentations(entries: CloudOsEntry[]): CloudOsEntry[] {
|
|
@@ -848,31 +813,31 @@ function dropSupersededActionPresentations(entries: CloudOsEntry[]): CloudOsEntr
|
|
|
848
813
|
}
|
|
849
814
|
|
|
850
815
|
/**
|
|
851
|
-
* update_plan 是**整份快照覆盖**,不是增量事件 ——
|
|
852
|
-
* 完整计划。原样按序渲染会把同一份计划画四遍(0/3、1/3、2/3、3/3)
|
|
853
|
-
*
|
|
816
|
+
* update_plan 是**整份快照覆盖**,不是增量事件 —— 一次 Turn 里每推进一步就会再发一份
|
|
817
|
+
* 完整计划。原样按序渲染会把同一份计划画四遍(0/3、1/3、2/3、3/3),只有该 Turn 的
|
|
818
|
+
* 最后一份是当前事实。所以每个 turnId 各自保留最后一份计划快照。
|
|
854
819
|
*/
|
|
855
820
|
function dropSupersededPlans(entries: CloudOsEntry[]): CloudOsEntry[] {
|
|
856
|
-
|
|
857
|
-
let lastPlanBlock = -1;
|
|
821
|
+
const lastPlanByTurn = new Map<string, { entryIndex: number; blockIndex: number }>();
|
|
858
822
|
entries.forEach((entry, entryIndex) => {
|
|
859
823
|
if (entry.type !== "assistant") return;
|
|
860
824
|
entry.blocks.forEach((block, blockIndex) => {
|
|
861
825
|
if (block.kind !== "plan") return;
|
|
862
|
-
|
|
863
|
-
lastPlanBlock = blockIndex;
|
|
826
|
+
lastPlanByTurn.set(entry.turnId, { entryIndex, blockIndex });
|
|
864
827
|
});
|
|
865
828
|
});
|
|
866
|
-
if (
|
|
829
|
+
if (lastPlanByTurn.size === 0) return entries;
|
|
867
830
|
|
|
868
831
|
return entries.map((entry, entryIndex) =>
|
|
869
832
|
entry.type === "assistant"
|
|
870
833
|
? {
|
|
871
834
|
...entry,
|
|
872
835
|
blocks: entry.blocks.filter(
|
|
873
|
-
(block, blockIndex) =>
|
|
874
|
-
block.kind !== "plan"
|
|
875
|
-
|
|
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
|
+
},
|
|
876
841
|
),
|
|
877
842
|
}
|
|
878
843
|
: entry,
|
|
@@ -983,6 +948,8 @@ export function deriveTurnActivity(
|
|
|
983
948
|
options: {
|
|
984
949
|
isActive: boolean;
|
|
985
950
|
isRecovering?: boolean;
|
|
951
|
+
recoveryAttempt?: number;
|
|
952
|
+
recoveryMax?: number;
|
|
986
953
|
recoveryStatusLabel?: string;
|
|
987
954
|
awaitingApproval?: boolean;
|
|
988
955
|
hasPendingSteer?: boolean;
|
|
@@ -991,7 +958,10 @@ export function deriveTurnActivity(
|
|
|
991
958
|
if (options.isRecovering) {
|
|
992
959
|
return {
|
|
993
960
|
state: "connecting",
|
|
994
|
-
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}…`),
|
|
995
965
|
};
|
|
996
966
|
}
|
|
997
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
|
+
}
|
|
@@ -320,7 +320,7 @@ function CloudOsChatInputContent({
|
|
|
320
320
|
|
|
321
321
|
return (
|
|
322
322
|
// isolation: isolate 把输入框内部的 z-index 关起来,免得盖到 portal 出去的模型下拉。
|
|
323
|
-
<div className="cos-chat-input-root relative isolate px-4
|
|
323
|
+
<div className="cos-chat-input-root relative isolate px-4 pb-4">
|
|
324
324
|
<ComposerTriggerPopover
|
|
325
325
|
char="/"
|
|
326
326
|
{...slash}
|
package/cloud-os/index.ts
CHANGED
|
@@ -68,13 +68,19 @@ export type {
|
|
|
68
68
|
export {
|
|
69
69
|
ThinkingTraceRow,
|
|
70
70
|
ToolCallDetails,
|
|
71
|
-
ToolGroupDetails,
|
|
72
71
|
ToolGroupRow,
|
|
73
|
-
WorkDescriptionRow,
|
|
74
72
|
WorkIcon,
|
|
75
|
-
WorkTraceDisclosure,
|
|
76
|
-
formatWorkDuration,
|
|
77
73
|
} from "./chat/tool-rows";
|
|
74
|
+
export { ToolCall } from "./chat/tool-call";
|
|
75
|
+
export type { ToolCallProps } from "./chat/tool-call";
|
|
76
|
+
export { CodeRunner } from "./chat/code-runner";
|
|
77
|
+
export type { RunState } from "./chat/code-runner";
|
|
78
|
+
export { ToolTimeline } from "./chat/tool-timeline";
|
|
79
|
+
export type {
|
|
80
|
+
TimelineStat,
|
|
81
|
+
TimelineStep,
|
|
82
|
+
ToolTimelineProps,
|
|
83
|
+
} from "./chat/tool-timeline";
|
|
78
84
|
export {
|
|
79
85
|
ApprovalBlock,
|
|
80
86
|
AskUserBlock,
|
|
@@ -98,7 +104,6 @@ export {
|
|
|
98
104
|
formatClockTime,
|
|
99
105
|
formatFullTimestamp,
|
|
100
106
|
messageText,
|
|
101
|
-
partitionAssistantBlocks,
|
|
102
107
|
requestedCapabilitiesOf,
|
|
103
108
|
rhythmTopClass,
|
|
104
109
|
} from "./chat/transcript-model";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";
|
|
4
|
+
|
|
5
|
+
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
|
|
6
|
+
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
|
|
10
|
+
return (
|
|
11
|
+
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
|
|
16
|
+
return (
|
|
17
|
+
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|