feat: expand intelligent investigation workspace
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent ddd03dbff6
commit c0e4231b83
223 changed files with 13155 additions and 5304 deletions
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { ChatTransport, UIMessageChunk } from "ai";
import type { AgentApiClient } from "@/features/agent";
import { AgentSessionStreamError, AgentStreamDisconnectedError, type AgentApiClient } from "@/features/agent";
import { WorkbenchSessionResources } from "./workbench-session-resources";
import { createAgentRuntime, mergeHistoryMessages, resumeAgentRuntime, runtimeRunning } from "./agent-session-runtime";
import type { AgentUiMessage } from "./agent-session-message-state";
@@ -8,6 +8,81 @@ import type { AgentUiMessage } from "./agent-session-message-state";
const message = (id: string, text = id): AgentUiMessage => ({ id, role: "assistant", parts: [{ type: "text", text }] });
describe("independent conversation runtimes", () => {
it("retries a disconnected subscription and preserves the draft, pages and reading position", async () => {
vi.useFakeTimers();
const resources = new WorkbenchSessionResources();
const streamSession = vi.fn(async (_id, options) => {
const complete = streamSession.mock.calls.length > 1;
options.onEvent({ type: "state", sessionId: "a", isStreaming: !complete,
messages: [message("b", complete ? "recovered" : "partial")], runStatus: complete ? "completed" : "running" });
});
const transport = { sendMessages: vi.fn() } as unknown as ChatTransport<AgentUiMessage>;
const runtime = createAgentRuntime(resources, "project:a", { streamSession } as unknown as AgentApiClient, transport, {
id: "a", sessionId: "a", title: "核查", createdAt: 1, updatedAt: 1, purpose: "ordinary_chat", isTitleManuallyEdited: false, messages: [message("older"), message("b")]
});
resources.activeAgent = runtime;
runtime.draft = "unsent";
runtime.readingPosition = { messageId: "older", offset: 12, atBottom: false };
try {
resumeAgentRuntime(resources, runtime);
await vi.advanceTimersByTimeAsync(0);
expect(streamSession).toHaveBeenCalledTimes(1);
expect(runtimeRunning(runtime)).toBe(true);
await vi.advanceTimersByTimeAsync(1_000);
expect(streamSession).toHaveBeenCalledTimes(2);
expect(runtime.chat.messages).toEqual([message("older"), message("b", "recovered")]);
expect(runtime.draft).toBe("unsent");
expect(runtime.readingPosition).toEqual({ messageId: "older", offset: 12, atBottom: false });
expect(transport.sendMessages).not.toHaveBeenCalled();
expect(runtime.controller).toBeUndefined();
} finally { resources.dispose(); vi.useRealTimers(); }
});
it("stops retrying after permission loss or resource disposal", async () => {
vi.useFakeTimers();
for (const forbidden of [true, false]) {
const resources = new WorkbenchSessionResources();
const streamSession = vi.fn(async () => { throw forbidden ? new AgentSessionStreamError("forbidden", 403) : new TypeError("offline"); });
const runtime = createAgentRuntime(resources, "project:a", { streamSession } as unknown as AgentApiClient, {} as ChatTransport<AgentUiMessage>);
runtime.sessionId = "a";
resources.activeAgent = runtime;
try {
resumeAgentRuntime(resources, runtime);
await vi.advanceTimersByTimeAsync(0);
if (forbidden) expect(runtime.controller).toBeUndefined();
resources.dispose();
await vi.advanceTimersByTimeAsync(30_000);
expect(streamSession).toHaveBeenCalledTimes(1);
} finally { resources.dispose(); }
}
vi.useRealTimers();
});
it("recovers an interrupted Responses stream through a GET subscription without resending the prompt", async () => {
const resources = new WorkbenchSessionResources();
let controller!: ReadableStreamDefaultController<UIMessageChunk>;
const sendMessages = vi.fn(async () => new ReadableStream<UIMessageChunk>({ start(value) { controller = value; } }));
const streamSession = vi.fn(async (_id, options) => options.onEvent({ type: "state", sessionId: "a", isStreaming: false,
runStatus: "completed", messages: [message("answer", "server completed")] }));
const runtime = createAgentRuntime(resources, "project:a", { streamSession } as unknown as AgentApiClient,
{ sendMessages, reconnectToStream: async () => null });
runtime.sessionId = "a";
resources.activeAgent = runtime;
try {
const request = runtime.chat.sendMessage({ text: "核查压力" });
await vi.waitFor(() => expect(controller).toBeDefined());
controller.enqueue({ type: "start", messageId: "answer" });
controller.enqueue({ type: "text-start", id: "text" });
controller.enqueue({ type: "text-delta", id: "text", delta: "partial" });
await vi.waitFor(() => expect(runtime.chat.messages.at(-1)?.parts).toContainEqual(expect.objectContaining({ text: "partial" })));
controller.error(new AgentStreamDisconnectedError("connection dropped"));
await request;
await vi.waitFor(() => expect(streamSession).toHaveBeenCalledTimes(1));
expect(sendMessages).toHaveBeenCalledTimes(1);
expect(runtime.chat.messages.at(-1)).toEqual(message("answer", "server completed"));
expect(runtime.error).toBeUndefined();
} finally { resources.dispose(); }
});
it("keeps the original response streaming after selecting another conversation", async () => {
const resources = new WorkbenchSessionResources();
let controller!: ReadableStreamDefaultController<UIMessageChunk>;
@@ -54,8 +129,7 @@ describe("independent conversation runtimes", () => {
streamSession: vi.fn(async (_id, options) => {
options.onEvent({ type: "state", sessionId: "a", messages: [message("b", "new"), message("c")], isStreaming: true });
options.onEvent({ type: "frontend_action", sessionId: "a", data: {
version: "frontend-action@1", sessionId: "a", actionId: "action-a", toolCallId: "call-a", name: "locate_features",
params: {}, issuedAt: Date.now(), expiresAt: Date.now() + 15_000,
version: "frontend-action@2", sessionId: "a", actionId: "action-a", toolCallId: "call-a", command: { action: "locate_features", layer: "scada", feature_ids: ["D1"] }, issuedAt: Date.now(), expiresAt: Date.now() + 15_000,
} });
options.onEvent({ type: "done", sessionId: "a", data: {} });
}),
@@ -74,3 +148,24 @@ describe("independent conversation runtimes", () => {
resources.dispose();
});
});
it.each([
[JSON.stringify({ code: "INTERACTION_PENDING", message: "请先回答或取消当前提问。" }), "waiting_input"],
["Connection refused", "error"],
[JSON.stringify({ code: "OTHER_FAILURE" }), "error"]
] as const)("keeps a pending question distinct from transport failure: %s", async (message, expected) => {
const resources = new WorkbenchSessionResources();
const transport: ChatTransport<AgentUiMessage> = {
sendMessages: async () => { throw new Error(message); },
reconnectToStream: async () => null
};
const runtime = createAgentRuntime(resources, "project:a", {} as AgentApiClient, transport);
resources.activeAgent = runtime;
const onError = vi.fn();
runtime.handlers = { onData: vi.fn(), onEvent: vi.fn(), onError, onFinish: vi.fn() };
try {
await runtime.chat.sendMessage({ text: "定位到公司上报地址" });
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message }));
expect(runtime.runStatus).toBe(expected);
} finally { resources.dispose(); }
});
@@ -157,7 +157,7 @@ async function rejectBackgroundAction(resources: WorkbenchSessionResources, runt
if (!resources.disposed) showMapNotice({ id: `agent-background-display-${runtime.key}`, tone: "info",
title: "后台对话需要展示结果", message: `回到“${runtime.title}”后重新请求地图或工作区展示。` });
await runtime.client.submitFrontendActionResult(request.sessionId, request.actionId, {
version: "frontend-action-result@1", actionId: request.actionId, status: "failed",
version: "frontend-action-result@2", actionId: request.actionId, status: "failed",
error: { code: "SESSION_NOT_ACTIVE", message: "返回对应会话后重新请求地图或工作区操作" }, completedAt: Date.now(),
}).catch(() => { runtime.error = "后台展示操作回执失败"; });
}
@@ -158,3 +158,44 @@ describe("live investigation presentation following", () => {
} finally { oldDisplay.resolve(); test.follower.stop(); }
});
});
it("buffers live events while saved history is loading and drains only after that boundary", async () => {
const ready = deferred<void>();
const loadHistory = vi.fn(async () => ready.promise);
const test = setup({ loadHistory });
test.follower.start(); test.channel.publish({ type: "open" });
try {
await vi.waitFor(() => expect(loadHistory).toHaveBeenCalledTimes(1));
test.channel.publish({ type: "events", events: [event(11, openChart)] });
expect(test.execute).not.toHaveBeenCalled();
ready.resolve();
await vi.waitFor(() => expect(test.execute).toHaveBeenCalledTimes(1));
} finally { test.follower.stop(); }
});
it("loads saved history without live following and aborts an unfinished history load on stop", async () => {
const ready = deferred<void>();
const loadHistory = vi.fn(async (_cursor: InvestigationPresentationCursor, _signal: AbortSignal) => ready.promise);
const subscribe = vi.fn();
const test = setup({ follow: false, loadHistory, subscribe });
test.follower.start();
await vi.waitFor(() => expect(loadHistory).toHaveBeenCalledTimes(1));
expect(subscribe).not.toHaveBeenCalled();
test.follower.stop();
expect(loadHistory.mock.calls[0][1].aborted).toBe(true);
ready.resolve();
expect(test.execute).not.toHaveBeenCalled();
});
it("reads saved history even before the live event stream opens", async () => {
const loadHistory = vi.fn(async () => undefined);
const test = setup({ loadHistory });
test.follower.start();
try {
await vi.waitFor(() => expect(loadHistory).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(test.onStatus).toHaveBeenCalledWith("history"));
expect(test.execute).not.toHaveBeenCalled();
} finally { test.follower.stop(); }
});
@@ -7,22 +7,35 @@ import {
import type { WorkbenchEvent } from "../api/workbench-sync-client";
import type { WorkbenchStreamNotification } from "./workbench-event-channel";
export type InvestigationIdentity = Pick<InvestigationPresentationCursor, "task_id" | "execution_generation" | "active_run">;
export type InvestigationIdentity = Pick<
InvestigationPresentationCursor,
"task_id" | "execution_generation" | "active_run"
>;
type Options = {
taskId: string;
subscribe: (listener: (notification: WorkbenchStreamNotification) => void) => () => void;
fetchCursor: (signal: AbortSignal) => Promise<InvestigationPresentationCursor>;
fetchIdentity: (signal: AbortSignal) => Promise<InvestigationIdentity>;
execute: (command: InvestigationPresentationCommand, signal: AbortSignal) => Promise<void>;
execute: (
command: InvestigationPresentationCommand,
signal: AbortSignal,
event: InvestigationPresentationEvent & { event_seq: number }
) => Promise<void>;
loadHistory?: (cursor: InvestigationPresentationCursor, signal: AbortSignal) => Promise<void>;
follow?: boolean;
clear: () => void;
stopPulse: () => void;
onStatus: (status: "connecting" | "live" | "disconnected" | "error", error?: string) => void;
onStatus: (
status: "connecting" | "live" | "history" | "disconnected" | "error",
error?: string
) => void;
};
type Pending = { sequence: number; event: InvestigationPresentationEvent };
/** No replay: a fresh authorized baseline is taken after every connection, with the listener already attached. */
/** Bind a read-only history snapshot before draining later live events; subscribing first prevents gaps. */
export class InvestigationPresentationFollower {
private unsubscribe?: () => void;
private streamConnected = false;
private binding: AbortController | null = null;
private work = new AbortController();
private identity: InvestigationIdentity | null = null;
@@ -35,39 +48,72 @@ export class InvestigationPresentationFollower {
constructor(private options: Options) {}
start() { this.unsubscribe = this.options.subscribe((notification) => this.receive(notification)); }
start() {
if (this.options.follow === false) void this.connect();
else {
this.unsubscribe = this.options.subscribe((notification) => this.receive(notification));
// Saved results remain readable when the SSE connection is not available.
if (this.options.loadHistory && !this.binding) void this.connect();
}
}
stop() {
this.streamConnected = false;
this.unsubscribe?.();
this.unsubscribe = undefined;
this.disconnect();
}
discardPendingMapCommands() {
this.pending = this.pending.filter(({ event }) =>
event.command.action === "open_chart" || event.command.action === "close_chart"
this.pending = this.pending.filter(
({ event }) => event.command.action === "open_chart" || event.command.action === "close_chart"
);
}
updateIdentity(identity: InvestigationIdentity) {
if (identity.task_id !== this.options.taskId || this.identity && identity.execution_generation < this.identity.execution_generation) return;
const changed = this.identity && (identity.execution_generation !== this.identity.execution_generation
|| identity.active_run && this.identity.active_run && identity.active_run.run_id !== this.identity.active_run.run_id);
if (
identity.task_id !== this.options.taskId ||
(this.identity && identity.execution_generation < this.identity.execution_generation)
)
return;
const changed =
this.identity &&
(identity.execution_generation !== this.identity.execution_generation ||
(identity.active_run &&
this.identity.active_run &&
identity.active_run.run_id !== this.identity.active_run.run_id));
if (changed) {
this.work.abort();
this.work = new AbortController();
this.options.clear();
}
// A finished run may no longer be active; retain its identity to finish already received close commands.
this.identity = identity.active_run || !this.identity || identity.execution_generation !== this.identity.execution_generation
? identity : { ...identity, active_run: this.identity.active_run ? { ...this.identity.active_run, status: "succeeded" } : null };
this.identity =
identity.active_run ||
!this.identity ||
identity.execution_generation !== this.identity.execution_generation
? identity
: {
...identity,
active_run: this.identity.active_run
? { ...this.identity.active_run, status: "succeeded" }
: null
};
if (!identity.active_run || identity.active_run.status !== "running") this.options.stopPulse();
void this.drain();
}
private receive(notification: WorkbenchStreamNotification) {
if (notification.type === "open") { void this.connect(); return; }
if (notification.type !== "events") { this.disconnect(); return; }
if (notification.type === "open") {
this.streamConnected = true;
void this.connect();
return;
}
if (notification.type !== "events") {
this.streamConnected = false;
this.disconnect();
return;
}
if (!this.binding) return;
for (const event of notification.events) this.add(event);
void this.drain();
@@ -75,11 +121,27 @@ export class InvestigationPresentationFollower {
private add(event: WorkbenchEvent) {
if (!this.binding) return;
if (event.topic !== "operational.presentation.requested" || event.metadata.task_id !== this.options.taskId) return;
if (event.event_seq <= (this.baseline ?? -1) || event.event_seq <= this.lastSeen || this.pending.some((item) => item.sequence === event.event_seq)) return;
if (
event.topic !== "operational.presentation.requested" ||
event.metadata.task_id !== this.options.taskId
)
return;
if (
event.event_seq <= (this.baseline ?? -1) ||
event.event_seq <= this.lastSeen ||
this.pending.some((item) => item.sequence === event.event_seq)
)
return;
const parsed = investigationPresentationEventSchema.safeParse(event.metadata);
if (!parsed.success) { this.options.onStatus("error", "调查展示参数未通过校验"); return; }
if (this.pending.length >= 1000) { this.disconnect(); this.options.onStatus("error", "调查展示积压,请关闭并重新开启跟随"); return; }
if (!parsed.success) {
this.options.onStatus("error", "调查展示参数未通过校验");
return;
}
if (this.pending.length >= 1000) {
this.disconnect();
this.options.onStatus("error", "调查展示积压,请关闭并重新开启跟随");
return;
}
this.pending.push({ sequence: event.event_seq, event: parsed.data });
this.pending.sort((a, b) => a.sequence - b.sequence);
}
@@ -97,11 +159,18 @@ export class InvestigationPresentationFollower {
this.baseline = cursor.event_cursor;
this.lastSeen = cursor.event_cursor;
this.pending = this.pending.filter((item) => item.sequence > cursor.event_cursor);
await this.options.loadHistory?.(cursor, controller.signal);
if (controller.signal.aborted || epoch !== this.epoch) return;
this.updateIdentity(cursor);
this.options.onStatus("live");
this.options.onStatus(
this.streamConnected && this.options.follow !== false ? "live" : "history"
);
void this.drain();
} catch (error) {
if (!controller.signal.aborted) { this.disconnect(); this.options.onStatus("error", error instanceof Error ? error.message : "调查展示连接失败"); }
if (!controller.signal.aborted) {
this.disconnect();
this.options.onStatus("error", error instanceof Error ? error.message : "调查展示连接失败");
}
}
}
@@ -129,7 +198,11 @@ export class InvestigationPresentationFollower {
const identity = await this.options.fetchIdentity(binding.signal);
if (!binding.signal.aborted) this.updateIdentity(identity);
} catch (error) {
if (!binding.signal.aborted) this.options.onStatus("error", error instanceof Error ? error.message : "调查运行状态核验失败");
if (!binding.signal.aborted)
this.options.onStatus(
"error",
error instanceof Error ? error.message : "调查运行状态核验失败"
);
}
}
@@ -140,24 +213,41 @@ export class InvestigationPresentationFollower {
try {
while (this.pending.length && epoch === this.epoch) {
const next = this.pending[0];
if (next.sequence <= this.lastSeen || next.event.execution_generation < this.identity!.execution_generation) { this.pending.shift(); continue; }
if (next.event.execution_generation !== this.identity!.execution_generation || next.event.run_id !== this.identity!.active_run?.run_id) {
if (
next.sequence <= this.lastSeen ||
next.event.execution_generation < this.identity!.execution_generation
) {
this.pending.shift();
continue;
}
if (
next.event.execution_generation !== this.identity!.execution_generation ||
next.event.run_id !== this.identity!.active_run?.run_id
) {
await this.resolveIdentity(next.event);
if (epoch !== this.epoch) break;
if (this.pending[0] !== next) continue;
if (next.event.execution_generation !== this.identity!.execution_generation || next.event.run_id !== this.identity!.active_run?.run_id) break;
if (
next.event.execution_generation !== this.identity!.execution_generation ||
next.event.run_id !== this.identity!.active_run?.run_id
)
break;
}
this.pending.shift();
this.lastSeen = next.sequence;
const signal = this.work.signal;
try {
const command = next.event.command.action === "highlight_features" && this.identity!.active_run?.status !== "running"
? { ...next.event.command, effect: "steady" as const } : next.event.command;
await this.options.execute(command, signal);
const command =
next.event.command.action === "highlight_features" &&
this.identity!.active_run?.status !== "running"
? { ...next.event.command, effect: "steady" as const }
: next.event.command;
await this.options.execute(command, signal, { ...next.event, event_seq: next.sequence });
if (!signal.aborted && epoch === this.epoch) this.options.onStatus("live");
if (this.identity!.active_run?.status !== "running") this.options.stopPulse();
} catch (error) {
if (!signal.aborted && epoch === this.epoch) this.options.onStatus("error", error instanceof Error ? error.message : "调查展示失败");
if (!signal.aborted && epoch === this.epoch)
this.options.onStatus("error", error instanceof Error ? error.message : "调查展示失败");
}
}
} finally {
@@ -28,7 +28,67 @@ describe("recent activities for Agent suggestions", () => {
describe("recommendation cache lifecycle", () => {
let root: Root | undefined;
afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; vi.restoreAllMocks(); });
afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; vi.useRealTimers(); vi.restoreAllMocks(); });
it("retries failures after cache expiry with a bounded budget and supports manual retry", async () => {
vi.useFakeTimers();
root = createRoot(document.createElement("div"));
const request = vi.fn<Parameters<typeof useAgentPromptSuggestions>[0]["request"]>().mockRejectedValue(new Error("unavailable"));
const empty: never[] = [];
let result: ReturnType<typeof useAgentPromptSuggestions>;
function Probe() {
result = useAgentPromptSuggestions({ enabled: true, model: "fixture/model", cacheScope: "a", sessionId: null,
request, alerts: empty, alertDataSource: "backend", conditions: empty, runtimeDataSource: "backend", artifact: null, sessions: empty });
return null;
}
await act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe))));
expect(result!.unavailable).toBe(true);
expect(request).toHaveBeenCalledTimes(1);
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(request).toHaveBeenCalledTimes(2);
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
expect(request).toHaveBeenCalledTimes(3);
await act(async () => { await vi.advanceTimersByTimeAsync(300_000); });
expect(request).toHaveBeenCalledTimes(3);
request.mockResolvedValueOnce([{ question: "核查压力数据", activityId: null }]);
await act(async () => result!.retry());
expect(request).toHaveBeenCalledTimes(4);
expect(result!.suggestions[0]?.question).toBe("核查压力数据");
expect(result!.unavailable).toBe(false);
});
it("retries cached failures on service recovery while preserving successful recommendations", async () => {
root = createRoot(document.createElement("div"));
const request = vi.fn<Parameters<typeof useAgentPromptSuggestions>[0]["request"]>()
.mockRejectedValueOnce(new Error("offline")).mockResolvedValue([{ question: "核查压力数据", activityId: null }]);
const empty: never[] = [];
function Probe({ recoveryVersion }: { recoveryVersion: number }) {
useAgentPromptSuggestions({ enabled: true, model: "fixture/model", cacheScope: "a", recoveryVersion, sessionId: null,
request, alerts: empty, alertDataSource: "backend", conditions: empty, runtimeDataSource: "backend", artifact: null, sessions: empty });
return null;
}
for (const recoveryVersion of [0, 1, 2]) {
await act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe, { recoveryVersion }))));
}
expect(request).toHaveBeenCalledTimes(2);
});
it("waits for a model and cancels automatic retries when the panel is disabled", async () => {
vi.useFakeTimers();
root = createRoot(document.createElement("div"));
const request = vi.fn(async () => { throw new Error("unavailable"); });
const empty: never[] = [];
function Probe({ model, enabled }: { model: string; enabled: boolean }) {
useAgentPromptSuggestions({ enabled, model, cacheScope: "a", sessionId: null,
request, alerts: empty, alertDataSource: "backend", conditions: empty, runtimeDataSource: "backend", artifact: null, sessions: empty });
return null;
}
const render = async (model: string, enabled: boolean) => act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe, { model, enabled }))));
await render("", true);
expect(request).not.toHaveBeenCalled();
await render("fixture/model", true);
expect(request).toHaveBeenCalledTimes(1);
await render("fixture/model", false);
await act(async () => { await vi.advanceTimersByTimeAsync(90_000); });
expect(request).toHaveBeenCalledTimes(1);
});
it("reuses recommendations across history refresh, panel reopen and a new empty conversation", async () => {
const container = document.createElement("div");
root = createRoot(container);
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AgentApiClient, AgentChatSessionSummary, AgentPromptSuggestion, AgentRecentActivity } from "@/features/agent";
import type { WorkbenchAlert, ScheduledConditionItem, WorkbenchAlertDataSource, WorkbenchRuntimeDataSource } from "../types";
import type { AnalysisArtifact } from "../workspace/workspace-model";
@@ -46,11 +46,12 @@ export function collectRecentAgentActivities(input: ActivityInputs, now = Date.n
return [...new Map([...alerts, ...tasks, ...analyses, ...sessions].map((item) => [item.id, item])).values()];
}
export function useAgentPromptSuggestions({ enabled, model, cacheScope = "project", request, alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions }: ActivityInputs & {
export function useAgentPromptSuggestions({ enabled, model, cacheScope = "project", recoveryVersion = 0, request, alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions }: ActivityInputs & {
enabled: boolean;
model: string;
sessionId: string | null;
cacheScope?: string;
recoveryVersion?: number;
request: AgentApiClient["generatePromptSuggestions"];
}) {
const { cache } = useWorkbenchSessionResources();
@@ -64,13 +65,22 @@ export function useAgentPromptSuggestions({ enabled, model, cacheScope = "projec
const activitySnapshot = useRef(activities);
activitySnapshot.current = activities;
const key = JSON.stringify(["suggestions", cacheScope, model, digest]);
const [retryVersion, setRetryVersion] = useState(0);
const [refreshVersion, setRefreshVersion] = useState(0);
const attempts = useRef({ key, count: 0, recoveryVersion, retryVersion });
const retry = useCallback(() => setRetryVersion((version) => version + 1), []);
const [state, setState] = useState<{ scope: string; suggestions: DisplaySuggestion[]; loading: boolean; unavailable: boolean }>({
scope: cacheScope, suggestions: [], loading: false, unavailable: false,
});
useEffect(() => {
if (!enabled) return;
if (!enabled || !model.trim()) return;
let disposed = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const previous = cache.peek<DisplaySuggestion[]>(key);
const recovered = attempts.current.recoveryVersion !== recoveryVersion || attempts.current.retryVersion !== retryVersion;
if (attempts.current.key !== key || recovered) attempts.current = { key, count: 0, recoveryVersion, retryVersion };
const attempt = attempts.current;
const context = activitySnapshot.current;
const apply = (suggestions: DisplaySuggestion[]) => {
if (!disposed) setState({ scope: cacheScope, suggestions, loading: false, unavailable: false });
};
@@ -78,7 +88,7 @@ export function useAgentPromptSuggestions({ enabled, model, cacheScope = "projec
else setState((current) => ({ scope: cacheScope,
suggestions: current.scope === cacheScope ? current.suggestions : [], loading: true, unavailable: false }));
void cache.load(key, async () => {
const context = activitySnapshot.current;
attempt.count++;
const suggestions = await request(context, model, AbortSignal.timeout(60_000));
const sourceById = new Map(context.map((item) => [item.id, item]));
return suggestions.map((item) => {
@@ -87,11 +97,17 @@ export function useAgentPromptSuggestions({ enabled, model, cacheScope = "projec
? `${source.origin === "demo" ? "演示" : ""}${activityLabels[source.kind]} · ${timeFormatter.format(new Date(source.occurred_at))}`
: "供水任务建议" };
});
}, 10 * 60_000, { errorTtl: 30_000 }).then(apply).catch(() => {
if (!disposed) setState((current) => ({ scope: cacheScope,
}, 10 * 60_000, { errorTtl: 30_000, force: recovered && !!previous?.error }).then(apply).catch(() => {
if (disposed) return;
setState((current) => ({ scope: cacheScope,
suggestions: current.scope === cacheScope ? current.suggestions : [], loading: false, unavailable: true }));
// Cache expiry alone does not rerender the panel. Retry transient failures
// here, with a bounded budget; reopening still reuses the shared cache.
if (attempt.count < 3) {
timer = setTimeout(() => setRefreshVersion((version) => version + 1), Math.max(1, (cache.peek(key)?.expiresAt ?? Date.now() + 30_000) - Date.now()));
}
});
return () => { disposed = true; };
}, [cache, cacheScope, digest, enabled, key, model, request]);
return state.scope === cacheScope ? state : { suggestions: [], loading: enabled, unavailable: false };
return () => { disposed = true; clearTimeout(timer); };
}, [cache, cacheScope, digest, enabled, key, model, recoveryVersion, refreshVersion, request, retryVersion]);
return { ...(state.scope === cacheScope ? state : { suggestions: [], loading: enabled, unavailable: false }), retry };
}
@@ -1,40 +1,110 @@
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { executePresentationCommand } from "../map/presentation-command-executor";
import type { FeatureCollection } from "geojson";
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
import type { WorkbenchAccess } from "../api/request-context";
import { fetchOperationalTask, fetchOperationalTaskPresentationCursor, type OperationalTask } from "../api/operational-task-client";
import {
fetchOperationalTask,
fetchOperationalTaskPresentationCursor,
fetchOperationalTaskPresentationHistory,
type OperationalTask
} from "../api/operational-task-client";
import type { InvestigationMapTarget } from "../api/investigation-report";
import type { InvestigationOverlay, InvestigationPresentationCommand } from "../api/investigation-presentation";
import {
presentationEventKey,
type InvestigationPresentationEvent
} from "../api/investigation-presentation";
import { hydrateInvestigationPresentationArchive } from "../utils/investigation-presentation-archive";
import { presentationCommandLabel } from "../utils/presentation-command-label";
import type {
InvestigationPresentationCommand
} from "../api/investigation-presentation";
import { InvestigationMapPresentation } from "../map/investigation-map-presentation";
import type { WorkbenchMapController } from "../map/workbench-map-controller";
import type { WorkbenchSyncState } from "./use-workbench-sync";
import { InvestigationPresentationFollower } from "./investigation-presentation-follower";
import {
type InvestigationDevPreview,
type InvestigationPresentationActivity
} from "../utils/investigation-dev-preview";
import {
PresentationViewHistory,
emptyPresentationView,
type PresentationView
} from "../utils/presentation-view-history";
import {
capturePresentationCamera,
fitPresentationCamera,
presentationHistoryPorts,
renderPresentationSnapshot
} from "../map/presentation-history-ports";
export type InvestigationChart = Extract<InvestigationPresentationCommand, { action: "open_chart" }>;
import {
createInvestigationDevCase,
type InvestigationDevCaseId
} from "../utils/investigation-dev-cases";
export type InvestigationChart = Extract<
InvestigationPresentationCommand,
{ action: "open_chart" }
>;
type Options = {
enabled: boolean; task: OperationalTask | null; projectId: string | null;
sync: WorkbenchSyncState; getAccessToken?: WorkbenchAccess;
mapRef: RefObject<MapLibreMap | null>; mapReady: boolean;
controller: WorkbenchMapController; getPadding: () => PaddingOptions;
scope?: "investigation" | "chat" | "preview";
scopeId?: string | null;
enabled: boolean;
task: OperationalTask | null;
projectId: string | null;
sync: WorkbenchSyncState;
getAccessToken?: WorkbenchAccess;
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
controller: WorkbenchMapController;
getPadding: () => PaddingOptions;
onClearLayers: () => void;
};
function frame(signal: AbortSignal) {
return new Promise<void>((resolve, reject) => {
signal.throwIfAborted();
const abort = () => { cancelAnimationFrame(id); reject(signal.reason); };
const id = requestAnimationFrame(() => { signal.removeEventListener("abort", abort); resolve(); });
const abort = () => {
cancelAnimationFrame(id);
reject(signal.reason);
};
const id = requestAnimationFrame(() => {
signal.removeEventListener("abort", abort);
resolve();
});
signal.addEventListener("abort", abort, { once: true });
});
}
/** Separate commands need a rendered frame; an open and close in one SSE batch must not collapse into one React update. */
async function waitForChart(chartId: string, signal: AbortSignal) {
async function waitForChart(
chartId: string,
signal: AbortSignal,
scope: string,
rendering = () => true
) {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
await frame(signal);
const element = [...document.querySelectorAll<HTMLElement>("[data-investigation-chart]")].find((item) => item.dataset.investigationChart === chartId);
if (!rendering()) return;
const panel = document.querySelector<HTMLElement>(`[data-presentation-scope="${scope}"]`);
// A deliberate collapse defers rendering, rather than failing a valid command.
if (panel?.dataset.investigationCollapsed === "true") return;
const element = [
...(panel?.querySelectorAll<HTMLElement>("[data-investigation-chart]") ?? [])
].find(
(item) => item.dataset.investigationChart === chartId && !item.closest('[aria-hidden="true"]')
);
if (element && element.getBoundingClientRect().height > 0) {
const viewport = panel?.querySelector<HTMLElement>("[data-investigation-content]");
if (viewport) {
const plot = element.querySelector("svg[role=img]") ?? element;
const overflow =
plot.getBoundingClientRect().bottom - viewport.getBoundingClientRect().bottom + 12;
if (overflow > 0) viewport.scrollTop += overflow;
}
const visibleUntil = Date.now() + 500;
while (Date.now() < visibleUntil) await frame(signal);
return;
@@ -46,123 +116,484 @@ async function waitForChart(chartId: string, signal: AbortSignal) {
export function useInvestigationPresentation(options: Options) {
const valuesRef = useRef(options);
valuesRef.current = options;
const [charts, setCharts] = useState<InvestigationChart[]>([]);
const [overlay, setOverlay] = useState<InvestigationOverlay | null>(null);
const storeRef = useRef(new PresentationViewHistory());
const [, refresh] = useState(0);
const publish = useCallback(() => refresh((value) => value + 1), []);
const [localPreview, setLocalPreview] = useState(false);
const localPreviewRef = useRef(false);
const [cameraPaused, setCameraPaused] = useState(false);
const cameraPausedRef = useRef(false);
const [status, setStatus] = useState<"connecting" | "live" | "disconnected" | "error">("disconnected");
const [status, setStatus] = useState<
"connecting" | "live" | "history" | "disconnected" | "error"
>("disconnected");
const [error, setError] = useState<string | null>(null);
const [highlightLayers, setHighlightLayers] = useState<string[]>([]);
const [activity, setActivity] = useState<InvestigationPresentationActivity[]>([]);
const activityIdRef = useRef(0);
const previewRef = useRef<AbortController | null>(null);
const followerRef = useRef<InvestigationPresentationFollower | null>(null);
const manualRequestRef = useRef<AbortController | null>(null);
const mapCommandsRef = useRef(new AbortController());
const scene = useMemo(() => new InvestigationMapPresentation(() => options.mapRef.current, () => valuesRef.current.getPadding()), [options.mapRef]);
const scene = useMemo(
() =>
new InvestigationMapPresentation(
() => options.mapRef.current,
() => valuesRef.current.getPadding(),
options.scope
),
[options.mapRef, options.scope]
);
const taskId = options.task?.task_id ?? null;
const projectRef = useRef(options.projectId);
const subscribe = options.sync.subscribePresentationEvents;
const source = useCallback(
(): InvestigationPresentationActivity["source"] =>
valuesRef.current.scope === "chat"
? "chat"
: valuesRef.current.scope === "preview"
? "preview"
: "live",
[]
);
const update = useCallback(
(updater: (view: PresentationView) => PresentationView) => {
storeRef.current.update(updater(storeRef.current.current));
publish();
},
[publish]
);
const record = useCallback(
(label: string, origin = source()) => {
storeRef.current.record(label, origin, valuesRef.current.task?.title ?? null, {
taskId: valuesRef.current.task?.task_id
});
publish();
},
[publish, source]
);
const selectHistory = useCallback(
(id: number | null) => {
const store = storeRef.current;
if (store.selectedId === null)
store.update({
...store.current,
camera:
capturePresentationCamera(valuesRef.current.mapRef.current) ?? store.current.camera
});
if (!store.select(id === store.currentEntryId ? null : id)) return;
publish();
const map = valuesRef.current.mapRef.current;
if (map && valuesRef.current.mapReady) renderPresentationSnapshot(scene, map, store.visible);
},
[publish, scene]
);
const clearMapOverlays = useCallback((discardPending = true) => {
mapCommandsRef.current.abort();
mapCommandsRef.current = new AbortController();
manualRequestRef.current?.abort();
if (discardPending) followerRef.current?.discardPendingMapCommands();
scene.clear(); setOverlay(null); setHighlightLayers([]);
}, [scene]);
const clearMapOverlays = useCallback(
(discardPending = true) => {
mapCommandsRef.current.abort();
mapCommandsRef.current = new AbortController();
manualRequestRef.current?.abort();
previewRef.current?.abort();
if (discardPending) followerRef.current?.discardPendingMapCommands();
storeRef.current.select(null);
scene.clear();
update((view) => ({
...view,
highlights: { type: "FeatureCollection", features: [] },
highlightLayers: [],
overlay: null,
scada: null
}));
},
[scene, update]
);
const clear = useCallback(() => {
// New task generations retain their first queued map command.
clearMapOverlays(false); setCharts([]);
localPreviewRef.current = false;
setLocalPreview(false);
clearMapOverlays(false);
update(() => ({
...emptyPresentationView(),
camera: capturePresentationCamera(valuesRef.current.mapRef.current)
}));
valuesRef.current.onClearLayers();
}, [clearMapOverlays]);
}, [clearMapOverlays, update]);
const executeCommand = useCallback(
async (
command: InvestigationPresentationCommand,
signal: AbortSignal,
origin: InvestigationPresentationActivity["source"],
event?: InvestigationPresentationEvent & { event_seq: number }
) => {
if (command.action !== "open_chart" && command.action !== "close_chart")
signal = AbortSignal.any([signal, mapCommandsRef.current.signal]);
signal.throwIfAborted();
const store = storeRef.current;
const eventKey = event ? presentationEventKey(event.task_id, event.event_seq) : undefined;
if (eventKey && store.entries.some((entry) => entry.key === eventKey))
return { recorded: true, duplicate: true };
update((view) => ({
...view,
evidence:
valuesRef.current.task?.evidence.map(({ evidence_id, summary }) => ({
evidence_id,
summary
})) ?? [],
camera:
store.selectedId === null && (!view.camera || cameraPausedRef.current)
? (capturePresentationCamera(valuesRef.current.mapRef.current) ?? view.camera)
: view.camera
}));
// Cleanup changes current but does not create a selectable historical result.
const historyId = command.action.startsWith("clear_")
? null
: store.begin(
presentationCommandLabel(command, store.current),
origin,
valuesRef.current.task?.title ?? null,
{ key: eventKey, taskId: valuesRef.current.task?.task_id }
);
const id = ++activityIdRef.current;
setActivity((current) => [
...current,
{ id, action: command.action, source: origin, status: "running" }
]);
let actionSnapshot = store.current;
const apply = (updater: (view: PresentationView) => PresentationView) => {
update(updater);
actionSnapshot = store.current;
};
try {
const result = await executePresentationCommand(
command,
signal,
presentationHistoryPorts({
controller: valuesRef.current.controller,
scene,
map: () => valuesRef.current.mapRef.current,
padding: () => valuesRef.current.getPadding(),
update: apply,
rendering: () => store.selectedId === null,
cameraAllowed: () => !cameraPausedRef.current,
waitForChart: (chartId, nextSignal) =>
waitForChart(
chartId,
nextSignal,
valuesRef.current.scope ?? "investigation",
() => store.selectedId === null
),
frame
})
);
signal.throwIfAborted();
if (historyId !== null) store.finish(historyId, "completed", undefined, actionSnapshot);
publish();
setActivity((current) =>
current.map((item) => (item.id === id ? { ...item, status: "completed" } : item))
);
return store.selectedId === null
? result
: {
...result,
displayed: false,
fitted: false,
recorded: historyId !== null,
viewing_history: true
};
} catch (cause) {
const message = signal.aborted
? "展示已取消"
: cause instanceof Error
? cause.message
: "展示失败";
if (historyId !== null)
store.finish(historyId, signal.aborted ? "cancelled" : "error", message, actionSnapshot);
publish();
setActivity((current) =>
current.map((item) => (item.id === id ? { ...item, status: "error", message } : item))
);
throw cause;
}
},
[scene, publish, update]
);
useEffect(() => {
if (projectRef.current !== options.projectId) {
projectRef.current = options.projectId;
storeRef.current.reset();
setActivity([]);
}
clear();
}, [clear, taskId, options.projectId, options.task?.execution_generation, options.scopeId]);
useEffect(() => {
manualRequestRef.current?.abort();
cameraPausedRef.current = false;
setCameraPaused(false);
if (!options.enabled || !taskId) { clear(); return; }
if (!taskId || (options.scope && options.scope !== "investigation") || !options.mapReady) {
if (!localPreviewRef.current) clear();
return;
}
const follower = new InvestigationPresentationFollower({
taskId, subscribe,
fetchCursor: (signal) => fetchOperationalTaskPresentationCursor(taskId, options.getAccessToken, signal),
taskId,
follow: options.enabled,
loadHistory: async (cursor, signal) => {
const task = valuesRef.current.task;
if (!task || task.task_id !== taskId) return;
const events = await fetchOperationalTaskPresentationHistory(
taskId,
cursor.event_cursor,
valuesRef.current.getAccessToken,
signal
);
signal.throwIfAborted();
const next = await hydrateInvestigationPresentationArchive({
events,
task,
history: storeRef.current,
signal,
createPorts: (staging) =>
presentationHistoryPorts({
controller: valuesRef.current.controller,
scene,
map: () => valuesRef.current.mapRef.current,
padding: () => valuesRef.current.getPadding(),
update: (updater) => staging.update(updater(staging.current)),
rendering: () => false,
cameraAllowed: () => true,
waitForChart: async () => undefined,
frame: async (nextSignal) => nextSignal.throwIfAborted()
})
});
signal.throwIfAborted();
storeRef.current.update(next);
publish();
const map = valuesRef.current.mapRef.current;
if (map && storeRef.current.selectedId === null)
renderPresentationSnapshot(scene, map, next);
await frame(signal);
},
subscribe,
fetchCursor: (signal) =>
fetchOperationalTaskPresentationCursor(taskId, options.getAccessToken, signal),
fetchIdentity: (signal) => fetchOperationalTask(taskId, options.getAccessToken, signal),
clear,
stopPulse: () => scene.stopPulse(),
onStatus: (next, message) => { setStatus(next); setError(message ?? null); },
execute: async (command, signal) => {
const sourceSignal = signal;
if (command.action !== "open_chart" && command.action !== "close_chart") {
signal = AbortSignal.any([signal, mapCommandsRef.current.signal]);
}
signal.throwIfAborted();
try {
if (command.action === "highlight_features") {
const collection = await valuesRef.current.controller.queryPresentationFeatures(command.layer, command.feature_ids, signal);
signal.throwIfAborted();
scene.highlight(collection, command.effect === "pulse", !cameraPausedRef.current);
setHighlightLayers([command.layer]);
} else if (command.action === "clear_highlights") {
scene.clearHighlights(); setHighlightLayers([]);
} else if (command.action === "open_chart") {
setCharts((current) => [...current.filter((item) => item.chart_id !== command.chart_id).slice(-5), command]);
await waitForChart(command.chart_id, signal);
} else if (command.action === "close_chart") {
setCharts((current) => current.filter((item) => item.chart_id !== command.chart_id));
await frame(signal);
} else if (command.action === "set_overlay") {
const next = { ...command, visible: true };
scene.setOverlay(next, !cameraPausedRef.current); setOverlay(next);
} else {
scene.clearOverlay(command.overlay_id);
setOverlay((current) => current?.overlay_id === command.overlay_id ? null : current);
}
} catch (cause) {
if (signal.aborted && !sourceSignal.aborted) return;
throw cause;
}
onStatus: (next, message) => {
setStatus(!valuesRef.current.enabled && next === "live" ? "history" : next);
setError(message ?? null);
},
execute: async (command, signal, event) => {
await executeCommand(command, signal, "live", event);
}
});
followerRef.current = follower;
follower.start();
return () => { follower.stop(); if (followerRef.current === follower) followerRef.current = null; };
}, [clear, options.enabled, options.getAccessToken, options.projectId, scene, subscribe, taskId]);
return () => {
follower.stop();
if (followerRef.current === follower) followerRef.current = null;
};
}, [
clear,
executeCommand,
options.enabled,
options.mapReady,
options.scope,
options.task?.execution_generation,
options.getAccessToken,
options.projectId,
publish,
scene,
subscribe,
taskId
]);
useEffect(
() => () => {
previewRef.current?.abort();
},
[taskId, options.projectId, options.task?.execution_generation, options.scopeId]
);
useEffect(() => {
if (options.task) followerRef.current?.updateIdentity(options.task);
}, [options.task]);
const pauseCamera = useCallback(() => {
cameraPausedRef.current = true; setCameraPaused(true);
if (storeRef.current.selectedId !== null) return;
cameraPausedRef.current = true;
setCameraPaused(true);
}, []);
useEffect(() => {
const map = options.mapRef.current;
if (!map || !options.mapReady || !options.enabled) return;
if (
!map ||
!options.mapReady ||
(!options.enabled && !localPreview && options.scope !== "chat")
)
return;
const canvas = map.getCanvasContainer();
// Canvas input excludes programmatic fitBounds/easeTo and also covers touch/keyboard navigation.
canvas.addEventListener("pointerdown", pauseCamera);
canvas.addEventListener("wheel", pauseCamera, { passive: true });
canvas.addEventListener("keydown", pauseCamera);
return () => { canvas.removeEventListener("pointerdown", pauseCamera); canvas.removeEventListener("wheel", pauseCamera); canvas.removeEventListener("keydown", pauseCamera); };
}, [options.enabled, options.mapReady, options.mapRef, pauseCamera]);
useEffect(() => () => { manualRequestRef.current?.abort(); scene.destroy(); }, [scene]);
return () => {
canvas.removeEventListener("pointerdown", pauseCamera);
canvas.removeEventListener("wheel", pauseCamera);
canvas.removeEventListener("keydown", pauseCamera);
};
}, [options.enabled, options.mapReady, options.mapRef, pauseCamera, localPreview, options.scope]);
useEffect(
() => () => {
manualRequestRef.current?.abort();
scene.destroy();
},
[scene]
);
const locateTargets = useCallback(async (targets: InvestigationMapTarget[]) => {
manualRequestRef.current?.abort();
const controller = new AbortController(); manualRequestRef.current = controller;
pauseCamera();
const groups = new Map<InvestigationMapTarget["source_id"], string[]>();
for (const target of targets) groups.set(target.source_id, [...groups.get(target.source_id) ?? [], target.feature_id]);
const collections = await Promise.all([...groups].map(([source, ids]) => valuesRef.current.controller.queryPresentationFeatures(source, ids, controller.signal)));
controller.signal.throwIfAborted();
const data: FeatureCollection = { type: "FeatureCollection", features: collections.flatMap((collection) => collection.features) };
if (!data.features.length) throw new Error("未找到关联地图要素");
scene.highlight(data, false, true); setHighlightLayers([...groups.keys()]);
}, [pauseCamera, scene]);
const locateTargets = useCallback(
async (targets: InvestigationMapTarget[]) => {
if (storeRef.current.selectedId !== null)
throw new Error("请先回到当前,再执行新的要素定位。");
manualRequestRef.current?.abort();
const controller = new AbortController();
manualRequestRef.current = controller;
pauseCamera();
const groups = new Map<InvestigationMapTarget["source_id"], string[]>();
for (const target of targets)
groups.set(target.source_id, [...(groups.get(target.source_id) ?? []), target.feature_id]);
const collections = await Promise.all(
[...groups].map(([layer, ids]) =>
valuesRef.current.controller.queryPresentationFeatures(layer, ids, controller.signal)
)
);
controller.signal.throwIfAborted();
const data: FeatureCollection = {
type: "FeatureCollection",
features: collections.flatMap((collection) => collection.features)
};
if (!data.features.length) throw new Error("未找到关联地图要素");
if (storeRef.current.selectedId === null) scene.highlight(data, false, true);
update((view) => ({
...view,
highlights: structuredClone(data),
highlightLayers: [...groups.keys()],
camera: fitPresentationCamera(
valuesRef.current.mapRef.current,
data,
valuesRef.current.getPadding()
)
}));
record("定位关联要素");
},
[pauseCamera, scene, update, record]
);
const runPreview = useCallback(
async (
input: InvestigationDevPreview | InvestigationPresentationCommand[],
label = "本地结果"
) => {
const preview = Array.isArray(input) ? { commands: input, blocks: [], records: [] } : input;
if (preview.commands.length > 0 && !valuesRef.current.mapReady)
throw new Error("请等待地图就绪后执行展示指令。");
localPreviewRef.current = true;
setLocalPreview(true);
previewRef.current?.abort();
const controller = new AbortController();
previewRef.current = controller;
update((view) => ({
...view,
blocks: structuredClone(preview.blocks),
records: structuredClone(preview.records)
}));
if (preview.blocks.length || preview.records.length) record(`展示结果 · ${label}`, "preview");
for (const command of preview.commands) {
controller.signal.throwIfAborted();
await executeCommand(command, controller.signal, "preview");
}
},
[executeCommand, update, record]
);
const createSample = useCallback((caseId: InvestigationDevCaseId = "combined") => {
const map = valuesRef.current.mapRef.current;
if (!map) return "";
const task = valuesRef.current.task;
const progress =
task?.latest_progress?.execution_generation === task?.execution_generation
? task?.latest_progress
: null;
const target = progress?.progress.diagram?.node_refs.flatMap(
(node) => node.map_targets ?? []
)[0];
return createInvestigationDevCase(
caseId,
map.getCenter().toArray() as [number, number],
target
);
}, []);
const taskEntries =
!options.scope || options.scope === "investigation"
? storeRef.current.entries.filter((entry) => entry.taskId === taskId)
: storeRef.current.entries;
const visible = storeRef.current.visible;
const reviewing = storeRef.current.selectedId !== null;
return {
charts, overlay, cameraPaused, status, error, highlightLayers, clearMapOverlays,
charts: visible.charts,
blocks: visible.blocks,
records: visible.records,
overlay: visible.overlay,
evidence: visible.evidence,
highlightLayers: visible.highlightLayers,
cameraPaused: reviewing ? false : cameraPaused,
status,
error,
activity,
localPreview,
hasHistory: taskEntries.length > 0,
history: {
currentEntryId: storeRef.current.currentEntryId,
entries: taskEntries,
selectedId: storeRef.current.selectedId,
onSelect: selectHistory
},
executeCommand,
runPreview,
createSample,
clearPreview: clear,
clearMapOverlays,
pauseCamera,
resumeCamera: () => { cameraPausedRef.current = false; setCameraPaused(false); },
resumeCamera: () => {
cameraPausedRef.current = false;
setCameraPaused(false);
},
locateTargets,
closeChart: (id: string) => setCharts((current) => current.filter((item) => item.chart_id !== id)),
toggleOverlay: () => { if (overlay) { scene.showOverlay(!overlay.visible); setOverlay({ ...overlay, visible: !overlay.visible }); } },
fitOverlay: () => { pauseCamera(); scene.fitOverlay(); }
closeChart: (id: string) => {
if (!reviewing)
void executeCommand(
{ action: "close_chart", chart_id: id },
new AbortController().signal,
source()
).catch(() => undefined);
},
toggleOverlay: () => {
if (reviewing || !visible.overlay) return;
const next = { ...visible.overlay, visible: !visible.overlay.visible };
scene.showOverlay(next.visible);
update((view) => ({ ...view, overlay: next }));
record(next.visible ? "显示覆盖层" : "隐藏覆盖层");
},
fitOverlay: () => {
pauseCamera();
scene.fitOverlay();
if (!reviewing && visible.overlay) {
update((view) => ({
...view,
camera: fitPresentationCamera(
valuesRef.current.mapRef.current,
visible.overlay!.data,
valuesRef.current.getPadding()
)
}));
record("定位覆盖层");
}
}
};
}
@@ -78,7 +78,7 @@ function task(taskType: OperationalTask["task_type"]): OperationalTask {
scenario_code: null,
feedback_requirements: [],
activity: [],
origin: "synthetic",
origin: "system_data",
updated_at: "2026-09-23T09:00:00Z"
}
};
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { WorkbenchAccess } from "../api/request-context";
import {
acknowledgeOperationalTaskNotification,
answerOperationalTaskQuestion,
type TaskQuestionAnswer,
createOperationalRequestId,
createOperationalTask,
decideOperationalTaskAction,
@@ -317,6 +319,13 @@ export function useOperationalTasks(
}
}, [notifications, acknowledgeNotification]);
const answerQuestion = useCallback(async (taskId: string, questionId: string, version: number,
answers: TaskQuestionAnswer[], requestId: string) => {
const updated = await answerOperationalTaskQuestion(taskId, questionId, version, answers, requestId, getAccessToken);
setTasks((current) => current.map((item) => item.task_id === taskId ? updated : item));
return updated;
}, [getAccessToken]);
return {
error: error ?? sync.error,
loading,
@@ -325,6 +334,7 @@ export function useOperationalTasks(
notifications,
refresh,
createInvestigation,
answerQuestion,
decideAction,
executeAction,
reconcileAction,
@@ -13,6 +13,7 @@ import {
useInteractionRequests,
useAgentConnection,
FrontendActionExecutor,
AnswerViewStateScope,
agentTaskDiscussionContextSchema,
type AgentTaskDiscussionContext,
agentBootstrapKey,
@@ -103,6 +104,12 @@ export function useWorkbenchAgent({
const projectScopeKey = `project:${getWorkbenchProject(getAccessToken)?.project_id ?? "default"}`;
const scopeKey = projectScopeKey;
const { connectionStatus, recheck: checkAgentConnection, recoveryVersion } = useAgentConnection(scopeClient, scopeKey);
const answerStateScope = useMemo(() => new AnswerViewStateScope(`${resources.id}:${scopeKey}`, scopeClient), [resources.id, scopeClient, scopeKey]);
useEffect(() => {
answerStateScope.retain();
resources.disposers.add(answerStateScope.dispose);
return () => { resources.disposers.delete(answerStateScope.dispose); answerStateScope.release(); };
}, [answerStateScope, resources]);
const history = useAgentHistory(scopeClient, scopeKey);
const topics = useAgentTopics(scopeClient, scopeKey);
const runtimeSummaries: AgentChatSessionSummary[] = agentRuntimes(resources).filter((entry) => entry.scope === scopeKey && entry.sessionId && entry.purpose !== "task_execution")
@@ -184,7 +191,7 @@ export function useWorkbenchAgent({
try {
await target.chat.sendMessage(alreadyPresent ? undefined : replyMessage, { body: {
session_id: owningSessionId, interaction_id: interactionId, model: selectedModel || undefined,
capabilities: frontendActionEnabledRef.current ? ["host-question@1", "frontend-action@1"] : ["host-question@1"],
capabilities: frontendActionEnabledRef.current ? ["host-question@1", "frontend-action@2"] : ["host-question@1"],
approval_mode: approvalMode, task_context: target.taskDiscussion?.context,
} });
} catch (error) { target.runStatus = "error"; throw error; }
@@ -344,8 +351,14 @@ export function useWorkbenchAgent({
[refreshInteractionRequests, refreshAgentMessageInbox, applySessionId, claimEnvelope, handleCredentialRefresh, handleFrontendAction, onUiEnvelope]
);
const synchronizeModelCatalog = useCallback((catalog: Awaited<ReturnType<AgentApiClient["getModels"]>>) => {
// A background request from an old project must not replace this project's controls.
if (resources.disposed || resources.activeAgent?.scope !== scopeKey) return;
setModelOptions(catalog.models);
setSelectedModel((current) => resolveAgentModelSelection(catalog, current));
}, [resources, scopeKey]);
const transport = useMemo(() => createWorkbenchAgentTransport(scopeClient, getAccessToken,
getProjectId), [getAccessToken, getProjectId, scopeClient]);
getProjectId, synchronizeModelCatalog), [getAccessToken, getProjectId, scopeClient, synchronizeModelCatalog]);
const defaultRuntime = useMemo(() => {
const created = createAgentRuntime(resources, scopeKey, scopeClient, transport);
return created;
@@ -768,7 +781,7 @@ export function useWorkbenchAgent({
const loaded = await projectClient.loadSession(id, { messageLimit: 40 });
if (!loaded) throw new Error("任务协作会话未能打开,请重试。");
target = createAgentRuntime(resources, projectScopeKey, projectClient,
createWorkbenchAgentTransport(projectClient, getAccessToken, getProjectId), loaded);
createWorkbenchAgentTransport(projectClient, getAccessToken, getProjectId, synchronizeModelCatalog), loaded);
if (loaded.isStreaming || loaded.runStatus === "running") resumeAgentRuntime(resources, target);
}
const titleBeforeOpen = titlesBeforeOpen.get(target);
@@ -781,7 +794,7 @@ export function useWorkbenchAgent({
activateRuntime(target);
setPromptFocusRequest((current) => current + 1);
return id;
}, [activateRuntime, getAccessToken, getProjectId, projectScopeKey, resources]);
}, [activateRuntime, getAccessToken, getProjectId, projectScopeKey, resources, synchronizeModelCatalog]);
const openTaskExecutionHistory = useCallback(async (taskId: string, runId: string, executionSessionId: string) => {
const normalizedTaskId = taskId.trim();
@@ -809,7 +822,7 @@ export function useWorkbenchAgent({
resources,
projectScopeKey,
projectClient,
createWorkbenchAgentTransport(projectClient, getAccessToken, getProjectId),
createWorkbenchAgentTransport(projectClient, getAccessToken, getProjectId, synchronizeModelCatalog),
loaded
);
} else {
@@ -833,7 +846,7 @@ export function useWorkbenchAgent({
activateRuntime(target);
setStatusLabel(loaded.runStatus === "running" ? "调查执行记录自动同步中" : "调查执行记录已打开");
return loaded.sessionId;
}, [activateRuntime, getAccessToken, getProjectId, projectScopeKey, resources]);
}, [activateRuntime, getAccessToken, getProjectId, projectScopeKey, resources, synchronizeModelCatalog]);
useEffect(() => {
const target = runtimeRef.current;
@@ -1084,7 +1097,7 @@ export function useWorkbenchAgent({
topic_id: !activeSessionId ? runtimeRef.current?.topicId ?? undefined : undefined,
model: selectedModel || undefined,
approval_mode: approvalMode,
capabilities: frontendActionEnabledRef.current ? ["frontend-action@1", "host-question@1"] : ["host-question@1"],
capabilities: frontendActionEnabledRef.current ? ["frontend-action@2", "host-question@1"] : ["host-question@1"],
task_context: taskDiscussion?.sessionId === sessionIdRef.current ? taskDiscussion.context : undefined
}
}
@@ -1108,7 +1121,7 @@ export function useWorkbenchAgent({
setStatusLabel("Agent 正在调查水质投诉事件");
void target.chat.sendMessage({ text: prompt }, { body: {
session_id: id, model: selectedModel || undefined, approval_mode: approvalMode, trigger_context: trigger,
capabilities: frontendActionEnabledRef.current ? ["frontend-action@1", "host-question@1"] : ["host-question@1"],
capabilities: frontendActionEnabledRef.current ? ["frontend-action@2", "host-question@1"] : ["host-question@1"],
} }).catch(() => {});
return { sessionId: id, reused: reserved.event_duplicate === true };
}, [activateRuntime, approvalMode, resources, scopeClient, scopeKey, selectedModel, transport]);
@@ -1185,6 +1198,7 @@ export function useWorkbenchAgent({
return {
apiClient: clientRef.current,
answerStateScope,
contentReference: runtime.reference,
setContentReference: (value: import("@/features/agent").AnswerUiSelection | undefined) => {
const active = resources.activeAgent;
@@ -1313,7 +1327,7 @@ function prefersReducedMotion() {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function createWorkbenchAgentTransport(client: AgentApiClient, access?: WorkbenchAccess, getProjectId?: () => Promise<string>) {
function createWorkbenchAgentTransport(client: AgentApiClient, access?: WorkbenchAccess, getProjectId?: () => Promise<string>, onModels?: (catalog: Awaited<ReturnType<AgentApiClient["getModels"]>>) => void) {
return new ResponsesChatTransport<AgentUiMessage>({
api: `${env.TJWATER_AGENT_URL.replace(/\/$/, "")}/api/v1/responses`,
headers: async (): Promise<Record<string, string>> => {
@@ -1323,12 +1337,13 @@ function createWorkbenchAgentTransport(client: AgentApiClient, access?: Workbenc
},
async prepareSendMessagesRequest({ messages, body }) {
const catalog = await client.getModels();
onModels?.(catalog);
const model = resolveAgentModelSelection(catalog, readBodyString(body, "model") ?? "");
if (!model) throw new Error("Agent 后端当前没有可用模型,请检查模型配置。");
return { body: createResponseRequest(messages, {
conversation: readBodyString(body, "session_id"), interactionId: readBodyString(body, "interaction_id"), model, topicId: readBodyString(body, "topic_id"),
approvalMode: readBodyString(body, "approval_mode") ?? "request",
capabilities: Array.isArray(body?.capabilities) ? body.capabilities.filter((value): value is string => value === "frontend-action@1" || value === "host-question@1") : [], triggerContext: body?.trigger_context, taskContext: body?.task_context,
capabilities: Array.isArray(body?.capabilities) ? body.capabilities.filter((value): value is string => value === "frontend-action@2" || value === "host-question@1") : [], triggerContext: body?.trigger_context, taskContext: body?.task_context,
}) };
},
});
@@ -628,8 +628,7 @@ function addMissingDataDependentLayers(
layers: StyleSpecification["layers"],
beforeLayerCandidates: readonly string[],
availableSourceIdSet: ReadonlySet<GeoServerWaterNetworkSourceId>,
layerVisibility: Record<string, boolean>,
workspace?: string
layerVisibility: Record<string, boolean>
) {
const beforeLayerId = beforeLayerCandidates.find((layerId) => map.getLayer(layerId));
layers.forEach((layer) => {
@@ -1,3 +1,4 @@
import { toLocalDateKey } from "@/shared/utils/date-time";
import { useEffect, useState } from "react";
import { getWorkbenchProject, type WorkbenchAccess } from "../api/request-context";
import { env } from "@/shared/config/env";
@@ -249,7 +250,7 @@ export function useWorkbenchRuntimeData(
: backendRuntimeActive
? "backend" as const
: "mock" as const,
operationalDate: formatLocalDateKey(operationalDate),
operationalDate: toLocalDateKey(operationalDate),
operationalCursorTime: operationalDate.toISOString(),
scheduledConditionsLoading,
realtimePipeFlows,
@@ -258,10 +259,3 @@ export function useWorkbenchRuntimeData(
scenarios: !allowDemoScenarios && resolvedRuntimeData.scenarioDataSource === "mock" ? [] : resolvedRuntimeData.scenarios
};
}
function formatLocalDateKey(value: Date) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}