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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +38 -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,
@@ -26,6 +23,7 @@ import {
26
23
  useRef,
27
24
  useState,
28
25
  type DragEvent as ReactDragEvent,
26
+ type Ref,
29
27
  type ReactNode,
30
28
  } from "react";
31
29
  import { DropdownMenu } from "../primitives/dropdown-menu";
@@ -36,6 +34,10 @@ import {
36
34
  renderFileView,
37
35
  type FileRenderer,
38
36
  } from "../file-view";
37
+ import {
38
+ CloudOsModelSelect,
39
+ type CloudOsModelOption,
40
+ } from "./cloud-os-model-select";
39
41
 
40
42
  const isComposingKeyEvent = (event: {
41
43
  isComposing?: boolean;
@@ -55,11 +57,6 @@ const isComposingKeyEvent = (event: {
55
57
  * assistant-ui ComposerTriggerPopover。
56
58
  */
57
59
 
58
- export interface CloudOsModelOption {
59
- id: string;
60
- name: string;
61
- }
62
-
63
60
  export interface CloudOsComposerCommand {
64
61
  prefix?: "/" | "$";
65
62
  name: string;
@@ -67,7 +64,7 @@ export interface CloudOsComposerCommand {
67
64
  }
68
65
 
69
66
  export interface CloudOsCapabilityView {
70
- kind: "skill" | "plan";
67
+ kind: "skill" | "tool" | "plan";
71
68
  name: string;
72
69
  label: string;
73
70
  }
@@ -108,7 +105,7 @@ export interface CloudOsChatInputProps {
108
105
  * 传了就以它为准来决定显示发送键还是停止键。
109
106
  */
110
107
  turnActive?: boolean;
111
- /** 可用的斜杠命令,也会显示在「+」菜单中。 */
108
+ /** 可用的斜杠命令。 */
112
109
  commands?: readonly CloudOsComposerCommand[];
113
110
  capabilities?: readonly CloudOsCapabilityView[];
114
111
  onCommandSelect?: (command: CloudOsComposerCommand) => void;
@@ -126,6 +123,8 @@ export interface CloudOsChatInputProps {
126
123
  minRows?: number;
127
124
  maxRows?: number;
128
125
  autoFocus?: boolean;
126
+ /** Gives the host direct access to the chat textarea. */
127
+ textareaRef?: Ref<HTMLTextAreaElement>;
129
128
  /** 输入框下方一行(原文件放 token / cost)。 */
130
129
  footnote?: ReactNode;
131
130
  }
@@ -185,6 +184,7 @@ function CloudOsChatInputContent({
185
184
  minRows = 1,
186
185
  maxRows = 6,
187
186
  autoFocus = false,
187
+ textareaRef: forwardedTextareaRef,
188
188
  footnote,
189
189
  runtime,
190
190
  }: CloudOsChatInputProps & { runtime: AssistantRuntime }) {
@@ -214,11 +214,6 @@ function CloudOsChatInputContent({
214
214
  (value.trim().length > 0 || hasReadyAttachment) &&
215
215
  !hasUnreadyAttachment;
216
216
 
217
- const selectedModelLabel =
218
- selectedModel === null
219
- ? "No agent"
220
- : (models.find((model) => model.id === selectedModel)?.name ?? selectedModel);
221
-
222
217
  useEffect(() => {
223
218
  const element = textareaRef.current;
224
219
  if (element) autoResizeTextarea(element, minRows, maxRows);
@@ -325,7 +320,7 @@ function CloudOsChatInputContent({
325
320
 
326
321
  return (
327
322
  // isolation: isolate 把输入框内部的 z-index 关起来,免得盖到 portal 出去的模型下拉。
328
- <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)]">
329
324
  <ComposerTriggerPopover
330
325
  char="/"
331
326
  {...slash}
@@ -411,6 +406,11 @@ function CloudOsChatInputContent({
411
406
  <textarea
412
407
  ref={(element) => {
413
408
  textareaRef.current = element;
409
+ if (typeof forwardedTextareaRef === "function") {
410
+ forwardedTextareaRef(element);
411
+ } else if (forwardedTextareaRef) {
412
+ forwardedTextareaRef.current = element;
413
+ }
414
414
  if (element) autoResizeTextarea(element, minRows, maxRows);
415
415
  }}
416
416
  value={value}
@@ -499,7 +499,7 @@ function CloudOsChatInputContent({
499
499
  render={
500
500
  <button
501
501
  type="button"
502
- 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"
503
503
  aria-label="Open chat options"
504
504
  >
505
505
  <Plus size={18} />
@@ -510,7 +510,7 @@ function CloudOsChatInputContent({
510
510
  {onToggleThinkingTraces && (
511
511
  <DropdownMenu.Item
512
512
  onClick={onToggleThinkingTraces}
513
- 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"
514
514
  >
515
515
  <span className="mr-2 inline-flex h-4 w-4 items-center justify-center text-kumo-inactive">
516
516
  <Brain size={14} />
@@ -520,29 +520,10 @@ function CloudOsChatInputContent({
520
520
  </span>
521
521
  </DropdownMenu.Item>
522
522
  )}
523
- {commands.length > 0 && (
524
- <>
525
- {commands.map((command) => (
526
- <DropdownMenu.Item
527
- key={`${command.prefix ?? "/"}${command.name}`}
528
- onClick={() => selectCommand(command, false)}
529
- 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"
530
- >
531
- <span className="mr-2 inline-flex h-4 w-4 items-center justify-center text-kumo-inactive">
532
- <Terminal size={14} />
533
- </span>
534
- <span className="min-w-0 flex-1 truncate">
535
- {commandLabel(command)}
536
- </span>
537
- </DropdownMenu.Item>
538
- ))}
539
- <div className="my-1 border-t border-kumo-line/70" />
540
- </>
541
- )}
542
523
  {onFilesSelected && (
543
524
  <DropdownMenu.Item
544
525
  onClick={() => attachmentInputRef.current?.click()}
545
- 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"
546
527
  >
547
528
  <span className="mr-2 inline-flex h-4 w-4 items-center justify-center text-kumo-inactive">
548
529
  <FileIcon size={14} />
@@ -556,7 +537,7 @@ function CloudOsChatInputContent({
556
537
  <button
557
538
  type="button"
558
539
  onClick={onAttachResource}
559
- 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"
560
541
  >
561
542
  <Plug size={15} className="flex-shrink-0" />
562
543
  <span className="cos-attach-label leading-none">
@@ -569,52 +550,19 @@ function CloudOsChatInputContent({
569
550
 
570
551
  <div className="ml-auto flex min-w-0 flex-shrink items-center gap-1.5">
571
552
  {onModelChange && (
572
- <DropdownMenu>
573
- <DropdownMenu.Trigger
574
- render={
575
- <button
576
- type="button"
577
- 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"
578
- aria-label="Select model"
579
- >
580
- <span className="min-w-0 truncate">{selectedModelLabel}</span>
581
- <CaretDown
582
- size={12}
583
- weight="bold"
584
- className="flex-shrink-0 text-kumo-inactive transition-transform duration-150 ease-out group-data-[state=open]:rotate-180"
585
- />
586
- </button>
587
- }
588
- />
589
- <DropdownMenu.Content
590
- align="end"
591
- className="themed-floating-shadow-lg !min-w-[190px] rounded-2xl border border-kumo-line/70 bg-kumo-base p-1"
592
- >
593
- {models.map((model) => (
594
- <DropdownMenu.Item
595
- key={model.id}
596
- onClick={() => onModelChange(model.id)}
597
- 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"
598
- >
599
- <span className="min-w-0 flex-1 truncate">{model.name}</span>
600
- {selectedModel === model.id && (
601
- <Check
602
- size={12}
603
- weight="bold"
604
- className="ml-3 flex-shrink-0 text-kumo-inactive"
605
- />
606
- )}
607
- </DropdownMenu.Item>
608
- ))}
609
- </DropdownMenu.Content>
610
- </DropdownMenu>
553
+ <CloudOsModelSelect
554
+ models={models}
555
+ selectedModel={selectedModel}
556
+ onModelChange={onModelChange}
557
+ variant="cloud-os"
558
+ />
611
559
  )}
612
560
  {!showStopButton && submitControl}
613
561
  {showStopButton ? (
614
562
  <WorkshopIconButton
615
563
  onClick={onStop}
616
564
  tone="primary"
617
- className="!h-8 !w-8"
565
+ className="!h-11 !w-11 md:!h-8 md:!w-8"
618
566
  aria-label="Stop agent"
619
567
  >
620
568
  <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
@@ -622,25 +570,27 @@ function CloudOsChatInputContent({
622
570
  </svg>
623
571
  </WorkshopIconButton>
624
572
  ) : (
625
- <WorkshopIconButton
573
+ <button
574
+ type="button"
626
575
  onClick={onSubmit}
627
576
  disabled={!canSend}
628
- tone="primary"
629
- 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"
630
578
  aria-label="Send message"
631
579
  >
632
580
  <svg
633
- width="16"
634
- height="16"
581
+ className="size-4"
635
582
  viewBox="0 0 24 24"
636
583
  fill="none"
637
584
  stroke="currentColor"
638
- strokeWidth="2.5"
585
+ strokeWidth="1.8"
586
+ strokeLinecap="round"
587
+ strokeLinejoin="round"
588
+ aria-hidden="true"
639
589
  >
640
- <line x1="12" y1="19" x2="12" y2="5" />
641
- <polyline points="5 12 12 5 19 12" />
590
+ <path d="m5 12 7-7 7 7" />
591
+ <path d="M12 19V5" />
642
592
  </svg>
643
- </WorkshopIconButton>
593
+ </button>
644
594
  )}
645
595
  </div>
646
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
  }