Files
next-tjwater-frontend/src/features/agent/components/agent-command-panel.tsx
T

1114 lines
52 KiB
TypeScript

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> | void;
onHistoryClose?: () => void;
onLoadMoreHistory?: () => Promise<unknown> | void;
hasOlderMessages?: boolean;
loadingOlderMessages?: boolean;
onLoadOlderMessages?: () => Promise<void> | 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> | void;
onPendingInputEdit?: (id: string) => Promise<void> | void;
onPendingInputCancel?: (id: string) => Promise<void> | void;
onDownloadFile?: (path: string) => Promise<void> | void;
messages?: AgentChatMessage[];
streamRenderState?: AgentStreamRenderState;
modelOptions?: AgentModelOption[];
selectedModel?: string;
currentProjectName?: string | null;
topicControls?: ComponentProps<typeof AgentTopicControls>;
historyTopicId?: string;
onHistoryTopicChange?: (id: string | undefined) => void;
onMoveSessionToTopic?: (sessionId: string, topicId: string | null) => Promise<void>;
approvalMode?: AgentApprovalMode;
uiResults?: AgentUiResult[];
interactionRequests?: InteractionRequestsController;
messageInbox?: ComponentProps<typeof AgentMessageInbox>;
workspaceContext?: AgentWorkspaceContextItem[];
prompt: string;
promptFocusRequest?: number;
promptReference?: { title: string; detail?: string; placeholder?: string };
contentReference?: AnswerUiSelection;
onContentReferenceChange?: (selection: AnswerUiSelection | undefined) => void;
onCollapse: () => void;
onPromptChange: Dispatch<SetStateAction<string>>;
onRefreshHistory?: () => Promise<void> | void;
onStartNewSession?: () => Promise<void> | void;
onLoadHistorySession?: (sessionId: string) => Promise<void> | void;
onRenameHistorySession?: (sessionId: string, title: string) => Promise<void> | void;
onDeleteHistorySession?: (sessionId: string) => Promise<void> | void;
onSelectModel?: (model: string) => void;
onApprovalModeChange?: (mode: AgentApprovalMode) => void;
onSubmitPrompt: (prompt: string) => Promise<void> | void;
onLocateAnswerFeatures?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
onStopPrompt?: () => void;
onReplyPermission?: (
permission: AgentPermissionRequest,
reply: AgentPermissionReply
) => Promise<void> | void;
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | 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<string | undefined>();
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [scrollRequestId, setScrollRequestId] = useState(0);
const [submissionError, setSubmissionError] = useState<string | null>(null);
const prefersReducedMotion = useReducedMotion();
const historyTriggerRef = useRef<HTMLButtonElement | null>(null);
const promptRestoreGenerationRef = useRef(0);
const promptTextareaRef = useRef<HTMLTextAreaElement | null>(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 = (
<AgentHistoryPanel
open={historyOpen}
onOpenChange={(open) => { 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 (
<AnswerViewStateProvider scope={answerStateScope} sessionId={activeSessionId}>
<MotionConfig reducedMotion="user">
{historyPanel}
<motion.aside
onCopy={copySelectedText}
aria-label="Agent 命令面板"
data-agent-presentation={presentation}
data-agent-starting={!hasConversation}
data-agent-centered={centeredComposer}
initial={prefersReducedMotion ? false : { opacity: 0, x: -8, scale: 0.985 }}
animate={collapsing ? { opacity: 0, x: -8, scale: 0.985 } : { opacity: 1, x: 0, scale: 1 }}
transition={prefersReducedMotion ? { duration: 0 } : agentLayoutTransition}
style={{ transformOrigin: "left top" }}
onClickCapture={(event) => {
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"
)}
>
<Agent
className={cn(
"relative flex h-full min-h-0 flex-col border-0 bg-transparent",
floatingPresentation && "gap-1 overflow-visible p-1"
)}
>
<div className="relative z-30 h-20 shrink-0">
<header
className={cn(
"agent-panel-header agent-panel-header-expandable inset-x-0 top-0 overflow-hidden",
floatingPresentation && "agent-panel-integrated-header",
presentation === "mobile-sheet" && "agent-panel-header-mobile"
)}
>
<div className="flex h-20 items-center justify-between gap-2 px-2 py-2 sm:px-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<AgentPersona state={personaState} />
<div className="flex min-w-0 flex-1 flex-col items-start gap-1.5">
<div className="flex min-w-0 max-w-full items-center gap-1 sm:gap-2">
<p className="shrink-0 whitespace-nowrap text-sm font-semibold leading-5 text-foreground">供水助手</p>
<p role="status" title={agentConnectionMetadata[connectionStatus].label}
className="flex shrink-0 items-center gap-1 whitespace-nowrap text-xs leading-4 text-muted-foreground sm:gap-1.5">
<StatusDot tone={agentConnectionMetadata[connectionStatus].tone} activity={connectionStatus === "connecting" ? "live" : "static"} />
<span>{agentConnectionMetadata[connectionStatus].label}</span>
</p>
</div>
{topicControls && !qaOnly ? <AgentTopicControls {...topicControls} /> : null}
</div>
</div>
<div role="group" aria-label="对话操作" className="flex shrink-0 items-center gap-0.5">
<Button variant="ghost" size="icon"
type="button"
aria-label="新建 Agent 对话"
title="新建 Agent 对话"
onClick={() => {
promptRestoreGenerationRef.current += 1;
setHistoryOpen(false);
void Promise.resolve(onStartNewSession?.());
}}
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-[var(--ink-muted)]"
>
<Plus size={17} aria-hidden="true" />
</Button>
<Button variant="ghost" size="icon"
ref={historyTriggerRef}
type="button"
aria-label="打开 Agent 历史记录"
title="打开 Agent 历史记录"
aria-expanded={historyOpen}
aria-haspopup="dialog"
onClick={() => {
setHistoryOpen(true);
void Promise.resolve(onHistoryOpen?.()).catch(() => {});
}}
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-[var(--ink-muted)] aria-expanded:bg-[var(--surface-control)] aria-expanded:text-foreground"
>
<History size={17} aria-hidden="true" />
</Button>
{presentation === "mobile-sheet" ? (
<Button variant="ghost" size="icon"
type="button"
aria-label="关闭 Agent 面板"
title="关闭 Agent 面板"
onClick={onCollapse}
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-[var(--ink-muted)]"
>
<X size={17} aria-hidden="true" />
</Button>
) : (
<Button variant="ghost" size="icon"
type="button"
aria-label="折叠 Agent 面板"
title="折叠 Agent 面板"
onClick={onCollapse}
className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-[var(--ink-muted)]"
>
<ChevronLeft size={17} aria-hidden="true" />
</Button>
)}
</div>
</div>
</header>
</div>
<Separator data-agent-header-divider className="bg-border/40" />
{hasConversation || sessionTitle !== "新对话" ? <div className="agent-chat-context-bar">
<p className="line-clamp-2 min-w-0 break-words text-sm font-medium text-foreground" title={sessionTitle}>
{sessionTitle}
</p>
</div> : null}
{backgroundStatus ? <button type="button" className="mx-3 mb-2 rounded-lg bg-[var(--surface-control)] px-3 py-2 text-left text-xs text-muted-foreground"
onClick={() => { setHistoryOpen(true); void Promise.resolve(onHistoryOpen?.()).catch(() => {}); }}>{backgroundStatus}</button> : null}
<motion.div layoutScroll className="agent-panel-body relative flex min-h-0 flex-1 flex-col">
<AgentContent className="agent-panel-main-content flex min-h-0 flex-1 flex-col gap-0 p-0">
<Conversation
data-agent-conversation-state={hasConversation ? "active" : "empty"}
className={cn(
"agent-panel-conversation min-h-0",
floatingPresentation && "agent-panel-conversation-canvas"
)}
>
<ConversationContent
className={cn(
"gap-4 p-3",
messages.length === 0 && "min-h-full",
floatingPresentation && "agent-panel-conversation-content",
floatingPresentation &&
!hasConversation &&
"agent-panel-conversation-content-empty"
)}
>
<AgentOlderMessages hasMore={hasOlderMessages} loading={loadingOlderMessages}
onLoad={onLoadOlderMessages} sessionId={activeSessionId} messages={messages} />
{loadingSessionId && !hasConversation ? (
<p role="status" className="p-4 text-center text-sm text-muted-foreground">正在加载对话…</p>
) : null}
<AnimatePresence mode="popLayout" initial={false}>
{messages.length > 0 || Boolean(messageInbox?.messages.length) ? (
<BackendMessageList
key="messages"
presentation={presentation}
messages={messages}
streaming={streaming}
streamRenderState={streamRenderState}
interactionRequests={interactionRequests}
onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
isSpeechSupported={isSpeechSupported}
speakingMessageId={speakingMessageId}
speechState={speechState}
onSpeak={speak}
onPauseSpeech={pauseSpeech}
onResumeSpeech={resumeSpeech}
onStopSpeech={stopSpeech}
qaOnly={qaOnly}
showProcess={!qaOnly || executionHistory}
onLocateAnswerFeatures={onLocateAnswerFeatures}
onSubmitAnswerForm={onSubmitPrompt}
/>
) : null}
</AnimatePresence>
{!qaOnly ? <AgentUiEnvelopeRenderer results={uiResults} /> : null}
{interactionRequests ? <AgentInteractionRequests controller={interactionRequests}
representedMessageIds={new Set(messages.filter((message) => message.role === "user").map((message) => message.id))} /> : null}
{messageInbox ? <AgentMessageInbox {...messageInbox} /> : null}
</ConversationContent>
<AgentConversationScrollManager
readingPosition={readingPosition}
onPositionChange={onReadingPositionChange}
activeSessionId={activeSessionId}
messages={messages}
scrollRequestId={scrollRequestId}
streaming={streaming}
/>
<ConversationScrollButton
data-agent-presentation={presentation}
className={cn(
"agent-panel-overlay agent-panel-floating-acrylic agent-conversation-scroll-button",
floatingPresentation ? "bottom-[10.25rem] z-30" : "bottom-3"
)}
/>
</Conversation>
</AgentContent>
<div
className={cn(
"agent-panel-band relative",
floatingPresentation
? "agent-panel-floating-acrylic agent-panel-composer absolute bottom-3 z-20 rounded-2xl p-1"
: "border-t border-[var(--surface-divider)] p-3"
)}
>
{executionHistory ? (
<div role="note" className="agent-panel-control mb-1 flex min-h-14 items-center gap-2.5 rounded-xl px-3 py-2 text-sm">
<Eye className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0">
<p className="font-medium text-foreground">执行记录自动同步</p>
<p className="text-xs leading-5 text-muted-foreground">发送消息后进入独立调试会话,系统执行记录不会被修改。</p>
</div>
</div>
) : null}
{centeredComposer && !promptReference ? (
<ConversationEmptyState className="agent-chat-empty-state">
<h2 className="text-2xl font-medium tracking-tight text-foreground">有什么可以帮你?</h2>
</ConversationEmptyState>
) : null}
<motion.div
className="agent-panel-composer-motion"
layout={prefersReducedMotion ? false : "position"}
layoutDependency={centeredComposer}
transition={{ layout: agentLayoutTransition }}
>
<AgentPendingInputs presentation={presentation} inputs={pendingInputs}
onDeliveryChange={onPendingInputDeliveryChange}
onEdit={onPendingInputEdit} onCancel={onPendingInputCancel} />
<PromptInput
className="@container/agent-composer agent-panel-control agent-panel-input-form overflow-hidden rounded-2xl [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none"
onSubmit={(message) => {
const nextPrompt = message.text.trim();
if (nextPrompt) {
submitPrompt(nextPrompt);
}
}}
>
<PromptInputBody className="agent-panel-input-body">
{contentReference ? <div role="group" aria-label="已选内容引用" className="agent-panel-input-reference mx-3 mt-2 flex min-w-0 items-start gap-1.5 text-sm leading-5">
<Quote size={14} className="mt-2 shrink-0 text-muted-foreground" aria-hidden />
<details className="min-w-0 flex-1 py-1.5">
<summary className="flex cursor-pointer list-none flex-wrap items-baseline gap-x-2 gap-y-1 rounded-sm focus-visible:outline-2 focus-visible:outline-ring">
<span className="min-w-0 truncate font-medium">{contentReference.title}</span>
<span className="shrink-0 text-muted-foreground">查看引用内容</span>
</summary>
<p className="mt-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words text-muted-foreground">{contentReference.text}</p>
<div className="mt-2 flex flex-wrap gap-1" aria-label="引用内容的分析方向">
{["解释这些内容", "比较差异与关联", "核查依据与缺口"].map((intent) => <Button key={intent} type="button" variant="outline" size="sm" className="h-8 text-sm"
onClick={() => {
promptRestoreGenerationRef.current += 1;
onPromptChange((current) => current.trim() ? `${current}\n${intent}` : intent);
setSubmissionError(null);
promptTextareaRef.current?.focus();
}}>{intent}</Button>)}
</div>
</details>
<Button type="button" size="icon" variant="ghost" className="size-8 shrink-0" aria-label="移除内容引用" title="移除内容引用"
onClick={() => { onContentReferenceChange?.(undefined); setSubmissionError(null); }}><X size={14} aria-hidden /></Button>
</div> : null}
{promptReference ? (
<div role="group" aria-label="对话引用" className="agent-panel-input-reference mx-3 mt-2 flex min-w-0 items-start gap-1.5 py-1 text-sm leading-5 text-muted-foreground">
<Quote aria-hidden="true" className="mt-0.5 size-3.5 shrink-0" />
<div className="min-w-0 flex-1 break-words">
<span data-agent-reference-title className="font-medium text-foreground">{promptReference.title}</span>
{promptReference.detail ? <>{" "}<span data-agent-reference-detail className="ml-1">{promptReference.detail.split(" · ").map((detail, index) => <span key={detail} className="inline-block whitespace-nowrap">{index ? " · " : ""}{detail}</span>)}</span></> : null}
</div>
</div>
) : null}
<PromptInputTextarea
ref={promptTextareaRef}
value={prompt}
onChange={(event) => {
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 ? "继续补充,或安排下一条消息…" : "询问供水问题,或描述需要处理的任务")}
/>
</PromptInputBody>
{submissionError ? <p role="alert" className="mx-3 mb-2 text-sm leading-5 text-destructive">{submissionError}</p> : null}
<PromptInputFooter className="agent-panel-input-tools justify-between gap-2">
<div className="flex min-w-0 items-center gap-1">
<ApprovalModeSelect presentation={presentation} value={approvalMode} disabled={streaming} onValueChange={onApprovalModeChange} />
</div>
<div className="flex shrink-0 items-center gap-1">
{acceptsAdditionalInput ? <InputDeliverySelect presentation={presentation} label="消息发送方式"
value={inputDelivery} onValueChange={onInputDeliveryChange} /> : <AgentModelSelect
presentation={presentation}
models={modelOptions}
value={selectedModel}
onValueChange={onSelectModel}
/>}
{isRecognitionSupported && !streaming ? (
<VoiceInputButton
isListening={isListening}
disabled={streaming}
onClick={
isListening ? stopListening : () => startListening(showRecognitionError)
}
/>
) : null}
<PromptInputSubmit
aria-label={isStopAction ? "停止 Agent 指令" : acceptsAdditionalInput ? inputDelivery === "steer" ? "引导当前 Agent 任务" : "将消息加入 Agent 队列" : "发送 Agent 指令"}
title={isStopAction ? "停止 Agent 指令" : acceptsAdditionalInput ? inputDelivery === "steer" ? "引导当前 Agent 任务" : "将消息加入 Agent 队列" : "发送 Agent 指令"}
className="agent-panel-primary-icon size-10 shrink-0"
data-agent-action={isStopAction ? "stop" : "send"}
disabled={!streaming && !trimmedPrompt}
status={isStopAction ? "streaming" : "ready"}
onStop={onStopPrompt}
>
{isStopAction ? <Square className="size-4" aria-hidden /> : <ArrowUp
className="agent-panel-send-icon size-5"
aria-hidden="true"
/>}
</PromptInputSubmit>
</div>
</PromptInputFooter>
</PromptInput>
</motion.div>
{showStartSuggestions ? (
<TooltipProvider delayDuration={250}>
<Suggestions className="agent-chat-suggestions" role="group" aria-label="推荐问题" aria-live="polite" aria-busy={promptSuggestionsLoading}>
{promptSuggestionsLoading && promptSuggestions.length === 0 ? (
<p className="agent-panel-suggestion-status">正在根据最近活动整理问题…</p>
) : promptSuggestions.length ? promptSuggestions.map((suggestion) => (
<Tooltip key={suggestion.question}>
<TooltipTrigger asChild>
<Suggestion
suggestion={suggestion.question}
onClick={submitPrompt}
className="agent-chat-suggestion"
>
<span className="min-w-0 whitespace-normal break-words text-left">{suggestion.question}</span>
{suggestion.sourceLabel ? <span className="sr-only">来源:{suggestion.sourceLabel}</span> : null}
</Suggestion>
</TooltipTrigger>
<TooltipContent className="max-w-72 bg-zinc-900 text-white">
<p className="leading-5">{suggestion.question}</p>
{suggestion.sourceLabel ? <p className="mt-1 text-[11px] text-zinc-300">{suggestion.sourceLabel}</p> : null}
</TooltipContent>
</Tooltip>
)) : (
<div className="agent-panel-suggestion-status">
<p>{promptSuggestionsUnavailable ? "推荐问题暂不可用,可直接输入任务。" : "描述调查目标,即可开始分析。"}</p>
{promptSuggestionsUnavailable && onRetryPromptSuggestions && (
<Button variant="ghost" size="sm" onClick={onRetryPromptSuggestions}>重试推荐</Button>
)}
</div>
)}
</Suggestions>
</TooltipProvider>
) : null}
</div>
</motion.div>
</Agent>
</motion.aside>
</MotionConfig>
</AnswerViewStateProvider>
);
}
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<HTMLElement>("[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<AgentConversationScrollSnapshot | null>(null);
const lastUserMessageId = getLastUserMessageId(messages);
const lastMessageRevision = getLastMessageRevision(messages);
const snapshot = useMemo<AgentConversationScrollSnapshot>(
() => ({
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<HTMLElement>(`[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> | 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<HTMLElement>("[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<HTMLElement>(`[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 ? <Button variant="ghost" className="w-full shrink-0" loading={loading}
onClick={() => void load()}>加载更早消息</Button> : 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<AgentConnectionStatus, { label: string; tone: StatusTone }>;
type BackendMessageListProps = {
presentation: AgentCommandPanelPresentation;
messages: AgentChatMessage[];
streaming: boolean;
streamRenderState: AgentStreamRenderState;
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
isSpeechSupported: boolean;
speakingMessageId: string | null;
speechState: AgentSpeechState;
onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise<void> | void;
onPauseSpeech: () => void;
onResumeSpeech: () => void;
onStopSpeech: () => void;
qaOnly: boolean;
showProcess: boolean;
onLocateAnswerFeatures?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
onSubmitAnswerForm: (text: string) => Promise<void> | void;
interactionRequests?: InteractionRequestsController;
};
function BackendMessageList(props: BackendMessageListProps) {
const { messages, streaming, interactionRequests } = props;
const replies = new Map<string, InteractionRequestsController["entries"][number]>();
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 (
<motion.div
className="agent-chat-message-list flex w-full flex-col gap-6"
variants={agentPanelListVariants}
animate="animate"
>
<AnimatePresence initial={false} mode="popLayout">
{messages.map((message, index) => {
return (
<BackendMessage
key={message.id}
{...props}
message={message}
processMessages={processGroups.get(message.id) ?? []}
running={streaming && index === messages.length - 1}
reply={message.role === "user" ? replies.get(message.id) : undefined}
/>
);
})}
</AnimatePresence>
</motion.div>
);
}
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<HTMLDivElement>;
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 ? (
<AgentMessageSpeechControls
speechState={speakingMessageId === message.id ? speechState : "idle"}
onSpeak={() => void Promise.resolve(onSpeak(message.id, stripSpeechMarkdown(reportText)))}
onPause={onPauseSpeech}
onResume={onResumeSpeech}
onStop={onStopSpeech}
/>
) : null;
const copy = completed ? <AgentChatMessageActions parts={answer.report} /> : 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 (
<motion.div
ref={ref}
className={cn("w-full", message.role === "user" && "flex justify-end")}
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
<Message
data-agent-message-id={message.id}
from={message.role}
className={message.role === "user" ? "max-w-[92%]" : "max-w-full"}
>
<MessageContent
className={cn(
message.role === "user" &&
"agent-panel-user-message rounded-2xl px-3 py-3 text-base leading-7 text-slate-800",
message.role === "assistant" &&
"agent-panel-message w-full rounded-2xl p-3 text-base leading-7 text-slate-700",
)}
>
<motion.div
ref={transition.root}
initial={false}
animate={transition.controls}
data-answer-phase={transition.phase}
className="space-y-3"
>
{message.role === "assistant" ? <AgentChatMessageHeader running={!completed} /> : null}
{message.role === "assistant" && message.taskContext ? (
<p className="text-sm leading-5 text-[var(--ink-muted)]">
依据:
{message.taskContext.execution_generation == null
? "历史资料 · 轮次未记录"
: `第 ${message.taskContext.execution_generation} 轮`}
{message.taskContext.report_revision == null
? " · 尚无报告"
: ` · 报告第 ${message.taskContext.report_revision} 版`}
</p>
) : null}
{hasOutput ? (
message.role === "assistant" ? (
<AgentSpeechMessage
presentation={presentation}
messageId={message.id}
content={reportText}
disabled={!completed}
isSupported={isSpeechSupported}
onSpeak={onSpeak}
>
<ExpandableReading
title="对话中的分析报告"
enabled={canExpandAnswer(answer.report)}
summary={
<p className="line-clamp-3">
{answer.report.find((part) => part.type === "text")?.text ?? "交互内容已在工作区展开。"}
</p>
}
>
<AnswerPartsView
parts={answer.report}
persist={answer.final && completed}
messageId={message.id}
disabled={!completed}
streaming={!answer.final && !completed}
streamDone={answer.final || (streamRenderState[message.id]?.done ?? completed)}
onLocate={onLocateAnswerFeatures}
onSubmit={onSubmitAnswerForm}
/>
</ExpandableReading>
</AgentSpeechMessage>
) : reply ? (
<AgentInteractionReply
entry={reply}
checking={interactionRequests?.checking ?? false}
onResume={() => void interactionRequests?.act(reply.record.interaction_id, "resume")}
/>
) : (
<AgentUserMessageText content={message.content} />
)
) : null}
{message.role === "assistant" && processComplete && hasProcessError ? (
<p role="status" className="text-xs text-[var(--status-warning-foreground)]">
部分查询或工具未完成,可展开处理过程查看原因。
</p>
) : null}
{message.role === "assistant" &&
!qaOnly &&
(needsReply ||
(!completed && processMessages.length > 0) ||
(processMessages.length > 0 && !hasProcess)) ? (
<AgentMessageDetails
message={message}
onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
) : null}
{hasProcess ? (
<AgentProcessDisclosure
key="message-process"
label="查看处理过程"
live={!completed}
toolbar={(trigger) => (
<AgentChatMessageToolbar speech={speech} copy={copy}>
{trigger}
</AgentChatMessageToolbar>
)}
>
<AgentMessageProcessContent
messages={processMessages}
qaOnly={qaOnly}
showProgress={showProcess}
runningMessageId={!completed ? message.id : undefined}
/>
</AgentProcessDisclosure>
) : null}
{message.role === "assistant" && completed && hasOutput && !hasProcess ? (
<AgentChatMessageToolbar speech={speech} copy={copy} />
) : null}
</motion.div>
</MessageContent>
</Message>
</motion.div>
);
}