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.
1119 lines
53 KiB
TypeScript
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>
|
|
);
|
|
}
|