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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user