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