@springbrand/message-panel 0.1.3-alpha.4 → 0.1.3-alpha.6
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/chat/activity-indicator.tsx +2 -2
- package/cloud-os/chat/cloud-os-chat-messages.tsx +56 -40
- package/cloud-os/chat/rich-blocks.tsx +180 -65
- package/cloud-os/chat/tool-presentation.ts +7 -5
- package/cloud-os/chat/transcript-model.ts +127 -53
- package/cloud-os/composer/cloud-os-chat-input.tsx +29 -44
- package/cloud-os/file-view.tsx +258 -0
- package/cloud-os/index.ts +11 -0
- package/demo/cloud-os-chat-showcase.tsx +11 -0
- package/package.json +2 -2
|
@@ -3,10 +3,10 @@ import { useCloudOsMode } from "../internal/theme-context";
|
|
|
3
3
|
import type { CloudOsActivity } from "./transcript-model";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
6
|
+
* 回合存活期间持续显示的全局活动指示。
|
|
7
7
|
*
|
|
8
8
|
* 用 thinking-orbs 的 canvas 球而不是一行 shimmer 文字:动画本身就是「还活着」
|
|
9
|
-
* 的信号,文案只负责说清在干什么(`deriveTurnActivity`
|
|
9
|
+
* 的信号,文案只负责说清在干什么(`deriveTurnActivity` 给出的九种之一)。
|
|
10
10
|
*
|
|
11
11
|
* 和 `src/status.tsx` 的 `ThinkingIndicator` 是同一个上游球、同一套 role/aria
|
|
12
12
|
* 形状,但**不共用实现** —— cloud-os 不 import `../src`(见 cloud-os/README.md,
|
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
CaretRight,
|
|
6
6
|
Check,
|
|
7
7
|
Copy,
|
|
8
|
-
File as FileIcon,
|
|
9
8
|
Terminal,
|
|
10
9
|
} from "@phosphor-icons/react";
|
|
11
10
|
import {
|
|
@@ -37,6 +36,10 @@ import {
|
|
|
37
36
|
import { ThinkingTraceRow, ToolGroupRow } from "./tool-rows";
|
|
38
37
|
import { CloudOsActivityIndicator } from "./activity-indicator";
|
|
39
38
|
import { CapabilityChip } from "../capability-chip";
|
|
39
|
+
import {
|
|
40
|
+
renderFileView,
|
|
41
|
+
type FileRenderer,
|
|
42
|
+
} from "../file-view";
|
|
40
43
|
import {
|
|
41
44
|
buildCloudOsEntries,
|
|
42
45
|
deriveTurnActivity,
|
|
@@ -51,9 +54,12 @@ export type CloudOsChatStatus = "ready" | "submitted" | "streaming" | "error";
|
|
|
51
54
|
export interface CloudOsChatMessagesProps {
|
|
52
55
|
messages: readonly UIMessage[];
|
|
53
56
|
status: CloudOsChatStatus;
|
|
57
|
+
/** 权威 Turn 生命周期;提供时替代本地 status 推断。 */
|
|
58
|
+
turnActive?: boolean;
|
|
54
59
|
isHydrating?: boolean;
|
|
55
60
|
isRecovering?: boolean;
|
|
56
61
|
recoveryStatusLabel?: string;
|
|
62
|
+
awaitingApproval?: boolean;
|
|
57
63
|
/** 有 pending steer 时,活动占位说明「正在等当前步骤收尾」。 */
|
|
58
64
|
hasPendingSteer?: boolean;
|
|
59
65
|
/** 首帧就带着待发消息:此时不显示空会话欢迎语。 */
|
|
@@ -75,6 +81,8 @@ export interface CloudOsChatMessagesProps {
|
|
|
75
81
|
input: Record<string, unknown>;
|
|
76
82
|
disabled: boolean;
|
|
77
83
|
}) => ReactNode;
|
|
84
|
+
/** 接管消息里的文件视图;未提供时使用包内 FileView。 */
|
|
85
|
+
renderFile?: FileRenderer;
|
|
78
86
|
/** 把消息里的 `sandbox:/workspace/...` 之类地址翻译成可访问 URL。 */
|
|
79
87
|
resolveUrl?: CloudOsUrlResolver;
|
|
80
88
|
/** 消息区宽度是否收敛到 920px 居中(GadgetEditor 里叫 constrainChatWidth)。 */
|
|
@@ -119,11 +127,13 @@ function UserBubble({
|
|
|
119
127
|
entry,
|
|
120
128
|
onCopy,
|
|
121
129
|
copied,
|
|
130
|
+
renderFile,
|
|
122
131
|
resolveUrl,
|
|
123
132
|
}: {
|
|
124
133
|
entry: Extract<CloudOsEntry, { type: "user" }>;
|
|
125
134
|
onCopy: (text: string) => void;
|
|
126
135
|
copied: boolean;
|
|
136
|
+
renderFile?: FileRenderer;
|
|
127
137
|
resolveUrl?: CloudOsUrlResolver;
|
|
128
138
|
}) {
|
|
129
139
|
return (
|
|
@@ -141,24 +151,14 @@ function UserBubble({
|
|
|
141
151
|
)}
|
|
142
152
|
{entry.attachments.length > 0 && (
|
|
143
153
|
<div className="mb-1.5 flex max-w-[min(680px,78%)] flex-wrap justify-end gap-2">
|
|
144
|
-
{entry.attachments.map((attachment, index) =>
|
|
145
|
-
attachment.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
) : (
|
|
153
|
-
<span
|
|
154
|
-
key={`${attachment.url}:${index}`}
|
|
155
|
-
className="inline-flex items-center gap-1.5 rounded-lg border border-kumo-line bg-kumo-elevated px-2.5 py-1.5 text-[12px] leading-4 text-kumo-subtle"
|
|
156
|
-
>
|
|
157
|
-
<FileIcon size={13} className="text-kumo-inactive" />
|
|
158
|
-
{attachment.filename ?? "Attachment"}
|
|
159
|
-
</span>
|
|
160
|
-
),
|
|
161
|
-
)}
|
|
154
|
+
{entry.attachments.map((attachment, index) => (
|
|
155
|
+
<Fragment key={`${attachment.url}:${index}`}>
|
|
156
|
+
{renderFileView(renderFile, {
|
|
157
|
+
file: attachment,
|
|
158
|
+
placement: "user-message",
|
|
159
|
+
})}
|
|
160
|
+
</Fragment>
|
|
161
|
+
))}
|
|
162
162
|
</div>
|
|
163
163
|
)}
|
|
164
164
|
{entry.text && (
|
|
@@ -196,9 +196,11 @@ function UserBubble({
|
|
|
196
196
|
export function CloudOsChatMessages({
|
|
197
197
|
messages,
|
|
198
198
|
status,
|
|
199
|
+
turnActive,
|
|
199
200
|
isHydrating = false,
|
|
200
201
|
isRecovering = false,
|
|
201
202
|
recoveryStatusLabel,
|
|
203
|
+
awaitingApproval = false,
|
|
202
204
|
hasPendingSteer = false,
|
|
203
205
|
startedWithPending = false,
|
|
204
206
|
error,
|
|
@@ -206,6 +208,7 @@ export function CloudOsChatMessages({
|
|
|
206
208
|
approvalIdOf,
|
|
207
209
|
approvalsDisabled = false,
|
|
208
210
|
renderApproval,
|
|
211
|
+
renderFile,
|
|
209
212
|
resolveUrl,
|
|
210
213
|
constrainWidth = true,
|
|
211
214
|
emptyTitle = "What are we working on?",
|
|
@@ -226,7 +229,8 @@ export function CloudOsChatMessages({
|
|
|
226
229
|
ReadonlySet<string>
|
|
227
230
|
>(() => new Set());
|
|
228
231
|
const { copied, copy } = useCopyAction();
|
|
229
|
-
const isActive =
|
|
232
|
+
const isActive =
|
|
233
|
+
turnActive ?? (status === "submitted" || status === "streaming");
|
|
230
234
|
|
|
231
235
|
const entries = useMemo(
|
|
232
236
|
() =>
|
|
@@ -279,10 +283,13 @@ export function CloudOsChatMessages({
|
|
|
279
283
|
element.scrollTo({ top: element.scrollHeight });
|
|
280
284
|
}, [isRecovering, messages, status, showScrollButton]);
|
|
281
285
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
+
const activity = deriveTurnActivity(entries, {
|
|
287
|
+
isActive,
|
|
288
|
+
isRecovering,
|
|
289
|
+
recoveryStatusLabel,
|
|
290
|
+
awaitingApproval,
|
|
291
|
+
hasPendingSteer,
|
|
292
|
+
});
|
|
286
293
|
const copyText = (text: string) => {
|
|
287
294
|
copy(text);
|
|
288
295
|
onCopy?.(text);
|
|
@@ -293,7 +300,7 @@ export function CloudOsChatMessages({
|
|
|
293
300
|
className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-kumo-base"
|
|
294
301
|
data-cloud-os-transcript=""
|
|
295
302
|
data-status={status}
|
|
296
|
-
aria-busy={isActive || isHydrating}
|
|
303
|
+
aria-busy={isActive || isHydrating || isRecovering}
|
|
297
304
|
>
|
|
298
305
|
<div
|
|
299
306
|
ref={scrollRef}
|
|
@@ -324,6 +331,7 @@ export function CloudOsChatMessages({
|
|
|
324
331
|
entry={entry}
|
|
325
332
|
onCopy={copyText}
|
|
326
333
|
copied={copied === entry.text}
|
|
334
|
+
renderFile={renderFile}
|
|
327
335
|
resolveUrl={resolveUrl}
|
|
328
336
|
/>
|
|
329
337
|
</div>
|
|
@@ -516,13 +524,29 @@ export function CloudOsChatMessages({
|
|
|
516
524
|
);
|
|
517
525
|
case "image":
|
|
518
526
|
return (
|
|
519
|
-
<
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
527
|
+
<Fragment key={block.key}>
|
|
528
|
+
{renderFileView(renderFile, {
|
|
529
|
+
file: {
|
|
530
|
+
url: resolveUrl?.(block.src) ?? block.src,
|
|
531
|
+
filename: block.alt,
|
|
532
|
+
mediaType: block.mediaType ?? "image/*",
|
|
533
|
+
},
|
|
534
|
+
placement: "assistant-message",
|
|
535
|
+
})}
|
|
536
|
+
</Fragment>
|
|
537
|
+
);
|
|
538
|
+
case "file":
|
|
539
|
+
return (
|
|
540
|
+
<Fragment key={block.key}>
|
|
541
|
+
{renderFileView(renderFile, {
|
|
542
|
+
file: {
|
|
543
|
+
...block.file,
|
|
544
|
+
url:
|
|
545
|
+
resolveUrl?.(block.file.url) ?? block.file.url,
|
|
546
|
+
},
|
|
547
|
+
placement: "assistant-message",
|
|
548
|
+
})}
|
|
549
|
+
</Fragment>
|
|
526
550
|
);
|
|
527
551
|
case "error":
|
|
528
552
|
return (
|
|
@@ -583,14 +607,6 @@ export function CloudOsChatMessages({
|
|
|
583
607
|
);
|
|
584
608
|
})}
|
|
585
609
|
|
|
586
|
-
{isRecovering && (
|
|
587
|
-
<div className="mt-4 flex max-w-[860px] items-center gap-3 px-1.5 py-1 text-[14px] leading-5 tracking-[-0.25px]">
|
|
588
|
-
<span className="cos-thinking-shimmer">
|
|
589
|
-
{recoveryStatusLabel ?? "Reconnecting…"}
|
|
590
|
-
</span>
|
|
591
|
-
</div>
|
|
592
|
-
)}
|
|
593
|
-
|
|
594
610
|
{activity && (
|
|
595
611
|
<div className="mt-5 inline-flex px-1.5 py-1 text-[14px] leading-5 tracking-[-0.25px]">
|
|
596
612
|
<CloudOsActivityIndicator {...activity} />
|
|
@@ -3,8 +3,9 @@ import {
|
|
|
3
3
|
CaretRight,
|
|
4
4
|
Check,
|
|
5
5
|
CircleNotch,
|
|
6
|
+
Clock,
|
|
7
|
+
Key,
|
|
6
8
|
ListChecks,
|
|
7
|
-
ShieldCheck,
|
|
8
9
|
UsersThree,
|
|
9
10
|
WarningCircle,
|
|
10
11
|
} from "@phosphor-icons/react";
|
|
@@ -18,7 +19,12 @@ import type {
|
|
|
18
19
|
PlanStep,
|
|
19
20
|
SubAgentView,
|
|
20
21
|
} from "./transcript-model";
|
|
21
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
askUserOutcome,
|
|
24
|
+
humanizeToolName,
|
|
25
|
+
safeStringify,
|
|
26
|
+
type CloudOsToolCall,
|
|
27
|
+
} from "./tool-presentation";
|
|
22
28
|
|
|
23
29
|
/**
|
|
24
30
|
* UIMessage 协议里存在、gadgets 那套没有的几类 part —— 计划快照、原位提问、
|
|
@@ -357,6 +363,66 @@ function scheduleIdOf(output: unknown): string {
|
|
|
357
363
|
return typeof id === "string" ? id : "";
|
|
358
364
|
}
|
|
359
365
|
|
|
366
|
+
export function ScheduleConfirmation({
|
|
367
|
+
input,
|
|
368
|
+
disabled = false,
|
|
369
|
+
onCancel,
|
|
370
|
+
onConfirm,
|
|
371
|
+
}: {
|
|
372
|
+
input: Record<string, unknown>;
|
|
373
|
+
disabled?: boolean;
|
|
374
|
+
onCancel: () => void;
|
|
375
|
+
onConfirm: () => void;
|
|
376
|
+
}) {
|
|
377
|
+
const name = String(input.label ?? input.title ?? "Scheduled task");
|
|
378
|
+
const prompt = typeof input.prompt === "string" && input.prompt.trim()
|
|
379
|
+
? input.prompt.trim()
|
|
380
|
+
: "Run the scheduled task";
|
|
381
|
+
|
|
382
|
+
return (
|
|
383
|
+
<section
|
|
384
|
+
data-slot="schedule-card"
|
|
385
|
+
aria-label={`确认定时任务:${name}`}
|
|
386
|
+
className="themed-surface-inset flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-kumo-line/70 bg-kumo-elevated p-4 text-kumo-default shadow-[0_1px_2px_rgba(0,0,0,0.04),0_12px_32px_-16px_rgba(0,0,0,0.12)]"
|
|
387
|
+
>
|
|
388
|
+
<div className="flex items-center gap-2.5">
|
|
389
|
+
<span
|
|
390
|
+
className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-kumo-fill/70 text-kumo-subtle"
|
|
391
|
+
aria-hidden="true"
|
|
392
|
+
>
|
|
393
|
+
<Clock size={14} />
|
|
394
|
+
</span>
|
|
395
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
396
|
+
<span className="truncate text-[13.5px] leading-[18px] font-medium">
|
|
397
|
+
{name}
|
|
398
|
+
</span>
|
|
399
|
+
<span className="truncate font-mono text-[11px] leading-4 tracking-tight text-kumo-inactive">
|
|
400
|
+
{scheduleCadence(input)}
|
|
401
|
+
</span>
|
|
402
|
+
</div>
|
|
403
|
+
</div>
|
|
404
|
+
|
|
405
|
+
<div className="flex items-start gap-2 rounded-xl bg-kumo-fill/50 px-3 py-2">
|
|
406
|
+
<span className="shrink-0 font-mono text-[11px] leading-[18px] tracking-tight text-kumo-inactive">
|
|
407
|
+
task
|
|
408
|
+
</span>
|
|
409
|
+
<span className="min-w-0 flex-1 text-[13px] leading-[18px] text-kumo-subtle">
|
|
410
|
+
{prompt}
|
|
411
|
+
</span>
|
|
412
|
+
</div>
|
|
413
|
+
|
|
414
|
+
<div className="flex items-center justify-end gap-2">
|
|
415
|
+
<WorkshopButton disabled={disabled} onClick={onCancel}>
|
|
416
|
+
取消
|
|
417
|
+
</WorkshopButton>
|
|
418
|
+
<WorkshopButton tone="primary" disabled={disabled} onClick={onConfirm}>
|
|
419
|
+
{disabled ? "正在确认…" : "确认安排"}
|
|
420
|
+
</WorkshopButton>
|
|
421
|
+
</div>
|
|
422
|
+
</section>
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
360
426
|
export function ScheduleBlock({
|
|
361
427
|
call,
|
|
362
428
|
onOpen,
|
|
@@ -365,6 +431,8 @@ export function ScheduleBlock({
|
|
|
365
431
|
/** 有 id 且宿主给了回调时,整张卡变成跳到「已安排」详情的按钮。 */
|
|
366
432
|
onOpen?: (scheduleId: string) => void;
|
|
367
433
|
}) {
|
|
434
|
+
// 待确认时由宿主把持久化审批渲染在输入框上方;这里不重复画第二张卡。
|
|
435
|
+
if (call.awaitingApproval) return null;
|
|
368
436
|
const state = call.failed ? "failed" : call.running ? "running" : "scheduled";
|
|
369
437
|
const scheduleId = state === "scheduled" ? scheduleIdOf(call.output) : "";
|
|
370
438
|
const clickable = Boolean(scheduleId && onOpen);
|
|
@@ -432,10 +500,112 @@ export function ScheduleBlock({
|
|
|
432
500
|
);
|
|
433
501
|
}
|
|
434
502
|
|
|
435
|
-
// ── 原位授权
|
|
503
|
+
// ── 原位授权 ──────────────────────────────────────────────
|
|
436
504
|
|
|
437
505
|
export type ApprovalDecision = "deny" | "allow_once" | "allow_level";
|
|
438
506
|
|
|
507
|
+
export function PermissionGrant({
|
|
508
|
+
capability,
|
|
509
|
+
requester,
|
|
510
|
+
reach,
|
|
511
|
+
disabled = false,
|
|
512
|
+
onGrant,
|
|
513
|
+
"aria-label": ariaLabel = `${capability} permission request`,
|
|
514
|
+
}: {
|
|
515
|
+
capability: string;
|
|
516
|
+
requester: string;
|
|
517
|
+
reach: readonly string[];
|
|
518
|
+
disabled?: boolean;
|
|
519
|
+
onGrant: (decision: ApprovalDecision) => void;
|
|
520
|
+
"aria-label"?: string;
|
|
521
|
+
}) {
|
|
522
|
+
const actionClassName =
|
|
523
|
+
"inline-flex h-8 items-center rounded-full px-3 text-[12px] leading-none font-medium tracking-[-0.25px] transition-[background-color,color,opacity,transform] duration-150 ease-out active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-ring/40 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100";
|
|
524
|
+
|
|
525
|
+
return (
|
|
526
|
+
<section
|
|
527
|
+
aria-label={ariaLabel}
|
|
528
|
+
className="themed-surface-inset w-full max-w-sm rounded-[20px] border border-kumo-line bg-kumo-elevated p-4 text-kumo-default shadow-sm"
|
|
529
|
+
>
|
|
530
|
+
<div className="flex items-center gap-2.5">
|
|
531
|
+
<span
|
|
532
|
+
className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-kumo-fill/70 text-kumo-subtle"
|
|
533
|
+
aria-hidden="true"
|
|
534
|
+
>
|
|
535
|
+
<Key size={14} weight="bold" />
|
|
536
|
+
</span>
|
|
537
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
538
|
+
<span className="truncate text-[13.5px] leading-[18px] font-medium">
|
|
539
|
+
{capability}
|
|
540
|
+
</span>
|
|
541
|
+
<span className="truncate text-[12px] leading-4 text-kumo-inactive">
|
|
542
|
+
requested by {requester}
|
|
543
|
+
</span>
|
|
544
|
+
</div>
|
|
545
|
+
</div>
|
|
546
|
+
|
|
547
|
+
<div className="mt-3.5 flex max-h-[200px] flex-col gap-1 overflow-y-auto pr-1">
|
|
548
|
+
<span className="font-mono text-[10px] leading-4 text-kumo-inactive">
|
|
549
|
+
this grants
|
|
550
|
+
</span>
|
|
551
|
+
{reach.map((item, index) => (
|
|
552
|
+
<span
|
|
553
|
+
key={`${index}:${item}`}
|
|
554
|
+
className="flex items-baseline gap-2 text-[12px] leading-[17px] text-kumo-subtle"
|
|
555
|
+
>
|
|
556
|
+
<span
|
|
557
|
+
className="size-1 shrink-0 rounded-full bg-kumo-interact"
|
|
558
|
+
aria-hidden="true"
|
|
559
|
+
/>
|
|
560
|
+
<span className="min-w-0 break-words whitespace-pre-wrap">{item}</span>
|
|
561
|
+
</span>
|
|
562
|
+
))}
|
|
563
|
+
</div>
|
|
564
|
+
|
|
565
|
+
<div className="mt-3.5 flex min-h-8 flex-wrap items-center justify-end gap-1">
|
|
566
|
+
<button
|
|
567
|
+
type="button"
|
|
568
|
+
disabled={disabled}
|
|
569
|
+
onClick={() => onGrant("deny")}
|
|
570
|
+
className={`${actionClassName} text-kumo-subtle enabled:hover:bg-kumo-fill enabled:hover:text-kumo-default`}
|
|
571
|
+
>
|
|
572
|
+
Deny
|
|
573
|
+
</button>
|
|
574
|
+
<button
|
|
575
|
+
type="button"
|
|
576
|
+
disabled={disabled}
|
|
577
|
+
onClick={() => onGrant("allow_once")}
|
|
578
|
+
className={`${actionClassName} text-kumo-subtle enabled:hover:bg-kumo-fill enabled:hover:text-kumo-default`}
|
|
579
|
+
>
|
|
580
|
+
Allow once
|
|
581
|
+
</button>
|
|
582
|
+
<Tooltip
|
|
583
|
+
content="Allow every action at this execution level, without future prompts."
|
|
584
|
+
asChild
|
|
585
|
+
>
|
|
586
|
+
<button
|
|
587
|
+
type="button"
|
|
588
|
+
disabled={disabled}
|
|
589
|
+
onClick={() => onGrant("allow_level")}
|
|
590
|
+
className={`${actionClassName} bg-kumo-contrast text-kumo-inverse enabled:hover:bg-kumo-strong`}
|
|
591
|
+
>
|
|
592
|
+
Allow level
|
|
593
|
+
</button>
|
|
594
|
+
</Tooltip>
|
|
595
|
+
</div>
|
|
596
|
+
</section>
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function approvalReach(call: CloudOsToolCall): string[] {
|
|
601
|
+
const entries = Object.entries(call.input);
|
|
602
|
+
return entries.length > 0
|
|
603
|
+
? entries.map(
|
|
604
|
+
([name, value]) => `${humanizeToolName(name)}: ${safeStringify(value)}`,
|
|
605
|
+
)
|
|
606
|
+
: ["Run this tool without arguments"];
|
|
607
|
+
}
|
|
608
|
+
|
|
439
609
|
export function ApprovalBlock({
|
|
440
610
|
call,
|
|
441
611
|
approvalId,
|
|
@@ -447,70 +617,15 @@ export function ApprovalBlock({
|
|
|
447
617
|
disabled?: boolean;
|
|
448
618
|
onApprove: (approvalId: string, decision: ApprovalDecision) => void;
|
|
449
619
|
}) {
|
|
450
|
-
const title = `${call.toolName} needs approval`;
|
|
451
|
-
const description =
|
|
452
|
-
Object.keys(call.input).length > 0
|
|
453
|
-
? "```json\n" + JSON.stringify(call.input, null, 2) + "\n```"
|
|
454
|
-
: "This action pauses the turn until you decide.";
|
|
455
|
-
|
|
456
620
|
return (
|
|
457
621
|
<div className="group/work max-w-[860px] text-[14px] leading-5 tracking-[-0.25px] text-kumo-subtle">
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
aria-hidden="true"
|
|
466
|
-
>
|
|
467
|
-
<ShieldCheck size={20} weight="fill" />
|
|
468
|
-
</span>
|
|
469
|
-
<div className="min-w-[12rem] flex-1">
|
|
470
|
-
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
|
471
|
-
<span className="min-w-0 truncate font-medium text-kumo-default">
|
|
472
|
-
{title}
|
|
473
|
-
</span>
|
|
474
|
-
</div>
|
|
475
|
-
<div className="cos-chat-panel cos-markdown mt-1 max-h-[200px] overflow-y-auto pr-1 text-[13px] leading-[18px] text-kumo-subtle">
|
|
476
|
-
<MarkdownMessage message={description} />
|
|
477
|
-
</div>
|
|
478
|
-
</div>
|
|
479
|
-
<div className="ml-auto flex flex-wrap items-center justify-end gap-1 self-center">
|
|
480
|
-
<Tooltip content="Reject this action and let the agent continue without it." asChild>
|
|
481
|
-
<WorkshopButton
|
|
482
|
-
tone="secondary"
|
|
483
|
-
disabled={disabled}
|
|
484
|
-
onClick={() => onApprove(approvalId, "deny")}
|
|
485
|
-
className="!h-8 !rounded-lg text-[12px]"
|
|
486
|
-
>
|
|
487
|
-
Deny
|
|
488
|
-
</WorkshopButton>
|
|
489
|
-
</Tooltip>
|
|
490
|
-
<Tooltip content="Allow just this call." asChild>
|
|
491
|
-
<WorkshopButton
|
|
492
|
-
tone="secondary"
|
|
493
|
-
disabled={disabled}
|
|
494
|
-
onClick={() => onApprove(approvalId, "allow_once")}
|
|
495
|
-
className="!h-8 !rounded-lg text-[12px]"
|
|
496
|
-
>
|
|
497
|
-
Allow once
|
|
498
|
-
</WorkshopButton>
|
|
499
|
-
</Tooltip>
|
|
500
|
-
<Tooltip content="Allow every action at this execution level, without future prompts." asChild>
|
|
501
|
-
<WorkshopButton
|
|
502
|
-
tone="primary"
|
|
503
|
-
disabled={disabled}
|
|
504
|
-
onClick={() => onApprove(approvalId, "allow_level")}
|
|
505
|
-
className="!h-8 gap-1 !rounded-lg text-[12px]"
|
|
506
|
-
>
|
|
507
|
-
<Check size={11} weight="bold" />
|
|
508
|
-
Allow level
|
|
509
|
-
</WorkshopButton>
|
|
510
|
-
</Tooltip>
|
|
511
|
-
</div>
|
|
512
|
-
</div>
|
|
513
|
-
</div>
|
|
622
|
+
<PermissionGrant
|
|
623
|
+
capability={humanizeToolName(call.toolName)}
|
|
624
|
+
requester="the agent"
|
|
625
|
+
reach={approvalReach(call)}
|
|
626
|
+
disabled={disabled}
|
|
627
|
+
onGrant={(decision) => onApprove(approvalId, decision)}
|
|
628
|
+
/>
|
|
514
629
|
</div>
|
|
515
630
|
);
|
|
516
631
|
}
|
|
@@ -145,7 +145,7 @@ function truncate(value: string, maxLength: number): string {
|
|
|
145
145
|
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…`;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
function
|
|
148
|
+
export function humanizeToolName(name: string): string {
|
|
149
149
|
const spaced = name
|
|
150
150
|
.replace(/^mcp__/, "")
|
|
151
151
|
.replace(/__/g, " ")
|
|
@@ -343,7 +343,7 @@ export function getToolCallSummary(call: CloudOsToolCall): {
|
|
|
343
343
|
case "agent": {
|
|
344
344
|
const name =
|
|
345
345
|
stringField(input, "subagentName", "agent", "name", "agentType") ||
|
|
346
|
-
|
|
346
|
+
humanizeToolName(toolName);
|
|
347
347
|
return { verb: running ? "Running" : "Ran", target: name };
|
|
348
348
|
}
|
|
349
349
|
case "skill": {
|
|
@@ -378,13 +378,15 @@ export function getToolCallSummary(call: CloudOsToolCall): {
|
|
|
378
378
|
const [, server = "", ...rest] = toolName.split("__");
|
|
379
379
|
return {
|
|
380
380
|
verb: running ? "Calling" : "Called",
|
|
381
|
-
target: rest.length
|
|
381
|
+
target: rest.length
|
|
382
|
+
? `${humanizeToolName(rest.join(" "))} · ${humanizeToolName(server)}`
|
|
383
|
+
: undefined,
|
|
382
384
|
};
|
|
383
385
|
}
|
|
384
386
|
case "generic":
|
|
385
387
|
break;
|
|
386
388
|
}
|
|
387
|
-
const displayName =
|
|
389
|
+
const displayName = humanizeToolName(toolName);
|
|
388
390
|
return { verb: running ? `Using ${displayName}` : `Used ${displayName}` };
|
|
389
391
|
}
|
|
390
392
|
|
|
@@ -435,7 +437,7 @@ export function describeToolCallCount(
|
|
|
435
437
|
case "generic":
|
|
436
438
|
break;
|
|
437
439
|
}
|
|
438
|
-
const displayName =
|
|
440
|
+
const displayName = humanizeToolName(toolName);
|
|
439
441
|
return count === 1 ? `Used ${displayName}` : `Used ${displayName} ${formatTimes(count)}`;
|
|
440
442
|
}
|
|
441
443
|
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
buildToolCallGroups,
|
|
6
6
|
toCloudOsToolCall,
|
|
7
7
|
type CloudOsToolCall,
|
|
8
|
+
type CloudOsToolKind,
|
|
8
9
|
type ToolCallGroup,
|
|
9
10
|
} from "./tool-presentation";
|
|
10
11
|
|
|
@@ -65,7 +66,14 @@ export type AssistantBlock =
|
|
|
65
66
|
| { kind: "approval"; key: string; call: CloudOsToolCall; approvalId: string }
|
|
66
67
|
| { kind: "parallel"; key: string; label: string; tools: ParallelTool[] }
|
|
67
68
|
| { kind: "subagents"; key: string; agents: SubAgentView[] }
|
|
68
|
-
| {
|
|
69
|
+
| {
|
|
70
|
+
kind: "image";
|
|
71
|
+
key: string;
|
|
72
|
+
src: string;
|
|
73
|
+
alt: string;
|
|
74
|
+
mediaType?: string;
|
|
75
|
+
}
|
|
76
|
+
| { kind: "file"; key: string; file: CloudOsAttachment }
|
|
69
77
|
| { kind: "error"; key: string; title: string; body?: string };
|
|
70
78
|
|
|
71
79
|
export interface ParallelTool {
|
|
@@ -376,15 +384,32 @@ function assistantBlocks(
|
|
|
376
384
|
const data = dataOf(part);
|
|
377
385
|
const src = String(data.src ?? data.url ?? record.url ?? "");
|
|
378
386
|
const mediaType = String(record.mediaType ?? data.mediaType ?? "");
|
|
379
|
-
if (src
|
|
380
|
-
|
|
387
|
+
if (!src) return;
|
|
388
|
+
|
|
389
|
+
flush();
|
|
390
|
+
const filename = String(
|
|
391
|
+
data.alt ?? data.filename ?? record.filename ?? "Attachment",
|
|
392
|
+
);
|
|
393
|
+
if (dataKind === "image" || mediaType.startsWith("image/")) {
|
|
381
394
|
blocks.push({
|
|
382
395
|
kind: "image",
|
|
383
396
|
key,
|
|
384
397
|
src,
|
|
385
|
-
alt:
|
|
398
|
+
alt: filename,
|
|
399
|
+
mediaType: mediaType || undefined,
|
|
386
400
|
});
|
|
401
|
+
return;
|
|
387
402
|
}
|
|
403
|
+
|
|
404
|
+
blocks.push({
|
|
405
|
+
kind: "file",
|
|
406
|
+
key,
|
|
407
|
+
file: {
|
|
408
|
+
url: src,
|
|
409
|
+
filename,
|
|
410
|
+
mediaType: mediaType || undefined,
|
|
411
|
+
},
|
|
412
|
+
});
|
|
388
413
|
return;
|
|
389
414
|
}
|
|
390
415
|
|
|
@@ -596,75 +621,124 @@ export interface CloudOsActivity {
|
|
|
596
621
|
label: string;
|
|
597
622
|
}
|
|
598
623
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
624
|
+
const SEARCHING_TOOL_KINDS: ReadonlySet<CloudOsToolKind> = new Set([
|
|
625
|
+
"read",
|
|
626
|
+
"glob",
|
|
627
|
+
"grep",
|
|
628
|
+
"list",
|
|
629
|
+
"web-search",
|
|
630
|
+
"web-fetch",
|
|
631
|
+
]);
|
|
632
|
+
const SHAPING_TOOL_KINDS: ReadonlySet<CloudOsToolKind> = new Set([
|
|
633
|
+
"write",
|
|
634
|
+
"edit",
|
|
635
|
+
"tasks",
|
|
636
|
+
"create-agent",
|
|
637
|
+
"publish-extension",
|
|
638
|
+
]);
|
|
639
|
+
|
|
640
|
+
function activityForTool(call: CloudOsToolCall | undefined): CloudOsActivity {
|
|
641
|
+
if (!call) return { state: "working", label: "Working…" };
|
|
642
|
+
const name = call.toolName.replaceAll("-", "_").toLowerCase();
|
|
643
|
+
if (call.kind === "agent") {
|
|
644
|
+
return {
|
|
645
|
+
state: "weaving",
|
|
646
|
+
label: call.running ? "Coordinating agents…" : "Combining results…",
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
if (SEARCHING_TOOL_KINDS.has(call.kind)) {
|
|
650
|
+
return {
|
|
651
|
+
state: "searching",
|
|
652
|
+
label: call.running ? "Searching…" : "Reading results…",
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
if (SHAPING_TOOL_KINDS.has(call.kind)) {
|
|
656
|
+
return { state: "shaping", label: "Shaping result…" };
|
|
657
|
+
}
|
|
658
|
+
if (call.kind === "skill" || name === "activate_skill") {
|
|
659
|
+
return { state: "working", label: "Loading skill…" };
|
|
660
|
+
}
|
|
661
|
+
if (name === "run_skill_script") {
|
|
662
|
+
return { state: "working", label: "Running skill…" };
|
|
663
|
+
}
|
|
664
|
+
return { state: "working", label: "Working…" };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function activityForTail(block: AssistantBlock | undefined): CloudOsActivity {
|
|
668
|
+
if (!block) return { state: "solving", label: "Thinking…" };
|
|
607
669
|
switch (block.kind) {
|
|
608
|
-
case "text":
|
|
609
670
|
case "reasoning":
|
|
610
|
-
return
|
|
671
|
+
return { state: "solving", label: "Reasoning…" };
|
|
672
|
+
case "text":
|
|
673
|
+
case "suggestions":
|
|
674
|
+
return { state: "composing", label: "Composing answer…" };
|
|
611
675
|
case "toolGroup":
|
|
612
|
-
return block.group.
|
|
676
|
+
return activityForTool(block.group.calls.at(-1));
|
|
613
677
|
case "plan":
|
|
614
|
-
|
|
678
|
+
case "image":
|
|
679
|
+
case "file":
|
|
680
|
+
return { state: "shaping", label: "Shaping result…" };
|
|
615
681
|
case "askUser":
|
|
616
|
-
return block.pending
|
|
617
|
-
|
|
682
|
+
return block.pending
|
|
683
|
+
? { state: "listening", label: "Waiting for your answer…" }
|
|
684
|
+
: { state: "solving", label: "Picking up your answer…" };
|
|
618
685
|
case "approval":
|
|
619
|
-
return
|
|
686
|
+
return { state: "breathing", label: "Waiting for approval…" };
|
|
687
|
+
case "schedule":
|
|
688
|
+
return block.call.awaitingApproval
|
|
689
|
+
? { state: "breathing", label: "Waiting for approval…" }
|
|
690
|
+
: { state: "working", label: "Working…" };
|
|
620
691
|
case "parallel":
|
|
621
|
-
return
|
|
692
|
+
return {
|
|
693
|
+
state: "weaving",
|
|
694
|
+
label: block.tools.some((tool) => tool.status === "running")
|
|
695
|
+
? "Coordinating tasks…"
|
|
696
|
+
: "Combining results…",
|
|
697
|
+
};
|
|
622
698
|
case "subagents":
|
|
623
|
-
return
|
|
699
|
+
return {
|
|
700
|
+
state: "weaving",
|
|
701
|
+
label: block.agents.some((agent) => agent.status === "running")
|
|
702
|
+
? "Coordinating agents…"
|
|
703
|
+
: "Combining results…",
|
|
704
|
+
};
|
|
624
705
|
default:
|
|
625
|
-
return
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
// 已结算的尾巴 → 此刻真正在发生的事。措辞刻意只说得出口的那部分:
|
|
630
|
-
// 工具跑完了,模型在读它的输出;卡答完了,模型在接你的答案。
|
|
631
|
-
function activityForTail(block: AssistantBlock | undefined): CloudOsActivity {
|
|
632
|
-
if (!block) return { state: "solving", label: "Thinking…" };
|
|
633
|
-
if (block.kind === "askUser") {
|
|
634
|
-
return { state: "solving", label: "Picking up your answer…" };
|
|
635
|
-
}
|
|
636
|
-
if (block.kind === "toolGroup") {
|
|
637
|
-
const kind = block.group.calls.at(-1)?.kind;
|
|
638
|
-
return kind !== undefined &&
|
|
639
|
-
["web-search", "web-fetch", "grep", "glob", "list", "read"].includes(
|
|
640
|
-
kind,
|
|
641
|
-
)
|
|
642
|
-
? { state: "searching", label: "Reading results…" }
|
|
643
|
-
: { state: "working", label: "Working…" };
|
|
706
|
+
return { state: "working", label: "Working…" };
|
|
644
707
|
}
|
|
645
|
-
return { state: "working", label: "Working…" };
|
|
646
708
|
}
|
|
647
709
|
|
|
648
710
|
/**
|
|
649
|
-
*
|
|
650
|
-
*
|
|
651
|
-
* @remarks
|
|
652
|
-
* 判据是「**尾巴有没有活口**」,不是「本回合有没有出过内容」。后者曾经是这里的
|
|
653
|
-
* 写法,于是答完 `ask_user`(卡片变成 Submitted,读起来就是"结束了")到模型下一
|
|
654
|
-
* 个 chunk 之间整屏没有任何在跑的迹象 —— 而那段恰好是最长的一次 TTFT,看起来
|
|
655
|
-
* 就是卡死。凡是尾巴为**已结算卡片**的断档都是同一个洞,这里一并补上。
|
|
711
|
+
* 回合活着时始终返回一个 Orb 状态;只有回合终止时才返回 `null`。
|
|
656
712
|
*/
|
|
657
713
|
export function deriveTurnActivity(
|
|
658
714
|
entries: readonly CloudOsEntry[],
|
|
659
|
-
options: {
|
|
715
|
+
options: {
|
|
716
|
+
isActive: boolean;
|
|
717
|
+
isRecovering?: boolean;
|
|
718
|
+
recoveryStatusLabel?: string;
|
|
719
|
+
awaitingApproval?: boolean;
|
|
720
|
+
hasPendingSteer?: boolean;
|
|
721
|
+
},
|
|
660
722
|
): CloudOsActivity | null {
|
|
723
|
+
if (options.isRecovering) {
|
|
724
|
+
return {
|
|
725
|
+
state: "connecting",
|
|
726
|
+
label: options.recoveryStatusLabel ?? "Reconnecting…",
|
|
727
|
+
};
|
|
728
|
+
}
|
|
661
729
|
if (!options.isActive) return null;
|
|
730
|
+
if (options.awaitingApproval) {
|
|
731
|
+
return { state: "breathing", label: "Waiting for approval…" };
|
|
732
|
+
}
|
|
733
|
+
if (options.hasPendingSteer) {
|
|
734
|
+
return {
|
|
735
|
+
state: "breathing",
|
|
736
|
+
label: "Waiting for the current step to finish…",
|
|
737
|
+
};
|
|
738
|
+
}
|
|
662
739
|
const tail = entries.at(-1) ?? null;
|
|
663
740
|
const block = tail?.type === "assistant" ? tail.blocks.at(-1) : undefined;
|
|
664
|
-
|
|
665
|
-
return options.hasPendingSteer
|
|
666
|
-
? { state: "solving", label: "Waiting for the current step to finish…" }
|
|
667
|
-
: activityForTail(block);
|
|
741
|
+
return activityForTail(block);
|
|
668
742
|
}
|
|
669
743
|
|
|
670
744
|
// ── 行间距节奏(照搬原 rhythmTopClass)──────────────────────────────────────
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
Plug,
|
|
7
7
|
Plus,
|
|
8
8
|
Terminal,
|
|
9
|
-
X,
|
|
10
9
|
} from "@phosphor-icons/react";
|
|
11
10
|
import {
|
|
12
11
|
AssistantRuntimeProvider,
|
|
@@ -20,6 +19,7 @@ import {
|
|
|
20
19
|
} from "@assistant-ui/react";
|
|
21
20
|
import { ComposerTriggerPopover } from "@springbrand/message-panel/composer";
|
|
22
21
|
import {
|
|
22
|
+
Fragment,
|
|
23
23
|
useCallback,
|
|
24
24
|
useEffect,
|
|
25
25
|
useMemo,
|
|
@@ -32,6 +32,10 @@ import { DropdownMenu } from "../primitives/dropdown-menu";
|
|
|
32
32
|
import { Tooltip } from "../primitives/tooltip";
|
|
33
33
|
import { WorkshopIconButton } from "../primitives/workshop-controls";
|
|
34
34
|
import { CapabilityChip } from "../capability-chip";
|
|
35
|
+
import {
|
|
36
|
+
renderFileView,
|
|
37
|
+
type FileRenderer,
|
|
38
|
+
} from "../file-view";
|
|
35
39
|
|
|
36
40
|
const isComposingKeyEvent = (event: {
|
|
37
41
|
isComposing?: boolean;
|
|
@@ -74,6 +78,7 @@ export interface CloudOsAttachmentView {
|
|
|
74
78
|
mediaType?: string;
|
|
75
79
|
size?: number;
|
|
76
80
|
previewUrl?: string;
|
|
81
|
+
progress?: number;
|
|
77
82
|
status: "uploading" | "ready" | "error";
|
|
78
83
|
}
|
|
79
84
|
|
|
@@ -90,6 +95,8 @@ export interface CloudOsChatInputProps {
|
|
|
90
95
|
onFilesSelected?: (files: readonly File[]) => void;
|
|
91
96
|
onAttachmentRemove?: (id: string) => void;
|
|
92
97
|
onAttachmentRetry?: (id: string) => void;
|
|
98
|
+
/** 接管输入框附件视图;未提供时使用包内 FileView。 */
|
|
99
|
+
renderFile?: FileRenderer;
|
|
93
100
|
/** 底部左侧的额外控件(宿主的 Agent 档位设置等)。 */
|
|
94
101
|
tools?: ReactNode;
|
|
95
102
|
/** 发送键左边的额外控件(宿主的「引导 / 排队」投递方式)。 */
|
|
@@ -123,7 +130,7 @@ export interface CloudOsChatInputProps {
|
|
|
123
130
|
footnote?: ReactNode;
|
|
124
131
|
}
|
|
125
132
|
|
|
126
|
-
const MAX_PENDING_ATTACHMENTS =
|
|
133
|
+
const MAX_PENDING_ATTACHMENTS = 8;
|
|
127
134
|
|
|
128
135
|
function commandLabel(command: CloudOsComposerCommand): string {
|
|
129
136
|
return `${command.prefix ?? "/"}${command.name.replaceAll("-", " ")}`;
|
|
@@ -159,6 +166,7 @@ function CloudOsChatInputContent({
|
|
|
159
166
|
onFilesSelected,
|
|
160
167
|
onAttachmentRemove,
|
|
161
168
|
onAttachmentRetry,
|
|
169
|
+
renderFile,
|
|
162
170
|
tools,
|
|
163
171
|
submitControl,
|
|
164
172
|
submissionBlocked = false,
|
|
@@ -460,48 +468,25 @@ function CloudOsChatInputContent({
|
|
|
460
468
|
{attachments.length > 0 && (
|
|
461
469
|
<div className="flex flex-wrap items-center gap-2 px-3 pb-2 pt-1">
|
|
462
470
|
{attachments.map((attachment) => (
|
|
463
|
-
<
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
)}
|
|
481
|
-
|
|
482
|
-
<div className="absolute inset-0 grid place-items-center rounded-lg bg-kumo-danger/80 px-1 text-center text-[9px] leading-3 text-white">
|
|
483
|
-
Failed
|
|
484
|
-
</div>
|
|
485
|
-
)}
|
|
486
|
-
{attachment.status === "error" && onAttachmentRetry && (
|
|
487
|
-
<button
|
|
488
|
-
type="button"
|
|
489
|
-
aria-label="Retry upload"
|
|
490
|
-
onClick={() => onAttachmentRetry(attachment.id)}
|
|
491
|
-
className="absolute inset-0 cursor-pointer"
|
|
492
|
-
/>
|
|
493
|
-
)}
|
|
494
|
-
{onAttachmentRemove && (
|
|
495
|
-
<button
|
|
496
|
-
type="button"
|
|
497
|
-
aria-label="Remove attachment"
|
|
498
|
-
onClick={() => onAttachmentRemove(attachment.id)}
|
|
499
|
-
className="absolute right-0.5 top-0.5 flex h-4 w-4 cursor-pointer items-center justify-center rounded-full bg-black/55 text-white hover:bg-black/75 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
|
|
500
|
-
>
|
|
501
|
-
<X size={10} weight="bold" />
|
|
502
|
-
</button>
|
|
503
|
-
)}
|
|
504
|
-
</div>
|
|
471
|
+
<Fragment key={attachment.id}>
|
|
472
|
+
{renderFileView(renderFile, {
|
|
473
|
+
file: {
|
|
474
|
+
url: attachment.previewUrl,
|
|
475
|
+
filename: attachment.filename,
|
|
476
|
+
mediaType: attachment.mediaType,
|
|
477
|
+
size: attachment.size,
|
|
478
|
+
},
|
|
479
|
+
placement: "composer",
|
|
480
|
+
status: attachment.status,
|
|
481
|
+
progress: attachment.progress,
|
|
482
|
+
onRemove: onAttachmentRemove
|
|
483
|
+
? () => onAttachmentRemove(attachment.id)
|
|
484
|
+
: undefined,
|
|
485
|
+
onRetry: onAttachmentRetry
|
|
486
|
+
? () => onAttachmentRetry(attachment.id)
|
|
487
|
+
: undefined,
|
|
488
|
+
})}
|
|
489
|
+
</Fragment>
|
|
505
490
|
))}
|
|
506
491
|
</div>
|
|
507
492
|
)}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { File as FileIcon } from "@phosphor-icons/react";
|
|
2
|
+
import {
|
|
3
|
+
CheckIcon,
|
|
4
|
+
FileArchiveIcon,
|
|
5
|
+
FileImageIcon,
|
|
6
|
+
FileTextIcon,
|
|
7
|
+
Loader2Icon,
|
|
8
|
+
RotateCcwIcon,
|
|
9
|
+
XIcon,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
import {
|
|
12
|
+
useEffect,
|
|
13
|
+
useRef,
|
|
14
|
+
useState,
|
|
15
|
+
type ComponentType,
|
|
16
|
+
} from "react";
|
|
17
|
+
|
|
18
|
+
export type FileViewPlacement =
|
|
19
|
+
| "assistant-message"
|
|
20
|
+
| "composer"
|
|
21
|
+
| "user-message";
|
|
22
|
+
|
|
23
|
+
export type FileViewStatus = "error" | "ready" | "uploading";
|
|
24
|
+
|
|
25
|
+
export interface FileViewFile {
|
|
26
|
+
url?: string;
|
|
27
|
+
filename?: string;
|
|
28
|
+
mediaType?: string;
|
|
29
|
+
size?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface FileViewProps {
|
|
33
|
+
file: FileViewFile;
|
|
34
|
+
placement: FileViewPlacement;
|
|
35
|
+
status?: FileViewStatus;
|
|
36
|
+
progress?: number;
|
|
37
|
+
onRemove?: () => void;
|
|
38
|
+
onRetry?: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type FileRenderer = ComponentType<FileViewProps>;
|
|
42
|
+
|
|
43
|
+
function fileMeta(file: FileViewFile): string {
|
|
44
|
+
if (file.size === undefined) return "Ready";
|
|
45
|
+
if (file.size < 1024) return `${file.size} B`;
|
|
46
|
+
if (file.size < 1024 * 1024) return `${Math.round(file.size / 1024)} KB`;
|
|
47
|
+
return `${(file.size / 1024 / 1024).toFixed(1)} MB`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function attachmentIcon(file: FileViewFile) {
|
|
51
|
+
if (file.mediaType?.startsWith("image/")) return FileImageIcon;
|
|
52
|
+
if (/zip|archive|compressed/i.test(file.mediaType ?? "")) {
|
|
53
|
+
return FileArchiveIcon;
|
|
54
|
+
}
|
|
55
|
+
return FileTextIcon;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ImageFileView({
|
|
59
|
+
src,
|
|
60
|
+
label,
|
|
61
|
+
placement,
|
|
62
|
+
}: {
|
|
63
|
+
src: string;
|
|
64
|
+
label: string;
|
|
65
|
+
placement: Exclude<FileViewPlacement, "composer">;
|
|
66
|
+
}) {
|
|
67
|
+
const imageRef = useRef<HTMLImageElement>(null);
|
|
68
|
+
const [loadedSrc, setLoadedSrc] = useState<string>();
|
|
69
|
+
const [errorSrc, setErrorSrc] = useState<string>();
|
|
70
|
+
const loaded = loadedSrc === src;
|
|
71
|
+
const error = errorSrc === src;
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
const image = imageRef.current;
|
|
75
|
+
if (!image?.complete) return;
|
|
76
|
+
if (image.naturalWidth > 0) setLoadedSrc(src);
|
|
77
|
+
else setErrorSrc(src);
|
|
78
|
+
}, [src]);
|
|
79
|
+
|
|
80
|
+
const assistant = placement === "assistant-message";
|
|
81
|
+
const previewClassName = assistant
|
|
82
|
+
? "relative inline-block max-w-full overflow-hidden rounded-xl border border-kumo-line align-bottom"
|
|
83
|
+
: "themed-thumbnail-shadow relative inline-block max-w-64 overflow-hidden rounded-xl border border-kumo-line align-bottom";
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<span
|
|
87
|
+
className={`${previewClassName} ${loaded && !error ? "" : "min-h-24 min-w-36"}`}
|
|
88
|
+
data-file-view=""
|
|
89
|
+
data-placement={placement}
|
|
90
|
+
>
|
|
91
|
+
<img
|
|
92
|
+
ref={imageRef}
|
|
93
|
+
src={src}
|
|
94
|
+
alt={label}
|
|
95
|
+
className={`${
|
|
96
|
+
assistant
|
|
97
|
+
? "block max-h-[28rem] max-w-full object-contain"
|
|
98
|
+
: "block max-h-52 max-w-64 object-cover"
|
|
99
|
+
} ${loaded && !error ? "" : "invisible"}`}
|
|
100
|
+
decoding="async"
|
|
101
|
+
loading="lazy"
|
|
102
|
+
onLoad={() => {
|
|
103
|
+
setLoadedSrc(src);
|
|
104
|
+
setErrorSrc(undefined);
|
|
105
|
+
}}
|
|
106
|
+
onError={() => setErrorSrc(src)}
|
|
107
|
+
/>
|
|
108
|
+
{!loaded && !error && (
|
|
109
|
+
<span
|
|
110
|
+
className="absolute inset-0 flex min-h-24 min-w-36 items-center justify-center bg-kumo-elevated text-kumo-inactive"
|
|
111
|
+
data-image-state="loading"
|
|
112
|
+
role="status"
|
|
113
|
+
>
|
|
114
|
+
<FileImageIcon
|
|
115
|
+
className="size-6 animate-pulse motion-reduce:animate-none"
|
|
116
|
+
aria-hidden="true"
|
|
117
|
+
/>
|
|
118
|
+
<span className="sr-only">Loading {label}</span>
|
|
119
|
+
</span>
|
|
120
|
+
)}
|
|
121
|
+
{error && (
|
|
122
|
+
<span
|
|
123
|
+
className="absolute inset-0 flex min-h-24 min-w-36 items-center justify-center bg-kumo-elevated text-kumo-danger"
|
|
124
|
+
data-image-state="error"
|
|
125
|
+
role="img"
|
|
126
|
+
aria-label={`Unable to load ${label}`}
|
|
127
|
+
>
|
|
128
|
+
<FileImageIcon className="size-6" aria-hidden="true" />
|
|
129
|
+
</span>
|
|
130
|
+
)}
|
|
131
|
+
</span>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Cloud OS 的默认文件视图。宿主可通过 `renderFile` 在同一 seam 替换它。
|
|
137
|
+
*/
|
|
138
|
+
export function FileView({
|
|
139
|
+
file,
|
|
140
|
+
placement,
|
|
141
|
+
status = "ready",
|
|
142
|
+
progress = 0,
|
|
143
|
+
onRemove,
|
|
144
|
+
onRetry,
|
|
145
|
+
}: FileViewProps) {
|
|
146
|
+
const isImage = Boolean(file.url && file.mediaType?.startsWith("image/"));
|
|
147
|
+
const label = file.filename ?? "Attachment";
|
|
148
|
+
|
|
149
|
+
if (placement === "composer") {
|
|
150
|
+
const AttachmentIcon = attachmentIcon(file);
|
|
151
|
+
const meta =
|
|
152
|
+
status === "uploading"
|
|
153
|
+
? "Uploading"
|
|
154
|
+
: status === "error"
|
|
155
|
+
? "Upload failed"
|
|
156
|
+
: fileMeta(file);
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<div
|
|
160
|
+
className="relative flex items-center gap-2.5 overflow-hidden rounded-[14px] bg-kumo-elevated py-1.5 ps-1.5 pe-2.5"
|
|
161
|
+
data-file-view=""
|
|
162
|
+
data-placement={placement}
|
|
163
|
+
data-status={status}
|
|
164
|
+
data-slot="composer-attachment"
|
|
165
|
+
data-state={status === "ready" ? "done" : status}
|
|
166
|
+
>
|
|
167
|
+
<span className="flex size-8 shrink-0 items-center justify-center rounded-[10px] bg-kumo-control text-kumo-inactive themed-thumbnail-shadow">
|
|
168
|
+
<AttachmentIcon className="size-4" aria-hidden="true" />
|
|
169
|
+
</span>
|
|
170
|
+
<span className="flex min-w-0 flex-col">
|
|
171
|
+
<span className="max-w-36 truncate text-xs font-medium text-kumo-default">
|
|
172
|
+
{label}
|
|
173
|
+
</span>
|
|
174
|
+
<span
|
|
175
|
+
className={
|
|
176
|
+
status === "error"
|
|
177
|
+
? "text-[11px] text-kumo-danger"
|
|
178
|
+
: "text-[11px] text-kumo-inactive"
|
|
179
|
+
}
|
|
180
|
+
>
|
|
181
|
+
{meta}
|
|
182
|
+
</span>
|
|
183
|
+
</span>
|
|
184
|
+
<span className="ms-1 flex w-5 items-center justify-end">
|
|
185
|
+
{status === "uploading" ? (
|
|
186
|
+
<Loader2Icon
|
|
187
|
+
className="size-3.5 animate-spin text-kumo-inactive motion-reduce:animate-none"
|
|
188
|
+
aria-label="Uploading"
|
|
189
|
+
/>
|
|
190
|
+
) : status === "error" && onRetry ? (
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
aria-label={`Retry ${label}`}
|
|
194
|
+
onClick={onRetry}
|
|
195
|
+
className="grid size-5 place-items-center rounded-full text-kumo-danger hover:bg-kumo-tint focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
|
|
196
|
+
>
|
|
197
|
+
<RotateCcwIcon className="size-3" aria-hidden="true" />
|
|
198
|
+
</button>
|
|
199
|
+
) : status === "ready" && onRemove ? (
|
|
200
|
+
<button
|
|
201
|
+
type="button"
|
|
202
|
+
aria-label={`Remove ${label}`}
|
|
203
|
+
onClick={onRemove}
|
|
204
|
+
className="grid size-5 place-items-center rounded-full text-kumo-inactive hover:bg-kumo-tint hover:text-kumo-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
|
|
205
|
+
>
|
|
206
|
+
<XIcon className="size-3" aria-hidden="true" />
|
|
207
|
+
</button>
|
|
208
|
+
) : status === "ready" ? (
|
|
209
|
+
<CheckIcon className="size-3.5 text-kumo-success" aria-label="Ready" />
|
|
210
|
+
) : null}
|
|
211
|
+
</span>
|
|
212
|
+
{status !== "ready" && onRemove && (
|
|
213
|
+
<button
|
|
214
|
+
type="button"
|
|
215
|
+
aria-label={`Remove ${label}`}
|
|
216
|
+
onClick={onRemove}
|
|
217
|
+
className="grid size-5 place-items-center rounded-full text-kumo-inactive hover:bg-kumo-tint hover:text-kumo-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
|
|
218
|
+
>
|
|
219
|
+
<XIcon className="size-3" aria-hidden="true" />
|
|
220
|
+
</button>
|
|
221
|
+
)}
|
|
222
|
+
{status === "uploading" && (
|
|
223
|
+
<span
|
|
224
|
+
aria-hidden="true"
|
|
225
|
+
className="absolute inset-x-0 bottom-0 h-0.5 bg-kumo-brand/70 transition-[width] duration-300"
|
|
226
|
+
style={{ width: `${Math.min(100, Math.max(0, progress * 100))}%` }}
|
|
227
|
+
/>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (isImage) {
|
|
234
|
+
return (
|
|
235
|
+
<ImageFileView src={file.url!} label={label} placement={placement} />
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<span
|
|
241
|
+
className="inline-flex items-center gap-1.5 rounded-lg border border-kumo-line bg-kumo-elevated px-2.5 py-1.5 text-[12px] leading-4 text-kumo-subtle"
|
|
242
|
+
data-file-view=""
|
|
243
|
+
data-placement={placement}
|
|
244
|
+
>
|
|
245
|
+
<FileIcon size={13} className="text-kumo-inactive" />
|
|
246
|
+
{label}
|
|
247
|
+
</span>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** 宿主没有接管时回退到包内默认视图。 */
|
|
252
|
+
export function renderFileView(
|
|
253
|
+
renderFile: FileRenderer | undefined,
|
|
254
|
+
props: FileViewProps,
|
|
255
|
+
) {
|
|
256
|
+
const Renderer = renderFile ?? FileView;
|
|
257
|
+
return <Renderer {...props} />;
|
|
258
|
+
}
|
package/cloud-os/index.ts
CHANGED
|
@@ -13,6 +13,14 @@
|
|
|
13
13
|
export { CloudOsRoot } from "./layout/cloud-os-root";
|
|
14
14
|
export { CapabilityChip } from "./capability-chip";
|
|
15
15
|
export type { CapabilityChipProps } from "./capability-chip";
|
|
16
|
+
export { FileView } from "./file-view";
|
|
17
|
+
export type {
|
|
18
|
+
FileRenderer,
|
|
19
|
+
FileViewFile,
|
|
20
|
+
FileViewPlacement,
|
|
21
|
+
FileViewProps,
|
|
22
|
+
FileViewStatus,
|
|
23
|
+
} from "./file-view";
|
|
16
24
|
export { CloudOsWorkspaceSplit } from "./layout/cloud-os-workspace-split";
|
|
17
25
|
export type { CloudOsWorkspaceSplitProps } from "./layout/cloud-os-workspace-split";
|
|
18
26
|
|
|
@@ -58,7 +66,9 @@ export {
|
|
|
58
66
|
ErrorBlock,
|
|
59
67
|
ParallelBlock,
|
|
60
68
|
PlanBlock,
|
|
69
|
+
PermissionGrant,
|
|
61
70
|
ScheduleBlock,
|
|
71
|
+
ScheduleConfirmation,
|
|
62
72
|
SubAgentsBlock,
|
|
63
73
|
SuggestionsBlock,
|
|
64
74
|
} from "./chat/rich-blocks";
|
|
@@ -92,6 +102,7 @@ export {
|
|
|
92
102
|
canonicalToolKind,
|
|
93
103
|
getToolCallSummary,
|
|
94
104
|
getToolIcon,
|
|
105
|
+
humanizeToolName,
|
|
95
106
|
toCloudOsToolCall,
|
|
96
107
|
toolNameOfPart,
|
|
97
108
|
} from "./chat/tool-presentation";
|
|
@@ -347,9 +347,20 @@ export function CloudOsChatShowcase() {
|
|
|
347
347
|
<CloudOsChatMessages
|
|
348
348
|
messages={messages}
|
|
349
349
|
status={status}
|
|
350
|
+
turnActive={
|
|
351
|
+
status === "submitted" ||
|
|
352
|
+
status === "streaming" ||
|
|
353
|
+
scenario === "ask" ||
|
|
354
|
+
((scenario === "approval" || scenario === "schedule_cancel") &&
|
|
355
|
+
approval === undefined)
|
|
356
|
+
}
|
|
350
357
|
isHydrating={scenario === "hydrating"}
|
|
351
358
|
isRecovering={scenario === "recovering"}
|
|
352
359
|
recoveryStatusLabel="模型响应暂时中断,正在重试…"
|
|
360
|
+
awaitingApproval={
|
|
361
|
+
(scenario === "approval" || scenario === "schedule_cancel") &&
|
|
362
|
+
approval === undefined
|
|
363
|
+
}
|
|
353
364
|
showThinkingTraces={showThinkingTraces}
|
|
354
365
|
approvalIdOf={approvalIdOf}
|
|
355
366
|
error={statusError}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@springbrand/message-panel",
|
|
3
|
-
"version": "0.1.3-alpha.
|
|
3
|
+
"version": "0.1.3-alpha.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"react-markdown": "^10.1.0",
|
|
36
36
|
"remark-gfm": "^4.0.1",
|
|
37
37
|
"streamdown": "^2.5.0",
|
|
38
|
-
"thinking-orbs": "0.
|
|
38
|
+
"thinking-orbs": "0.2.0",
|
|
39
39
|
"use-stick-to-bottom": "^1.1.6"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|