feat(agent): refine ready state actions

This commit is contained in:
2026-07-15 18:30:21 +08:00
parent 055335e404
commit df2fb3ca38
10 changed files with 149 additions and 41 deletions
+2
View File
@@ -372,6 +372,8 @@ Required sections:
- Confirmation controls.
- Conversation or command input.
Empty Agent conversations use a centered readiness introduction with one identity mark and a compact 2-by-2 capability summary. Keep suggested questions in their existing action strip above the prompt input instead of turning capability cards into duplicate actions. The identity mark may use a slow orbit and low-amplitude core breathing animation, but it must become static under reduced-motion preferences.
Agent recommendation cards should show:
- Action name.
+40
View File
@@ -368,6 +368,20 @@ input:focus-visible {
background: var(--surface-reading);
}
.agent-ready-orbit,
.agent-ready-core {
transform-box: fill-box;
transform-origin: center;
}
.agent-ready-orbit {
animation: agent-ready-orbit 9s linear infinite;
}
.agent-ready-core {
animation: agent-ready-core 2.8s ease-in-out infinite;
}
.agent-panel-icon-button {
border: 1px solid var(--surface-divider);
background: var(--surface-control);
@@ -664,6 +678,25 @@ input:focus-visible {
}
}
@keyframes agent-ready-orbit {
to {
transform: rotate(360deg);
}
}
@keyframes agent-ready-core {
0%,
100% {
opacity: 0.72;
transform: scale(0.94);
}
50% {
opacity: 1;
transform: scale(1.04);
}
}
@media (prefers-reduced-motion: reduce) {
.agent-panel-enter,
.agent-panel-collapse,
@@ -699,6 +732,13 @@ input:focus-visible {
opacity: 1;
transform: none;
}
.agent-ready-orbit,
.agent-ready-core {
animation: none;
opacity: 1;
transform: none;
}
}
.maplibregl-ctrl-group {
@@ -170,8 +170,8 @@ export function AgentCommandPanel({
<div className="flex min-h-16 items-center justify-between gap-2 px-3 py-2">
<div className="flex min-w-0 flex-1 items-center gap-2.5 text-slate-900">
<AgentPersona
className="h-10 w-10"
fallbackClassName="h-10 w-10 rounded-xl"
className="h-12 w-12"
fallbackClassName="h-12 w-12 rounded-xl"
state={personaState}
statusLabel={statusLabel}
streaming={streaming}
@@ -293,7 +293,7 @@ export function AgentCommandPanel({
) : (
<motion.div
key="empty"
className="flex flex-1 items-start justify-start px-4 py-6"
className="flex flex-1 items-center justify-center px-4 py-8"
initial="initial"
animate="animate"
exit="exit"
@@ -319,7 +319,7 @@ export function AgentCommandPanel({
<div className="agent-panel-band relative border-t border-slate-200 p-3">
{messages.length === 0 && !streaming ? (
<div className="surface-dock pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2">
<div className="agent-panel-conversation pointer-events-none absolute inset-x-0 bottom-full z-10 px-3 pb-2 pt-2">
<Suggestions aria-label="推荐问题" role="group" className="pointer-events-auto px-0.5">
{AGENT_PROMPT_SUGGESTIONS.map((suggestion) => (
<Suggestion
@@ -596,28 +596,57 @@ 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="#CE75FF"
/>
<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="#F62E76"
/>
<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="#CD88FF"
/>
</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="#F3E2FF"
/>
</svg>
);
}
function AgentEmptyState() {
return (
<section
aria-labelledby="agent-empty-state-title"
className="mx-auto flex w-full max-w-sm flex-col px-2 py-4 text-left"
className="surface-reading mx-auto w-full max-w-sm rounded-2xl border border-slate-200/80 px-4 pb-4 pt-5 text-center shadow-sm"
>
<p id="agent-empty-state-title" className="text-base font-semibold text-slate-900">
<div className="mx-auto grid h-16 w-16 place-items-center rounded-full border border-violet-100 bg-white shadow-[0_10px_28px_rgba(126,34,206,0.12)]">
<AgentReadyMark />
</div>
<h2 id="agent-empty-state-title" className="mt-4 text-lg font-semibold leading-7 text-slate-900">
</h2>
<p className="mx-auto mt-2 max-w-[30ch] text-sm leading-6 text-slate-500">
使
</p>
<p className="mt-1.5 text-xs leading-5 text-slate-500">
Agent
</p>
<ul className="mt-5 w-full divide-y divide-slate-200 border-y border-slate-200" aria-label="Agent 分析能力">
<ul className="mt-5 grid grid-cols-2 gap-2" aria-label="Agent 分析能力">
{AGENT_CAPABILITIES.map(({ icon: Icon, label }) => (
<li
key={label}
className="flex min-h-11 items-center gap-3 px-1 text-xs font-semibold text-slate-700"
className="surface-control flex min-h-16 flex-col items-center justify-center gap-1.5 rounded-xl border border-slate-200/70 px-2 py-2.5 text-center shadow-[0_6px_18px_rgba(15,23,42,0.04)] sm:flex-row sm:gap-2 sm:px-2.5 sm:text-left"
>
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-blue-50 text-blue-700">
<Icon size={16} strokeWidth={1.8} aria-hidden="true" />
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-blue-50 text-blue-700">
<Icon size={17} strokeWidth={1.9} aria-hidden="true" />
</span>
<span className="whitespace-nowrap">{label}</span>
<span className="whitespace-nowrap text-xs font-semibold leading-5 text-slate-800">{label}</span>
</li>
))}
</ul>
@@ -161,15 +161,17 @@ function AgentNestedBlock({
icon: Icon,
iconClassName,
title,
children
children,
floating = false
}: {
icon: LucideIcon;
iconClassName: string;
title: string;
children: ReactNode;
floating?: boolean;
}) {
return (
<div className="agent-panel-nested rounded-xl p-2.5">
<div className={cn(floating ? "px-0 py-1" : "agent-panel-nested rounded-xl p-2.5")}>
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-600">
<Icon size={14} className={iconClassName} aria-hidden="true" />
{title}
@@ -430,7 +432,7 @@ function QuestionList({
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
}) {
return (
<AgentNestedBlock icon={HelpCircle} iconClassName="text-blue-600" title="补充信息">
<AgentNestedBlock icon={HelpCircle} iconClassName="text-blue-600" title="补充信息" floating>
<motion.div className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{questions.slice(-3).map((request) => (
@@ -479,7 +481,7 @@ function QuestionRequestCard({
};
return (
<div className="surface-reading rounded-lg px-2.5 py-2 text-xs">
<div className="px-0 py-1 text-xs">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="min-w-0 truncate font-semibold text-slate-800">
{request.questions[0]?.header || "问题"}
@@ -597,7 +599,7 @@ function QuestionInput({
return (
<label
key={option.label}
className="surface-reading flex cursor-pointer items-start gap-2 rounded-md border border-slate-200 px-2 py-1.5 text-slate-600"
className="flex cursor-pointer items-start gap-2 rounded-md border border-slate-200/80 px-2 py-1.5 text-slate-600"
>
<input
type={question.multiple ? "checkbox" : "radio"}
@@ -620,7 +622,7 @@ function QuestionInput({
) : null}
{showCustomInput ? (
<textarea
className="surface-reading min-h-16 w-full resize-none rounded-md border border-slate-200 px-2 py-1.5 text-xs leading-5 text-slate-700 outline-none transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
className="min-h-16 w-full resize-none rounded-md border border-slate-200/80 bg-transparent px-2 py-1.5 text-xs leading-5 text-slate-700 outline-none transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
placeholder="输入回答"
value={value.custom}
disabled={disabled}
+2 -2
View File
@@ -1,10 +1,10 @@
export const FRONTEND_ACTION_NAMES = ["zoom_to_map", "locate_features", "view_history", "view_scada"] as const;
export const FRONTEND_ACTION_NAMES = ["zoom_to_map", "locate_features", "view_history"] as const;
export type FrontendActionName = (typeof FRONTEND_ACTION_NAMES)[number];
export type FrontendActionRegistry = { schema_version: "frontend-action-registry@1"; actions: Array<{ id: FrontendActionName; version: "frontend-action@1" }> };
export type FrontendActionRequest = { version: "frontend-action@1"; actionId: string; toolCallId: string; sessionId: string; name: FrontendActionName; params: Record<string, unknown>; issuedAt: number; expiresAt: number };
export type FrontendActionResult = { version: "frontend-action-result@1"; actionId: string; status: "succeeded" | "failed" | "cancelled" | "expired"; output?: unknown; error?: { code: string; message: string }; completedAt: number };
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
export function parseFrontendActionRegistry(value: unknown): FrontendActionRegistry | null { if (!isRecord(value) || value.schema_version !== "frontend-action-registry@1" || !Array.isArray(value.actions)) return null; const actions = value.actions.filter(isRecord); if (!FRONTEND_ACTION_NAMES.every((name) => actions.some((item) => item.id === name && item.version === "frontend-action@1"))) return null; return value as FrontendActionRegistry; }
export function parseFrontendActionRegistry(value: unknown): FrontendActionRegistry | null { if (!isRecord(value) || value.schema_version !== "frontend-action-registry@1" || !Array.isArray(value.actions)) return null; const actions = value.actions.filter((item): item is { id: FrontendActionName; version: "frontend-action@1" } => isRecord(item) && FRONTEND_ACTION_NAMES.includes(item.id as FrontendActionName) && item.version === "frontend-action@1"); return { schema_version: "frontend-action-registry@1", actions }; }
export function parseFrontendActionRequest(value: unknown): FrontendActionRequest | null { if (!isRecord(value) || value.version !== "frontend-action@1" || typeof value.actionId !== "string" || typeof value.toolCallId !== "string" || typeof value.sessionId !== "string" || !FRONTEND_ACTION_NAMES.includes(value.name as FrontendActionName) || !isRecord(value.params) || typeof value.issuedAt !== "number" || typeof value.expiresAt !== "number") return null; return value as FrontendActionRequest; }
const storageKey = (sessionId: string) => `tjwater:frontend-actions:${sessionId}`;
export function readActionResults(sessionId: string): Record<string, FrontendActionResult> { try { const value = JSON.parse(sessionStorage.getItem(storageKey(sessionId)) ?? "{}"); return isRecord(value) ? value as Record<string, FrontendActionResult> : {}; } catch { return {}; } }
@@ -55,6 +55,14 @@ describe("toTrustedMapAction", () => {
});
});
it("converts EPSG:3857 zoom coordinates", () => {
const action = toTrustedMapAction("zoom_to_map", { x: 13046644.321, y: 4736005.854, source_crs: "EPSG:3857" });
expect(action?.type).toBe("zoom_to_map");
expect(action?.type === "zoom_to_map" ? action.center[0] : undefined).toBeCloseTo(117.2, 3);
expect(action?.type === "zoom_to_map" ? action.center[1] : undefined).toBeCloseTo(39.1, 3);
});
it("normalizes legacy locate tool actions", () => {
expect(toTrustedMapAction("locate_pipes", { pipe_ids: "P1,P2" })).toEqual({
type: "locate_features",
+24 -3
View File
@@ -9,6 +9,7 @@ export type TrustedMapAction =
type: "zoom_to_map";
center: [number, number];
zoom?: number;
durationMs?: number;
fallbackText?: string;
}
| {
@@ -49,6 +50,7 @@ export function toTrustedMapAction(action: string, params: unknown, fallbackText
}
const zoom = numberValue(params.zoom);
const durationMs = numberValue(params.duration_ms ?? params.durationMs);
if (center[0] < -180 || center[0] > 180 || center[1] < -90 || center[1] > 90 ||
(zoom !== undefined && (zoom < 0 || zoom > 24))) return null;
@@ -56,6 +58,7 @@ export function toTrustedMapAction(action: string, params: unknown, fallbackText
type: "zoom_to_map",
center,
zoom,
...(durationMs === undefined ? {} : { durationMs }),
fallbackText
};
}
@@ -91,22 +94,39 @@ export function toTrustedMapAction(action: string, params: unknown, fallbackText
}
function parseCenter(params: Record<string, unknown>): [number, number] | null {
const sourceCrs = params.source_crs ?? params.sourceCrs;
if (Array.isArray(params.center) && params.center.length >= 2) {
const lng = numberValue(params.center[0]);
const lat = numberValue(params.center[1]);
return lng === undefined || lat === undefined ? null : [lng, lat];
return lng === undefined || lat === undefined ? null : normalizeCenter(lng, lat, sourceCrs);
}
const rawCoordinate = params.coordinate ?? params.coordinates;
if (Array.isArray(rawCoordinate) && rawCoordinate.length >= 2) {
const lng = numberValue(rawCoordinate[0]);
const lat = numberValue(rawCoordinate[1]);
return lng === undefined || lat === undefined ? null : [lng, lat];
return lng === undefined || lat === undefined ? null : normalizeCenter(lng, lat, sourceCrs);
}
const lng = numberValue(params.lng ?? params.lon ?? params.longitude ?? params.x);
const lat = numberValue(params.lat ?? params.latitude ?? params.y);
return lng === undefined || lat === undefined ? null : [lng, lat];
return lng === undefined || lat === undefined ? null : normalizeCenter(lng, lat, sourceCrs);
}
function normalizeCenter(x: number, y: number, sourceCrs: unknown): [number, number] {
if (sourceCrs === "EPSG:3857") {
return webMercatorToLngLat(x, y);
}
return [x, y];
}
function webMercatorToLngLat(x: number, y: number): [number, number] {
const lng = (x / WEB_MERCATOR_RADIUS) * (180 / Math.PI);
const lat = (2 * Math.atan(Math.exp(y / WEB_MERCATOR_RADIUS)) - Math.PI / 2) * (180 / Math.PI);
return [lng, lat];
}
function stringValue(value: unknown) {
@@ -145,6 +165,7 @@ const LEGACY_LOCATE_ACTION_LAYERS: Record<string, string> = {
const RESULT_REF_PATTERN = /^res-[A-Za-z0-9_-]{8,128}$/;
const ALLOWED_LAYER_IDS = new Set(["junctions", "pipes"]);
const WEB_MERCATOR_RADIUS = 6378137;
const LOCATE_ID_PARAM_KEYS = [
"feature_ids",
+1 -4
View File
@@ -5,10 +5,7 @@ import type { UIRegistry } from "./types";
const registry: UIRegistry = {
schema_version: "agent-ui-registry@1",
chart_grammars: ["echarts-safe-subset"],
components: [
{ id: "HistoryPanel", supportedSurfaces: ["side_panel", "canvas"] },
{ id: "ScadaPanel", supportedSurfaces: ["side_panel", "canvas"] }
],
components: [{ id: "HistoryPanel", supportedSurfaces: ["side_panel", "canvas"] }],
actions: [{ id: "zoom_to_map", supportedSurfaces: ["map_overlay"] }]
};
+1 -7
View File
@@ -5,8 +5,7 @@ import type { UIEnvelope, UIEnvelopePayload, UIRegistry, UISurface } from "./typ
const surfaces = new Set<UISurface>(["chat_inline", "side_panel", "canvas", "map_overlay"]);
const MAX_ID_LENGTH = 128;
const LOCAL_COMPONENT_SURFACES = new Map<string, UISurface[]>([
["HistoryPanel", ["side_panel", "canvas"]],
["ScadaPanel", ["side_panel", "canvas"]]
["HistoryPanel", ["side_panel", "canvas"]]
]);
const LOCAL_ACTION_SURFACES = new Map<string, UISurface[]>([
["locate_features", ["map_overlay"]],
@@ -183,11 +182,6 @@ function isValidComponentProps(component: string, props: unknown) {
items.every((item) => Array.isArray(item) && item.length === 2 && item.every((part) => boundedString(part, 128))) &&
(props.data_type === "realtime" || props.data_type === "scheme" || props.data_type === "none");
}
if (component === "ScadaPanel") {
if (!hasOnlyKeys(props, ["reason", "device_ids", "device_id", "start_time", "end_time"])) return false;
const ids = Array.isArray(props.device_ids) ? props.device_ids : props.device_id ? [props.device_id] : [];
return ids.length > 0 && ids.length <= 100 && ids.every((id) => Boolean(boundedString(id, 128)));
}
return false;
}
+19 -4
View File
@@ -526,8 +526,18 @@ export function MapWorkbenchPage() {
async function handleFrontendAction(request: FrontendActionRequest, signal: AbortSignal) {
const map = mapRef.current;
if (request.name === "zoom_to_map") {
if (!map || !mapReady) throw new Error("MAP_NOT_READY"); const x = Number(request.params.x); const y = Number(request.params.y);
const center: [number, number] = request.params.source_crs === "EPSG:4326" ? [x, y] : [x * 180 / 20037508.34, Math.atan(Math.exp(y * Math.PI / 20037508.34)) * 360 / Math.PI - 90]; const zoom = Number(request.params.zoom ?? 18); map.easeTo({ center, zoom, padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen), duration: Number(request.params.duration_ms ?? 500) }); return { center, zoom };
if (!map || !mapReady) throw new Error("MAP_NOT_READY");
const trustedAction = toTrustedMapAction("zoom_to_map", request.params);
if (!trustedAction || trustedAction.type !== "zoom_to_map") throw new Error("INVALID_ZOOM_TO_MAP");
const zoom = trustedAction.zoom ?? 18;
const duration = trustedAction.durationMs ?? 500;
map.easeTo({
center: trustedAction.center,
zoom,
padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen),
duration
});
return { center: trustedAction.center, zoom, duration };
}
if (request.name === "locate_features") {
if (!map || !mapReady) throw new Error("MAP_NOT_READY"); const ids = request.params.ids as string[]; const featureType = String(request.params.feature_type);
@@ -536,7 +546,12 @@ export function MapWorkbenchPage() {
if (map.getSource("agent-locate")) (map.getSource("agent-locate") as unknown as { setData: (data: unknown) => void }).setData(geojson); else { map.addSource("agent-locate", { type: "geojson", data: geojson }); map.addLayer({ id: "agent-locate-lines", type: "line", source: "agent-locate", filter: ["==", ["geometry-type"], "LineString"], paint: { "line-color": "#f97316", "line-width": 6 } }); map.addLayer({ id: "agent-locate-points", type: "circle", source: "agent-locate", filter: ["==", ["geometry-type"], "Point"], paint: { "circle-color": "#f97316", "circle-radius": 8, "circle-stroke-color": "#fff", "circle-stroke-width": 2 } }); }
const coordinates: number[][] = []; const collect = (value: unknown) => { if (Array.isArray(value) && value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number") coordinates.push(value as number[]); else if (Array.isArray(value)) value.forEach(collect); }; features.forEach((feature) => collect(feature.geometry.coordinates)); const bounds: [number, number, number, number] = [Math.min(...coordinates.map((c) => c[0])), Math.min(...coordinates.map((c) => c[1])), Math.max(...coordinates.map((c) => c[0])), Math.max(...coordinates.map((c) => c[1]))]; map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen), maxZoom: 18 }); const locatedIds = features.map((feature) => String(feature.id ?? feature.properties?.id ?? "")).filter(Boolean); return { locatedIds, missingIds: ids.filter((id) => !locatedIds.includes(id)), featureType, bounds };
}
const history = request.name === "view_history"; const itemCount = history ? (request.params.feature_infos as unknown[]).length : Array.isArray(request.params.device_ids) ? request.params.device_ids.length : 1; setAgentUiResults((current) => [...current.slice(-5), { id: request.actionId, sessionId: request.sessionId, createdAt: Date.now(), envelope: { kind: "registered_component", schemaVersion: "agent-ui@1", component: history ? "HistoryPanel" : "ScadaPanel", surface: "side_panel", props: request.params } }]); return { component: history ? "HistoryPanel" : "ScadaPanel", rendered: true, itemCount };
if (request.name === "view_history") {
const itemCount = (request.params.feature_infos as unknown[]).length;
setAgentUiResults((current) => [...current.slice(-5), { id: request.actionId, sessionId: request.sessionId, createdAt: Date.now(), envelope: { kind: "registered_component", schemaVersion: "agent-ui@1", component: "HistoryPanel", surface: "side_panel", props: request.params } }]);
return { component: "HistoryPanel", rendered: true, itemCount };
}
throw new Error("UNSUPPORTED_FRONTEND_ACTION");
}
async function handleAgentMapAction(action: string, params: unknown, sessionId: string, fallbackText?: string) {
@@ -561,7 +576,7 @@ export function MapWorkbenchPage() {
center: trustedAction.center,
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen),
duration: 500
duration: trustedAction.durationMs ?? 500
});
showMapNotice({
tone: "info",