@springbrand/message-panel 0.1.3-alpha.22 → 0.1.3-alpha.24

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.
@@ -35,6 +35,8 @@ import {
35
35
  } from "./rich-blocks";
36
36
  import { ThinkingTraceRow, ToolGroupRow } from "./tool-rows";
37
37
  import { CloudOsActivityIndicator } from "./activity-indicator";
38
+ import { ProductGeneration } from "./image-generation";
39
+ import { GenerationLoader } from "./loading-state";
38
40
  import { CapabilityChip } from "../capability-chip";
39
41
  import {
40
42
  renderFileView,
@@ -47,6 +49,7 @@ import {
47
49
  formatFullTimestamp,
48
50
  rhythmTopClass,
49
51
  type CloudOsEntry,
52
+ type CloudOsActionPresentation,
50
53
  } from "./transcript-model";
51
54
 
52
55
  export type CloudOsChatStatus = "ready" | "submitted" | "streaming" | "error";
@@ -200,6 +203,83 @@ function UserBubble({
200
203
  );
201
204
  }
202
205
 
206
+ function ActionPresentationBlock({
207
+ presentation,
208
+ renderFile,
209
+ resolveUrl,
210
+ }: {
211
+ presentation: CloudOsActionPresentation;
212
+ renderFile?: FileRenderer;
213
+ resolveUrl?: CloudOsUrlResolver;
214
+ }) {
215
+ const { state } = presentation;
216
+ if (state === "ready") {
217
+ return (
218
+ <div
219
+ data-action-presentation={state}
220
+ role="status"
221
+ aria-label="Completed"
222
+ className="space-y-2"
223
+ >
224
+ {presentation.content.length === 0
225
+ ? <p className="m-0 text-[14px] leading-5 text-kumo-subtle">Completed</p>
226
+ : presentation.content.map((file, index) => (
227
+ <Fragment key={`${file.url}:${index}`}>
228
+ {renderFileView(renderFile, {
229
+ file: {
230
+ ...file,
231
+ filename: "Generated file",
232
+ url: resolveUrl?.(file.url) ?? file.url,
233
+ },
234
+ placement: "assistant-message",
235
+ variant: "product",
236
+ })}
237
+ </Fragment>
238
+ ))}
239
+ </div>
240
+ );
241
+ }
242
+
243
+ const product = presentation.produces;
244
+ const productName = product?.type ?? "other";
245
+ const isFileProduct = productName !== "data" && productName !== "other";
246
+ const body = state === "running"
247
+ ? isFileProduct
248
+ ? `Generating ${productName}${product?.mediaType ? ` (${product.mediaType})` : ""}`
249
+ : "Generating"
250
+ : state === "outcome_unknown"
251
+ ? "The provider may have completed this action. Check the status before trying again."
252
+ : undefined;
253
+ if (state === "running") {
254
+ return (
255
+ <div
256
+ data-action-presentation={state}
257
+ data-product-type={productName}
258
+ role="status"
259
+ aria-label={body}
260
+ >
261
+ {isFileProduct
262
+ ? <ProductGeneration type={productName} mediaType={product?.mediaType} />
263
+ : <GenerationLoader />}
264
+ </div>
265
+ );
266
+ }
267
+ return (
268
+ <div
269
+ data-action-presentation={state}
270
+ role={state === "failed" ? "alert" : "status"}
271
+ className="inline-flex max-w-72 items-start rounded-xl border border-kumo-line bg-kumo-elevated px-3 py-2 text-[13px] leading-5"
272
+ >
273
+ <span>
274
+ <strong className="block font-medium text-kumo-default">
275
+ {state === "failed" ? "Generation failed" : "Status unavailable"}
276
+ </strong>
277
+ {body && <span className="block text-kumo-subtle">{body}</span>}
278
+ </span>
279
+ </div>
280
+ );
281
+ }
282
+
203
283
  export function CloudOsChatMessages({
204
284
  messages,
205
285
  status,
@@ -564,6 +644,15 @@ export function CloudOsChatMessages({
564
644
  })}
565
645
  </Fragment>
566
646
  );
647
+ case "actionPresentation":
648
+ return (
649
+ <ActionPresentationBlock
650
+ key={block.key}
651
+ presentation={block.presentation}
652
+ renderFile={renderFile}
653
+ resolveUrl={resolveUrl}
654
+ />
655
+ );
567
656
  case "error":
568
657
  return (
569
658
  <ErrorBlock
@@ -0,0 +1,76 @@
1
+ import type { CloudOsActionPresentation } from "./transcript-model";
2
+
3
+ export type ProductType = NonNullable<CloudOsActionPresentation["produces"]>["type"];
4
+
5
+ export const PRODUCT_PRESETS = {
6
+ image: { frame: "aspect-square w-52", columns: 6, rows: 6 },
7
+ video: { frame: "aspect-video w-64", columns: 8, rows: 4 },
8
+ audio: { frame: "h-24 w-64", columns: 9, rows: 2 },
9
+ document: { frame: "aspect-[4/5] w-44", columns: 5, rows: 7 },
10
+ archive: { frame: "h-28 w-44", columns: 5, rows: 3 },
11
+ data: { frame: "h-32 w-52", columns: 6, rows: 3 },
12
+ other: { frame: "h-28 w-44", columns: 5, rows: 3 },
13
+ } satisfies Record<ProductType, { frame: string; columns: number; rows: number }>;
14
+
15
+ export function productTypeForMediaType(mediaType?: string): ProductType {
16
+ const type = mediaType?.toLowerCase() ?? "";
17
+ if (type.startsWith("image/")) return "image";
18
+ if (type.startsWith("audio/")) return "audio";
19
+ if (type.startsWith("video/")) return "video";
20
+ if (/zip|archive|compressed|gzip|tar/.test(type)) return "archive";
21
+ if (/json|csv|spreadsheet|excel|parquet|sql/.test(type)) return "data";
22
+ if (type.startsWith("text/") || /pdf|document|word|rtf|presentation/.test(type)) {
23
+ return "document";
24
+ }
25
+ return "other";
26
+ }
27
+
28
+ /** Product-aware adaptation of https://www.assistant-ui.com/elements/image-generation. */
29
+ export function ProductGeneration({
30
+ type,
31
+ mediaType,
32
+ }: {
33
+ type: ProductType;
34
+ mediaType?: string;
35
+ }) {
36
+ const { frame, columns, rows } = PRODUCT_PRESETS[type];
37
+ const dots = Array.from({ length: columns * rows }, (_, index) => index);
38
+ return (
39
+ <div className="flex w-fit max-w-full flex-col gap-2.5" data-product-generation={type}>
40
+ <div className={`themed-thumbnail-shadow relative max-w-full overflow-hidden rounded-2xl border border-kumo-line bg-kumo-elevated ${frame}`}>
41
+ <div
42
+ className="absolute inset-0 grid place-items-center p-5"
43
+ data-generation-grid={`${columns}x${rows}`}
44
+ style={{
45
+ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
46
+ gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
47
+ }}
48
+ aria-hidden="true"
49
+ >
50
+ {dots.map((dot) => {
51
+ const row = Math.floor(dot / columns);
52
+ const column = dot % columns;
53
+ return (
54
+ <span
55
+ key={dot}
56
+ data-generation-dot=""
57
+ className="size-1 rounded-full bg-kumo-fill-hover animate-pulse motion-reduce:animate-none"
58
+ style={{ animationDelay: `${(row + column) * 90}ms` }}
59
+ />
60
+ );
61
+ })}
62
+ </div>
63
+ {mediaType && (
64
+ <span className="absolute end-2.5 top-2.5 font-mono text-[10px] text-kumo-inactive">
65
+ {mediaType}
66
+ </span>
67
+ )}
68
+ </div>
69
+ <p className="m-0 min-w-0 truncate text-xs text-kumo-inactive">
70
+ <span className="capitalize text-kumo-subtle">{type}</span>
71
+ <span aria-hidden="true"> · </span>
72
+ <span className="cos-thinking-shimmer motion-reduce:animate-none">Generating</span>
73
+ </p>
74
+ </div>
75
+ );
76
+ }
@@ -0,0 +1,25 @@
1
+ const CELLS = Array.from({ length: 9 }, (_, index) => index);
2
+
3
+ /** CSS-driven adaptation of https://www.assistant-ui.com/elements/loading-state. */
4
+ export function GenerationLoader() {
5
+ return (
6
+ <div
7
+ data-slot="generation-loader"
8
+ className="inline-flex flex-col items-center gap-3 py-2"
9
+ >
10
+ <div aria-hidden="true" className="grid grid-cols-3 gap-1">
11
+ {CELLS.map((cell) => (
12
+ <span
13
+ key={cell}
14
+ data-loading-cell=""
15
+ className="size-2 animate-pulse rounded-full bg-[var(--text-color-kumo-default)] motion-reduce:animate-none"
16
+ style={{ animationDelay: `${cell * 100}ms` }}
17
+ />
18
+ ))}
19
+ </div>
20
+ <span className="cos-thinking-shimmer text-sm text-kumo-inactive motion-reduce:animate-none">
21
+ Generating
22
+ </span>
23
+ </div>
24
+ );
25
+ }
@@ -14,6 +14,7 @@ import {
14
14
  export type CloudOsUrlResolver = (url: string) => string | null | undefined;
15
15
 
16
16
  const ALLOWED_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
17
+ const CJK_TEXT = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
17
18
 
18
19
  export function safeExternalUrl(href: string | undefined): string | undefined {
19
20
  if (!href) return undefined;
@@ -109,6 +110,13 @@ export const MarkdownMessage = memo(function MarkdownMessage({
109
110
  );
110
111
  return (
111
112
  <Streamdown
113
+ animated={
114
+ streaming
115
+ ? CJK_TEXT.test(message)
116
+ ? { sep: "char", duration: 100, stagger: 5 }
117
+ : true
118
+ : false
119
+ }
112
120
  className="space-y-0"
113
121
  controls={false}
114
122
  mode={streaming ? "streaming" : "static"}
@@ -45,6 +45,25 @@ export interface CloudOsAttachment {
45
45
  mediaType?: string;
46
46
  }
47
47
 
48
+ interface CloudOsActionPresentationFile {
49
+ type: "file";
50
+ url: string;
51
+ mediaType?: string;
52
+ }
53
+
54
+ interface CloudOsActionProduces {
55
+ type: "image" | "audio" | "video" | "document" | "archive" | "data" | "other";
56
+ mediaType?: string;
57
+ }
58
+
59
+ export interface CloudOsActionPresentation {
60
+ type: "action-execution";
61
+ executionId: string;
62
+ state: "running" | "ready" | "failed" | "outcome_unknown";
63
+ produces?: CloudOsActionProduces;
64
+ content: CloudOsActionPresentationFile[];
65
+ }
66
+
48
67
  export interface PlanStep {
49
68
  text: string;
50
69
  status: "pending" | "in_progress" | "done";
@@ -79,6 +98,11 @@ export type AssistantBlock =
79
98
  mediaType?: string;
80
99
  }
81
100
  | { kind: "file"; key: string; file: CloudOsAttachment }
101
+ | {
102
+ kind: "actionPresentation";
103
+ key: string;
104
+ presentation: CloudOsActionPresentation;
105
+ }
82
106
  | { kind: "error"; key: string; title: string; body?: string };
83
107
 
84
108
  export interface ParallelTool {
@@ -244,6 +268,71 @@ function normalizeStatus(value: unknown): ParallelTool["status"] {
244
268
  return "pending";
245
269
  }
246
270
 
271
+ function actionPresentationOf(value: unknown): CloudOsActionPresentation | null {
272
+ const presentation = recordOf(recordOf(value).presentation);
273
+ const state = presentation.state;
274
+ if (
275
+ presentation.type !== "action-execution" ||
276
+ typeof presentation.executionId !== "string" ||
277
+ !/^[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(
278
+ presentation.executionId,
279
+ ) ||
280
+ (state !== "running" &&
281
+ state !== "ready" &&
282
+ state !== "failed" &&
283
+ state !== "outcome_unknown") ||
284
+ !Array.isArray(presentation.content)
285
+ ) return null;
286
+
287
+ let produces: CloudOsActionProduces | undefined;
288
+ if (presentation.produces !== undefined) {
289
+ const hint = recordOf(presentation.produces);
290
+ if (
291
+ !["image", "audio", "video", "document", "archive", "data", "other"].includes(
292
+ String(hint.type),
293
+ ) ||
294
+ (hint.mediaType !== undefined &&
295
+ (typeof hint.mediaType !== "string" || !/^[^\s/]+\/[^\s/]+$/.test(hint.mediaType)))
296
+ ) return null;
297
+ produces = {
298
+ type: hint.type as CloudOsActionProduces["type"],
299
+ ...(hint.mediaType === undefined ? {} : { mediaType: hint.mediaType as string }),
300
+ };
301
+ }
302
+ if (state !== "running" && produces) return null;
303
+
304
+ const content: CloudOsActionPresentationFile[] = [];
305
+ for (const value of presentation.content) {
306
+ const file = recordOf(value);
307
+ if (file.type !== "file" || typeof file.url !== "string") return null;
308
+ try {
309
+ if (new URL(file.url).protocol !== "https:") return null;
310
+ } catch {
311
+ return null;
312
+ }
313
+ if (
314
+ file.mediaType !== undefined &&
315
+ (typeof file.mediaType !== "string" || !file.mediaType.trim())
316
+ ) return null;
317
+ content.push({
318
+ type: "file" as const,
319
+ url: file.url,
320
+ ...(file.mediaType !== undefined
321
+ ? { mediaType: file.mediaType }
322
+ : {}),
323
+ });
324
+ }
325
+ if (state !== "ready" && content.length > 0) return null;
326
+
327
+ return {
328
+ type: "action-execution",
329
+ executionId: presentation.executionId,
330
+ state,
331
+ ...(produces ? { produces } : {}),
332
+ content,
333
+ };
334
+ }
335
+
247
336
  // ── 独立消息(非 assistant 回合)判定 ────────────────────────────────────────
248
337
 
249
338
  function standaloneEntry(
@@ -427,6 +516,13 @@ function assistantBlocks(
427
516
  if (toolName === null) return;
428
517
 
429
518
  const call = toCloudOsToolCall(part, key, isActive);
519
+ const presentation = actionPresentationOf(call.output);
520
+ if (presentation) {
521
+ pendingCalls.push(call);
522
+ flush();
523
+ blocks.push({ kind: "actionPresentation", key, presentation });
524
+ return;
525
+ }
430
526
 
431
527
  if (call.awaitingApproval) {
432
528
  const approvalId = approvalIdOf(part);
@@ -587,7 +683,53 @@ export function buildCloudOsEntries({
587
683
  });
588
684
  });
589
685
 
590
- return dropSupersededPlans(entries);
686
+ return dropSupersededActionPresentations(dropSupersededPlans(entries));
687
+ }
688
+
689
+ function dropSupersededActionPresentations(entries: CloudOsEntry[]): CloudOsEntry[] {
690
+ const snapshots = new Map<string, {
691
+ entryIndex: number;
692
+ blockIndex: number;
693
+ presentation: CloudOsActionPresentation;
694
+ }>();
695
+ entries.forEach((entry, entryIndex) => {
696
+ if (entry.type !== "assistant") return;
697
+ entry.blocks.forEach((block, blockIndex) => {
698
+ if (block.kind !== "actionPresentation") return;
699
+ const current = snapshots.get(block.presentation.executionId);
700
+ if (!current) {
701
+ snapshots.set(block.presentation.executionId, {
702
+ entryIndex,
703
+ blockIndex,
704
+ presentation: block.presentation,
705
+ });
706
+ return;
707
+ }
708
+ if (block.presentation.state !== "running") {
709
+ current.presentation = block.presentation;
710
+ } else if (current.presentation.state === "running") {
711
+ current.presentation = {
712
+ ...block.presentation,
713
+ produces: block.presentation.produces ?? current.presentation.produces,
714
+ };
715
+ }
716
+ });
717
+ });
718
+
719
+ return entries.map((entry, entryIndex) =>
720
+ entry.type !== "assistant"
721
+ ? entry
722
+ : {
723
+ ...entry,
724
+ blocks: entry.blocks.flatMap<AssistantBlock>((block, blockIndex) => {
725
+ if (block.kind !== "actionPresentation") return [block];
726
+ const snapshot = snapshots.get(block.presentation.executionId)!;
727
+ return snapshot.entryIndex === entryIndex && snapshot.blockIndex === blockIndex
728
+ ? [{ ...block, presentation: snapshot.presentation }]
729
+ : [];
730
+ }),
731
+ }
732
+ );
591
733
  }
592
734
 
593
735
  /**
@@ -3,7 +3,6 @@ import {
3
3
  File as FileIcon,
4
4
  Plug,
5
5
  Plus,
6
- Terminal,
7
6
  } from "@phosphor-icons/react";
8
7
  import {
9
8
  AssistantRuntimeProvider,
@@ -106,7 +105,7 @@ export interface CloudOsChatInputProps {
106
105
  * 传了就以它为准来决定显示发送键还是停止键。
107
106
  */
108
107
  turnActive?: boolean;
109
- /** 可用的斜杠命令,也会显示在「+」菜单中。 */
108
+ /** 可用的斜杠命令。 */
110
109
  commands?: readonly CloudOsComposerCommand[];
111
110
  capabilities?: readonly CloudOsCapabilityView[];
112
111
  onCommandSelect?: (command: CloudOsComposerCommand) => void;
@@ -521,25 +520,6 @@ function CloudOsChatInputContent({
521
520
  </span>
522
521
  </DropdownMenu.Item>
523
522
  )}
524
- {commands.length > 0 && (
525
- <>
526
- {commands.map((command) => (
527
- <DropdownMenu.Item
528
- key={`${command.prefix ?? "/"}${command.name}`}
529
- onClick={() => selectCommand(command, false)}
530
- className="!h-auto rounded-xl !px-2 !py-1.5 text-[12px] leading-4 font-normal tracking-[-0.15px] text-kumo-subtle transition-colors data-highlighted:bg-kumo-tint/70 data-highlighted:text-kumo-default"
531
- >
532
- <span className="mr-2 inline-flex h-4 w-4 items-center justify-center text-kumo-inactive">
533
- <Terminal size={14} />
534
- </span>
535
- <span className="min-w-0 flex-1 truncate">
536
- {commandLabel(command)}
537
- </span>
538
- </DropdownMenu.Item>
539
- ))}
540
- <div className="my-1 border-t border-kumo-line/70" />
541
- </>
542
- )}
543
523
  {onFilesSelected && (
544
524
  <DropdownMenu.Item
545
525
  onClick={() => attachmentInputRef.current?.click()}
@@ -1,4 +1,13 @@
1
- import { File as FileIcon } from "@phosphor-icons/react";
1
+ import {
2
+ Database,
3
+ File as FileIcon,
4
+ FileArchive,
5
+ FileAudio,
6
+ FileImage,
7
+ FileText,
8
+ FileVideo,
9
+ type Icon,
10
+ } from "@phosphor-icons/react";
2
11
  import {
3
12
  CheckIcon,
4
13
  FileArchiveIcon,
@@ -14,6 +23,21 @@ import {
14
23
  useState,
15
24
  type ComponentType,
16
25
  } from "react";
26
+ import {
27
+ PRODUCT_PRESETS,
28
+ productTypeForMediaType,
29
+ type ProductType,
30
+ } from "./chat/image-generation";
31
+
32
+ const PRODUCT_ICONS = {
33
+ image: FileImage,
34
+ audio: FileAudio,
35
+ video: FileVideo,
36
+ document: FileText,
37
+ archive: FileArchive,
38
+ data: Database,
39
+ other: FileIcon,
40
+ } satisfies Record<ProductType, Icon>;
17
41
 
18
42
  export type FileViewPlacement =
19
43
  | "assistant-message"
@@ -21,6 +45,7 @@ export type FileViewPlacement =
21
45
  | "user-message";
22
46
 
23
47
  export type FileViewStatus = "error" | "ready" | "uploading";
48
+ export type FileViewVariant = "compact" | "product";
24
49
 
25
50
  export interface FileViewFile {
26
51
  url?: string;
@@ -32,6 +57,7 @@ export interface FileViewFile {
32
57
  export interface FileViewProps {
33
58
  file: FileViewFile;
34
59
  placement: FileViewPlacement;
60
+ variant?: FileViewVariant;
35
61
  status?: FileViewStatus;
36
62
  progress?: number;
37
63
  onRemove?: () => void;
@@ -59,10 +85,12 @@ function ImageFileView({
59
85
  src,
60
86
  label,
61
87
  placement,
88
+ frameClassName,
62
89
  }: {
63
90
  src: string;
64
91
  label: string;
65
92
  placement: Exclude<FileViewPlacement, "composer">;
93
+ frameClassName?: string;
66
94
  }) {
67
95
  const imageRef = useRef<HTMLImageElement>(null);
68
96
  const [loadedSrc, setLoadedSrc] = useState<string>();
@@ -78,9 +106,11 @@ function ImageFileView({
78
106
  }, [src]);
79
107
 
80
108
  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";
109
+ const previewClassName = frameClassName
110
+ ? `themed-thumbnail-shadow relative inline-block max-w-full overflow-hidden rounded-2xl border border-kumo-line align-bottom ${frameClassName}`
111
+ : assistant
112
+ ? "relative inline-block max-w-full overflow-hidden rounded-xl border border-kumo-line align-bottom"
113
+ : "themed-thumbnail-shadow relative inline-block max-w-64 overflow-hidden rounded-xl border border-kumo-line align-bottom";
84
114
 
85
115
  return (
86
116
  <span
@@ -93,9 +123,11 @@ function ImageFileView({
93
123
  src={src}
94
124
  alt={label}
95
125
  className={`${
96
- assistant
97
- ? "block max-h-[28rem] max-w-full object-contain"
98
- : "block max-h-52 max-w-64 object-cover"
126
+ frameClassName
127
+ ? "block size-full object-contain"
128
+ : assistant
129
+ ? "block max-h-[28rem] max-w-full object-contain"
130
+ : "block max-h-52 max-w-64 object-cover"
99
131
  } ${loaded && !error ? "" : "invisible"}`}
100
132
  decoding="async"
101
133
  loading="lazy"
@@ -115,7 +147,7 @@ function ImageFileView({
115
147
  className="size-6 animate-pulse motion-reduce:animate-none"
116
148
  aria-hidden="true"
117
149
  />
118
- <span className="sr-only">Loading {label}</span>
150
+ <span className="sr-only">Loading preview...</span>
119
151
  </span>
120
152
  )}
121
153
  {error && (
@@ -123,7 +155,7 @@ function ImageFileView({
123
155
  className="absolute inset-0 flex min-h-24 min-w-36 items-center justify-center bg-kumo-elevated text-kumo-danger"
124
156
  data-image-state="error"
125
157
  role="img"
126
- aria-label={`Unable to load ${label}`}
158
+ aria-label="Unable to load preview"
127
159
  >
128
160
  <FileImageIcon className="size-6" aria-hidden="true" />
129
161
  </span>
@@ -132,12 +164,100 @@ function ImageFileView({
132
164
  );
133
165
  }
134
166
 
167
+ function ProductFileView({
168
+ file,
169
+ label,
170
+ placement,
171
+ }: {
172
+ file: FileViewFile;
173
+ label: string;
174
+ placement: Exclude<FileViewPlacement, "composer">;
175
+ }) {
176
+ const type = productTypeForMediaType(file.mediaType);
177
+ const { frame } = PRODUCT_PRESETS[type];
178
+ const image = type === "image" && file.url;
179
+ const video = type === "video" && file.url;
180
+ const ProductIcon = PRODUCT_ICONS[type];
181
+ const preview = image
182
+ ? (
183
+ <ImageFileView
184
+ src={file.url!}
185
+ label={label}
186
+ placement={placement}
187
+ frameClassName={frame}
188
+ />
189
+ )
190
+ : video
191
+ ? (
192
+ <video
193
+ src={file.url}
194
+ controls
195
+ preload="metadata"
196
+ playsInline
197
+ aria-label={label}
198
+ className={`themed-thumbnail-shadow block max-w-full rounded-2xl border border-kumo-line bg-kumo-elevated object-contain ${frame}`}
199
+ data-video-preview=""
200
+ />
201
+ )
202
+ : (
203
+ <span className={`themed-thumbnail-shadow relative inline-grid max-w-full place-items-center overflow-hidden rounded-2xl border border-kumo-line bg-kumo-elevated ${frame}`}>
204
+ <ProductIcon
205
+ size={24}
206
+ weight="light"
207
+ className="text-kumo-subtle"
208
+ aria-hidden="true"
209
+ data-product-icon={type}
210
+ />
211
+ {file.mediaType && (
212
+ <span className="absolute end-2.5 top-2.5 font-mono text-[10px] text-kumo-inactive">
213
+ {file.mediaType}
214
+ </span>
215
+ )}
216
+ </span>
217
+ );
218
+ const content = (
219
+ <span className="flex w-fit max-w-full flex-col gap-2.5" data-product-file-view={type}>
220
+ {preview}
221
+ <span className="flex min-w-0 max-w-full items-center gap-1 text-xs text-kumo-inactive">
222
+ <span className="capitalize text-kumo-subtle">{type}</span>
223
+ <span aria-hidden="true">·</span>
224
+ <span className="min-w-0 truncate">{label}</span>
225
+ </span>
226
+ </span>
227
+ );
228
+
229
+ return file.url && !video ? (
230
+ <a
231
+ href={file.url}
232
+ target="_blank"
233
+ rel="noopener noreferrer"
234
+ aria-label={`Open ${label}`}
235
+ className="inline-block max-w-full rounded-2xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
236
+ data-file-view={image ? undefined : ""}
237
+ data-placement={placement}
238
+ data-file-variant="product"
239
+ >
240
+ {content}
241
+ </a>
242
+ ) : (
243
+ <span
244
+ className="inline-block max-w-full"
245
+ data-file-view=""
246
+ data-placement={placement}
247
+ data-file-variant="product"
248
+ >
249
+ {content}
250
+ </span>
251
+ );
252
+ }
253
+
135
254
  /**
136
255
  * Cloud OS 的默认文件视图。宿主可通过 `renderFile` 在同一 seam 替换它。
137
256
  */
138
257
  export function FileView({
139
258
  file,
140
259
  placement,
260
+ variant = "compact",
141
261
  status = "ready",
142
262
  progress = 0,
143
263
  onRemove,
@@ -238,6 +358,10 @@ export function FileView({
238
358
  );
239
359
  }
240
360
 
361
+ if (variant === "product") {
362
+ return <ProductFileView file={file} label={label} placement={placement} />;
363
+ }
364
+
241
365
  if (isImage) {
242
366
  return (
243
367
  <ImageFileView src={file.url!} label={label} placement={placement} />
@@ -251,7 +375,28 @@ export function FileView({
251
375
  data-placement={placement}
252
376
  >
253
377
  <FileIcon size={13} className="text-kumo-inactive" />
254
- {label}
378
+ <span className="min-w-0 truncate">{label}</span>
379
+ {file.url && (
380
+ <span className="ms-1 inline-flex items-center gap-1.5 border-s border-kumo-line ps-2">
381
+ <a
382
+ href={file.url}
383
+ target="_blank"
384
+ rel="noopener noreferrer"
385
+ aria-label={`Open ${label}`}
386
+ className="rounded text-kumo-default hover:text-kumo-default-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
387
+ >
388
+ Open
389
+ </a>
390
+ <a
391
+ href={file.url}
392
+ download={label}
393
+ aria-label={`Download ${label}`}
394
+ className="rounded text-kumo-default hover:text-kumo-default-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
395
+ >
396
+ Download
397
+ </a>
398
+ </span>
399
+ )}
255
400
  </span>
256
401
  );
257
402
  }
package/cloud-os/index.ts CHANGED
@@ -20,6 +20,7 @@ export type {
20
20
  FileViewPlacement,
21
21
  FileViewProps,
22
22
  FileViewStatus,
23
+ FileViewVariant,
23
24
  } from "./file-view";
24
25
  export {
25
26
  CloudOsConversationLoading,
@@ -579,6 +579,10 @@
579
579
  }
580
580
 
581
581
  @media (prefers-reduced-motion: reduce) {
582
+ [data-sd-animate] {
583
+ animation: none !important;
584
+ }
585
+
582
586
  .cos-thinking-shimmer {
583
587
  animation: none;
584
588
  background: none;
@@ -37,6 +37,8 @@ export type DemoScenario =
37
37
  | "temporary_agent"
38
38
  | "agent_tools"
39
39
  | "background_agent"
40
+ | "action_loading"
41
+ | "action_files"
40
42
  | "schedule_create"
41
43
  | "schedule"
42
44
  | "schedule_cancel"
@@ -119,6 +121,18 @@ export const scenarios: Record<DemoScenario, DemoScenarioMeta> = {
119
121
  status: "ready",
120
122
  flowStep: 3,
121
123
  },
124
+ action_loading: {
125
+ label: "Action product loading",
126
+ description: "Preview loading UI for every Action product type.",
127
+ status: "streaming",
128
+ flowStep: 2,
129
+ },
130
+ action_files: {
131
+ label: "Action file results",
132
+ description: "Preview image, audio, video, document, spreadsheet, archive, and unknown files.",
133
+ status: "ready",
134
+ flowStep: 3,
135
+ },
122
136
  schedule_create: {
123
137
  label: "Create scheduled task",
124
138
  description: "Review the task, cadence, and timezone before creating the schedule.",
@@ -246,6 +260,11 @@ function userMessage(prompt: string): UIMessage {
246
260
  } as UIMessage;
247
261
  }
248
262
 
263
+ function actionUserMessage(prompt: string): UIMessage {
264
+ const message = userMessage(prompt);
265
+ return { ...message, parts: message.parts.filter((part) => part.type === "text") };
266
+ }
267
+
249
268
  export interface DemoAskUserResponse {
250
269
  answers: Array<{ selections: string[]; text?: string }>;
251
270
  }
@@ -503,6 +522,95 @@ const backgroundResult = {
503
522
  ],
504
523
  } as unknown as UIMessage;
505
524
 
525
+ const actionProductHints = [
526
+ { type: "image", mediaType: "image/png" },
527
+ { type: "audio", mediaType: "audio/mpeg" },
528
+ { type: "video", mediaType: "video/mp4" },
529
+ { type: "document", mediaType: "application/pdf" },
530
+ { type: "archive", mediaType: "application/zip" },
531
+ { type: "data", mediaType: "application/json" },
532
+ { type: "other" },
533
+ ] as const;
534
+
535
+ function demoExecutionId(index: number) {
536
+ return `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`;
537
+ }
538
+
539
+ const actionProductLoading = {
540
+ id: "camel-demo-action-product-loading",
541
+ role: "assistant",
542
+ parts: actionProductHints.map((produces, index) => ({
543
+ type: "dynamic-tool",
544
+ toolCallId: `camel-demo-action-loading-${index}`,
545
+ toolName: `action_generate_${produces.type}`,
546
+ state: "output-available",
547
+ input: {},
548
+ output: {
549
+ status: "running",
550
+ executionId: demoExecutionId(index),
551
+ presentation: {
552
+ type: "action-execution",
553
+ executionId: demoExecutionId(index),
554
+ state: "running",
555
+ produces,
556
+ content: [],
557
+ },
558
+ },
559
+ })),
560
+ } as unknown as UIMessage;
561
+
562
+ const actionFileFixtures = [
563
+ {
564
+ name: "image",
565
+ mediaType: "image/png",
566
+ url: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1024&q=80",
567
+ },
568
+ { name: "audio", mediaType: "audio/mpeg", url: "https://files.example/action-demo/audio.mp3" },
569
+ { name: "video", mediaType: "video/mp4", url: "https://files.example/action-demo/video.mp4" },
570
+ { name: "pdf", mediaType: "application/pdf", url: "https://files.example/action-demo/report.pdf" },
571
+ { name: "markdown", mediaType: "text/markdown", url: "https://files.example/action-demo/report.md" },
572
+ {
573
+ name: "spreadsheet",
574
+ mediaType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
575
+ url: "https://files.example/action-demo/data.xlsx",
576
+ },
577
+ { name: "archive", mediaType: "application/zip", url: "https://files.example/action-demo/files.zip" },
578
+ {
579
+ name: "unknown",
580
+ mediaType: "application/octet-stream",
581
+ url: "https://files.example/action-demo/result.bin",
582
+ },
583
+ ] as const;
584
+
585
+ const actionFileResults = {
586
+ id: "camel-demo-action-file-results",
587
+ role: "assistant",
588
+ parts: [
589
+ ...actionFileFixtures.map((file, index) => ({
590
+ type: "dynamic-tool",
591
+ toolCallId: `camel-demo-action-file-${index}`,
592
+ toolName: `action_generate_${file.name}`,
593
+ state: "output-available",
594
+ input: {},
595
+ output: {
596
+ status: "succeeded",
597
+ executionId: demoExecutionId(index + actionProductHints.length),
598
+ result: { kind: "file", content_type: file.mediaType, url: file.url },
599
+ presentation: {
600
+ type: "action-execution",
601
+ executionId: demoExecutionId(index + actionProductHints.length),
602
+ state: "ready",
603
+ content: [{ type: "file", url: file.url, mediaType: file.mediaType }],
604
+ },
605
+ },
606
+ })),
607
+ {
608
+ type: "text",
609
+ text: "All Action file result types are rendered above from structured output.",
610
+ },
611
+ ],
612
+ } as unknown as UIMessage;
613
+
506
614
  export const demoScheduleInput = {
507
615
  label: "Weekly competitor report",
508
616
  prompt: "Generate and send this week's competitor report.",
@@ -917,6 +1025,12 @@ export function messagesForScenario(
917
1025
  if (scenario === "background_agent") {
918
1026
  return [...conversation, backgroundDispatch, backgroundResult];
919
1027
  }
1028
+ if (scenario === "action_loading") {
1029
+ return [noticeMessage(scenario), actionUserMessage(prompt), actionProductLoading];
1030
+ }
1031
+ if (scenario === "action_files") {
1032
+ return [noticeMessage(scenario), actionUserMessage(prompt), actionFileResults];
1033
+ }
920
1034
  if (scenario === "schedule_create") {
921
1035
  return [...conversation, scheduleRequest];
922
1036
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/message-panel",
3
- "version": "0.1.3-alpha.22",
3
+ "version": "0.1.3-alpha.24",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",