Files
deerflow-code/frontend-web/src/components/workspace/messages/message-list.tsx
T
董宏凯andClaude Sonnet 4.6 69c8c14c91 fix(nav): 修复问答梳理等带子路径页面的导航高亮丢失问题
KnowledgeBasePage 加载后自动跳转至 /knowledge/:noteId,原精确路径匹配
导致顶栏和侧边栏选中态丢失。将 hasActiveChild / isItemActive /
menuItemContainsPath 等 8 处判断统一改为前缀匹配
(pathname === path || pathname.startsWith(path + "/")),
覆盖浅色侧边栏、深色侧边栏、顶部导航栏三套组件。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 20:50:26 +08:00

1131 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Message } from "@langchain/langgraph-sdk";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useStickToBottomContext } from "use-stick-to-bottom";
import {
Conversation,
ConversationContent,
} from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { rememberAskTimes } from "@/core/chat-timestamps";
import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
extractPresentFilesFromMessage,
extractTextFromMessage,
groupMessages,
hasContent,
hasPresentFiles,
hasReasoning,
hasToolCalls,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
} from "@/core/skill-display/runtime";
import type { Subtask } from "@/core/tasks";
import { useUpdateSubtask } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads";
import { cn } from "@/lib/utils";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { ArtifactFileList } from "../artifacts/artifact-file-list";
import { getQuestionAnchorId } from "../question-navigator";
import { StreamingIndicator } from "../streaming-indicator";
import { ReferenceBatchesContext, ThreadContext } from "./context";
import { MarkdownContent } from "./markdown-content";
import { MessageGroup } from "./message-group";
import { MessageListItem } from "./message-list-item";
import {
MessageMetricsProvider,
MessageTokenUsageList,
} from "./message-token-usage";
import { MessageListSkeleton } from "./skeleton";
import { SubtaskCard } from "./subtask-card";
export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160;
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
/** 用户发送新问题时强制滚到底部(即使用户此前上滑看过历史)。 */
function ScrollToBottomOnUserMessage({
threadId,
messages,
}: {
threadId: string;
messages: Message[];
}) {
const { scrollToBottom } = useStickToBottomContext();
const prevHumanCountRef = useRef<number | null>(null);
useEffect(() => {
prevHumanCountRef.current = null;
}, [threadId]);
useEffect(() => {
const humanCount = messages.reduce(
(count, message) => count + (message.type === "human" ? 1 : 0),
0,
);
const prev = prevHumanCountRef.current;
prevHumanCountRef.current = humanCount;
if (prev !== null && humanCount > prev) {
requestAnimationFrame(() => {
requestAnimationFrame(() => scrollToBottom());
});
}
}, [messages, scrollToBottom]);
return null;
}
export function MessageList({
className,
contentClassName,
fitMessageRowsToContent = false,
threadId,
thread,
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false,
extraRehypePlugins,
onClarificationSubmit,
onEditResubmit,
onSetupAgentApprove,
onSetupAgentReject,
onSetupWritingApprove,
onSetupWritingReject,
}: {
className?: string;
contentClassName?: string;
fitMessageRowsToContent?: boolean;
threadId: string;
thread: BaseStream<AgentThreadState>;
paddingBottom?: number;
tokenUsageEnabled?: boolean;
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
onEditResubmit?: (text: string) => void;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
[baseRehypePlugins, extraRehypePlugins],
);
const updateSubtask = useUpdateSubtask();
const { t } = useI18n();
const threadContext = useContext(ThreadContext);
// Read-only contexts (public share, mock previews) must not offer deletion.
const allowDelete = !threadContext?.publicShareCode && !threadContext?.isMock;
// Locally hide messages the user just deleted. The server-side checkpoint is
// trimmed immediately, but the live `useStream` cache still holds the message
// until the next refetch — filtering by id here makes the removal instant and
// keeps it hidden for the rest of the session (a reload reads the trimmed
// state). Reset whenever the active thread changes.
const [hiddenMessageIds, setHiddenMessageIds] = useState<Set<string>>(
() => new Set(),
);
useEffect(() => {
setHiddenMessageIds(new Set());
}, [threadId]);
const handleMessageDeleted = useCallback((deletedIds: string[]) => {
if (deletedIds.length === 0) return;
setHiddenMessageIds((prev) => {
const next = new Set(prev);
for (const id of deletedIds) next.add(id);
return next;
});
}, []);
const allMessages = thread.messages;
const messages = useMemo(
() =>
hiddenMessageIds.size === 0
? allMessages
: allMessages.filter((m) => !m.id || !hiddenMessageIds.has(m.id)),
[allMessages, hiddenMessageIds],
);
// Stamp a wall-clock time onto every question/answer the first time it is
// seen, in a single batched commit. Questions sent this session already carry
// an accurate time (see useThreadStream); this also covers assistant answers
// and messages loaded from history. First-write-wins keeps the earliest time.
useEffect(() => {
const now = new Date().toISOString();
const entries: Record<string, string> = {};
for (const message of messages) {
if (message.type !== "human" && message.type !== "ai") continue;
if (!message.id || message.id.startsWith("opt-")) continue;
if (typeof message.additional_kwargs?.client_ts === "string") continue;
entries[message.id] = now;
}
if (Object.keys(entries).length > 0) rememberAskTimes(entries);
}, [messages]);
const [settings] = useLocalSettings();
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
const { skills } = useSkills();
const skillDisplayMap = useMemo(
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills],
);
// Same per-turn reference batches the right-side panel renders (backend
// batches preferred). Inline [n] tokens must resolve against these so badge
// numbering matches the panel, and so citations still render when the
// frontend tool-message scan below comes up empty (e.g. after a reload).
const referenceBatches = useContext(ReferenceBatchesContext);
// Track which clarification messages are still awaiting an answer.
// Pending = clarification tool messages that appear after the last human message
// (no user reply has been sent for them yet).
const pendingClarificationIds = useMemo(() => {
let lastHumanIndex = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i]?.type === "human") {
lastHumanIndex = i;
break;
}
}
const ids: string[] = [];
for (let i = lastHumanIndex + 1; i < messages.length; i++) {
const msg = messages[i];
if (msg && msg.id && msg.type === "tool" && msg.name === "ask_clarification") {
ids.push(msg.id);
}
}
return ids;
}, [messages]);
const [clarificationSelections, setClarificationSelections] = useState<
Map<string, ClarificationSelection>
>(new Map());
const [submittingClarification, setSubmittingClarification] = useState(false);
const handleClarificationSelectionChange = useCallback(
(id: string, next: ClarificationSelection) => {
setClarificationSelections((prev) => {
const map = new Map(prev);
map.set(id, next);
return map;
});
},
[],
);
const handleSubmitAllClarifications = useCallback(async (
selectionOverrides?: Map<string, ClarificationSelection>,
) => {
if (pendingClarificationIds.length === 0 || !onClarificationSubmit) {
return;
}
const parts: string[] = [];
for (let index = 0; index < pendingClarificationIds.length; index++) {
const id = pendingClarificationIds[index]!;
const message = messages.find((msg) => msg.id === id);
const payload = message ? getClarificationPayload(message) : null;
const selection = selectionOverrides?.get(id) ?? clarificationSelections.get(id) ?? {
selectedIds: [],
custom: "",
};
const options = (payload?.options ?? []).filter(
(option) => typeof option.label === "string" && option.label.trim(),
);
const selectedLabels = selection.selectedIds
.map(
(sid) =>
options.find((option) => (option.id || option.label) === sid)?.label,
)
.filter((label): label is string => Boolean(label && label.trim()));
const trimmedCustom = selection.custom.trim();
let part = "";
if (selectedLabels.length > 0 && trimmedCustom) {
part = `我选择:${selectedLabels.join("、")};补充:${trimmedCustom}`;
} else if (selectedLabels.length > 0) {
part = `我选择:${selectedLabels.join("、")}`;
} else if (trimmedCustom) {
part = trimmedCustom;
} else {
continue;
}
if (pendingClarificationIds.length > 1) {
const question = payload?.question?.trim();
parts.push(
question
? `问题${index + 1}「${question}」:${part}`
: `问题${index + 1}:${part}`,
);
} else {
parts.push(part);
}
}
if (parts.length === 0) {
return;
}
const combined =
pendingClarificationIds.length > 1
? `针对你提出的多个问题,依次回答:\n${parts.map((p) => `- ${p}`).join("\n")}`
: parts[0]!;
setSubmittingClarification(true);
try {
await onClarificationSubmit(combined);
} finally {
setSubmittingClarification(false);
}
}, [pendingClarificationIds, messages, clarificationSelections, onClarificationSubmit]);
const totalPending = pendingClarificationIds.length;
const hasAnyClarificationAnswer = useMemo(() => {
return pendingClarificationIds.some((id) => {
const sel = clarificationSelections.get(id);
return (sel?.selectedIds.length ?? 0) > 0 || (sel?.custom.trim() ?? "").length > 0;
});
}, [pendingClarificationIds, clarificationSelections]);
if (thread.isThreadLoading && messages.length === 0) {
return <MessageListSkeleton />;
}
return (
<Conversation
className={cn("flex size-full flex-col justify-center", className)}
>
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
contentClassName,
)}
>
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => {
const isAssistantMessage =
ragModeEnabled &&
group.type === "assistant" &&
msg.type === "ai";
const batchSources = isAssistantMessage
? referenceBatches.find(
(batch) => batch.assistant_message_id === msg.id,
)?.sources ?? []
: [];
const displaySources = isAssistantMessage
? batchSources.length > 0
? batchSources
: collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id,
includeBeforeMessage: true,
})
: [];
return (
<MessageListItem
key={`${group.id}/${msg.id}`}
fitRowToContent={fitMessageRowsToContent}
anchorId={
msg.type === "human"
? getQuestionAnchorId(msg, messages.indexOf(msg))
: undefined
}
message={msg}
isLoading={thread.isLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
onEditResubmit={onEditResubmit}
onMessageDeleted={
allowDelete ? handleMessageDeleted : undefined
}
/>
);
});
} else if (group.type === "assistant:clarification") {
const message = group.messages[0];
if (message && hasContent(message)) {
const messageId = message.id ?? "";
const pendingIndex = pendingClarificationIds.indexOf(messageId);
const isPending = pendingIndex >= 0;
const isLastPending =
isPending && pendingIndex === totalPending - 1;
return (
<div key={group.id} className="w-full">
<ClarificationCard
fallbackContent={extractContentFromMessage(message)}
isLoading={thread.isLoading}
message={message}
rehypePlugins={rehypePlugins}
isPending={isPending}
pendingIndex={isPending ? pendingIndex : undefined}
pendingCount={isPending ? totalPending : undefined}
isLastPending={isLastPending}
selection={
clarificationSelections.get(messageId) ?? {
selectedIds: [],
custom: "",
}
}
onSelectionChange={(next) =>
handleClarificationSelectionChange(messageId, next)
}
submitting={submittingClarification}
canSubmit={hasAnyClarificationAnswer}
onSubmitAll={(override) => {
const overrides = override
? new Map([[messageId, override]])
: undefined;
void handleSubmitAllClarifications(overrides);
}}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
messages={group.messages}
/>
</div>
);
}
return null;
} else if (group.type === "assistant:setup-agent-approval") {
const message = group.messages[0];
return message ? (
<div key={group.id} className="w-full">
<SetupAgentApprovalCard
disabled={thread.isLoading}
message={message}
onApprove={onSetupAgentApprove}
onReject={onSetupAgentReject}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
messages={group.messages}
/>
</div>
) : null;
} else if (group.type === "assistant:setup-writing-approval") {
const message = group.messages[0];
return message ? (
<div key={group.id} className="w-full">
<SetupWritingApprovalCard
disabled={thread.isLoading}
message={message}
onApprove={onSetupWritingApprove}
onReject={onSetupWritingReject}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
messages={group.messages}
/>
</div>
) : null;
} else if (group.type === "assistant:present-files") {
const files: string[] = [];
for (const message of group.messages) {
if (hasPresentFiles(message)) {
const presentFiles = extractPresentFilesFromMessage(message);
files.push(...presentFiles);
}
}
return (
<div className="w-full" key={group.id}>
{group.messages[0] && hasContent(group.messages[0]) && (
<MarkdownContent
content={extractContentFromMessage(group.messages[0])}
isLoading={thread.isLoading}
rehypePlugins={rehypePlugins}
className="mb-4"
/>
)}
<ArtifactFileList files={files} threadId={threadId} />
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
messages={group.messages}
/>
</div>
);
} else if (group.type === "assistant:subagent") {
const tasks = new Set<Subtask>();
for (const message of group.messages) {
if (message.type === "ai") {
for (const toolCall of message.tool_calls ?? []) {
if (toolCall.name === "task") {
const task: Subtask = {
id: toolCall.id!,
subagent_type: toolCall.args.subagent_type,
description: toolCall.args.description,
prompt: toolCall.args.prompt,
status: "in_progress",
};
updateSubtask(task);
tasks.add(task);
}
}
} else if (message.type === "tool") {
const taskId = message.tool_call_id;
if (taskId) {
const result = extractTextFromMessage(message);
if (result.startsWith("Task Succeeded. Result:")) {
updateSubtask({
id: taskId,
status: "completed",
result: result
.split("Task Succeeded. Result:")[1]
?.trim(),
});
} else if (result.startsWith("Task failed.")) {
updateSubtask({
id: taskId,
status: "failed",
error: result.split("Task failed.")[1]?.trim(),
});
} else if (result.startsWith("Task timed out")) {
updateSubtask({
id: taskId,
status: "failed",
error: result,
});
} else {
updateSubtask({
id: taskId,
status: "in_progress",
});
}
}
}
}
const results: React.ReactNode[] = [];
for (const message of group.messages.filter(
(message) => message.type === "ai",
)) {
if (hasReasoning(message)) {
results.push(
<MessageGroup
key={"thinking-group-" + message.id}
messages={[message]}
isLoading={thread.isLoading}
/>,
);
}
results.push(
<div
key="subtask-count"
className="text-muted-foreground pt-2 text-sm font-normal"
>
{t.subtasks.executing(tasks.size)}
</div>,
);
const taskIds = message.tool_calls
?.filter((toolCall) => toolCall.name === "task")
.map((toolCall) => toolCall.id);
for (const taskId of taskIds ?? []) {
results.push(
<SubtaskCard
key={"task-group-" + taskId}
taskId={taskId!}
isLoading={thread.isLoading}
/>,
);
}
}
return (
<div
key={"subtask-group-" + group.id}
className="relative z-1 flex flex-col gap-2"
>
{results}
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
messages={group.messages}
/>
</div>
);
}
const tokenUsageMessages = group.messages.filter(
(message) =>
message.type === "ai" &&
(hasToolCalls(message) ? true : !hasContent(message)),
);
return (
<div key={"group-" + group.id} className="w-full">
<MessageGroup
messages={group.messages}
isLoading={thread.isLoading}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
messages={tokenUsageMessages}
/>
</div>
);
})}
{thread.isLoading && <StreamingIndicator className="my-4" />}
<div style={{ height: `${paddingBottom}px` }} />
</MessageMetricsProvider>
</ConversationContent>
</Conversation>
);
}
type ClarificationOption = {
id: string;
label: string;
description?: string;
};
type ClarificationPayload = {
question?: string;
context?: string | null;
clarification_type?: string;
options?: ClarificationOption[];
allow_custom?: boolean;
allow_multiple?: boolean;
};
type ClarificationSelection = {
selectedIds: string[];
custom: string;
};
export type SetupAgentApprovalPayload = {
name?: string;
description?: string;
soul?: string;
skills?: string[];
};
export type SetupWritingApprovalPayload = {
user_intent?: string;
article_type?: string;
word_count_target?: number | null;
target_audience?: string;
writing_mode?: string;
material_source?: string;
outline?: string;
};
function findPreviousHumanMessageId(
messages: Message[],
beforeMessageId?: string,
): string | undefined {
const beforeIndex =
beforeMessageId === undefined
? messages.length
: messages.findIndex((message) => message.id === beforeMessageId);
const end = beforeIndex < 0 ? messages.length : beforeIndex;
for (let index = end - 1; index >= 0; index--) {
const message = messages[index];
if (message?.type === "human") {
return message.id;
}
}
return undefined;
}
function getSetupAgentApprovalPayload(
message: unknown,
): SetupAgentApprovalPayload | null {
if (typeof message !== "object" || message === null) {
return null;
}
const additionalKwargs = Reflect.get(message, "additional_kwargs");
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
return null;
}
const approval = Reflect.get(additionalKwargs, "setup_agent_approval");
if (typeof approval !== "object" || approval === null) {
return null;
}
return approval as SetupAgentApprovalPayload;
}
function SetupAgentApprovalCard({
disabled,
message,
onApprove,
onReject,
}: {
disabled: boolean;
message: unknown;
onApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
}) {
const payload = getSetupAgentApprovalPayload(message);
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(
null,
);
if (!payload) {
return null;
}
const approvalPayload = payload;
const busy = disabled || submitting !== null;
const skills = approvalPayload.skills ?? [];
async function run(action: "approve" | "reject") {
if (busy) {
return;
}
setSubmitting(action);
try {
if (action === "approve") {
await onApprove?.(approvalPayload);
} else {
await onReject?.(approvalPayload);
}
} finally {
setSubmitting(null);
}
}
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-3">
<div>
<div className="text-muted-foreground text-xs font-medium">
创建智能体审批
</div>
<div className="text-sm font-medium">请确认是否写入这个智能体配置。</div>
</div>
{approvalPayload.name ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">名称</div>
<p className="text-sm font-medium">{approvalPayload.name}</p>
</div>
) : null}
{approvalPayload.description ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">描述</div>
<p className="text-sm leading-6">{approvalPayload.description}</p>
</div>
) : null}
{skills.length > 0 ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">技能</div>
<div className="flex flex-wrap gap-1.5">
{skills.map((skill) => (
<span
key={skill}
className="bg-secondary text-secondary-foreground rounded-md px-2 py-1 text-xs"
>
{skill}
</span>
))}
</div>
</div>
) : null}
{approvalPayload.soul ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">SOUL.md</div>
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
{approvalPayload.soul}
</pre>
</div>
) : null}
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={busy || !onReject}
onClick={() => void run("reject")}
>
<XIcon className="h-4 w-4" />
拒绝
</Button>
<Button
type="button"
size="sm"
disabled={busy || !onApprove}
onClick={() => void run("approve")}
>
<CheckIcon className="h-4 w-4" />
批准创建
</Button>
</div>
</div>
);
}
function getSetupWritingApprovalPayload(
message: unknown,
): SetupWritingApprovalPayload | null {
if (typeof message !== "object" || message === null) {
return null;
}
const additionalKwargs = Reflect.get(message, "additional_kwargs");
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
return null;
}
const approval = Reflect.get(additionalKwargs, "setup_writing_approval");
if (typeof approval !== "object" || approval === null) {
return null;
}
return approval as SetupWritingApprovalPayload;
}
const WRITING_MODE_LABELS: Record<string, string> = {
strict: "严格依据素材",
loose: "可结合通用知识",
};
const MATERIAL_SOURCE_LABELS: Record<string, string> = {
general: "通用检索",
notebook: "我的空间",
// 旧值(历史会话回显兼容)
knowledge_base: "知识库",
skill: "技能检索",
};
function SetupWritingApprovalCard({
disabled,
message,
onApprove,
onReject,
}: {
disabled: boolean;
message: unknown;
onApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
}) {
const payload = getSetupWritingApprovalPayload(message);
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(
null,
);
if (!payload) {
return null;
}
const approvalPayload = payload;
const busy = disabled || submitting !== null;
async function run(action: "approve" | "reject") {
if (busy) {
return;
}
setSubmitting(action);
try {
if (action === "approve") {
await onApprove?.(approvalPayload);
} else {
await onReject?.(approvalPayload);
}
} finally {
setSubmitting(null);
}
}
const rows: Array<{ label: string; value: string }> = [];
if (approvalPayload.article_type) {
rows.push({ label: "文章类型", value: approvalPayload.article_type });
}
if (typeof approvalPayload.word_count_target === "number") {
rows.push({ label: "目标字数", value: `${approvalPayload.word_count_target} 字` });
}
if (approvalPayload.target_audience) {
rows.push({ label: "目标读者", value: approvalPayload.target_audience });
}
if (approvalPayload.writing_mode) {
rows.push({
label: "写作模式",
value: WRITING_MODE_LABELS[approvalPayload.writing_mode] ?? approvalPayload.writing_mode,
});
}
if (approvalPayload.material_source) {
rows.push({
label: "素材来源",
value: MATERIAL_SOURCE_LABELS[approvalPayload.material_source] ?? approvalPayload.material_source,
});
}
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-3">
<div>
<div className="text-muted-foreground text-xs font-medium">写作配置建议</div>
<div className="text-sm font-medium">确认后将应用到左侧写作表单。</div>
</div>
{approvalPayload.user_intent ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">写作意图</div>
<p className="text-sm leading-6">{approvalPayload.user_intent}</p>
</div>
) : null}
{rows.length > 0 ? (
<div className="flex flex-wrap gap-x-6 gap-y-1.5">
{rows.map((row) => (
<div key={row.label} className="space-y-0.5">
<div className="text-muted-foreground text-xs">{row.label}</div>
<p className="text-sm font-medium">{row.value}</p>
</div>
))}
</div>
) : null}
{approvalPayload.outline ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">已有大纲</div>
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
{approvalPayload.outline}
</pre>
</div>
) : null}
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={busy || !onReject}
onClick={() => void run("reject")}
>
<XIcon className="h-4 w-4" />
忽略
</Button>
<Button
type="button"
size="sm"
disabled={busy || !onApprove}
onClick={() => void run("approve")}
>
<CheckIcon className="h-4 w-4" />
应用到表单
</Button>
</div>
</div>
);
}
function getClarificationPayload(message: unknown): ClarificationPayload | null {
if (typeof message !== "object" || message === null) {
return null;
}
const additionalKwargs = Reflect.get(message, "additional_kwargs");
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
return null;
}
const clarification = Reflect.get(additionalKwargs, "clarification");
if (typeof clarification !== "object" || clarification === null) {
return null;
}
return clarification as ClarificationPayload;
}
function ClarificationCard({
fallbackContent,
isLoading,
message,
rehypePlugins,
isPending,
pendingIndex,
pendingCount,
isLastPending,
selection,
onSelectionChange,
submitting,
canSubmit,
onSubmitAll,
}: {
fallbackContent: string;
isLoading: boolean;
message: unknown;
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
isPending: boolean;
pendingIndex?: number;
pendingCount?: number;
isLastPending: boolean;
selection: ClarificationSelection;
onSelectionChange: (next: ClarificationSelection) => void;
submitting: boolean;
canSubmit: boolean;
onSubmitAll: (override?: ClarificationSelection) => void | Promise<void>;
}) {
const payload = getClarificationPayload(message);
const options = useMemo(
() =>
(payload?.options ?? []).filter(
(option) => typeof option.label === "string" && option.label.trim(),
),
[payload?.options],
);
if (!payload) {
return (
<MarkdownContent
content={fallbackContent}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
/>
);
}
const disabled = !isPending || submitting;
const multiPending = (pendingCount ?? 0) > 1;
const allowMultiple = resolveAllowMultiple(
payload.allow_multiple,
payload.clarification_type,
);
function handleOptionClick(optionId: string) {
if (disabled) {
return;
}
if (!allowMultiple) {
// 单选:仅当「只有这一张待答澄清卡」时才点击即发送。多张澄清卡同时出现时,
// 点击只记录本卡选择,待最后一张的「全部发送」统一提交——否则点了第一张就立刻
// 提交整批,后面的卡片再也无法选择(见 bug:选了第一个,第二个不让选)。
const next = { selectedIds: [optionId], custom: selection.custom };
onSelectionChange(next);
if (!multiPending) {
void onSubmitAll(next);
}
return;
}
const next = selection.selectedIds.includes(optionId)
? selection.selectedIds.filter((id) => id !== optionId)
: [...selection.selectedIds, optionId];
onSelectionChange({ ...selection, selectedIds: next });
}
function updateCustom(value: string) {
if (disabled) {
return;
}
onSelectionChange({ ...selection, custom: value });
}
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-2">
<div className="text-muted-foreground flex items-center gap-2 text-xs font-medium">
<span>需要你的确认</span>
{multiPending && pendingIndex !== undefined && pendingCount ? (
<span className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-[10px]">
{pendingIndex + 1} / {pendingCount}
</span>
) : null}
</div>
{payload.context ? (
<p className="text-muted-foreground text-sm leading-6">
{payload.context}
</p>
) : null}
<div className="text-sm leading-6 font-medium">
{payload.question || fallbackContent}
</div>
</div>
{options.length > 0 ? (
<div className="mt-4 grid gap-2">
<div className="text-muted-foreground text-xs">
{allowMultiple
? "可多选(再次点击取消选择)"
: multiPending
? "请选择一项"
: "请选择一项(点击即发送)"}
</div>
{options.map((option) => {
const optionId = option.id || option.label;
const isSelected = selection.selectedIds.includes(optionId);
return (
<Button
key={optionId}
type="button"
variant={isSelected && (allowMultiple || multiPending) ? "default" : "outline"}
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
disabled={disabled}
onClick={() => handleOptionClick(optionId)}
>
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
<span>{option.label}</span>
{option.description ? (
<span
className={cn(
"text-xs font-normal leading-5",
isSelected
? "text-primary-foreground/80"
: "text-muted-foreground",
)}
>
{option.description}
</span>
) : null}
</span>
{isSelected && (allowMultiple || multiPending) ? (
<CheckIcon className="ml-2 h-4 w-4 shrink-0" />
) : null}
</Button>
);
})}
</div>
) : null}
{payload.allow_custom !== false ? (
<div className="mt-4 flex flex-col gap-2">
<Textarea
value={selection.custom}
onChange={(event) => updateCustom(event.currentTarget.value)}
placeholder={
options.length > 0
? "可选:补充说明(与所选项一起发送),或在此填写自定义回答"
: "如果以上选项都不合适,可以在这里输入你的回答"
}
disabled={disabled}
className="min-h-20 resize-none"
/>
</div>
) : null}
{isPending && (allowMultiple || multiPending || payload.allow_custom !== false) ? (
isLastPending ? (
<div className="mt-3 flex items-center justify-end gap-2">
{multiPending ? (
<span className="text-muted-foreground text-xs">
将一起提交以上 {pendingCount} 个问题的回答
</span>
) : null}
<Button
type="button"
size="sm"
disabled={!canSubmit || submitting}
onClick={() => void onSubmitAll()}
>
<SendIcon className="h-4 w-4" />
{submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"}
</Button>
</div>
) : allowMultiple || multiPending ? (
<div className="text-muted-foreground mt-3 text-right text-xs">
选择后等待最后一题统一提交
</div>
) : null
) : null}
</div>
);
}