@springbrand/message-panel 0.1.3-alpha.28 → 0.1.3-alpha.30

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.
@@ -20,6 +20,7 @@ import { Tooltip } from "../primitives/tooltip";
20
20
  import { WorkshopIconButton } from "../primitives/workshop-controls";
21
21
  import {
22
22
  MarkdownMessage,
23
+ type CloudOsArtifactResolver,
23
24
  type CloudOsUrlResolver,
24
25
  } from "./markdown-message";
25
26
  import {
@@ -92,6 +93,10 @@ export interface CloudOsChatMessagesProps {
92
93
  renderFile?: FileRenderer;
93
94
  /** 把消息里的 `sandbox:/workspace/...` 之类地址翻译成可访问 URL。 */
94
95
  resolveUrl?: CloudOsUrlResolver;
96
+ /** 把图片 URL 关联到宿主的持久产物。 */
97
+ resolveArtifactId?: CloudOsArtifactResolver;
98
+ /** 打开宿主拥有的产物视图。 */
99
+ onOpenArtifact?: (artifactId: string) => void;
95
100
  /** 消息区宽度是否收敛到 920px 居中(GadgetEditor 里叫 constrainChatWidth)。 */
96
101
  constrainWidth?: boolean;
97
102
  emptyTitle?: string;
@@ -148,12 +153,16 @@ function UserBubble({
148
153
  copied,
149
154
  renderFile,
150
155
  resolveUrl,
156
+ resolveArtifactId,
157
+ onOpenArtifact,
151
158
  }: {
152
159
  entry: Extract<CloudOsEntry, { type: "user" }>;
153
160
  onCopy: (text: string) => void;
154
161
  copied: boolean;
155
162
  renderFile?: FileRenderer;
156
163
  resolveUrl?: CloudOsUrlResolver;
164
+ resolveArtifactId?: CloudOsArtifactResolver;
165
+ onOpenArtifact?: (artifactId: string) => void;
157
166
  }) {
158
167
  return (
159
168
  <div className="group/message relative flex flex-col items-end">
@@ -193,7 +202,12 @@ function UserBubble({
193
202
  )}
194
203
  {entry.text && (
195
204
  <div className="themed-user-bubble-shadow cos-markdown cos-user-markdown w-fit max-w-[min(680px,78%)] rounded-[12px] border border-transparent bg-kumo-bubble-user px-4 py-2.5 text-[14px] leading-[22px] tracking-[-0.25px] text-kumo-default">
196
- <MarkdownMessage message={entry.text} resolveUrl={resolveUrl} />
205
+ <MarkdownMessage
206
+ message={entry.text}
207
+ resolveUrl={resolveUrl}
208
+ resolveArtifactId={resolveArtifactId}
209
+ onOpenArtifact={onOpenArtifact}
210
+ />
197
211
  </div>
198
212
  )}
199
213
  <div className="mt-0.5 flex items-center justify-end gap-2 pr-1 text-[11px] leading-4 text-kumo-inactive opacity-0 transition-opacity duration-150 ease-out group-hover/message:opacity-100 group-focus-within/message:opacity-100">
@@ -222,12 +236,20 @@ function UserBubble({
222
236
 
223
237
  function ActionPresentationBlock({
224
238
  presentation,
239
+ failureCount = 1,
240
+ partial = false,
225
241
  renderFile,
226
242
  resolveUrl,
243
+ resolveArtifactId,
244
+ onOpenArtifact,
227
245
  }: {
228
246
  presentation: CloudOsActionPresentation;
247
+ failureCount?: number;
248
+ partial?: boolean;
229
249
  renderFile?: FileRenderer;
230
250
  resolveUrl?: CloudOsUrlResolver;
251
+ resolveArtifactId?: CloudOsArtifactResolver;
252
+ onOpenArtifact?: (artifactId: string) => void;
231
253
  }) {
232
254
  const { state } = presentation;
233
255
  if (state === "ready") {
@@ -240,19 +262,26 @@ function ActionPresentationBlock({
240
262
  >
241
263
  {presentation.content.length === 0
242
264
  ? <p className="m-0 text-[14px] leading-5 text-kumo-subtle">Completed</p>
243
- : presentation.content.map((file, index) => (
244
- <Fragment key={`${file.url}:${index}`}>
245
- {renderFileView(renderFile, {
246
- file: {
247
- ...file,
248
- filename: "Generated file",
249
- url: resolveUrl?.(file.url) ?? file.url,
250
- },
251
- placement: "assistant-message",
252
- variant: "product",
253
- })}
254
- </Fragment>
255
- ))}
265
+ : presentation.content.map((file, index) => {
266
+ const url = resolveUrl?.(file.url) ?? file.url;
267
+ const artifactId = resolveArtifactId?.(url);
268
+ return (
269
+ <Fragment key={`${file.url}:${index}`}>
270
+ {renderFileView(renderFile, {
271
+ file: {
272
+ ...file,
273
+ filename: "Generated file",
274
+ url,
275
+ },
276
+ placement: "assistant-message",
277
+ variant: "product",
278
+ onOpen: file.mediaType?.startsWith("image/") && artifactId && onOpenArtifact
279
+ ? () => onOpenArtifact(artifactId)
280
+ : undefined,
281
+ })}
282
+ </Fragment>
283
+ );
284
+ })}
256
285
  </div>
257
286
  );
258
287
  }
@@ -260,13 +289,17 @@ function ActionPresentationBlock({
260
289
  const product = presentation.produces;
261
290
  const productName = product?.type ?? "other";
262
291
  const isFileProduct = productName !== "data" && productName !== "other";
292
+ const insufficientCredits = state === "failed" &&
293
+ presentation.failureCode === "insufficient_credits";
263
294
  const body = state === "running"
264
295
  ? isFileProduct
265
296
  ? `Generating ${productName}${product?.mediaType ? ` (${product.mediaType})` : ""}`
266
297
  : "Generating"
267
298
  : state === "outcome_unknown"
268
299
  ? "The provider may have completed this action. Check the status before trying again."
269
- : undefined;
300
+ : insufficientCredits
301
+ ? `${failureCount} API call${failureCount === 1 ? " was" : "s were"} not started because there weren't enough Credits.`
302
+ : undefined;
270
303
  if (state === "running") {
271
304
  return (
272
305
  <div
@@ -289,7 +322,9 @@ function ActionPresentationBlock({
289
322
  >
290
323
  <span>
291
324
  <strong className="block font-medium text-kumo-default">
292
- {state === "failed" ? "Generation failed" : "Status unavailable"}
325
+ {insufficientCredits
326
+ ? partial ? "Partially completed" : "Not completed"
327
+ : state === "failed" ? "Action failed" : "Status unavailable"}
293
328
  </strong>
294
329
  {body && <span className="block text-kumo-subtle">{body}</span>}
295
330
  </span>
@@ -313,6 +348,7 @@ export function CloudOsChatMessages({
313
348
  renderApproval,
314
349
  renderFile,
315
350
  resolveUrl,
351
+ resolveArtifactId,
316
352
  constrainWidth = true,
317
353
  emptyTitle = "What are we working on?",
318
354
  onRetry,
@@ -320,6 +356,7 @@ export function CloudOsChatMessages({
320
356
  onSuggestion,
321
357
  onRespond,
322
358
  onOpenSchedule,
359
+ onOpenArtifact,
323
360
  onCopy,
324
361
  }: CloudOsChatMessagesProps) {
325
362
  const scrollRef = useRef<HTMLDivElement>(null);
@@ -473,6 +510,8 @@ export function CloudOsChatMessages({
473
510
  copied={copied === entry.text}
474
511
  renderFile={renderFile}
475
512
  resolveUrl={resolveUrl}
513
+ resolveArtifactId={resolveArtifactId}
514
+ onOpenArtifact={onOpenArtifact}
476
515
  />
477
516
  </div>
478
517
  );
@@ -551,6 +590,8 @@ export function CloudOsChatMessages({
551
590
  <MarkdownMessage
552
591
  message={entry.summary}
553
592
  resolveUrl={resolveUrl}
593
+ resolveArtifactId={resolveArtifactId}
594
+ onOpenArtifact={onOpenArtifact}
554
595
  />
555
596
  </div>
556
597
  )}
@@ -566,6 +607,16 @@ export function CloudOsChatMessages({
566
607
  const hasNarrative = contentBlocks.some(
567
608
  (block) => block.kind === "text",
568
609
  );
610
+ const insufficientFailures = contentBlocks.filter(
611
+ (block) => block.kind === "actionPresentation" &&
612
+ block.presentation.state === "failed" &&
613
+ block.presentation.failureCode === "insufficient_credits",
614
+ );
615
+ const firstInsufficientFailure = insufficientFailures[0];
616
+ const hasCompletedAction = contentBlocks.some(
617
+ (block) => block.kind === "actionPresentation" &&
618
+ block.presentation.state === "ready",
619
+ );
569
620
 
570
621
  return (
571
622
  <div
@@ -657,6 +708,8 @@ export function CloudOsChatMessages({
657
708
  <MarkdownMessage
658
709
  message={block.text}
659
710
  resolveUrl={resolveUrl}
711
+ resolveArtifactId={resolveArtifactId}
712
+ onOpenArtifact={onOpenArtifact}
660
713
  streaming={streaming}
661
714
  />
662
715
  </div>
@@ -739,18 +792,25 @@ export function CloudOsChatMessages({
739
792
  <SubAgentsBlock key={block.key} agents={block.agents} />
740
793
  );
741
794
  case "image":
742
- return (
743
- <Fragment key={block.key}>
744
- {renderFileView(renderFile, {
745
- file: {
746
- url: resolveUrl?.(block.src) ?? block.src,
747
- filename: block.alt,
748
- mediaType: block.mediaType ?? "image/*",
749
- },
750
- placement: "assistant-message",
751
- })}
752
- </Fragment>
753
- );
795
+ {
796
+ const url = resolveUrl?.(block.src) ?? block.src;
797
+ const artifactId = resolveArtifactId?.(url);
798
+ return (
799
+ <Fragment key={block.key}>
800
+ {renderFileView(renderFile, {
801
+ file: {
802
+ url,
803
+ filename: block.alt,
804
+ mediaType: block.mediaType ?? "image/*",
805
+ },
806
+ placement: "assistant-message",
807
+ onOpen: artifactId && onOpenArtifact
808
+ ? () => onOpenArtifact(artifactId)
809
+ : undefined,
810
+ })}
811
+ </Fragment>
812
+ );
813
+ }
754
814
  case "file":
755
815
  return (
756
816
  <Fragment key={block.key}>
@@ -765,12 +825,20 @@ export function CloudOsChatMessages({
765
825
  </Fragment>
766
826
  );
767
827
  case "actionPresentation":
828
+ if (
829
+ block.presentation.failureCode === "insufficient_credits" &&
830
+ block !== firstInsufficientFailure
831
+ ) return null;
768
832
  return (
769
833
  <ActionPresentationBlock
770
834
  key={block.key}
771
835
  presentation={block.presentation}
836
+ failureCount={insufficientFailures.length}
837
+ partial={hasCompletedAction}
772
838
  renderFile={renderFile}
773
839
  resolveUrl={resolveUrl}
840
+ resolveArtifactId={resolveArtifactId}
841
+ onOpenArtifact={onOpenArtifact}
774
842
  />
775
843
  );
776
844
  case "error":
@@ -18,6 +18,7 @@ import {
18
18
  */
19
19
 
20
20
  export type CloudOsUrlResolver = (url: string) => string | null | undefined;
21
+ export type CloudOsArtifactResolver = (url: string) => string | undefined;
21
22
 
22
23
  const ALLOWED_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
23
24
  const SMOOTH_STREAMING = {
@@ -104,23 +105,35 @@ const DEFAULT_COMPONENTS: Components = {
104
105
  export const MarkdownMessage = memo(function MarkdownMessage({
105
106
  message,
106
107
  resolveUrl,
108
+ resolveArtifactId,
109
+ onOpenArtifact,
107
110
  streaming = false,
108
111
  }: {
109
112
  message: string;
110
113
  resolveUrl?: CloudOsUrlResolver;
114
+ resolveArtifactId?: CloudOsArtifactResolver;
115
+ onOpenArtifact?: (artifactId: string) => void;
111
116
  streaming?: boolean;
112
117
  }): ReactNode {
113
118
  return (
114
119
  <TextMessagePartProvider text={message} isRunning={streaming}>
115
- <MarkdownMessageContent resolveUrl={resolveUrl} />
120
+ <MarkdownMessageContent
121
+ resolveUrl={resolveUrl}
122
+ resolveArtifactId={resolveArtifactId}
123
+ onOpenArtifact={onOpenArtifact}
124
+ />
116
125
  </TextMessagePartProvider>
117
126
  );
118
127
  });
119
128
 
120
129
  function MarkdownMessageContent({
121
130
  resolveUrl,
131
+ resolveArtifactId,
132
+ onOpenArtifact,
122
133
  }: {
123
134
  resolveUrl?: CloudOsUrlResolver;
135
+ resolveArtifactId?: CloudOsArtifactResolver;
136
+ onOpenArtifact?: (artifactId: string) => void;
124
137
  }) {
125
138
  const part = useSmooth(useMessagePartText(), SMOOTH_STREAMING);
126
139
  const streaming = part.status.type === "running";
@@ -131,6 +144,26 @@ function MarkdownMessageContent({
131
144
  : STREAMDOWN_REHYPE_PLUGINS,
132
145
  [resolveUrl],
133
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]);
134
167
  return (
135
168
  <Streamdown
136
169
  animated={false}
@@ -140,7 +173,7 @@ function MarkdownMessageContent({
140
173
  isAnimating={streaming}
141
174
  rehypePlugins={rehypePlugins}
142
175
  skipHtml
143
- components={DEFAULT_COMPONENTS}
176
+ components={components}
144
177
  >
145
178
  {part.text}
146
179
  </Streamdown>
@@ -61,6 +61,7 @@ export interface CloudOsActionPresentation {
61
61
  type: "action-execution";
62
62
  executionId: string;
63
63
  state: "running" | "ready" | "failed" | "outcome_unknown";
64
+ failureCode?: "insufficient_credits";
64
65
  produces?: CloudOsActionProduces;
65
66
  content: CloudOsActionPresentationFile[];
66
67
  }
@@ -383,6 +384,9 @@ function actionPresentationOf(value: unknown): CloudOsActionPresentation | null
383
384
  type: "action-execution",
384
385
  executionId: presentation.executionId,
385
386
  state,
387
+ ...(state === "failed" && presentation.failureCode === "insufficient_credits"
388
+ ? { failureCode: presentation.failureCode }
389
+ : {}),
386
390
  ...(produces ? { produces } : {}),
387
391
  content,
388
392
  };
@@ -60,6 +60,7 @@ export interface FileViewProps {
60
60
  variant?: FileViewVariant;
61
61
  status?: FileViewStatus;
62
62
  progress?: number;
63
+ onOpen?: () => void;
63
64
  onRemove?: () => void;
64
65
  onRetry?: () => void;
65
66
  }
@@ -168,10 +169,12 @@ function ProductFileView({
168
169
  file,
169
170
  label,
170
171
  placement,
172
+ onOpen,
171
173
  }: {
172
174
  file: FileViewFile;
173
175
  label: string;
174
176
  placement: Exclude<FileViewPlacement, "composer">;
177
+ onOpen?: () => void;
175
178
  }) {
176
179
  const type = productTypeForMediaType(file.mediaType);
177
180
  const { frame } = PRODUCT_PRESETS[type];
@@ -226,7 +229,16 @@ function ProductFileView({
226
229
  </span>
227
230
  );
228
231
 
229
- return file.url && !video ? (
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 ? (
230
242
  <a
231
243
  href={file.url}
232
244
  target="_blank"
@@ -260,6 +272,7 @@ export function FileView({
260
272
  variant = "compact",
261
273
  status = "ready",
262
274
  progress = 0,
275
+ onOpen,
263
276
  onRemove,
264
277
  onRetry,
265
278
  }: FileViewProps) {
@@ -359,13 +372,30 @@ export function FileView({
359
372
  }
360
373
 
361
374
  if (variant === "product") {
362
- return <ProductFileView file={file} label={label} placement={placement} />;
375
+ return (
376
+ <ProductFileView
377
+ file={file}
378
+ label={label}
379
+ placement={placement}
380
+ onOpen={onOpen}
381
+ />
382
+ );
363
383
  }
364
384
 
365
385
  if (isImage) {
366
- return (
386
+ const image = (
367
387
  <ImageFileView src={file.url!} label={label} placement={placement} />
368
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;
369
399
  }
370
400
 
371
401
  return (
package/cloud-os/index.ts CHANGED
@@ -61,7 +61,10 @@ export type {
61
61
  } from "./workspace/cloud-os-workspace-panel";
62
62
 
63
63
  export { MarkdownMessage } from "./chat/markdown-message";
64
- export type { CloudOsUrlResolver } from "./chat/markdown-message";
64
+ export type {
65
+ CloudOsArtifactResolver,
66
+ CloudOsUrlResolver,
67
+ } from "./chat/markdown-message";
65
68
  export {
66
69
  ThinkingTraceRow,
67
70
  ToolCallDetails,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/message-panel",
3
- "version": "0.1.3-alpha.28",
3
+ "version": "0.1.3-alpha.30",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",