feat(drainage): execute frontend actions

This commit is contained in:
2026-07-13 17:26:03 +08:00
parent c2e1c937ae
commit 0b7e827024
8 changed files with 171 additions and 8 deletions
@@ -31,6 +31,8 @@ import {
type UIEnvelopePayload,
type UIRegistry
} from "@/features/agent/ui-envelope";
import type { FrontendActionRequest } from "@/features/agent/frontend-action";
import { FrontendActionExecutor } from "@/features/agent/frontend-action/executor";
import {
appendStreamRenderToken,
applySessionStreamEvent,
@@ -53,9 +55,10 @@ type AgentRuntimeAvailability = "connecting" | "connected" | "unavailable" | "fa
type UseWorkbenchAgentOptions = {
onUiEnvelope: (payload: UIEnvelopePayload, sessionId: string) => Promise<void> | void;
onFrontendAction: (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>;
};
export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkbenchAgentOptions) {
const collapseTimerRef = useRef<number | null>(null);
const mobileCollapseTimerRef = useRef<number | null>(null);
const sessionStreamAbortRef = useRef<AbortController | null>(null);
@@ -64,6 +67,9 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
const clientRef = useRef(createAgentApiClient());
const sessionIdRef = useRef<string | null>(null);
const registryRef = useRef<UIRegistry | null>(null);
const frontendActionEnabledRef = useRef(false);
const onFrontendActionRef = useRef(onFrontendAction);
const processedEnvelopeIdsRef = useRef(new Set<string>());
const [panelOpen, setPanelOpen] = useState(true);
const [panelCollapsing, setPanelCollapsing] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
@@ -88,6 +94,10 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
registryRef.current = registry;
}, [registry]);
useEffect(() => {
onFrontendActionRef.current = onFrontendAction;
}, [onFrontendAction]);
const applySessionId = useCallback((nextSessionId: string | undefined) => {
if (!nextSessionId || sessionIdRef.current === nextSessionId) {
return;
@@ -105,6 +115,30 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
return clientRef.current.resolveRenderRef(renderRef, nextSessionId);
}, []);
const frontendActionExecutor = useMemo(
() => new FrontendActionExecutor(
(request, signal) => onFrontendActionRef.current(request, signal),
(nextSessionId, actionId, result) => clientRef.current.submitFrontendActionResult(nextSessionId, actionId, result)
),
[]
);
const handleFrontendAction = useCallback(
(value: unknown) => frontendActionExecutor.handle(value, sessionIdRef.current),
[frontendActionExecutor]
);
const claimEnvelope = useCallback((payload: UIEnvelopePayload, activeSessionId: string) => {
if (payload.session_id !== activeSessionId) return false;
const key = `${activeSessionId}:${payload.envelope_id}`;
if (processedEnvelopeIdsRef.current.has(key)) return false;
processedEnvelopeIdsRef.current.add(key);
if (processedEnvelopeIdsRef.current.size > 256) {
const oldest = processedEnvelopeIdsRef.current.values().next().value;
if (oldest) processedEnvelopeIdsRef.current.delete(oldest);
}
return true;
}, []);
const handleDataPart = useCallback(
(part: AgentDataPart) => {
const eventSessionId = getDataString(part.data, "session_id");
@@ -130,6 +164,7 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
}
return;
}
if (part.type === "data-frontend_action") { void handleFrontendAction(part.data); return; }
if (part.type === "data-session_title") {
const title = getDataString(part.data, "title");
@@ -153,6 +188,7 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
});
return;
}
if (!claimEnvelope(payload, activeSessionId)) return;
void Promise.resolve(onUiEnvelope(payload, activeSessionId)).catch((error) => {
showMapNotice({
@@ -162,7 +198,7 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
});
});
},
[applySessionId, onUiEnvelope]
[applySessionId, claimEnvelope, handleFrontendAction, onUiEnvelope]
);
const transport = useMemo(
@@ -178,7 +214,8 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
trigger,
messageId,
session_id: readBodyString(body, "session_id") ?? sessionIdRef.current ?? undefined,
approval_mode: readBodyString(body, "approval_mode") ?? "request"
approval_mode: readBodyString(body, "approval_mode") ?? "request",
capabilities: frontendActionEnabledRef.current ? ["frontend-action@1"] : []
}
};
}
@@ -253,9 +290,10 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
clearCollapseTimer();
clearMobileCollapseTimer();
stopSessionStreamSubscription();
frontendActionExecutor.cancelAll();
chatRef.current.stop();
};
}, [clearCollapseTimer, clearMobileCollapseTimer, stopSessionStreamSubscription]);
}, [clearCollapseTimer, clearMobileCollapseTimer, frontendActionExecutor, stopSessionStreamSubscription]);
useEffect(() => {
if (!backendConnection) {
@@ -263,6 +301,7 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
}
registryRef.current = backendConnection.registry;
frontendActionEnabledRef.current = Boolean(backendConnection.frontendActionRegistry);
setRegistry(backendConnection.registry);
setModelOptions(backendConnection.models);
setSelectedModel((current) => current || backendConnection.defaultModel || backendConnection.models[0]?.id || "");
@@ -396,6 +435,7 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
});
return;
}
if (!claimEnvelope(payload, activeSessionId)) return;
void Promise.resolve(onUiEnvelope(payload, activeSessionId)).catch((error) => {
showMapNotice({
tone: "error",
@@ -403,6 +443,8 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
message: error instanceof Error ? error.message : "工作台处理 Agent 展示结果失败。"
});
});
} else if (event.type === "frontend_action") {
void handleFrontendAction(event.data);
} else if (event.type === "progress") {
const title = getDataString(event.data, "title");
if (title) {
@@ -422,7 +464,7 @@ export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
chatRef.current.setMessages((current) => applySessionStreamEvent(current, event));
},
[onUiEnvelope, refreshSessionHistory]
[claimEnvelope, handleFrontendAction, onUiEnvelope, refreshSessionHistory]
);
const startSessionStreamSubscription = useCallback(