feat: expand intelligent investigation workspace
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user