@springbrand/message-panel 0.1.3-alpha.9 → 0.2.0-alpha.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/cloud-os/README.md +4 -3
  2. package/cloud-os/assets/followup-arrow.svg +3 -0
  3. package/cloud-os/assets/loading-corner.svg +3 -0
  4. package/cloud-os/assets/loading-mark.svg +16 -0
  5. package/cloud-os/assets/loading-spark.svg +3 -0
  6. package/cloud-os/assets/model-selected.svg +5 -0
  7. package/cloud-os/assets/thinking-star.svg +9 -0
  8. package/cloud-os/capability-chip.tsx +1 -1
  9. package/cloud-os/chat/cloud-os-chat-messages.tsx +441 -98
  10. package/cloud-os/chat/code-runner.tsx +106 -0
  11. package/cloud-os/chat/image-generation.tsx +76 -0
  12. package/cloud-os/chat/loading-state.tsx +25 -0
  13. package/cloud-os/chat/markdown-message.tsx +144 -41
  14. package/cloud-os/chat/range.ts +8 -0
  15. package/cloud-os/chat/rich-blocks.tsx +285 -227
  16. package/cloud-os/chat/surfaces.tsx +90 -0
  17. package/cloud-os/chat/tool-call.tsx +94 -0
  18. package/cloud-os/chat/tool-presentation.ts +31 -22
  19. package/cloud-os/chat/tool-rows.tsx +195 -143
  20. package/cloud-os/chat/tool-timeline.tsx +123 -0
  21. package/cloud-os/chat/transcript-model.ts +258 -20
  22. package/cloud-os/chat/web-search.tsx +135 -0
  23. package/cloud-os/composer/cloud-os-chat-input.tsx +29 -88
  24. package/cloud-os/composer/cloud-os-model-select.tsx +105 -0
  25. package/cloud-os/file-view.tsx +195 -12
  26. package/cloud-os/index.ts +24 -3
  27. package/cloud-os/layout/cloud-os-workspace-split.tsx +60 -3
  28. package/cloud-os/primitives/collapsible.tsx +21 -0
  29. package/cloud-os/primitives/workshop-controls.tsx +2 -2
  30. package/cloud-os/styles/cloud-os.css +132 -1
  31. package/demo/camel-chat-showcase.tsx +21 -13
  32. package/demo/chat-scenarios.ts +214 -40
  33. package/demo/cloud-os-chat-showcase.tsx +37 -14
  34. package/demo/fixtures.ts +4 -5
  35. package/demo/message-panel-gallery.tsx +16 -2
  36. package/package.json +8 -4
  37. package/springbrand-pet/LICENSE.bloub +21 -0
  38. package/springbrand-pet/bot/cycles.test.ts +221 -0
  39. package/springbrand-pet/bot/cycles.ts +225 -0
  40. package/springbrand-pet/bot/decor.ts +285 -0
  41. package/springbrand-pet/bot/engine.test.ts +543 -0
  42. package/springbrand-pet/bot/engine.ts +558 -0
  43. package/springbrand-pet/bot/expressions.test.ts +135 -0
  44. package/springbrand-pet/bot/expressions.ts +192 -0
  45. package/springbrand-pet/bot/eyefit.ts +455 -0
  46. package/springbrand-pet/bot/face.test.ts +101 -0
  47. package/springbrand-pet/bot/face.ts +179 -0
  48. package/springbrand-pet/bot/math.ts +42 -0
  49. package/springbrand-pet/bot/profiles.ts +22 -0
  50. package/springbrand-pet/bot/repere.ts +31 -0
  51. package/springbrand-pet/bot/shape.test.ts +97 -0
  52. package/springbrand-pet/bot/shape.ts +310 -0
  53. package/springbrand-pet/bot/skins.test.ts +325 -0
  54. package/springbrand-pet/bot/skins.ts +161 -0
  55. package/springbrand-pet/bot/states.ts +616 -0
  56. package/springbrand-pet/drag.test.ts +19 -0
  57. package/springbrand-pet/index.tsx +569 -0
  58. package/springbrand-pet/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  59. package/springbrand-pet/vitest.config.ts +9 -0
  60. package/src/camel/camel-chat-messages.tsx +78 -78
  61. package/src/camel/camel-tool-presentation.tsx +19 -15
  62. package/src/camel/camel-turn.ts +1 -17
  63. package/src/composer/composer-attachments.tsx +1 -1
  64. package/src/composer/composer.tsx +2 -2
  65. package/src/contracts.ts +0 -2
  66. package/src/message-panel.tsx +1 -24
  67. package/src/parts/index.tsx +103 -64
