@springbrand/message-panel 0.1.2 → 0.1.3-alpha.1

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 (99) hide show
  1. package/demo/camel-chat-showcase.tsx +1265 -0
  2. package/demo/fixtures.ts +292 -0
  3. package/demo/message-panel-chat-showcase.tsx +250 -0
  4. package/demo/message-panel-gallery.tsx +389 -0
  5. package/package.json +6 -16
  6. package/src/adapters/ai-sdk.ts +32 -0
  7. package/src/camel/camel-chat-messages.tsx +1435 -0
  8. package/src/camel/camel-model-select.tsx +88 -0
  9. package/src/camel/camel-plan-bar.tsx +115 -0
  10. package/src/camel/camel-prompt-input.tsx +547 -0
  11. package/src/camel/camel-tool-presentation.tsx +1270 -0
  12. package/src/camel/camel-turn.ts +334 -0
  13. package/src/camel/turn-summary-bar.tsx +81 -0
  14. package/src/chat-summary-panel.tsx +229 -0
  15. package/src/composer/chat-composer.tsx +216 -0
  16. package/src/composer/composer-attachments.tsx +138 -0
  17. package/src/composer/composer-trigger-popover.tsx +236 -0
  18. package/src/composer/composer.tsx +425 -0
  19. package/src/composer/drop-controller.ts +80 -0
  20. package/src/composer/types.ts +79 -0
  21. package/src/contracts.ts +138 -0
  22. package/src/conversation.tsx +76 -0
  23. package/src/internal/cn.ts +3 -0
  24. package/src/message-panel.tsx +368 -0
  25. package/src/message.tsx +254 -0
  26. package/src/parts/index.tsx +1149 -0
  27. package/src/parts/kind.ts +67 -0
  28. package/src/parts/plan.ts +64 -0
  29. package/src/parts/primitives.tsx +439 -0
  30. package/src/registry.ts +44 -0
  31. package/src/status.tsx +132 -0
  32. package/dist/demo/camel-chat-showcase.d.ts +0 -1
  33. package/dist/demo/camel-chat-showcase.js +0 -1007
  34. package/dist/demo/fixtures.d.ts +0 -32
  35. package/dist/demo/fixtures.js +0 -250
  36. package/dist/demo/index.js +0 -4
  37. package/dist/demo/message-panel-chat-showcase.d.ts +0 -1
  38. package/dist/demo/message-panel-chat-showcase.js +0 -114
  39. package/dist/demo/message-panel-gallery.d.ts +0 -1
  40. package/dist/demo/message-panel-gallery.js +0 -130
  41. package/dist/src/adapters/ai-sdk.d.ts +0 -4
  42. package/dist/src/adapters/ai-sdk.js +0 -25
  43. package/dist/src/camel/camel-chat-messages.d.ts +0 -44
  44. package/dist/src/camel/camel-chat-messages.js +0 -421
  45. package/dist/src/camel/camel-model-select.d.ts +0 -13
  46. package/dist/src/camel/camel-model-select.js +0 -7
  47. package/dist/src/camel/camel-plan-bar.d.ts +0 -5
  48. package/dist/src/camel/camel-plan-bar.js +0 -24
  49. package/dist/src/camel/camel-prompt-input.d.ts +0 -32
  50. package/dist/src/camel/camel-prompt-input.js +0 -182
  51. package/dist/src/camel/camel-tool-presentation.d.ts +0 -21
  52. package/dist/src/camel/camel-tool-presentation.js +0 -784
  53. package/dist/src/camel/camel-turn.d.ts +0 -57
  54. package/dist/src/camel/camel-turn.js +0 -203
  55. package/dist/src/camel/index.js +0 -7
  56. package/dist/src/camel/turn-summary-bar.d.ts +0 -8
  57. package/dist/src/camel/turn-summary-bar.js +0 -12
  58. package/dist/src/chat-summary-panel.d.ts +0 -22
  59. package/dist/src/chat-summary-panel.js +0 -26
  60. package/dist/src/composer/chat-composer.d.ts +0 -4
  61. package/dist/src/composer/chat-composer.js +0 -90
  62. package/dist/src/composer/composer-attachments.d.ts +0 -13
  63. package/dist/src/composer/composer-attachments.js +0 -47
  64. package/dist/src/composer/composer-trigger-popover.d.ts +0 -31
  65. package/dist/src/composer/composer-trigger-popover.js +0 -29
  66. package/dist/src/composer/composer.d.ts +0 -15
  67. package/dist/src/composer/composer.js +0 -180
  68. package/dist/src/composer/drop-controller.d.ts +0 -8
  69. package/dist/src/composer/drop-controller.js +0 -69
  70. package/dist/src/composer/index.js +0 -6
  71. package/dist/src/composer/types.d.ts +0 -62
  72. package/dist/src/composer/types.js +0 -1
  73. package/dist/src/contracts.d.ts +0 -94
  74. package/dist/src/contracts.js +0 -1
  75. package/dist/src/conversation.d.ts +0 -13
  76. package/dist/src/conversation.js +0 -18
  77. package/dist/src/index.js +0 -12
  78. package/dist/src/internal/cn.d.ts +0 -1
  79. package/dist/src/internal/cn.js +0 -3
  80. package/dist/src/message-panel.d.ts +0 -4
  81. package/dist/src/message-panel.js +0 -144
  82. package/dist/src/message.d.ts +0 -36
  83. package/dist/src/message.js +0 -83
  84. package/dist/src/parts/index.d.ts +0 -187
  85. package/dist/src/parts/index.js +0 -332
  86. package/dist/src/parts/kind.d.ts +0 -3
  87. package/dist/src/parts/kind.js +0 -69
  88. package/dist/src/parts/plan.d.ts +0 -10
  89. package/dist/src/parts/plan.js +0 -40
  90. package/dist/src/parts/primitives.d.ts +0 -70
  91. package/dist/src/parts/primitives.js +0 -153
  92. package/dist/src/registry.d.ts +0 -4
  93. package/dist/src/registry.js +0 -31
  94. package/dist/src/status.d.ts +0 -26
  95. package/dist/src/status.js +0 -74
  96. /package/{dist/demo/index.d.ts → demo/index.ts} +0 -0
  97. /package/{dist/src/camel/index.d.ts → src/camel/index.ts} +0 -0
  98. /package/{dist/src/composer/index.d.ts → src/composer/index.ts} +0 -0
  99. /package/{dist/src/index.d.ts → src/index.ts} +0 -0
