fix: unify chat process and reply presentation

This commit is contained in:
2026-10-29 17:54:34 +08:00
parent 7621bf0f86
commit 9474788db6
46 changed files with 3160 additions and 500 deletions
@@ -1,22 +1,29 @@
import { AgentInteractionReply, AgentInteractionRequests } from "./agent-interaction-request-card";
import { canReplaceInteractionRequest } from "../api/interaction-requests";
import type { InteractionRequestsController } from "../hooks/use-interaction-requests";
import type { AnswerUiMapTarget } from "../answer-ui/answer-ui-view";
import { AnswerPartsView } from "../answer-ui/answer-parts-view";
import type { AnswerUiSelection } from "../answer-ui/answer-ui-interaction-context";
import { ExpandableReading } from "@/shared/ui/reading-workspace";
import { getAnswerPresentation } from "../answer-ui/answer-presentation";
import { Disclosure } from "@/shared/ui/disclosure";
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,
AudioLines,
CheckCircle2,
ChevronDown,
ChevronsUpDown,
ChevronLeft,
History,
LoaderCircle,
Mic,
Pause,
Play,
Plus,
Quote,
SendHorizontal,
Shield,
ShieldCheck,
Square,
@@ -34,6 +41,7 @@ import {
useRef,
useState,
type Dispatch,
type Ref,
type ReactNode,
type SetStateAction
} from "react";
@@ -73,7 +81,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shar
import { cn } from "@/shared/ui/cn";
import { StatusDot, type StatusTone } from "@/shared/ui/status";
import type { AgentChatSessionSummary, AgentInputDelivery, AgentResponseInput } from "../api/client";
import { AgentChatMessageActions, AgentChatMessageHeader } from "./agent-chat-message-chrome";
import { AGENT_MESSAGE_ACTION_CLASS_NAME, AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME, AgentChatMessageActions, AgentChatMessageHeader, AgentChatMessageToolbar } from "./agent-chat-message-chrome";
import {
agentLayoutTransition,
agentPanelItemVariants,
@@ -86,7 +94,7 @@ import { AgentHistoryPanel } from "./agent-history-panel";
import { AgentMessageInbox } from "./agent-message-inbox";
import { AgentPendingInputs } from "./agent-pending-inputs";
import type { ComponentProps } from "react";
import { AgentMessageDetails, AgentMessageProgress } from "./agent-message-details";
import { AgentMessageDetails } from "./agent-message-details";
import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer";
import { useAgentSpeechRecognition, useAgentSpeechSynthesis } from "../hooks/use-agent-voice";
import {
@@ -142,6 +150,7 @@ type AgentCommandPanelProps = {
promptSuggestions?: Array<{ question: string; sourceLabel?: string }>;
promptSuggestionsLoading?: boolean;
promptSuggestionsUnavailable?: boolean;
onRetryPromptSuggestions?: () => void;
activeSessionId?: string | null;
qaOnly?: boolean;
statusLabel?: string;
@@ -165,6 +174,7 @@ type AgentCommandPanelProps = {
onMoveSessionToTopic?: (sessionId: string, topicId: string | null) => Promise<void>;
approvalMode?: AgentApprovalMode;
uiResults?: AgentUiResult[];
interactionRequests?: InteractionRequestsController;
reviewPanel?: ReactNode;
messageInbox?: ComponentProps<typeof AgentMessageInbox>;
workspaceContext?: AgentWorkspaceContextItem[];
@@ -207,6 +217,7 @@ export function AgentCommandPanel({
promptSuggestions = [],
promptSuggestionsLoading = false,
promptSuggestionsUnavailable = false,
onRetryPromptSuggestions,
activeSessionId,
qaOnly = false,
connectionStatus = "connecting",
@@ -226,6 +237,7 @@ export function AgentCommandPanel({
topicControls, historyTopicId, onHistoryTopicChange, onMoveSessionToTopic,
approvalMode = "request",
uiResults = [],
interactionRequests,
reviewPanel,
messageInbox,
prompt,
@@ -253,6 +265,7 @@ export function AgentCommandPanel({
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);
@@ -261,11 +274,13 @@ export function AgentCommandPanel({
const desktopFloating = presentation === "desktop-floating";
const floatingPresentation = desktopFloating;
const hasConversation = messages.length > 0 || streaming || Boolean(messageInbox?.messages.length);
// Existing task state and loading/errors stay visible even before a first message.
const centeredComposer = !hasConversation && !loadingSessionId && !reviewPanel
// Task discussions keep their reference and composer layout before the first message.
const centeredComposer = !hasConversation && !loadingSessionId && !reviewPanel && !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(() => {
@@ -315,6 +330,7 @@ export function AgentCommandPanel({
useEffect(() => {
stopSpeech();
setSubmissionError(null);
}, [activeSessionId, stopSpeech]);
useEffect(() => {
@@ -325,17 +341,22 @@ export function AgentCommandPanel({
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);
const submittedReference = contentReference;
const message = submittedReference
? `${nextPrompt}\n\n引用内容:${submittedReference.title}\n${submittedReference.text}`
: nextPrompt;
onContentReferenceChange?.(undefined);
void Promise.resolve(onSubmitPrompt(message)).catch(() => {
void Promise.resolve().then(() => onSubmitPrompt(message)).catch(() => {
if (!mountedRef.current || promptRestoreGenerationRef.current !== restoreGeneration) {
return;
}
@@ -375,6 +396,7 @@ export function AgentCommandPanel({
<MotionConfig reducedMotion="user">
{historyPanel}
<motion.aside
onCopy={copySelectedText}
aria-label="Agent 命令面板"
data-agent-presentation={presentation}
data-agent-starting={!hasConversation}
@@ -423,7 +445,7 @@ export function AgentCommandPanel({
<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-0.5 whitespace-nowrap text-xs leading-4 text-muted-foreground sm:gap-1.5">
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>
@@ -527,6 +549,7 @@ export function AgentCommandPanel({
messages={messages}
streaming={streaming}
streamRenderState={streamRenderState}
interactionRequests={interactionRequests}
onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
@@ -550,6 +573,8 @@ export function AgentCommandPanel({
{!qaOnly ? <AgentUiEnvelopeRenderer results={uiResults} /> : null}
{reviewPanel}
{interactionRequests ? <AgentInteractionRequests controller={interactionRequests}
representedMessageIds={new Set(messages.filter((message) => message.role === "user").map((message) => message.id))} /> : null}
{messageInbox ? <AgentMessageInbox {...messageInbox} /> : null}
</ConversationContent>
<AgentConversationScrollManager
@@ -574,7 +599,7 @@ export function AgentCommandPanel({
className={cn(
"agent-panel-band relative",
floatingPresentation
? "form-field agent-panel-floating-acrylic agent-panel-composer absolute bottom-3 z-20 rounded-2xl p-1"
? "agent-panel-floating-acrylic agent-panel-composer absolute bottom-3 z-20 rounded-2xl p-1"
: "border-t border-[var(--surface-divider)] p-3"
)}
>
@@ -590,11 +615,11 @@ export function AgentCommandPanel({
layoutDependency={centeredComposer}
transition={{ layout: agentLayoutTransition }}
>
<AgentPendingInputs presentation={presentation} inputs={pendingInputs}
onDeliveryChange={onPendingInputDeliveryChange}
onEdit={onPendingInputEdit} onCancel={onPendingInputCancel} />
<PromptInput
className={cn(
"agent-panel-control overflow-hidden rounded-2xl [&>[data-slot=input-group]]:rounded-[inherit] [&>[data-slot=input-group]]:border-0 [&>[data-slot=input-group]]:shadow-none",
!desktopFloating ? "agent-panel-input-form" : "form-field shadow-xs"
)}
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) {
@@ -602,29 +627,34 @@ export function AgentCommandPanel({
}
}}
>
<AgentPendingInputs presentation={presentation} inputs={pendingInputs}
onDeliveryChange={onPendingInputDeliveryChange}
onEdit={onPendingInputEdit} onCancel={onPendingInputCancel} />
<PromptInputBody className={!desktopFloating ? "agent-panel-input-body form-field" : undefined}>
{contentReference ? <div role="group" aria-label="已选内容引用" className="m-3 mb-0 rounded-lg border bg-muted/30 p-2.5">
<div className="flex items-start gap-2">
<Quote size={14} className="mt-1 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1 text-xs leading-5">
<p className="font-medium">{contentReference.title}</p>
<details><summary className="cursor-pointer text-muted-foreground">查看引用内容</summary>
<p className="max-h-32 overflow-y-auto whitespace-pre-wrap break-words text-muted-foreground">{contentReference.text}</p>
</details>
<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>
<Button type="button" size="sm" variant="ghost" className="h-8 text-xs" aria-label="移除内容引用"
onClick={() => onContentReferenceChange?.(undefined)}>移除</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="mx-3 mt-3 flex min-w-0 items-start gap-2 rounded-lg bg-[var(--surface-subtle)] px-3 py-2 text-[var(--ink-muted)]">
<Quote aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0 text-xs leading-relaxed">
<p className="break-words font-medium text-[var(--ink)]">{promptReference.title}</p>
{promptReference.detail ? <p className="break-words">{promptReference.detail}</p> : null}
<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}
@@ -632,20 +662,22 @@ export function AgentCommandPanel({
ref={promptTextareaRef}
value={prompt}
onChange={(event) => {
setSubmissionError(null);
promptRestoreGenerationRef.current += 1;
onPromptChange(event.currentTarget.value);
}}
className={cn("agent-panel-prompt-textarea text-sm", floatingPresentation ? "min-h-20" : "min-h-14")}
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-2">
{!qaOnly ? <ApprovalModeSelect presentation={presentation} value={approvalMode} disabled={streaming} onValueChange={onApprovalModeChange} /> : <span className="px-1 text-[11px] text-[var(--ink-muted)]">任务协作问答</span>}
<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-2">
{streaming ? <InputDeliverySelect presentation={presentation} label="运行中消息发送方式"
<div className="flex shrink-0 items-center gap-1">
{acceptsAdditionalInput ? <InputDeliverySelect presentation={presentation} label="消息发送方式"
value={inputDelivery} onValueChange={onInputDeliveryChange} /> : <AgentModelSelect
presentation={presentation}
models={modelOptions}
@@ -662,20 +694,18 @@ export function AgentCommandPanel({
/>
) : null}
<PromptInputSubmit
aria-label={streaming && !trimmedPrompt ? "停止 Agent 指令" : streaming ? inputDelivery === "steer" ? "引导当前 Agent 任务" : "将消息加入 Agent 队列" : "发送 Agent 指令"}
title={streaming && !trimmedPrompt ? "停止 Agent 指令" : streaming ? inputDelivery === "steer" ? "引导当前 Agent 任务" : "将消息加入 Agent 队列" : "发送 Agent 指令"}
className="agent-panel-primary-icon"
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={streaming && !trimmedPrompt ? "streaming" : "ready"}
status={isStopAction ? "streaming" : "ready"}
onStop={onStopPrompt}
>
{streaming && !trimmedPrompt ? <Square size={15} aria-hidden="true" /> : (
<SendHorizontal
size={15}
style={{ transform: "rotate(-45deg)" }}
aria-hidden="true"
/>
)}
{isStopAction ? <Square className="size-4" aria-hidden /> : <ArrowUp
className="agent-panel-send-icon size-5"
aria-hidden="true"
/>}
</PromptInputSubmit>
</div>
</PromptInputFooter>
@@ -704,7 +734,12 @@ export function AgentCommandPanel({
</TooltipContent>
</Tooltip>
)) : (
<p className="agent-panel-suggestion-status">{promptSuggestionsUnavailable ? "推荐问题暂不可用,可直接输入任务。" : "描述调查目标,即可开始分析。"}</p>
<div className="agent-panel-suggestion-status">
<p>{promptSuggestionsUnavailable ? "推荐问题暂不可用,可直接输入任务。" : "描述调查目标,即可开始分析。"}</p>
{promptSuggestionsUnavailable && onRetryPromptSuggestions && (
<Button variant="ghost" size="sm" onClick={onRetryPromptSuggestions}>重试推荐</Button>
)}
</div>
)}
</Suggestions>
</TooltipProvider>
@@ -744,7 +779,7 @@ function VoiceInputButton({
aria-pressed={isListening}
title={label}
className={cn(
"group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border-0 after:absolute after:-inset-1 after:content-['']",
"agent-panel-voice-input group relative grid size-10 shrink-0 place-items-center overflow-visible rounded-full border-0",
"bg-transparent shadow-none",
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-0",
@@ -759,7 +794,7 @@ function VoiceInputButton({
{isListening ? (
<motion.span
data-slot="voice-input-pulse"
className="pointer-events-none absolute inset-0 rounded-lg bg-[var(--status-mark)] opacity-20"
className="pointer-events-none absolute inset-0 rounded-full bg-[var(--status-mark)] opacity-20"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.9 }}
animate={
@@ -816,7 +851,7 @@ function VoiceInputButton({
</motion.span>
) : (
<motion.span
key="voice-microphone"
key="voice-idle"
className="grid place-items-center"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.72, y: 2 }}
@@ -824,10 +859,9 @@ function VoiceInputButton({
exit={{ opacity: 0, scale: 0.72, y: -2 }}
transition={{ duration: 0.16, ease: "easeOut" }}
>
<Mic
size={15}
strokeWidth={2.15}
className="transition-transform duration-200 group-hover:scale-105"
<AudioLines
strokeWidth={2}
className="size-4 transition-transform duration-200 group-hover:scale-105"
/>
</motion.span>
)}
@@ -840,6 +874,10 @@ function VoiceInputButton({
const AGENT_SELECT_ITEM_CLASS_NAME =
"rounded-lg border border-transparent px-2.5 py-2 focus:border-[var(--action-border-hover)] focus:bg-[var(--action-selection-soft-hover)] data-[highlighted]:border-[var(--action-border-hover)] data-[highlighted]:bg-[var(--action-selection-soft-hover)] motion-reduce:transition-none";
function AgentToolbarCaret() {
return <ChevronDown className="agent-panel-toolbar-caret size-3 shrink-0 opacity-50" aria-hidden="true" />;
}
function InputDeliverySelect({ presentation, label, value, onValueChange }: {
presentation: AgentCommandPanelPresentation;
label: string;
@@ -850,10 +888,10 @@ function InputDeliverySelect({ presentation, label, value, onValueChange }: {
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="sm" aria-label={label}
title={value === "queue" ? "当前任务结束后处理" : "在下一步补充当前任务"}
className="agent-panel-toolbar-trigger h-10 shrink-0 gap-1.5 px-2 text-xs font-medium active:scale-95 motion-reduce:transform-none"
className="agent-panel-toolbar-trigger h-10 shrink-0 gap-1.5 px-2 text-sm font-normal active:scale-95 motion-reduce:transform-none"
disabled={!onValueChange}>
{value === "queue" ? "排队" : "引导"}
<ChevronDown className="size-3 opacity-60" aria-hidden="true" />
<AgentToolbarCaret />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent data-agent-presentation={presentation} side="top" align="end" sideOffset={6}
@@ -863,7 +901,7 @@ function InputDeliverySelect({ presentation, label, value, onValueChange }: {
}}>
{([ ["steer", "引导当前任务"], ["queue", "排队处理"] ] as const).map(([delivery, text]) => (
<DropdownMenuRadioItem key={delivery} value={delivery}
className={cn(AGENT_SELECT_ITEM_CLASS_NAME, "min-h-10 pl-8 pr-2.5 text-xs",
className={cn(AGENT_SELECT_ITEM_CLASS_NAME, "min-h-10 pl-8 pr-2.5 text-sm",
value === delivery && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]")}>
{text}
</DropdownMenuRadioItem>
@@ -893,17 +931,18 @@ function ApprovalModeSelect({
type="button"
variant="ghost"
size="sm"
className="agent-panel-toolbar-trigger shrink-0 gap-1.5 border-0 bg-transparent px-1.5 text-xs text-slate-600 shadow-none data-[state=open]:bg-[var(--action-soft)] data-[state=open]:text-[var(--action-blue-hover)] focus-visible:ring-1 focus-visible:ring-offset-0"
className="agent-panel-toolbar-trigger h-10 shrink-0 gap-1.5 border-0 bg-transparent px-1.5 text-sm text-slate-600 shadow-none data-[state=open]:bg-[var(--action-soft)] data-[state=open]:text-[var(--action-blue-hover)] focus-visible:ring-1 focus-visible:ring-offset-0"
disabled={disabled || !onValueChange}
aria-label="权限批准模式"
title={requestApproval ? "请求批准" : "始终允许"}
>
{requestApproval ? (
<Shield size={14} aria-hidden="true" />
<Shield className="size-4" aria-hidden="true" />
) : (
<ShieldCheck size={14} aria-hidden="true" />
<ShieldCheck className="size-4" aria-hidden="true" />
)}
<span>{requestApproval ? "请求批准" : "始终允许"}</span>
<ChevronsUpDown size={12} className="opacity-50" aria-hidden="true" />
<AgentToolbarCaret />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -1130,7 +1169,7 @@ function AgentModelSelect({
if (!models.length) {
return (
<span className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-[var(--ink-muted)]">模型</span>
<span className="shrink-0 rounded-md px-2 py-1 text-sm text-[var(--ink-muted)]">模型</span>
);
}
@@ -1141,7 +1180,7 @@ function AgentModelSelect({
type="button"
variant="ghost"
size="sm"
className="agent-panel-toolbar-trigger w-12 shrink-0 justify-between gap-1 border-0 bg-transparent px-1 text-xs text-slate-600 shadow-none data-[state=open]:bg-[var(--action-soft)] data-[state=open]:text-[var(--action-blue-hover)] focus-visible:ring-1 focus-visible:ring-offset-0 sm:w-auto sm:min-w-[72px] sm:gap-1.5 sm:px-1.5"
className="agent-panel-toolbar-trigger h-10 w-11 shrink-0 justify-between gap-1 border-0 bg-transparent px-1 text-sm text-slate-600 shadow-none data-[state=open]:bg-[var(--action-soft)] data-[state=open]:text-[var(--action-blue-hover)] focus-visible:ring-1 focus-visible:ring-offset-0 @[400px]/agent-composer:w-auto @[400px]/agent-composer:max-w-40 @[400px]/agent-composer:gap-1.5 @[400px]/agent-composer:px-1.5"
disabled={!onValueChange}
aria-label="选择 Agent 模型"
aria-description={`当前模型:${selectedModel?.label ?? "模型"}`}
@@ -1149,9 +1188,9 @@ function AgentModelSelect({
>
<span className="flex min-w-0 flex-1 items-center gap-1">
<ModelIcon model={selectedModel} />
<span className="hidden shrink-0 sm:inline">{selectedModel?.label ?? "模型"}</span>
<span className="hidden truncate @[400px]/agent-composer:inline">{selectedModel?.label ?? "模型"}</span>
</span>
<ChevronsUpDown size={12} className="shrink-0 opacity-50" aria-hidden="true" />
<AgentToolbarCaret />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -1207,13 +1246,77 @@ const agentConnectionMetadata = {
function ModelIcon({ model }: { model?: AgentModelOption }) {
const Icon = model?.icon === "bolt" ? Zap : Sparkles;
return <Icon className="size-4 text-primary" aria-hidden="true" />;
return <Icon className="size-4" aria-hidden="true" />;
}
function BackendMessageList({
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;
onLocateAnswerFeatures?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
onSubmitAnswerForm: (text: string) => Promise<void> | void;
onAsk?: (selection: AnswerUiSelection) => 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,
messages,
streaming,
streamRenderState,
onReplyPermission,
onReplyQuestion,
@@ -1228,147 +1331,170 @@ function BackendMessageList({
qaOnly,
onLocateAnswerFeatures,
onSubmitAnswerForm,
onAsk
}: {
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;
onLocateAnswerFeatures?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
onSubmitAnswerForm: (text: string) => Promise<void> | void;
onAsk?: (selection: AnswerUiSelection) => void;
onAsk,
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 ||
(!qaOnly &&
(source.progress?.length ||
(!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
className="agent-chat-message-list flex w-full flex-col gap-6"
variants={agentPanelListVariants}
ref={ref}
className={cn("w-full", message.role === "user" && "flex justify-end")}
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
<AnimatePresence initial={false} mode="popLayout">
{messages.map((message, index) => {
const answer = getAnswerPresentation(message);
const needsReply = Boolean(
message.permissions?.some((item) => ["pending", "submitting", "error"].includes(item.status)) ||
message.questions?.some((item) => ["pending", "submitting", "error"].includes(item.status))
);
return (
<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
key={message.id}
className={cn("w-full", message.role === "user" && "flex justify-end")}
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
ref={transition.root}
initial={false}
animate={transition.controls}
data-answer-phase={transition.phase}
className="space-y-3"
>
<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-slate-800",
message.role === "assistant" &&
"agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700"
{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}
messageId={message.id}
disabled={!completed}
streaming={!answer.final && !completed}
streamDone={answer.final || (streamRenderState[message.id]?.done ?? completed)}
onLocate={onLocateAnswerFeatures}
onSubmit={onSubmitAnswerForm}
onAsk={onAsk}
/>
</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>
)}
>
<div className="space-y-3">
{message.role === "assistant" ? (
<AgentChatMessageHeader
running={streaming && index === messages.length - 1}
interactive={answer.report.some((part) => part.type === "answer-ui")}
/>
) : null}
{message.role === "assistant" && message.taskContext ? <p className="text-xs 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}
{message.role === "assistant" && !qaOnly && !answer.final ? (
<AgentMessageProgress
progress={message.progress}
running={streaming && index === messages.length - 1}
/>
) : null}
{message.content || answer.report.length > 0 ? (
message.role === "assistant" ? (
<AgentSpeechMessage
presentation={presentation}
messageId={message.id}
content={message.content}
disabled={streaming && index === messages.length - 1}
isSupported={isSpeechSupported}
speechState={speakingMessageId === message.id ? speechState : "idle"}
onSpeak={onSpeak}
onPause={onPauseSpeech}
onResume={onResumeSpeech}
onStop={onStopSpeech}
>
<ExpandableReading title="对话中的分析报告"
enabled={answer.report.some((part) => part.type === "answer-ui")}
summary={<p className="line-clamp-3">{answer.report.find((part) => part.type === "text")?.text ?? "交互内容已在工作区展开。"}</p>}>
<AnswerPartsView
parts={answer.report}
messageId={message.id}
disabled={streaming}
streaming={!answer.final && streaming && index === messages.length - 1}
streamDone={answer.final || (streamRenderState[message.id]?.done ?? !streaming)}
onLocate={onLocateAnswerFeatures}
onSubmit={onSubmitAnswerForm}
onAsk={onAsk}
/>
</ExpandableReading>
</AgentSpeechMessage>
) : (
<p className="whitespace-pre-wrap">{message.content}</p>
)
) : streaming && index === messages.length - 1 ? (
<span className="text-[var(--ink-muted)]">正在生成...</span>
) : null}
{message.role === "assistant" && answer.final && (answer.process.length > 0 || (!qaOnly && message.progress?.length)) ? (
<Disclosure key="completed-process" label="查看处理过程" className="border-t border-[var(--surface-divider)]">
<div className="space-y-3 py-2">
{!qaOnly ? <AgentMessageProgress progress={message.progress} running={false} /> : null}
<AnswerPartsView parts={answer.process} messageId={`${message.id}-process`} disabled streaming={false} streamDone />
{!qaOnly && !needsReply ? <AgentMessageDetails message={message} /> : null}
</div>
</Disclosure>
) : null}
{message.role === "assistant" && !qaOnly && (!answer.final || needsReply) ? (
<AgentMessageDetails
message={message}
onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
) : null}
{message.role === "assistant" && answer.final && message.progress?.some((item) => item.status === "error") ? (
<p role="status" className="text-xs text-[var(--status-warning-foreground)]">部分查询或工具未完成,可展开处理过程查看原因。</p>
) : null}
{message.role === "assistant" && !(streaming && index === messages.length - 1) ? (
<AgentChatMessageActions parts={answer.report} />
) : null}
</div>
</MessageContent>
</Message>
<AgentMessageProcessContent
messages={processMessages}
qaOnly={qaOnly}
runningMessageId={!completed ? message.id : undefined}
/>
</AgentProcessDisclosure>
) : null}
{message.role === "assistant" && completed && hasOutput && !hasProcess ? (
<AgentChatMessageToolbar speech={speech} copy={copy} />
) : null}
</motion.div>
);
})}
</AnimatePresence>
</MessageContent>
</Message>
</motion.div>
);
}
@@ -1385,11 +1511,7 @@ function AgentSpeechMessage({
content,
disabled,
isSupported,
speechState,
onSpeak,
onPause,
onResume,
onStop,
children
}: {
presentation: AgentCommandPanelPresentation;
@@ -1397,11 +1519,7 @@ function AgentSpeechMessage({
content: string;
disabled: boolean;
isSupported: boolean;
speechState: AgentSpeechState;
onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise<void> | void;
onPause: () => void;
onResume: () => void;
onStop: () => void;
children: ReactNode;
}) {
const contentRef = useRef<HTMLDivElement | null>(null);
@@ -1534,57 +1652,62 @@ function AgentSpeechMessage({
document.body
)
: null}
</div>
);
}
{isSupported && !disabled ? (
<div className="mt-2 flex min-h-8 items-center gap-1 border-t border-[var(--surface-divider)] pt-1.5">
{speechState === "idle" ? (
<SpeechIconButton
label="语音播放"
onClick={() => void Promise.resolve(onSpeak(messageId, speechText))}
>
<Volume2 size={15} aria-hidden="true" />
</SpeechIconButton>
) : null}
{speechState === "loading" ? (
<>
<SpeechIconButton label="正在生成语音" disabled>
<LoaderCircle size={15} className="animate-spin text-[var(--action-blue-hover)]" aria-hidden="true" />
</SpeechIconButton>
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
<Square size={14} fill="currentColor" aria-hidden="true" />
</SpeechIconButton>
</>
) : null}
{speechState === "playing" ? (
<>
<SpeechIconButton label="暂停语音播放" tone="primary" onClick={onPause}>
<Pause size={15} fill="currentColor" aria-hidden="true" />
</SpeechIconButton>
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
<Square size={14} fill="currentColor" aria-hidden="true" />
</SpeechIconButton>
</>
) : null}
{speechState === "paused" ? (
<>
<SpeechIconButton label="继续语音播放" tone="primary" onClick={onResume}>
<Play size={15} fill="currentColor" aria-hidden="true" />
</SpeechIconButton>
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
<Square size={14} fill="currentColor" aria-hidden="true" />
</SpeechIconButton>
</>
) : null}
<span className="text-[11px] text-[var(--ink-muted)]">
{speechState === "idle"
? "语音播放"
: speechState === "loading"
? "正在生成"
: speechState === "playing"
? "播放中"
: "已暂停"}
</span>
</div>
function AgentMessageSpeechControls({
speechState,
onSpeak,
onPause,
onResume,
onStop
}: {
speechState: AgentSpeechState;
onSpeak: () => void;
onPause: () => void;
onResume: () => void;
onStop: () => void;
}) {
return (
<div className="flex shrink-0 items-center gap-0.5">
{speechState === "idle" ? (
<SpeechIconButton label="语音播放" onClick={onSpeak}>
<Volume2 size={16} aria-hidden="true" />
<span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>语音播放</span>
</SpeechIconButton>
) : null}
{speechState === "loading" ? (
<>
<SpeechIconButton label="正在生成语音" disabled>
<LoaderCircle size={16} className="motion-safe:animate-spin" aria-hidden="true" />
</SpeechIconButton>
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
<Square size={16} aria-hidden="true" />
</SpeechIconButton>
</>
) : null}
{speechState === "playing" ? (
<>
<SpeechIconButton label="暂停语音播放" tone="primary" onClick={onPause}>
<Pause size={16} aria-hidden="true" />
<span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>暂停</span>
</SpeechIconButton>
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
<Square size={16} aria-hidden="true" />
</SpeechIconButton>
</>
) : null}
{speechState === "paused" ? (
<>
<SpeechIconButton label="继续语音播放" tone="primary" onClick={onResume}>
<Play size={16} aria-hidden="true" />
<span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>继续</span>
</SpeechIconButton>
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
<Square size={16} aria-hidden="true" />
</SpeechIconButton>
</>
) : null}
</div>
);
@@ -1610,10 +1733,11 @@ function SpeechIconButton({
title={label}
disabled={disabled}
onClick={onClick}
size="icon-sm"
size="sm"
variant="ghost"
className={cn(
"shrink-0 text-[var(--ink-muted)] hover:bg-[var(--surface-control)] disabled:cursor-wait",
AGENT_MESSAGE_ACTION_CLASS_NAME,
"shrink-0 disabled:cursor-wait",
tone === "primary" && "text-[var(--action-blue-hover)]",
tone === "danger" && "status-tone-danger text-[var(--status-foreground)]"
)}