@@ -1,11 +1,8 @@
1
1
  import {
2
2
  Brain,
3
- CaretDown,
4
- Check,
5
3
  File as FileIcon,
6
4
  Plug,
7
5
  Plus,
8
- Terminal,
9
6
  } from "@phosphor-icons/react";
10
7
  import {
11
8
  AssistantRuntimeProvider,
@@ -37,6 +34,10 @@ import {
37
34
  renderFileView,
38
35
  type FileRenderer,
39
36
  } from "../file-view";
37
+ import {
38
+ CloudOsModelSelect,
39
+ type CloudOsModelOption,
40
+ } from "./cloud-os-model-select";
40
41
 
41
42
  const isComposingKeyEvent = (event: {
42
43
  isComposing?: boolean;
@@ -56,11 +57,6 @@ const isComposingKeyEvent = (event: {
56
57
  * assistant-ui ComposerTriggerPopover。
57
58
  */
58
59
 
59
- export interface CloudOsModelOption {
60
- id: string;
61
- name: string;
62
- }
63
-
64
60
  export interface CloudOsComposerCommand {
65
61
  prefix?: "/" | "$";
66
62
  name: string;
@@ -68,7 +64,7 @@ export interface CloudOsComposerCommand {
68
64
  }
69
65
 
70
66
  export interface CloudOsCapabilityView {
71
- kind: "skill" | "plan";
67
+ kind: "skill" | "tool" | "plan";
72
68
  name: string;
73
69
  label: string;
74
70
  }
@@ -109,7 +105,7 @@ export interface CloudOsChatInputProps {
109
105
  * 传了就以它为准来决定显示发送键还是停止键。
110
106
  */
111
107
  turnActive?: boolean;
112
- /** 可用的斜杠命令,也会显示在「+」菜单中。 */
108
+ /** 可用的斜杠命令。 */
113
109
  commands?: readonly CloudOsComposerCommand[];
114
110
  capabilities?: readonly CloudOsCapabilityView[];
115
111
  onCommandSelect?: (command: CloudOsComposerCommand) => void;
@@ -218,11 +214,6 @@ function CloudOsChatInputContent({
218
214
  (value.trim().length > 0 || hasReadyAttachment) &&
219
215
  !hasUnreadyAttachment;
220
216
 
221
- const selectedModelLabel =
222
- selectedModel === null
223
- ? "No agent"
224
- : (models.find((model) => model.id === selectedModel)?.name ?? selectedModel);
225
-
226
217
  useEffect(() => {
227
218
  const element = textareaRef.current;
228
219
  if (element) autoResizeTextarea(element, minRows, maxRows);
@@ -329,7 +320,7 @@ function CloudOsChatInputContent({
329
320
 
330
321
  return (
331
322
  // isolation: isolate 把输入框内部的 z-index 关起来,免得盖到 portal 出去的模型下拉。
332
- <div className="cos-chat-input-root relative isolate px-4 py-4">
323
+ <div className="cos-chat-input-root relative isolate pl-[calc(env(safe-area-inset-left)+1rem)] pr-[calc(env(safe-area-inset-right)+1rem)] pb-[calc(env(safe-area-inset-bottom)+1rem)]">
333
324
  <ComposerTriggerPopover
334
325
  char="/"
335
326
  {...slash}
@@ -508,7 +499,7 @@ function CloudOsChatInputContent({
508
499
  render={
509
500
  <button
510
501
  type="button"
511
- className="group flex h-8 w-8 flex-shrink-0 cursor-pointer items-center justify-center rounded-lg text-kumo-inactive transition-[background-color,color,transform] duration-150 ease-out hover:bg-kumo-tint hover:text-kumo-subtle focus-visible:bg-kumo-tint focus-visible:text-kumo-subtle focus-visible:outline-none active:scale-[0.96] data-[state=open]:bg-kumo-tint data-[state=open]:text-kumo-subtle"
502
+ className="group flex h-11 w-11 flex-shrink-0 cursor-pointer items-center justify-center rounded-lg text-kumo-inactive transition-[background-color,color,transform] duration-150 ease-out hover:bg-kumo-tint hover:text-kumo-subtle focus-visible:bg-kumo-tint focus-visible:text-kumo-subtle focus-visible:outline-none active:scale-[0.96] data-[state=open]:bg-kumo-tint data-[state=open]:text-kumo-subtle md:h-8 md:w-8"
512
503
  aria-label="Open chat options"
513
504
  >
514
505
  <Plus size={18} />
@@ -519,7 +510,7 @@ function CloudOsChatInputContent({
519
510
  {onToggleThinkingTraces && (
520
511
  <DropdownMenu.Item
521
512
  onClick={onToggleThinkingTraces}
522
- 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"
513
+ className="min-h-11 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 md:min-h-0"
523
514
  >
524
515
  <span className="mr-2 inline-flex h-4 w-4 items-center justify-center text-kumo-inactive">
525
516
  <Brain size={14} />
@@ -529,29 +520,10 @@ function CloudOsChatInputContent({
529
520
  </span>
530
521
  </DropdownMenu.Item>
531
522
  )}
532
- {commands.length > 0 && (
533
- <>
534
- {commands.map((command) => (
535
- <DropdownMenu.Item
536
- key={`${command.prefix ?? "/"}${command.name}`}
537
- onClick={() => selectCommand(command, false)}
538
- 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"
539
- >
540
- <span className="mr-2 inline-flex h-4 w-4 items-center justify-center text-kumo-inactive">
541
- <Terminal size={14} />
542
- </span>
543
- <span className="min-w-0 flex-1 truncate">
544
- {commandLabel(command)}
545
- </span>
546
- </DropdownMenu.Item>
547
- ))}
548
- <div className="my-1 border-t border-kumo-line/70" />
549
- </>
550
- )}
551
523
  {onFilesSelected && (
552
524
  <DropdownMenu.Item
553
525
  onClick={() => attachmentInputRef.current?.click()}
554
- 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"
526
+ className="min-h-11 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 md:min-h-0"
555
527
  >
556
528
  <span className="mr-2 inline-flex h-4 w-4 items-center justify-center text-kumo-inactive">
557
529
  <FileIcon size={14} />
@@ -565,7 +537,7 @@ function CloudOsChatInputContent({
565
537
  <button
566
538
  type="button"
567
539
  onClick={onAttachResource}
568
- className="inline-flex h-8 flex-shrink-0 cursor-pointer items-center gap-1.5 rounded-lg px-2 text-[13px] leading-none tracking-[-0.25px] text-kumo-inactive transition-[background-color,color,transform] duration-150 ease-out hover:bg-kumo-tint hover:text-kumo-subtle focus-visible:bg-kumo-tint focus-visible:text-kumo-subtle focus-visible:outline-none active:scale-[0.97]"
540
+ className="inline-flex h-11 flex-shrink-0 cursor-pointer items-center gap-1.5 rounded-lg px-2 text-[13px] leading-none tracking-[-0.25px] text-kumo-inactive transition-[background-color,color,transform] duration-150 ease-out hover:bg-kumo-tint hover:text-kumo-subtle focus-visible:bg-kumo-tint focus-visible:text-kumo-subtle focus-visible:outline-none active:scale-[0.97] md:h-8"
569
541
  >
570
542
  <Plug size={15} className="flex-shrink-0" />
571
543
  <span className="cos-attach-label leading-none">
@@ -578,52 +550,19 @@ function CloudOsChatInputContent({
578
550
 
579
551
  <div className="ml-auto flex min-w-0 flex-shrink items-center gap-1.5">
580
552
  {onModelChange && (
581
- <DropdownMenu>
582
- <DropdownMenu.Trigger
583
- render={
584
- <button
585
- type="button"
586
- className="group inline-flex h-8 min-w-0 max-w-[180px] cursor-pointer items-center gap-1.5 rounded-lg px-2 text-[13px] leading-5 tracking-[-0.25px] text-kumo-subtle transition-[background-color,color,transform] duration-150 ease-out hover:bg-kumo-tint hover:text-kumo-default focus-visible:bg-kumo-tint focus-visible:text-kumo-default focus-visible:outline-none active:scale-[0.97] data-[state=open]:bg-kumo-tint data-[state=open]:text-kumo-default"
587
- aria-label="Select model"
588
- >
589
- <span className="min-w-0 truncate">{selectedModelLabel}</span>
590
- <CaretDown
591
- size={12}
592
- weight="bold"
593
- className="flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out group-data-[state=open]:rotate-180"
594
- />
595
- </button>
596
- }
597
- />
598
- <DropdownMenu.Content
599
- align="end"
600
- className="themed-floating-shadow-lg !min-w-[190px] rounded-2xl border border-kumo-line/70 bg-kumo-base p-1"
601
- >
602
- {models.map((model) => (
603
- <DropdownMenu.Item
604
- key={model.id}
605
- onClick={() => onModelChange(model.id)}
606
- 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"
607
- >
608
- <span className="min-w-0 flex-1 truncate">{model.name}</span>
609
- {selectedModel === model.id && (
610
- <Check
611
- size={12}
612
- weight="bold"
613
- className="ml-3 flex-shrink-0 text-kumo-inactive"
614
- />
615
- )}
616
- </DropdownMenu.Item>
617
- ))}
618
- </DropdownMenu.Content>
619
- </DropdownMenu>
553
+ <CloudOsModelSelect
554
+ models={models}
555
+ selectedModel={selectedModel}
556
+ onModelChange={onModelChange}
557
+ variant="cloud-os"
558
+ />
620
559
  )}
621
560
  {!showStopButton && submitControl}
622
561
  {showStopButton ? (
623
562
  <WorkshopIconButton
624
563
  onClick={onStop}
625
564
  tone="primary"
626
- className="!h-8 !w-8"
565
+ className="!h-11 !w-11 md:!h-8 md:!w-8"
627
566
  aria-label="Stop agent"
628
567
  >
629
568
  <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
@@ -631,25 +570,27 @@ function CloudOsChatInputContent({
631
570
  </svg>
632
571
  </WorkshopIconButton>
633
572
  ) : (
634
- <WorkshopIconButton
573
+ <button
574
+ type="button"
635
575
  onClick={onSubmit}
636
576
  disabled={!canSend}
637
- tone="primary"
638
- className="!h-8 !w-8 disabled:cursor-not-allowed disabled:opacity-30"
577
+ className="grid size-11 shrink-0 place-items-center rounded-full bg-[var(--primary,var(--color-kumo-brand))] text-[var(--primary-foreground,var(--text-color-kumo-inverse))] shadow-sm transition-colors enabled:hover:bg-[var(--primary-hover,var(--color-kumo-brand-hover))] disabled:cursor-not-allowed disabled:opacity-20 md:size-8"
639
578
  aria-label="Send message"
640
579
  >
641
580
  <svg
642
- width="16"
643
- height="16"
581
+ className="size-4"
644
582
  viewBox="0 0 24 24"
645
583
  fill="none"
646
584
  stroke="currentColor"
647
- strokeWidth="2.5"
585
+ strokeWidth="1.8"
586
+ strokeLinecap="round"
587
+ strokeLinejoin="round"
588
+ aria-hidden="true"
648
589
  >
649
- <line x1="12" y1="19" x2="12" y2="5" />
650
- <polyline points="5 12 12 5 19 12" />
590
+ <path d="m5 12 7-7 7 7" />
591
+ <path d="M12 19V5" />
651
592
  </svg>
652
- </WorkshopIconButton>
593
+ </button>
653
594
  )}
654
595
  </div>
655
596
  </div>
@@ -0,0 +1,105 @@
1
+ import { CaretDown } from "@phosphor-icons/react";
2
+ import { cn } from "../internal/cn";
3
+ import { DropdownMenu } from "../primitives/dropdown-menu";
4
+
5
+ const selectedIcon = new URL("../assets/model-selected.svg", import.meta.url).href;
6
+
7
+ export interface CloudOsModelOption {
8
+ id: string;
9
+ name: string;
10
+ disabled?: boolean;
11
+ disabledLabel?: string;
12
+ }
13
+
14
+ export interface CloudOsModelSelectProps {
15
+ models: readonly CloudOsModelOption[];
16
+ selectedModel?: string | null;
17
+ onModelChange: (modelId: string) => void;
18
+ disabled?: boolean;
19
+ variant?: "default" | "cloud-os";
20
+ }
21
+
22
+ export function CloudOsModelSelect({
23
+ models,
24
+ selectedModel = null,
25
+ onModelChange,
26
+ disabled = false,
27
+ variant = "default",
28
+ }: CloudOsModelSelectProps) {
29
+ if (models.length === 0) return null;
30
+
31
+ const selectedLabel =
32
+ models.find((model) => model.id === selectedModel)?.name ?? "Model";
33
+ const cloudOs = variant === "cloud-os";
34
+
35
+ return (
36
+ <DropdownMenu>
37
+ <DropdownMenu.Trigger
38
+ render={
39
+ <button
40
+ type="button"
41
+ disabled={disabled}
42
+ aria-label="Select model"
43
+ className={cn(
44
+ "group inline-flex h-11 min-w-0 items-center gap-1.5 rounded-lg transition-[background-color,color,transform] duration-150 ease-out focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-40 md:h-8",
45
+ cloudOs
46
+ ? "max-w-[180px] cursor-pointer px-2 text-[13px] leading-5 tracking-[-0.25px] text-kumo-subtle hover:bg-kumo-tint hover:text-kumo-default focus-visible:bg-kumo-tint focus-visible:text-kumo-default active:scale-[0.97] data-[state=open]:bg-kumo-tint data-[state=open]:text-kumo-default"
47
+ : "max-w-[min(11rem,38vw)] px-2.5 text-xs leading-none text-black/55 hover:bg-black/5 hover:text-black focus-visible:ring-2 focus-visible:ring-black/15",
48
+ )}
49
+ >
50
+ <span className="min-w-0 truncate">{selectedLabel}</span>
51
+ <CaretDown
52
+ size={12}
53
+ weight="bold"
54
+ aria-hidden="true"
55
+ className={cn(
56
+ "shrink-0 transition-transform duration-150 ease-out group-data-[state=open]:rotate-180",
57
+ cloudOs ? "text-kumo-inactive" : "text-black/55",
58
+ )}
59
+ />
60
+ </button>
61
+ }
62
+ />
63
+ <DropdownMenu.Content
64
+ align="end"
65
+ side="top"
66
+ sideOffset={8}
67
+ data-slot="model-select-content"
68
+ className="cos-model-select min-w-[12.5rem] rounded-lg border p-2"
69
+ >
70
+ {models.map((model) => (
71
+ <DropdownMenu.Item
72
+ key={model.id}
73
+ disabled={model.disabled}
74
+ onClick={model.disabled ? undefined : () => onModelChange(model.id)}
75
+ data-slot="model-select-item"
76
+ className="cos-model-select__item gap-2 rounded px-2 py-2.5 text-[14px] leading-[1.4] font-normal transition-colors"
77
+ >
78
+ <span
79
+ className="cos-model-select__name min-w-0 flex-1 truncate"
80
+ data-muted={selectedModel === model.id || model.disabled ? "" : undefined}
81
+ >
82
+ {model.name}
83
+ </span>
84
+ {model.disabledLabel ? (
85
+ <span
86
+ className="cos-model-select__plan min-w-0 flex-1 truncate text-right"
87
+ data-disabled={model.disabled ? "" : undefined}
88
+ >
89
+ {model.disabledLabel}
90
+ </span>
91
+ ) : selectedModel === model.id ? (
92
+ <img
93
+ src={selectedIcon}
94
+ alt=""
95
+ aria-hidden="true"
96
+ data-slot="model-selected-icon"
97
+ className="size-4 shrink-0"
98
+ />
99
+ ) : null}
100
+ </DropdownMenu.Item>
101
+ ))}
102
+ </DropdownMenu.Content>
103
+ </DropdownMenu>
104
+ );
105
+ }
@@ -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,8 +57,10 @@ 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;
63
+ onOpen?: () => void;
37
64
  onRemove?: () => void;
38
65
  onRetry?: () => void;
39
66
  }
@@ -59,10 +86,12 @@ function ImageFileView({
59
86
  src,
60
87
  label,
61
88
  placement,
89
+ frameClassName,
62
90
  }: {
63
91
  src: string;
64
92
  label: string;
65
93
  placement: Exclude<FileViewPlacement, "composer">;
94
+ frameClassName?: string;
66
95
  }) {
67
96
  const imageRef = useRef<HTMLImageElement>(null);
68
97
  const [loadedSrc, setLoadedSrc] = useState<string>();
@@ -78,9 +107,11 @@ function ImageFileView({
78
107
  }, [src]);
79
108
 
80
109
  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";
110
+ const previewClassName = frameClassName
111
+ ? `themed-thumbnail-shadow relative inline-block max-w-full overflow-hidden rounded-2xl border border-kumo-line align-bottom ${frameClassName}`
112
+ : assistant
113
+ ? "relative inline-block max-w-full overflow-hidden rounded-xl border border-kumo-line align-bottom"
114
+ : "themed-thumbnail-shadow relative inline-block max-w-64 overflow-hidden rounded-xl border border-kumo-line align-bottom";
84
115
 
85
116
  return (
86
117
  <span
@@ -93,9 +124,11 @@ function ImageFileView({
93
124
  src={src}
94
125
  alt={label}
95
126
  className={`${
96
- assistant
97
- ? "block max-h-[28rem] max-w-full object-contain"
98
- : "block max-h-52 max-w-64 object-cover"
127
+ frameClassName
128
+ ? "block size-full object-contain"
129
+ : assistant
130
+ ? "block max-h-[28rem] max-w-full object-contain"
131
+ : "block max-h-52 max-w-64 object-cover"
99
132
  } ${loaded && !error ? "" : "invisible"}`}
100
133
  decoding="async"
101
134
  loading="lazy"
@@ -115,7 +148,7 @@ function ImageFileView({
115
148
  className="size-6 animate-pulse motion-reduce:animate-none"
116
149
  aria-hidden="true"
117
150
  />
118
- <span className="sr-only">Loading {label}</span>
151
+ <span className="sr-only">Loading preview...</span>
119
152
  </span>
120
153
  )}
121
154
  {error && (
@@ -123,7 +156,7 @@ function ImageFileView({
123
156
  className="absolute inset-0 flex min-h-24 min-w-36 items-center justify-center bg-kumo-elevated text-kumo-danger"
124
157
  data-image-state="error"
125
158
  role="img"
126
- aria-label={`Unable to load ${label}`}
159
+ aria-label="Unable to load preview"
127
160
  >
128
161
  <FileImageIcon className="size-6" aria-hidden="true" />
129
162
  </span>
@@ -132,14 +165,114 @@ function ImageFileView({
132
165
  );
133
166
  }
134
167
 
168
+ function ProductFileView({
169
+ file,
170
+ label,
171
+ placement,
172
+ onOpen,
173
+ }: {
174
+ file: FileViewFile;
175
+ label: string;
176
+ placement: Exclude<FileViewPlacement, "composer">;
177
+ onOpen?: () => void;
178
+ }) {
179
+ const type = productTypeForMediaType(file.mediaType);
180
+ const { frame } = PRODUCT_PRESETS[type];
181
+ const image = type === "image" && file.url;
182
+ const video = type === "video" && file.url;
183
+ const ProductIcon = PRODUCT_ICONS[type];
184
+ const preview = image
185
+ ? (
186
+ <ImageFileView
187
+ src={file.url!}
188
+ label={label}
189
+ placement={placement}
190
+ frameClassName={frame}
191
+ />
192
+ )
193
+ : video
194
+ ? (
195
+ <video
196
+ src={file.url}
197
+ controls
198
+ preload="metadata"
199
+ playsInline
200
+ aria-label={label}
201
+ className={`themed-thumbnail-shadow block max-w-full rounded-2xl border border-kumo-line bg-kumo-elevated object-contain ${frame}`}
202
+ data-video-preview=""
203
+ />
204
+ )
205
+ : (
206
+ <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}`}>
207
+ <ProductIcon
208
+ size={24}
209
+ weight="light"
210
+ className="text-kumo-subtle"
211
+ aria-hidden="true"
212
+ data-product-icon={type}
213
+ />
214
+ {file.mediaType && (
215
+ <span className="absolute end-2.5 top-2.5 font-mono text-[10px] text-kumo-inactive">
216
+ {file.mediaType}
217
+ </span>
218
+ )}
219
+ </span>
220
+ );
221
+ const content = (
222
+ <span className="flex w-fit max-w-full flex-col gap-2.5" data-product-file-view={type}>
223
+ {preview}
224
+ <span className="flex min-w-0 max-w-full items-center gap-1 text-xs text-kumo-inactive">
225
+ <span className="capitalize text-kumo-subtle">{type}</span>
226
+ <span aria-hidden="true">·</span>
227
+ <span className="min-w-0 truncate">{label}</span>
228
+ </span>
229
+ </span>
230
+ );
231
+
232
+ return onOpen ? (
233
+ <button
234
+ type="button"
235
+ onClick={onOpen}
236
+ aria-label={`Open ${label} in Creation View`}
237
+ className="inline-block max-w-full cursor-pointer rounded-2xl text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
238
+ >
239
+ {content}
240
+ </button>
241
+ ) : file.url && !video ? (
242
+ <a
243
+ href={file.url}
244
+ target="_blank"
245
+ rel="noopener noreferrer"
246
+ aria-label={`Open ${label}`}
247
+ className="inline-block max-w-full rounded-2xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
248
+ data-file-view={image ? undefined : ""}
249
+ data-placement={placement}
250
+ data-file-variant="product"
251
+ >
252
+ {content}
253
+ </a>
254
+ ) : (
255
+ <span
256
+ className="inline-block max-w-full"
257
+ data-file-view=""
258
+ data-placement={placement}
259
+ data-file-variant="product"
260
+ >
261
+ {content}
262
+ </span>
263
+ );
264
+ }
265
+
135
266
  /**
136
267
  * Cloud OS 的默认文件视图。宿主可通过 `renderFile` 在同一 seam 替换它。
137
268
  */
138
269
  export function FileView({
139
270
  file,
140
271
  placement,
272
+ variant = "compact",
141
273
  status = "ready",
142
274
  progress = 0,
275
+ onOpen,
143
276
  onRemove,
144
277
  onRetry,
145
278
  }: FileViewProps) {
@@ -165,7 +298,15 @@ export function FileView({
165
298
  data-state={status === "ready" ? "done" : status}
166
299
  >
167
300
  <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" />
301
+ {isImage ? (
302
+ <img
303
+ src={file.url}
304
+ alt=""
305
+ className="size-8 object-cover"
306
+ />
307
+ ) : (
308
+ <AttachmentIcon className="size-4" aria-hidden="true" />
309
+ )}
169
310
  </span>
170
311
  <span className="flex min-w-0 flex-col">
171
312
  <span className="max-w-36 truncate text-xs font-medium text-kumo-default">
@@ -230,10 +371,31 @@ export function FileView({
230
371
  );
231
372
  }
232
373
 
233
- if (isImage) {
374
+ if (variant === "product") {
234
375
  return (
376
+ <ProductFileView
377
+ file={file}
378
+ label={label}
379
+ placement={placement}
380
+ onOpen={onOpen}
381
+ />
382
+ );
383
+ }
384
+
385
+ if (isImage) {
386
+ const image = (
235
387
  <ImageFileView src={file.url!} label={label} placement={placement} />
236
388
  );
389
+ return onOpen ? (
390
+ <button
391
+ type="button"
392
+ onClick={onOpen}
393
+ aria-label={`Open ${label} in Creation View`}
394
+ className="inline-block max-w-full cursor-pointer rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
395
+ >
396
+ {image}
397
+ </button>
398
+ ) : image;
237
399
  }
238
400
 
239
401
  return (
@@ -243,7 +405,28 @@ export function FileView({
243
405
  data-placement={placement}
244
406
  >
245
407
  <FileIcon size={13} className="text-kumo-inactive" />
246
- {label}
408
+ <span className="min-w-0 truncate">{label}</span>
409
+ {file.url && (
410
+ <span className="ms-1 inline-flex items-center gap-1.5 border-s border-kumo-line ps-2">
411
+ <a
412
+ href={file.url}
413
+ target="_blank"
414
+ rel="noopener noreferrer"
415
+ aria-label={`Open ${label}`}
416
+ className="rounded text-kumo-default hover:text-kumo-default-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
417
+ >
418
+ Open
419
+ </a>
420
+ <a
421
+ href={file.url}
422
+ download={label}
423
+ aria-label={`Download ${label}`}
424
+ className="rounded text-kumo-default hover:text-kumo-default-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
425
+ >
426
+ Download
427
+ </a>
428
+ </span>
429
+ )}
247
430
  </span>
248
431
  );
249
432
  }