@@ -0,0 +1,1149 @@
1
+ import {
2
+ AlertCircleIcon,
3
+ ArrowRightIcon,
4
+ AtomIcon,
5
+ BarChart3Icon,
6
+ BellIcon,
7
+ BlocksIcon,
8
+ BotIcon,
9
+ BrainCircuitIcon,
10
+ CheckCircle2Icon,
11
+ CheckIcon,
12
+ ChevronDownIcon,
13
+ ChevronRightIcon,
14
+ CircleDotIcon,
15
+ CircleIcon,
16
+ Code2Icon,
17
+ DownloadIcon,
18
+ ExternalLinkIcon,
19
+ EyeIcon,
20
+ FileAudioIcon,
21
+ FileCodeIcon,
22
+ FileImageIcon,
23
+ FileSpreadsheetIcon,
24
+ FileTextIcon,
25
+ GlobeIcon,
26
+ ImageIcon,
27
+ Loader2Icon,
28
+ ListTodoIcon,
29
+ Maximize2Icon,
30
+ NewspaperIcon,
31
+ OrbitIcon,
32
+ RefreshCcwIcon,
33
+ SparklesIcon,
34
+ TerminalIcon,
35
+ TriangleAlertIcon,
36
+ WrenchIcon,
37
+ XIcon,
38
+ } from "lucide-react";
39
+ import {
40
+ useMemo,
41
+ useState,
42
+ type CSSProperties,
43
+ type HTMLAttributes,
44
+ type ReactNode,
45
+ } from "react";
46
+ import type {
47
+ MessagePanelActions,
48
+ PartRendererContext,
49
+ RichPartKind,
50
+ AIToolPart,
51
+ } from "../contracts";
52
+ import { cn } from "../internal/cn";
53
+ import { MessageResponse } from "../message";
54
+ import { ThinkingIndicator } from "../status";
55
+ import { resolvePartKind, toolNameOfPart } from "./kind";
56
+ import {
57
+ planStepsOfPart,
58
+ type PlanStep,
59
+ type PlanStepStatus,
60
+ } from "./plan";
61
+ import {
62
+ Confirmation,
63
+ ConfirmationAccepted,
64
+ ConfirmationAction,
65
+ ConfirmationActions,
66
+ ConfirmationRejected,
67
+ ConfirmationRequest,
68
+ ConfirmationTitle,
69
+ Plan,
70
+ PlanContent,
71
+ PlanHeader,
72
+ PlanTitle,
73
+ Reasoning,
74
+ ReasoningContent,
75
+ ReasoningTrigger,
76
+ } from "./primitives";
77
+
78
+ export { resolvePartKind, toolNameOfPart } from "./kind";
79
+ export {
80
+ planStepsOfPart,
81
+ projectLatestPlan,
82
+ type PlanStep,
83
+ type PlanStepStatus,
84
+ } from "./plan";
85
+
86
+ export interface TextPartProps {
87
+ text: string;
88
+ streaming?: boolean;
89
+ }
90
+
91
+ export const OUT_OF_BAND_PREFIX = "[Delivered to the user out of band]";
92
+
93
+ export function stripNoticePrefix(text: string): string {
94
+ const trimmed = text.trimStart();
95
+ return trimmed.startsWith(OUT_OF_BAND_PREFIX)
96
+ ? trimmed.slice(OUT_OF_BAND_PREFIX.length).trim()
97
+ : text.trim();
98
+ }
99
+
100
+ export function TextPart({ text, streaming = false }: TextPartProps) {
101
+ if (!text.trim()) return null;
102
+ if (text.trimStart().startsWith(OUT_OF_BAND_PREFIX)) {
103
+ return <div className="sb-notice-line">{stripNoticePrefix(text)}</div>;
104
+ }
105
+ return (
106
+ <div data-part-type="text">
107
+ <MessageResponse streaming={streaming}>{text}</MessageResponse>
108
+ </div>
109
+ );
110
+ }
111
+
112
+ export interface ReasoningPartProps {
113
+ text: string;
114
+ label?: string;
115
+ streaming?: boolean;
116
+ duration?: number;
117
+ }
118
+
119
+ export function ReasoningPart({
120
+ text,
121
+ label,
122
+ streaming = false,
123
+ duration,
124
+ }: ReasoningPartProps) {
125
+ if (!text.trim()) return null;
126
+ return (
127
+ <div data-part-type="reasoning">
128
+ <Reasoning isStreaming={streaming} defaultOpen={streaming} duration={duration}>
129
+ <ReasoningTrigger>{label}</ReasoningTrigger>
130
+ <ReasoningContent>{text}</ReasoningContent>
131
+ </Reasoning>
132
+ </div>
133
+ );
134
+ }
135
+
136
+ type ToolIcon = "web" | "news" | "code" | "image" | "file" | "terminal";
137
+ const toolIcons = {
138
+ web: GlobeIcon,
139
+ news: NewspaperIcon,
140
+ code: Code2Icon,
141
+ image: ImageIcon,
142
+ file: FileTextIcon,
143
+ terminal: TerminalIcon,
144
+ } as const;
145
+
146
+ function humanizeToolName(name: string): string {
147
+ return name
148
+ .replace(/^tool-/, "")
149
+ .replace(/[_-]+/g, " ")
150
+ .replace(/\b\w/g, (character) => character.toUpperCase());
151
+ }
152
+
153
+ export interface ToolPartProps {
154
+ name: string;
155
+ state: AIToolPart["state"] | "interrupted";
156
+ input?: unknown;
157
+ output?: unknown;
158
+ errorText?: string;
159
+ icon?: ToolIcon;
160
+ label?: string;
161
+ meta?: string;
162
+ results?: Array<{ title: string; domain: string }>;
163
+ command?: string;
164
+ isActive?: boolean;
165
+ activityId?: string;
166
+ onNavigate?: (target: string) => void;
167
+ }
168
+
169
+ export type ToolPart = AIToolPart;
170
+
171
+ export function ToolPart({
172
+ name,
173
+ state,
174
+ input,
175
+ output,
176
+ errorText,
177
+ icon,
178
+ label,
179
+ meta,
180
+ results,
181
+ command,
182
+ activityId,
183
+ onNavigate,
184
+ }: ToolPartProps) {
185
+ const Icon = icon ? toolIcons[icon] : WrenchIcon;
186
+ const [open, setOpen] = useState(false);
187
+ const title = label ?? humanizeToolName(name);
188
+ const running = state === "input-streaming" || state === "input-available";
189
+ const hasDetails =
190
+ input != null ||
191
+ output != null ||
192
+ Boolean(errorText || command || results?.length);
193
+
194
+ if (state === "interrupted") {
195
+ return (
196
+ <div className="sb-tool-failed" data-part-type="tool">
197
+ <TriangleAlertIcon size={16} />
198
+ <span>Interrupted</span>
199
+ <small>{title}</small>
200
+ </div>
201
+ );
202
+ }
203
+
204
+ if (state === "output-error") {
205
+ return (
206
+ <div className="sb-tool-failed" data-part-type="tool">
207
+ <TriangleAlertIcon size={16} />
208
+ <span>Failed</span>
209
+ <small>{errorText || title}</small>
210
+ </div>
211
+ );
212
+ }
213
+
214
+ return (
215
+ <div className="sb-tool-line" data-part-type="tool">
216
+ <button
217
+ type="button"
218
+ className="sb-tool-line__trigger"
219
+ aria-expanded={open}
220
+ onClick={() => {
221
+ if (hasDetails) setOpen((current) => !current);
222
+ if (activityId) onNavigate?.(activityId);
223
+ }}
224
+ >
225
+ <Icon size={16} />
226
+ <span>{running ? `${title}…` : title}</span>
227
+ {meta && <small>{meta}</small>}
228
+ {running ? (
229
+ <Loader2Icon size={14} className="sb-spin" />
230
+ ) : (
231
+ hasDetails && (
232
+ <ChevronRightIcon
233
+ size={15}
234
+ className={cn("sb-tool-line__chevron", open && "is-open")}
235
+ />
236
+ )
237
+ )}
238
+ </button>
239
+ {open && (
240
+ <div className="sb-tool-line__details">
241
+ {results?.length ? (
242
+ <ul className="sb-tool-results">
243
+ {results.map((result, index) => (
244
+ <li key={`${result.domain}:${result.title}`}>
245
+ <i data-index={index % 2} />
246
+ <span>{result.title}</span>
247
+ <small>{result.domain}</small>
248
+ </li>
249
+ ))}
250
+ </ul>
251
+ ) : null}
252
+ {command && (
253
+ <div className="sb-terminal">
254
+ <code>$ {command}</code>
255
+ {typeof output === "string" && <pre>{output}</pre>}
256
+ </div>
257
+ )}
258
+ {!command && input != null && (
259
+ <pre>{JSON.stringify(input, null, 2)}</pre>
260
+ )}
261
+ {!command && output != null && (
262
+ <pre>
263
+ {typeof output === "string"
264
+ ? output
265
+ : JSON.stringify(output, null, 2)}
266
+ </pre>
267
+ )}
268
+ </div>
269
+ )}
270
+ </div>
271
+ );
272
+ }
273
+
274
+ export function ErrorPart({
275
+ title = "Error",
276
+ body,
277
+ onRetry,
278
+ }: {
279
+ title?: string;
280
+ body: string;
281
+ onRetry?: () => void;
282
+ }) {
283
+ return (
284
+ <div className="sb-error-part" data-part-type="error" role="alert">
285
+ <AlertCircleIcon size={18} />
286
+ <div>
287
+ <strong>{title}</strong>
288
+ <p>{body}</p>
289
+ {onRetry && (
290
+ <button type="button" className="sb-link-button" onClick={onRetry}>
291
+ <RefreshCcwIcon size={14} />
292
+ 重试
293
+ </button>
294
+ )}
295
+ </div>
296
+ </div>
297
+ );
298
+ }
299
+
300
+ export function ImagePart({
301
+ src,
302
+ alt,
303
+ onPreview,
304
+ }: {
305
+ src: string;
306
+ alt: string;
307
+ onPreview?: () => void;
308
+ }) {
309
+ return (
310
+ <figure className="sb-image-part" data-part-type="image">
311
+ <button type="button" onClick={onPreview} aria-label={`预览图片:${alt}`}>
312
+ <img src={src} alt={alt} />
313
+ {onPreview && <Maximize2Icon size={17} />}
314
+ </button>
315
+ <figcaption>{alt}</figcaption>
316
+ </figure>
317
+ );
318
+ }
319
+
320
+ export interface ChartBar {
321
+ label: string;
322
+ value: number;
323
+ }
324
+
325
+ export function ChartPart({
326
+ title,
327
+ unit,
328
+ bars,
329
+ expandable,
330
+ onPreview,
331
+ }: {
332
+ title: string;
333
+ unit?: string;
334
+ bars: readonly ChartBar[];
335
+ expandable?: boolean;
336
+ onPreview?: () => void;
337
+ }) {
338
+ const max = Math.max(...bars.map((bar) => bar.value), 1);
339
+ return (
340
+ <section className="sb-chart" data-part-type="chart" aria-label={title}>
341
+ <header>
342
+ <span>
343
+ <BarChart3Icon size={17} />
344
+ <strong>{title}</strong>
345
+ </span>
346
+ {expandable && (
347
+ <button type="button" onClick={onPreview} aria-label="展开图表">
348
+ <Maximize2Icon size={16} />
349
+ </button>
350
+ )}
351
+ </header>
352
+ <div className="sb-chart__plot">
353
+ {bars.map((bar) => (
354
+ <div key={bar.label} className="sb-chart__bar-group">
355
+ <span>{bar.value}{unit}</span>
356
+ <i style={{ "--sb-bar-height": `${(bar.value / max) * 100}%` } as CSSProperties} />
357
+ <small>{bar.label}</small>
358
+ </div>
359
+ ))}
360
+ </div>
361
+ </section>
362
+ );
363
+ }
364
+
365
+ export function AgentCardPart({
366
+ title,
367
+ agentName,
368
+ agentDesc,
369
+ onNavigate,
370
+ }: {
371
+ title: string;
372
+ agentName: string;
373
+ agentDesc: string;
374
+ onNavigate?: () => void;
375
+ }) {
376
+ return (
377
+ <section className="sb-agent-card" data-part-type="agent_card">
378
+ <small>{title}</small>
379
+ <div>
380
+ <span className="sb-agent-card__icon"><BotIcon size={20} /></span>
381
+ <div>
382
+ <strong>{agentName}</strong>
383
+ <p>{agentDesc}</p>
384
+ </div>
385
+ {onNavigate && (
386
+ <button type="button" onClick={onNavigate} aria-label={`打开 ${agentName}`}>
387
+ <ArrowRightIcon size={17} />
388
+ </button>
389
+ )}
390
+ </div>
391
+ </section>
392
+ );
393
+ }
394
+
395
+ export function StepStatusIcon({
396
+ status,
397
+ animated = false,
398
+ }: {
399
+ status: PlanStepStatus;
400
+ animated?: boolean;
401
+ }) {
402
+ if (status === "done") return <CheckCircle2Icon size={16} data-tone="success" />;
403
+ if (status === "in_progress") {
404
+ return animated ? (
405
+ <Loader2Icon size={16} className="sb-spin" data-tone="active" />
406
+ ) : (
407
+ <CircleDotIcon size={16} data-tone="muted" />
408
+ );
409
+ }
410
+ return <CircleIcon size={16} data-tone="muted" />;
411
+ }
412
+
413
+ export function PlanPart({
414
+ title,
415
+ steps,
416
+ streaming = false,
417
+ }: {
418
+ title: string;
419
+ steps: readonly PlanStep[];
420
+ streaming?: boolean;
421
+ }) {
422
+ const done = steps.filter((step) => step.status === "done").length;
423
+ const allDone = steps.length > 0 && done === steps.length;
424
+ const animated = streaming && !allDone;
425
+ return (
426
+ <Plan
427
+ className="sb-plan-card"
428
+ data-part-type="plan"
429
+ isStreaming={animated}
430
+ >
431
+ <PlanHeader>
432
+ <div className="sb-plan__heading">
433
+ <ListTodoIcon size={18} />
434
+ <PlanTitle>{title}</PlanTitle>
435
+ </div>
436
+ {steps.length > 0 && (
437
+ <span
438
+ className="sb-plan__badge"
439
+ data-state={allDone ? "done" : animated ? "running" : "paused"}
440
+ >
441
+ {allDone && <CheckCircle2Icon size={13} />}
442
+ {allDone
443
+ ? "Done"
444
+ : `${animated ? "Running · " : ""}${done}/${steps.length}`}
445
+ </span>
446
+ )}
447
+ </PlanHeader>
448
+ <PlanContent>
449
+ {steps.length > 0 ? (
450
+ <ol className="sb-plan__steps">
451
+ {steps.map((step, index) => {
452
+ const status =
453
+ step.status ??
454
+ (animated && index === done ? "in_progress" : "pending");
455
+ return (
456
+ <li key={`${index}:${step.text}`} data-status={status}>
457
+ <StepStatusIcon
458
+ status={status}
459
+ animated={animated && status === "in_progress"}
460
+ />
461
+ <span>{step.text}</span>
462
+ </li>
463
+ );
464
+ })}
465
+ </ol>
466
+ ) : animated ? (
467
+ <div className="sb-plan__skeleton" role="status" aria-label="正在生成计划">
468
+ {[72, 56, 40].map((width) => (
469
+ <span key={width}>
470
+ <i />
471
+ <b style={{ width: `${width}%` }} />
472
+ </span>
473
+ ))}
474
+ </div>
475
+ ) : (
476
+ <p className="sb-plan__empty">(空计划)</p>
477
+ )}
478
+ </PlanContent>
479
+ </Plan>
480
+ );
481
+ }
482
+
483
+ export function SchedulePart({
484
+ title,
485
+ cadence,
486
+ nextRun,
487
+ scheduled = false,
488
+ onSchedule,
489
+ }: {
490
+ title: string;
491
+ cadence: string;
492
+ nextRun: string;
493
+ scheduled?: boolean;
494
+ onSchedule?: () => void;
495
+ }) {
496
+ return (
497
+ <section className="sb-schedule" data-part-type="schedule">
498
+ <BellIcon size={18} />
499
+ <div>
500
+ <strong>{title}</strong>
501
+ <p>{cadence}</p>
502
+ <small>{nextRun}</small>
503
+ </div>
504
+ <button type="button" className="sb-button" onClick={onSchedule} disabled={scheduled}>
505
+ {scheduled ? "已安排" : "安排"}
506
+ </button>
507
+ </section>
508
+ );
509
+ }
510
+
511
+ export interface ParallelTool {
512
+ icon?: Exclude<ToolIcon, "image" | "terminal">;
513
+ label: string;
514
+ meta?: string;
515
+ status?: "running" | "done" | "failed";
516
+ results?: Array<{ title: string; domain: string }>;
517
+ }
518
+
519
+ export function RunningRow({ tool }: { tool: ParallelTool }) {
520
+ const Icon = tool.icon ? toolIcons[tool.icon] : WrenchIcon;
521
+ return (
522
+ <div className="sb-running-row">
523
+ <Icon size={15} />
524
+ <span>{tool.label}</span>
525
+ <Loader2Icon size={14} className="sb-spin" />
526
+ </div>
527
+ );
528
+ }
529
+
530
+ export function CollapsedRow({ tool }: { tool: ParallelTool }) {
531
+ const Icon = tool.icon ? toolIcons[tool.icon] : WrenchIcon;
532
+ return (
533
+ <div className="sb-collapsed-row">
534
+ <Icon size={15} />
535
+ <span>{tool.label}</span>
536
+ <small>{tool.meta}</small>
537
+ {tool.status === "failed" ? <XIcon size={14} /> : <CheckIcon size={14} />}
538
+ </div>
539
+ );
540
+ }
541
+
542
+ export function ParallelPart({
543
+ label = "Parallel tasks",
544
+ tools,
545
+ isActive = true,
546
+ }: {
547
+ label?: string;
548
+ tools: readonly ParallelTool[];
549
+ isActive?: boolean;
550
+ }) {
551
+ const active = isActive && tools.some((tool) => tool.status === "running");
552
+ return (
553
+ <details className="sb-parallel" data-part-type="parallel" open={active}>
554
+ <summary>
555
+ <strong>{label}</strong>
556
+ <span>{tools.filter((tool) => tool.status === "done").length}/{tools.length}</span>
557
+ <ChevronDownIcon size={16} />
558
+ </summary>
559
+ <div>
560
+ {tools.map((tool, index) =>
561
+ tool.status === "running" && isActive ? (
562
+ <RunningRow key={`${index}:${tool.label}`} tool={tool} />
563
+ ) : (
564
+ <CollapsedRow
565
+ key={`${index}:${tool.label}`}
566
+ tool={
567
+ tool.status === "running"
568
+ ? { ...tool, status: "failed" }
569
+ : tool
570
+ }
571
+ />
572
+ ),
573
+ )}
574
+ </div>
575
+ </details>
576
+ );
577
+ }
578
+
579
+ export interface SubAgent {
580
+ name: string;
581
+ tint?: string;
582
+ summary: string;
583
+ status?: "running" | "done" | "failed";
584
+ }
585
+
586
+ const SUBAGENT_VISUALS = [
587
+ { icon: SparklesIcon, name: "sparkles", color: "#e59a45" },
588
+ { icon: AtomIcon, name: "atom", color: "#9b7bd1" },
589
+ { icon: BrainCircuitIcon, name: "brain", color: "#9b7bd1" },
590
+ { icon: OrbitIcon, name: "orbit", color: "#e59a45" },
591
+ { icon: BlocksIcon, name: "blocks", color: "#e59a45" },
592
+ ] as const;
593
+
594
+ function subAgentVisual(name: string) {
595
+ let hash = 0;
596
+ for (const character of name) {
597
+ hash = (hash * 31 + (character.codePointAt(0) ?? 0)) >>> 0;
598
+ }
599
+ return SUBAGENT_VISUALS[hash % SUBAGENT_VISUALS.length] ?? SUBAGENT_VISUALS[0];
600
+ }
601
+
602
+ export function SubAgentsPart({
603
+ agents,
604
+ isActive = true,
605
+ }: {
606
+ agents: readonly SubAgent[];
607
+ isActive?: boolean;
608
+ }) {
609
+ return (
610
+ <div className="sb-subagents" data-part-type="subagents">
611
+ {agents.map((agent, index) => {
612
+ const visual = subAgentVisual(agent.name);
613
+ const AgentIcon = visual.icon;
614
+ return (
615
+ <article key={`${index}:${agent.name}`} data-state={agent.status ?? "done"}>
616
+ <span
617
+ data-subagent-icon={visual.name}
618
+ style={{ color: agent.tint ?? visual.color }}
619
+ aria-hidden="true"
620
+ >
621
+ <AgentIcon size={14} strokeWidth={2.25} />
622
+ </span>
623
+ <div>
624
+ <strong>{agent.name}</strong>
625
+ {agent.status === "running" && isActive ? (
626
+ <small><Loader2Icon size={12} className="sb-spin" />Running…</small>
627
+ ) : (
628
+ <p>{agent.summary || (agent.status === "running" ? "Interrupted" : "")}</p>
629
+ )}
630
+ </div>
631
+ </article>
632
+ );
633
+ })}
634
+ </div>
635
+ );
636
+ }
637
+
638
+ export function ConfirmationPart({
639
+ id = "confirmation",
640
+ title,
641
+ body,
642
+ confirmLabel = "确认",
643
+ cancelLabel = "取消",
644
+ state,
645
+ onConfirm,
646
+ }: {
647
+ id?: string;
648
+ title: string;
649
+ body?: string;
650
+ confirmLabel?: string;
651
+ cancelLabel?: string;
652
+ state?: "pending" | "confirmed" | "cancelled";
653
+ onConfirm?: (confirmed: boolean) => void | Promise<void>;
654
+ }) {
655
+ const [internalState, setInternalState] = useState(state ?? "pending");
656
+ const resolvedState = state ?? internalState;
657
+ const decide = async (confirmed: boolean) => {
658
+ await onConfirm?.(confirmed);
659
+ if (state === undefined) setInternalState(confirmed ? "confirmed" : "cancelled");
660
+ };
661
+ return (
662
+ <div data-part-type="confirmation">
663
+ <Confirmation state={resolvedState} approval={{ id, approved: resolvedState === "pending" ? undefined : resolvedState === "confirmed" }}>
664
+ <ConfirmationTitle>
665
+ <AlertCircleIcon size={18} />
666
+ <span>
667
+ <ConfirmationRequest>{title}</ConfirmationRequest>
668
+ <ConfirmationAccepted>已确认:{title}</ConfirmationAccepted>
669
+ <ConfirmationRejected>已取消:{title}</ConfirmationRejected>
670
+ </span>
671
+ </ConfirmationTitle>
672
+ {body && <p>{body}</p>}
673
+ <ConfirmationActions>
674
+ <ConfirmationAction className="sb-button--secondary" onClick={() => void decide(false)}>
675
+ {cancelLabel}
676
+ </ConfirmationAction>
677
+ <ConfirmationAction onClick={() => void decide(true)}>
678
+ {confirmLabel}
679
+ </ConfirmationAction>
680
+ </ConfirmationActions>
681
+ </Confirmation>
682
+ </div>
683
+ );
684
+ }
685
+
686
+ function ToolApprovalPresentation({
687
+ children,
688
+ toolName,
689
+ state,
690
+ context,
691
+ }: {
692
+ children: ReactNode;
693
+ toolName: string;
694
+ state: AIToolPart["state"];
695
+ context: PartRendererContext;
696
+ }) {
697
+ const approval = context.approval;
698
+ if (!approval) return <>{children}</>;
699
+ return (
700
+ <div className="sb-tool-stack">
701
+ {children}
702
+ <Confirmation approval={approval} state={state}>
703
+ <ConfirmationTitle>
704
+ <ConfirmationRequest>
705
+ <strong>{humanizeToolName(toolName)}</strong> 请求执行,需要你的批准。
706
+ </ConfirmationRequest>
707
+ <ConfirmationAccepted>
708
+ 已批准 {humanizeToolName(toolName)},继续执行。
709
+ </ConfirmationAccepted>
710
+ <ConfirmationRejected>
711
+ 已拒绝 {humanizeToolName(toolName)}。
712
+ </ConfirmationRejected>
713
+ </ConfirmationTitle>
714
+ <ConfirmationActions>
715
+ <ConfirmationAction
716
+ className="sb-button--secondary"
717
+ onClick={() => void context.actions.onApprove?.(approval.id, "deny")}
718
+ >
719
+ 拒绝
720
+ </ConfirmationAction>
721
+ <ConfirmationAction
722
+ className="sb-button--secondary"
723
+ onClick={() => void context.actions.onApprove?.(approval.id, "allow_once")}
724
+ >
725
+ 仅本次允许
726
+ </ConfirmationAction>
727
+ <ConfirmationAction
728
+ onClick={() => void context.actions.onApprove?.(approval.id, "allow_level")}
729
+ >
730
+ 允许该档位
731
+ </ConfirmationAction>
732
+ </ConfirmationActions>
733
+ </Confirmation>
734
+ </div>
735
+ );
736
+ }
737
+
738
+ export type FileKind = "doc" | "image" | "html" | "audio" | "csv" | "code";
739
+ const fileIcons = {
740
+ doc: FileTextIcon,
741
+ image: FileImageIcon,
742
+ html: Code2Icon,
743
+ audio: FileAudioIcon,
744
+ csv: FileSpreadsheetIcon,
745
+ code: FileCodeIcon,
746
+ } as const;
747
+
748
+ export function FilePart({
749
+ name,
750
+ kind = "doc",
751
+ note,
752
+ url,
753
+ artifact,
754
+ onPreview,
755
+ onDownload,
756
+ }: {
757
+ name: string;
758
+ kind?: FileKind;
759
+ note?: string;
760
+ url?: string;
761
+ artifact?: unknown;
762
+ onPreview?: (artifact: unknown) => void;
763
+ onDownload?: (artifact: unknown) => void;
764
+ }) {
765
+ const Icon = fileIcons[kind];
766
+ const payload = artifact ?? { name, kind, note, url };
767
+ return (
768
+ <div className="sb-file-part" data-part-type="file">
769
+ <span><Icon size={19} /></span>
770
+ <div>
771
+ <strong>{name}</strong>
772
+ {note && <small>{note}</small>}
773
+ </div>
774
+ <div>
775
+ {onPreview && (
776
+ <button type="button" onClick={() => onPreview(payload)} aria-label={`预览 ${name}`}>
777
+ <EyeIcon size={16} />
778
+ </button>
779
+ )}
780
+ {onDownload ? (
781
+ <button type="button" onClick={() => onDownload(payload)} aria-label={`下载 ${name}`}>
782
+ <DownloadIcon size={16} />
783
+ </button>
784
+ ) : url ? (
785
+ <a href={url} target="_blank" rel="noreferrer" download={name} aria-label={`打开 ${name}`}>
786
+ <ExternalLinkIcon size={16} />
787
+ </a>
788
+ ) : null}
789
+ </div>
790
+ </div>
791
+ );
792
+ }
793
+
794
+ export interface DashboardData {
795
+ title: string;
796
+ cards?: Array<{ icon?: string; title: string; body: string }>;
797
+ tiles?: Array<{ label: string; value: string }>;
798
+ badges?: string[];
799
+ fileNote?: { title: string; body: string };
800
+ table?: { columns: string[]; rows: string[][] };
801
+ }
802
+
803
+ export function DashboardPart(data: DashboardData) {
804
+ return (
805
+ <section className="sb-dashboard" data-part-type="dashboard">
806
+ <h3>{data.title}</h3>
807
+ {data.tiles?.length ? (
808
+ <div className="sb-dashboard__tiles">
809
+ {data.tiles.map((tile) => (
810
+ <article key={tile.label}><strong>{tile.value}</strong><small>{tile.label}</small></article>
811
+ ))}
812
+ </div>
813
+ ) : null}
814
+ {data.cards?.length ? (
815
+ <div className="sb-dashboard__cards">
816
+ {data.cards.map((card) => (
817
+ <article key={card.title}><span>{card.icon}</span><strong>{card.title}</strong><p>{card.body}</p></article>
818
+ ))}
819
+ </div>
820
+ ) : null}
821
+ {data.badges?.length ? <div className="sb-dashboard__badges">{data.badges.map((badge) => <span key={badge}>{badge}</span>)}</div> : null}
822
+ {data.fileNote && <aside><strong>{data.fileNote.title}</strong><p>{data.fileNote.body}</p></aside>}
823
+ {data.table && (
824
+ <div className="sb-table-scroll">
825
+ <table>
826
+ <thead><tr>{data.table.columns.map((column) => <th key={column}>{column}</th>)}</tr></thead>
827
+ <tbody>{data.table.rows.map((row, index) => <tr key={index}>{row.map((cell, cellIndex) => <td key={cellIndex}>{cell}</td>)}</tr>)}</tbody>
828
+ </table>
829
+ </div>
830
+ )}
831
+ </section>
832
+ );
833
+ }
834
+
835
+ export interface AskQuestion {
836
+ prompt: string;
837
+ kind: "single" | "multi";
838
+ options: string[];
839
+ }
840
+
841
+ export function AskUserPart({
842
+ questions,
843
+ freeTextPlaceholder,
844
+ submittedAnswer,
845
+ onSubmit,
846
+ }: {
847
+ questions: readonly AskQuestion[];
848
+ freeTextPlaceholder?: string;
849
+ submittedAnswer?: string;
850
+ onSubmit?: (payload: { selections: string[][]; text: string }) => void | Promise<void>;
851
+ }) {
852
+ const [selections, setSelections] = useState<string[][]>(() => questions.map(() => []));
853
+ const [text, setText] = useState("");
854
+ const [submitted, setSubmitted] = useState(false);
855
+ const externalAnswer = submittedAnswer?.trim() ?? "";
856
+ const externalSelections = useMemo(
857
+ () =>
858
+ new Set(
859
+ externalAnswer
860
+ ? externalAnswer.split("、").map((item) => item.trim()).filter(Boolean)
861
+ : [],
862
+ ),
863
+ [externalAnswer],
864
+ );
865
+ const resolvedSubmitted = submitted || Boolean(externalAnswer);
866
+ const valid = questions.every(
867
+ (question, index) => question.kind !== "single" || selections[index].length === 1,
868
+ );
869
+ const toggle = (questionIndex: number, option: string) => {
870
+ if (resolvedSubmitted) return;
871
+ setSelections((current) =>
872
+ current.map((selected, index) => {
873
+ if (index !== questionIndex) return selected;
874
+ if (questions[index].kind === "single") return [option];
875
+ return selected.includes(option)
876
+ ? selected.filter((item) => item !== option)
877
+ : [...selected, option];
878
+ }),
879
+ );
880
+ };
881
+ const submit = async () => {
882
+ if (!valid || resolvedSubmitted) return;
883
+ await onSubmit?.({ selections, text: text.trim() });
884
+ setSubmitted(true);
885
+ };
886
+ const resolvedAnswer =
887
+ externalAnswer ||
888
+ [...selections.flat(), text.trim()].filter(Boolean).join("、");
889
+ return (
890
+ <section
891
+ className="sb-ask-user"
892
+ data-part-type="ask_user"
893
+ data-state={resolvedSubmitted ? "submitted" : "pending"}
894
+ >
895
+ {questions.map((question, questionIndex) => (
896
+ <fieldset
897
+ key={`${questionIndex}:${question.prompt}`}
898
+ disabled={resolvedSubmitted}
899
+ >
900
+ <legend>{question.prompt}</legend>
901
+ <div>
902
+ {question.options.map((option) => {
903
+ const selected = externalAnswer
904
+ ? externalSelections.has(option)
905
+ : selections[questionIndex].includes(option);
906
+ return (
907
+ <button
908
+ key={option}
909
+ type="button"
910
+ aria-pressed={selected}
911
+ onClick={() => toggle(questionIndex, option)}
912
+ >
913
+ {question.kind === "multi" && <span>{selected && <CheckIcon size={12} />}</span>}
914
+ {selected && question.kind === "single" && <CheckIcon size={14} />}
915
+ {option}
916
+ </button>
917
+ );
918
+ })}
919
+ </div>
920
+ </fieldset>
921
+ ))}
922
+ {freeTextPlaceholder && !resolvedSubmitted && (
923
+ <textarea value={text} onChange={(event) => setText(event.currentTarget.value)} placeholder={freeTextPlaceholder} />
924
+ )}
925
+ {resolvedSubmitted ? (
926
+ <div className="sb-ask-user__submitted">
927
+ <CheckIcon size={15} />
928
+ <span>Submitted</span>
929
+ {resolvedAnswer && <small>{resolvedAnswer}</small>}
930
+ </div>
931
+ ) : (
932
+ <button type="button" className="sb-button" disabled={!valid} onClick={() => void submit()}>
933
+ 提交<ArrowRightIcon size={15} />
934
+ </button>
935
+ )}
936
+ </section>
937
+ );
938
+ }
939
+
940
+ export function Suggestions({
941
+ items,
942
+ onPick,
943
+ }: {
944
+ items: readonly string[];
945
+ onPick?: (text: string) => void | Promise<void>;
946
+ }) {
947
+ return (
948
+ <div className="sb-suggestions" data-part-type="suggestions">
949
+ {items.map((item) => (
950
+ <button type="button" key={item} onClick={() => void onPick?.(item)}>
951
+ <ArrowRightIcon size={15} />
952
+ <span>{item}</span>
953
+ </button>
954
+ ))}
955
+ </div>
956
+ );
957
+ }
958
+
959
+ export function InlineText({ children }: { children: ReactNode }) {
960
+ return <span className="sb-inline-text">{children}</span>;
961
+ }
962
+
963
+ export function PartLoading({
964
+ kind,
965
+ label,
966
+ }: {
967
+ kind: RichPartKind;
968
+ label?: string;
969
+ }) {
970
+ if (kind === "reasoning") return <ThinkingIndicator />;
971
+ if (kind === "tool") return <RunningRow tool={{ label: label ?? "Running tool", status: "running" }} />;
972
+ return (
973
+ <div className="sb-part-loading" data-kind={kind} role="status">
974
+ <span />
975
+ <span />
976
+ <span />
977
+ </div>
978
+ );
979
+ }
980
+
981
+ export function UnknownPart({ part }: { part: unknown }) {
982
+ const type =
983
+ part && typeof part === "object" && typeof (part as { type?: unknown }).type === "string"
984
+ ? (part as { type: string }).type
985
+ : "unknown";
986
+ return (
987
+ <div className="sb-unknown-part" data-part-type="unknown" data-unknown-type={type}>
988
+ <AlertCircleIcon size={15} />
989
+ <span>暂不支持的消息组件:{type}</span>
990
+ </div>
991
+ );
992
+ }
993
+
994
+ function objectOf(value: unknown): Record<string, unknown> {
995
+ return value != null && typeof value === "object"
996
+ ? (value as Record<string, unknown>)
997
+ : {};
998
+ }
999
+
1000
+ function strings(value: unknown): string[] {
1001
+ return Array.isArray(value)
1002
+ ? value.filter((item): item is string => typeof item === "string")
1003
+ : [];
1004
+ }
1005
+
1006
+ function dataOf(part: unknown): Record<string, unknown> {
1007
+ const record = objectOf(part);
1008
+ return objectOf(record.data ?? record.input ?? record.arguments ?? record);
1009
+ }
1010
+
1011
+ function fileKind(mediaType: unknown, filename: unknown): FileKind {
1012
+ const value = `${String(mediaType ?? "")} ${String(filename ?? "")}`;
1013
+ if (/image/i.test(value)) return "image";
1014
+ if (/audio/i.test(value)) return "audio";
1015
+ if (/csv|sheet|excel/i.test(value)) return "csv";
1016
+ if (/html/i.test(value)) return "html";
1017
+ if (/code|javascript|typescript|json|xml/i.test(value)) return "code";
1018
+ return "doc";
1019
+ }
1020
+
1021
+ export function DefaultPartRenderer({
1022
+ part,
1023
+ context,
1024
+ }: {
1025
+ part: unknown;
1026
+ context: PartRendererContext;
1027
+ }) {
1028
+ const record = objectOf(part);
1029
+ if (record.type === "step-start") return null;
1030
+ const data = dataOf(part);
1031
+ const kind = resolvePartKind(part);
1032
+ const toolName = toolNameOfPart(part);
1033
+ const toolState = String(
1034
+ record.state ?? data.state ?? "output-available",
1035
+ ) as AIToolPart["state"];
1036
+ const withToolApproval = (children: ReactNode) =>
1037
+ toolName ? (
1038
+ <ToolApprovalPresentation
1039
+ toolName={toolName}
1040
+ state={toolState}
1041
+ context={context}
1042
+ >
1043
+ {children}
1044
+ </ToolApprovalPresentation>
1045
+ ) : (
1046
+ children
1047
+ );
1048
+ if (kind === "text") {
1049
+ return <TextPart text={String(record.text ?? data.text ?? "")} streaming={context.isActive} />;
1050
+ }
1051
+ if (kind === "reasoning") {
1052
+ return (
1053
+ <ReasoningPart
1054
+ text={String(
1055
+ record.thinking ?? record.text ?? data.detail ?? data.text ?? "",
1056
+ )}
1057
+ label={typeof data.label === "string" ? data.label : undefined}
1058
+ streaming={context.isActive && record.state === "streaming"}
1059
+ />
1060
+ );
1061
+ }
1062
+ if (kind === "tool") {
1063
+ const name = toolName ?? String(data.name ?? "tool");
1064
+ const state = toolState;
1065
+ const pending = state === "input-available" || state === "input-streaming";
1066
+ return withToolApproval(
1067
+ <ToolPart
1068
+ name={name}
1069
+ label={typeof data.label === "string" ? data.label : undefined}
1070
+ icon={typeof data.icon === "string" && data.icon in toolIcons ? data.icon as ToolIcon : undefined}
1071
+ state={pending && !context.isActive ? "interrupted" : state}
1072
+ input={record.input ?? record.arguments}
1073
+ output={record.output ?? data.output}
1074
+ errorText={typeof record.errorText === "string" ? record.errorText : undefined}
1075
+ command={typeof data.command === "string" ? data.command : undefined}
1076
+ meta={typeof data.meta === "string" ? data.meta : undefined}
1077
+ results={Array.isArray(data.results) ? data.results as Array<{ title: string; domain: string }> : undefined}
1078
+ activityId={context.activityId}
1079
+ onNavigate={context.actions.onNavigate}
1080
+ />
1081
+ );
1082
+ }
1083
+ if (kind === "error") {
1084
+ return <ErrorPart title={String(data.title ?? "Error")} body={String(data.body ?? data.message ?? "请求失败")} />;
1085
+ }
1086
+ if (kind === "image") {
1087
+ return <ImagePart src={String(record.src ?? record.url ?? data.src ?? data.url ?? "")} alt={String(record.alt ?? record.filename ?? data.alt ?? data.filename ?? "图片")} onPreview={() => context.actions.onPreview?.(part)} />;
1088
+ }
1089
+ if (kind === "chart") {
1090
+ return <ChartPart title={String(data.title ?? "Chart")} unit={typeof data.unit === "string" ? data.unit : undefined} bars={Array.isArray(data.bars) ? data.bars as ChartBar[] : []} expandable={Boolean(data.expandable)} onPreview={() => context.actions.onPreview?.(part)} />;
1091
+ }
1092
+ if (kind === "agent_card") {
1093
+ return <AgentCardPart title={String(data.title ?? "Agent")} agentName={String(data.agentName ?? data.name ?? "Agent")} agentDesc={String(data.agentDesc ?? data.description ?? "")} onNavigate={() => context.actions.onNavigate?.(String(data.target ?? "agents"))} />;
1094
+ }
1095
+ if (kind === "plan") {
1096
+ if (!context.isLatestOfKind) return null;
1097
+ const steps = planStepsOfPart(part);
1098
+ return withToolApproval(
1099
+ <PlanPart
1100
+ title={String(data.title ?? "任务计划")}
1101
+ steps={steps}
1102
+ streaming={context.isActive && context.status === "streaming"}
1103
+ />,
1104
+ );
1105
+ }
1106
+ if (kind === "schedule") {
1107
+ return withToolApproval(<SchedulePart title={String(data.title ?? "安排任务")} cadence={String(data.cadence ?? data.schedule ?? "")} nextRun={String(data.nextRun ?? data.next_run ?? "")} scheduled={Boolean(data.scheduled)} onSchedule={() => context.actions.onAnswer?.({ type: "schedule", data })} />);
1108
+ }
1109
+ if (kind === "parallel") {
1110
+ return withToolApproval(<ParallelPart label={typeof data.label === "string" ? data.label : undefined} tools={Array.isArray(data.tools) ? data.tools as ParallelTool[] : []} isActive={context.isActive} />);
1111
+ }
1112
+ if (kind === "subagents") {
1113
+ const agents = Array.isArray(data.agents)
1114
+ ? data.agents as SubAgent[]
1115
+ : [{ name: String(data.name ?? "Sub-agent"), summary: String(data.summary ?? ""), status: context.isActive ? "running" as const : "done" as const }];
1116
+ return withToolApproval(<SubAgentsPart agents={agents} isActive={context.isActive} />);
1117
+ }
1118
+ if (kind === "confirmation") {
1119
+ return <ConfirmationPart id={String(data.id ?? context.activityId)} title={String(data.title ?? "确认操作?")} body={typeof data.body === "string" ? data.body : undefined} confirmLabel={String(data.confirmLabel ?? "确认")} cancelLabel={String(data.cancelLabel ?? "取消")} onConfirm={(confirmed) => context.actions.onApprove?.(String(data.id ?? context.activityId), confirmed ? "allow_once" : "deny")} />;
1120
+ }
1121
+ if (kind === "file") {
1122
+ const filename = String(record.filename ?? data.name ?? data.filename ?? record.mediaType ?? "附件");
1123
+ return <FilePart name={filename} kind={fileKind(record.mediaType ?? data.mediaType, filename)} note={typeof data.note === "string" ? data.note : undefined} url={typeof record.url === "string" ? record.url : typeof data.url === "string" ? data.url : undefined} artifact={part} onPreview={context.actions.onPreview} onDownload={context.actions.onDownload} />;
1124
+ }
1125
+ if (kind === "dashboard") {
1126
+ return <DashboardPart {...data as unknown as DashboardData} title={String(data.title ?? "Dashboard")} />;
1127
+ }
1128
+ if (kind === "ask_user") {
1129
+ const questions: AskQuestion[] = Array.isArray(data.questions)
1130
+ ? data.questions as AskQuestion[]
1131
+ : [{
1132
+ prompt: String(data.question ?? "请选择"),
1133
+ kind: data.multiSelect === true ? "multi" : "single",
1134
+ options: strings(data.options),
1135
+ }];
1136
+ return withToolApproval(
1137
+ <AskUserPart
1138
+ questions={questions}
1139
+ freeTextPlaceholder={typeof data.freeTextPlaceholder === "string" ? data.freeTextPlaceholder : undefined}
1140
+ submittedAnswer={context.replyText}
1141
+ onSubmit={(payload) => context.actions.onAnswer?.(payload)}
1142
+ />,
1143
+ );
1144
+ }
1145
+ if (kind === "suggestions") {
1146
+ return withToolApproval(<Suggestions items={strings(data.items)} onPick={context.actions.onSuggestion} />);
1147
+ }
1148
+ return <UnknownPart part={part} />;
1149
+ }