@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
@@ -0,0 +1,106 @@
1
+ "use client";
2
+
3
+ import type { ComponentProps } from "react";
4
+ import { Loader2Icon, PlayIcon } from "lucide-react";
5
+ import { cn } from "../internal/cn";
6
+ import { codeScroll, codeSurface, ghostButton, mono, paper } from "./surfaces";
7
+
8
+ export type RunState = "idle" | "running" | "ok" | "error";
9
+
10
+ export function CodeRunner({
11
+ language,
12
+ code,
13
+ state,
14
+ output,
15
+ durationMs,
16
+ onRun,
17
+ className,
18
+ ...props
19
+ }: Omit<
20
+ ComponentProps<"div">,
21
+ "children" | "language" | "code" | "state" | "output" | "durationMs" | "onRun"
22
+ > & {
23
+ language: string;
24
+ code: string;
25
+ state: RunState;
26
+ output: readonly string[];
27
+ durationMs?: number;
28
+ onRun?: () => void;
29
+ }) {
30
+ return (
31
+ <div
32
+ data-slot="code-runner"
33
+ className={cn(
34
+ paper,
35
+ "flex w-full max-w-md flex-col overflow-hidden rounded-2xl",
36
+ className,
37
+ )}
38
+ {...props}
39
+ >
40
+ <div className="flex items-center gap-2 px-3.5 py-2">
41
+ <span className={cn(mono, "text-foreground/35 min-w-0 flex-1")}>
42
+ {language}
43
+ </span>
44
+ {durationMs !== undefined && state !== "running" && (
45
+ <span
46
+ className={cn(mono, "text-foreground/30 shrink-0 tabular-nums")}
47
+ >
48
+ {durationMs}ms
49
+ </span>
50
+ )}
51
+ {onRun && (
52
+ <button
53
+ type="button"
54
+ aria-label="Run this snippet"
55
+ onClick={onRun}
56
+ disabled={state === "running"}
57
+ className={cn(
58
+ ghostButton,
59
+ "size-7 shrink-0 disabled:pointer-events-none",
60
+ )}
61
+ >
62
+ {state === "running" ? (
63
+ <Loader2Icon className="size-3.5 animate-spin motion-reduce:animate-none" />
64
+ ) : (
65
+ <PlayIcon className="size-3.5 translate-x-px" />
66
+ )}
67
+ </button>
68
+ )}
69
+ </div>
70
+
71
+ <pre className={cn(codeScroll, "border-foreground/[0.07] border-t px-3.5 py-2.5 font-mono text-xs leading-relaxed")}>
72
+ <code className="text-foreground/75">{code}</code>
73
+ </pre>
74
+
75
+ {state !== "idle" && (
76
+ <div className="border-foreground/[0.07] fade-in animate-in flex flex-col border-t px-3.5 py-2.5 duration-300">
77
+ <span className={cn(mono, "text-foreground/30 pb-1")}>output</span>
78
+ <div className={codeScroll}>
79
+ <div className={cn(codeSurface, "flex flex-col")}>
80
+ {output.map((line, index) => (
81
+ <span
82
+ key={`${index}-${line}`}
83
+ className={cn(
84
+ "fade-in animate-in fill-mode-both font-mono text-xs leading-relaxed whitespace-pre",
85
+ state === "error"
86
+ ? "text-red-600 dark:text-red-400"
87
+ : "text-foreground/70",
88
+ )}
89
+ style={{ animationDelay: `${index * 80}ms` }}
90
+ >
91
+ {line}
92
+ </span>
93
+ ))}
94
+ </div>
95
+ </div>
96
+ {state === "running" && (
97
+ <span
98
+ aria-hidden
99
+ className="bg-foreground/40 h-[1em] w-[2px] animate-pulse rounded-full motion-reduce:animate-none"
100
+ />
101
+ )}
102
+ </div>
103
+ )}
104
+ </div>
105
+ );
106
+ }
@@ -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
+ }
@@ -1,19 +1,30 @@
1
+ import {
2
+ TextMessagePartProvider,
3
+ useMessagePartText,
4
+ useSmooth,
5
+ type SmoothOptions,
6
+ } from "@assistant-ui/react";
1
7
  import { memo, useMemo, type ReactNode } from "react";
2
- import ReactMarkdown, { type Components } from "react-markdown";
3
- import remarkGfm from "remark-gfm";
8
+ import {
9
+ defaultRehypePlugins,
10
+ Streamdown,
11
+ type Components,
12
+ type StreamdownProps,
13
+ } from "streamdown";
4
14
 
