feat(workbench): integrate operational workflows and map state

This commit is contained in:
2026-09-24 04:34:46 +08:00
parent 1752b9ef84
commit 436d5500f1
76 changed files with 5081 additions and 1937 deletions
+8 -2
View File
@@ -33,7 +33,10 @@ describe("Agent API client sessions", () => {
status: undefined,
parentSessionId: undefined,
isStreaming: undefined,
runStatus: "running"
runStatus: "running",
purpose: "ordinary_chat",
taskId: undefined,
trigger: undefined
},
{
id: "old",
@@ -43,7 +46,10 @@ describe("Agent API client sessions", () => {
status: undefined,
parentSessionId: undefined,
isStreaming: undefined,
runStatus: undefined
runStatus: undefined,
purpose: "ordinary_chat",
taskId: undefined,
trigger: undefined
}
]);
});
+38 -4
View File
@@ -2,6 +2,7 @@ import { env } from "@/shared/config/env";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
export type AgentSessionPurpose = "ordinary_chat" | "task_execution" | "task_collaboration";
export type AgentEventTriggerContext = {
schema_version: "agent-trigger@1";
@@ -51,9 +52,12 @@ export type AgentChatSession = {
created?: boolean;
event_duplicate?: boolean;
trigger_id?: string;
purpose?: AgentSessionPurpose;
task_id?: string;
};
export type AgentChatSessionSummary = {
trigger?: { subjectId: string; workflow: "water_quality_complaint"; occurredAt: string };
id: string;
title: string;
createdAt: number;
@@ -62,6 +66,8 @@ export type AgentChatSessionSummary = {
parentSessionId?: string;
isStreaming?: boolean;
runStatus?: AgentRunStatus;
purpose: AgentSessionPurpose;
taskId?: string;
};
export type AgentLoadedChatSession = AgentChatSessionSummary & {
@@ -71,6 +77,7 @@ export type AgentLoadedChatSession = AgentChatSessionSummary & {
};
export type AgentSessionStreamDataEventType =
| "execution_plan"
| "token"
| "progress"
| "todo_update"
@@ -90,6 +97,7 @@ export type AgentSessionStreamDataEventType =
export type AgentSessionStreamEvent =
| {
type: "state";
executionPlans?: unknown[];
sessionId: string;
messages: unknown[];
isStreaming: boolean;
@@ -103,7 +111,8 @@ export type AgentSessionStreamEvent =
export type AgentApiClient = {
createSession: (options?: { triggerContext?: AgentEventTriggerContext }) => Promise<AgentChatSession>;
listSessions: () => Promise<AgentChatSessionSummary[]>;
listSessions: (options?: { purpose?: AgentSessionPurpose; taskId?: string }) => Promise<AgentChatSessionSummary[]>;
createTaskCollaborationSession: (taskId: string) => Promise<AgentChatSession>;
loadSession: (sessionId: string) => Promise<AgentLoadedChatSession | null>;
streamSession: (
sessionId: string,
@@ -184,11 +193,23 @@ export function createAgentApiClient(
});
},
async listSessions() {
const payload = await requestJson<{ sessions?: unknown[] }>("/sessions");
async listSessions(listOptions) {
const params = new URLSearchParams();
if (listOptions?.purpose) params.set("purpose", listOptions.purpose);
if (listOptions?.taskId) params.set("task_id", listOptions.taskId);
const payload = await requestJson<{ sessions?: unknown[] }>(
`/sessions${params.size ? `?${params.toString()}` : ""}`
);
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
},
async createTaskCollaborationSession(taskId) {
return requestJson<AgentChatSession>(
`/task/${encodeURIComponent(taskId)}/collaboration/session`,
{ method: "POST" }
);
},
async getFrontendActionRegistry() {
return requestJson<unknown>("/frontend-action-registry");
},
@@ -432,6 +453,10 @@ function readRunStatus(value: unknown): AgentRunStatus | undefined {
: undefined;
}
function readSessionPurpose(value: unknown): AgentSessionPurpose {
return value === "task_execution" || value === "task_collaboration" ? value : "ordinary_chat";
}
function toSessionSummary(value: unknown): AgentChatSessionSummary | null {
if (!isRecord(value)) {
return null;
@@ -450,7 +475,14 @@ function toSessionSummary(value: unknown): AgentChatSessionSummary | null {
status: typeof value.status === "string" ? value.status : undefined,
parentSessionId: typeof value.parent_session_id === "string" ? value.parent_session_id : undefined,
isStreaming: typeof value.is_streaming === "boolean" ? value.is_streaming : undefined,
runStatus: readRunStatus(value.run_status)
runStatus: readRunStatus(value.run_status),
purpose: readSessionPurpose(value.purpose),
taskId: typeof value.task_id === "string" ? value.task_id : undefined,
trigger: isRecord(value.trigger) && value.trigger.workflow === "water_quality_complaint" &&
typeof value.trigger.subject_id === "string" && typeof value.trigger.occurred_at === "string" &&
Number.isFinite(Date.parse(value.trigger.occurred_at))
? { subjectId: value.trigger.subject_id, workflow: "water_quality_complaint", occurredAt: value.trigger.occurred_at }
: undefined
};
}
@@ -530,6 +562,7 @@ function emitSessionSseEvent(
const sessionId = typeof data.session_id === "string" ? data.session_id : "";
onEvent({
type: "state",
executionPlans: Array.isArray(data.execution_plans) ? data.execution_plans : undefined,
sessionId,
messages: Array.isArray(data.messages) ? data.messages : [],
isStreaming: data.is_streaming === true,
@@ -554,6 +587,7 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
value === "token" ||
value === "progress" ||
value === "todo_update" ||
value === "execution_plan" ||
value === "permission_request" ||
value === "permission_response" ||
value === "question_request" ||
+4 -2
View File
@@ -35,13 +35,15 @@ describe("Agent API SWR helpers", () => {
id: "session-1",
title: "会话",
createdAt: 1,
updatedAt: 2
updatedAt: 2,
purpose: "ordinary_chat" as const
}
];
const client = {
listSessions: vi.fn(async () => sessions)
} as Pick<AgentApiClient, "listSessions"> as AgentApiClient;
await expect(fetchAgentSessions(client)).resolves.toBe(sessions);
await expect(fetchAgentSessions(client)).resolves.toEqual(sessions);
expect(client.listSessions).toHaveBeenCalledWith({ purpose: "ordinary_chat" });
});
});
+3 -1
View File
@@ -32,5 +32,7 @@ export async function fetchAgentBootstrap(client: AgentApiClient): Promise<Agent
}
export async function fetchAgentSessions(client: AgentApiClient): Promise<AgentChatSessionSummary[]> {
return client.listSessions();
const sessions = await client.listSessions({ purpose: "ordinary_chat" });
// Keep the product boundary even while an older Agent service is rolling forward.
return sessions.filter((session) => session.purpose === "ordinary_chat");
}
@@ -108,6 +108,7 @@ type AgentCommandPanelProps = {
sessionHistory?: AgentChatSessionSummary[];
sessionHistoryLoading?: boolean;
activeSessionId?: string | null;
qaOnly?: boolean;
statusLabel?: string;
connectionStatus?: AgentConnectionStatus;
streaming?: boolean;
@@ -162,6 +163,7 @@ export function AgentCommandPanel({
sessionHistory = [],
sessionHistoryLoading = false,
activeSessionId,
qaOnly = false,
statusLabel = "Agent 后端连接中",
connectionStatus = "connecting",
streaming = false,
@@ -561,6 +563,7 @@ export function AgentCommandPanel({
onPauseSpeech={pauseSpeech}
onResumeSpeech={resumeSpeech}
onStopSpeech={stopSpeech}
qaOnly={qaOnly}
/>
) : (
<motion.div
@@ -581,7 +584,7 @@ export function AgentCommandPanel({
)}
</AnimatePresence>
<AgentUiEnvelopeRenderer results={uiResults} />
{!qaOnly ? <AgentUiEnvelopeRenderer results={uiResults} /> : null}
</ConversationContent>
<AgentConversationScrollManager
activeSessionId={activeSessionId}
@@ -653,11 +656,7 @@ export function AgentCommandPanel({
</PromptInputBody>
<PromptInputFooter className="justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<ApprovalModeSelect
value={approvalMode}
disabled={streaming}
onValueChange={onApprovalModeChange}
/>
{!qaOnly ? <ApprovalModeSelect value={approvalMode} disabled={streaming} onValueChange={onApprovalModeChange} /> : <span className="px-1 text-[11px] text-slate-500">任务协作问答</span>}
</div>
<div className="flex shrink-0 items-center gap-2">
<AgentModelSelect
@@ -1238,7 +1237,8 @@ function BackendMessageList({
onSpeak,
onPauseSpeech,
onResumeSpeech,
onStopSpeech
onStopSpeech,
qaOnly
}: {
messages: AgentChatMessage[];
streaming: boolean;
@@ -1256,6 +1256,7 @@ function BackendMessageList({
onPauseSpeech: () => void;
onResumeSpeech: () => void;
onStopSpeech: () => void;
qaOnly: boolean;
}) {
return (
<motion.div
@@ -1286,7 +1287,7 @@ function BackendMessageList({
)}
>
<div className="space-y-3">
{message.role === "assistant" ? (
{message.role === "assistant" && !qaOnly ? (
<AgentMessageProgress
progress={message.progress}
running={streaming && index === messages.length - 1}
@@ -1319,7 +1320,7 @@ function BackendMessageList({
) : streaming && index === messages.length - 1 ? (
<span className="text-slate-500">正在生成...</span>
) : null}
{message.role === "assistant" ? (
{message.role === "assistant" && !qaOnly ? (
<AgentMessageDetails
message={message}
onReplyPermission={onReplyPermission}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { executionPlanStages, parseExecutionPlan, readExecutionPlanHistory } from "./execution-plan";
const snapshot = {
schema_version: "execution-plan@1", session_id: "s1", revision: 1, updated_at: "2026-09-23T01:00:00.000Z",
title: "调查", reason: "开始",
nodes: [{ id: "a", title: '水质"] --> injected["<script>', status: "waiting", owner: "human", detail: "等待采样", evidence: [] }], edges: []
};
describe("execution plan projection", () => {
it("groups independent checks before their shared downstream decision", () => {
const plan = parseExecutionPlan({ ...snapshot,
nodes: ["a", "b", "c", "d"].map((id) => ({ ...snapshot.nodes[0], id })),
edges: [{ from: "a", to: "b", label: "" }, { from: "a", to: "c", label: "" }, { from: "b", to: "d", label: "" }, { from: "c", to: "d", label: "" }]
}, "s1")!;
expect(executionPlanStages(plan).map((stage) => stage.map((node) => node.id))).toEqual([["a"], ["b", "c"], ["d"]]);
});
it("rejects other sessions and dangling dependencies", () => {
expect(parseExecutionPlan(snapshot, "s2")).toBeNull();
expect(parseExecutionPlan({ ...snapshot, edges: [{ from: "a", to: "b", label: "" }] }, "s1")).toBeNull();
});
it("restores and deduplicates UI and active-run snapshots without completing waiting work", () => {
const history = readExecutionPlanHistory([{ parts: [{ type: "data-execution_plan", data: snapshot }] }, { executionPlans: [snapshot] }], "s1");
expect(history).toHaveLength(1);
expect(history[0].nodes[0].status).toBe("waiting");
});
it("rejects cyclic dependencies rather than guessing their order", () => {
expect(parseExecutionPlan({ ...snapshot, nodes: [snapshot.nodes[0], { ...snapshot.nodes[0], id: "b" }], edges: [{ from: "a", to: "b", label: "" }, { from: "b", to: "a", label: "" }] }, "s1")).toBeNull();
});
});
+57
View File
@@ -0,0 +1,57 @@
import { z } from "zod";
const id = z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/);
const schema = z.object({
schema_version: z.literal("execution-plan@1"),
session_id: z.string().min(1),
revision: z.number().int().positive(),
updated_at: z.string().datetime(),
title: z.string().min(1).max(120),
reason: z.string().min(1).max(1000),
nodes: z.array(z.object({
id, title: z.string().min(1).max(120),
status: z.enum(["pending", "running", "waiting", "completed", "failed", "skipped", "cancelled"]),
owner: z.enum(["agent", "human"]),
detail: z.string().max(2000), evidence: z.array(z.string().min(1).max(1000)).max(12)
})).min(1).max(40),
edges: z.array(z.object({ from: id, to: id, label: z.string().max(120) })).max(80)
}).refine((plan) => {
const ids = new Set(plan.nodes.map((node) => node.id));
return ids.size === plan.nodes.length && plan.edges.every((edge) => ids.has(edge.from) && ids.has(edge.to) && edge.from !== edge.to);
});
export type AgentExecutionPlan = z.infer<typeof schema>;
export function parseExecutionPlan(value: unknown, sessionId: string): AgentExecutionPlan | null {
const parsed = schema.safeParse(value);
return parsed.success && parsed.data.session_id === sessionId && executionPlanStages(parsed.data).length ? parsed.data : null;
}
export function readExecutionPlanHistory(messages: unknown[], sessionId: string) {
const plans = messages.flatMap((message) => {
const value = (message as { executionPlans?: unknown } | null)?.executionPlans;
const parts = (message as { parts?: unknown } | null)?.parts;
const data = Array.isArray(parts) ? parts.filter((part) => part?.type === "data-execution_plan").map((part) => part.data) : [];
return [...(Array.isArray(value) ? value : []), ...data];
}).map((value) => parseExecutionPlan(value, sessionId)).filter((plan): plan is AgentExecutionPlan => plan !== null);
return [...new Map(plans.map((plan) => [plan.revision, plan])).values()].sort((a, b) => a.revision - b.revision);
}
export const executionStatusLabels = {
pending: "待执行", running: "执行中", waiting: "等待资料/人工", completed: "已完成",
failed: "失败", skipped: "不适用", cancelled: "已取消"
};
/** Same-stage nodes have no dependency on one another; this describes eligibility, not a scheduler. */
export function executionPlanStages(plan: AgentExecutionPlan): AgentExecutionPlan["nodes"][] {
const remaining = new Set(plan.nodes.map((node) => node.id));
const stages: AgentExecutionPlan["nodes"][] = [];
while (remaining.size) {
const nodes = plan.nodes.filter((node) => remaining.has(node.id) &&
plan.edges.filter((edge) => edge.to === node.id).every((edge) => !remaining.has(edge.from)));
if (!nodes.length) return []; // Malformed cyclic history is never converted into a guessed sequence.
stages.push(nodes);
nodes.forEach((node) => remaining.delete(node.id));
}
return stages;
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { appendExecutionReportText, readExecutionReport, updateExecutionReportProgress } from "./execution-report";
describe("execution report", () => {
it("uses assistant output and public tool records without exposing reasoning or inventing completion", () => {
const report = readExecutionReport([
{ role: "user", content: "不应成为调查结论" },
{ id: "a", role: "assistant", parts: [
{ type: "text", text: "疑似二次供水问题,待现场核验。" },
{ type: "data-progress", data: { id: "private", phase: "planning", title: "推理内容", status: "running" } },
{ type: "data-progress", data: { id: "tool", phase: "tool", title: "核对记录", status: "running" } }
] }
]);
expect(report.responses).toEqual([{ id: "a", text: "疑似二次供水问题,待现场核验。" }]);
expect(report.activities).toHaveLength(1);
expect(report.activities[0].status).toBe("running");
expect(updateExecutionReportProgress(report, { id: "tool", phase: "tool", title: "查询失败", status: "error" }).activities[0].status).toBe("error");
});
it("joins resumed chunks to the active snapshot despite differing transport IDs", () => {
const report = readExecutionReport([{ id: "legacy", role: "assistant", content: "上游指标正常。" }]);
const next = appendExecutionReportText(report, { message_id: "ui-assistant", content: "等待现场采样。" });
expect(next.responses).toEqual([{ id: "ui-assistant", text: "上游指标正常。等待现场采样。" }]);
});
it("keeps a new empty assistant turn separate from the previous report", () => {
const report = readExecutionReport([{ id: "old", role: "assistant", content: "前序结论" }, { id: "current", role: "assistant", content: "" }]);
const next = appendExecutionReportText(report, { message_id: "current-ui", content: "最新进展" });
expect(next.responses.map((response) => response.text)).toEqual(["前序结论", "最新进展"]);
});
});
+49
View File
@@ -0,0 +1,49 @@
export type AgentExecutionReport = {
responses: { id: string; text: string }[];
activities: { id: string; title: string; detail?: string; status: "running" | "completed" | "error" }[];
};
const record = (value: unknown): Record<string, unknown> | null =>
value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
export function updateExecutionReportProgress(report: AgentExecutionReport, value: unknown): AgentExecutionReport {
const progress = record(value);
// The report contains public execution facts, not the model's reasoning stream.
if (!progress || !["tool", "start", "execution_plan"].includes(String(progress.phase)) ||
typeof progress.id !== "string" || typeof progress.title !== "string" ||
!["running", "completed", "error"].includes(String(progress.status))) return report;
const activity = {
id: progress.id, title: progress.title,
detail: typeof progress.detail === "string" ? progress.detail : undefined,
status: progress.status as "running" | "completed" | "error"
};
const existing = report.activities.findIndex((item) => item.id === activity.id);
return { ...report, activities: existing < 0 ? [...report.activities, activity].slice(-8) :
report.activities.map((item, index) => index === existing ? activity : item) };
}
export function readExecutionReport(messages: unknown[]): AgentExecutionReport {
let report: AgentExecutionReport = { responses: [], activities: [] };
messages.forEach((value, index) => {
const message = record(value);
if (!message || message.role !== "assistant") return;
const parts = Array.isArray(message.parts) ? message.parts.map(record).filter((part) => part !== null) : [];
const text = parts.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("") ||
(typeof message.content === "string" ? message.content : "");
report.responses.push({ id: typeof message.id === "string" ? message.id : `response-${index}`, text });
for (const progress of Array.isArray(message.progress) ? message.progress : []) report = updateExecutionReportProgress(report, progress);
for (const part of parts) if (part.type === "data-progress") report = updateExecutionReportProgress(report, part.data);
});
return { ...report, responses: report.responses.slice(-3) };
}
export function appendExecutionReportText(report: AgentExecutionReport, value: unknown): AgentExecutionReport {
const data = record(value);
if (!data || typeof data.content !== "string") return report;
const id = typeof data.message_id === "string" ? data.message_id : "current-response";
const matching = report.responses.findIndex((response) => response.id === id);
// Active-run snapshots and UI stream chunks currently use different assistant IDs.
const existing = matching < 0 ? report.responses.length - 1 : matching;
return { ...report, responses: existing < 0 ? [...report.responses, { id, text: data.content }].slice(-3) :
report.responses.map((response, index) => index === existing ? { ...response, id, text: response.text + data.content } : response) };
}
+3
View File
@@ -1,4 +1,6 @@
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
export { readExecutionReport, updateExecutionReportProgress, appendExecutionReportText, type AgentExecutionReport } from "./execution-report";
export { parseExecutionPlan, readExecutionPlanHistory, executionPlanStages, executionStatusLabels, type AgentExecutionPlan } from "./execution-plan";
export { AgentCommandPanel } from "./components/agent-command-panel";
export { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
export type {
@@ -11,6 +13,7 @@ export type {
AgentApiClient,
AgentApiClientOptions,
AgentChatSessionSummary,
AgentSessionPurpose,
AgentEventTriggerContext,
AgentLoadedChatSession,
AgentSessionStreamEvent