- {running ? (
-
-
- 正在回复
-
- ) : interactive ? (
-
- 交互回答
-
- ) : null}
+
+
+
+ 正在回复
+
+
+ );
+}
+
+export const AGENT_MESSAGE_ACTION_CLASS_NAME =
+ "h-8 min-w-8 gap-1.5 px-2 text-sm font-normal text-muted-foreground shadow-none after:inset-0";
+export const AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME = "hidden @[320px]/agent-message-actions:inline";
+
+export function AgentChatMessageToolbar({
+ speech,
+ copy,
+ children
+}: {
+ speech?: ReactNode;
+ copy?: ReactNode;
+ children?: ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+ {speech}
+ {copy}
+
+
);
}
@@ -38,18 +51,16 @@ export function AgentChatMessageHeader({
export function AgentChatMessageActions({ parts }: { parts: AnswerUiPart[] }) {
const [copied, setCopied] = useState(false);
const [error, setError] = useState(false);
- const text = parts
- .map((part) => (part.type === "text" ? part.text : part.block.fallback_text))
- .join("\n\n");
+ const text = getAnswerPartsTranscript(parts);
if (!text.trim()) return null;
return (
{
try {
@@ -62,10 +73,18 @@ export function AgentChatMessageActions({ parts }: { parts: AnswerUiPart[] }) {
}
}}
>
- {copied ? : }
- {copied ? "已复制" : "复制回复"}
+ {error ? (
+
+ ) : copied ? (
+
+ ) : (
+
+ )}
+
+ {error ? "重试复制" : copied ? "已复制" : "复制"}
+
-
+
{error ? "复制失败,请选择文字复制" : copied ? "已复制回复文字与交互内容摘要" : ""}
diff --git a/src/features/agent/components/agent-command-panel.tsx b/src/features/agent/components/agent-command-panel.tsx
index d658d93..0369037 100644
--- a/src/features/agent/components/agent-command-panel.tsx
+++ b/src/features/agent/components/agent-command-panel.tsx
@@ -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
;
approvalMode?: AgentApprovalMode;
uiResults?: AgentUiResult[];
+ interactionRequests?: InteractionRequestsController;
reviewPanel?: ReactNode;
messageInbox?: ComponentProps;
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();
const [loadingSessionId, setLoadingSessionId] = useState(null);
const [scrollRequestId, setScrollRequestId] = useState(0);
+ const [submissionError, setSubmissionError] = useState(null);
const prefersReducedMotion = useReducedMotion();
const historyTriggerRef = useRef(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({
{historyPanel}
供水助手
+ className="flex shrink-0 items-center gap-1 whitespace-nowrap text-xs leading-4 text-muted-foreground sm:gap-1.5">
{agentConnectionMetadata[connectionStatus].label}
@@ -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 ? : null}
{reviewPanel}
+ {interactionRequests ? message.role === "user").map((message) => message.id))} /> : null}
{messageInbox ? : null}
@@ -590,11 +615,11 @@ export function AgentCommandPanel({
layoutDependency={centeredComposer}
transition={{ layout: agentLayoutTransition }}
>
+
[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({
}
}}
>
-
-
- {contentReference ?
-
-
-
-
{contentReference.title}
-
查看引用内容
- {contentReference.text}
-
+
+ {contentReference ?
+
+
+
+ {contentReference.title}
+ 查看引用内容
+
+ {contentReference.text}
+
+ {["解释这些内容", "比较差异与关联", "核查依据与缺口"].map((intent) => )}
-
-
+
+
: null}
{promptReference ? (
-
-
-
-
{promptReference.title}
- {promptReference.detail ?
{promptReference.detail}
: null}
+
+
+
+ {promptReference.title}
+ {promptReference.detail ? <>{" "}{promptReference.detail.split(" · ").map((detail, index) => {index ? " · " : ""}{detail})}> : null}
) : 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 ? "继续补充,或安排下一条消息…" : "询问供水问题,或描述需要处理的任务")}
/>
+ {submissionError ?
{submissionError}
: null}
-
- {!qaOnly ?
:
任务协作问答}
+
-
- {streaming ?
+ {acceptsAdditionalInput ? :
) : null}
- {streaming && !trimmedPrompt ? : (
-
- )}
+ {isStopAction ? : }
@@ -704,7 +734,12 @@ export function AgentCommandPanel({
)) : (
-
{promptSuggestionsUnavailable ? "推荐问题暂不可用,可直接输入任务。" : "描述调查目标,即可开始分析。"}
+
+
{promptSuggestionsUnavailable ? "推荐问题暂不可用,可直接输入任务。" : "描述调查目标,即可开始分析。"}
+ {promptSuggestionsUnavailable && onRetryPromptSuggestions && (
+
+ )}
+
)}
@@ -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 ? (
) : (
-
)}
@@ -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 ;
+}
+
function InputDeliverySelect({ presentation, label, value, onValueChange }: {
presentation: AgentCommandPanelPresentation;
label: string;
@@ -850,10 +888,10 @@ function InputDeliverySelect({ presentation, label, value, onValueChange }: {
{([ ["steer", "引导当前任务"], ["queue", "排队处理"] ] as const).map(([delivery, text]) => (
{text}
@@ -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 ? (
-
+
) : (
-
+
)}
{requestApproval ? "请求批准" : "始终允许"}
-
+
模型
+ 模型
);
}
@@ -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({
>
- {selectedModel?.label ?? "模型"}
+ {selectedModel?.label ?? "模型"}
-
+
;
+ return ;
}
-function BackendMessageList({
+type BackendMessageListProps = {
+ presentation: AgentCommandPanelPresentation;
+ messages: AgentChatMessage[];
+ streaming: boolean;
+ streamRenderState: AgentStreamRenderState;
+ onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise | void;
+ onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise | void;
+ onRejectQuestion?: (request: AgentQuestionRequest) => Promise | void;
+ isSpeechSupported: boolean;
+ speakingMessageId: string | null;
+ speechState: AgentSpeechState;
+ onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise | void;
+ onPauseSpeech: () => void;
+ onResumeSpeech: () => void;
+ onStopSpeech: () => void;
+ qaOnly: boolean;
+ onLocateAnswerFeatures?: (target: AnswerUiMapTarget) => Promise | void;
+ onSubmitAnswerForm: (text: string) => Promise | void;
+ onAsk?: (selection: AnswerUiSelection) => void;
+ interactionRequests?: InteractionRequestsController;
+};
+
+function BackendMessageList(props: BackendMessageListProps) {
+ const { messages, streaming, interactionRequests } = props;
+ const replies = new Map();
+ 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 (
+
+
+ {messages.map((message, index) => {
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
+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;
- onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise | void;
- onRejectQuestion?: (request: AgentQuestionRequest) => Promise | void;
- isSpeechSupported: boolean;
- speakingMessageId: string | null;
- speechState: AgentSpeechState;
- onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise | void;
- onPauseSpeech: () => void;
- onResumeSpeech: () => void;
- onStopSpeech: () => void;
- qaOnly: boolean;
- onLocateAnswerFeatures?: (target: AnswerUiMapTarget) => Promise | void;
- onSubmitAnswerForm: (text: string) => Promise | void;
- onAsk?: (selection: AnswerUiSelection) => void;
+ onAsk,
+ interactionRequests,
+}: BackendMessageListProps & {
+ ref?: Ref;
+ 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 ? (
+ void Promise.resolve(onSpeak(message.id, stripSpeechMarkdown(reportText)))}
+ onPause={onPauseSpeech}
+ onResume={onResumeSpeech}
+ onStop={onStopSpeech}
+ />
+ ) : null;
+ const copy = completed ? : 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 (
-
- {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 (
+
+
-
- : null}
+ {message.role === "assistant" && message.taskContext ? (
+
+ 依据:
+ {message.taskContext.execution_generation == null
+ ? "历史资料 · 轮次未记录"
+ : `第 ${message.taskContext.execution_generation} 轮`}
+ {message.taskContext.report_revision == null
+ ? " · 尚无报告"
+ : ` · 报告第 ${message.taskContext.report_revision} 版`}
+
+ ) : null}
+ {hasOutput ? (
+ message.role === "assistant" ? (
+
+
+ {answer.report.find((part) => part.type === "text")?.text ?? "交互内容已在工作区展开。"}
+
+ }
+ >
+
+
+
+ ) : reply ? (
+ void interactionRequests?.act(reply.record.interaction_id, "resume")}
+ />
+ ) : (
+
+ )
+ ) : null}
+ {message.role === "assistant" && processComplete && hasProcessError ? (
+
+ 部分查询或工具未完成,可展开处理过程查看原因。
+
+ ) : null}
+ {message.role === "assistant" &&
+ !qaOnly &&
+ (needsReply ||
+ (!completed && processMessages.length > 0) ||
+ (processMessages.length > 0 && !hasProcess)) ? (
+
+ ) : null}
+ {hasProcess ? (
+ (
+
+ {trigger}
+
)}
>
-
- {message.role === "assistant" ? (
-
part.type === "answer-ui")}
- />
- ) : null}
- {message.role === "assistant" && message.taskContext ?
- 依据:{message.taskContext.execution_generation == null ? "历史资料 · 轮次未记录" : `第 ${message.taskContext.execution_generation} 轮`}
- {message.taskContext.report_revision == null ? " · 尚无报告" : ` · 报告第 ${message.taskContext.report_revision} 版`}
-
: null}
- {message.role === "assistant" && !qaOnly && !answer.final ? (
-
- ) : null}
- {message.content || answer.report.length > 0 ? (
- message.role === "assistant" ? (
-
- part.type === "answer-ui")}
- summary={{answer.report.find((part) => part.type === "text")?.text ?? "交互内容已在工作区展开。"}
}>
-
-
-
- ) : (
- {message.content}
- )
- ) : streaming && index === messages.length - 1 ? (
- 正在生成...
- ) : null}
- {message.role === "assistant" && answer.final && (answer.process.length > 0 || (!qaOnly && message.progress?.length)) ? (
-
-
- {!qaOnly ?
: null}
-
- {!qaOnly && !needsReply ?
: null}
-
-
- ) : null}
- {message.role === "assistant" && !qaOnly && (!answer.final || needsReply) ? (
-
- ) : null}
- {message.role === "assistant" && answer.final && message.progress?.some((item) => item.status === "error") ? (
- 部分查询或工具未完成,可展开处理过程查看原因。
- ) : null}
- {message.role === "assistant" && !(streaming && index === messages.length - 1) ? (
-
- ) : null}
-
-
-
+
+
+ ) : null}
+ {message.role === "assistant" && completed && hasOutput && !hasProcess ? (
+
+ ) : null}
- );
- })}
-
+
+
);
}
@@ -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;
- onPause: () => void;
- onResume: () => void;
- onStop: () => void;
children: ReactNode;
}) {
const contentRef = useRef(null);
@@ -1534,57 +1652,62 @@ function AgentSpeechMessage({
document.body
)
: null}
+
+ );
+}
- {isSupported && !disabled ? (
-
- {speechState === "idle" ? (
-
void Promise.resolve(onSpeak(messageId, speechText))}
- >
-
-
- ) : null}
- {speechState === "loading" ? (
- <>
-
-
-
-
-
-
- >
- ) : null}
- {speechState === "playing" ? (
- <>
-
-
-
-
-
-
- >
- ) : null}
- {speechState === "paused" ? (
- <>
-
-
-
-
-
-
- >
- ) : null}
-
- {speechState === "idle"
- ? "语音播放"
- : speechState === "loading"
- ? "正在生成"
- : speechState === "playing"
- ? "播放中"
- : "已暂停"}
-
-
+function AgentMessageSpeechControls({
+ speechState,
+ onSpeak,
+ onPause,
+ onResume,
+ onStop
+}: {
+ speechState: AgentSpeechState;
+ onSpeak: () => void;
+ onPause: () => void;
+ onResume: () => void;
+ onStop: () => void;
+}) {
+ return (
+
+ {speechState === "idle" ? (
+
+
+ 语音播放
+
+ ) : null}
+ {speechState === "loading" ? (
+ <>
+
+
+
+
+
+
+ >
+ ) : null}
+ {speechState === "playing" ? (
+ <>
+
+
+ 暂停
+
+
+
+
+ >
+ ) : null}
+ {speechState === "paused" ? (
+ <>
+
+
+ 继续
+
+
+
+
+ >
) : null}
);
@@ -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)]"
)}
diff --git a/src/features/agent/components/agent-interaction-request-card.tsx b/src/features/agent/components/agent-interaction-request-card.tsx
new file mode 100644
index 0000000..1645cd5
--- /dev/null
+++ b/src/features/agent/components/agent-interaction-request-card.tsx
@@ -0,0 +1,146 @@
+import { CircleCheck, CircleHelp, LoaderCircle, RotateCw, X } from "lucide-react";
+import { Button } from "@/shared/ui/button";
+import { Checkbox } from "@/shared/ui/checkbox";
+import { cn } from "@/shared/ui/cn";
+import { Radio } from "@/shared/ui/radio";
+import { Textarea } from "@/shared/ui/textarea";
+import type { InteractionAnswer } from "../api/interaction-requests";
+import type { InteractionRequestEntry, InteractionRequestsController } from "../hooks/use-interaction-requests";
+import { AgentUserMessageText } from "./agent-user-message-text";
+
+const statuses = { pending: "等待回答", answered: "答案已保存", skipped: "已跳过", cancelled: "已取消", invalidated: "已失效" };
+const deliveries = { queued: "等待继续", dispatching: "正在继续处理", delivered: "已继续处理", blocked: "继续失败,可以重试", cancelled: "已停止继续" };
+
+export function AgentInteractionRequests({ controller, representedMessageIds }: {
+ controller: InteractionRequestsController;
+ representedMessageIds?: ReadonlySet;
+}) {
+ return
+ {controller.checking ? 正在核对提问状态,核对后可回答。
: null}
+ {controller.error ? {controller.error}
: null}
+ {controller.entries.filter(({ record }) => !(record.delivery && ["answered", "skipped"].includes(record.status)
+ && representedMessageIds?.has(record.delivery.native_message_id))).map((entry) =>
+ controller.update(entry.record.interaction_id, draft)} onAction={(action) => void controller.act(entry.record.interaction_id, action)} />)}
+ ;
+}
+
+/** Render the stored human answer, independent of the model-facing transcript. */
+export function AgentInteractionReply({ entry, checking, onResume }: {
+ entry: InteractionRequestEntry;
+ checking: boolean;
+ onResume: () => void;
+}) {
+ const { record } = entry;
+ const retryable = record.delivery && ["queued", "dispatching", "blocked"].includes(record.delivery.status);
+ return
+
+
+ {record.status === "skipped" ? "已跳过" : "已回答"}
+
+ {record.status === "skipped" ?
已跳过本组可选问题。
:
+ {record.questions.map((question) => {
+ const answer = record.answers?.find((item) => item.question_id === question.question_id);
+ return
+
- {question.title}
+
-
+ {answer?.selected_options.map((option) => )}
+ {answer?.custom_text ?
{answer.selected_options.length ?
补充说明 : null}
: null}
+ {!answer?.selected_options.length && !answer?.custom_text ? 未填写
: null}
+
+
;
+ })}
+
}
+ {entry.error ?
{entry.error}
: null}
+ {entry.conflict ?
查看本窗口未提交的输入,供复制
{JSON.stringify(entry.conflict, null, 2)} : null}
+ {record.delivery ?
+ {checking ? "正在核对提问" : deliveries[record.delivery.status]}
+ {retryable ? : null}
+
: null}
+
;
+}
+
+export function AgentInteractionRequestCard({ entry, checking, onChange, onAction }: {
+ entry: InteractionRequestEntry; checking: boolean; onChange: (draft: InteractionAnswer[]) => void;
+ onAction: (action: "reply" | "skip" | "cancel" | "resume") => void;
+}) {
+ const { record } = entry;
+ if (["answered", "skipped"].includes(record.status)) return
+
+
onAction("resume")} />
+
+
;
+ const pending = record.status === "pending";
+ const disabled = checking || entry.busy || !pending;
+ const values = entry.draft;
+ const answerFor = (id: string) => values.find((answer) => answer.question_id === id) ?? { question_id: id, selected_option_ids: [], custom_text: "" };
+ const change = (answer: InteractionAnswer) => onChange(record.questions.map((question) => question.question_id === answer.question_id ? answer : answerFor(question.question_id)));
+ const valid = record.questions.every((question) => !question.required || answerFor(question.question_id).selected_option_ids.length > 0 || (question.allow_custom && answerFor(question.question_id).custom_text.trim().length > 0));
+ const draftStatus = checking ? "正在核对提问" : entry.saving ? "正在保存草稿" : entry.dirty ? "草稿尚未保存"
+ : entry.draft.some((answer) => answer.selected_option_ids.length || answer.custom_text.trim()) ? "草稿已保存" : "提交后继续处理";
+
+ return
+
+
+
+ {record.questions.map((question) => {
+ const answer = answerFor(question.question_id);
+ const selected = new Set(answer.selected_option_ids);
+ return
;
+ })}
+ {entry.error ?
{entry.error}
: null}
+ {entry.conflict ?
查看本窗口未提交的输入,供复制
{JSON.stringify(entry.conflict, null, 2)} : null}
+
+
+
+ ;
+}
diff --git a/src/features/agent/components/agent-message-details.tsx b/src/features/agent/components/agent-message-details.tsx
index 64ee9a5..fc44aeb 100644
--- a/src/features/agent/components/agent-message-details.tsx
+++ b/src/features/agent/components/agent-message-details.tsx
@@ -1,3 +1,5 @@
+import { AgentProgressStatusIcon } from "./agent-progress-status-icon";
+import { AgentToolProgress } from "./agent-tool-progress";
import {
CheckCircle2,
ChevronDown,
@@ -40,7 +42,6 @@ import type {
AgentTodoUpdate
} from "../types";
-type ProgressStatus = AgentChatProgress["status"];
type TodoStatus = AgentTodoUpdate["todos"][number]["status"];
type PermissionStatus = AgentPermissionRequest["status"];
type QuestionStatus = AgentQuestionRequest["status"];
@@ -53,22 +54,6 @@ type AgentStatusMeta = {
activity?: StatusActivity;
};
-const progressStatusMeta: Record = {
- running: {
- label: "进行中",
- tone: "info",
- activity: "live"
- },
- completed: {
- label: "已完成",
- tone: "success"
- },
- error: {
- label: "失败",
- tone: "danger"
- }
-};
-
const todoStatusMeta: Record = {
completed: {
label: "完成",
@@ -100,6 +85,8 @@ const permissionStatusMeta: Record = {
};
const questionStatusMeta: Record = {
+ checking: { label: "核对中", tone: "info" },
+ invalidated: { label: "已失效", tone: "neutral" },
answered: { label: "已回答", tone: "success" },
rejected: { label: "已跳过", tone: "neutral" },
error: { label: "失败", tone: "danger" },
@@ -208,28 +195,21 @@ function AnimatedDetailItem({ children }: { children: ReactNode }) {
}
function ProgressList({ progress, running }: { progress: AgentChatProgress[]; running: boolean }) {
- const [expanded, setExpanded] = useState(false);
+ const [userExpanded, setExpanded] = useState(false);
+ const expanded = !running || userExpanded;
const listId = useId();
- const compact = running && !expanded;
- const visibleProgress = expanded
- ? progress
- : running
- ? progress.slice(-STREAMING_PROGRESS_LIMIT)
- : [];
- const listMode = expanded ? "expanded" : running ? "streaming" : "collapsed";
+ const visibleProgress = expanded ? progress : progress.slice(-STREAMING_PROGRESS_LIMIT);
return (
-
-
+
+ {running ?
- 执行进度
+ 执行进度
- {running ? (
-
- ) : null}
- {running ? (expanded ? "全部" : "最近") : "步骤"}
+
+ {expanded ? "全部" : "最近"}
- {expanded || !running
+ {expanded
? progress.length
: Math.min(progress.length, STREAMING_PROGRESS_LIMIT)}
/
@@ -254,14 +234,14 @@ function ProgressList({ progress, running }: { progress: AgentChatProgress[]; ru
aria-hidden="true"
/>
-
+
: null}
{visibleProgress.length ? (
-
+ {item.phase === "tool" ? : <>
+
{item.title}
{item.detail ? (
{item.detail}
) : null}
-
-
- {progressStatusMeta[item.status].label}
-
-
+ > }
))}
diff --git a/src/features/agent/components/agent-message-process-content.tsx b/src/features/agent/components/agent-message-process-content.tsx
new file mode 100644
index 0000000..ce7a856
--- /dev/null
+++ b/src/features/agent/components/agent-message-process-content.tsx
@@ -0,0 +1,37 @@
+import { AnswerPartsView } from "../answer-ui/answer-parts-view";
+import { getAnswerPresentation } from "../answer-ui/answer-presentation";
+import { hasPendingMessageInput } from "../presentation/message-process-groups";
+import type { AgentChatMessage } from "../types";
+import { AgentMessageDetails, AgentMessageProgress } from "./agent-message-details";
+
+export function AgentMessageProcessContent({
+ messages,
+ qaOnly,
+ runningMessageId
+}: {
+ messages: AgentChatMessage[];
+ qaOnly: boolean;
+ runningMessageId?: string;
+}) {
+ const progress = messages.flatMap((message) => (message.progress ?? []).map((item) => ({
+ ...item, id: JSON.stringify([message.id, item.id])
+ })));
+ return (
+ <>
+ {!qaOnly ?
: null}
+ {messages.map((message) => {
+ const { process } = getAnswerPresentation(message);
+ const running = message.id === runningMessageId;
+ const showDetails = !qaOnly && !running && !hasPendingMessageInput(message)
+ && Boolean(message.todos?.todos.length || message.permissions?.length || message.questions?.length);
+ if (!process.length && !showDetails) return null;
+ return
+ {process.length > 0 ? (
+
+ ) : null}
+ {showDetails ?
: null}
+
;
+ })}
+ >
+ );
+}
diff --git a/src/features/agent/components/agent-process-disclosure.tsx b/src/features/agent/components/agent-process-disclosure.tsx
new file mode 100644
index 0000000..687cae6
--- /dev/null
+++ b/src/features/agent/components/agent-process-disclosure.tsx
@@ -0,0 +1,227 @@
+import { useEffect, useId, useRef, useState, type MouseEvent, type ReactNode } from "react";
+import { flushSync } from "react-dom";
+import { ChevronDown } from "lucide-react";
+import { motion, useAnimationControls, useReducedMotion } from "motion/react";
+import { useStickToBottomContext } from "use-stick-to-bottom";
+import { Button } from "@/shared/ui/button";
+import { AGENT_MESSAGE_ACTION_CLASS_NAME } from "./agent-chat-message-chrome";
+import { cn } from "@/shared/ui/cn";
+import { agentEase } from "./agent-motion";
+
+type Anchor = {
+ scroll: HTMLElement;
+ element: HTMLElement;
+ top: number;
+ release: () => void;
+};
+
+/** A reader action stops bottom-following before it changes the message's layout. */
+export function AgentProcessDisclosure({
+ label,
+ children,
+ kind = "process",
+ toolbar,
+ live = false
+}: {
+ label: ReactNode;
+ children: ReactNode;
+ kind?: "process" | "tool";
+ toolbar?: (trigger: ReactNode) => ReactNode;
+ live?: boolean;
+}) {
+ const { scrollRef, stopScroll } = useStickToBottomContext();
+ const [userOpen, setOpen] = useState
();
+ const open = live || (userOpen ?? false);
+ const [animated, setAnimated] = useState(false);
+ const [phase, setPhase] = useState<"idle" | "opening" | "closing">("idle");
+ const requestedOpen = useRef(false);
+ const sequence = useRef(0);
+ const root = useRef(null);
+ const anchor = useRef(null);
+ const frame = useRef(undefined);
+ const controls = useAnimationControls();
+ const reducedMotion = useReducedMotion();
+ const contentId = useId();
+
+ function releaseAnchor() {
+ if (frame.current !== undefined) cancelAnimationFrame(frame.current);
+ frame.current = undefined;
+ anchor.current?.release();
+ anchor.current = null;
+ }
+
+ function captureAnchor() {
+ releaseAnchor();
+ const scroll = scrollRef.current;
+ const details = root.current;
+ if (!scroll || !details) return;
+ stopScroll();
+ const element = details.closest("[data-agent-message-id]") ?? details;
+ const previous = scroll.style.overflowAnchor;
+ scroll.style.overflowAnchor = "none";
+ const cancel = () => releaseAnchor();
+ for (const event of ["wheel", "pointerdown", "touchmove", "keydown"])
+ scroll.addEventListener(event, cancel, { passive: true });
+ anchor.current = {
+ scroll,
+ element,
+ top: element.getBoundingClientRect().top,
+ release: () => {
+ scroll.style.overflowAnchor = previous;
+ for (const event of ["wheel", "pointerdown", "touchmove", "keydown"])
+ scroll.removeEventListener(event, cancel);
+ }
+ };
+ }
+
+ function restoreAnchor() {
+ const value = anchor.current;
+ if (!value || !value.element.isConnected) return;
+ stopScroll();
+ const delta = value.element.getBoundingClientRect().top - value.top;
+ if (Math.abs(delta) > 0.5) value.scroll.scrollTop += delta;
+ }
+
+ async function toggle(event: MouseEvent) {
+ event.preventDefault();
+ const current = ++sequence.current;
+ const next = !(phase === "idle" ? open : requestedOpen.current);
+ requestedOpen.current = next;
+ controls.stop();
+ captureAnchor();
+ const immediate = Boolean(reducedMotion) || event.detail === 0;
+ setAnimated(!immediate);
+ if (next) {
+ controls.set(immediate ? { opacity: 1, y: 0 } : { opacity: 0, y: -4 });
+ flushSync(() => {
+ setPhase(immediate ? "idle" : "opening");
+ setOpen(true);
+ });
+ restoreAnchor();
+ } else {
+ setPhase(immediate ? "idle" : "closing");
+ }
+ if (!immediate)
+ await controls.start(next ? { opacity: 1, y: 0 } : { opacity: 0, y: -4 }, {
+ duration: 0.16,
+ ease: agentEase
+ });
+ if (sequence.current !== current) return;
+ if (!next) {
+ flushSync(() => setOpen(false));
+ controls.set({ opacity: 1, y: 0 });
+ }
+ restoreAnchor();
+ setPhase("idle");
+ frame.current = requestAnimationFrame(() => {
+ restoreAnchor();
+ releaseAnchor();
+ });
+ }
+
+ useEffect(
+ () => () => {
+ sequence.current += 1;
+ controls.stop();
+ if (frame.current !== undefined) cancelAnimationFrame(frame.current);
+ anchor.current?.release();
+ anchor.current = null;
+ },
+ [controls]
+ );
+
+ const processIcon = (
+
+
+
+ );
+ return (
+ <>
+ {!live && toolbar?.(
+
+ )}
+
+ {
+ void toggle(event);
+ }}
+ aria-controls={contentId}
+ aria-hidden={toolbar ? true : undefined}
+ tabIndex={toolbar ? -1 : undefined}
+ className={cn(
+ toolbar
+ ? "hidden"
+ : "flex min-h-10 cursor-pointer list-none gap-2 text-xs font-medium leading-5 text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring [&::-webkit-details-marker]:hidden",
+ !toolbar &&
+ (kind === "process" ? "items-center px-3 py-2" : "items-start rounded-md py-1"),
+ kind === "process" && open && "border-b border-border"
+ )}
+ >
+ {!toolbar ? (
+ <>
+ {kind === "process" && !toolbar ? processIcon : null}
+ {label}
+ {kind === "tool" ? (
+
+
+
+ ) : null}
+ >
+ ) : null}
+
+
+ {children}
+
+
+ >
+ );
+}
diff --git a/src/features/agent/components/agent-progress-status-icon.tsx b/src/features/agent/components/agent-progress-status-icon.tsx
new file mode 100644
index 0000000..3c3c870
--- /dev/null
+++ b/src/features/agent/components/agent-progress-status-icon.tsx
@@ -0,0 +1,31 @@
+import { CheckCircle2, LoaderCircle, XCircle } from "lucide-react";
+import { StatusIcon, type StatusTone } from "@/shared/ui/status";
+import type { AgentChatProgress } from "../types";
+
+const states = {
+ completed: { label: "已完成", tone: "success", Icon: CheckCircle2 },
+ error: { label: "失败", tone: "danger", Icon: XCircle },
+ running: { label: "进行中", tone: "info", Icon: LoaderCircle }
+} satisfies Record<
+ AgentChatProgress["status"],
+ { label: string; tone: StatusTone; Icon: typeof CheckCircle2 }
+>;
+
+/** Ordinary steps and tool calls share the same status glyph, size and tone. */
+export function AgentProgressStatusIcon({ status }: { status: AgentChatProgress["status"] }) {
+ const { label, tone, Icon } = states[status];
+ return (
+
+
+
+ );
+}
diff --git a/src/features/agent/components/agent-tool-progress.test.tsx b/src/features/agent/components/agent-tool-progress.test.tsx
new file mode 100644
index 0000000..381720c
--- /dev/null
+++ b/src/features/agent/components/agent-tool-progress.test.tsx
@@ -0,0 +1,26 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, expect, it, vi } from "vitest";
+import { AgentToolProgress } from "./agent-tool-progress";
+
+vi.mock("use-stick-to-bottom", () => ({ useStickToBottomContext: () => ({ scrollRef: { current: null }, stopScroll: vi.fn() }) }));
+
+afterEach(cleanup);
+
+it("keeps full structured parameters collapsed until the tool row opens", () => {
+ const address = "完整地址".repeat(70);
+ const { container } = render();
+ const details = container.querySelector("details")!;
+ expect(details.open).toBe(false);
+ expect(screen.getByText("mcp_tjwater_geocode")).toBeTruthy();
+ expect(container.querySelector("pre")?.textContent).toContain(address);
+ expect(screen.queryByText("legacy secret summary")).toBeNull();
+ fireEvent.click(container.querySelector("summary")!);
+ expect(details.open).toBe(true);
+});
+
+it("retains historical details and removes the old status text from the tool label", () => {
+ const { container } = render();
+ expect(screen.getByText("mcp_tjwater_query_nearby_elements")).toBeTruthy();
+ expect(screen.getByText("历史参数:radius=500")).toBeTruthy();
+ expect(container.querySelector("details")?.open).toBe(false);
+});
diff --git a/src/features/agent/components/agent-tool-progress.tsx b/src/features/agent/components/agent-tool-progress.tsx
new file mode 100644
index 0000000..dbad2b6
--- /dev/null
+++ b/src/features/agent/components/agent-tool-progress.tsx
@@ -0,0 +1,63 @@
+import { Wrench } from "lucide-react";
+import { cn } from "@/shared/ui/cn";
+import { AgentProgressStatusIcon } from "./agent-progress-status-icon";
+import type { AgentChatProgress } from "../types";
+import { AgentProcessDisclosure } from "./agent-process-disclosure";
+
+export function AgentToolProgress({
+ item,
+ compact = false
+}: {
+ item: AgentChatProgress;
+ compact?: boolean;
+}) {
+ const name =
+ item.tool?.name ??
+ item.title
+ .replace(/^(?:正在调用|准备调用)\s*/, "")
+ .replace(
+ /\s*(?:调用失败|已执行完成|已完成|正在执行|正在调用|调用中|已进入待执行状态|等待执行|执行失败)\s*$/,
+ ""
+ );
+ const input = item.tool?.input;
+ return (
+
+
+
+
+
+
+ {name || "工具调用"}
+
+
+ }
+ >
+ {input ? (
+
+
调用参数
+
+ {JSON.stringify(input, null, 2)}
+
+ {item.status === "error" && item.detail ? (
+
+ {item.detail}
+
+ ) : null}
+
+ ) : null}
+ {!input && item.detail ? (
+
+ {item.detail}
+
+ ) : !input ? (
+ 无附加参数
+ ) : null}
+
+ );
+}
diff --git a/src/features/agent/components/agent-user-message-text.tsx b/src/features/agent/components/agent-user-message-text.tsx
new file mode 100644
index 0000000..a5700a3
--- /dev/null
+++ b/src/features/agent/components/agent-user-message-text.tsx
@@ -0,0 +1,5 @@
+import { copySelectedParagraph } from "@/shared/ai-elements/paragraph-copy";
+
+export function AgentUserMessageText({ content }: { content: string }) {
+ return copySelectedParagraph(event, event.currentTarget)}>{content}
;
+}
diff --git a/src/features/agent/hooks/use-agent-answer-transition.ts b/src/features/agent/hooks/use-agent-answer-transition.ts
new file mode 100644
index 0000000..c90442c
--- /dev/null
+++ b/src/features/agent/hooks/use-agent-answer-transition.ts
@@ -0,0 +1,103 @@
+import { useLayoutEffect, useRef, useState } from "react";
+import { useAnimationControls, useReducedMotion } from "motion/react";
+import { useStickToBottomContext } from "use-stick-to-bottom";
+import type { AgentChatMessage } from "../types";
+import { agentEase } from "../components/agent-motion";
+
+type ReadingAnchor = { element: HTMLElement; text: string; top: number };
+const readable = "p, h1, h2, h3, li, summary";
+
+/** A final report is readable before transport cleanup finishes. Handoff once. */
+export function useAgentAnswerTransition(message: AgentChatMessage, running: boolean) {
+ const ready = Boolean(message.finalAnswer) || !running;
+ const [committed, setCommitted] = useState(ready);
+ const [phase, setPhase] = useState(ready ? "settled" : "streaming");
+ const previous = useRef(message);
+ const root = useRef(null);
+ const anchor = useRef<{ scroll: HTMLElement; reading?: ReadingAnchor; top: number; release: () => void } | null>(null);
+ const controls = useAnimationControls();
+ const reducedMotion = useReducedMotion();
+ const { scrollRef, stopScroll, state } = useStickToBottomContext();
+
+ useLayoutEffect(() => { if (!ready) previous.current = message; }, [message, ready]);
+
+ useLayoutEffect(() => {
+ if (!ready && committed) {
+ setCommitted(false);
+ setPhase("streaming");
+ controls.set({ opacity: 1 });
+ return;
+ }
+ if (!ready || committed) return;
+ let disposed = false;
+ const scroll = scrollRef.current;
+ const element = root.current;
+ if (scroll && element) {
+ anchor.current?.release();
+ anchor.current = null;
+ const viewport = scroll.getBoundingClientRect();
+ // Use the same near-bottom state as the conversation's follow button.
+ const following = state.isAtBottom || state.isNearBottom;
+ const reading = !following ? [...scroll.querySelectorAll(readable)].find((node) => {
+ const bounds = node.getBoundingClientRect();
+ return bounds.height > 0 && bounds.bottom > viewport.top + 12 && bounds.top < viewport.bottom;
+ }) : undefined;
+ stopScroll();
+ const overflowAnchor = scroll.style.overflowAnchor;
+ scroll.style.overflowAnchor = "none";
+ const cancel = () => { anchor.current?.release(); anchor.current = null; };
+ const events = ["wheel", "pointerdown", "touchmove", "keydown"];
+ for (const event of events) scroll.addEventListener(event, cancel, { passive: true });
+ anchor.current = { scroll,
+ reading: reading ? { element: reading, text: reading.textContent ?? "", top: reading.getBoundingClientRect().top } : undefined,
+ top: Math.max(viewport.top + 12, element.getBoundingClientRect().top),
+ release: () => {
+ scroll.style.overflowAnchor = overflowAnchor;
+ for (const event of events) scroll.removeEventListener(event, cancel);
+ },
+ };
+ }
+ const commit = () => { if (!disposed) { setCommitted(true); setPhase(reducedMotion ? "settled" : "entering"); } };
+ if (reducedMotion) commit();
+ else {
+ setPhase("leaving");
+ void controls.start({ opacity: 0 }, { duration: 0.08, ease: agentEase }).then(commit);
+ }
+ return () => { disposed = true; controls.stop(); };
+ }, [committed, controls, ready, reducedMotion, scrollRef, state, stopScroll]);
+
+ useLayoutEffect(() => {
+ if (!committed) return;
+ let disposed = false;
+ let frame: number | undefined;
+ const restore = () => {
+ const current = anchor.current;
+ if (!current || !root.current) return;
+ let target: HTMLElement | null = root.current.querySelector("[data-answer-parts]") ?? root.current;
+ const reading = current.reading;
+ if (reading) {
+ target = reading.element.isConnected && reading.element.textContent === reading.text && reading.element.getBoundingClientRect().height > 0 ? reading.element
+ : [...current.scroll.querySelectorAll(readable)].find((node) => node.textContent === reading.text && node.getBoundingClientRect().height > 0) ?? target;
+ }
+ if (target) current.scroll.scrollTop += target.getBoundingClientRect().top - (reading?.top ?? current.top);
+ };
+ restore();
+ frame = requestAnimationFrame(restore);
+ const finish = () => {
+ if (disposed) return;
+ restore();
+ anchor.current?.release();
+ anchor.current = null;
+ setPhase("settled");
+ };
+ if (reducedMotion) {
+ controls.set({ opacity: 1 });
+ frame = requestAnimationFrame(() => { restore(); frame = requestAnimationFrame(finish); });
+ }
+ else void controls.start({ opacity: 1 }, { duration: 0.12, ease: agentEase }).then(finish);
+ return () => { disposed = true; if (frame !== undefined) cancelAnimationFrame(frame); controls.stop(); };
+ }, [committed, controls, reducedMotion]);
+
+ useLayoutEffect(() => () => { anchor.current?.release(); anchor.current = null; }, []);
+ return { message: ready && !committed ? previous.current : message, completed: committed, phase, root, controls };
+}
diff --git a/src/features/agent/hooks/use-agent-connection.test.tsx b/src/features/agent/hooks/use-agent-connection.test.tsx
new file mode 100644
index 0000000..ebe3ac0
--- /dev/null
+++ b/src/features/agent/hooks/use-agent-connection.test.tsx
@@ -0,0 +1,51 @@
+import { act, renderHook } from "@testing-library/react";
+import { afterEach, expect, it, vi } from "vitest";
+import { useAgentConnection } from "./use-agent-connection";
+
+afterEach(() => vi.useRealTimers());
+
+it("rechecks an offline service, probes on focus and clears work on unmount", async () => {
+ vi.useFakeTimers();
+ const getHealth = vi.fn().mockRejectedValueOnce(new Error("offline")).mockResolvedValue(undefined);
+ const client = { getHealth };
+ const { result, unmount } = renderHook(() => useAgentConnection(client, "project-a"));
+ await act(async () => {});
+ expect(result.current.connectionStatus).toBe("offline");
+ await act(async () => vi.advanceTimersByTimeAsync(1_000));
+ expect(result.current.connectionStatus).toBe("online");
+ expect(result.current.recoveryVersion).toBe(1);
+ await act(async () => window.dispatchEvent(new Event("focus")));
+ expect(getHealth).toHaveBeenCalledTimes(3);
+ unmount();
+ expect(vi.getTimerCount()).toBe(0);
+});
+
+it("discards a late result from a previous client and scope", async () => {
+ vi.useFakeTimers();
+ let finish!: () => void;
+ let oldSignal!: AbortSignal;
+ const oldClient = { getHealth: vi.fn((signal: AbortSignal) => { oldSignal = signal; return new Promise((resolve) => { finish = resolve; }); }) };
+ const nextClient = { getHealth: vi.fn(async () => { throw new Error("next service offline"); }) };
+ const { result, rerender, unmount } = renderHook(({ client, scope }) => useAgentConnection(client, scope), { initialProps: { client: oldClient, scope: "old" } });
+ await act(async () => {});
+ rerender({ client: nextClient, scope: "next" });
+ await act(async () => {});
+ expect(oldSignal.aborted).toBe(true);
+ await act(async () => finish());
+ expect(result.current.connectionStatus).toBe("offline");
+ unmount();
+});
+
+it("aborts a stalled probe and recovers on the next retry", async () => {
+ vi.useFakeTimers();
+ const getHealth = vi.fn().mockImplementationOnce((signal: AbortSignal) => new Promise((_resolve, reject) => {
+ signal.addEventListener("abort", () => reject(new DOMException("timeout", "AbortError")), { once: true });
+ })).mockResolvedValue(undefined);
+ const client = { getHealth };
+ const { result, unmount } = renderHook(() => useAgentConnection(client, "project"));
+ await act(async () => vi.advanceTimersByTimeAsync(5_000));
+ expect(result.current.connectionStatus).toBe("offline");
+ await act(async () => vi.advanceTimersByTimeAsync(1_000));
+ expect(result.current.connectionStatus).toBe("online");
+ unmount();
+});
diff --git a/src/features/agent/hooks/use-agent-connection.ts b/src/features/agent/hooks/use-agent-connection.ts
new file mode 100644
index 0000000..8f20b24
--- /dev/null
+++ b/src/features/agent/hooks/use-agent-connection.ts
@@ -0,0 +1,80 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { AgentApiClient } from "../api/client";
+import type { AgentConnectionStatus } from "../types";
+
+/** Service readiness is independent of a conversation's business result. */
+export function useAgentConnection(client: Pick, scope: string) {
+ const [snapshot, setSnapshot] = useState({ client, scope, status: "connecting" as AgentConnectionStatus, recoveryVersion: 0 });
+ const check = useRef<() => void>(() => {});
+ const recheck = useCallback(() => check.current(), []);
+ useEffect(() => {
+ let disposed = false;
+ let timer: ReturnType | undefined;
+ let timeout: ReturnType | undefined;
+ let controller: AbortController | undefined;
+ let failures = 0;
+ let status: AgentConnectionStatus = "connecting";
+ let recoveryVersion = 0;
+ const publish = (next: AgentConnectionStatus) => {
+ if (disposed) return;
+ if (next === "online" && status === "offline") recoveryVersion++;
+ status = next;
+ setSnapshot((previous) => previous.client === client && previous.scope === scope && previous.status === status && previous.recoveryVersion === recoveryVersion
+ ? previous : { client, scope, status, recoveryVersion });
+ };
+ const probe = async () => {
+ if (disposed || controller) return;
+ clearTimeout(timer);
+ if (!navigator.onLine) { publish("offline"); return; }
+ const pending = new AbortController();
+ controller = pending;
+ timeout = setTimeout(() => pending.abort(), 5_000);
+ try {
+ await client.getHealth(pending.signal);
+ if (disposed || controller !== pending) return;
+ failures = 0;
+ publish("online");
+ } catch {
+ if (disposed || controller !== pending) return;
+ failures++;
+ publish("offline");
+ } finally {
+ if (controller === pending) {
+ clearTimeout(timeout);
+ controller = undefined;
+ if (!disposed) timer = setTimeout(() => void probe(), failures ? Math.min(1_000 * 2 ** Math.min(failures - 1, 4), 15_000) : 15_000);
+ }
+ }
+ };
+ const reconnect = () => { void probe(); };
+ const visible = () => { if (document.visibilityState === "visible") reconnect(); };
+ const offline = () => {
+ clearTimeout(timer);
+ clearTimeout(timeout);
+ const pending = controller;
+ controller = undefined;
+ pending?.abort();
+ publish("offline");
+ };
+ check.current = reconnect;
+ publish("connecting");
+ reconnect();
+ window.addEventListener("focus", reconnect);
+ window.addEventListener("online", reconnect);
+ window.addEventListener("offline", offline);
+ document.addEventListener("visibilitychange", visible);
+ return () => {
+ disposed = true;
+ clearTimeout(timer);
+ clearTimeout(timeout);
+ controller?.abort();
+ check.current = () => {};
+ window.removeEventListener("focus", reconnect);
+ window.removeEventListener("online", reconnect);
+ window.removeEventListener("offline", offline);
+ document.removeEventListener("visibilitychange", visible);
+ };
+ }, [client, scope]);
+ return { connectionStatus: snapshot.client === client && snapshot.scope === scope ? snapshot.status : "connecting" as const,
+ recoveryVersion: snapshot.client === client && snapshot.scope === scope ? snapshot.recoveryVersion : 0, recheck };
+}
diff --git a/src/features/agent/hooks/use-interaction-requests.test.tsx b/src/features/agent/hooks/use-interaction-requests.test.tsx
new file mode 100644
index 0000000..62e5b34
--- /dev/null
+++ b/src/features/agent/hooks/use-interaction-requests.test.tsx
@@ -0,0 +1,138 @@
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { expect, it, vi } from "vitest";
+import type { InteractionRequest, InteractionRequestApi } from "../api/interaction-requests";
+import { useInteractionRequests } from "./use-interaction-requests";
+const record = (status: InteractionRequest["status"] = "pending", revision = 0): InteractionRequest => ({ version: "agent-interaction-request@1", interaction_id: "i", message_id: "m", turn_id: "t", call_id: "c", questions: [{ question_id: "q", title: "确认范围", body: "选择范围", mode: "single", options: [{ id: "all", label: "全部" }], required: true, allow_custom: true }], status, revision, draft_revision: 0, draft: [], created_at: "now" });
+it("debounces draft CAS and never continues on open; late pending reads cannot revive accepted answers", async () => {
+ vi.useFakeTimers();
+ const root = createRoot(document.createElement("div"));
+ let readResolve!: (value: { items: InteractionRequest[] }) => void;
+ const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record("answered", 1)), writeInteractionRequest: vi.fn(async (_session, _id, action) => action === "draft" ? { ...record(), draft_revision: 1 } : { ...record("answered", 1), delivery: { native_message_id: "native", revision: 0, status: "queued" as const } }) };
+ const resume = vi.fn(async () => {});
+ let current!: ReturnType;
+ function Probe({ session = "first" }: { session?: string }) { current = useInteractionRequests(api, "actor:project", session, resume); return null; }
+ try {
+ await act(async () => root.render());
+ expect(resume).not.toHaveBeenCalled();
+ await act(async () => current.update("i", [{ question_id: "q", selected_option_ids: ["all"], custom_text: "" }]));
+ await act(async () => vi.advanceTimersByTimeAsync(499));
+ expect(api.writeInteractionRequest).not.toHaveBeenCalled();
+ await act(async () => vi.advanceTimersByTimeAsync(1));
+ expect(api.writeInteractionRequest).toHaveBeenCalledWith("first", "i", "draft", expect.objectContaining({ expected_draft_revision: 0 }));
+ vi.mocked(api.listInteractionRequests).mockImplementationOnce(() => new Promise((resolve) => { readResolve = resolve; }));
+ let refresh!: Promise;
+ await act(async () => { refresh = current.refresh(); });
+ // Finish checking then submit. A stale read dispatched before submit must not win afterward.
+ await act(async () => { readResolve({ items: [record()] }); await refresh; });
+ await act(async () => current.act("i", "reply"));
+ expect(current.entries[0].record.status).toBe("answered");
+ expect(resume).toHaveBeenCalledWith("first", "i");
+ await act(async () => current.refresh());
+ expect(current.entries[0].record.status).toBe("answered");
+ await act(async () => root.render());
+ expect(current.entries[0].record.status).toBe("pending");
+ await act(async () => root.render());
+ expect(current.entries[0].record.status).toBe("answered");
+ } finally { await act(async () => root.unmount()); vi.useRealTimers(); }
+});
+it("binds a delayed submission to its original session and keeps the next session unchanged", async () => {
+ const root = createRoot(document.createElement("div"));
+ let resolveReply!: (value: InteractionRequest) => void;
+ const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record()), writeInteractionRequest: vi.fn(() => new Promise((resolve) => { resolveReply = resolve; })) };
+ const resume = vi.fn(async () => {});
+ let current!: ReturnType;
+ function Probe({ session }: { session: string }) { current = useInteractionRequests(api, "actor", session, resume); return null; }
+ try {
+ await act(async () => root.render());
+ await act(async () => current.update("i", [{ question_id: "q", selected_option_ids: ["all"], custom_text: "first draft" }]));
+ let submission!: Promise;
+ await act(async () => { submission = current.act("i", "reply"); });
+ await act(async () => root.render());
+ await act(async () => { resolveReply({ ...record("answered", 1), delivery: { native_message_id: "native", revision: 0, status: "queued" } }); await submission; });
+ expect(resume).toHaveBeenCalledWith("first", "i");
+ expect(current.entries[0].record.status).toBe("pending");
+ expect(current.entries[0].draft).toEqual([]);
+ await act(async () => root.render());
+ expect(current.entries[0].record.status).toBe("answered");
+ } finally { await act(async () => root.unmount()); }
+});
+it("keeps delivery revisions monotonic and ignores superseded reads", async () => {
+ const root = createRoot(document.createElement("div"));
+ const delivered = { ...record("answered", 1), delivery: { native_message_id: "native", revision: 2, status: "delivered" as const } };
+ const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [delivered] })), getInteractionRequest: vi.fn(async () => delivered), writeInteractionRequest: vi.fn(async () => delivered) };
+ const resume = vi.fn(async () => {});
+ let current!: ReturnType;
+ function Probe() { current = useInteractionRequests(api, "actor", "session", resume); return null; }
+ try {
+ await act(async () => root.render());
+ vi.mocked(api.listInteractionRequests).mockResolvedValueOnce({ items: [{ ...delivered, delivery: { ...delivered.delivery, revision: 1, status: "queued" } }] });
+ await act(async () => current.refresh());
+ expect(current.entries[0].record.delivery?.status).toBe("delivered");
+ let first!: (value: { items: InteractionRequest[] }) => void;
+ let second!: (value: { items: InteractionRequest[] }) => void;
+ vi.mocked(api.listInteractionRequests).mockImplementationOnce(() => new Promise((resolve) => { first = resolve; })).mockImplementationOnce(() => new Promise((resolve) => { second = resolve; }));
+ let a!: Promise, b!: Promise;
+ await act(async () => { a = current.refresh(); b = current.refresh(); });
+ await act(async () => { first({ items: [delivered] }); await a; });
+ expect(current.checking).toBe(true);
+ await act(async () => { second({ items: [delivered] }); await b; });
+ expect(current.checking).toBe(false);
+ } finally { await act(async () => root.unmount()); }
+});
+it("purges revoked snapshots and prevents pending reads and old callbacks from restoring them", async () => {
+ const { InteractionRequestApiError } = await import("../api/interaction-requests");
+ const root = createRoot(document.createElement("div"));
+ const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record()), writeInteractionRequest: vi.fn(async () => record()) };
+ const resume = vi.fn(async () => {});
+ let current!: ReturnType;
+ function Probe({ client = api }: { client?: InteractionRequestApi }) { current = useInteractionRequests(client, "actor", "session", resume); return null; }
+ try {
+ await act(async () => root.render());
+ const staleAction = current.act;
+ vi.mocked(api.listInteractionRequests).mockRejectedValueOnce(new InteractionRequestApiError("无权访问", 403));
+ await act(async () => current.refresh());
+ expect(current.entries).toEqual([]);
+ await act(async () => staleAction("i", "reply"));
+ expect(api.writeInteractionRequest).not.toHaveBeenCalled();
+ const next = { ...api, listInteractionRequests: vi.fn(async () => ({ items: [] })) };
+ await act(async () => root.render());
+ expect(current.entries).toEqual([]);
+ await act(async () => staleAction("i", "reply"));
+ expect(resume).not.toHaveBeenCalled();
+ } finally { await act(async () => root.unmount()); }
+});
+it("flushes the old session draft on switch without waiting for the debounce", async () => {
+ vi.useFakeTimers();
+ const root = createRoot(document.createElement("div"));
+ const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record()), writeInteractionRequest: vi.fn(async () => ({ ...record(), draft_revision: 1 })) };
+ const resume = vi.fn(async () => {});
+ let current!: ReturnType;
+ function Probe({ session }: { session: string }) { current = useInteractionRequests(api, "actor", session, resume); return null; }
+ try {
+ await act(async () => root.render());
+ await act(async () => current.update("i", [{ question_id: "q", selected_option_ids: [], custom_text: "保留的输入" }]));
+ expect(api.writeInteractionRequest).not.toHaveBeenCalled();
+ await act(async () => root.render());
+ expect(api.writeInteractionRequest).toHaveBeenCalledWith("first", "i", "draft", expect.objectContaining({ answers: [{ question_id: "q", selected_option_ids: [], custom_text: "保留的输入" }] }));
+ expect(current.entries[0].draft).toEqual([]);
+ } finally { await act(async () => root.unmount()); vi.useRealTimers(); }
+});
+it("drops an in-flight accepted reply after the API identity changes", async () => {
+ const root = createRoot(document.createElement("div"));
+ let resolveReply!: (value: InteractionRequest) => void;
+ const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record()), writeInteractionRequest: vi.fn(() => new Promise((resolve) => { resolveReply = resolve; })) };
+ const other = { ...api, listInteractionRequests: vi.fn(async () => ({ items: [] })) };
+ const resume = vi.fn(async () => {});
+ let current!: ReturnType;
+ function Probe({ client }: { client: InteractionRequestApi }) { current = useInteractionRequests(client, "project", "session", resume); return null; }
+ try {
+ await act(async () => root.render());
+ let submission!: Promise;
+ await act(async () => { submission = current.act("i", "reply"); });
+ await act(async () => root.render());
+ await act(async () => { resolveReply({ ...record("answered", 1), delivery: { native_message_id: "native", revision: 0, status: "queued" } }); await submission; });
+ expect(current.entries).toEqual([]);
+ expect(resume).not.toHaveBeenCalled();
+ } finally { await act(async () => root.unmount()); }
+});
diff --git a/src/features/agent/hooks/use-interaction-requests.ts b/src/features/agent/hooks/use-interaction-requests.ts
new file mode 100644
index 0000000..8fea7a5
--- /dev/null
+++ b/src/features/agent/hooks/use-interaction-requests.ts
@@ -0,0 +1,124 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { InteractionRequestApiError, canReplaceInteractionRequest, type InteractionAnswer, type InteractionRequest, type InteractionRequestApi } from "../api/interaction-requests";
+
+const version = "agent-interaction-request@1" as const;
+const requestId = () => crypto.randomUUID();
+type Entry = { record: InteractionRequest; draft: InteractionAnswer[]; dirty: boolean; busy: boolean; saving?: boolean; error?: string; conflict?: InteractionAnswer[]; submission?: { id: string; answers: InteractionAnswer[] }; draftWrite?: { id: string; answers: InteractionAnswer[]; revision: number } };
+export type InteractionRequestEntry = Entry;
+const denied = (error: unknown) => error instanceof Error && "status" in error && typeof error.status === "number" && [401, 403, 404].includes(error.status);
+export function useInteractionRequests(api: InteractionRequestApi, scope: string, sessionId: string | null, onContinue: (sessionId: string, interactionId: string) => Promise) {
+ const identity = useRef({ api, scope, generation: 0, revoked: false });
+ const cache = useRef(new Map>());
+ const checked = useRef(new Set());
+ const sequences = useRef(new Map());
+ if (identity.current.api !== api || identity.current.scope !== scope) {
+ identity.current = { api, scope, generation: identity.current.generation + 1, revoked: false };
+ cache.current.clear(); checked.current.clear(); sequences.current.clear();
+ }
+ const generation = identity.current.generation;
+ const valid = useCallback(() => identity.current.generation === generation && !identity.current.revoked, [generation]);
+ const key = `${scope}:${generation}:${sessionId ?? ""}`;
+ const active = useRef(key); active.current = key;
+ const [tick, render] = useState(0);
+ const [checkingKey, setCheckingKey] = useState();
+ const [loadError, setLoadError] = useState<{ key: string; message: string }>();
+ const notify = useCallback(() => render((value) => value + 1), []);
+ const entries = useMemo(() => {
+ const existing = cache.current.get(key) ?? new Map();
+ cache.current.set(key, existing);
+ return existing;
+ }, [key]);
+ const purge = useCallback((error: unknown) => {
+ if (!valid() || !denied(error)) return false;
+ cache.current.forEach((items) => items.clear());
+ checked.current.clear(); sequences.current.clear();
+ identity.current.generation += 1;
+ identity.current.revoked = true;
+ setLoadError({ key, message: error instanceof Error ? error.message : "提问访问权限已失效" });
+ notify(); return true;
+ }, [key, notify, valid]);
+ const refresh = useCallback(async () => {
+ if (!sessionId || !valid()) return;
+ const target = entries;
+ const sequence = (sequences.current.get(key) ?? 0) + 1;
+ sequences.current.set(key, sequence);
+ const latest = () => valid() && sequences.current.get(key) === sequence;
+ setCheckingKey(key); checked.current.delete(key);
+ try {
+ const result = await api.listInteractionRequests(sessionId);
+ if (!latest()) return;
+ for (const record of result.items) {
+ const previous = target.get(record.interaction_id);
+ if (previous && !canReplaceInteractionRequest(record, previous.record)) continue;
+ const next: Entry = { ...previous, record, draft: previous?.dirty ? previous.draft : record.draft, dirty: previous?.dirty ?? false, busy: previous?.busy ?? false,
+ ...(record.status !== "pending" && previous?.dirty ? { conflict: previous.draft } : {}) };
+ target.set(record.interaction_id, previous ? Object.assign(previous, next) : next);
+ }
+ checked.current.add(key);
+ if (active.current === key) setLoadError(undefined);
+ } catch (error) {
+ if (latest() && !purge(error) && active.current === key) setLoadError({ key, message: error instanceof Error ? error.message : "提问核对失败" });
+ } finally { if (latest() && active.current === key) { setCheckingKey(undefined); notify(); } }
+ }, [api, entries, key, sessionId, notify, valid, purge]);
+ useEffect(() => { void refresh(); }, [refresh]);
+ const update = (interactionId: string, draft: InteractionAnswer[]) => {
+ const entry = entries.get(interactionId);
+ if (!valid() || !entry || entry.record.status !== "pending" || entry.busy) return;
+ entry.draft = draft; entry.dirty = true; entry.error = undefined; entry.submission = undefined; notify();
+ };
+ const saveDrafts = useCallback(() => {
+ if (!sessionId || !valid() || !checked.current.has(key)) return;
+ for (const entry of entries.values()) {
+ if (!entry.dirty || entry.busy || entry.saving || entry.error || entry.record.status !== "pending") continue;
+ entry.saving = true;
+ const write = entry.draftWrite ?? { id: requestId(), answers: entry.draft, revision: entry.record.draft_revision };
+ entry.draftWrite = write;
+ void api.writeInteractionRequest(sessionId, entry.record.interaction_id, "draft", { version, request_id: write.id, expected_draft_revision: write.revision, answers: write.answers }).then((record) => {
+ if (!valid() || !canReplaceInteractionRequest(record, entry.record)) return;
+ entry.record = record; entry.draftWrite = undefined;
+ if (entry.draft === write.answers) entry.dirty = false;
+ }).catch((error: unknown) => {
+ if (!valid() || purge(error)) return;
+ if (error instanceof InteractionRequestApiError && error.current && canReplaceInteractionRequest(error.current, entry.record)) {
+ entry.record = error.current; entry.draftWrite = undefined;
+ if (error.current.status !== "pending") entry.conflict = write.answers;
+ }
+ entry.error = error instanceof Error ? error.message : "草稿未保存";
+ }).finally(() => { entry.saving = false; if (valid() && active.current === key) notify(); });
+ }
+ }, [api, entries, key, notify, purge, sessionId, valid]);
+ useEffect(() => {
+ const timer = window.setTimeout(saveDrafts, 500);
+ return () => window.clearTimeout(timer);
+ }, [saveDrafts, tick]);
+ // Flush the owning session before replacing its subscription. Identity changes invalidate this closure.
+ useEffect(() => () => saveDrafts(), [saveDrafts]);
+ const act = async (interactionId: string, action: "reply" | "skip" | "cancel" | "resume") => {
+ if (!sessionId || !valid() || !checked.current.has(key)) return;
+ const entry = entries.get(interactionId);
+ if (!entry || entry.busy || checkingKey === key) return;
+ entry.busy = true; entry.error = undefined; notify();
+ const submission = entry.submission ?? { id: requestId(), answers: structuredClone(entry.draft) };
+ if (action === "reply") entry.submission = submission;
+ try {
+ const record = await api.writeInteractionRequest(sessionId, interactionId, action, { version, request_id: action === "reply" ? submission.id : requestId(), expected_revision: action === "resume" ? entry.record.delivery?.revision ?? 0 : entry.record.revision, ...(action === "reply" ? { answers: submission.answers } : {}) });
+ if (!valid()) return;
+ if (canReplaceInteractionRequest(record, entry.record)) entry.record = record;
+ entry.dirty = false;
+ if (action !== "cancel" && record.delivery && ["queued", "blocked", "dispatching"].includes(record.delivery.status)) await onContinue(sessionId, interactionId);
+ } catch (error) {
+ if (!valid() || purge(error)) return;
+ if (error instanceof InteractionRequestApiError && error.current && canReplaceInteractionRequest(error.current, entry.record)) { entry.record = error.current; entry.conflict = submission.answers; }
+ entry.error = error instanceof Error ? error.message : "操作失败,请重试";
+ try {
+ const record = await api.getInteractionRequest(sessionId, interactionId);
+ if (valid() && canReplaceInteractionRequest(record, entry.record)) entry.record = record;
+ } catch (readError) { purge(readError); }
+ } finally { entry.busy = false; if (valid() && active.current === key) notify(); }
+ };
+ const list = [...entries.values()];
+ const checking = Boolean(sessionId) && (!checked.current.has(key) || checkingKey === key);
+ return { entries: list, checking, error: loadError && (loadError.key === key || identity.current.revoked) ? loadError.message : undefined, refresh, update, act,
+ awaitingInput: list.some(({ record }) => record.status === "pending" || (record.delivery && ["queued", "dispatching", "blocked"].includes(record.delivery.status))) };
+}
+export type InteractionRequestsController = ReturnType;
diff --git a/src/features/agent/index.ts b/src/features/agent/index.ts
index a6e3475..46718ae 100644
--- a/src/features/agent/index.ts
+++ b/src/features/agent/index.ts
@@ -10,7 +10,8 @@ export type {
AgentWorkspaceContextItem
} from "./components/agent-command-panel";
export { AgentPersona } from "./components/agent-persona";
-export { createAgentApiClient } from "./api/client";
+export { createAgentApiClient, AgentResponseFinishedError, AgentSessionStreamError } from "./api/client";
+export { useAgentConnection } from "./hooks/use-agent-connection";
export { useAgentMessageInbox } from "./hooks/use-agent-message-inbox";
export type { AgentMailboxMessage } from "./api/agent-messages";
export type {
@@ -96,11 +97,17 @@ export { parseFrontendChart } from "./frontend-action/chart";
export type { AnswerUiMapTarget } from "./answer-ui/answer-ui-view";
-export { ResponsesChatTransport, createResponseRequest } from "./api/responses-transport";
+export { ResponsesChatTransport, createResponseRequest, AgentStreamDisconnectedError } from "./api/responses-transport";
export { AnswerUiView } from "./answer-ui/answer-ui-view";
export { parseAnswerUiBlock, reportUiBlocksSchema } from "./answer-ui/contract";
+export { getFinalAnswerTranscript } from "./answer-ui/answer-presentation";
export type { AnswerUiBlock, AnswerUiPart, StoredReportUiBlock } from "./answer-ui/contract";
export type { AnswerUiSelection } from "./answer-ui/answer-ui-interaction-context";
export type { AgentSessionPage, AgentHistoryMessagePage, AgentMessagePagination } from "./api/client";
export type { AgentRunStatus } from "./api/client";
+
+export { useInteractionRequests } from "./hooks/use-interaction-requests";
+export { AgentInteractionRequests } from "./components/agent-interaction-request-card";
+export { readInteractionReply } from "./api/interaction-requests";
+export type { InteractionRequestsController } from "./hooks/use-interaction-requests";
diff --git a/src/features/agent/presentation/message-process-groups.test.ts b/src/features/agent/presentation/message-process-groups.test.ts
new file mode 100644
index 0000000..36e93f6
--- /dev/null
+++ b/src/features/agent/presentation/message-process-groups.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from "vitest";
+import type { InteractionRequest } from "../api/interaction-requests";
+import type { AgentChatMessage } from "../types";
+import { groupMessageProcesses } from "./message-process-groups";
+
+const assistant = (id: string, content = ""): AgentChatMessage => ({
+ id, role: "assistant", content,
+ progress: [{ id: "query", phase: "tool", status: "completed", title: id }]
+});
+const user = (id: string): AgentChatMessage => ({ id, role: "user", content: id });
+const request = (source: string, reply: string, extra: Partial = {}): InteractionRequest => ({
+ version: "agent-interaction-request@1", interaction_id: `question-${source}`, message_id: source,
+ turn_id: `turn-${source}`, call_id: `call-${source}`, status: "answered", revision: 1, draft_revision: 0,
+ questions: [{ question_id: "scope", title: "范围", body: "核查范围?", mode: "single", required: true, allow_custom: true, options: [] }],
+ draft: [], created_at: "2026-10-28T10:00:00Z", delivery: { native_message_id: reply, revision: 1, status: "delivered" },
+ ...extra
+});
+
+describe("process groups across clarification replies", () => {
+ const source = assistant("question");
+ const reply = user("reply");
+ const result = assistant("answer_reply", "结果正文");
+ const messages = [user("original"), source, reply, result];
+
+ it("moves the source process to the related continuation without editing stored messages or progress ids", () => {
+ const original = structuredClone(messages);
+ const groups = groupMessageProcesses(messages, [request(source.id, reply.id)]);
+ expect(groups.get(source.id)).toEqual([]);
+ expect(groups.get(result.id)).toEqual([source, result]);
+ expect(groups.get(result.id)?.map((message) => message.progress?.[0].id)).toEqual(["query", "query"]);
+ expect(messages).toEqual(original);
+ });
+
+ it("groups an existing result even when delivery is blocked and there is no final_answer", () => {
+ const record = request(source.id, reply.id, { delivery: { native_message_id: reply.id, revision: 2, status: "blocked", error_code: "CONTINUATION_UNCONFIRMED" } });
+ expect(groupMessageProcesses(messages, [record]).get(result.id)).toEqual([source, result]);
+ expect(record.delivery?.status).toBe("blocked");
+ });
+
+ it("keeps processes in place while waiting, cancelled, missing a source page or missing a continuation", () => {
+ for (const extra of [{ status: "pending" }, { status: "cancelled" }, { delivery: undefined },
+ { delivery: { native_message_id: reply.id, revision: 2, status: "cancelled" } }] satisfies Partial[]) {
+ expect(groupMessageProcesses(messages, [request(source.id, reply.id, extra)]).get(source.id)).toEqual([source]);
+ }
+ expect(groupMessageProcesses(messages.slice(0, -1), [request(source.id, reply.id)]).get(source.id)).toEqual([source]);
+ expect(groupMessageProcesses(messages.slice(2), [request(source.id, reply.id)]).get(result.id)).toEqual([result]);
+ });
+
+ it("does not merge across an unrelated user request on either side of the saved reply", () => {
+ for (const list of [
+ [source, user("other-request"), reply, result],
+ [source, reply, user("other-request"), result],
+ [reply, source, result]
+ ]) {
+ const groups = groupMessageProcesses(list, [request(source.id, reply.id)]);
+ expect(groups.get(source.id)).toEqual([source]);
+ expect(groups.get(result.id)).toEqual([result]);
+ }
+ });
+
+ it("combines successive clarifications in order and ignores duplicate records", () => {
+ const reply2 = user("reply2");
+ const final = assistant("answer_reply2", "最终结果");
+ const first = request(source.id, reply.id);
+ const second = request(result.id, reply2.id);
+ const groups = groupMessageProcesses([...messages, reply2, final], [second, first, first]);
+ expect(groups.get(source.id)).toEqual([]);
+ expect(groups.get(result.id)).toEqual([]);
+ expect(groups.get(final.id)).toEqual([source, result, final]);
+ });
+
+ it("retains actionable questions and permissions at their original message", () => {
+ const pendingQuestion: AgentChatMessage = { ...source, questions: [{
+ requestId: "pending", sessionId: "s", questions: [], createdAt: 0, status: "checking"
+ }] };
+ const pendingPermission: AgentChatMessage = { ...source, permissions: [{
+ requestId: "pending", sessionId: "s", permission: "simulate", patterns: [], always: [], createdAt: 0, status: "pending"
+ }] };
+ for (const pending of [pendingQuestion, pendingPermission]) {
+ expect(groupMessageProcesses([pending, reply, result], [request(source.id, reply.id)]).get(source.id)).toEqual([pending]);
+ }
+ });
+});
diff --git a/src/features/agent/presentation/message-process-groups.ts b/src/features/agent/presentation/message-process-groups.ts
new file mode 100644
index 0000000..f6adda0
--- /dev/null
+++ b/src/features/agent/presentation/message-process-groups.ts
@@ -0,0 +1,36 @@
+import type { InteractionRequest } from "../api/interaction-requests";
+import type { AgentChatMessage } from "../types";
+
+export function hasPendingMessageInput(message: AgentChatMessage): boolean {
+ return Boolean(
+ message.permissions?.some((item) => ["pending", "submitting", "error"].includes(item.status)) ||
+ message.questions?.some((item) => ["checking", "pending", "submitting", "error"].includes(item.status))
+ );
+}
+
+/** Group presentation only; preserve persisted message identities and per-message step ids. */
+export function groupMessageProcesses(
+ messages: readonly AgentChatMessage[],
+ requests: readonly InteractionRequest[]
+): Map {
+ const positions = new Map(messages.map((message, index) => [message.id, index]));
+ const groups = new Map(messages.filter((message) => message.role === "assistant").map((message) => [message.id, [message]]));
+ const ordered = [...requests].sort((a, b) => (positions.get(a.message_id) ?? -1) - (positions.get(b.message_id) ?? -1));
+ for (const record of ordered) {
+ if (!["answered", "skipped"].includes(record.status) || !record.delivery || record.delivery.status === "cancelled") continue;
+ const sourceIndex = positions.get(record.message_id);
+ const replyIndex = positions.get(record.delivery.native_message_id);
+ if (sourceIndex === undefined || replyIndex === undefined || sourceIndex >= replyIndex) continue;
+ const source = messages[sourceIndex];
+ const reply = messages[replyIndex];
+ const target = messages[replyIndex + 1];
+ if (source.role !== "assistant" || reply.role !== "user" || target?.role !== "assistant" || hasPendingMessageInput(source)) continue;
+ // A saved reply can arrive late; do not pull a process across another user request.
+ if (messages.slice(sourceIndex + 1, replyIndex).some((message) => message.role === "user")) continue;
+ const previous = groups.get(source.id);
+ if (!previous?.length) continue;
+ groups.set(target.id, [...previous, ...(groups.get(target.id) ?? [])]);
+ groups.set(source.id, []);
+ }
+ return groups;
+}
diff --git a/src/features/agent/session-state.test.ts b/src/features/agent/session-state.test.ts
index 2a0cdb3..e527717 100644
--- a/src/features/agent/session-state.test.ts
+++ b/src/features/agent/session-state.test.ts
@@ -31,6 +31,15 @@ describe("agent session state", () => {
expect(next[0]).toMatchObject({ id: "tool-1", status: "completed", title: "工具完成", durationMs: 40 });
});
+ it("retains full tool inputs across stream updates and persisted history", () => {
+ const input = { address: "完整地址".repeat(70), point: { longitude: 121.5, latitude: 31.2 }, radius: 500 };
+ const first = upsertProgress(undefined, { id: "tool", phase: "tool", tool: { name: "mcp_tjwater_geocode", call_id: "native-call", input } });
+ const next = upsertProgress(first, { id: "tool", phase: "tool", status: "completed" });
+ expect(next[0].tool).toEqual({ name: "mcp_tjwater_geocode", callId: "native-call", input });
+ expect(upsertProgress(undefined, next[0])[0].tool).toEqual(next[0].tool);
+ expect(upsertProgress(undefined, { tool: { name: 3, input: [] } })[0].tool).toBeUndefined();
+ });
+
it("normalizes todo updates", () => {
expect(
toTodoUpdate({
diff --git a/src/features/agent/session-state.ts b/src/features/agent/session-state.ts
index d40cdc3..80ca998 100644
--- a/src/features/agent/session-state.ts
+++ b/src/features/agent/session-state.ts
@@ -29,6 +29,7 @@ export function upsertProgress(progress: AgentChatProgress[] | undefined, data:
status,
title: readString(payload.title) ?? "正在处理",
detail: readString(payload.detail),
+ tool: readProgressTool(payload.tool) ?? existing?.tool,
startedAt,
endedAt,
elapsedMs: status === "running" ? elapsedMs : undefined,
@@ -373,3 +374,15 @@ function readNumber(value: unknown) {
function readStringArray(value: unknown) {
return Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : [];
}
+
+export function readProgressTool(value: unknown): AgentChatProgress["tool"] {
+ const tool = asRecord(value);
+ const name = readString(tool.name);
+ if (!name) return undefined;
+ return {
+ name,
+ callId: readString(tool.call_id) ?? readString(tool.callId),
+ input: tool.input && typeof tool.input === "object" && !Array.isArray(tool.input)
+ ? tool.input as Record : undefined
+ };
+}
diff --git a/src/features/agent/types.ts b/src/features/agent/types.ts
index a1b51cf..45aaaca 100644
--- a/src/features/agent/types.ts
+++ b/src/features/agent/types.ts
@@ -9,6 +9,7 @@ export type AgentChatMessage = {
questions?: AgentQuestionRequest[];
todos?: AgentTodoUpdate;
taskContext?: import("./task-context").AgentTaskContextReference;
+ interactionReply?: import("./api/interaction-requests").InteractionRequest;
};
export type AgentStreamRenderMessageState = {
@@ -20,6 +21,7 @@ export type AgentStreamRenderState = Record };
id: string;
phase: string;
status: "running" | "completed" | "error";
@@ -86,7 +88,7 @@ export type AgentQuestionRequest = {
tool?: AgentInteractionToolRef;
createdAt: number;
repliedAt?: number;
- status: "pending" | "submitting" | "answered" | "rejected" | "error";
+ status: "checking" | "invalidated" | "pending" | "submitting" | "answered" | "rejected" | "error";
answers?: string[][];
error?: string;
};
diff --git a/src/features/workbench/hooks/agent-session-message-state.test.ts b/src/features/workbench/hooks/agent-session-message-state.test.ts
index afd9656..107561d 100644
--- a/src/features/workbench/hooks/agent-session-message-state.test.ts
+++ b/src/features/workbench/hooks/agent-session-message-state.test.ts
@@ -2,6 +2,21 @@ import { describe, expect, it } from "vitest";
import { applySessionStreamEvent, toAgentChatMessages, toAgentUiMessages } from "./agent-session-message-state";
describe("task context in persisted answers", () => {
+ it("restores submitted answer cards only from snapshots bound to their user message", () => {
+ const reply = { version: "agent-interaction-request@1", interaction_id: "question", message_id: "source", turn_id: "turn", call_id: "call",
+ status: "answered", revision: 1, draft_revision: 0, draft: [], created_at: "2026-10-28T10:00:00Z",
+ questions: [{ question_id: "q", title: "核查时段", body: "请选择", mode: "single", required: true, allow_custom: false, options: [{ id: "a", label: "前一天" }] }],
+ answers: [{ question_id: "q", selected_option_ids: ["a"], selected_options: [{ id: "a", label: "前一天" }], custom_text: "" }],
+ delivery: { native_message_id: "reply", revision: 2, status: "delivered" } };
+ const parts = [{ type: "text", text: "核查时段: 前一天" }, { type: "data-interaction_reply", data: reply }];
+ const messages = toAgentChatMessages(toAgentUiMessages([
+ { id: "reply", role: "user", parts }, { id: "other", role: "user", parts },
+ { id: "reply", role: "assistant", parts },
+ { id: "pending", role: "user", parts: [{ type: "data-interaction_reply", data: { ...reply, status: "pending", delivery: { ...reply.delivery, native_message_id: "pending" } } }] }
+ ]), {}, {});
+ expect(messages[0].interactionReply).toEqual(reply);
+ expect(messages.slice(1).every((message) => message.interactionReply === undefined)).toBe(true);
+ });
it("inserts consumed user input once before the active assistant in live and resumed streams", () => {
const messages = toAgentUiMessages([
{ id: "original", role: "user", parts: [{ type: "text", text: "原问题" }] },
@@ -60,6 +75,20 @@ describe("task context in persisted answers", () => {
describe("rich answer replay", () => {
const block = { version: "answer-ui@1", block_id: "reading", fallback_text: "演练读数", spec: { root: "metric", elements: { metric: { type: "Metric", props: { label: "压力", value: "0.3", unit: "MPa", source: "演练数据" }, children: [] } } } };
+ it("restores anchored final content without process UI, duplicate blocks or internal reference markers", () => {
+ const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [
+ { type: "data-answer_ui", data: { ...block, block_id: "query", phase: "process", fallback_text: "查询过程资料" } },
+ { type: "data-answer_ui", data: { ...block, phase: "final" } },
+ { type: "data-answer_ui", data: { ...block, phase: "final", fallback_text: "更新后的最终读数" } },
+ { type: "data-final_answer", data: { content: "最终说明\n\n[[ui:reading]]\n\n后续解读\n\n[[ui:query]]", ui_block_ids: ["reading", "query"] } },
+ ] }]);
+ const restored = toAgentChatMessages(messages, {}, {})[0];
+ expect(restored.answerParts?.filter((part) => part.type === "answer-ui")).toHaveLength(2);
+ expect(restored.content).toContain("更新后的最终读数");
+ expect(restored.content).not.toContain("查询过程资料");
+ expect(restored.content).not.toContain("[[ui:");
+ expect(restored.content.indexOf("更新后的最终读数")).toBeLessThan(restored.content.indexOf("后续解读"));
+ });
it("keeps text, component and summary in their original order across history restoration", () => {
const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [
{ type: "text", text: "下面是演练数据。" },
diff --git a/src/features/workbench/hooks/agent-session-message-state.ts b/src/features/workbench/hooks/agent-session-message-state.ts
index 1fa04f8..44a67c8 100644
--- a/src/features/workbench/hooks/agent-session-message-state.ts
+++ b/src/features/workbench/hooks/agent-session-message-state.ts
@@ -2,6 +2,8 @@ import type { UIMessage } from "ai";
import type { Dispatch, SetStateAction } from "react";
import {
parseAnswerUiBlock,
+ readInteractionReply,
+ getFinalAnswerTranscript,
type AnswerUiPart,
applyPermissionResponse,
agentTaskContextReferenceSchema,
@@ -31,6 +33,8 @@ type AgentUiDataParts = {
todo_update: Record;
permission_request: Record;
permission_response: Record;
+ interaction_request: Record;
+ interaction_reply: Record;
question_request: Record;
question_response: Record;
stream_token: Record;
@@ -137,7 +141,10 @@ export function toAgentChatMessages(
if (!isAgentDataPart(part)) {
continue;
}
- if (part.type === "data-task_context") {
+ if (part.type === "data-interaction_reply" && message.role === "user") {
+ const reply = readInteractionReply(part.data, message.id);
+ if (reply) next = { ...next, interactionReply: reply };
+ } else if (part.type === "data-task_context") {
const parsed = agentTaskContextReferenceSchema.safeParse(part.data);
if (parsed.success) next = { ...next, taskContext: parsed.data };
} else if (part.type === "data-final_answer") {
@@ -184,8 +191,7 @@ export function toAgentChatMessages(
}
if (next.finalAnswer) {
- const selected = new Set(next.finalAnswer.uiBlockIds);
- next.content = [next.finalAnswer.content, ...(next.answerParts ?? []).flatMap((part) => part.type === "answer-ui" && selected.has(part.block.block_id) ? [part.block.fallback_text] : [])].join("\n\n");
+ next.content = getFinalAnswerTranscript(next);
}
return [applyQuestionOverrides(applyPermissionOverrides(next, permissionOverrides), questionOverrides)];
});
@@ -526,9 +532,15 @@ function collectAnswerParts(message: AgentUiMessage): AnswerUiPart[] | undefined
if (part.type === "text" && part.text) result.push({ type: "text", text: part.text });
if (part.type === "data-answer_ui") {
const data = typeof part.data === "object" && part.data !== null ? part.data : {};
- const { version, block_id, fallback_text, spec } = data;
- const block = parseAnswerUiBlock({ version, block_id, fallback_text, spec });
- if (block) result.push({ type: "answer-ui", block });
+ const { version, block_id, phase, fallback_text, spec } = data;
+ const block = parseAnswerUiBlock({ version, block_id, ...(phase === undefined ? {} : { phase }), fallback_text, spec });
+ if (block) {
+ const existing = result.findIndex((item) => item.type === "answer-ui" && item.block.block_id === block.block_id);
+ if (existing >= 0) {
+ const previous = result[existing];
+ if (previous.type === "answer-ui" && previous.block.phase === block.phase) result[existing] = { type: "answer-ui", block };
+ } else result.push({ type: "answer-ui", block });
+ }
else result.push({ type: "text", text: typeof fallback_text === "string" ? fallback_text.slice(0, 4000) : "这段结构化内容无法显示。" });
}
diff --git a/src/features/workbench/hooks/agent-session-runtime.ts b/src/features/workbench/hooks/agent-session-runtime.ts
index 24b4f81..34a44db 100644
--- a/src/features/workbench/hooks/agent-session-runtime.ts
+++ b/src/features/workbench/hooks/agent-session-runtime.ts
@@ -1,6 +1,6 @@
import { Chat } from "@ai-sdk/react";
import type { ChatTransport } from "ai";
-import { parseFrontendActionRequest, type AgentApiClient, type AgentRunStatus, type AgentLoadedChatSession,
+import { AgentSessionStreamError, AgentStreamDisconnectedError, parseFrontendActionRequest, type AgentApiClient, type AgentRunStatus, type AgentLoadedChatSession,
type AgentMessagePagination, type AgentSessionStreamEvent, type AgentSessionPurpose, type AgentTaskDiscussionContext, type AnswerUiSelection } from "@/features/agent";
import { showMapNotice } from "@/features/map/core";
import { applySessionStreamEvent, getDataString, toAgentChatMessages, toAgentUiMessages,
@@ -8,6 +8,15 @@ import { applySessionStreamEvent, getDataString, toAgentChatMessages, toAgentUiM
import type { WorkbenchSessionResources } from "./workbench-session-resources";
export type AgentReadingPosition = { messageId?: string; offset: number; atBottom: boolean };
+
+export function isPendingInteractionError(error: unknown) {
+ const message = error instanceof Error ? error.message : error;
+ if (typeof message !== "string") return false;
+ try {
+ const value: unknown = JSON.parse(message);
+ return typeof value === "object" && value !== null && "code" in value && value.code === "INTERACTION_PENDING";
+ } catch { return false; }
+}
export type AgentSessionRuntime = {
key: string;
scope: string;
@@ -29,6 +38,9 @@ export type AgentSessionRuntime = {
updatedAt: number;
resumed: boolean;
controller?: AbortController;
+ recoveryTimer?: ReturnType;
+ transportInterrupted?: boolean;
+ connectionError?: string;
error?: string;
runStatus?: AgentRunStatus;
handlers?: {
@@ -36,6 +48,7 @@ export type AgentSessionRuntime = {
onEvent: (event: AgentSessionStreamEvent) => void;
onError: (error: Error) => void;
onFinish: (status: AgentRunStatus) => void;
+ onReconnect?: () => void;
};
};
@@ -84,6 +97,7 @@ export function createAgentRuntime(resources: WorkbenchSessionResources, scope:
const sessionId = getDataString(data.data, "session_id");
if (sessionId && runtime.sessionId && sessionId !== runtime.sessionId) return;
if (sessionId) runtime.sessionId = sessionId;
+ if (data.type === "data-interaction_request") runtime.runStatus = "waiting_input";
if (data.type === "data-session_title") runtime.title = getDataString(data.data, "title") ?? runtime.title;
if (data.type === "data-credential_refresh_required") {
void refreshRuntimeCredential(runtime, data.data);
@@ -92,19 +106,35 @@ export function createAgentRuntime(resources: WorkbenchSessionResources, scope:
} else if (active()) runtime.handlers?.onData(data);
notify();
},
- onError: (error) => { if (resources.disposed) return; runtime.error = error.message; runtime.runStatus = "error"; if (active()) runtime.handlers?.onError(error); notify(); },
+ onError: (error) => { if (resources.disposed) return; runtime.transportInterrupted = error instanceof AgentStreamDisconnectedError || error instanceof TypeError;
+ runtime.error = error.message; runtime.runStatus = isPendingInteractionError(error) ? "waiting_input" : "error";
+ if (active()) {
+ if (runtime.transportInterrupted && runtime.sessionId && runtime.handlers?.onReconnect) runtime.handlers.onReconnect();
+ else runtime.handlers?.onError(error);
+ }
+ notify(); },
onFinish: ({ isAbort, isError }) => {
if (resources.disposed) return;
runtime.loadedAt = Date.now();
runtime.updatedAt = Date.now();
- runtime.runStatus = isAbort ? "aborted" : isError ? "error" : "completed";
- if (active()) runtime.handlers?.onFinish(runtime.runStatus);
- else notifyBackgroundCompletion(runtime);
+ runtime.runStatus = isAbort ? "aborted" : isError
+ ? isPendingInteractionError(runtime.error) ? "waiting_input" : "error"
+ : runtime.runStatus === "waiting_input" ? "waiting_input" : "completed";
+ const recovering = isError && !isAbort && runtime.transportInterrupted && runtime.sessionId;
+ if (active()) { if (!recovering) runtime.handlers?.onFinish(runtime.runStatus); }
+ else if (!recovering) notifyBackgroundCompletion(runtime);
+ if (recovering) {
+ runtime.recoveryTimer = setTimeout(() => {
+ runtime.recoveryTimer = undefined;
+ if (!resources.disposed && resources.agents.get(runtime.key) === runtime) resumeAgentRuntime(resources, runtime);
+ }, 0);
+ }
+ runtime.transportInterrupted = false;
notify();
},
});
resources.agents.set(runtime.key, runtime);
- resources.disposers.add(() => { runtime.handlers = undefined; runtime.controller?.abort(); void runtime.chat.stop(); });
+ resources.disposers.add(() => { runtime.handlers = undefined; clearTimeout(runtime.recoveryTimer); runtime.controller?.abort(); void runtime.chat.stop(); });
return runtime;
}
@@ -132,15 +162,13 @@ async function rejectBackgroundAction(resources: WorkbenchSessionResources, runt
}
export function resumeAgentRuntime(resources: WorkbenchSessionResources, runtime: AgentSessionRuntime) {
- if (!runtime.sessionId || runtime.controller || runtime.chat.status === "submitted" || runtime.chat.status === "streaming") return;
+ if (resources.disposed || !runtime.sessionId || runtime.controller || runtime.chat.status === "submitted" || runtime.chat.status === "streaming") return;
const controller = new AbortController();
runtime.controller = controller;
runtime.resumed = true;
runtime.runStatus = "running";
- runtime.error = undefined;
- void runtime.client.streamSession(runtime.sessionId, { signal: controller.signal, messageLimit: 40,
- onEvent: (event) => {
- if (resources.disposed) return;
+ const onEvent = (event: AgentSessionStreamEvent) => {
+ if (resources.disposed || controller.signal.aborted || runtime.controller !== controller) return;
const sessionId = event.type === "state" ? event.sessionId : event.sessionId ?? getDataString(event.data, "session_id");
if (sessionId && sessionId !== runtime.sessionId) return;
if (event.type === "state") {
@@ -155,27 +183,47 @@ export function resumeAgentRuntime(resources: WorkbenchSessionResources, runtime
if (!runtime.messagePage) runtime.messagePage = event.messagePage;
runtime.resumed = event.isStreaming;
runtime.runStatus = event.runStatus ?? (event.isStreaming ? "running" : "completed");
+ if (runtime.runStatus !== "error") runtime.error = undefined;
+ terminal = !event.isStreaming;
+ runtime.connectionError = undefined;
} else {
if (event.type === "credential_refresh_required") void refreshRuntimeCredential(runtime, event.data);
else if (resources.activeAgent !== runtime && event.type === "frontend_action") void rejectBackgroundAction(resources, runtime, event.data);
runtime.chat.messages = applySessionStreamEvent(runtime.chat.messages, event);
if (event.type === "session_title") runtime.title = getDataString(event.data, "title") ?? runtime.title;
if (event.type === "done" || event.type === "error") {
+ terminal = true;
runtime.resumed = false;
- runtime.runStatus = event.type === "error" ? "error" : "completed";
+ runtime.runStatus = event.type === "error" ? "error" : event.data.reason === "waiting_input" ? "waiting_input" : "completed";
runtime.updatedAt = Date.now();
}
}
if (resources.activeAgent === runtime && event.type !== "credential_refresh_required") runtime.handlers?.onEvent(event);
resources.notify();
- },
- }).catch((error: unknown) => {
- if (!controller.signal.aborted) {
- runtime.error = error instanceof Error ? error.message : "会话连接中断";
- if (resources.activeAgent === runtime) runtime.handlers?.onError(new Error(runtime.error));
+ };
+ let terminal = false;
+ void (async () => {
+ let failures = 0;
+ while (!resources.disposed && !controller.signal.aborted) {
+ terminal = false;
+ try {
+ await runtime.client.streamSession(runtime.sessionId!, { signal: controller.signal, messageLimit: 40, onEvent });
+ if (terminal || controller.signal.aborted || resources.disposed) break;
+ throw new AgentStreamDisconnectedError("会话连接已断开,正在自动恢复");
+ } catch (error) {
+ if (controller.signal.aborted || resources.disposed || terminal) break;
+ runtime.connectionError = error instanceof Error ? error.message : "会话连接中断";
+ if (error instanceof AgentSessionStreamError && [400, 401, 403, 404, 410].includes(error.status)) {
+ runtime.runStatus = "error";
+ if (resources.activeAgent === runtime) runtime.handlers?.onError(error);
+ break;
+ }
+ if (resources.activeAgent === runtime) runtime.handlers?.onReconnect?.();
+ resources.notify();
+ await waitForReconnect(Math.min(1_000 * 2 ** Math.min(failures++, 4), 15_000), controller.signal);
+ }
}
- }).finally(() => {
- if (runtime.resumed && !controller.signal.aborted && !runtime.error) runtime.error = "会话连接已断开,返回会话后重新连接";
+ })().finally(() => {
if (runtime.controller === controller) { runtime.controller = undefined; runtime.resumed = false; }
runtime.loadedAt = Date.now();
if (!resources.disposed && !controller.signal.aborted && resources.activeAgent !== runtime) notifyBackgroundCompletion(runtime);
@@ -183,6 +231,23 @@ export function resumeAgentRuntime(resources: WorkbenchSessionResources, runtime
});
}
+function waitForReconnect(delay: number, signal: AbortSignal): Promise {
+ return new Promise((resolve) => {
+ if (signal.aborted) { resolve(); return; }
+ const finish = () => {
+ clearTimeout(timer);
+ signal.removeEventListener("abort", finish);
+ window.removeEventListener("online", finish);
+ window.removeEventListener("focus", finish);
+ resolve();
+ };
+ const timer = navigator.onLine ? setTimeout(finish, delay) : undefined;
+ signal.addEventListener("abort", finish, { once: true });
+ window.addEventListener("online", finish, { once: true });
+ window.addEventListener("focus", finish, { once: true });
+ });
+}
+
function notifyBackgroundCompletion(runtime: AgentSessionRuntime) {
showMapNotice({ id: `agent-background-${runtime.key}`, tone: runtime.error || runtime.runStatus === "error" ? "error" : "info",
title: runtime.error || runtime.runStatus === "error" ? "后台对话处理失败" : runtime.runStatus === "aborted" ? "后台对话已停止" : "后台对话已完成",
diff --git a/src/features/workbench/hooks/use-workbench-agent.ts b/src/features/workbench/hooks/use-workbench-agent.ts
index c4d937a..c69bc99 100644
--- a/src/features/workbench/hooks/use-workbench-agent.ts
+++ b/src/features/workbench/hooks/use-workbench-agent.ts
@@ -10,11 +10,14 @@ import { showMapNotice } from "@/features/map/core";
import { findOwnedWorkbenchProject } from "../api/project-client";
import type { InvestigationDiscussion } from "../api/investigation-report";
import {
+ useInteractionRequests,
+ useAgentConnection,
FrontendActionExecutor,
agentTaskDiscussionContextSchema,
type AgentTaskDiscussionContext,
agentBootstrapKey,
createAgentApiClient,
+ AgentResponseFinishedError,
useAgentMessageInbox,
fetchAgentBootstrap,
resolveAgentModelSelection,
@@ -59,6 +62,7 @@ import { useWorkbenchSessionResources } from "./workbench-session-resources";
import { useAgentTopics } from "./use-agent-topics";
import { useAgentHistory } from "./use-agent-history";
import { agentRuntimes, createAgentRuntime, mergeHistoryMessages, resumeAgentRuntime, runtimeNeedsInput, runtimeRunning,
+ isPendingInteractionError,
type AgentReadingPosition, type AgentSessionRuntime } from "./agent-session-runtime";
const AGENT_PANEL_COLLAPSE_MS = 180;
@@ -98,6 +102,7 @@ export function useWorkbenchAgent({
clientRef.current = scopeClient;
const projectScopeKey = `project:${getWorkbenchProject(getAccessToken)?.project_id ?? "default"}`;
const scopeKey = projectScopeKey;
+ const { connectionStatus, recheck: checkAgentConnection, recoveryVersion } = useAgentConnection(scopeClient, scopeKey);
const history = useAgentHistory(scopeClient, scopeKey);
const topics = useAgentTopics(scopeClient, scopeKey);
const runtimeSummaries: AgentChatSessionSummary[] = agentRuntimes(resources).filter((entry) => entry.scope === scopeKey && entry.sessionId && entry.purpose !== "task_execution")
@@ -142,8 +147,6 @@ export function useWorkbenchAgent({
const [registry, setRegistry] = useState(null);
const [sessionTitle, setSessionTitle] = useState("新对话");
const [statusLabel, setStatusLabel] = useState("Agent 后端连接中");
- const [connectionStatus, setConnectionStatus] =
- useState("connecting");
const [modelOptions, setModelOptions] = useState([]);
const [selectedModel, setSelectedModel] = useState("");
const [inputDelivery, setInputDelivery] = useState("steer");
@@ -162,6 +165,44 @@ export function useWorkbenchAgent({
const [questionOverrides, setQuestionOverrides] = useState>({});
const agentMessageInbox = useAgentMessageInbox(clientRef.current, sessionPurpose === "ordinary_chat" ? sessionId : null);
const refreshAgentMessageInbox = agentMessageInbox.refresh;
+ const continueInteraction = useCallback(async (owningSessionId: string, interactionId: string) => {
+ const target = agentRuntimes(resources).find((entry) => entry.scope === scopeKey && entry.sessionId === owningSessionId);
+ if (!target || target.client !== scopeClient || resources.disposed) throw new Error("原会话已关闭,请重新打开后继续。");
+ const record = await target.client.getInteractionRequest(owningSessionId, interactionId);
+ if (target.client !== scopeClient || resources.disposed || !agentRuntimes(resources).includes(target)) return;
+ if (!record.delivery || ["delivered", "cancelled"].includes(record.delivery.status)) return;
+ const text = record.status === "skipped" ? "已跳过本组提问。" : record.questions.map((question) => {
+ const answer = record.answers?.find((item) => item.question_id === question.question_id);
+ return `${question.title}:${[...(answer?.selected_options.map((option) => option.label) ?? []), answer?.custom_text].filter(Boolean).join(";")}`;
+ }).join("\n");
+ const alreadyPresent = target.chat.messages.some((message) => message.id === record.delivery!.native_message_id);
+ const replyMessage: AgentUiMessage = { id: record.delivery.native_message_id, role: "user", parts: [
+ { type: "text", text }, { type: "data-interaction_reply", data: record },
+ ] };
+ if (alreadyPresent) target.chat.messages = target.chat.messages.map((message) => message.id === replyMessage.id ? replyMessage : message);
+ target.runStatus = "running";
+ try {
+ await target.chat.sendMessage(alreadyPresent ? undefined : replyMessage, { body: {
+ session_id: owningSessionId, interaction_id: interactionId, model: selectedModel || undefined,
+ capabilities: frontendActionEnabledRef.current ? ["host-question@1", "frontend-action@1"] : ["host-question@1"],
+ approval_mode: approvalMode, task_context: target.taskDiscussion?.context,
+ } });
+ } catch (error) { target.runStatus = "error"; throw error; }
+ finally {
+ if (!resources.disposed && target.client === scopeClient && !runtimeRunning(target)) {
+ try {
+ const loaded = await target.client.loadSession(owningSessionId, { messageLimit: 40 });
+ if (loaded && !resources.disposed && target.client === scopeClient) {
+ target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages));
+ resources.notify();
+ }
+ } catch { /* A failed reconciliation leaves the accepted answer intact. */ }
+ }
+ }
+ }, [resources, scopeKey, scopeClient, selectedModel, approvalMode]);
+ const interactionRequests = useInteractionRequests(scopeClient, scopeKey, sessionId, continueInteraction);
+ const refreshInteractionRequests = interactionRequests.refresh;
+
useEffect(() => {
sessionIdRef.current = sessionId;
@@ -232,7 +273,6 @@ export function useWorkbenchAgent({
const handleDataPart = useCallback(
(part: AgentDataPart) => {
- setConnectionStatus("online");
const eventSessionId = getDataString(part.data, "session_id");
if (eventSessionId && sessionIdRef.current && eventSessionId !== sessionIdRef.current) {
return;
@@ -247,6 +287,7 @@ export function useWorkbenchAgent({
);
return;
}
+ if (part.type === "data-interaction_request") { void refreshInteractionRequests(); return; }
if (part.type === "data-agent_message") { refreshAgentMessageInbox(); return; }
if (part.type === "data-user_input" && part.data.status === "delivered") {
chatRef.current.setMessages((current) => applySessionStreamEvent(current, { type: "user_input", data: part.data }));
@@ -295,7 +336,7 @@ export function useWorkbenchAgent({
});
});
},
- [refreshAgentMessageInbox, applySessionId, claimEnvelope, handleCredentialRefresh, handleFrontendAction, onUiEnvelope]
+ [refreshInteractionRequests, refreshAgentMessageInbox, applySessionId, claimEnvelope, handleCredentialRefresh, handleFrontendAction, onUiEnvelope]
);
const transport = useMemo(() => createWorkbenchAgentTransport(scopeClient, getAccessToken,
@@ -355,9 +396,14 @@ export function useWorkbenchAgent({
const {
data: backendConnection,
- error: backendConnectionError
+ error: backendConnectionError,
+ mutate: refreshBootstrap
} = useSWR([...agentBootstrapKey, getAccessToken, scopeKey], () => fetchAgentBootstrap(scopeClient), {
- shouldRetryOnError: false
+ shouldRetryOnError: true,
+ errorRetryInterval: 5_000,
+ errorRetryCount: 3,
+ revalidateOnFocus: true,
+ revalidateOnReconnect: true
});
const clearCollapseTimer = useCallback(() => {
if (collapseTimerRef.current !== null) {
@@ -374,6 +420,7 @@ export function useWorkbenchAgent({
}, []);
const stopSessionStreamSubscription = useCallback(() => {
+ clearTimeout(runtimeRef.current?.recoveryTimer);
runtimeRef.current?.controller?.abort();
if (runtimeRef.current) { runtimeRef.current.controller = undefined; runtimeRef.current.resumed = false; }
}, []);
@@ -402,8 +449,7 @@ export function useWorkbenchAgent({
setRegistry(backendConnection.registry);
setModelOptions(backendConnection.models);
setSelectedModel((current) => resolveAgentModelSelection(backendConnection, current));
- if (chatRef.current.status === "ready") {
- setConnectionStatus("online");
+ if (chatRef.current.status === "ready" && runtimeRef.current && !runtimeRunning(runtimeRef.current) && !runtimeRef.current.error) {
setStatusLabel("准备就绪");
}
}, [backendConnection]);
@@ -413,8 +459,7 @@ export function useWorkbenchAgent({
return;
}
- setConnectionStatus("offline");
- setStatusLabel("Agent 后端不可用");
+ if (runtimeRef.current && !runtimeRunning(runtimeRef.current) && !runtimeRef.current.error) setStatusLabel("Agent 初始化信息暂不可用");
}, [backendConnectionError]);
const collapsePanel = useCallback(() => {
@@ -469,6 +514,12 @@ export function useWorkbenchAgent({
() => clientRef.current.listResponseInputs(sessionId!),
{ refreshInterval: streaming ? 1000 : 0, shouldRetryOnError: false }
);
+ useEffect(() => {
+ if (!recoveryVersion) return;
+ void refreshBootstrap();
+ void refreshInteractionRequests();
+ void refreshPendingInputs();
+ }, [recoveryVersion, refreshBootstrap, refreshInteractionRequests, refreshPendingInputs]);
const updateInputDelivery = useCallback(async (id: string, delivery: AgentInputDelivery) => {
if (!sessionIdRef.current) return;
try {
@@ -504,10 +555,38 @@ export function useWorkbenchAgent({
if (sessionIdRef.current === activeSessionId) void refreshPendingInputs().catch(() => {});
}
}, [refreshPendingInputs, resources, setPromptDraft]);
+ const checkedLegacyQuestions = useRef(new Set());
+ const checkingLegacyQuestions = useRef(new Set());
+ const legacyQuestionScope = useRef(`${scopeKey}:${sessionId}`);
+ if (legacyQuestionScope.current !== `${scopeKey}:${sessionId}`) {
+ legacyQuestionScope.current = `${scopeKey}:${sessionId}`;
+ checkedLegacyQuestions.current.clear();
+ checkingLegacyQuestions.current.clear();
+ }
const messages = useMemo(
- () => toAgentChatMessages(chat.messages, permissionOverrides, questionOverrides),
- [chat.messages, permissionOverrides, questionOverrides]
+ () => toAgentChatMessages(chat.messages, permissionOverrides, questionOverrides).map((message) => ({ ...message,
+ questions: message.questions?.map((question) => question.status === "pending" && !checkedLegacyQuestions.current.has(`${scopeKey}:${question.sessionId}:${question.requestId}`)
+ ? { ...question, status: "checking" as const } : question),
+ })),
+ [chat.messages, permissionOverrides, questionOverrides, scopeKey]
);
+ useEffect(() => {
+ const target = runtime;
+ for (const message of messages) for (const question of message.questions ?? []) {
+ const identity = `${scopeKey}:${question.sessionId}:${question.requestId}`;
+ if (question.status !== "checking" || checkingLegacyQuestions.current.has(identity)) continue;
+ checkingLegacyQuestions.current.add(identity);
+ void target.client.getQuestionStatus(question.sessionId, question.requestId).then(({ status }) => {
+ checkedLegacyQuestions.current.add(identity);
+ checkingLegacyQuestions.current.delete(identity);
+ target.questionOverrides = { ...target.questionOverrides, [question.requestId]: { status } };
+ if (resources.activeAgent === target) setQuestionOverrides(target.questionOverrides);
+ }).catch(() => {
+ // Uncertainty keeps historical controls disabled until the session is reopened.
+ checkingLegacyQuestions.current.delete(identity);
+ });
+ }
+ }, [messages, resources, runtime, scopeKey]);
const personaState = useMemo(
() => deriveAgentPersonaState(messages, {
chatStatus: chat.status,
@@ -521,13 +600,15 @@ export function useWorkbenchAgent({
const handleSessionStreamEvent = useCallback(
(event: AgentSessionStreamEvent, expectedSessionId: string) => {
+ if (event.type === "interaction_request") { void refreshInteractionRequests(); return; }
if (event.type === "state") {
+ void refreshInteractionRequests();
if (sessionIdRef.current !== expectedSessionId && sessionIdRef.current !== event.sessionId) {
return;
}
const nextMessages = runtimeRef.current?.chat.messages ?? toAgentUiMessages(event.messages);
setStreamRenderState(createCompletedStreamRenderState(nextMessages));
- setStatusLabel(event.isStreaming ? "Agent 会话流已恢复" : "Agent 历史会话已打开");
+ setStatusLabel(event.runStatus === "waiting_input" ? "等待补充信息" : event.isStreaming ? "Agent 会话流已恢复" : "Agent 历史会话已打开");
return;
}
@@ -576,8 +657,9 @@ export function useWorkbenchAgent({
setStatusLabel(title);
}
} else if (event.type === "done") {
+ void refreshInteractionRequests();
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
- setStatusLabel("Agent 分析完成");
+ setStatusLabel(event.data.reason === "waiting_input" ? "等待补充信息" : "Agent 分析完成");
runtimeRef.current!.loadedAt = Date.now();
} else if (event.type === "error") {
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
@@ -587,7 +669,7 @@ export function useWorkbenchAgent({
// Message changes are applied once by the owning runtime, including background streams.
},
- [refreshAgentMessageInbox, claimEnvelope, handleCredentialRefresh, handleFrontendAction, onUiEnvelope]
+ [refreshInteractionRequests, refreshAgentMessageInbox, claimEnvelope, handleCredentialRefresh, handleFrontendAction, onUiEnvelope]
);
useLayoutEffect(() => {
@@ -595,23 +677,35 @@ export function useWorkbenchAgent({
onData: handleDataPart,
onEvent: (event) => handleSessionStreamEvent(event, runtime.sessionId ?? ""),
onError: (error) => {
+ void refreshInteractionRequests();
markLastAssistantStreamDone(runtime.chat.messages, setStreamRenderState);
- setConnectionStatus("offline"); setStatusLabel(error.message);
+ if (isPendingInteractionError(error)) {
+ setStatusLabel("等待补充信息");
+ showMapNotice({ id: "agent-stream-status", tone: "warning", title: "有待回答的问题", message: "请在对话中的提问卡片中选择或填写后提交,也可以取消提问。" });
+ return;
+ }
+ checkAgentConnection();
+ setStatusLabel(error.message);
showMapNotice({ id: "agent-stream-status", tone: "error", title: "Agent 请求失败", message: error.message });
},
onFinish: (status) => {
+ void refreshInteractionRequests();
markLastAssistantStreamDone(runtime.chat.messages, setStreamRenderState);
- setConnectionStatus(status === "error" ? "offline" : "online");
- setStatusLabel(status === "error" ? runtime.error ?? "Agent 处理失败" : status === "aborted" ? "Agent 已停止" : "Agent 分析完成");
+ if (status === "error") checkAgentConnection();
+ setStatusLabel(status === "error" ? runtime.error ?? "Agent 处理失败" : status === "aborted" ? "Agent 已停止" : status === "waiting_input" ? "等待补充信息" : "Agent 分析完成");
+ },
+ onReconnect: () => {
+ checkAgentConnection();
+ setStatusLabel("连接已中断,正在恢复会话");
},
};
return () => { runtime.handlers = undefined; };
- }, [handleDataPart, handleSessionStreamEvent, runtime]);
+ }, [checkAgentConnection, handleDataPart, handleSessionStreamEvent, refreshInteractionRequests, runtime]);
const loadHistorySession = useCallback(async (nextSessionId: string) => {
const generation = ++loadingGenerationRef.current;
let target = agentRuntimes(resources).find((entry) => entry.scope === scopeKey && entry.sessionId === nextSessionId);
try {
- if (!target || target.error || (!runtimeRunning(target) && Date.now() - target.loadedAt >= 60_000)) {
+ if (!target || target.error || target.connectionError || (!runtimeRunning(target) && Date.now() - target.loadedAt >= 60_000)) {
const loaded = await resources.cache.load(JSON.stringify(["session", scopeKey, nextSessionId]),
() => scopeClient.loadSession(nextSessionId, { messageLimit: 40, signal: AbortSignal.timeout(15_000) }), 60_000, { force: Boolean(target?.error) });
if (!loaded) throw new Error("这个历史会话不存在或已被删除。");
@@ -732,7 +826,6 @@ export function useWorkbenchAgent({
activateRuntime(next);
setTaskDiscussion(null);
setStatusLabel(nextStatusLabel);
- setConnectionStatus("online");
}, [activateRuntime, resources, scopeClient, scopeKey, transport]);
const selectTopic = (topicId: string | null) => {
@@ -798,7 +891,9 @@ export function useWorkbenchAgent({
return;
}
- if (streaming) {
+ if (streaming || interactionRequests.awaitingInput) {
+ const owningRuntime = runtimeRef.current;
+ const owningSessionId = sessionIdRef.current;
try {
if (!sessionIdRef.current) throw new Error("Agent 正在建立会话,请稍后追加消息。");
const receipt = await clientRef.current.submitResponseInput(sessionIdRef.current, normalizedPrompt, inputDelivery, `msg_${crypto.randomUUID().replaceAll("-", "")}`);
@@ -808,15 +903,23 @@ export function useWorkbenchAgent({
? [...current.filter((input) => input.id !== receipt.id), receipt]
: current.filter((input) => input.id !== receipt.id), { revalidate: false })
.then(() => refreshPendingInputs()).catch(() => {});
+ return;
} catch (error) {
- showMapNotice({ tone: "error", title: "追加消息失败", message: error instanceof Error ? error.message : "请稍后重试。" });
- throw error;
+ if (error instanceof AgentResponseFinishedError) {
+ await refreshInteractionRequests();
+ if (runtimeRef.current !== owningRuntime || sessionIdRef.current !== owningSessionId) {
+ throw new Error("原会话已切换,请回到原会话后重新发送。");
+ }
+ } else {
+ showMapNotice({ tone: "error", title: "追加消息失败", message: error instanceof Error ? error.message : "请稍后重试。" });
+ throw error;
+ }
}
- return;
}
setStatusLabel("Agent 正在分析");
runtimeRef.current!.error = undefined;
+ runtimeRef.current!.runStatus = "running";
await chat.sendMessage(
{ text: normalizedPrompt },
@@ -826,13 +929,13 @@ export function useWorkbenchAgent({
topic_id: !sessionIdRef.current ? runtimeRef.current?.topicId ?? undefined : undefined,
model: selectedModel || undefined,
approval_mode: approvalMode,
- capabilities: frontendActionEnabledRef.current ? ["frontend-action@1"] : [],
+ capabilities: frontendActionEnabledRef.current ? ["frontend-action@1", "host-question@1"] : ["host-question@1"],
task_context: taskDiscussion?.sessionId === sessionIdRef.current ? taskDiscussion.context : undefined
}
}
);
},
- [approvalMode, chat, inputDelivery, refreshPendingInputs, selectedModel, streaming, taskDiscussion]
+ [approvalMode, chat, inputDelivery, interactionRequests.awaitingInput, refreshInteractionRequests, refreshPendingInputs, selectedModel, streaming, taskDiscussion]
);
const triggerEvent = useCallback(async (trigger: AgentEventTriggerContext, prompt: string) => {
@@ -846,17 +949,19 @@ export function useWorkbenchAgent({
if (loaded.runStatus === "running" || loaded.isStreaming) resumeAgentRuntime(resources, target);
}
activateRuntime(target);
- setConnectionStatus("online");
if (runtimeRunning(target) || target.chat.messages.length) return { sessionId: id, reused: true };
setStatusLabel("Agent 正在调查水质投诉事件");
void target.chat.sendMessage({ text: prompt }, { body: {
session_id: id, model: selectedModel || undefined, approval_mode: approvalMode, trigger_context: trigger,
- capabilities: frontendActionEnabledRef.current ? ["frontend-action@1"] : [],
+ capabilities: frontendActionEnabledRef.current ? ["frontend-action@1", "host-question@1"] : ["host-question@1"],
} }).catch(() => {});
return { sessionId: id, reused: reserved.event_duplicate === true };
}, [activateRuntime, approvalMode, resources, scopeClient, scopeKey, selectedModel, transport]);
const stopPrompt = useCallback(() => {
+ for (const entry of interactionRequests.entries) {
+ if (entry.record.status === "pending") void interactionRequests.act(entry.record.interaction_id, "cancel");
+ }
chat.stop();
stopSessionStreamSubscription();
const activeSessionId = sessionIdRef.current;
@@ -864,7 +969,7 @@ export function useWorkbenchAgent({
void clientRef.current.abort(activeSessionId).then(() => refreshPendingInputs()).catch(() => undefined);
}
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
- }, [chat, refreshPendingInputs, stopSessionStreamSubscription]);
+ }, [chat, interactionRequests, refreshPendingInputs, stopSessionStreamSubscription]);
const replyPermission = useCallback(async (permission: AgentPermissionRequest, reply: AgentPermissionReply) => {
const target = resources.activeAgent;
@@ -933,6 +1038,7 @@ export function useWorkbenchAgent({
inputDelivery,
setInputDelivery,
pendingInputs,
+ interactionRequests,
updateInputDelivery,
withdrawInput,
agentMessageInbox,
@@ -946,6 +1052,7 @@ export function useWorkbenchAgent({
openMobilePanel,
closeMobilePanel,
connectionStatus,
+ connectionRecoveryVersion: recoveryVersion,
panelCollapsing,
panelOpen,
personaState,
@@ -1063,9 +1170,9 @@ function createWorkbenchAgentTransport(client: AgentApiClient, access?: Workbenc
const model = resolveAgentModelSelection(catalog, readBodyString(body, "model") ?? "");
if (!model) throw new Error("Agent 后端当前没有可用模型,请检查模型配置。");
return { body: createResponseRequest(messages, {
- conversation: readBodyString(body, "session_id"), model, topicId: readBodyString(body, "topic_id"),
+ conversation: readBodyString(body, "session_id"), interactionId: readBodyString(body, "interaction_id"), model, topicId: readBodyString(body, "topic_id"),
approvalMode: readBodyString(body, "approval_mode") ?? "request",
- capabilities: Array.isArray(body?.capabilities) ? body.capabilities.filter((value): value is string => value === "frontend-action@1") : [], triggerContext: body?.trigger_context, taskContext: body?.task_context,
+ capabilities: Array.isArray(body?.capabilities) ? body.capabilities.filter((value): value is string => value === "frontend-action@1" || value === "host-question@1") : [], triggerContext: body?.trigger_context, taskContext: body?.task_context,
}) };
},
});
diff --git a/src/features/workbench/map-workbench-page.tsx b/src/features/workbench/map-workbench-page.tsx
index 78d9fec..954b3c8 100644
--- a/src/features/workbench/map-workbench-page.tsx
+++ b/src/features/workbench/map-workbench-page.tsx
@@ -1247,6 +1247,7 @@ function MapWorkbenchContent({
inputDelivery: agent.inputDelivery,
onInputDeliveryChange: agent.setInputDelivery,
pendingInputs: agent.pendingInputs,
+ interactionRequests: agent.interactionRequests,
onPendingInputDeliveryChange: agent.updateInputDelivery,
onPendingInputEdit: (id) => agent.withdrawInput(id, true),
onPendingInputCancel: (id) => agent.withdrawInput(id, false),
diff --git a/src/shared/ai-elements/paragraph-copy.ts b/src/shared/ai-elements/paragraph-copy.ts
new file mode 100644
index 0000000..c0664cd
--- /dev/null
+++ b/src/shared/ai-elements/paragraph-copy.ts
@@ -0,0 +1,51 @@
+import type { ClipboardEvent } from "react";
+
+export function copySelectedText(event: ClipboardEvent) {
+ if (event.defaultPrevented) return;
+ const target = event.target instanceof Element ? event.target : null;
+ if (target?.closest("input, textarea, [contenteditable]:not([contenteditable=false])")) return;
+ const selection = event.currentTarget.ownerDocument.defaultView?.getSelection();
+ if (!selection || selection.isCollapsed || selection.rangeCount !== 1) return;
+ const range = selection.getRangeAt(0);
+ const text = selection.toString();
+ const suffix = text.match(/\n+$/)?.[0].length ?? 0;
+ const sourceSuffix = range.toString().match(/\n+$/)?.[0].length ?? 0;
+ // Chromium includes the block boundary when selecting an entire paragraph.
+ // Keep native internal paragraphs, table tabs and code indentation, removing
+ // only the trailing boundary that is absent from the selected source.
+ if (suffix < 2 || suffix <= sourceSuffix) return;
+ event.clipboardData.setData("text/plain", text.slice(0, text.length - suffix + sourceSuffix));
+ event.preventDefault();
+}
+
+export function copySelectedParagraph(event: ClipboardEvent, paragraph: HTMLParagraphElement, hasNonSelectableControls = false) {
+ const selection = paragraph.ownerDocument.defaultView?.getSelection();
+ if (!selection || selection.isCollapsed || selection.rangeCount !== 1) return;
+
+ const selected = selection.getRangeAt(0);
+ if (!selected.intersectsNode(paragraph)) return;
+
+ const contents = paragraph.ownerDocument.createRange();
+ contents.selectNodeContents(paragraph);
+ const bounded = selected.cloneRange();
+ if (bounded.compareBoundaryPoints(Range.START_TO_START, contents) < 0) {
+ bounded.setStart(contents.startContainer, contents.startOffset);
+ }
+ if (bounded.compareBoundaryPoints(Range.END_TO_END, contents) > 0) {
+ bounded.setEnd(contents.endContainer, contents.endOffset);
+ }
+
+ // Native paragraph selection can extend to the next message and serialize
+ // the paragraph boundary as extra newlines. Range text preserves the actual
+ // selected source, including intentional whitespace inside the message.
+ const text = bounded.toString();
+ if (!text) return;
+ if (hasNonSelectableControls) {
+ // DOM ranges can include adjacent user-select:none toolbar labels, while
+ // the rendered selection contains only this paragraph and its boundary.
+ if (selection.toString() !== `${text}\n\n`) return;
+ } else if (selected.toString() !== text || selection.toString() === text) return;
+
+ event.clipboardData.setData("text/plain", text);
+ event.preventDefault();
+}
diff --git a/src/styles.css b/src/styles.css
index 7a2ee8c..b19fd88 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -157,6 +157,7 @@
--shadow-panel: 0 12px 32px rgba(15, 33, 55, 0.14);
--shadow-agent-history-bottom: 0 12px 12px -12px rgba(15, 33, 55, 0.32);
--shadow-control: 0 6px 18px rgba(15, 33, 55, 0.1);
+ --shadow-agent-composer: 0 2px 4px rgb(15 23 42 / 0.04), 0 12px 32px -10px rgb(15 23 42 / 0.12);
--shadow-transient: 0 14px 34px rgba(15, 33, 55, 0.18);
--shadow-agent-floating:
0 18px 42px rgba(15, 33, 55, 0.18), 0 4px 12px rgba(15, 33, 55, 0.1),
@@ -824,11 +825,6 @@ button:disabled {
border: 0;
}
-.agent-panel-composer.form-field:focus-within {
- outline: 1px solid var(--field-focus-border);
- outline-offset: -1px;
-}
-
.agent-panel-history-extension .agent-history-item,
.agent-panel-history-extension .agent-history-state {
background-color: var(--agent-floating-reading-item);
@@ -932,11 +928,53 @@ button.agent-history-session-trigger:not(:disabled):active {
.agent-streaming-response {
display: block;
+ min-width: 0;
+ font-size: 16px;
+ line-height: 1.75;
--sd-animation: agent-stream-token-in;
--sd-duration: 260ms;
--sd-easing: cubic-bezier(0.16, 1, 0.3, 1);
}
+.agent-streaming-response :is(p, li) { font-size: inherit; line-height: inherit; }
+.agent-streaming-response :is(h1, h2, h3, h4) { margin-block: 1.3em 0.6em; line-height: 1.5; text-wrap: pretty; }
+.agent-streaming-response h1 { font-size: 24px; }
+.agent-streaming-response h2 { font-size: 21px; }
+.agent-streaming-response h3 { font-size: 18px; }
+.agent-streaming-response h4 { font-size: 16px; }
+.agent-streaming-response :is(ul, ol) { padding-inline-start: 1.5em; }
+.agent-streaming-response blockquote { margin-block: 1em; padding: 12px 16px; border: 1px solid var(--surface-border); border-radius: var(--radius); background: var(--surface-control); }
+.agent-streaming-response pre { max-width: 100%; overflow-x: auto; font-size: 13px; line-height: 1.7; }
+
+.markdown-reading-block { min-width: 0; margin-block: 20px; overflow: hidden; border: 1px solid var(--surface-border); border-radius: var(--radius); background: var(--surface-reading); }
+.markdown-table-scroll { max-width: 100%; max-height: 384px; overflow: auto; overscroll-behavior: contain; }
+.markdown-table-scroll:focus-visible { outline: 2px solid var(--action-blue); outline-offset: -2px; }
+.markdown-table-scroll table { width: 100%; border-collapse: collapse; font-size: 15px; line-height: 1.7; }
+.markdown-table-scroll :is(th, td) { min-width: 112px; max-width: 400px; padding: 12px 16px; border: 0; border-bottom: 1px solid var(--surface-divider); vertical-align: top; overflow-wrap: anywhere; }
+.markdown-table-scroll th { position: sticky; top: 0; z-index: 1; background: var(--surface-control); color: var(--ink); text-align: start; font-weight: 600; }
+.markdown-table-scroll td { font-variant-numeric: tabular-nums; }
+.markdown-table-scroll tr:last-child td { border-bottom: 0; }
+.markdown-reading-window { width: calc(100vw - 32px); max-width: 1120px; height: min(84dvh, 900px); max-height: calc(100dvh - 32px); display: flex; flex-direction: column; gap: 0; overflow: hidden; padding: 0; border-radius: 12px; background: var(--surface-reading); color: var(--ink); }
+.markdown-reading-window [data-sd-animate] { animation: none; filter: none; opacity: 1; transform: none; }
+.markdown-reading-window .markdown-table-scroll { max-height: none; overflow: visible; }
+.markdown-reading-window .markdown-table-scroll table { font-size: 16px; }
+.markdown-reading-window[data-reading-kind="table"] { height: auto; max-height: min(84dvh, 900px); }
+.markdown-diagram-renderer { min-width: 0; padding: 8px; }
+.markdown-diagram-renderer [data-streamdown="mermaid-block"] { margin: 0; border: 0; border-radius: 0; background: var(--surface-reading); padding: 0; }
+.markdown-diagram-renderer [data-streamdown="mermaid-block"] > div:first-child { visibility: hidden; }
+.markdown-diagram-renderer [data-streamdown="mermaid-block-actions"] { background: var(--surface-control); backdrop-filter: none; }
+.markdown-diagram-renderer [data-streamdown="mermaid"] { height: clamp(300px, 52dvh, 480px); }
+.markdown-diagram-renderer [data-streamdown="mermaid"] > div { height: 100%; }
+.markdown-diagram-renderer [data-streamdown="mermaid"] [role="application"] { justify-content: flex-start; }
+.markdown-diagram-renderer [data-streamdown="mermaid"] [role="img"] { width: max-content; min-width: 100%; flex: none; padding: 20px 48px 20px 12px; }
+.markdown-diagram-renderer [data-streamdown="mermaid"] [role="img"] > svg { width: auto; max-width: none; height: auto; flex-shrink: 0; }
+.markdown-reading-window .markdown-diagram-renderer [data-streamdown="mermaid"] { height: min(62dvh, 640px); }
+@media (max-width: 639px) {
+ .markdown-reading-window { width: calc(100vw - 16px); max-height: calc(100dvh - 16px); height: min(88dvh, 900px); }
+ .markdown-reading-window[data-reading-kind="table"] { height: auto; max-height: min(88dvh, 900px); }
+ .markdown-table-scroll :is(th, td) { padding: 10px 12px; }
+}
+
[data-streamdown="table-fullscreen"] [data-sd-animate] {
animation: none;
filter: none;
@@ -1159,10 +1197,6 @@ button.agent-history-session-trigger:not(:disabled):active {
transition: background-color 180ms ease;
}
-.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-input-body:focus-within {
- border-color: var(--field-focus-border);
-}
-
.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-input-tools {
min-height: 40px;
padding: 0 4px;
@@ -1196,7 +1230,7 @@ button.agent-history-session-trigger:not(:disabled):active {
.agent-panel-suggestion-status {
padding: 12px;
- font-size: 12px;
+ font-size: 14px;
line-height: 20px;
color: var(--ink-muted);
}
@@ -1241,7 +1275,7 @@ button.agent-history-session-trigger:not(:disabled):active {
background: transparent;
}
-.agent-panel-shell[data-agent-presentation="desktop-dock"] [data-agent-progress] {
+.agent-panel-shell[data-agent-presentation="desktop-dock"] .agent-panel-nested[data-agent-progress] {
border-color: var(--surface-divider);
background: var(--agent-dock-surface);
box-shadow: none;
@@ -2020,7 +2054,7 @@ details[open] > summary > .disclosure-accordion-indicator {
}
.agent-chat-avatar { width: 28px; height: 28px; border-radius: 8px; }
.agent-chat-message-header { display: flex; align-items: center; gap: 8px; min-height: 28px; }
-.agent-chat-message-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 12px; }
+.agent-chat-message-actions { display: flex; flex-wrap: nowrap; align-items: center; gap: 0; }
.agent-chat-context-bar { display: flex; flex-direction: column; flex-shrink: 0; min-width: 0; padding: 12px; background: var(--surface-navigation); }
.agent-panel-shell[data-agent-presentation] .agent-panel-conversation {
@@ -2044,7 +2078,8 @@ details[open] > summary > .disclosure-accordion-indicator {
background: #f1f5f9;
box-shadow: none;
}
-.agent-chat-message-list { padding: 8px 4px; }
+.agent-panel-shell[data-agent-presentation] { --agent-message-list-inset: 4px; }
+.agent-chat-message-list { padding: 8px var(--agent-message-list-inset, 4px); }
.agent-panel-shell[data-agent-presentation] .agent-panel-band {
background: #ffffff;
padding: 8px 12px;
@@ -2056,38 +2091,73 @@ details[open] > summary > .disclosure-accordion-indicator {
}
.agent-panel-shell[data-agent-presentation] .agent-panel-input-form {
- border: 1px solid #cbd5e1;
- border-radius: 14px;
+ border: 1px solid #e4e4e7;
+ border-radius: 22px;
background: #ffffff;
background-image: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
- box-shadow: 0 2px 8px rgb(15 23 42 / 0.04);
-}
-.agent-panel-shell[data-agent-presentation] .agent-panel-input-form:focus-within {
- border-color: #2563eb;
- box-shadow: 0 0 0 2px rgb(37 99 235 / 0.08);
}
.agent-panel-shell[data-agent-presentation] .agent-panel-input-body {
+ display: block;
+ width: 100%;
border: 0;
- border-radius: 14px 14px 0 0;
+ border-radius: 22px 22px 0 0;
background: #ffffff;
}
.agent-panel-shell[data-agent-presentation] .agent-panel-input-tools {
+ width: auto;
min-width: 0;
+ align-self: stretch;
+ flex-wrap: nowrap;
margin: 0 6px 6px;
padding: 4px;
border-top: 0;
background: #ffffff;
}
.agent-panel-shell[data-agent-presentation="mobile-sheet"] .agent-panel-band { flex-shrink: 0; }
-.agent-panel-shell[data-agent-presentation="mobile-sheet"] .agent-panel-input-tools { flex-wrap: wrap; }
+.agent-panel-shell[data-agent-presentation] .agent-panel-toolbar-trigger { border-radius: 10px; }
+.agent-panel-shell[data-agent-presentation] .agent-panel-voice-input { border-radius: 50%; }
+.agent-panel-shell[data-agent-presentation] .agent-panel-input-tools svg {
+ width: 16px;
+ height: 16px;
+ stroke-width: 2;
+}
+.agent-panel-shell[data-agent-presentation] .agent-panel-input-tools .agent-panel-toolbar-caret {
+ width: 12px;
+ height: 12px;
+ color: var(--ink-muted);
+}
+.agent-panel-shell[data-agent-presentation] .agent-panel-input-tools .agent-panel-send-icon {
+ width: 20px;
+ height: 20px;
+ stroke-width: 2.25;
+}
.agent-panel-shell[data-agent-presentation] .agent-panel-primary-icon {
- border-radius: 9px;
+ display: grid;
+ place-items: center;
+ padding: 0;
+ border-radius: 999px;
background: #2563eb;
box-shadow: none;
}
-.agent-panel-shell[data-agent-presentation] .agent-panel-primary-icon:disabled { background: #94a3b8; }
+.agent-panel-shell[data-agent-presentation] .agent-panel-primary-icon:disabled {
+ background: var(--surface-control);
+ color: var(--ink-muted);
+ opacity: 1;
+}
+.agent-panel-shell[data-agent-presentation] .agent-panel-primary-icon[data-agent-action="stop"] {
+ background: var(--status-danger-soft);
+ color: var(--status-danger-foreground);
+}
+@media (hover: hover) {
+ .agent-panel-shell[data-agent-presentation] .agent-panel-primary-icon:hover:not(:disabled) {
+ background: var(--action-blue-hover);
+ }
+ .agent-panel-shell[data-agent-presentation] .agent-panel-primary-icon[data-agent-action="stop"]:hover:not(:disabled) {
+ background: var(--status-danger-border);
+ }
+}
.agent-panel-shell[data-agent-presentation] .agent-panel-icon-button,
.agent-panel-shell[data-agent-presentation] .agent-project-trigger {
border-color: transparent;
@@ -2111,7 +2181,39 @@ details[open] > summary > .disclosure-accordion-indicator {
@media (max-width: 639px) {
.agent-panel-shell[data-agent-centered="true"] .agent-panel-band { padding: 24px 16px; }
}
-.agent-panel-shell[data-agent-presentation] .agent-chat-context-bar { background: #ffffff; }
+.agent-panel-shell[data-agent-presentation] .agent-chat-context-bar {
+ position: relative;
+ z-index: 2;
+ padding-inline: calc(12px + var(--agent-message-list-inset));
+ background: var(--surface-navigation);
+ text-align: left;
+}
+.agent-panel-shell[data-agent-presentation] .agent-chat-context-bar > p {
+ scrollbar-gutter: stable both-edges;
+}
+.agent-panel-shell[data-agent-presentation] .agent-chat-context-bar::after,
+.agent-panel-shell[data-agent-presentation][data-agent-starting="false"] .agent-panel-band::before {
+ position: absolute;
+ inset-inline: 0;
+ height: 32px;
+ pointer-events: none;
+ content: "";
+}
+.agent-panel-shell[data-agent-presentation] .agent-chat-context-bar::after {
+ top: 100%;
+ background: linear-gradient(to bottom, var(--surface-navigation), transparent);
+}
+.agent-panel-shell[data-agent-presentation][data-agent-starting="false"] .agent-panel-band::before {
+ z-index: 2;
+ bottom: 100%;
+ background: linear-gradient(to top, var(--surface-navigation), transparent);
+}
+@media (forced-colors: active), (prefers-reduced-transparency: reduce) {
+ .agent-panel-shell[data-agent-presentation] .agent-chat-context-bar::after,
+ .agent-panel-shell[data-agent-presentation][data-agent-starting="false"] .agent-panel-band::before {
+ content: none;
+ }
+}
/* Keep one compact form mounted while the starting group becomes a bottom composer. */
.agent-panel-shell[data-agent-presentation][data-agent-starting] .agent-panel-input-form > [data-slot="input-group"] { gap: 4px; }
.agent-panel-shell[data-agent-presentation][data-agent-starting] .agent-panel-input-tools { padding-block: 4px; }
@@ -2119,8 +2221,11 @@ details[open] > summary > .disclosure-accordion-indicator {
min-height: 64px;
max-height: clamp(64px, 24dvh, 160px);
padding: 14px 14px 8px;
- font-size: 14px;
- line-height: 22px;
+ font-size: 16px;
+ line-height: 28px;
+}
+.agent-panel-shell[data-agent-presentation][data-agent-starting] .agent-panel-input-body:has(.agent-panel-input-reference) textarea.agent-panel-prompt-textarea {
+ padding-top: 6px;
}
.agent-panel-shell[data-agent-presentation] .agent-chat-suggestions {
flex-direction: column;
@@ -2142,7 +2247,7 @@ details[open] > summary > .disclosure-accordion-indicator {
background: #ffffff;
color: #52525b;
box-shadow: none;
- font-size: 12px;
+ font-size: 14px;
line-height: 20px;
font-weight: 400;
}
@@ -2153,15 +2258,17 @@ details[open] > summary > .disclosure-accordion-indicator {
color: #18181b;
}
}
-.agent-panel-shell[data-agent-presentation] .agent-panel-input-form { border-color: #e4e4e7; box-shadow: 0 1px 2px rgb(0 0 0 / 0.03); }
+.agent-panel-shell[data-agent-presentation] form.agent-panel-control { box-shadow: var(--shadow-agent-composer); }
.resizable-handle { color: var(--ink-muted); touch-action: none; }
+.resizable-grip { opacity: 0; }
.resizable-handle::after { content: ""; position: absolute; inset: 0 -6px; }
.resizable-handle[aria-orientation="horizontal"]::after { inset: -6px 0; }
.resizable-handle[aria-orientation="horizontal"] .resizable-grip { transform: rotate(90deg); }
.resizable-handle:is([data-separator="hover"], [data-separator="active"], :focus-visible) { background: var(--action-blue); }
.resizable-handle:is([data-separator="hover"], [data-separator="active"], :focus-visible) .resizable-grip {
+ opacity: 1;
color: var(--action-blue);
border-color: var(--action-blue);
}
diff --git a/tests/browser/agent-answer-transition.e2e.ts b/tests/browser/agent-answer-transition.e2e.ts
new file mode 100644
index 0000000..41bdd0d
--- /dev/null
+++ b/tests/browser/agent-answer-transition.e2e.ts
@@ -0,0 +1,113 @@
+import { expect, test, type Page } from "@playwright/test";
+import { createServer } from "node:http";
+import type { AddressInfo } from "node:net";
+import { responseFixtureWriter } from "./support/responses-fixture";
+import { mockAgentApi } from "./support/mock-agent-api";
+import { mockWorkbenchContext } from "./support/mock-workbench-context";
+
+const finalText = "已完成三根管道的压力核查。结果属于模型计算,仍需现场核验。";
+async function openStream(page: Page, width: number) {
+ await page.setViewportSize({ width, height: 900 });
+ await mockWorkbenchContext(page);
+ await mockAgentApi(page);
+ let publishFinal!: () => void;
+ let finish!: () => void;
+ const server = createServer((_request, response) => {
+ response.writeHead(200, { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" });
+ const encode = responseFixtureWriter();
+ const send = (chunk: Record) => response.write(encode(chunk));
+ send({ type: "start", messageId: "answer-transition" });
+ send({ type: "text-start", id: "process-text" });
+ send({ type: "text-delta", id: "process-text", delta: Array.from({ length: 8 }, (_, i) => `第${i + 1}阶段:正在核对管道压力与模拟时段,本阶段数据仅用于分析过程。`).join("\n\n") });
+ for (let i = 0; i < 6; i++) send({ type: "data-progress", data: { id: `step-${i}`, phase: "tool", title: `核对资料步骤 ${i + 1}`, detail: "查询监测记录并检查时段与单位。", status: i === 5 ? "running" : "completed" } });
+ send({ type: "text-end", id: "process-text" });
+ publishFinal = () => send({ type: "data-final_answer", data: { content: finalText, ui_block_ids: [] } });
+ finish = () => { send({ type: "finish", finishReason: "stop" }); response.end(); };
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ await page.route("**/api/v1/responses", (route) => route.continue({ url: `http://127.0.0.1:${(server.address() as AddressInfo).port}/stream` }));
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
+ const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
+ await panel.locator("textarea").fill("核对压力");
+ await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
+ const message = panel.locator('[data-agent-message-id="answer-transition"]');
+ await expect(message.getByText("核对资料步骤 6", { exact: true })).toBeVisible();
+ return { panel, message, publishFinal: () => publishFinal(), finish: () => finish(), close: async () => {
+ server.closeAllConnections();
+ await new Promise((resolve) => server.close(() => resolve()));
+ } };
+}
+
+for (const width of [1440, 320]) {
+ for (const reducedMotion of ["reduce", "no-preference"] as const) {
+ test(`final report settles once before transport close at ${width}px with ${reducedMotion}`, async ({ page }, testInfo) => {
+ await page.emulateMedia({ reducedMotion });
+ const stream = await openStream(page, width);
+ try {
+ const { message } = stream;
+ await message.locator("[data-agent-progress]").evaluate((node) => { node.setAttribute("data-original-progress", "true"); });
+ stream.publishFinal();
+ await expect(message.getByText(finalText, { exact: true })).toBeVisible();
+ await expect(message.getByRole("button", { name: "复制回复", exact: true })).toBeVisible();
+ await expect(message.getByText("正在回复", { exact: true })).toHaveCount(0);
+ await expect(message.locator("details[data-agent-process]")).not.toHaveAttribute("open");
+ const settled = await message.boundingBox();
+ await message.screenshot({ path: testInfo.outputPath(`report-ready-${width}-${reducedMotion}.png`) });
+ stream.finish();
+ await expect(stream.panel.getByRole("button", { name: "发送 Agent 指令", exact: true })).toBeVisible();
+ const after = await message.boundingBox();
+ expect(Math.abs(after!.height - settled!.height)).toBeLessThanOrEqual(1);
+ expect(Math.abs(after!.y - settled!.y)).toBeLessThanOrEqual(1);
+ await message.getByRole("button", { name: "查看处理过程", exact: true }).click();
+ const process = message.locator("details[data-agent-process]");
+ await expect(process.getByText(/第1阶段:/)).toBeVisible();
+ await expect(process.locator('[data-original-progress="true"]')).toBeVisible();
+ } finally { await stream.close(); }
+ });
+ }
+}
+
+for (const reducedMotion of ["reduce", "no-preference"] as const) test(`reading a live process still folds on completion and retains tool details with ${reducedMotion}`, async ({ page }) => {
+ await page.emulateMedia({ reducedMotion });
+ const stream = await openStream(page, 1440);
+ try {
+ const { message, panel } = stream;
+ const tool = message.locator("details[data-agent-tool]").last();
+ await tool.locator("summary").click();
+ const detail = tool.getByText("查询监测记录并检查时段与单位。", { exact: true });
+ await expect(detail).toBeVisible();
+ await expect(tool).toHaveAttribute("data-disclosure-motion", "idle");
+ const scroll = panel.locator(".agent-conversation-scroll");
+ await scroll.hover();
+ await page.mouse.wheel(0, -300);
+ await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
+ const reading = message.getByText(/第1阶段:/).first();
+ await expect(reading).toBeInViewport();
+ // Finish the reader's wheel movement and any preceding disclosure layout
+ // before measuring the separate final-report handoff.
+ let lastTop: number | undefined;
+ await expect.poll(async () => {
+ const top = (await reading.boundingBox())!.y;
+ const stable = top === lastTop;
+ lastTop = top;
+ return stable;
+ }).toBe(true);
+ stream.publishFinal();
+ await expect(message.getByRole("button", { name: "复制回复", exact: true })).toBeVisible();
+ const process = message.locator("details[data-agent-process]");
+ await expect(process).not.toHaveAttribute("open");
+ await expect(detail).toBeHidden();
+ await expect(message.locator("[data-answer-phase]")).toHaveAttribute("data-answer-phase", "settled");
+ await expect(message.getByText(finalText, { exact: true })).toBeInViewport();
+ stream.finish();
+ await expect(panel.getByRole("button", { name: "发送 Agent 指令", exact: true })).toBeVisible();
+ await expect(process).not.toHaveAttribute("open");
+ await message.getByRole("button", { name: "查看处理过程", exact: true }).click();
+ await expect(process).toHaveAttribute("open", "");
+ await expect(process.locator("details[data-agent-tool]").last()).toHaveAttribute("open", "");
+ await expect(detail).toBeVisible();
+ await message.getByRole("button", { name: "查看处理过程", exact: true }).click();
+ await expect(process).not.toHaveAttribute("open");
+ } finally { await stream.close(); }
+});
diff --git a/tests/browser/agent-interaction-card.e2e.ts b/tests/browser/agent-interaction-card.e2e.ts
new file mode 100644
index 0000000..d00cc11
--- /dev/null
+++ b/tests/browser/agent-interaction-card.e2e.ts
@@ -0,0 +1,78 @@
+import { expect, test } from "@playwright/test";
+import type { InteractionAnswer, InteractionRequest } from "../../src/features/agent/api/interaction-requests";
+import { mockAgentApi } from "./support/mock-agent-api";
+
+for (const width of [1440, 375, 320]) {
+ test(`question card aligns wrapped choices and accepts a text-only answer at ${width}px`, async ({ page }, testInfo) => {
+ await page.setViewportSize({ width, height: 900 });
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await mockAgentApi(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.setContent('');
+ await page.evaluate(async () => {
+ const entry = await (await fetch("/src/main.tsx")).text();
+ const reactUrl = entry.match(/from "([^"\n]*\/react\.js[^"\n]*)"/)?.[1];
+ const clientUrl = entry.match(/from "([^"\n]*\/react-dom_client\.js[^"\n]*)"/)?.[1];
+ if (!reactUrl || !clientUrl) throw new Error("Vite React module paths unavailable");
+ const reactModule = await import(reactUrl);
+ const React = reactModule.default ?? reactModule;
+ const clientModule = await import(clientUrl);
+ const { createRoot } = clientModule.default ?? clientModule;
+ const { AgentInteractionRequestCard } = await import("/src/features/agent/components/agent-interaction-request-card.tsx");
+ const root = createRoot(document.getElementById("question-reader"));
+ function Fixture({ custom }: { custom: boolean }) {
+ const [draft, setDraft] = React.useState([] as InteractionAnswer[]);
+ const [submitted, setSubmitted] = React.useState(false);
+ const record: InteractionRequest = { version: "agent-interaction-request@1", interaction_id: custom ? "free-text" : "closed-choices", message_id: "m", turn_id: "t", call_id: "c",
+ questions: [{ question_id: "target", title: "确认要定位的对象", body: custom ? "可直接填写需要定位的对象和地址线索。" : "当前记录包含公司上报地址和三组模拟管段位置。不同目标会在地图上定位到不同位置,请选择要定位的对象。",
+ options: custom ? [] : [
+ { id: "company", label: "公司上报地址:安徽省滁州市凤阳县西泉镇的公司点位" },
+ { id: "pipe1", label: "模拟管段 Pipe19026(用户给出的约315米位置)" },
+ { id: "pipe2", label: "模拟管段 Pipe18751(用户给出的约516米位置)" },
+ { id: "pipe3", label: "模拟管段 Pipe18750(用户给出的约523米位置)" }
+ ], mode: "single", required: true, allow_custom: custom }],
+ status: submitted ? "answered" : "pending", revision: submitted ? 1 : 0, draft_revision: 0, draft: [], created_at: "2026-10-28T09:19:24Z",
+ ...(submitted ? { answers: draft.map((answer: InteractionAnswer) => ({ ...answer, selected_options: [] })) } : {}) };
+ return React.createElement(AgentInteractionRequestCard, { entry: { record, draft, dirty: draft.length > 0, busy: false }, checking: false,
+ onChange: (next: InteractionAnswer[]) => setDraft(next), onAction: (action: string) => { if (action === "reply") setSubmitted(true); } });
+ }
+ const render = (custom: boolean) => root.render(React.createElement(Fixture, { key: String(custom), custom }));
+ Object.assign(window, { showTextOnlyQuestion: () => render(true) });
+ render(false);
+ });
+ const card = page.getByRole("article", { name: "需要补充的信息" });
+ await expect(card.getByRole("radio")).toHaveCount(4);
+ await expect(card.getByRole("textbox")).toHaveCount(0);
+ await expect(card.getByRole("button", { name: "提交回答", exact: true })).toBeDisabled();
+ const choice = card.getByRole("radio").last();
+ const backgroundBefore = await choice.locator("xpath=ancestor::label").evaluate((element) => getComputedStyle(element).backgroundColor);
+ await choice.focus();
+ await page.keyboard.press("Space");
+ await expect(choice).toBeChecked();
+ const backgroundAfter = await choice.locator("xpath=ancestor::label").evaluate((element) => getComputedStyle(element).backgroundColor);
+ expect(backgroundAfter).not.toBe(backgroundBefore);
+ const offsets = await card.getByRole("radio").evaluateAll((inputs) => inputs.map((input) => {
+ const label = input.closest("label")!;
+ const text = label.lastElementChild!;
+ const radioBounds = input.getBoundingClientRect();
+ const textBounds = text.getBoundingClientRect();
+ return Math.abs(radioBounds.top + radioBounds.height / 2 - textBounds.top - parseFloat(getComputedStyle(text).lineHeight) / 2);
+ }));
+ expect(Math.max(...offsets)).toBeLessThanOrEqual(1);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
+ await card.screenshot({ path: testInfo.outputPath(`question-choices-${width}.png`) });
+
+ await page.evaluate(() => (window as unknown as { showTextOnlyQuestion: () => void }).showTextOnlyQuestion());
+ await expect(card.getByRole("radio")).toHaveCount(0);
+ const custom = card.getByRole("textbox", { name: "确认要定位的对象:补充说明" });
+ await custom.fill("请定位公司上报地址,并保留现有管段标记。");
+ await expect(card.getByRole("button", { name: "提交回答", exact: true })).toBeEnabled();
+ await card.screenshot({ path: testInfo.outputPath(`question-custom-${width}.png`) });
+ await card.getByRole("button", { name: "提交回答", exact: true }).click();
+ const reply = page.getByRole("group", { name: "已提交的回答" });
+ await expect(reply.getByText("已回答", { exact: true })).toBeVisible();
+ await expect(card).toHaveCount(0);
+ await expect(page.getByRole("textbox")).toHaveCount(0);
+ await expect(reply.getByText("请定位公司上报地址,并保留现有管段标记。", { exact: true })).toBeVisible();
+ });
+}
diff --git a/tests/browser/agent-interaction-requests.e2e.ts b/tests/browser/agent-interaction-requests.e2e.ts
new file mode 100644
index 0000000..c507733
--- /dev/null
+++ b/tests/browser/agent-interaction-requests.e2e.ts
@@ -0,0 +1,173 @@
+import { expect, test } from "@playwright/test";
+import { responseFixtureWriter } from "./support/responses-fixture";
+import { mockAgentApi } from "./support/mock-agent-api";
+import { mockWorkbenchContext } from "./support/mock-workbench-context";
+import type { InteractionRequest } from "../../src/features/agent/api/interaction-requests";
+
+for (const width of [1440, 320]) {
+ test(`host question restores draft and requires explicit submit at ${width}px`, async ({ page }) => {
+ await page.setViewportSize({ width, height: 900 });
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await mockWorkbenchContext(page, undefined, undefined, {}, [], { runtimeMode: "backend" });
+ await mockAgentApi(page, { sessions: [{ session_id: "host-question", title: "待补充范围", created_at: Date.now(), updated_at: Date.now() }] });
+ const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, PUT, POST, OPTIONS", "Access-Control-Allow-Headers": "Authorization, Content-Type, X-Project-Id" };
+ let record: InteractionRequest = { version: "agent-interaction-request@1", interaction_id: "i", message_id: "m", turn_id: "t", call_id: "c", questions: [{ question_id: "q", title: "核查范围", body: "选择本次核查范围", options: [{ id: "north", label: "北侧管网" }, { id: "all", label: "全部管网" }], mode: "single", required: true, allow_custom: true }], revision: 0, draft_revision: 0, draft: [], status: "pending", created_at: new Date().toISOString() };
+ const writes: string[] = [];
+ const inputs: Array<{ id: string; text: string; delivery: string; status: string }> = [];
+ await page.route("**/api/v1/responses", (route) => {
+ const body = route.request().postDataJSON();
+ expect(body.stream).toBe(false);
+ const input = { id: body.tjwater.input_id, text: body.input, delivery: body.tjwater.delivery, status: "pending" };
+ inputs.push(input);
+ return route.fulfill({ headers, status: 202, json: input });
+ });
+ await page.route("**/api/v1/session/host-question/inputs", (route) => route.fulfill({ headers, json: { items: inputs } }));
+ await page.route("**/api/v1/session/host-question?**", (route) => route.fulfill({ headers, json: { session_id: "host-question", title: "待补充范围", purpose: "ordinary_chat", status: "active", is_streaming: false, messages: [{ id: "m", role: "assistant", parts: [{ type: "text", text: "请补充核查范围。" }] }] } }));
+ await page.route("**/api/v1/session/host-question/interactions**", async (route) => {
+ const request = route.request();
+ if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers });
+ const action = new URL(request.url()).pathname.split("/").at(-1)!;
+ if (request.method() === "GET") return route.fulfill({ headers, json: action === "interactions" ? { items: [record] } : record });
+ writes.push(action);
+ const body = request.postDataJSON();
+ if (action === "draft") record = { ...record, draft: body.answers, draft_revision: record.draft_revision + 1 };
+ if (action === "cancel") record = { ...record, status: "cancelled", revision: 1 };
+ return route.fulfill({ headers, json: record });
+ });
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
+ const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
+ await panel.getByRole("button", { name: /历史/ }).click();
+ await page.locator("button.agent-history-session-trigger").filter({ hasText: "待补充范围" }).click();
+ const card = panel.getByRole("article", { name: "需要补充的信息" });
+ await expect(card).toBeVisible();
+ await expect(card.getByRole("button", { name: "提交回答" })).toBeDisabled();
+ await expect(card.getByRole("button", { name: "跳过", exact: true })).toHaveCount(0);
+ await card.getByRole("radio", { name: "北侧管网" }).check();
+ await card.getByRole("textbox", { name: "核查范围:补充说明" }).fill("优先检查压力下降区域");
+ await expect.poll(() => record.draft[0]?.custom_text).toBe("优先检查压力下降区域");
+ expect(writes.length).toBeGreaterThan(0);
+ expect(writes.every((action) => action === "draft")).toBe(true);
+ await expect(card.getByRole("button", { name: "提交回答" })).toBeEnabled();
+ await card.screenshot({ path: `/tmp/host-question-${width}.png` });
+ const bounds = await card.boundingBox();
+ expect(bounds!.x).toBeGreaterThanOrEqual(0);
+ expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(width);
+ await panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill("先核对压力测点");
+ await panel.getByRole("button", { name: "引导当前 Agent 任务", exact: true }).click();
+ await expect.poll(() => inputs.map((input) => input.delivery)).toEqual(["steer"]);
+ await expect(card.getByText("等待回答", { exact: true })).toBeVisible();
+ expect(writes.every((action) => action === "draft")).toBe(true);
+ await card.getByRole("button", { name: "取消提问" }).click();
+ await expect(card.getByText("已取消", { exact: true })).toBeVisible();
+ await expect(card.getByRole("button", { name: "提交回答" })).toHaveCount(0);
+ });
+}
+
+for (const responseMode of ["completed_without_event", "pending_conflict"] as const) {
+ test(`collaboration restores a persisted question after ${responseMode} and submits its answer`, async ({ page }, testInfo) => {
+ const width = responseMode === "pending_conflict" ? 320 : 1440;
+ await page.setViewportSize({ width, height: 900 });
+ await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await mockWorkbenchContext(page, undefined, undefined, {}, [], { runtimeMode: "backend" });
+ const sessionId = "collaboration-question";
+ await mockAgentApi(page, { sessions: [{ session_id: sessionId, title: "公司位置协作", purpose: "task_collaboration", created_at: Date.now(), updated_at: Date.now() }] });
+ const headers = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, PUT, POST, OPTIONS", "Access-Control-Allow-Headers": "Authorization, Content-Type, X-Project-Id" };
+ let pending = false;
+ let listReads = 0;
+ let responses = 0;
+ let record: InteractionRequest = { version: "agent-interaction-request@1", interaction_id: "locate-target", message_id: "question-message", turn_id: "question-turn", call_id: "ask-call", questions: [{ question_id: "target", title: "选择定位对象", body: "选择公司上报地址或模拟管段。", options: [{ id: "company", label: "公司上报地址" }, { id: "pipes", label: "模拟管段" }], mode: "single", required: true, allow_custom: true }], revision: 0, draft_revision: 0, draft: [], status: "pending", created_at: new Date().toISOString() };
+ await page.route(`**/api/v1/session/${sessionId}?**`, (route) => route.fulfill({ headers, json: { session_id: sessionId, title: "公司位置协作", purpose: "task_collaboration", status: "active", is_streaming: false, messages: [{ id: "context-message", role: "assistant", parts: [{ type: "text", text: "调查报告已就绪。" }] },
+ ...(record.delivery ? [{ id: record.delivery.native_message_id, role: "user", parts: [{ type: "text", text: "用户提交的提问回复:\n选择定位对象:公司上报地址;使用报告中的公司位置" }] }] : [])] } }));
+ await page.route(`**/api/v1/session/${sessionId}/interactions**`, async (route) => {
+ const request = route.request();
+ if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers });
+ const action = new URL(request.url()).pathname.split("/").at(-1)!;
+ if (request.method() === "GET") {
+ if (action === "interactions") listReads++;
+ return route.fulfill({ headers, json: action === "interactions" ? { items: pending ? [record] : [] } : record });
+ }
+ const body = request.postDataJSON();
+ if (action === "draft") record = { ...record, draft: body.answers, draft_revision: record.draft_revision + 1 };
+ if (action === "reply") record = { ...record, status: "answered", revision: 1,
+ answers: body.answers.map((answer: { question_id: string; selected_option_ids: string[]; custom_text: string }) => ({ ...answer, selected_options: record.questions[0].options.filter((option) => answer.selected_option_ids.includes(option.id)) })),
+ delivery: { native_message_id: `msg_${"a".repeat(32)}`, status: "queued", revision: 0 } };
+ if (action === "resume") record = { ...record, delivery: { ...record.delivery!, status: "queued", revision: record.delivery!.revision + 1 } };
+ return route.fulfill({ headers, json: record });
+ });
+ await page.route("**/api/v1/responses", (route) => {
+ responses++;
+ const body = route.request().postDataJSON();
+ if (responses === 1) {
+ pending = true;
+ if (responseMode === "pending_conflict") return route.fulfill({ headers, status: 409, json: { code: "INTERACTION_PENDING", message: "请先回答或取消当前提问。" } });
+ } else {
+ expect(body.tjwater.interaction_id).toBe(record.interaction_id);
+ expect(record.answers?.[0]).toMatchObject({ selected_option_ids: responseMode === "pending_conflict" ? ["company"] : [], custom_text: "使用报告中的公司位置" });
+ if (responseMode === "pending_conflict" && responses === 2) {
+ record = { ...record, delivery: { ...record.delivery!, status: "blocked", revision: 1 } };
+ return route.fulfill({ headers, status: 503, body: "暂时无法继续,请重试。" });
+ }
+ record = { ...record, delivery: { ...record.delivery!, status: "delivered", revision: record.delivery!.revision + 1 } };
+ }
+ const write = responseFixtureWriter(sessionId);
+ return route.fulfill({ headers: { ...headers, "Content-Type": "text/event-stream" }, body: [
+ { type: "start", messageId: responses === 1 ? "question-message" : "located-message" },
+ { type: "text-start", id: "text" },
+ { type: "text-delta", id: "text", delta: responses === 1 ? "请选择要定位的对象。" : "已定位公司上报地址。" },
+ { type: "text-end", id: "text" },
+ { type: "finish", finishReason: "stop" }
+ ].map(write).join("") });
+ });
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
+ const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
+ await panel.getByRole("button", { name: /历史/ }).click();
+ await page.locator("button.agent-history-session-trigger").filter({ hasText: "公司位置协作" }).click();
+ await expect.poll(() => listReads).toBeGreaterThan(0);
+ await expect(panel.getByRole("article", { name: "需要补充的信息" })).toHaveCount(0);
+ const readsBeforeSend = listReads;
+ const input = panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
+ await input.fill("定位到公司上报地址");
+ await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
+ const card = panel.getByRole("article", { name: "需要补充的信息" });
+ await expect(card).toBeVisible();
+ expect(listReads).toBeGreaterThan(readsBeforeSend);
+ await expect(panel.getByText("离线", { exact: true })).toHaveCount(0);
+ await expect(panel.getByRole("button", { name: "引导当前 Agent 任务", exact: true })).toBeDisabled();
+ if (responseMode === "pending_conflict") await card.getByRole("radio", { name: "公司上报地址", exact: true }).check();
+ await card.getByRole("textbox", { name: "选择定位对象:补充说明" }).fill("使用报告中的公司位置");
+ await card.screenshot({ path: testInfo.outputPath(`recovered-${responseMode}.png`) });
+ await card.getByRole("button", { name: "提交回答", exact: true }).click();
+ const reply = panel.getByRole("group", { name: "已提交的回答" });
+ await expect(reply).toHaveCount(1);
+ await expect(reply.getByText("已回答", { exact: true })).toBeVisible();
+ await expect(reply.getByText("使用报告中的公司位置", { exact: false })).toBeVisible();
+ if (responseMode === "pending_conflict") {
+ await expect(reply.getByText("继续失败,可以重试", { exact: true })).toBeVisible();
+ await reply.getByRole("button", { name: "重试继续", exact: true }).click();
+ }
+ await expect(reply.getByText("已继续处理", { exact: true })).toBeVisible();
+ await expect(card).toHaveCount(0);
+ await expect(panel.getByText("用户提交的提问回复", { exact: false })).toHaveCount(0);
+ expect(await reply.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
+ await reply.screenshot({ path: testInfo.outputPath(`submitted-${responseMode}.png`) });
+ await reply.getByText("使用报告中的公司位置", { exact: true }).click({ clickCount: 3 });
+ await page.keyboard.press("Control+c");
+ expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("使用报告中的公司位置");
+ await expect(panel.getByText("已定位公司上报地址。", { exact: true })).toBeVisible();
+ expect(responses).toBe(responseMode === "pending_conflict" ? 3 : 2);
+ await expect(reply).toHaveCount(1);
+ await input.fill("继续核查");
+ await expect(panel.getByRole("button", { name: "发送 Agent 指令", exact: true })).toBeEnabled();
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
+ await panel.getByRole("button", { name: /历史/ }).click();
+ await page.locator("button.agent-history-session-trigger").filter({ hasText: "公司位置协作" }).click();
+ await expect(reply).toHaveCount(1);
+ await expect(reply.getByText("使用报告中的公司位置", { exact: false })).toBeVisible();
+ await expect(panel.getByText("用户提交的提问回复", { exact: false })).toHaveCount(0);
+ await expect(card).toHaveCount(0);
+ });
+}
diff --git a/tests/browser/agent-process-continuation.e2e.ts b/tests/browser/agent-process-continuation.e2e.ts
new file mode 100644
index 0000000..de1f12a
--- /dev/null
+++ b/tests/browser/agent-process-continuation.e2e.ts
@@ -0,0 +1,131 @@
+import { expect, test } from "@playwright/test";
+import { mockAgentApi } from "./support/mock-agent-api";
+import { mockWorkbenchContext } from "./support/mock-workbench-context";
+import { responsesFixture } from "./support/responses-fixture";
+
+for (const width of [1440, 320]) {
+ test(`restored clarification process belongs to its continuation at ${width}px`, async ({ page }, testInfo) => {
+ await page.setViewportSize({ width, height: 900 });
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await mockWorkbenchContext(page);
+ const sessionId = "clarification-process";
+ const nativeReplyId = `msg_${"b".repeat(32)}`;
+ const continuationId = `answer_${nativeReplyId}`;
+ const progress = (id: string, title: string) => ({ type: "data-progress", data: {
+ id, title, phase: "tool", status: "completed", detail: "合成演练记录。"
+ } });
+ const messages: Array<{ id: string; role: string; parts: Array<{ type: string; [key: string]: unknown }> }> = [
+ { id: "original-user", role: "user", parts: [{ type: "text", text: "分别模拟三根管道。" }] },
+ { id: "clarifying-message", role: "assistant", parts: [progress("query", "核对管道与时段")] },
+ { id: nativeReplyId, role: "user", parts: [{ type: "text", text: "用户提交的提问回复:按顺序对应时段" }] },
+ { id: continuationId, role: "assistant", parts: [
+ progress("query", "完成对应时段模拟"),
+ { type: "text", text: "三根管道的模拟结果已整理,仅供演练。" }
+ ] }
+ ];
+ const interaction = {
+ version: "agent-interaction-request@1", interaction_id: "clarification", message_id: "clarifying-message",
+ turn_id: "clarification-turn", call_id: "ask", status: "answered", revision: 1, draft_revision: 0,
+ draft: [], created_at: "2026-10-28T10:00:00Z",
+ questions: [{ question_id: "mapping", title: "管道与时段对应", body: "请确认对应顺序。", mode: "single", required: true,
+ allow_custom: false, options: [{ id: "ordered", label: "按顺序对应时段" }] }],
+ answers: [{ question_id: "mapping", selected_option_ids: ["ordered"], custom_text: "", selected_options: [{ id: "ordered", label: "按顺序对应时段" }] }],
+ delivery: { native_message_id: nativeReplyId, revision: 2, status: "blocked", error_code: "CONTINUATION_UNCONFIRMED" }
+ };
+ messages[2].parts.push({ type: "data-interaction_reply", data: interaction });
+ let releaseInteractions!: () => void;
+ let interactionGate = new Promise((resolve) => { releaseInteractions = resolve; });
+ await mockAgentApi(page, { sessions: [{ session_id: sessionId, title: "补充问题后继续模拟", created_at: Date.now(), updated_at: Date.now() }] });
+ const headers = { "Access-Control-Allow-Origin": "*" };
+ let writes = 0;
+ let finishBeforeNextInput = false;
+ const queued: Array<{ id: string; text: string; delivery: string; status: string }> = [];
+ await page.route(`**/api/v1/session/${sessionId}?**`, (route) => route.fulfill({ headers, json: {
+ session_id: sessionId, title: "补充问题后继续模拟", purpose: "ordinary_chat", status: "active", is_streaming: false, messages
+ } }));
+ await page.route(`**/api/v1/session/${sessionId}/interactions**`, async (route) => {
+ if (route.request().method() !== "GET") writes++;
+ await interactionGate;
+ return route.fulfill({ headers, json: { items: [interaction] } });
+ });
+ await page.route(`**/api/v1/session/${sessionId}/inputs`, (route) => route.fulfill({ headers, json: { items: queued } }));
+ await page.route("**/api/v1/responses", (route) => {
+ writes++;
+ const body = route.request().postDataJSON();
+ if (finishBeforeNextInput) {
+ expect(typeof body.input === "string" ? body.input : body.input[0].content[0].text).toBe("继续检查模拟结果");
+ if (body.stream === false) {
+ interaction.delivery.status = "delivered";
+ return route.fulfill({ headers, status: 409, json: { code: "RESPONSE_FINISHED", message: "Finished before admission" } });
+ }
+ return route.fulfill({ headers: { ...headers, "Content-Type": "text/event-stream" }, body: responsesFixture([
+ { type: "start", messageId: "next-response" }, { type: "text-start", id: "text" },
+ { type: "text-delta", id: "text", delta: "已开始新一轮核查。" }, { type: "text-end", id: "text" }, { type: "finish", finishReason: "stop" }
+ ]) });
+ }
+ expect(body.stream).toBe(false);
+ expect(body.conversation).toBe(sessionId);
+ const receipt = { id: body.tjwater.input_id, text: body.input, delivery: body.tjwater.delivery, status: "pending" };
+ queued.push(receipt);
+ return route.fulfill({ headers, status: 202, json: receipt });
+ });
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
+ const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
+ await panel.getByRole("button", { name: /历史/ }).click();
+ await page.locator("button.agent-history-session-trigger").filter({ hasText: "补充问题后继续模拟" }).click();
+ const continuation = panel.locator(`[data-agent-message-id="${continuationId}"]`);
+ await expect(continuation.getByText("三根管道的模拟结果已整理,仅供演练。", { exact: true })).toBeVisible();
+ const reply = panel.getByRole("group", { name: "已提交的回答" });
+ await expect(reply).toHaveCount(1);
+ await expect(panel.getByText(/用户提交的提问回复/)).toHaveCount(0);
+ await expect(reply.getByText("按顺序对应时段", { exact: true })).toBeVisible();
+ releaseInteractions();
+ await expect(panel.locator('[data-agent-message-id="clarifying-message"]')).toHaveCount(0);
+ await expect(panel.getByRole("button", { name: "查看处理过程", exact: true })).toHaveCount(1);
+ const toggle = continuation.getByRole("button", { name: "查看处理过程", exact: true });
+ await toggle.click();
+ const process = continuation.locator("details[data-agent-process]");
+ await expect(process.getByText("核对管道与时段", { exact: true })).toBeVisible();
+ await expect(process.getByText("完成对应时段模拟", { exact: true })).toBeVisible();
+ await expect(process.locator("[data-agent-progress]")).toHaveCount(1);
+ await expect(process.getByRole("list", { name: "全部执行进度" }).locator("li")).toHaveCount(2);
+ await expect(process.getByRole("list", { name: "全部执行进度" }).locator("[data-tool-name]")).toHaveText(["核对管道与时段", "完成对应时段模拟"]);
+ await expect(reply.getByText("继续失败,可以重试", { exact: true })).toBeVisible();
+ await expect(reply.getByRole("button", { name: "重试继续", exact: true })).toBeEnabled();
+ const reportBox = (await continuation.locator("[data-answer-parts]").first().boundingBox())!;
+ const processBox = (await process.boundingBox())!;
+ expect(processBox.y).toBeGreaterThanOrEqual(reportBox.y + reportBox.height);
+ await panel.screenshot({ path: testInfo.outputPath(`continued-process-${width}.png`) });
+ // A hard reload must keep the same human card while metadata is delayed again.
+ interactionGate = new Promise((resolve) => { releaseInteractions = resolve; });
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
+ await panel.getByRole("button", { name: /历史/ }).click();
+ await page.locator("button.agent-history-session-trigger").filter({ hasText: "补充问题后继续模拟" }).click();
+ await expect(reply).toHaveCount(1);
+ await expect(panel.getByText(/用户提交的提问回复/)).toHaveCount(0);
+ await expect(panel.locator('[data-agent-message-id="clarifying-message"]')).toHaveCount(0);
+ await expect(toggle).toHaveCount(1);
+ releaseInteractions();
+ await expect(reply.getByRole("button", { name: "重试继续", exact: true })).toBeEnabled();
+ expect(writes).toBe(0);
+ const prompt = panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
+ await prompt.fill("先核查压力数据");
+ await panel.getByRole("button", { name: "消息发送方式", exact: true }).click();
+ await page.getByRole("menuitemradio", { name: "排队处理", exact: true }).click();
+ await panel.getByRole("button", { name: "将消息加入 Agent 队列", exact: true }).click();
+ await expect(panel.getByRole("region", { name: "待处理消息队列" }).getByText("先核查压力数据", { exact: true })).toBeVisible();
+ await prompt.fill("优先检查第二根管道");
+ await panel.getByRole("button", { name: "消息发送方式", exact: true }).click();
+ await page.getByRole("menuitemradio", { name: "引导当前任务", exact: true }).click();
+ await panel.getByRole("button", { name: "引导当前 Agent 任务", exact: true }).click();
+ await expect.poll(() => queued.map((item) => item.delivery)).toEqual(["queue", "steer"]);
+ expect(writes).toBe(2);
+ finishBeforeNextInput = true;
+ await prompt.fill("继续检查模拟结果");
+ await panel.getByRole("button", { name: "引导当前 Agent 任务", exact: true }).click();
+ await expect(panel.getByText("已开始新一轮核查。", { exact: true })).toBeVisible();
+ expect(writes).toBe(4);
+ });
+}
diff --git a/tests/browser/agent-progress-style.e2e.ts b/tests/browser/agent-progress-style.e2e.ts
new file mode 100644
index 0000000..70c2750
--- /dev/null
+++ b/tests/browser/agent-progress-style.e2e.ts
@@ -0,0 +1,112 @@
+import { expect, test, type Locator } from "@playwright/test";
+import { createServer } from "node:http";
+import type { AddressInfo } from "node:net";
+import { mockAgentApi } from "./support/mock-agent-api";
+import { mockWorkbenchContext } from "./support/mock-workbench-context";
+import { responseFixtureWriter } from "./support/responses-fixture";
+
+async function readRowStyle(row: Locator) {
+ return row.evaluate((element) => {
+ const style = getComputedStyle(element);
+ const text = element.querySelector("span.min-w-0 > span")!;
+ const icon = element.querySelector('[role="img"]')!;
+ return {
+ columns: style.gridTemplateColumns.split(" ")[0],
+ gap: style.columnGap,
+ fontSize: style.fontSize,
+ titleWeight: getComputedStyle(text).fontWeight,
+ titleLineHeight: getComputedStyle(text).lineHeight,
+ iconWidth: icon.getBoundingClientRect().width,
+ iconHeight: icon.getBoundingClientRect().height
+ };
+ });
+}
+
+for (const width of [1440, 320]) {
+ test(`live progress matches archived rows and retains folding at ${width}px`, async ({ page }, testInfo) => {
+ await page.setViewportSize({ width, height: 900 });
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await mockWorkbenchContext(page);
+ await mockAgentApi(page);
+ const steps = [
+ { id: "start", title: "已收到请求", status: "completed", detail: "正在核对当前项目。" },
+ { id: "context", title: "Agent 正在处理请求", status: "running", detail: "Agent 正在执行推理、工具调用或结果整理。" },
+ { id: "data", title: "获取业务数据", status: "running", detail: "正在核对附近阀门、关联管道与隔离范围。" },
+ { id: "isolation", phase: "tool", title: "阀门隔离分析", status: "running", tool: {
+ name: "mcp_tjwater_analyze_valve_isolation", call_id: "isolation-call", input: { link_ids: [533793, 486121, 523616] }
+ } }
+ ];
+ let finish!: () => void;
+ const server = createServer((_request, response) => {
+ response.writeHead(200, { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" });
+ const write = responseFixtureWriter();
+ const send = (chunk: Record) => response.write(write(chunk));
+ send({ type: "start", messageId: "consistent-progress" });
+ for (const step of steps) send({ type: "data-progress", data: step });
+ finish = () => {
+ for (const step of steps) send({ type: "data-progress", data: { ...step, status: "completed" } });
+ send({ type: "data-final_answer", data: { content: "已核对附近可用于隔离的阀门。" } });
+ send({ type: "finish", finishReason: "stop" });
+ response.end();
+ };
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ try {
+ await page.route("**/api/v1/responses", (route) => route.continue({ url: `http://127.0.0.1:${(server.address() as AddressInfo).port}` }));
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
+ const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
+ await panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill("找下附近可用的阀门,用于隔离");
+ await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
+ const message = panel.locator('[data-agent-message-id="consistent-progress"]');
+ const progress = message.locator("[data-agent-progress]");
+ const recent = progress.getByRole("list", { name: "最近执行进度" });
+ await expect(recent.locator("li")).toHaveCount(3);
+ const container = message.locator("details[data-agent-live-process]");
+ await expect(container).toHaveCSS("background-color", "rgb(255, 255, 255)");
+ await expect(container).toHaveCSS("border-radius", "12px");
+ await expect(container).toHaveCSS("border-top-width", "1px");
+ await expect(progress).toHaveCSS("border-top-width", "0px");
+ const containerStyle = await container.evaluate((element) => {
+ const style = getComputedStyle(element);
+ return [style.borderColor, style.borderRadius, style.backgroundColor];
+ });
+ const liveRow = recent.locator("li").filter({ hasText: "Agent 正在处理请求" });
+ const liveStyle = await readRowStyle(liveRow);
+ expect(liveStyle).toMatchObject({ columns: "24px", gap: "8px", fontSize: "14px", titleWeight: "500", iconWidth: 24, iconHeight: 24 });
+ await expect(liveRow.getByRole("img", { name: "进行中", exact: true })).toBeVisible();
+ await progress.getByRole("button", { name: "展开执行进度", exact: true }).click();
+ await expect(progress.getByRole("list", { name: "全部执行进度" }).locator("li")).toHaveCount(4);
+ await progress.getByRole("button", { name: "收起执行进度", exact: true }).click();
+ await expect(recent.locator("li")).toHaveCount(3);
+ const tool = progress.locator("details[data-agent-tool]");
+ await expect(tool).not.toHaveAttribute("open");
+ await tool.locator("summary").click();
+ await expect(tool.locator("pre")).toContainText("533793");
+ await message.screenshot({ path: testInfo.outputPath(`live-progress-${width}.png`) });
+ finish();
+ const archived = message.locator("details[data-agent-process]");
+ await expect(archived).not.toHaveAttribute("open");
+ await message.getByRole("button", { name: "查看处理过程", exact: true }).click();
+ await expect(archived).toHaveAttribute("open", "");
+ await expect(archived).toHaveCSS("background-color", "rgb(255, 255, 255)");
+ expect(await archived.evaluate((element) => {
+ const style = getComputedStyle(element);
+ return [style.borderColor, style.borderRadius, style.backgroundColor];
+ })).toEqual(containerStyle);
+ const archivedRow = archived.getByRole("list", { name: "全部执行进度" }).locator("li").filter({ hasText: "Agent 正在处理请求" });
+ expect(await readRowStyle(archivedRow)).toEqual(liveStyle);
+ await expect(archivedRow.getByRole("img", { name: "已完成", exact: true })).toBeVisible();
+ await expect(tool).toHaveAttribute("open", "");
+ await message.getByRole("button", { name: "查看处理过程", exact: true }).click();
+ await expect(progress).toBeHidden();
+ await message.getByRole("button", { name: "查看处理过程", exact: true }).click();
+ await expect(progress).toBeVisible();
+ await message.screenshot({ path: testInfo.outputPath(`archived-progress-${width}.png`) });
+ expect(await progress.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
+ } finally {
+ server.closeAllConnections();
+ await new Promise((resolve) => server.close(() => resolve()));
+ }
+ });
+}
diff --git a/tests/browser/agent-response-inputs.e2e.ts b/tests/browser/agent-response-inputs.e2e.ts
index afb2baf..1df59f1 100644
--- a/tests/browser/agent-response-inputs.e2e.ts
+++ b/tests/browser/agent-response-inputs.e2e.ts
@@ -79,7 +79,7 @@ for (const width of [1440, 375, 320]) {
const composer = page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
await composer.fill("核对压力变化");
await page.getByRole("button", { name: "发送 Agent 指令" }).click();
- const deliveryMode = page.getByRole("button", { name: "运行中消息发送方式" });
+ const deliveryMode = page.getByRole("button", { name: "消息发送方式", exact: true });
await expect(deliveryMode).toHaveText("引导");
await deliveryMode.focus();
await page.keyboard.press("ArrowDown");
@@ -93,12 +93,21 @@ for (const width of [1440, 375, 320]) {
await expect(deliveryMode).toBeFocused();
await expect(deliveryMode).toHaveText("引导");
await expect(page.getByRole("button", { name: "停止 Agent 指令" })).toBeVisible();
+ const stopButton = page.getByRole("button", { name: "停止 Agent 指令" });
+ await expectThemeColor(stopButton, "background-color", "--status-danger-soft");
+ await expectThemeColor(stopButton, "color", "--status-danger-foreground");
+ await stopButton.hover();
+ await expectThemeColor(stopButton, "background-color", "--status-danger-border");
+ await page.locator("form.agent-panel-input-form").screenshot({ path: testInfo.outputPath("stop-action-hover.png"), animations: "disabled" });
const actionSlot = await page.getByRole("button", { name: "停止 Agent 指令" }).boundingBox();
await composer.fill("补充当前任务");
await expect(page.getByRole("button", { name: "停止 Agent 指令" })).toHaveCount(0);
- const sendSlot = await page.getByRole("button", { name: "引导当前 Agent 任务" }).boundingBox();
- expect(sendSlot).toEqual(actionSlot);
- await page.getByRole("button", { name: "引导当前 Agent 任务" }).click();
+ const sendButton = page.getByRole("button", { name: "引导当前 Agent 任务" });
+ await expect(sendButton).toHaveAttribute("data-agent-action", "send");
+ expect(await sendButton.boundingBox()).toEqual(actionSlot);
+ await expect(page.getByRole("button", { name: "权限批准模式" })).toHaveText("请求批准");
+ await page.locator("form.agent-panel-input-form").screenshot({ path: testInfo.outputPath("streaming-composer-with-draft.png"), animations: "disabled" });
+ await sendButton.click();
const queue = page.getByRole("list", { name: "待处理消息" });
const pendingRow = queue.getByRole("listitem").filter({ hasText: "补充当前任务" });
const pendingMode = pendingRow.getByRole("button", { name: "消息操作:补充当前任务" });
@@ -131,7 +140,11 @@ for (const width of [1440, 375, 320]) {
const expandMessage = longRow.getByRole("button", { name: "展开全文" });
await expect(expandMessage).toBeVisible();
await expect(expandMessage).toHaveAttribute("aria-expanded", "false");
- await expect(page.locator("form").getByRole("list", { name: "待处理消息" })).toBeVisible();
+ await expect(queue).toBeVisible();
+ await expect(page.locator("form").getByRole("list", { name: "待处理消息" })).toHaveCount(0);
+ const queueBox = (await page.getByRole("region", { name: "待处理消息队列" }).boundingBox())!;
+ const editorBox = (await page.locator("form").boundingBox())!;
+ expect(queueBox.y + queueBox.height).toBeLessThanOrEqual(editorBox.y);
await page.screenshot({ path: testInfo.outputPath("pending-messages-compact.png"), animations: "disabled" });
await expandMessage.click();
await expect(longRow.getByRole("button", { name: "收起全文" })).toHaveAttribute("aria-expanded", "true");
diff --git a/tests/browser/agent-user-message-copy.e2e.ts b/tests/browser/agent-user-message-copy.e2e.ts
new file mode 100644
index 0000000..1c07fe0
--- /dev/null
+++ b/tests/browser/agent-user-message-copy.e2e.ts
@@ -0,0 +1,132 @@
+import { expect, test } from "@playwright/test";
+import { mockAgentApi } from "./support/mock-agent-api";
+import { mockWorkbenchContext } from "./support/mock-workbench-context";
+import { responsesFixture } from "./support/responses-fixture";
+
+for (const width of [1440, 320]) {
+ test(`native user message copy preserves source text at ${width}px`, async ({ page, context }) => {
+ await page.setViewportSize({ width, height: 900 });
+ await context.grantPermissions(["clipboard-read", "clipboard-write"]);
+ await mockWorkbenchContext(page);
+ await mockAgentApi(page);
+ let responseIndex = 0;
+ await page.route("**/api/v1/responses", (route) => route.fulfill({
+ headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" },
+ body: responsesFixture([
+ { type: "start", messageId: `copy-answer-${++responseIndex}` },
+ { type: "data-final_answer", data: { content: "已核对管道。" } },
+ { type: "finish", finishReason: "stop" }
+ ])
+ }));
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
+ const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
+ const prompt = "能直接高亮三根管道吗";
+ await panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill(prompt);
+ await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
+ await expect(panel.getByText("已核对管道。", { exact: true })).toBeVisible();
+ const answerParagraph = panel.locator(".agent-streaming-response p").first();
+ await answerParagraph.click({ clickCount: 3 });
+ await page.keyboard.press("Control+c");
+ expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("已核对管道。");
+ const text = panel.locator(".agent-panel-user-message p");
+ await text.click({ clickCount: 3 });
+ await page.keyboard.press("Control+c");
+ expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(prompt);
+
+ // Exact substrings retain native copy behavior.
+ await text.evaluate((paragraph) => {
+ const range = document.createRange();
+ range.setStart(paragraph.firstChild!, 2);
+ range.setEnd(paragraph.firstChild!, 6);
+ window.getSelection()!.removeAllRanges();
+ window.getSelection()!.addRange(range);
+ });
+ await page.keyboard.press("Control+c");
+ expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(prompt.slice(2, 6));
+
+ // A selection containing another message must retain its native separators.
+ const expectedCrossMessage = await panel.evaluate((element) => {
+ const user = element.querySelector(".agent-panel-user-message p")!;
+ const answer = element.querySelector(".agent-streaming-response p")!;
+ const range = document.createRange();
+ range.setStart(user.firstChild!, 0);
+ range.setEnd(answer.firstChild!, answer.textContent!.length);
+ window.getSelection()!.removeAllRanges();
+ window.getSelection()!.addRange(range);
+ return window.getSelection()!.toString();
+ });
+ expect(expectedCrossMessage).toContain("已核对管道。");
+ await page.keyboard.press("Control+c");
+ expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(expectedCrossMessage);
+
+ // Multiline source text keeps blank lines, indentation and interior spaces.
+ const multiline = "第一行\n\n 第二行 内容";
+ await panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill(multiline);
+ await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
+ const lastText = panel.locator(".agent-panel-user-message p").last();
+ await expect(lastText).toHaveText(multiline, { useInnerText: false });
+ await expect(panel.locator(".agent-streaming-response p")).toHaveCount(2);
+ await expect(panel.locator(".agent-streaming-response p").last()).toHaveText("已核对管道。");
+ await panel.evaluate((element) => {
+ const user = element.querySelectorAll(".agent-panel-user-message p");
+ const answer = element.querySelectorAll(".agent-streaming-response p");
+ const range = document.createRange();
+ range.setStart(user[user.length - 1].firstChild!, 0);
+ range.setEnd(answer[answer.length - 1].firstChild!, 0);
+ window.getSelection()!.removeAllRanges();
+ window.getSelection()!.addRange(range);
+ });
+ await page.keyboard.press("Control+c");
+ expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(multiline);
+ });
+}
+
+for (const width of [1440, 320]) {
+ test(`assistant rich text keeps meaningful native copy at ${width}px`, async ({ page, context }) => {
+ await page.setViewportSize({ width, height: 900 });
+ await context.grantPermissions(["clipboard-read", "clipboard-write"]);
+ await mockWorkbenchContext(page);
+ await mockAgentApi(page);
+ const markdown = "首段包含**加粗**文字。\n\n第二段保留段落。\n\n```text\n first\n second\n```\n\n| 名称 | 数值 |\n| --- | --- |\n| 流量 | 12 |";
+ await page.route("**/api/v1/responses", (route) => route.fulfill({
+ headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" },
+ body: responsesFixture([
+ { type: "start", messageId: "rich-copy-answer" },
+ { type: "data-final_answer", data: { content: markdown } },
+ { type: "finish", finishReason: "stop" }
+ ])
+ }));
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
+ const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
+ await panel.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" }).fill("核对格式");
+ await panel.getByRole("button", { name: "发送 Agent 指令", exact: true }).click();
+ const response = panel.locator(".agent-streaming-response");
+ await expect(response.locator("table")).toBeVisible();
+ await response.locator("p").first().click({ clickCount: 3 });
+ await page.keyboard.press("Control+c");
+ expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("首段包含加粗文字。");
+ for (const selector of ["p", "pre", "table"]) {
+ const nativeText = await response.evaluate((element, selector) => {
+ const nodes = element.querySelectorAll(selector);
+ const range = document.createRange();
+ if (selector === "p") {
+ range.setStart(nodes[0].firstChild!, 0);
+ range.setEnd(nodes[1].firstChild!, nodes[1].firstChild!.textContent!.length);
+ } else {
+ range.selectNodeContents(nodes[0]);
+ }
+ window.getSelection()!.removeAllRanges();
+ window.getSelection()!.addRange(range);
+ return window.getSelection()!.toString();
+ }, selector);
+ if (selector === "p") expect(nativeText).toContain("\n\n");
+ if (selector === "pre") expect(nativeText).toContain(" first\n second");
+ if (selector === "table") expect(nativeText).toContain("\t");
+ await page.keyboard.press("Control+c");
+ expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(nativeText);
+ }
+ await expect(panel.getByRole("button", { name: /复制/ }).first()).toBeAttached();
+ });
+}
diff --git a/tests/browser/support/mock-agent-api.ts b/tests/browser/support/mock-agent-api.ts
index ce664d2..ca0fbe0 100644
--- a/tests/browser/support/mock-agent-api.ts
+++ b/tests/browser/support/mock-agent-api.ts
@@ -13,6 +13,7 @@ type MockAgentApiOptions = {
export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}) {
const corsHeaders = { "Access-Control-Allow-Origin": "*" };
+ await page.route("http://127.0.0.1:8787/health", (route) => route.fulfill({ headers: corsHeaders, json: { ok: true, ready: true } }));
const model = options.model ?? {
id: "test/model",
label: "测试模型",