5
15
  /**
6
- * cloudflare-os-main 的 `MarkdownMessage` 拷来。
7
- * 删掉的部分:capsule / format 提及的 remark 插件 —— 那是 gadgets 的资源体系,
8
- * UIMessage 协议里没有对应概念,留着只会是死代码。
9
- * 保留的部分:skipHtml、remark-gfm、表格外包一层横向滚动、外链白名单 + target=_blank。
10
- * 加上的部分:resolveUrl —— 本仓的消息里会出现 `sandbox:/workspace/...` 这种
11
- * 只有宿主知道怎么翻译成可访问 URL 的地址,原来由 camel 的同名钩子负责。
16
+ * Streamdown 依然使用原生标签,避免它的默认 Tailwind 组件样式覆盖 cloud-os CSS。
17
+ * resolveUrl 负责把 `sandbox:/workspace/...` 翻译成可访问 URL。
12
18
  */
13
19
 
14
20
  export type CloudOsUrlResolver = (url: string) => string | null | undefined;
21
+ export type CloudOsArtifactResolver = (url: string) => string | undefined;
15
22
 
16
23
  const ALLOWED_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
24
+ const SMOOTH_STREAMING = {
25
+ maxCharsPerFrame: 12,
26
+ minCommitMs: 32,
27
+ } satisfies SmoothOptions;
17
28
 
18
29
  export function safeExternalUrl(href: string | undefined): string | undefined {
19
30
  if (!href) return undefined;
@@ -25,54 +36,146 @@ export function safeExternalUrl(href: string | undefined): string | undefined {
25
36
  }
26
37
  }
27
38
 
