1114 lines
52 KiB
TypeScript
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>
|
|
);
|
|
}
|