@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,138 @@
1
+ import type { ComponentType, HTMLAttributes, ReactNode } from "react";
2
+
3
+ export type MessagePanelStatus = "ready" | "submitted" | "streaming" | "error";
4
+ export type MessageRole = "user" | "assistant" | "system" | "tool";
5
+
6
+ export interface ToolApprovalView {
7
+ id: string;
8
+ approved?: boolean;
9
+ }
10
+
11
+ export type ToolApprovalDecision = "deny" | "allow_once" | "allow_level";
12
+
13
+ export interface MessageAdapter<TMessage> {
14
+ getId: (message: TMessage, index: number) => string;
15
+ getRole: (message: TMessage) => MessageRole;
16
+ getParts: (message: TMessage) => readonly unknown[];
17
+ getMetadata?: (message: TMessage) => unknown;
18
+ getText?: (message: TMessage) => string;
19
+ isPartRenderable?: (part: unknown) => boolean;
20
+ /** Extracts provider-specific tool approval state without leaking it into UI code. */
21
+ getToolApproval?: (part: unknown) => ToolApprovalView | undefined;
22
+ /**
23
+ * Marks snapshot-like parts that supersede older parts with the same key.
24
+ * `update_plan` uses the built-in "plan" key when this is omitted.
25
+ */
26
+ getPartPresentationKey?: (part: unknown) => string | undefined;
27
+ }
28
+
29
+ export interface MessagePanelActions {
30
+ onCopy?: (text: string) => void | Promise<void>;
31
+ onRetry?: (messageId: string) => void | Promise<void>;
32
+ onFork?: (messageId: string) => void | Promise<void>;
33
+ onNavigate?: (target: string) => void;
34
+ onPreview?: (artifact: unknown) => void;
35
+ onDownload?: (artifact: unknown) => void;
36
+ onAnswer?: (payload: unknown) => void | Promise<void>;
37
+ onApprove?: (
38
+ approvalId: string,
39
+ decision: ToolApprovalDecision,
40
+ ) => void | Promise<void>;
41
+ onSuggestion?: (text: string) => void | Promise<void>;
42
+ }
43
+
44
+ export interface PartRendererContext {
45
+ messageId: string;
46
+ partIndex: number;
47
+ activityId: string;
48
+ isActive: boolean;
49
+ isLatestOfKind: boolean;
50
+ /** First user message after the assistant message that owns this part. */
51
+ replyText?: string;
52
+ approval?: ToolApprovalView;
53
+ status: MessagePanelStatus;
54
+ actions: MessagePanelActions;
55
+ }
56
+
57
+ export interface PartRendererProps<TPart = unknown> {
58
+ part: TPart;
59
+ context: PartRendererContext;
60
+ }
61
+
62
+ export type PartRendererComponent<TPart = unknown> = ComponentType<
63
+ PartRendererProps<TPart>
64
+ >;
65
+
66
+ export interface PartRendererDefinition {
67
+ name: string;
68
+ match: (part: unknown) => boolean;
69
+ component: PartRendererComponent;
70
+ }
71
+
72
+ export interface PartRendererRegistry {
73
+ resolve: (part: unknown) => PartRendererComponent | undefined;
74
+ names: readonly string[];
75
+ }
76
+
77
+ export interface MessagePanelSlots {
78
+ assistantHeader?: ReactNode | ((messageId: string) => ReactNode);
79
+ empty?: ReactNode;
80
+ hydrating?: ReactNode;
81
+ recovering?: ReactNode;
82
+ background?: ReactNode;
83
+ error?: ReactNode | ((error?: unknown) => ReactNode);
84
+ beforeMessages?: ReactNode;
85
+ afterMessages?: ReactNode;
86
+ }
87
+
88
+ export interface MessagePanelProps<TMessage>
89
+ extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
90
+ messages: readonly TMessage[];
91
+ status: MessagePanelStatus;
92
+ isHydrating?: boolean;
93
+ isRecovering?: boolean;
94
+ isServerStreaming?: boolean;
95
+ error?: unknown;
96
+ adapter: MessageAdapter<TMessage>;
97
+ renderers?: PartRendererRegistry;
98
+ actions?: MessagePanelActions;
99
+ slots?: MessagePanelSlots;
100
+ emptyTitle?: string;
101
+ emptyDescription?: string;
102
+ }
103
+
104
+ export type RichPartKind =
105
+ | "text"
106
+ | "reasoning"
107
+ | "tool"
108
+ | "error"
109
+ | "image"
110
+ | "chart"
111
+ | "agent_card"
112
+ | "plan"
113
+ | "schedule"
114
+ | "parallel"
115
+ | "subagents"
116
+ | "confirmation"
117
+ | "file"
118
+ | "dashboard"
119
+ | "ask_user"
120
+ | "suggestions";
121
+
122
+ export type MessageToolState =
123
+ | "input-streaming"
124
+ | "input-available"
125
+ | "approval-requested"
126
+ | "approval-responded"
127
+ | "output-available"
128
+ | "output-denied"
129
+ | "output-error";
130
+
131
+ export interface AIToolPart {
132
+ type: string;
133
+ state: MessageToolState;
134
+ input?: unknown;
135
+ output?: unknown;
136
+ errorText?: string;
137
+ toolName?: string;
138
+ }
@@ -0,0 +1,76 @@
1
+ import { ArrowDownIcon } from "lucide-react";
2
+ import { forwardRef, useCallback, type ComponentProps, type HTMLAttributes } from "react";
3
+ import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
4
+ import { cn } from "./internal/cn";
5
+
6
+ export type ConversationProps = ComponentProps<typeof StickToBottom>;
7
+
8
+ export const Conversation = ({ className, ...props }: ConversationProps) => (
9
+ <StickToBottom
10
+ className={cn("sb-conversation", className)}
11
+ initial="instant"
12
+ resize="instant"
13
+ role="log"
14
+ {...props}
15
+ />
16
+ );
17
+
18
+ export type ConversationContentProps = ComponentProps<
19
+ typeof StickToBottom.Content
20
+ >;
21
+
22
+ export const ConversationContent = ({
23
+ className,
24
+ ...props
25
+ }: ConversationContentProps) => (
26
+ <StickToBottom.Content
27
+ className={cn("sb-conversation__content", className)}
28
+ {...props}
29
+ />
30
+ );
31
+
32
+ export interface ConversationEmptyStateProps
33
+ extends HTMLAttributes<HTMLDivElement> {
34
+ title?: string;
35
+ description?: string;
36
+ icon?: React.ReactNode;
37
+ }
38
+
39
+ export const ConversationEmptyState = forwardRef<
40
+ HTMLDivElement,
41
+ ConversationEmptyStateProps
42
+ >(({ className, title = "开始对话", description, icon, children, ...props }, ref) => (
43
+ <div ref={ref} className={cn("sb-conversation__empty", className)} {...props}>
44
+ {children ?? (
45
+ <>
46
+ {icon}
47
+ <strong>{title}</strong>
48
+ {description && <p>{description}</p>}
49
+ </>
50
+ )}
51
+ </div>
52
+ ));
53
+ ConversationEmptyState.displayName = "ConversationEmptyState";
54
+
55
+ export function ConversationScrollButton({
56
+ className,
57
+ ...props
58
+ }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
59
+ const { isAtBottom, scrollToBottom } = useStickToBottomContext();
60
+ const handleClick = useCallback(() => {
61
+ void scrollToBottom({ animation: "smooth" });
62
+ }, [scrollToBottom]);
63
+
64
+ if (isAtBottom) return null;
65
+ return (
66
+ <button
67
+ type="button"
68
+ className={cn("sb-conversation__scroll", className)}
69
+ onClick={handleClick}
70
+ aria-label="回到最新消息"
71
+ {...props}
72
+ >
73
+ <ArrowDownIcon size={16} />
74
+ </button>
75
+ );
76
+ }
@@ -0,0 +1,3 @@
1
+ export function cn(...values: Array<string | false | null | undefined>): string {
2
+ return values.filter(Boolean).join(" ");
3
+ }
@@ -0,0 +1,368 @@
1
+ import {
2
+ forwardRef,
3
+ useMemo,
4
+ type ReactNode,
5
+ } from "react";
6
+ import type {
7
+ MessageAdapter,
8
+ MessagePanelActions,
9
+ MessagePanelProps,
10
+ MessagePanelSlots,
11
+ MessageRole,
12
+ PartRendererRegistry,
13
+ } from "./contracts";
14
+ import {
15
+ Conversation,
16
+ ConversationContent,
17
+ ConversationEmptyState,
18
+ ConversationScrollButton,
19
+ } from "./conversation";
20
+ import { cn } from "./internal/cn";
21
+ import {
22
+ AssistantMessageActions,
23
+ BrandMark,
24
+ Message,
25
+ MessageContent,
26
+ } from "./message";
27
+ import {
28
+ DefaultPartRenderer,
29
+ resolvePartKind,
30
+ stripNoticePrefix,
31
+ } from "./parts";
32
+ import { PanelStatus, ThinkingIndicator, TopLoadingBar } from "./status";
33
+
34
+ function roleOf<T>(
35
+ adapter: MessageAdapter<T>,
36
+ message: T,
37
+ ): MessageRole {
38
+ return adapter.getRole(message);
39
+ }
40
+
41
+ function messageText<T>(
42
+ adapter: MessageAdapter<T>,
43
+ message: T,
44
+ ): string {
45
+ if (adapter.getText) return adapter.getText(message);
46
+ return adapter
47
+ .getParts(message)
48
+ .map((part) => {
49
+ if (
50
+ part != null &&
51
+ typeof part === "object" &&
52
+ (part as { type?: unknown }).type === "text"
53
+ ) {
54
+ return String((part as { text?: unknown }).text ?? "");
55
+ }
56
+ return "";
57
+ })
58
+ .filter(Boolean)
59
+ .join("\n");
60
+ }
61
+
62
+ function isNotice(metadata: unknown): boolean {
63
+ if (metadata == null || typeof metadata !== "object") return false;
64
+ const kind = (metadata as { deliveryKind?: unknown }).deliveryKind;
65
+ return kind === "notice" || kind === "interim";
66
+ }
67
+
68
+ function slotValue(
69
+ slot: MessagePanelSlots["assistantHeader"],
70
+ messageId: string,
71
+ ): ReactNode {
72
+ return typeof slot === "function" ? slot(messageId) : slot;
73
+ }
74
+
75
+ function presentationKey<T>(
76
+ adapter: MessageAdapter<T>,
77
+ part: unknown,
78
+ ): string | undefined {
79
+ const customKey = adapter.getPartPresentationKey?.(part);
80
+ if (customKey) return customKey;
81
+ return resolvePartKind(part) === "plan" ? "plan" : undefined;
82
+ }
83
+
84
+ interface MessageRowProps<T> {
85
+ message: T;
86
+ index: number;
87
+ messageId: string;
88
+ isLastAssistant: boolean;
89
+ isActive: boolean;
90
+ adapter: MessageAdapter<T>;
91
+ renderers?: PartRendererRegistry;
92
+ actions: MessagePanelActions;
93
+ slots: MessagePanelSlots;
94
+ status: MessagePanelProps<T>["status"];
95
+ latestActivityByKind: ReadonlyMap<string, string>;
96
+ replyText?: string;
97
+ }
98
+
99
+ function MessageRow<T>({
100
+ message,
101
+ messageId,
102
+ isLastAssistant,
103
+ isActive,
104
+ adapter,
105
+ renderers,
106
+ actions,
107
+ slots,
108
+ status,
109
+ latestActivityByKind,
110
+ replyText,
111
+ }: MessageRowProps<T>) {
112
+ const role = roleOf(adapter, message);
113
+ const parts = adapter.getParts(message);
114
+ const visibleParts = adapter.isPartRenderable
115
+ ? parts.filter(adapter.isPartRenderable)
116
+ : parts;
117
+ const metadata = adapter.getMetadata?.(message);
118
+ const text = messageText(adapter, message);
119
+
120
+ if (isNotice(metadata)) {
121
+ return (
122
+ <Message from="assistant" data-message-id={messageId}>
123
+ <div className="sb-notice-line">{stripNoticePrefix(text)}</div>
124
+ </Message>
125
+ );
126
+ }
127
+
128
+ if (role === "user") {
129
+ return (
130
+ <Message from="user" data-message-id={messageId} data-message-role="user">
131
+ <MessageContent>
132
+ {parts.map((part, partIndex) => {
133
+ const Renderer = renderers?.resolve(part) ?? DefaultPartRenderer;
134
+ const activityId = `${messageId}:${partIndex}`;
135
+ const key = presentationKey(adapter, part);
136
+ return (
137
+ <Renderer
138
+ key={activityId}
139
+ part={part}
140
+ context={{
141
+ messageId,
142
+ partIndex,
143
+ activityId,
144
+ isActive: false,
145
+ isLatestOfKind:
146
+ key === undefined ||
147
+ latestActivityByKind.get(key) === activityId,
148
+ approval: adapter.getToolApproval?.(part),
149
+ status,
150
+ actions,
151
+ }}
152
+ />
153
+ );
154
+ })}
155
+ </MessageContent>
156
+ </Message>
157
+ );
158
+ }
159
+
160
+ return (
161
+ <Message
162
+ from="assistant"
163
+ data-message-id={messageId}
164
+ data-message-role="assistant"
165
+ data-active={isActive ? "true" : undefined}
166
+ >
167
+ {slots.assistantHeader && (
168
+ <div className="sb-assistant-header">
169
+ {slotValue(slots.assistantHeader, messageId)}
170
+ </div>
171
+ )}
172
+ <MessageContent>
173
+ {parts.map((part, partIndex) => {
174
+ const Renderer = renderers?.resolve(part) ?? DefaultPartRenderer;
175
+ const activityId = `${messageId}:${partIndex}`;
176
+ const key = presentationKey(adapter, part);
177
+ return (
178
+ <Renderer
179
+ key={activityId}
180
+ part={part}
181
+ context={{
182
+ messageId,
183
+ partIndex,
184
+ activityId,
185
+ isActive,
186
+ isLatestOfKind:
187
+ key === undefined ||
188
+ latestActivityByKind.get(key) === activityId,
189
+ replyText,
190
+ approval: adapter.getToolApproval?.(part),
191
+ status,
192
+ actions,
193
+ }}
194
+ />
195
+ );
196
+ })}
197
+ {isActive && visibleParts.length === 0 && <ThinkingIndicator />}
198
+ </MessageContent>
199
+ {!isActive && (
200
+ <AssistantMessageActions
201
+ text={text}
202
+ messageId={messageId}
203
+ canRetry={isLastAssistant && status !== "streaming"}
204
+ onCopy={actions.onCopy}
205
+ onRetry={actions.onRetry}
206
+ onFork={actions.onFork}
207
+ />
208
+ )}
209
+ </Message>
210
+ );
211
+ }
212
+
213
+ function MessagePanelInner<T>(
214
+ {
215
+ messages,
216
+ status,
217
+ isHydrating = false,
218
+ isRecovering = false,
219
+ isServerStreaming = false,
220
+ error,
221
+ adapter,
222
+ renderers,
223
+ actions = {},
224
+ slots = {},
225
+ className,
226
+ emptyTitle = "开始对话",
227
+ emptyDescription = "发送消息,Agent 会在这里展示思考、工具和产出。",
228
+ ...props
229
+ }: MessagePanelProps<T>,
230
+ ref: React.ForwardedRef<HTMLDivElement>,
231
+ ) {
232
+ const ids = useMemo(
233
+ () => messages.map((message, index) => adapter.getId(message, index)),
234
+ [messages, adapter],
235
+ );
236
+ const lastAssistantIndex = useMemo(() => {
237
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
238
+ if (roleOf(adapter, messages[index]) === "assistant") return index;
239
+ }
240
+ return -1;
241
+ }, [messages, adapter]);
242
+ const lastRole =
243
+ messages.length > 0 ? roleOf(adapter, messages[messages.length - 1]) : undefined;
244
+ const showPending =
245
+ status === "submitted" &&
246
+ (lastRole === "user" || messages.length === 0);
247
+ const latestActivityByKind = useMemo(() => {
248
+ const latest = new Map<string, string>();
249
+ messages.forEach((message, messageIndex) => {
250
+ if (roleOf(adapter, message) !== "assistant") return;
251
+ adapter.getParts(message).forEach((part, partIndex) => {
252
+ const key = presentationKey(adapter, part);
253
+ if (key) latest.set(key, `${ids[messageIndex]}:${partIndex}`);
254
+ });
255
+ });
256
+ return latest;
257
+ }, [adapter, ids, messages]);
258
+ const replyTextByMessageId = useMemo(() => {
259
+ const replies = new Map<string, string>();
260
+ messages.forEach((message, messageIndex) => {
261
+ if (roleOf(adapter, message) !== "assistant") return;
262
+ for (
263
+ let replyIndex = messageIndex + 1;
264
+ replyIndex < messages.length;
265
+ replyIndex += 1
266
+ ) {
267
+ const reply = messages[replyIndex];
268
+ if (roleOf(adapter, reply) !== "user") continue;
269
+ const text = messageText(adapter, reply).trim();
270
+ if (text) replies.set(ids[messageIndex], text);
271
+ break;
272
+ }
273
+ });
274
+ return replies;
275
+ }, [adapter, ids, messages]);
276
+
277
+ return (
278
+ <div
279
+ ref={ref}
280
+ className={cn("sb-message-panel", className)}
281
+ data-sb-message-panel=""
282
+ data-status={status}
283
+ data-hydrating={isHydrating ? "true" : undefined}
284
+ aria-busy={status === "submitted" || status === "streaming" || isHydrating}
285
+ {...props}
286
+ >
287
+ <TopLoadingBar status={status} />
288
+ {isRecovering &&
289
+ (slots.recovering ?? (
290
+ <PanelStatus kind="recovering">正在恢复对话流…</PanelStatus>
291
+ ))}
292
+ {!isRecovering &&
293
+ isServerStreaming &&
294
+ (slots.background ?? (
295
+ <PanelStatus kind="background">Agent 正在后台更新…</PanelStatus>
296
+ ))}
297
+ {status === "error" &&
298
+ (typeof slots.error === "function"
299
+ ? slots.error(error)
300
+ : slots.error ?? (
301
+ <PanelStatus kind="error">
302
+ {error instanceof Error ? error.message : "这一轮出错了,请重试。"}
303
+ </PanelStatus>
304
+ ))}
305
+ <Conversation>
306
+ <ConversationContent>
307
+ {slots.beforeMessages}
308
+ {messages.length === 0 &&
309
+ !isHydrating &&
310
+ (slots.empty ?? (
311
+ <ConversationEmptyState
312
+ title={emptyTitle}
313
+ description={emptyDescription}
314
+ icon={<BrandMark className="sb-brand-mark--large" />}
315
+ />
316
+ ))}
317
+ {messages.map((message, index) => (
318
+ <MessageRow
319
+ key={ids[index]}
320
+ message={message}
321
+ index={index}
322
+ messageId={ids[index]}
323
+ isLastAssistant={index === lastAssistantIndex}
324
+ isActive={
325
+ status === "streaming" &&
326
+ index === messages.length - 1 &&
327
+ roleOf(adapter, message) === "assistant"
328
+ }
329
+ adapter={adapter}
330
+ renderers={renderers}
331
+ actions={actions}
332
+ slots={slots}
333
+ status={status}
334
+ latestActivityByKind={latestActivityByKind}
335
+ replyText={replyTextByMessageId.get(ids[index])}
336
+ />
337
+ ))}
338
+ {showPending && (
339
+ <Message
340
+ from="assistant"
341
+ data-message-role="assistant"
342
+ data-pending="true"
343
+ >
344
+ {slots.assistantHeader && (
345
+ <div className="sb-assistant-header">
346
+ {slotValue(slots.assistantHeader, "pending")}
347
+ </div>
348
+ )}
349
+ <ThinkingIndicator />
350
+ </Message>
351
+ )}
352
+ {slots.afterMessages}
353
+ </ConversationContent>
354
+ {isHydrating &&
355
+ (slots.hydrating ?? (
356
+ <div className="sb-hydrating-overlay">
357
+ <PanelStatus kind="hydrating">正在恢复历史消息…</PanelStatus>
358
+ </div>
359
+ ))}
360
+ <ConversationScrollButton />
361
+ </Conversation>
362
+ </div>
363
+ );
364
+ }
365
+
366
+ export const MessagePanel = forwardRef(MessagePanelInner) as <T>(
367
+ props: MessagePanelProps<T> & { ref?: React.ForwardedRef<HTMLDivElement> },
368
+ ) => React.ReactElement;