import type { ComponentProps, Dispatch, Ref, SetStateAction } from "react";
import type { PersonaState } from "@/shared/ai-elements/persona";
import type { StatusTone } from "@/shared/ui/status";
import type { AgentChatSessionSummary, AgentInputDelivery, AgentResponseInput } from "../api/client";
import type { AgentApprovalMode, AgentChatMessage, AgentModelOption, AgentPermissionReply, AgentPermissionRequest, AgentQuestionRequest, AgentStreamRenderState, AgentUiResult } from "../types";
import type { AgentSpeakOptions, AgentSpeechState } from "../speech";
import type { AnswerUiMapTarget } from "../answer-ui/answer-ui-view";
import type { AnswerUiSelection } from "../answer-ui/answer-ui-interaction-context";
import type { AnswerViewStateScope } from "../session-state/answer-view-state-scope";
import { AgentInteractionReply, AgentInteractionRequests } from "./agent-interaction-request-card";
import { canReplaceInteractionRequest } from "../api/interaction-requests";
import type { InteractionRequestsController } from "../hooks/use-interaction-requests";
import { AnswerViewStateProvider } from "../session-state/answer-view-state-context";
import { AnswerPartsView } from "../answer-ui/answer-parts-view";
import { ExpandableReading } from "@/shared/ui/reading-workspace";
import { canExpandAnswer, getAnswerPartsTranscript, getAnswerPresentation } from "../answer-ui/answer-presentation";
import { AgentProcessDisclosure } from "./agent-process-disclosure";
import { AgentMessageProcessContent } from "./agent-message-process-content";
import { groupMessageProcesses, hasPendingMessageInput } from "../presentation/message-process-groups";
import { AgentUserMessageText } from "./agent-user-message-text";
import { useAgentAnswerTransition } from "../hooks/use-agent-answer-transition";
import { copySelectedText } from "@/shared/ai-elements/paragraph-copy";
import { ArrowUp, ChevronLeft, Eye, History, Plus, Quote, Square, X } from "lucide-react";
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useStickToBottomContext } from "use-stick-to-bottom";
import { showMapNotice } from "@/features/map/core";
import { Agent, AgentContent } from "@/shared/ai-elements/agent";
import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButton } from "@/shared/ai-elements/conversation";
import { Message, MessageContent } from "@/shared/ai-elements/message";
import { PromptInput, PromptInputBody, PromptInputFooter, PromptInputSubmit, PromptInputTextarea } from "@/shared/ai-elements/prompt-input";
import { Suggestion, Suggestions } from "@/shared/ai-elements/suggestion";
import { Button } from "@/shared/ui/button";
import { Separator } from "@/shared/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip";
import { cn } from "@/shared/ui/cn";
import { StatusDot } from "@/shared/ui/status";
import { AgentChatMessageActions, AgentChatMessageHeader, AgentChatMessageToolbar } from "./agent-chat-message-chrome";
import { agentLayoutTransition, agentPanelItemVariants, agentPanelListVariants } from "./agent-motion";
import { AgentPersona } from "./agent-persona";
import { AgentTopicControls } from "./agent-topic-controls";
import { AgentHistoryPanel } from "./agent-history-panel";
import { AgentMessageInbox } from "./agent-message-inbox";
import { AgentPendingInputs } from "./agent-pending-inputs";
import { AgentMessageDetails } from "./agent-message-details";
import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer";
import { useAgentSpeechRecognition, useAgentSpeechSynthesis } from "../hooks/use-agent-voice";
import { stripSpeechMarkdown } from "../speech";
import type { AgentConnectionStatus } from "../types";
import { VoiceInputButton, InputDeliverySelect, ApprovalModeSelect, AgentModelSelect } from "./agent-panel-controls";
import { AgentSpeechMessage, AgentMessageSpeechControls } from "./agent-message-speech";
export type AgentCommandPanelPresentation = "desktop-dock" | "desktop-floating" | "mobile-sheet";
export type AgentWorkspaceContextItem = {
id: string;
label: string;
value: string;
tone?: "neutral" | "info" | "warning";
};
type AgentCommandPanelProps = {
presentation?: AgentCommandPanelPresentation;
collapsing?: boolean;
personaState?: PersonaState;
sessionTitle?: string;
sessionHistory?: AgentChatSessionSummary[];
sessionHistoryLoading?: boolean;
historyQuery?: string;
historyHasMore?: boolean;
historyLoadingMore?: boolean;
historyError?: string;
historyPendingUpdate?: boolean;
onHistoryQueryChange?: (value: string) => void;
onHistoryOpen?: () => Promise | void;
onHistoryClose?: () => void;
onLoadMoreHistory?: () => Promise | void;
hasOlderMessages?: boolean;
loadingOlderMessages?: boolean;
onLoadOlderMessages?: () => Promise | void;
backgroundStatus?: string;
readingPosition?: { messageId?: string; offset: number; atBottom: boolean };
onReadingPositionChange?: (position: { messageId?: string; offset: number; atBottom: boolean }) => void;
promptSuggestions?: Array<{ question: string; sourceLabel?: string }>;
promptSuggestionsLoading?: boolean;
promptSuggestionsUnavailable?: boolean;
onRetryPromptSuggestions?: () => void;
activeSessionId?: string | null;
answerStateScope?: AnswerViewStateScope;
qaOnly?: boolean;
executionHistory?: boolean;
statusLabel?: string;
connectionStatus?: AgentConnectionStatus;
streaming?: boolean;
inputDelivery?: AgentInputDelivery;
pendingInputs?: AgentResponseInput[];
onInputDeliveryChange?: (delivery: AgentInputDelivery) => void;
onPendingInputDeliveryChange?: (id: string, delivery: AgentInputDelivery) => Promise | void;
onPendingInputEdit?: (id: string) => Promise | void;
onPendingInputCancel?: (id: string) => Promise | void;
onDownloadFile?: (path: string) => Promise | void;
messages?: AgentChatMessage[];
streamRenderState?: AgentStreamRenderState;
modelOptions?: AgentModelOption[];
selectedModel?: string;
currentProjectName?: string | null;
topicControls?: ComponentProps;
historyTopicId?: string;
onHistoryTopicChange?: (id: string | undefined) => void;
onMoveSessionToTopic?: (sessionId: string, topicId: string | null) => Promise;
approvalMode?: AgentApprovalMode;
uiResults?: AgentUiResult[];
interactionRequests?: InteractionRequestsController;
messageInbox?: ComponentProps;
workspaceContext?: AgentWorkspaceContextItem[];
prompt: string;
promptFocusRequest?: number;
promptReference?: { title: string; detail?: string; placeholder?: string };
contentReference?: AnswerUiSelection;
onContentReferenceChange?: (selection: AnswerUiSelection | undefined) => void;
onCollapse: () => void;
onPromptChange: Dispatch>;
onRefreshHistory?: () => Promise | void;
onStartNewSession?: () => Promise | void;
onLoadHistorySession?: (sessionId: string) => Promise | void;
onRenameHistorySession?: (sessionId: string, title: string) => Promise | void;
onDeleteHistorySession?: (sessionId: string) => Promise | void;
onSelectModel?: (model: string) => void;
onApprovalModeChange?: (mode: AgentApprovalMode) => void;
onSubmitPrompt: (prompt: string) => Promise | void;
onLocateAnswerFeatures?: (target: AnswerUiMapTarget) => Promise | void;
onStopPrompt?: () => void;
onReplyPermission?: (
permission: AgentPermissionRequest,
reply: AgentPermissionReply
) => Promise | void;
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise | void;
};
export function AgentCommandPanel({
presentation = "desktop-dock",
collapsing = false,
personaState,
sessionTitle = "新对话",
sessionHistory = [],
sessionHistoryLoading = false,
historyQuery, historyHasMore, historyLoadingMore, historyError, historyPendingUpdate,
onHistoryQueryChange, onHistoryOpen, onHistoryClose, onLoadMoreHistory,
hasOlderMessages, loadingOlderMessages, onLoadOlderMessages, backgroundStatus,
readingPosition, onReadingPositionChange,
promptSuggestions = [],
promptSuggestionsLoading = false,
promptSuggestionsUnavailable = false,
onRetryPromptSuggestions,
activeSessionId,
answerStateScope,
qaOnly = false,
executionHistory = false,
connectionStatus = "connecting",
streaming = false,
inputDelivery = "steer",
pendingInputs = [],
onInputDeliveryChange,
onPendingInputDeliveryChange,
onPendingInputEdit,
onPendingInputCancel,
onDownloadFile,
messages = [],
streamRenderState = {},
modelOptions = [],
selectedModel = "",
currentProjectName,
topicControls, historyTopicId, onHistoryTopicChange, onMoveSessionToTopic,
approvalMode = "request",
uiResults = [],
interactionRequests,
messageInbox,
prompt,
promptFocusRequest = 0,
promptReference,
contentReference,
onContentReferenceChange,
onCollapse,
onPromptChange,
onRefreshHistory,
onStartNewSession,
onLoadHistorySession,
onRenameHistorySession,
onDeleteHistorySession,
onSelectModel,
onApprovalModeChange,
onSubmitPrompt,
onLocateAnswerFeatures,
onStopPrompt,
onReplyPermission,
onReplyQuestion,
onRejectQuestion
}: AgentCommandPanelProps) {
const [historyOpen, setHistoryOpen] = useState(false);
const [historySelectionError, setHistorySelectionError] = useState();
const [loadingSessionId, setLoadingSessionId] = useState(null);
const [scrollRequestId, setScrollRequestId] = useState(0);
const [submissionError, setSubmissionError] = useState(null);
const prefersReducedMotion = useReducedMotion();
const historyTriggerRef = useRef(null);
const promptRestoreGenerationRef = useRef(0);
const promptTextareaRef = useRef(null);
const mountedRef = useRef(true);
const desktopFloating = presentation === "desktop-floating";
const floatingPresentation = desktopFloating;
const hasConversation = messages.length > 0 || streaming || Boolean(messageInbox?.messages.length);
// Task discussions keep their reference and composer layout before the first message.
const centeredComposer = !hasConversation && !loadingSessionId && !promptReference
&& uiResults.length === 0 && !messageInbox?.loading && !messageInbox?.error;
const showStartSuggestions = centeredComposer && !qaOnly && !promptReference;
const trimmedPrompt = prompt.trim();
const isStopAction = streaming && !trimmedPrompt;
const acceptsAdditionalInput = streaming || Boolean(interactionRequests?.awaitingInput);
useEffect(() => {
if (!promptFocusRequest) return;
const frame = requestAnimationFrame(() => {
const textarea = promptTextareaRef.current;
textarea?.focus();
textarea?.setSelectionRange(textarea.value.length, textarea.value.length);
});
return () => cancelAnimationFrame(frame);
}, [promptFocusRequest]);
const {
speechState,
speakingMessageId,
speak,
pause: pauseSpeech,
resume: resumeSpeech,
stop: stopSpeech,
isSupported: isSpeechSupported
} = useAgentSpeechSynthesis((message) => {
showMapNotice({
id: "agent-speech-playback-error",
tone: "error",
title: "语音播放失败",
message,
duration: 5000
});
});
const appendRecognitionResult = useCallback((text: string) => {
promptRestoreGenerationRef.current += 1;
onPromptChange((current) => `${current}${current.trim() ? " " : ""}${text}`);
}, [onPromptChange]);
const showRecognitionError = useCallback((message: string) => {
showMapNotice({
id: "agent-speech-recognition-error",
tone: "error",
title: "语音输入不可用",
message,
duration: 5000
});
}, []);
const {
isListening,
start: startListening,
stop: stopListening,
isSupported: isRecognitionSupported
} = useAgentSpeechRecognition(appendRecognitionResult);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
promptRestoreGenerationRef.current += 1;
};
}, []);
useEffect(() => {
stopSpeech();
setSubmissionError(null);
}, [activeSessionId, stopSpeech]);
useEffect(() => {
if (streaming) stopListening();
}, [stopListening, streaming]);
const submitPrompt = (nextPrompt: string) => {
if (!nextPrompt) {
return;
}
const submittedReference = contentReference;
const message = submittedReference
? `${nextPrompt}\n\n引用内容:${submittedReference.title}\n${submittedReference.text}`
: nextPrompt;
if (message.length > 10000) {
setSubmissionError(`消息与引用合计 ${message.length.toLocaleString()} 字,超过 10,000 字。请缩短问题或减少引用内容后再发送。`);
return;
}
setSubmissionError(null);
const restoreGeneration = promptRestoreGenerationRef.current + 1;
promptRestoreGenerationRef.current = restoreGeneration;
onPromptChange("");
stopListening();
setScrollRequestId((requestId) => requestId + 1);
onContentReferenceChange?.(undefined);
void Promise.resolve().then(() => onSubmitPrompt(message)).catch(() => {
if (!mountedRef.current || promptRestoreGenerationRef.current !== restoreGeneration) {
return;
}
onPromptChange((current) => current || nextPrompt);
if (submittedReference) onContentReferenceChange?.(submittedReference);
});
};
const historyPanel = (
{ setHistoryOpen(open); if (!open) onHistoryClose?.(); }}
triggerRef={historyTriggerRef}
scopeLabel={currentProjectName ?? "当前项目"}
query={historyQuery} onQueryChange={onHistoryQueryChange}
hasMore={historyHasMore} loadingMore={historyLoadingMore} error={historySelectionError ?? historyError}
pendingUpdate={historyPendingUpdate} onLoadMore={onLoadMoreHistory}
activeSessionId={activeSessionId}
loadingSessionId={loadingSessionId}
loading={sessionHistoryLoading}
sessions={sessionHistory}
topics={topicControls?.topics} topicId={historyTopicId} onTopicChange={onHistoryTopicChange} onMoveSessionToTopic={onMoveSessionToTopic}
onRefresh={onRefreshHistory}
onSelectSession={(nextSessionId) => {
promptRestoreGenerationRef.current += 1;
setLoadingSessionId(nextSessionId);
setHistorySelectionError(undefined);
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
.then(() => { setHistoryOpen(false); onHistoryClose?.(); })
.catch((error: unknown) => setHistorySelectionError(error instanceof Error ? error.message : "无法打开这个历史会话"))
.finally(() => setLoadingSessionId(null));
}}
onRenameSession={onRenameHistorySession}
onDeleteSession={onDeleteHistorySession}
/>
);
return (
{historyPanel}
{
const target = event.target instanceof Element ? event.target.closest("a") : null;
const href = target?.getAttribute("href");
if (!href?.startsWith("/api/v1/session/") || !href.includes("/files/")) return;
event.preventDefault();
event.stopPropagation();
void onDownloadFile?.(href);
}}
className={cn(
"pointer-events-auto flex h-full min-h-0 w-full flex-col",
"agent-panel-shell",
presentation === "desktop-dock"
? "surface-dock overflow-hidden rounded-none border-0 border-r"
: desktopFloating
? "acrylic-panel overflow-visible rounded-2xl border"
: "overflow-hidden rounded-none border-0 bg-transparent",
collapsing && "pointer-events-none"
)}
>
供水助手
{agentConnectionMetadata[connectionStatus].label}
{topicControls && !qaOnly ?
: null}
{presentation === "mobile-sheet" ? (
) : (
)}
{hasConversation || sessionTitle !== "新对话" ? : null}
{backgroundStatus ? : null}
{loadingSessionId && !hasConversation ? (
正在加载对话…
) : null}
{messages.length > 0 || Boolean(messageInbox?.messages.length) ? (
) : null}
{!qaOnly ? : null}
{interactionRequests ? message.role === "user").map((message) => message.id))} /> : null}
{messageInbox ? : null}
{executionHistory ? (
执行记录自动同步
发送消息后进入独立调试会话,系统执行记录不会被修改。
) : null}
{centeredComposer && !promptReference ? (
有什么可以帮你?
) : null}
{
const nextPrompt = message.text.trim();
if (nextPrompt) {
submitPrompt(nextPrompt);
}
}}
>
{contentReference ?
{contentReference.title}
查看引用内容
{contentReference.text}
{["解释这些内容", "比较差异与关联", "核查依据与缺口"].map((intent) => )}
: null}
{promptReference ? (
{promptReference.title}
{promptReference.detail ? <>{" "}{promptReference.detail.split(" · ").map((detail, index) => {index ? " · " : ""}{detail})}> : null}
) : null}
{
setSubmissionError(null);
promptRestoreGenerationRef.current += 1;
onPromptChange(event.currentTarget.value);
}}
className={cn("agent-panel-prompt-textarea text-base", floatingPresentation ? "min-h-20" : "min-h-14")}
aria-label={promptReference?.placeholder ?? "询问供水问题,或描述需要处理的任务"}
placeholder={promptReference?.placeholder ?? (streaming ? "继续补充,或安排下一条消息…" : "询问供水问题,或描述需要处理的任务")}
/>
{submissionError ? {submissionError}
: null}
{acceptsAdditionalInput ?
:
}
{isRecognitionSupported && !streaming ? (
startListening(showRecognitionError)
}
/>
) : null}
{isStopAction ? : }
{showStartSuggestions ? (
{promptSuggestionsLoading && promptSuggestions.length === 0 ? (
正在根据最近活动整理问题…
) : promptSuggestions.length ? promptSuggestions.map((suggestion) => (
{suggestion.question}
{suggestion.sourceLabel ? 来源:{suggestion.sourceLabel} : null}
{suggestion.question}
{suggestion.sourceLabel ? {suggestion.sourceLabel}
: null}
)) : (
{promptSuggestionsUnavailable ? "推荐问题暂不可用,可直接输入任务。" : "描述调查目标,即可开始分析。"}
{promptSuggestionsUnavailable && onRetryPromptSuggestions && (
)}
)}
) : null}
);
}
type AgentConversationScrollSnapshot = {
activeSessionId?: string | null;
lastMessageRevision: string;
lastUserMessageId: string | null;
scrollRequestId: number;
streaming: boolean;
};
function AgentConversationScrollManager({
activeSessionId,
messages,
scrollRequestId,
streaming,
readingPosition,
onPositionChange,
}: {
activeSessionId?: string | null;
messages: AgentChatMessage[];
scrollRequestId: number;
streaming: boolean;
readingPosition?: { messageId?: string; offset: number; atBottom: boolean };
onPositionChange?: (position: { messageId?: string; offset: number; atBottom: boolean }) => void;
}) {
const { scrollToBottom, scrollRef, stopScroll } = useStickToBottomContext();
const positionRef = useRef(readingPosition);
const onPositionRef = useRef(onPositionChange);
positionRef.current = readingPosition;
onPositionRef.current = onPositionChange;
useEffect(() => {
const scroll = scrollRef.current;
if (!scroll) return;
const save = () => {
const top = scroll.getBoundingClientRect().top;
const message = [...scroll.querySelectorAll("[data-agent-message-id]")]
.find((element) => element.getBoundingClientRect().bottom > top);
onPositionRef.current?.({ messageId: message?.dataset.agentMessageId,
offset: message ? message.getBoundingClientRect().top - top : 0,
atBottom: scroll.scrollHeight - scroll.clientHeight - scroll.scrollTop < 24 });
};
scroll.addEventListener("scroll", save, { passive: true });
return () => scroll.removeEventListener("scroll", save);
}, [activeSessionId, scrollRef]);
const previousSnapshotRef = useRef(null);
const lastUserMessageId = getLastUserMessageId(messages);
const lastMessageRevision = getLastMessageRevision(messages);
const snapshot = useMemo(
() => ({
activeSessionId,
lastMessageRevision,
lastUserMessageId,
scrollRequestId,
streaming
}),
[activeSessionId, lastMessageRevision, lastUserMessageId, scrollRequestId, streaming]
);
useEffect(() => {
const previousSnapshot = previousSnapshotRef.current;
previousSnapshotRef.current = snapshot;
const forceScroll =
!previousSnapshot ||
previousSnapshot.activeSessionId !== snapshot.activeSessionId ||
previousSnapshot.scrollRequestId !== snapshot.scrollRequestId;
if (!forceScroll) {
return;
}
const animationFrameId = window.requestAnimationFrame(() => {
const position = positionRef.current;
if (previousSnapshot?.activeSessionId !== snapshot.activeSessionId && position && !position.atBottom && position.messageId) {
const scroll = scrollRef.current;
const target = scroll?.querySelector(`[data-agent-message-id="${CSS.escape(position.messageId)}"]`);
if (scroll && target) {
stopScroll();
scroll.scrollTop += target.getBoundingClientRect().top - scroll.getBoundingClientRect().top - position.offset;
return;
}
}
void scrollToBottom({
animation: "instant",
ignoreEscapes: true,
wait: false
});
});
return () => window.cancelAnimationFrame(animationFrameId);
}, [scrollRef, scrollToBottom, snapshot, stopScroll]);
return null;
}
function AgentOlderMessages({ hasMore, loading, onLoad, sessionId, messages }: {
hasMore?: boolean; loading?: boolean; onLoad?: () => Promise | void;
sessionId?: string | null; messages: AgentChatMessage[];
}) {
const { scrollRef, stopScroll } = useStickToBottomContext();
const anchor = useRef<{ id?: string; top: number; height: number; sessionId?: string | null } | null>(null);
const pending = useRef(false);
const load = useCallback(async () => {
const scroll = scrollRef.current;
if (!scroll || !hasMore || loading || pending.current || !onLoad) return;
const visible = [...scroll.querySelectorAll("[data-agent-message-id]")]
.find((element) => element.getBoundingClientRect().bottom > scroll.getBoundingClientRect().top);
anchor.current = { id: visible?.dataset.agentMessageId, top: visible?.getBoundingClientRect().top ?? 0,
height: scroll.scrollHeight, sessionId };
pending.current = true;
stopScroll();
try { await onLoad(); } finally { pending.current = false; }
}, [hasMore, loading, onLoad, scrollRef, sessionId, stopScroll]);
useLayoutEffect(() => {
const prior = anchor.current;
const scroll = scrollRef.current;
if (!prior || !scroll || prior.sessionId !== sessionId || scroll.scrollHeight === prior.height) return;
const visible = prior.id ? scroll.querySelector(`[data-agent-message-id="${CSS.escape(prior.id)}"]`) : null;
if (visible) scroll.scrollTop += visible.getBoundingClientRect().top - prior.top;
anchor.current = null;
}, [messages, scrollRef, sessionId]);
useEffect(() => {
const scroll = scrollRef.current;
if (!scroll) return;
const onScroll = () => { if (scroll.scrollTop < 64) void load(); };
scroll.addEventListener("scroll", onScroll, { passive: true });
return () => scroll.removeEventListener("scroll", onScroll);
}, [load, scrollRef]);
return hasMore ? : null;
}
function getLastMessageRevision(messages: AgentChatMessage[]) {
const lastMessage = messages.at(-1);
if (!lastMessage) {
return "";
}
const progressRevision =
lastMessage.progress?.map((item) => `${item.id}:${item.status}`).join("|") ?? "";
return `${lastMessage.id}:${lastMessage.content.length}:${progressRevision}`;
}
function getLastUserMessageId(messages: AgentChatMessage[]) {
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index].role === "user") {
return messages[index].id;
}
}
return null;
}
const agentConnectionMetadata = {
connecting: { label: "连接中", tone: "info" },
online: { label: "在线", tone: "success" },
offline: { label: "离线", tone: "danger" }
} satisfies Record;
type BackendMessageListProps = {
presentation: AgentCommandPanelPresentation;
messages: AgentChatMessage[];
streaming: boolean;
streamRenderState: AgentStreamRenderState;
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise | void;
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise | void;
isSpeechSupported: boolean;
speakingMessageId: string | null;
speechState: AgentSpeechState;
onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise | void;
onPauseSpeech: () => void;
onResumeSpeech: () => void;
onStopSpeech: () => void;
qaOnly: boolean;
showProcess: boolean;
onLocateAnswerFeatures?: (target: AnswerUiMapTarget) => Promise | void;
onSubmitAnswerForm: (text: string) => Promise | void;
interactionRequests?: InteractionRequestsController;
};
function BackendMessageList(props: BackendMessageListProps) {
const { messages, streaming, interactionRequests } = props;
const replies = new Map();
for (const message of messages) {
if (message.role === "user" && message.interactionReply) replies.set(message.id, {
record: message.interactionReply, draft: message.interactionReply.draft, dirty: false, busy: false,
});
}
for (const entry of interactionRequests?.entries ?? []) {
if (!entry.record.delivery || !["answered", "skipped"].includes(entry.record.status)) continue;
const id = entry.record.delivery.native_message_id;
const previous = replies.get(id);
if (!previous || canReplaceInteractionRequest(entry.record, previous.record)) replies.set(id, entry);
}
const processGroups = groupMessageProcesses(messages, [...replies.values()].map(({ record }) => record));
return (
{messages.map((message, index) => {
return (
);
})}
);
}
function BackendMessage({
ref,
message: incomingMessage,
processMessages: incomingProcessMessages,
running,
reply,
presentation,
streamRenderState,
onReplyPermission,
onReplyQuestion,
onRejectQuestion,
isSpeechSupported,
speakingMessageId,
speechState,
onSpeak,
onPauseSpeech,
onResumeSpeech,
onStopSpeech,
qaOnly,
showProcess,
onLocateAnswerFeatures,
onSubmitAnswerForm,
interactionRequests,
}: BackendMessageListProps & {
ref?: Ref;
message: AgentChatMessage;
processMessages: AgentChatMessage[];
running: boolean;
reply?: InteractionRequestsController["entries"][number];
}) {
const transition = useAgentAnswerTransition(incomingMessage, running && incomingMessage.role === "assistant");
const { message, completed } = transition;
const processMessages = incomingProcessMessages.map((source) => (source.id === message.id ? message : source));
const answer = getAnswerPresentation(message);
const needsReply = hasPendingMessageInput(message);
if (message.role === "assistant" && !processMessages.length && !answer.report.length && !needsReply) return null;
const reportText = getAnswerPartsTranscript(answer.report);
const hasOutput = answer.report.length > 0;
const speech =
completed && hasOutput && isSpeechSupported ? (
void Promise.resolve(onSpeak(message.id, stripSpeechMarkdown(reportText)))}
onPause={onPauseSpeech}
onResume={onResumeSpeech}
onStop={onStopSpeech}
/>
) : null;
const copy = completed ? : null;
const processComplete = completed;
const hasProcess =
message.role === "assistant" &&
processMessages.some(
(source) =>
getAnswerPresentation(source).process.length > 0 ||
(showProcess && source.progress?.length) ||
(!qaOnly && !hasPendingMessageInput(source)
&& (source.todos?.todos.length || source.permissions?.length || source.questions?.length)),
);
const hasProcessError = processMessages.some((source) => source.progress?.some((item) => item.status === "error"));
return (
{message.role === "assistant" ? : null}
{message.role === "assistant" && message.taskContext ? (
依据:
{message.taskContext.execution_generation == null
? "历史资料 · 轮次未记录"
: `第 ${message.taskContext.execution_generation} 轮`}
{message.taskContext.report_revision == null
? " · 尚无报告"
: ` · 报告第 ${message.taskContext.report_revision} 版`}
) : null}
{hasOutput ? (
message.role === "assistant" ? (
{answer.report.find((part) => part.type === "text")?.text ?? "交互内容已在工作区展开。"}
}
>
) : reply ? (
void interactionRequests?.act(reply.record.interaction_id, "resume")}
/>
) : (
)
) : null}
{message.role === "assistant" && processComplete && hasProcessError ? (
部分查询或工具未完成,可展开处理过程查看原因。
) : null}
{message.role === "assistant" &&
!qaOnly &&
(needsReply ||
(!completed && processMessages.length > 0) ||
(processMessages.length > 0 && !hasProcess)) ? (
) : null}
{hasProcess ? (
(
{trigger}
)}
>
) : null}
{message.role === "assistant" && completed && hasOutput && !hasProcess ? (
) : null}
);
}