28
- function buildComponents(resolveUrl?: CloudOsUrlResolver): Components {
29
- const resolve = (url: string | undefined): string | undefined => {
30
- if (!url) return undefined;
31
- return resolveUrl?.(url) ?? url;
32
- };
33
- return {
34
- table: ({ node: _node, children, ...props }) => (
35
- <div style={{ maxWidth: "100%", overflowX: "auto" }}>
36
- <table {...props}>{children}</table>
37
- </div>
38
- ),
39
- img: ({ node: _node, src, ...props }) => {
40
- const resolved = resolve(typeof src === "string" ? src : undefined);
41
- return resolved ? <img {...props} src={resolved} /> : null;
42
- },
43
- a: ({ node: _node, href, children, ...props }) => {
44
- const safeHref = safeExternalUrl(resolve(href));
45
- if (!safeHref) return <>{children}</>;
46
- return (
47
- <a {...props} href={safeHref} target="_blank" rel="noopener noreferrer">
48
- {children}
49
- </a>
50
- );
51
- },
39
+ const STREAMDOWN_REHYPE_PLUGINS = Object.values(
40
+ defaultRehypePlugins,
41
+ ) as NonNullable<StreamdownProps["rehypePlugins"]>;
42
+
43
+ type UrlNode = {
44
+ type?: string;
45
+ properties?: Record<string, unknown>;
46
+ children?: UrlNode[];
47
+ };
48
+
49
+ function resolveUrlsPlugin(resolveUrl: CloudOsUrlResolver) {
50
+ return () => (tree: UrlNode) => {
51
+ const visit = (node: UrlNode): void => {
52
+ if (node.type === "element" && node.properties) {
53
+ for (const attribute of ["href", "src"]) {
54
+ const url = node.properties[attribute];
55
+ if (typeof url !== "string") continue;
56
+ const resolved = resolveUrl(url);
57
+ if (resolved === null) delete node.properties[attribute];
58
+ else if (resolved !== undefined) node.properties[attribute] = resolved;
59
+ }
60
+ }
61
+ node.children?.forEach(visit);
62
+ };
63
+ visit(tree);
52
64
  };
53
65
  }
54
66
 
55
- const REMARK_PLUGINS = [remarkGfm];
56
- const DEFAULT_COMPONENTS = buildComponents();
67
+ const DEFAULT_COMPONENTS: Components = {
68
+ blockquote: "blockquote",
69
+ code: "code",
70
+ h1: "h1",
71
+ h2: "h2",
72
+ h3: "h3",
73
+ h4: "h4",
74
+ h5: "h5",
75
+ h6: "h6",
76
+ hr: "hr",
77
+ li: "li",
78
+ ol: "ol",
79
+ p: "p",
80
+ pre: "pre",
81
+ section: "section",
82
+ strong: "strong",
83
+ sub: "sub",
84
+ sup: "sup",
85
+ table: "table",
86
+ tbody: "tbody",
87
+ td: "td",
88
+ th: "th",
89
+ thead: "thead",
90
+ tr: "tr",
91
+ ul: "ul",
92
+ img: ({ node: _node, src, ...props }) =>
93
+ src ? <img {...props} src={src} /> : null,
94
+ a: ({ node: _node, href, children, ...props }) => {
95
+ const safeHref = safeExternalUrl(href);
96
+ if (!safeHref) return <>{children}</>;
97
+ return (
98
+ <a {...props} href={safeHref} target="_blank" rel="noopener noreferrer">
99
+ {children}
100
+ </a>
101
+ );
102
+ },
103
+ };
57
104
 
58
105
  export const MarkdownMessage = memo(function MarkdownMessage({
59
106
  message,
60
107
  resolveUrl,
108
+ resolveArtifactId,
109
+ onOpenArtifact,
110
+ streaming = false,
61
111
  }: {
62
112
  message: string;
63
113
  resolveUrl?: CloudOsUrlResolver;
114
+ resolveArtifactId?: CloudOsArtifactResolver;
115
+ onOpenArtifact?: (artifactId: string) => void;
116
+ streaming?: boolean;
64
117
  }): ReactNode {
65
- const components = useMemo(
66
- () => (resolveUrl ? buildComponents(resolveUrl) : DEFAULT_COMPONENTS),
118
+ return (
119
+ <TextMessagePartProvider text={message} isRunning={streaming}>
120
+ <MarkdownMessageContent
121
+ resolveUrl={resolveUrl}
122
+ resolveArtifactId={resolveArtifactId}
123
+ onOpenArtifact={onOpenArtifact}
124
+ />
125
+ </TextMessagePartProvider>
126
+ );
127
+ });
128
+
129
+ function MarkdownMessageContent({
130
+ resolveUrl,
131
+ resolveArtifactId,
132
+ onOpenArtifact,
133
+ }: {
134
+ resolveUrl?: CloudOsUrlResolver;
135
+ resolveArtifactId?: CloudOsArtifactResolver;
136
+ onOpenArtifact?: (artifactId: string) => void;
137
+ }) {
138
+ const part = useSmooth(useMessagePartText(), SMOOTH_STREAMING);
139
+ const streaming = part.status.type === "running";
140
+ const rehypePlugins = useMemo(
141
+ () =>
142
+ resolveUrl
143
+ ? [resolveUrlsPlugin(resolveUrl), ...STREAMDOWN_REHYPE_PLUGINS]
144
+ : STREAMDOWN_REHYPE_PLUGINS,
67
145
  [resolveUrl],
68
146
  );
147
+ const components = useMemo<Components>(() => ({
148
+ ...DEFAULT_COMPONENTS,
149
+ img: ({ node: _node, src, alt, ...props }) => {
150
+ if (!src) return null;
151
+ const image = <img {...props} src={src} alt={alt} />;
152
+ const artifactId = resolveArtifactId?.(src);
153
+ return artifactId && onOpenArtifact
154
+ ? (
155
+ <button
156
+ type="button"
157
+ onClick={() => onOpenArtifact(artifactId)}
158
+ aria-label={`Open ${alt || "image"} in Creation View`}
159
+ className="inline-block max-w-full cursor-pointer rounded-xl align-bottom focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
160
+ >
161
+ {image}
162
+ </button>
163
+ )
164
+ : image;
165
+ },
166
+ }), [onOpenArtifact, resolveArtifactId]);
69
167
  return (
70
- <ReactMarkdown
168
+ <Streamdown
169
+ animated={false}
170
+ className="space-y-0"
171
+ controls={false}
172
+ mode={streaming ? "streaming" : "static"}
173
+ isAnimating={streaming}
174
+ rehypePlugins={rehypePlugins}
71
175
  skipHtml
72
- remarkPlugins={REMARK_PLUGINS}
73
176
  components={components}
74
177
  >
75
- {message}
76
- </ReactMarkdown>
178
+ {part.text}
179
+ </Streamdown>
77
180
  );
78
- });
181
+ }
@@ -0,0 +1,8 @@
1
+ export function clamp(value: number, min: number, max: number) {
2
+ if (Number.isNaN(value)) return min;
3
+ return Math.min(max, Math.max(min, value));
4
+ }
5
+
6
+ export function take<T>(items: readonly T[], count: number) {
7
+ return items.slice(0, Math.floor(clamp(count, 0, items.length)));
8
+ }