feat: expand intelligent investigation workspace
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent ddd03dbff6
commit c0e4231b83
223 changed files with 13155 additions and 5304 deletions
@@ -1,9 +1,17 @@
import type { ComponentProps, Dispatch, Ref, SetStateAction } from "react";
import type { PersonaState } from "@/shared/ai-elements/persona";
import type { StatusTone } from "@/shared/ui/status";
import type { AgentChatSessionSummary, AgentInputDelivery, AgentResponseInput } from "../api/client";
import type { AgentApprovalMode, AgentChatMessage, AgentModelOption, AgentPermissionReply, AgentPermissionRequest, AgentQuestionRequest, AgentStreamRenderState, AgentUiResult } from "../types";
import type { AgentSpeakOptions, AgentSpeechState } from "../speech";
import type { AnswerUiMapTarget } from "../answer-ui/answer-ui-view";
import type { AnswerUiSelection } from "../answer-ui/answer-ui-interaction-context";
import type { AnswerViewStateScope } from "../session-state/answer-view-state-scope";
import { AgentInteractionReply, AgentInteractionRequests } from "./agent-interaction-request-card";
import { canReplaceInteractionRequest } from "../api/interaction-requests";
import type { InteractionRequestsController } from "../hooks/use-interaction-requests";
import type { AnswerUiMapTarget } from "../answer-ui/answer-ui-view";
import { AnswerViewStateProvider } from "../session-state/answer-view-state-context";
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 { canExpandAnswer, getAnswerPartsTranscript, getAnswerPresentation } from "../answer-ui/answer-presentation";
import { AgentProcessDisclosure } from "./agent-process-disclosure";
@@ -12,109 +20,35 @@ import { groupMessageProcesses, hasPendingMessageInput } from "../presentation/m
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,
ChevronLeft,
Eye,
History,
LoaderCircle,
Pause,
Play,
Plus,
Quote,
Shield,
ShieldCheck,
Square,
Sparkles,
Zap,
Volume2,
X
} from "lucide-react";
import { ArrowUp, ChevronLeft, Eye, History, Plus, Quote, Square, X } from "lucide-react";
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type Dispatch,
type Ref,
type ReactNode,
type SetStateAction
} from "react";
import { createPortal } from "react-dom";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useStickToBottomContext } from "use-stick-to-bottom";
import { showMapNotice } from "@/features/map/core";
import { Agent, AgentContent } from "@/shared/ai-elements/agent";
import {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton
} from "@/shared/ai-elements/conversation";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButton } from "@/shared/ai-elements/conversation";
import { Message, MessageContent } from "@/shared/ai-elements/message";
import {
PromptInput,
PromptInputBody,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea
} from "@/shared/ai-elements/prompt-input";
import { PromptInput, PromptInputBody, PromptInputFooter, PromptInputSubmit, PromptInputTextarea } from "@/shared/ai-elements/prompt-input";
import { Suggestion, Suggestions } from "@/shared/ai-elements/suggestion";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { Button } from "@/shared/ui/button";
import { Separator } from "@/shared/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip";
import { cn } from "@/shared/ui/cn";
import { StatusDot, type StatusTone } from "@/shared/ui/status";
import type { AgentChatSessionSummary, AgentInputDelivery, AgentResponseInput } from "../api/client";
import { AGENT_MESSAGE_ACTION_CLASS_NAME, AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME, AgentChatMessageActions, AgentChatMessageHeader, AgentChatMessageToolbar } from "./agent-chat-message-chrome";
import {
agentLayoutTransition,
agentPanelItemVariants,
agentPanelListVariants,
agentPanelSectionVariants
} from "./agent-motion";
import { StatusDot } from "@/shared/ui/status";
import { AgentChatMessageActions, AgentChatMessageHeader, AgentChatMessageToolbar } from "./agent-chat-message-chrome";
import { agentLayoutTransition, agentPanelItemVariants, agentPanelListVariants } from "./agent-motion";
import { AgentPersona } from "./agent-persona";
import { AgentTopicControls } from "./agent-topic-controls";
import { AgentHistoryPanel } from "./agent-history-panel";
import { AgentMessageInbox } from "./agent-message-inbox";
import { AgentPendingInputs } from "./agent-pending-inputs";
import type { ComponentProps } from "react";
import { AgentMessageDetails } from "./agent-message-details";
import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer";
import { useAgentSpeechRecognition, useAgentSpeechSynthesis } from "../hooks/use-agent-voice";
import {
findSpeechSelectionStartOffset,
stripSpeechMarkdown,
type AgentSpeakOptions,
type AgentSpeechState
} from "../speech";
import type {
AgentApprovalMode,
AgentChatMessage,
AgentConnectionStatus,
AgentModelOption,
AgentPermissionReply,
AgentPermissionRequest,
AgentQuestionRequest,
AgentStreamRenderState,
AgentUiResult
} from "../types";
import { stripSpeechMarkdown } from "../speech";
import type { AgentConnectionStatus } from "../types";
import { VoiceInputButton, InputDeliverySelect, ApprovalModeSelect, AgentModelSelect } from "./agent-panel-controls";
import { AgentSpeechMessage, AgentMessageSpeechControls } from "./agent-message-speech";
export type AgentCommandPanelPresentation = "desktop-dock" | "desktop-floating" | "mobile-sheet";
@@ -125,7 +59,6 @@ export type AgentWorkspaceContextItem = {
tone?: "neutral" | "info" | "warning";
};
type AgentCommandPanelProps = {
presentation?: AgentCommandPanelPresentation;
collapsing?: boolean;
@@ -153,6 +86,7 @@ type AgentCommandPanelProps = {
promptSuggestionsUnavailable?: boolean;
onRetryPromptSuggestions?: () => void;
activeSessionId?: string | null;
answerStateScope?: AnswerViewStateScope;
qaOnly?: boolean;
executionHistory?: boolean;
statusLabel?: string;
@@ -177,7 +111,6 @@ type AgentCommandPanelProps = {
approvalMode?: AgentApprovalMode;
uiResults?: AgentUiResult[];
interactionRequests?: InteractionRequestsController;
reviewPanel?: ReactNode;
messageInbox?: ComponentProps<typeof AgentMessageInbox>;
workspaceContext?: AgentWorkspaceContextItem[];
prompt: string;
@@ -221,6 +154,7 @@ export function AgentCommandPanel({
promptSuggestionsUnavailable = false,
onRetryPromptSuggestions,
activeSessionId,
answerStateScope,
qaOnly = false,
executionHistory = false,
connectionStatus = "connecting",
@@ -241,7 +175,6 @@ export function AgentCommandPanel({
approvalMode = "request",
uiResults = [],
interactionRequests,
reviewPanel,
messageInbox,
prompt,
promptFocusRequest = 0,
@@ -278,7 +211,7 @@ export function AgentCommandPanel({
const floatingPresentation = desktopFloating;
const hasConversation = messages.length > 0 || streaming || Boolean(messageInbox?.messages.length);
// Task discussions keep their reference and composer layout before the first message.
const centeredComposer = !hasConversation && !loadingSessionId && !reviewPanel && !promptReference
const centeredComposer = !hasConversation && !loadingSessionId && !promptReference
&& uiResults.length === 0 && !messageInbox?.loading && !messageInbox?.error;
const showStartSuggestions = centeredComposer && !qaOnly && !promptReference;
const trimmedPrompt = prompt.trim();
@@ -302,7 +235,15 @@ export function AgentCommandPanel({
resume: resumeSpeech,
stop: stopSpeech,
isSupported: isSpeechSupported
} = useAgentSpeechSynthesis();
} = useAgentSpeechSynthesis((message) => {
showMapNotice({
id: "agent-speech-playback-error",
tone: "error",
title: "语音播放失败",
message,
duration: 5000
});
});
const appendRecognitionResult = useCallback((text: string) => {
promptRestoreGenerationRef.current += 1;
onPromptChange((current) => `${current}${current.trim() ? " " : ""}${text}`);
@@ -396,6 +337,7 @@ export function AgentCommandPanel({
/>
);
return (
<AnswerViewStateProvider scope={answerStateScope} sessionId={activeSessionId}>
<MotionConfig reducedMotion="user">
{historyPanel}
<motion.aside
@@ -567,16 +509,12 @@ export function AgentCommandPanel({
showProcess={!qaOnly || executionHistory}
onLocateAnswerFeatures={onLocateAnswerFeatures}
onSubmitAnswerForm={onSubmitPrompt}
onAsk={(selection) => {
onContentReferenceChange?.(selection);
requestAnimationFrame(() => promptTextareaRef.current?.focus());
}}
/>
) : null}
</AnimatePresence>
{!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}
@@ -762,255 +700,7 @@ export function AgentCommandPanel({
</Agent>
</motion.aside>
</MotionConfig>
);
}
const VOICE_WAVE_BARS = [
{ height: 5, duration: 0.72, delay: 0.08 },
{ height: 10, duration: 0.58, delay: 0 },
{ height: 15, duration: 0.66, delay: 0.12 },
{ height: 9, duration: 0.62, delay: 0.04 },
{ height: 4, duration: 0.7, delay: 0.16 }
] as const;
function VoiceInputButton({
isListening,
disabled,
onClick
}: {
isListening: boolean;
disabled: boolean;
onClick: () => void;
}) {
const reduceMotion = useReducedMotion();
const label = isListening ? "停止语音输入" : "语音输入";
return (
<motion.button
type="button"
aria-label={label}
aria-pressed={isListening}
title={label}
className={cn(
"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",
"disabled:cursor-not-allowed disabled:opacity-45",
isListening
? "status-tone-danger bg-[var(--status-soft)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:brightness-95"
: "text-slate-600 [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)] active:bg-[var(--action-soft-pressed)]"
)}
disabled={disabled}
onClick={onClick}
>
{isListening ? (
<motion.span
data-slot="voice-input-pulse"
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={
reduceMotion
? { opacity: 0.38, scale: 1 }
: { opacity: [0, 0.4, 0], scale: [0.9, 1, 1.28] }
}
transition={
reduceMotion
? { duration: 0.16 }
: {
duration: 1.7,
ease: "easeOut",
repeat: Infinity,
times: [0, 0.12, 1]
}
}
/>
) : null}
<span className="relative grid h-5 w-5 place-items-center overflow-hidden">
<AnimatePresence initial={false} mode="wait">
{isListening ? (
<motion.span
key="voice-wave"
className="flex h-4 items-center gap-0.5"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.72 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.72 }}
transition={{ duration: 0.16, ease: "easeOut" }}
>
{VOICE_WAVE_BARS.map((bar) => (
<motion.span
key={bar.height}
className="w-px rounded-full bg-[var(--status-mark)]"
style={{ height: bar.height }}
animate={
reduceMotion ? { scaleY: 0.72 } : { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
}
transition={
reduceMotion
? { duration: 0.16 }
: {
duration: bar.duration,
delay: bar.delay,
ease: "easeInOut",
repeat: Infinity
}
}
aria-hidden="true"
/>
))}
</motion.span>
) : (
<motion.span
key="voice-idle"
className="grid place-items-center"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.72, y: 2 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.72, y: -2 }}
transition={{ duration: 0.16, ease: "easeOut" }}
>
<AudioLines
strokeWidth={2}
className="size-4 transition-transform duration-200 group-hover:scale-105"
/>
</motion.span>
)}
</AnimatePresence>
</span>
</motion.button>
);
}
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;
value: AgentInputDelivery;
onValueChange?: (delivery: AgentInputDelivery) => void;
}) {
return <DropdownMenu>
<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-sm font-normal active:scale-95 motion-reduce:transform-none"
disabled={!onValueChange}>
{value === "queue" ? "排队" : "引导"}
<AgentToolbarCaret />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent data-agent-presentation={presentation} side="top" align="end" sideOffset={6}
collisionPadding={12} className="agent-panel-overlay agent-panel-control w-44 rounded-xl p-1.5 shadow-lg">
<DropdownMenuRadioGroup aria-label={label} value={value} onValueChange={(delivery) => {
if (delivery === "steer" || delivery === "queue") onValueChange?.(delivery);
}}>
{([ ["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-sm",
value === delivery && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]")}>
{text}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>;
}
function ApprovalModeSelect({
presentation,
value,
disabled,
onValueChange
}: {
presentation: AgentCommandPanelPresentation;
value: AgentApprovalMode;
disabled: boolean;
onValueChange?: (mode: AgentApprovalMode) => void;
}) {
const requestApproval = value === "request";
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
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 className="size-4" aria-hidden="true" />
) : (
<ShieldCheck className="size-4" aria-hidden="true" />
)}
<span>{requestApproval ? "请求批准" : "始终允许"}</span>
<AgentToolbarCaret />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
data-agent-presentation={presentation}
side="top"
align="start"
sideOffset={8}
className="agent-panel-overlay agent-panel-control w-[220px] rounded-xl p-2 shadow-lg"
>
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-[var(--ink-muted)]">
权限批准模式
</DropdownMenuLabel>
<DropdownMenuItem
className={cn(
AGENT_SELECT_ITEM_CLASS_NAME,
"items-start",
requestApproval && "border-[var(--surface-border)] bg-[var(--action-soft)]"
)}
onSelect={() => onValueChange?.("request")}
>
<Shield className="mt-0.5 text-[var(--action-blue-hover)]" aria-hidden="true" />
<span>
<span className="block text-sm font-semibold text-slate-800">请求批准</span>
<span className="block text-xs leading-5 text-[var(--ink-muted)]">需要审批的请求逐次确认;只读白名单直接执行</span>
</span>
{requestApproval ? (
<CheckCircle2 className="ml-auto mt-0.5 text-[var(--action-blue-hover)]" aria-hidden="true" />
) : null}
</DropdownMenuItem>
<DropdownMenuItem
className={cn(
AGENT_SELECT_ITEM_CLASS_NAME,
"mt-1 items-start",
!requestApproval &&
"status-tone-success border-[var(--status-border)] bg-[var(--status-soft)]"
)}
onSelect={() => onValueChange?.("always")}
>
<ShieldCheck
className="status-tone-success mt-0.5 text-[var(--status-foreground)]"
aria-hidden="true"
/>
<span>
<span className="block text-sm font-semibold text-slate-800">始终允许</span>
<span className="block text-xs leading-5 text-[var(--ink-muted)]">自动批准本轮工具权限请求,不改变业务权限或安全限制</span>
</span>
{!requestApproval ? (
<CheckCircle2
className="status-tone-success ml-auto mt-0.5 text-[var(--status-foreground)]"
aria-hidden="true"
/>
) : null}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</AnswerViewStateProvider>
);
}
@@ -1167,101 +857,12 @@ function getLastUserMessageId(messages: AgentChatMessage[]) {
return null;
}
function AgentModelSelect({
presentation,
models,
value,
onValueChange
}: {
presentation: AgentCommandPanelPresentation;
models: AgentModelOption[];
value: string;
onValueChange?: (model: string) => void;
}) {
const selectedModel = models.find((model) => model.id === value);
if (!models.length) {
return (
<span className="shrink-0 rounded-md px-2 py-1 text-sm text-[var(--ink-muted)]">模型</span>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
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 ?? "模型"}`}
title={selectedModel?.label ?? "模型"}
>
<span className="flex min-w-0 flex-1 items-center gap-1">
<ModelIcon model={selectedModel} />
<span className="hidden truncate @[400px]/agent-composer:inline">{selectedModel?.label ?? "模型"}</span>
</span>
<AgentToolbarCaret />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
data-agent-presentation={presentation}
side="top"
align="end"
sideOffset={8}
className="agent-panel-overlay agent-panel-control w-[230px] rounded-xl p-2 shadow-lg"
>
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-[var(--ink-muted)]">
模型选择
</DropdownMenuLabel>
<div className="space-y-1">
{models.map((model) => {
const selected = model.id === selectedModel?.id;
return (
<DropdownMenuItem
key={model.id}
className={cn(
AGENT_SELECT_ITEM_CLASS_NAME,
selected && "border-[var(--surface-border)] bg-[var(--action-soft)]"
)}
onSelect={() => onValueChange?.(model.id)}
>
<span className="ml-0.5 shrink-0 pt-0.5">
<ModelIcon model={model} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-slate-800">
{model.label}
</span>
<span className="block truncate text-xs leading-4 text-[var(--ink-muted)]">
{model.description}
</span>
</span>
{selected ? (
<CheckCircle2 size={15} className={presentation === "desktop-dock" ? "text-[var(--action-blue-hover)]" : "text-[var(--action-blue-hover)]"} aria-hidden="true" />
) : null}
</DropdownMenuItem>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
const agentConnectionMetadata = {
connecting: { label: "连接中", tone: "info" },
online: { label: "在线", tone: "success" },
offline: { label: "离线", tone: "danger" }
} satisfies Record<AgentConnectionStatus, { label: string; tone: StatusTone }>;
function ModelIcon({ model }: { model?: AgentModelOption }) {
const Icon = model?.icon === "bolt" ? Zap : Sparkles;
return <Icon className="size-4" aria-hidden="true" />;
}
type BackendMessageListProps = {
presentation: AgentCommandPanelPresentation;
messages: AgentChatMessage[];
@@ -1281,7 +882,6 @@ type BackendMessageListProps = {
showProcess: boolean;
onLocateAnswerFeatures?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
onSubmitAnswerForm: (text: string) => Promise<void> | void;
onAsk?: (selection: AnswerUiSelection) => void;
interactionRequests?: InteractionRequestsController;
};
@@ -1346,7 +946,6 @@ function BackendMessage({
showProcess,
onLocateAnswerFeatures,
onSubmitAnswerForm,
onAsk,
interactionRequests,
}: BackendMessageListProps & {
ref?: Ref<HTMLDivElement>;
@@ -1448,13 +1047,13 @@ function BackendMessage({
>
<AnswerPartsView
parts={answer.report}
persist={answer.final && completed}
messageId={message.id}
disabled={!completed}
streaming={!answer.final && !completed}
streamDone={answer.final || (streamRenderState[message.id]?.done ?? completed)}
onLocate={onLocateAnswerFeatures}
onSubmit={onSubmitAnswerForm}
onAsk={onAsk}
/>
</ExpandableReading>
</AgentSpeechMessage>
@@ -1513,251 +1112,3 @@ function BackendMessage({
</motion.div>
);
}
type SpeechSelection = {
startOffset: number;
left: number;
top: number;
};
function AgentSpeechMessage({
presentation,
messageId,
content,
disabled,
isSupported,
onSpeak,
children
}: {
presentation: AgentCommandPanelPresentation;
messageId: string;
content: string;
disabled: boolean;
isSupported: boolean;
onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise<void> | void;
children: ReactNode;
}) {
const contentRef = useRef<HTMLDivElement | null>(null);
const lastSelectionRef = useRef<SpeechSelection | null>(null);
const [selection, setSelection] = useState<SpeechSelection | null>(null);
const speechText = useMemo(() => stripSpeechMarkdown(content), [content]);
const selectionAnchor = selection ?? lastSelectionRef.current;
if (selection) {
lastSelectionRef.current = selection;
}
const captureSelection = useCallback(() => {
if (!isSupported || disabled) {
setSelection(null);
return;
}
const browserSelection = window.getSelection();
const container = contentRef.current;
if (
!browserSelection ||
browserSelection.rangeCount === 0 ||
browserSelection.isCollapsed ||
!container
) {
setSelection(null);
return;
}
const range = browserSelection.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) {
setSelection(null);
return;
}
const startOffset = findSpeechSelectionStartOffset(speechText, browserSelection.toString());
const rect = range.getBoundingClientRect();
if (startOffset === null || (!rect.width && !rect.height)) {
setSelection(null);
return;
}
setSelection({
startOffset,
left: Math.min(Math.max(rect.left + rect.width / 2, 88), window.innerWidth - 88),
top: Math.max(rect.top - 10, 48)
});
}, [disabled, isSupported, speechText]);
useEffect(() => {
setSelection(null);
}, [messageId, speechText]);
useEffect(() => {
if (!selection) return;
const close = () => setSelection(null);
const closeCollapsedSelection = () => {
if (window.getSelection()?.isCollapsed) close();
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
};
document.addEventListener("selectionchange", closeCollapsedSelection);
document.addEventListener("keydown", closeOnEscape);
window.addEventListener("resize", close);
window.addEventListener("scroll", close, true);
return () => {
document.removeEventListener("selectionchange", closeCollapsedSelection);
document.removeEventListener("keydown", closeOnEscape);
window.removeEventListener("resize", close);
window.removeEventListener("scroll", close, true);
};
}, [selection]);
const speakFromSelection = () => {
if (!selection) return;
void Promise.resolve(onSpeak(messageId, speechText, { startOffset: selection.startOffset }));
window.getSelection()?.removeAllRanges();
setSelection(null);
};
return (
<div>
<div
ref={contentRef}
onPointerUp={() => window.requestAnimationFrame(captureSelection)}
onKeyUp={() => window.requestAnimationFrame(captureSelection)}
>
{children}
</div>
{selectionAnchor && typeof document !== "undefined"
? createPortal(
<div
className="pointer-events-none fixed z-[80] -translate-x-1/2 -translate-y-full"
style={{ left: selectionAnchor.left, top: selectionAnchor.top }}
>
<AnimatePresence>
{selection ? (
<motion.button
key="speech-selection-action"
type="button"
data-agent-presentation={presentation}
className="agent-panel-overlay agent-panel-floating-acrylic agent-speech-selection-action pointer-events-auto inline-flex h-10 items-center gap-2 rounded-xl px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-[var(--action-blue-hover)] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]"
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: { duration: 0.12, ease: [0.16, 1, 0.3, 1] }
}}
exit={{
opacity: 0,
transition: { duration: 0.08, ease: [0.4, 0, 1, 1] }
}}
onMouseDown={(event) => event.preventDefault()}
onClick={speakFromSelection}
>
<span
className="grid h-6 w-6 shrink-0 place-items-center rounded-lg bg-[var(--action-soft)]/75 text-[var(--action-blue-hover)]"
aria-hidden="true"
>
<Volume2 size={15} />
</span>
从这里开始朗读
</motion.button>
) : null}
</AnimatePresence>
</div>,
document.body
)
: null}
</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>
);
}
function SpeechIconButton({
label,
tone = "default",
disabled = false,
onClick,
children
}: {
label: string;
tone?: "default" | "primary" | "danger";
disabled?: boolean;
onClick?: () => void;
children: ReactNode;
}) {
return (
<Button
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
size="sm"
variant="ghost"
className={cn(
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)]"
)}
>
{children}
</Button>
);
}