Files
next-tjwater-frontend/src/features/agent/components/agent-command-panel.tsx
T
jiang 692bbf609c refactor: consolidate requests and finalize agent tool states
Share project JSON transport, interaction replies and report selectors.
Settle tool progress consistently for direct streams, reconnects and history, preserving failure reasons and stopped state.
2026-11-09 17:55:00 +08:00

1119 lines
53 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" && message.runError && message.content !== `错误:${message.runError}` ? (
<p role="status" className="whitespace-pre-wrap break-words text-sm text-destructive">
错误:{message.runError}
</p>
) : 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>
);
}