import type { Message } from "@langchain/langgraph-sdk"; import type { BaseStream } from "@langchain/langgraph-sdk/react"; import { CheckIcon, PenLineIcon, SendIcon, XIcon } from "lucide-react"; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { useStickToBottomContext } from "use-stick-to-bottom"; import { Conversation, ConversationContent, ConversationScrollButton, } from "@/components/ai-elements/conversation"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps"; import { useI18n } from "@/core/i18n/hooks"; import { extractContentFromMessage, extractPresentFilesFromMessage, extractTextFromMessage, groupMessages, hasContent, hasPresentFiles, hasReasoning, hasToolCalls, isHiddenFromUIMessage, } 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(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, scrollButtonClassName, onClarificationSubmit, onEditResubmit, onEditSave, messageContentOverrides, externallyHiddenMessageIds, onSetupAgentApprove, onSetupAgentReject, onSetupWritingApprove, onSetupWritingReject, onSetupWritingStart, onOpenArtifact, }: { className?: string; contentClassName?: string; fitMessageRowsToContent?: boolean; threadId: string; thread: BaseStream; paddingBottom?: number; tokenUsageEnabled?: boolean; /** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */ scrollButtonClassName?: string; extraRehypePlugins?: ReturnType; onClarificationSubmit?: (answer: string) => void | Promise; onEditResubmit?: (text: string) => void; onEditSave?: (messageId: string, text: string) => void | Promise; messageContentOverrides?: Record; externallyHiddenMessageIds?: Set; onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise; onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise; onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise; onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise; /** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */ onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise; onOpenArtifact?: (filepath: string) => 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>( () => 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 = useMemo(() => { const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0; return thread.messages .filter((m) => !isHiddenFromUIMessage(m)) .map((message) => { const override = hasOverrides && message.id ? messageContentOverrides?.[message.id] : undefined; return override === undefined ? message : ({ ...message, content: override } as Message); }); }, [messageContentOverrides, thread.messages]); const messages = useMemo( () => { const externalHidden = externallyHiddenMessageIds ?? new Set(); return hiddenMessageIds.size === 0 && externalHidden.size === 0 ? allMessages : allMessages.filter( (m) => !m.id || (!hiddenMessageIds.has(m.id) && !externalHidden.has(m.id)), ); }, [allMessages, externallyHiddenMessageIds, hiddenMessageIds], ); const askTimes = useChatAskTimes(); // Stamp a wall-clock time onto every *question* the first time it is seen, in // a single batched commit. Questions sent this session already carry an // accurate `client_ts` (see useThreadStream) and are skipped; this only backs // up legacy questions loaded from history that predate `client_ts`. // First-write-wins keeps the earliest time. // // AI answers are deliberately NOT stamped here: they carry no timestamp of // their own, so a `now` stamp would be the *load* time, not the answer time — // and for an unfinished/interrupted turn that re-stamps to the current clock // on every reload. Instead the answer inherits its question's durable time // via `turnTimes` below. useEffect(() => { const now = new Date().toISOString(); const entries: Record = {}; for (const message of messages) { if (message.type !== "human") 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]); // Durable ask time per message id, used to time-stamp the AI answer (which has // no timestamp of its own) with the time of the question that triggered it. const turnTimes = useMemo(() => { const map = new Map(); let lastQuestionTime: string | undefined; for (const message of messages) { if (message.type === "human") { const ts = message.additional_kwargs?.client_ts; const fromAskTimes = message.id ? askTimes[message.id] : undefined; lastQuestionTime = typeof ts === "string" && ts ? ts : fromAskTimes; } else if (message.type === "ai" && message.id && lastQuestionTime) { map.set(message.id, lastQuestionTime); } } return map; }, [messages, askTimes]); 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 >(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, ) => { 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 ; } return ( {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 ( ); }); } 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 (
handleClarificationSelectionChange(messageId, next) } submitting={submittingClarification} canSubmit={hasAnyClarificationAnswer} onSubmitAll={(override) => { const overrides = override ? new Map([[messageId, override]]) : undefined; void handleSubmitAllClarifications(overrides); }} />
); } return null; } else if (group.type === "assistant:setup-agent-approval") { const message = group.messages[0]; return message ? (
) : null; } else if (group.type === "assistant:setup-writing-approval") { const message = group.messages[0]; return message ? (
) : 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 (
{group.messages[0] && hasContent(group.messages[0]) && ( )}
); } else if (group.type === "assistant:subagent") { const tasks = new Set(); 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( , ); } results.push(
{t.subtasks.executing(tasks.size)}
, ); const taskIds = message.tool_calls ?.filter((toolCall) => toolCall.name === "task") .map((toolCall) => toolCall.id); for (const taskId of taskIds ?? []) { results.push( , ); } } return (
{results}
); } const tokenUsageMessages = group.messages.filter( (message) => message.type === "ai" && (hasToolCalls(message) ? true : !hasContent(message)), ); return (
); })} {thread.isLoading && }
{/* ChatGPT 式「回到最新」悬浮钮:仅在向上滚离底部时出现,点击平滑回到 最新问答。默认贴底,调用方可用 scrollButtonClassName 上移避开输入框。 */} ); } 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; /** 当 article_type 是已有类型库里没有的新类型时,模型识别出的一句话特征说明(命中已有类型时为空)。 */ article_type_detail?: 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; onReject?: (payload: SetupAgentApprovalPayload) => void | Promise; }) { 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 (
创建智能体审批
请确认是否写入这个智能体配置。
{approvalPayload.name ? (
名称

{approvalPayload.name}

) : null} {approvalPayload.description ? (
描述

{approvalPayload.description}

) : null} {skills.length > 0 ? (
技能
{skills.map((skill) => ( {skill} ))}
) : null} {approvalPayload.soul ? (
SOUL.md
              {approvalPayload.soul}
            
) : null}
); } 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 = { strict: "严格依据素材", loose: "可结合通用知识", }; const MATERIAL_SOURCE_LABELS: Record = { general: "通用检索", notebook: "我的空间", // 旧值(历史会话回显兼容) knowledge_base: "知识库", skill: "技能检索", }; function SetupWritingApprovalCard({ disabled, message, onApprove, onReject, onStart, }: { disabled: boolean; message: unknown; onApprove?: (payload: SetupWritingApprovalPayload) => void | Promise; onReject?: (payload: SetupWritingApprovalPayload) => void | Promise; onStart?: (payload: SetupWritingApprovalPayload) => void | Promise; }) { const payload = getSetupWritingApprovalPayload(message); const [submitting, setSubmitting] = useState<"approve" | "reject" | "start" | null>( null, ); if (!payload) { return null; } const approvalPayload = payload; const busy = disabled || submitting !== null; async function run(action: "approve" | "reject" | "start") { if (busy) { return; } setSubmitting(action); try { if (action === "approve") { await onApprove?.(approvalPayload); } else if (action === "start") { await onStart?.(approvalPayload); } else { await onReject?.(approvalPayload); } } finally { setSubmitting(null); } } const hasNewType = !!approvalPayload.article_type_detail?.trim(); const rows: Array<{ label: string; value: string }> = []; if (approvalPayload.article_type) { rows.push({ label: "文章类型", // 新类型(列表外)标注「· 新」,让用户知道这是模型新识别的类型。 value: hasNewType ? `${approvalPayload.article_type} · 新` : 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 (
写作配置建议
确认后将应用到左侧写作表单。
{approvalPayload.user_intent ? (
写作意图

{approvalPayload.user_intent}

) : null} {rows.length > 0 ? (
{rows.map((row) => (
{row.label}

{row.value}

))}
) : null} {hasNewType ? (
新类型说明

{approvalPayload.article_type_detail}

) : null} {approvalPayload.outline ? (
已有大纲
              {approvalPayload.outline}
            
) : null}
{onStart ? ( ) : null}
); } 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; isPending: boolean; pendingIndex?: number; pendingCount?: number; isLastPending: boolean; selection: ClarificationSelection; onSelectionChange: (next: ClarificationSelection) => void; submitting: boolean; canSubmit: boolean; onSubmitAll: (override?: ClarificationSelection) => void | Promise; }) { const payload = getClarificationPayload(message); const options = useMemo( () => (payload?.options ?? []).filter( (option) => typeof option.label === "string" && option.label.trim(), ), [payload?.options], ); if (!payload) { return ( ); } 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 (
需要你的确认 {multiPending && pendingIndex !== undefined && pendingCount ? ( {pendingIndex + 1} / {pendingCount} ) : null}
{payload.context ? (

{payload.context}

) : null}
{payload.question || fallbackContent}
{options.length > 0 ? (
{allowMultiple ? "可多选(再次点击取消选择)" : multiPending ? "请选择一项" : "请选择一项(点击即发送)"}
{options.map((option) => { const optionId = option.id || option.label; const isSelected = selection.selectedIds.includes(optionId); return ( ); })}
) : null} {payload.allow_custom !== false ? (