feat(workbench): redesign investigation reports and diagrams

This commit is contained in:
2026-10-10 16:02:47 +08:00
parent 85a9a3ab45
commit ba78a24384
27 changed files with 2380 additions and 187 deletions
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { toAgentChatMessages, toAgentUiMessages } from "./agent-session-message-state";
describe("task context in persisted answers", () => {
it("keeps the report reference of each answer when history is restored", () => {
const reference = {
task_id: "22222222-2222-4222-8222-222222222222",
execution_generation: 1,
report_revision: 3
};
const stored = [
{
id: "answer-1",
role: "assistant",
parts: [
{ type: "data-task_context", data: reference },
{ type: "text", text: "该判断仅覆盖投诉时段的上游记录。" }
]
}
];
const messages = toAgentChatMessages(toAgentUiMessages(stored), {}, {});
expect(messages[0]?.taskContext).toEqual(reference);
expect(messages[0]?.content).toBe("该判断仅覆盖投诉时段的上游记录。");
});
it("keeps the answer readable when a report reference is invalid", () => {
const messages = toAgentChatMessages(
toAgentUiMessages([
{
id: "answer-1",
role: "assistant",
parts: [
{
type: "data-task_context",
data: { task_id: "unknown", execution_generation: -1, report_revision: 0 }
},
{ type: "text", text: "报告引用未记录。" }
]
}
]),
{},
{}
);
expect(messages[0]?.taskContext).toBeUndefined();
expect(messages[0]?.content).toBe("报告引用未记录。");
});
});
@@ -2,6 +2,7 @@ import type { UIMessage } from "ai";
import type { Dispatch, SetStateAction } from "react";
import {
applyPermissionResponse,
agentTaskContextReferenceSchema,
applyQuestionResponse,
toTodoUpdate,
upsertPermission,
@@ -16,6 +17,7 @@ import {
} from "@/features/agent";
type AgentUiDataParts = {
task_context: Record<string, unknown>;
execution_plan: Record<string, unknown>;
progress: Record<string, unknown>;
todo_update: Record<string, unknown>;
@@ -123,7 +125,10 @@ export function toAgentChatMessages(
if (!isAgentDataPart(part)) {
continue;
}
if (part.type === "data-progress") {
if (part.type === "data-task_context") {
const parsed = agentTaskContextReferenceSchema.safeParse(part.data);
if (parsed.success) next = { ...next, taskContext: parsed.data };
} else if (part.type === "data-progress") {
next = {
...next,
progress: upsertProgress(next.progress, part.data)
@@ -11,6 +11,7 @@ import {
executeOperationalTaskAction,
fetchOperationalTask,
fetchOperationalTaskEvidenceSnapshot,
fetchOperationalTaskReports,
fetchOperationalTaskNotifications,
reconcileOperationalTaskAction,
type OperationalTask,
@@ -172,7 +173,10 @@ export function useOperationalTasks(
};
}, [acknowledgeEventsThrough, eventRetryGeneration, getAccessToken, syncEvents]);
const conditions = useMemo(() => tasks.map(toOperationalTaskCondition), [tasks]);
const conditions = useMemo(() => tasks.map((task) => ({ ...toOperationalTaskCondition(task),
hasNewReport: notifications.some((item) => item.task_id === task.task_id && item.kind === "report_updated"
&& item.report_reference != null && item.lifecycle_status === "active" && item.status === "unread"),
})), [tasks, notifications]);
const createInvestigation = useCallback(async (input: CreateInvestigationInput) => {
const task = await createOperationalTask({
@@ -297,6 +301,20 @@ export function useOperationalTasks(
const loadEvidenceSnapshot = useCallback((taskId: string, evidenceId: string) =>
fetchOperationalTaskEvidenceSnapshot(taskId, evidenceId, getAccessToken), [getAccessToken]);
const loadReports = useCallback((taskId: string, offset: number) => fetchOperationalTaskReports(taskId, offset, getAccessToken), [getAccessToken]);
const markingReportsRef = useRef(new Set<string>());
const markReportRead = useCallback((taskId: string, generation: number, revision: number) => {
const unread = notifications.filter((item) => item.task_id === taskId && item.kind === "report_updated"
&& item.lifecycle_status === "active" && item.status === "unread"
&& item.report_reference?.execution_generation === generation && item.report_reference.revision <= revision
&& !markingReportsRef.current.has(item.notification_id));
for (const item of unread) {
markingReportsRef.current.add(item.notification_id);
void acknowledgeNotification(item.notification_id, item.version)
.catch((error) => setError(error instanceof Error ? error.message : "新报告标记更新失败"))
.finally(() => markingReportsRef.current.delete(item.notification_id));
}
}, [notifications, acknowledgeNotification]);
return {
error: error ?? sync.error,
@@ -313,7 +331,9 @@ export function useOperationalTasks(
confirmWorkOrderDispatch,
decideAccess,
acknowledgeNotification,
loadEvidenceSnapshot
loadEvidenceSnapshot,
loadReports,
markReportRead
};
}
@@ -361,6 +381,8 @@ function toConditionStatus(task: OperationalTask): ScheduledConditionStatus {
function toRiskLevel(task: OperationalTask): ScheduledConditionRiskLevel {
const { priority, lifecycle_status: status } = task;
if (task.latest_report?.execution_generation === task.execution_generation
&& task.latest_report?.report.outcome === "insufficient_data") return "attention";
if (status === "failed" || priority === "urgent") return "critical";
if (status === "waiting_human") return "attention";
return "normal";
@@ -10,6 +10,8 @@ import { showMapNotice } from "@/features/map/core";
import { findOwnedWorkbenchProject } from "../api/project-client";
import {
FrontendActionExecutor,
agentTaskDiscussionContextSchema,
type AgentTaskDiscussionContext,
agentBootstrapKey,
agentSessionsKey,
createAgentApiClient,
@@ -90,6 +92,7 @@ export function useWorkbenchAgent({
const [sessionId, setSessionId] = useState<string | null>(null);
const [sessionPurpose, setSessionPurpose] = useState<AgentSessionPurpose>("ordinary_chat");
const [sessionTaskId, setSessionTaskId] = useState<string | null>(null);
const taskDiscussionRef = useRef<{ sessionId: string; context: AgentTaskDiscussionContext } | null>(null);
const [registry, setRegistry] = useState<UIRegistry | null>(null);
const [sessionTitle, setSessionTitle] = useState("新对话");
const [statusLabel, setStatusLabel] = useState("Agent 后端连接中");
@@ -231,7 +234,11 @@ export function useWorkbenchAgent({
api: `${env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")}/api/v1/agent/chat/stream`,
headers: async (): Promise<Record<string, string>> => {
const accessToken = await getAccessToken?.();
return accessToken ? { Authorization: `Bearer ${accessToken}` } : {};
const projectId = await getProjectId();
return {
...(projectId ? { "X-Project-Id": projectId } : {}),
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {})
};
},
prepareSendMessagesRequest({ id, messages, body, trigger, messageId }) {
return {
@@ -248,7 +255,7 @@ export function useWorkbenchAgent({
};
}
}),
[getAccessToken]
[getAccessToken, getProjectId]
);
const chat = useChat<AgentUiMessage>({
@@ -575,7 +582,7 @@ export function useWorkbenchAgent({
[applySessionId, chat, refreshSessionHistory, startSessionStreamSubscription, stopSessionStreamSubscription, streaming]
);
const openTaskCollaboration = useCallback(async (taskId: string) => {
const openTaskCollaboration = useCallback(async (taskId: string, context?: AgentTaskDiscussionContext) => {
const normalizedTaskId = taskId.trim();
if (!normalizedTaskId) {
throw new Error("工况任务 ID 不能为空。");
@@ -586,6 +593,7 @@ export function useWorkbenchAgent({
throw new Error("Agent 未返回任务协作会话。");
}
await loadHistorySession(collaborationSessionId);
taskDiscussionRef.current = { sessionId: collaborationSessionId, context: agentTaskDiscussionContextSchema.parse(context ?? {}) };
return collaborationSessionId;
}, [loadHistorySession]);
@@ -709,7 +717,8 @@ export function useWorkbenchAgent({
body: {
session_id: sessionIdRef.current ?? undefined,
model: selectedModel || undefined,
approval_mode: approvalMode
approval_mode: approvalMode,
task_context: taskDiscussionRef.current?.sessionId === sessionIdRef.current ? taskDiscussionRef.current.context : undefined
}
}
);