feat(agent): support Responses steering and project-aware tools

Use the dedicated TJWATER_AGENT_URL and Responses stream protocol. Keep running
turns active while users steer or queue follow-ups and edit pending delivery.
Refresh the model catalog before generation and preserve approval mode selection.

Integrate scoped investigation messaging/presentation, geographic place selection,
current Agent composer styles and supporting workbench contracts.

Validation: 77 focused Vitest checks, 21 browser cases, lint, typecheck and
production build. Preserve direct Server/Agent routing and project authentication.
This commit is contained in:
2026-10-19 18:46:58 +08:00
parent 2c5bf15cce
commit ec23d44b02
131 changed files with 6663 additions and 1204 deletions
@@ -4,21 +4,25 @@ import { getAnswerPresentation } from "../answer-ui/answer-presentation";
import { Disclosure } from "@/shared/ui/disclosure";
import {
Activity,
ArrowUpRight,
CheckCircle2,
ChevronDown,
ChevronsUpDown,
ChevronLeft,
DatabaseZap,
Folder,
FolderPlus,
History,
LoaderCircle,
MapPinned,
Mic,
Pause,
Play,
Plus,
Quote,
SendHorizontal,
Shield,
ShieldCheck,
Square,
UserRound,
Volume2,
X
} from "lucide-react";
@@ -27,6 +31,7 @@ import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -58,12 +63,15 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import { StatusBadge, StatusDot, type StatusTone } from "@/shared/ui/status";
import type { AgentChatSessionSummary } from "../api/client";
import type { AgentChatSessionSummary, AgentInputDelivery, AgentResponseInput } from "../api/client";
import { AgentPersona } from "./agent-persona";
import {
agentPanelItemVariants,
@@ -71,6 +79,8 @@ import {
agentPanelSectionVariants
} from "./agent-motion";
import { AgentHistoryPanel } from "./agent-history-panel";
import { AgentMessageInbox } from "./agent-message-inbox";
import type { ComponentProps } from "react";
import { AgentMessageDetails, AgentMessageProgress } from "./agent-message-details";
import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer";
import { useAgentSpeechRecognition, useAgentSpeechSynthesis } from "../hooks/use-agent-voice";
@@ -101,6 +111,11 @@ export type AgentWorkspaceContextItem = {
tone?: "neutral" | "info" | "warning";
};
export type AgentConversationProjectOption = {
id: string;
name: string;
};
type AgentCommandPanelProps = {
presentation?: AgentCommandPanelPresentation;
collapsing?: boolean;
@@ -108,20 +123,37 @@ type AgentCommandPanelProps = {
sessionTitle?: string;
sessionHistory?: AgentChatSessionSummary[];
sessionHistoryLoading?: boolean;
promptSuggestions?: Array<{ question: string; sourceLabel?: string }>;
promptSuggestionsLoading?: boolean;
promptSuggestionsUnavailable?: boolean;
activeSessionId?: string | null;
qaOnly?: boolean;
statusLabel?: string;
connectionStatus?: AgentConnectionStatus;
streaming?: boolean;
inputDelivery?: AgentInputDelivery;
pendingInputs?: AgentResponseInput[];
onInputDeliveryChange?: (delivery: AgentInputDelivery) => void;
onPendingInputDeliveryChange?: (id: string, delivery: AgentInputDelivery) => Promise<void> | void;
messages?: AgentChatMessage[];
streamRenderState?: AgentStreamRenderState;
modelOptions?: AgentModelOption[];
selectedModel?: string;
conversationScope?: "personal" | "project";
onConversationScopeChange?: (scope: "personal" | "project") => Promise<void> | void;
conversationProjects?: AgentConversationProjectOption[];
selectedConversationProjectId?: string | null;
onConversationProjectChange?: (projectId: string) => Promise<void> | void;
currentProjectName?: string | null;
onOpenProjectCreator?: () => void;
approvalMode?: AgentApprovalMode;
uiResults?: AgentUiResult[];
reviewPanel?: ReactNode;
messageInbox?: ComponentProps<typeof AgentMessageInbox>;
workspaceContext?: AgentWorkspaceContextItem[];
prompt: string;
promptFocusRequest?: number;
promptReference?: { title: string; detail?: string; placeholder?: string };
onCollapse: () => void;
onPromptChange: Dispatch<SetStateAction<string>>;
onRefreshHistory?: () => Promise<void> | void;
@@ -142,22 +174,6 @@ type AgentCommandPanelProps = {
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
};
const AGENT_PROMPT_SUGGESTIONS = [
"分析当前管网中的水力瓶颈管道,并给出改造建议。",
"供水服务分区分析。",
"帮我分析当前管网压力异常点,并按风险等级排序。",
"帮我生成一份今日运行简报,包含问题、原因和建议。",
"查询关键 SCADA 点位最近 24 小时的异常波动。",
"排查当前管网爆管风险,并说明优先处置建议。"
] as const;
const AGENT_CAPABILITIES = [
{ icon: DatabaseZap, label: "水力瓶颈诊断" },
{ icon: Activity, label: "压力异常研判" },
{ icon: ShieldCheck, label: "爆管风险排查" },
{ icon: MapPinned, label: "GIS 地图联动" }
] as const;
export function AgentCommandPanel({
presentation = "desktop-dock",
collapsing = false,
@@ -165,18 +181,35 @@ export function AgentCommandPanel({
sessionTitle = "新对话",
sessionHistory = [],
sessionHistoryLoading = false,
promptSuggestions = [],
promptSuggestionsLoading = false,
promptSuggestionsUnavailable = false,
activeSessionId,
qaOnly = false,
connectionStatus = "connecting",
streaming = false,
inputDelivery = "steer",
pendingInputs = [],
onInputDeliveryChange,
onPendingInputDeliveryChange,
messages = [],
streamRenderState = {},
modelOptions = [],
selectedModel = "",
conversationScope = "project",
onConversationScopeChange,
conversationProjects = [],
selectedConversationProjectId,
onConversationProjectChange,
currentProjectName,
onOpenProjectCreator,
approvalMode = "request",
uiResults = [],
reviewPanel,
messageInbox,
prompt,
promptFocusRequest = 0,
promptReference,
onCollapse,
onPromptChange,
onRefreshHistory,
@@ -200,11 +233,31 @@ export function AgentCommandPanel({
const historyTriggerRef = useRef<HTMLButtonElement | null>(null);
const historyPanelRef = useRef<HTMLDivElement | null>(null);
const promptRestoreGenerationRef = useRef(0);
const composerRef = useRef<HTMLDivElement | null>(null);
const promptTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const composerStartRectRef = useRef<DOMRect | null>(null);
const composerTransitionRef = useRef<Animation | null>(null);
const mountedRef = useRef(true);
const desktopFloating = presentation === "desktop-floating";
const floatingPresentation = desktopFloating || presentation === "mobile-sheet";
const hasConversation = messages.length > 0 || streaming;
const hasConversation = messages.length > 0 || streaming || Boolean(messageInbox?.messages.length);
const centeredComposer = !hasConversation && (presentation === "desktop-dock" || Boolean(promptReference));
const showStartSuggestions = centeredComposer && !qaOnly && !promptReference;
const prefersReducedMotion = useReducedMotion();
const trimmedPrompt = prompt.trim();
useEffect(() => {
if (!promptFocusRequest) return;
const frame = requestAnimationFrame(() => {
const textarea = promptTextareaRef.current;
textarea?.focus();
textarea?.setSelectionRange(textarea.value.length, textarea.value.length);
});
return () => cancelAnimationFrame(frame);
}, [promptFocusRequest]);
const selectedConversationProject = conversationProjects.find(
(project) => project.id === selectedConversationProjectId
);
const selectedProjectName = selectedConversationProject?.name ?? currentProjectName ?? "当前项目";
const {
speechState,
speakingMessageId,
@@ -296,12 +349,31 @@ export function AgentCommandPanel({
if (streaming) stopListening();
}, [stopListening, streaming]);
useLayoutEffect(() => {
if (centeredComposer || !composerStartRectRef.current || !composerRef.current) return;
const start = composerStartRectRef.current;
composerStartRectRef.current = null;
const end = composerRef.current.getBoundingClientRect();
if (prefersReducedMotion || (start.x === end.x && start.y === end.y)) return;
composerTransitionRef.current?.cancel();
composerTransitionRef.current = composerRef.current.animate(
[
{ transform: `translate(${start.x - end.x}px, ${start.y - end.y}px)` },
{ transform: "translate(0, 0)" }
],
{ duration: 280, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }
);
}, [centeredComposer, prefersReducedMotion]);
const submitPrompt = (nextPrompt: string) => {
if (!nextPrompt || streaming) {
if (!nextPrompt) {
return;
}
const restoreGeneration = promptRestoreGenerationRef.current + 1;
promptRestoreGenerationRef.current = restoreGeneration;
if (centeredComposer && !prefersReducedMotion && composerRef.current) {
composerStartRectRef.current = composerRef.current.getBoundingClientRect();
}
onPromptChange("");
stopListening();
setScrollRequestId((requestId) => requestId + 1);
@@ -335,6 +407,7 @@ export function AgentCommandPanel({
<aside
aria-label="Agent 命令面板"
data-agent-presentation={presentation}
data-agent-starting={centeredComposer}
className={cn(
"pointer-events-auto flex h-full min-h-0 w-full flex-col",
"agent-panel-shell",
@@ -361,12 +434,12 @@ export function AgentCommandPanel({
historyOpen && "agent-panel-header-expanded"
)}
>
<div className={cn("flex items-center justify-between gap-2 px-3", presentation === "desktop-dock" ? "min-h-14 py-1.5" : "min-h-16 py-2")}>
<div className={cn("flex items-center justify-between gap-2 px-3", presentation === "desktop-dock" ? "h-14 py-1" : "min-h-16 py-2")}>
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
<AgentPersona className={presentation === "desktop-dock" ? "h-10 w-10" : "h-12 w-12"} state={personaState} />
<div className="min-w-0 flex-1">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<div className="min-w-0 space-y-0.5">
<div className="flex min-w-0 items-center gap-3">
<p className="shrink-0 text-xs font-semibold uppercase text-[var(--ink-muted)]">
对话主题
</p>
@@ -382,12 +455,90 @@ export function AgentCommandPanel({
{agentConnectionMetadata[connectionStatus].label}
</StatusBadge>
</div>
<p
className="truncate text-sm font-semibold text-slate-950"
title={sessionTitle}
>
{sessionTitle}
</p>
<div className="flex min-w-0 items-center gap-2">
<p
className="min-w-0 shrink truncate text-sm font-semibold text-slate-950"
title={sessionTitle}
>
{sessionTitle}
</p>
{onConversationScopeChange && !qaOnly ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="agent-project-trigger h-7 max-w-36 shrink-0 gap-1.5 rounded-xl px-2.5 text-xs font-medium text-slate-600 focus-visible:ring-1 focus-visible:ring-offset-0"
aria-label="选择对话归属" disabled={streaming}>
{conversationScope === "personal"
? <UserRound size={13} aria-hidden="true" />
: <Folder size={13} aria-hidden="true" />}
<span className="truncate">
{conversationScope === "personal" ? "个人会话" : selectedProjectName}
</span>
<ChevronDown size={12} className="opacity-60" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent data-agent-presentation={presentation} align="start" sideOffset={8} className="agent-panel-overlay w-60 p-2">
<DropdownMenuLabel className="px-3 pb-1.5 pt-1 text-xs font-medium text-[var(--ink-muted)]">
对话归属
</DropdownMenuLabel>
<DropdownMenuItem
className={cn(
"min-h-10 rounded-lg px-3 text-sm",
conversationScope === "personal" && "bg-[var(--action-selection-soft)] text-slate-900"
)}
onSelect={() => void onConversationScopeChange("personal")}
>
<UserRound size={14} className="text-[var(--ink-muted)]" aria-hidden="true" />
<span className="flex-1">个人会话</span>
{conversationScope === "personal" ? <CheckCircle2 size={14} className="text-[var(--action-blue-hover)]" aria-hidden="true" /> : null}
</DropdownMenuItem>
{conversationProjects.length > 0 ? conversationProjects.map((project) => {
const selected = conversationScope === "project"
&& project.id === selectedConversationProjectId;
return (
<DropdownMenuItem
key={project.id}
className={cn(
"min-h-10 rounded-lg px-3 text-sm",
selected && "bg-[var(--action-selection-soft)] text-slate-900"
)}
onSelect={() => {
if (onConversationProjectChange) {
void onConversationProjectChange(project.id);
return;
}
void onConversationScopeChange("project");
}}
>
<Folder size={14} className="text-[var(--ink-muted)]" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{project.name}</span>
{selected ? <CheckCircle2 size={14} className="text-[var(--action-blue-hover)]" aria-hidden="true" /> : null}
</DropdownMenuItem>
);
}) : (
<DropdownMenuItem
className={cn(
"min-h-10 rounded-lg px-3 text-sm",
conversationScope === "project" && "bg-[var(--action-selection-soft)] text-slate-900"
)}
onSelect={() => void onConversationScopeChange("project")}
>
<Folder size={14} className="text-[var(--ink-muted)]" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{selectedProjectName}</span>
{conversationScope === "project" ? <CheckCircle2 size={14} className="text-[var(--action-blue-hover)]" aria-hidden="true" /> : null}
</DropdownMenuItem>
)}
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem
className="min-h-10 rounded-lg px-3 text-sm font-medium text-[var(--action-blue-hover)] focus:bg-[var(--action-soft)] focus:text-[var(--action-blue-hover)]"
onSelect={onOpenProjectCreator}
>
<FolderPlus size={14} aria-hidden="true" />
新建项目
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
</div>
</div>
@@ -481,7 +632,7 @@ export function AgentCommandPanel({
)}
>
<AnimatePresence mode="popLayout" initial={false}>
{messages.length > 0 ? (
{messages.length > 0 || Boolean(messageInbox?.messages.length) ? (
<BackendMessageList
key="messages"
presentation={presentation}
@@ -502,27 +653,12 @@ export function AgentCommandPanel({
onLocateAnswerFeatures={onLocateAnswerFeatures}
onSubmitAnswerForm={onSubmitPrompt}
/>
) : (
<motion.div
key="empty"
className={cn(
"flex flex-1 px-4",
presentation === "mobile-sheet"
? "items-start justify-center pb-0 pt-5"
: "items-center justify-center py-8"
)}
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
<AgentEmptyState compact={presentation === "mobile-sheet"} />
</motion.div>
)}
) : null}
</AnimatePresence>
{!qaOnly ? <AgentUiEnvelopeRenderer results={uiResults} /> : null}
{reviewPanel}
{messageInbox ? <AgentMessageInbox {...messageInbox} /> : null}
</ConversationContent>
<AgentConversationScrollManager
activeSessionId={activeSessionId}
@@ -543,16 +679,20 @@ export function AgentCommandPanel({
<div
className={cn(
"agent-panel-band relative",
centeredComposer && "agent-panel-start-composer",
floatingPresentation
? "form-field agent-panel-floating-acrylic agent-panel-composer absolute bottom-3 z-20 rounded-2xl p-1"
: "border-t border-[var(--surface-divider)] p-3"
)}
>
{!hasConversation ? (
{showStartSuggestions ? (
<h2 className="agent-panel-start-heading">我能帮你处理什么?</h2>
) : null}
{!hasConversation && !centeredComposer && !qaOnly ? (
<div
className={cn(
"agent-panel-conversation pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2",
floatingPresentation && "bg-transparent"
"agent-panel-suggestions pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2",
floatingPresentation && "agent-panel-conversation bg-transparent"
)}
>
<Suggestions
@@ -560,78 +700,142 @@ export function AgentCommandPanel({
role="group"
className="pointer-events-auto px-0.5"
>
{AGENT_PROMPT_SUGGESTIONS.map((suggestion) => (
{promptSuggestions.map(({ question }) => (
<Suggestion
key={suggestion}
suggestion={suggestion}
key={question}
suggestion={question}
onClick={submitPrompt}
className="surface-reading max-w-full border border-[var(--surface-divider)] text-slate-600 hover:bg-[var(--action-soft)] hover:text-[var(--action-blue-hover)]"
>
<span className="truncate">{suggestion}</span>
<span className="truncate">{question}</span>
</Suggestion>
))}
{promptSuggestionsLoading ? <span className="px-2 text-xs text-[var(--ink-muted)]">正在整理推荐问题…</span> : null}
</Suggestions>
</div>
) : null}
<PromptInput
className="form-field agent-panel-control overflow-hidden rounded-2xl shadow-xs [&>[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) {
submitPrompt(nextPrompt);
}
}}
<div
ref={composerRef}
className="agent-panel-composer-motion"
>
<PromptInputBody>
<PromptInputTextarea
value={prompt}
onChange={(event) => {
promptRestoreGenerationRef.current += 1;
onPromptChange(event.currentTarget.value);
}}
className={cn("text-sm", floatingPresentation ? "min-h-20" : "min-h-14")}
placeholder="输入调度问题,Agent 将通过后端会话流式响应"
/>
</PromptInputBody>
<PromptInputFooter className="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>}
{streaming ? (
<div className="mb-2 flex items-center justify-between gap-2 px-1 text-xs text-[var(--ink-muted)]">
<span>追加消息</span>
<InputDeliverySelect presentation={presentation} label="运行中消息发送方式"
value={inputDelivery} onValueChange={onInputDeliveryChange} />
</div>
<div className="flex shrink-0 items-center gap-2">
<AgentModelSelect
presentation={presentation}
models={modelOptions}
value={selectedModel}
onValueChange={onSelectModel}
/>
{isRecognitionSupported ? (
<VoiceInputButton
isListening={isListening}
disabled={streaming}
onClick={
isListening ? stopListening : () => startListening(showRecognitionError)
}
/>
) : null}
{pendingInputs.length ? (
<ul aria-label="待处理消息" className="mb-2 max-h-32 space-y-1 overflow-y-auto text-xs text-[var(--ink-muted)]">
{pendingInputs.map((input) => (
<li key={input.id} className="flex min-w-0 items-center gap-2 rounded-md bg-[var(--surface-subtle)] px-2 py-1.5">
<span className="min-w-0 flex-1 truncate" title={input.text}>{input.text}</span>
<InputDeliverySelect presentation={presentation} label={`消息发送方式:${input.text}`}
value={input.delivery} onValueChange={onPendingInputDeliveryChange
? (delivery) => { void onPendingInputDeliveryChange(input.id, delivery); }
: undefined} />
</li>
))}
</ul>
) : null}
<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",
presentation === "desktop-dock" ? "agent-panel-input-form" : "form-field shadow-xs"
)}
onSubmit={(message) => {
const nextPrompt = message.text.trim();
if (nextPrompt) {
submitPrompt(nextPrompt);
}
}}
>
<PromptInputBody className={presentation === "desktop-dock" ? "agent-panel-input-body form-field" : undefined}>
{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>
</div>
) : null}
<PromptInputSubmit
aria-label={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
title={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
className="agent-panel-primary-icon"
disabled={!streaming && !trimmedPrompt}
status={streaming ? "streaming" : "ready"}
onStop={onStopPrompt}
>
{streaming ? undefined : (
<SendHorizontal
size={15}
style={{ transform: "rotate(-45deg)" }}
aria-hidden="true"
<PromptInputTextarea
ref={promptTextareaRef}
value={prompt}
onChange={(event) => {
promptRestoreGenerationRef.current += 1;
onPromptChange(event.currentTarget.value);
}}
className={cn("agent-panel-prompt-textarea text-sm", floatingPresentation ? "min-h-20" : "min-h-14")}
placeholder={promptReference?.placeholder ?? "询问供水问题,或描述需要处理的任务"}
/>
</PromptInputBody>
<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>
<div className="flex shrink-0 items-center gap-2">
<AgentModelSelect
presentation={presentation}
models={modelOptions}
value={selectedModel}
onValueChange={streaming ? undefined : onSelectModel}
/>
{isRecognitionSupported ? (
<VoiceInputButton
isListening={isListening}
disabled={streaming}
onClick={
isListening ? stopListening : () => startListening(showRecognitionError)
}
/>
)}
</PromptInputSubmit>
</div>
</PromptInputFooter>
</PromptInput>
) : null}
{streaming ? <Button type="button" variant="ghost" size="icon" aria-label="停止 Agent 指令" title="停止 Agent 指令"
className="agent-panel-icon-button shrink-0" onClick={onStopPrompt}><Square size={14} aria-hidden="true" /></Button> : null}
<PromptInputSubmit
aria-label={streaming ? inputDelivery === "steer" ? "引导当前 Agent 任务" : "将消息加入 Agent 队列" : "发送 Agent 指令"}
title={streaming ? inputDelivery === "steer" ? "引导当前 Agent 任务" : "将消息加入 Agent 队列" : "发送 Agent 指令"}
className="agent-panel-primary-icon"
disabled={!trimmedPrompt}
status="ready"
>
{(
<SendHorizontal
size={15}
style={{ transform: "rotate(-45deg)" }}
aria-hidden="true"
/>
)}
</PromptInputSubmit>
</div>
</PromptInputFooter>
</PromptInput>
</div>
{showStartSuggestions ? (
<div className="agent-panel-start-suggestions" role="group" aria-label="推荐问题" aria-live="polite" aria-busy={promptSuggestionsLoading}>
{promptSuggestionsLoading ? (
<p className="agent-panel-suggestion-status">正在根据最近活动整理问题…</p>
) : promptSuggestions.length ? promptSuggestions.map((suggestion) => (
<button
key={suggestion.question}
type="button"
className="agent-panel-start-suggestion group"
title={suggestion.question}
onClick={() => submitPrompt(suggestion.question)}
>
<Activity size={17} className="shrink-0 text-[var(--action-blue-hover)]" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="line-clamp-2 block text-sm leading-5">{suggestion.question}</span>
{suggestion.sourceLabel ? <span className="mt-1 block text-[11px] leading-4 text-[var(--ink-muted)]">{suggestion.sourceLabel}</span> : null}
</span>
<ArrowUpRight size={14} className="shrink-0 text-[var(--ink-muted)]" aria-hidden="true" />
</button>
)) : (
<p className="agent-panel-suggestion-status">{promptSuggestionsUnavailable ? "推荐问题暂不可用,可直接输入任务。" : "描述调查目标,即可开始分析。"}</p>
)}
</div>
) : null}
</div>
</Agent>
</aside>
@@ -666,14 +870,14 @@ 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 after:absolute after:-inset-1 after:content-['']",
"border-transparent bg-transparent shadow-none",
"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-['']",
"bg-transparent shadow-none",
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1",
"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:border-[var(--action-border-hover)] [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]"
: "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}
@@ -762,6 +966,38 @@ 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 InputDeliverySelect({ presentation, label, value, onValueChange }: {
presentation: AgentCommandPanelPresentation;
label: string;
value: AgentInputDelivery;
onValueChange?: (delivery: AgentInputDelivery) => void;
}) {
return <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" aria-label={label}
className="agent-panel-control h-10 shrink-0 gap-2 px-2.5 text-xs font-medium"
disabled={!onValueChange}>
{value === "queue" ? "排队处理" : "引导当前任务"}
<ChevronDown className="size-3 opacity-60" aria-hidden="true" />
</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-xs",
value === delivery && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]")}>
{text}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>;
}
function ApprovalModeSelect({
presentation,
value,
@@ -780,9 +1016,9 @@ function ApprovalModeSelect({
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
variant="ghost"
size="sm"
className="shrink-0 gap-1.5 border-transparent bg-transparent px-1.5 text-xs text-slate-600 shadow-none hover:bg-[var(--surface-reading)]"
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"
disabled={disabled || !onValueChange}
aria-label="权限批准模式"
>
@@ -816,7 +1052,7 @@ function ApprovalModeSelect({
<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 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" />
@@ -837,7 +1073,7 @@ function ApprovalModeSelect({
/>
<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 className="block text-xs leading-5 text-[var(--ink-muted)]">自动批准本轮工具权限请求,不改变业务权限或安全限制</span>
</span>
{!requestApproval ? (
<CheckCircle2
@@ -951,7 +1187,7 @@ function AgentModelSelect({
value: string;
onValueChange?: (model: string) => void;
}) {
const selectedModel = models.find((model) => model.id === value) ?? models[0];
const selectedModel = models.find((model) => model.id === value);
if (!models.length) {
return (
@@ -964,9 +1200,9 @@ function AgentModelSelect({
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
variant="ghost"
size="sm"
className="w-12 shrink-0 justify-between gap-1 border-transparent bg-transparent px-1 text-xs text-slate-600 shadow-none hover:bg-[var(--surface-reading)] sm:w-24 sm:gap-1.5 sm:px-1.5"
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"
disabled={!onValueChange}
aria-label="选择 Agent 模型"
aria-description={`当前模型:${selectedModel?.label ?? "模型"}`}
@@ -1041,7 +1277,7 @@ function FastModelIcon({ size }: { size: number }) {
height={size}
viewBox="0 0 24 24"
fill="none"
className="text-sky-600"
className="text-[var(--action-blue-hover)]"
aria-hidden="true"
>
<path
@@ -1070,111 +1306,6 @@ function ExpertModelIcon({ size }: { size: number }) {
);
}
function AgentReadyMark() {
return (
<svg viewBox="0 0 1024 1024" className="h-12 w-12 overflow-visible" aria-hidden="true">
<g className="agent-ready-orbit">
<path
d="M384.1536 952.1664a38.4 38.4 0 0 1-49.3568 22.528 498.3808 498.3808 0 0 1-284.928-273.92 38.4 38.4 0 0 1 70.8608-29.6448 421.5808 421.5808 0 0 0 240.896 231.6288 38.4 38.4 0 0 1 22.528 49.408zM952.1152 384.9728a38.4 38.4 0 0 1-49.4592-22.528 421.5296 421.5296 0 0 0-234.1376-241.5104 38.4 38.4 0 0 1 29.184-71.0656 498.3296 498.3296 0 0 1 276.8896 285.696 38.4 38.4 0 0 1-22.528 49.408z"
fill="var(--palette-brand)"
/>
<path
d="M981.248 768.0512a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.2992 0zM127.9488 256.0512a42.6496 42.6496 0 1 1-85.3504 0 42.6496 42.6496 0 0 1 85.3504 0z"
fill="var(--palette-champagne)"
/>
<path
d="M810.496 938.8544a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0zM298.496 85.504a42.6496 42.6496 0 1 1-85.2992 0 42.6496 42.6496 0 0 1 85.3504 0z"
fill="var(--palette-sand)"
/>
</g>
<path
className="agent-ready-core"
d="M511.9488 276.736l-27.8528 114.7392A126.0544 126.0544 0 0 1 391.3216 484.352l-114.7904 27.8528 114.7904 27.8016a126.0544 126.0544 0 0 1 92.7744 92.8256L512 747.52l27.8016-114.7392a126.0544 126.0544 0 0 1 92.8256-92.8256l114.7392-27.8016-114.7392-27.8528a126.0544 126.0544 0 0 1-92.8256-92.8256L512 276.736z m55.6544-62.1568c-14.1312-58.368-97.1776-58.368-111.36 0L417.28 375.296a57.344 57.344 0 0 1-42.1888 42.1888l-160.6656 38.912c-58.4192 14.1824-58.4192 97.28 0 111.4112l160.6656 38.9632c20.8384 5.12 37.12 21.3504 42.1888 42.1888l38.9632 160.7168c14.1824 58.368 97.2288 58.368 111.36 0l38.9632-160.7168a57.344 57.344 0 0 1 42.1888-42.1888l160.7168-38.912c58.368-14.1824 58.368-97.28 0-111.4112l-160.7168-38.9632a57.344 57.344 0 0 1-42.1888-42.1888l-38.912-160.7168z"
fill="var(--action-selection-soft)"
/>
</svg>
);
}
function AgentEmptyState({ compact = false }: { compact?: boolean }) {
if (compact) {
return (
<section
aria-labelledby="agent-empty-state-title"
className="mx-auto flex w-full max-w-[360px] items-center gap-3 px-1 py-1"
lang="zh-CN"
>
<div className="surface-control grid size-9 shrink-0 place-items-center rounded-lg shadow-[inset_0_0_0_1px_oklch(0.84_0.025_260)]">
<AgentReadyMark />
</div>
<div className="min-w-0 flex-1 text-left">
<h2
id="agent-empty-state-title"
className="text-sm font-semibold leading-5 text-slate-950"
>
我已就绪,请描述任务
</h2>
<p className="mt-0.5 text-xs leading-5 text-slate-600">
描述调查目标,我会整理证据并带回地图。
</p>
</div>
</section>
);
}
return (
<section
aria-labelledby="agent-empty-state-title"
className="agent-empty-state mx-auto w-full max-w-[360px]"
lang="zh-CN"
>
<div className="px-2 py-4">
<div className="flex items-center gap-4">
<div className="agent-empty-mark surface-control grid h-[72px] w-[72px] shrink-0 place-items-center rounded-xl shadow-[inset_0_0_0_1px_oklch(0.84_0.025_260)]">
<AgentReadyMark />
</div>
<div className="min-w-0 flex-1 text-left">
<h2
id="agent-empty-state-title"
className="text-balance text-lg font-semibold leading-7 text-slate-950"
>
我已就绪,请描述任务
</h2>
</div>
</div>
<p className="mt-5 text-pretty text-left text-sm leading-6 text-[#334155]">
直接说出你想调查的问题,我会整理监测与空间证据,并将分析结果带回地图。
</p>
</div>
<ul
className={cn(
"agent-capabilities surface-control grid grid-cols-2 overflow-hidden rounded-xl shadow-[inset_0_0_0_1px_rgba(148,163,184,0.32)]",
compact && "hidden"
)}
aria-label="Agent 分析能力"
>
{AGENT_CAPABILITIES.map(({ icon: Icon, label }, index) => (
<li
key={label}
className={cn(
"agent-capability flex min-h-20 items-center gap-3 px-3.5 py-3 text-left",
index % 2 === 0 && "border-r border-[var(--surface-border)]/80",
index < 2 && "border-b border-[var(--surface-border)]/80"
)}
>
<span className="agent-capability-icon grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-[#f9fbfe] text-[#334155] shadow-[inset_0_0_0_1px_rgba(148,163,184,0.28)]">
<Icon size={16} strokeWidth={1.8} aria-hidden="true" />
</span>
<span className="text-sm font-semibold leading-5 text-[#1e293b]">{label}</span>
</li>
))}
</ul>
</section>
);
}
function BackendMessageList({
presentation,
messages,