@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.
- package/demo/camel-chat-showcase.tsx +1265 -0
- package/demo/fixtures.ts +292 -0
- package/demo/message-panel-chat-showcase.tsx +250 -0
- package/demo/message-panel-gallery.tsx +389 -0
- package/package.json +6 -16
- package/src/adapters/ai-sdk.ts +32 -0
- package/src/camel/camel-chat-messages.tsx +1435 -0
- package/src/camel/camel-model-select.tsx +88 -0
- package/src/camel/camel-plan-bar.tsx +115 -0
- package/src/camel/camel-prompt-input.tsx +547 -0
- package/src/camel/camel-tool-presentation.tsx +1270 -0
- package/src/camel/camel-turn.ts +334 -0
- package/src/camel/turn-summary-bar.tsx +81 -0
- package/src/chat-summary-panel.tsx +229 -0
- package/src/composer/chat-composer.tsx +216 -0
- package/src/composer/composer-attachments.tsx +138 -0
- package/src/composer/composer-trigger-popover.tsx +236 -0
- package/src/composer/composer.tsx +425 -0
- package/src/composer/drop-controller.ts +80 -0
- package/src/composer/types.ts +79 -0
- package/src/contracts.ts +138 -0
- package/src/conversation.tsx +76 -0
- package/src/internal/cn.ts +3 -0
- package/src/message-panel.tsx +368 -0
- package/src/message.tsx +254 -0
- package/src/parts/index.tsx +1149 -0
- package/src/parts/kind.ts +67 -0
- package/src/parts/plan.ts +64 -0
- package/src/parts/primitives.tsx +439 -0
- package/src/registry.ts +44 -0
- package/src/status.tsx +132 -0
- package/dist/demo/camel-chat-showcase.d.ts +0 -1
- package/dist/demo/camel-chat-showcase.js +0 -1007
- package/dist/demo/fixtures.d.ts +0 -32
- package/dist/demo/fixtures.js +0 -250
- package/dist/demo/index.js +0 -4
- package/dist/demo/message-panel-chat-showcase.d.ts +0 -1
- package/dist/demo/message-panel-chat-showcase.js +0 -114
- package/dist/demo/message-panel-gallery.d.ts +0 -1
- package/dist/demo/message-panel-gallery.js +0 -130
- package/dist/src/adapters/ai-sdk.d.ts +0 -4
- package/dist/src/adapters/ai-sdk.js +0 -25
- package/dist/src/camel/camel-chat-messages.d.ts +0 -44
- package/dist/src/camel/camel-chat-messages.js +0 -421
- package/dist/src/camel/camel-model-select.d.ts +0 -13
- package/dist/src/camel/camel-model-select.js +0 -7
- package/dist/src/camel/camel-plan-bar.d.ts +0 -5
- package/dist/src/camel/camel-plan-bar.js +0 -24
- package/dist/src/camel/camel-prompt-input.d.ts +0 -32
- package/dist/src/camel/camel-prompt-input.js +0 -182
- package/dist/src/camel/camel-tool-presentation.d.ts +0 -21
- package/dist/src/camel/camel-tool-presentation.js +0 -784
- package/dist/src/camel/camel-turn.d.ts +0 -57
- package/dist/src/camel/camel-turn.js +0 -203
- package/dist/src/camel/index.js +0 -7
- package/dist/src/camel/turn-summary-bar.d.ts +0 -8
- package/dist/src/camel/turn-summary-bar.js +0 -12
- package/dist/src/chat-summary-panel.d.ts +0 -22
- package/dist/src/chat-summary-panel.js +0 -26
- package/dist/src/composer/chat-composer.d.ts +0 -4
- package/dist/src/composer/chat-composer.js +0 -90
- package/dist/src/composer/composer-attachments.d.ts +0 -13
- package/dist/src/composer/composer-attachments.js +0 -47
- package/dist/src/composer/composer-trigger-popover.d.ts +0 -31
- package/dist/src/composer/composer-trigger-popover.js +0 -29
- package/dist/src/composer/composer.d.ts +0 -15
- package/dist/src/composer/composer.js +0 -180
- package/dist/src/composer/drop-controller.d.ts +0 -8
- package/dist/src/composer/drop-controller.js +0 -69
- package/dist/src/composer/index.js +0 -6
- package/dist/src/composer/types.d.ts +0 -62
- package/dist/src/composer/types.js +0 -1
- package/dist/src/contracts.d.ts +0 -94
- package/dist/src/contracts.js +0 -1
- package/dist/src/conversation.d.ts +0 -13
- package/dist/src/conversation.js +0 -18
- package/dist/src/index.js +0 -12
- package/dist/src/internal/cn.d.ts +0 -1
- package/dist/src/internal/cn.js +0 -3
- package/dist/src/message-panel.d.ts +0 -4
- package/dist/src/message-panel.js +0 -144
- package/dist/src/message.d.ts +0 -36
- package/dist/src/message.js +0 -83
- package/dist/src/parts/index.d.ts +0 -187
- package/dist/src/parts/index.js +0 -332
- package/dist/src/parts/kind.d.ts +0 -3
- package/dist/src/parts/kind.js +0 -69
- package/dist/src/parts/plan.d.ts +0 -10
- package/dist/src/parts/plan.js +0 -40
- package/dist/src/parts/primitives.d.ts +0 -70
- package/dist/src/parts/primitives.js +0 -153
- package/dist/src/registry.d.ts +0 -4
- package/dist/src/registry.js +0 -31
- package/dist/src/status.d.ts +0 -26
- package/dist/src/status.js +0 -74
- /package/{dist/demo/index.d.ts → demo/index.ts} +0 -0
- /package/{dist/src/camel/index.d.ts → src/camel/index.ts} +0 -0
- /package/{dist/src/composer/index.d.ts → src/composer/index.ts} +0 -0
- /package/{dist/src/index.d.ts → src/index.ts} +0 -0
package/src/contracts.ts
ADDED
|
@@ -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,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;
|