feat: add project-aware agent workspace
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
} from "@/features/agent";
|
||||
|
||||
type AgentUiDataParts = {
|
||||
session_created: Record<string, unknown>;
|
||||
agent_message: Record<string, unknown>;
|
||||
task_context: Record<string, unknown>;
|
||||
execution_plan: Record<string, unknown>;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ChatTransport, UIMessageChunk } from "ai";
|
||||
import 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";
|
||||
|
||||
const message = (id: string, text = id): AgentUiMessage => ({ id, role: "assistant", parts: [{ type: "text", text }] });
|
||||
|
||||
describe("independent conversation runtimes", () => {
|
||||
it("keeps the original response streaming after selecting another conversation", async () => {
|
||||
const resources = new WorkbenchSessionResources();
|
||||
let controller!: ReadableStreamDefaultController<UIMessageChunk>;
|
||||
const transport: ChatTransport<AgentUiMessage> = {
|
||||
sendMessages: async () => new ReadableStream({ start(stream) { controller = stream; } }),
|
||||
reconnectToStream: async () => null,
|
||||
};
|
||||
const client = { refreshCredential: vi.fn(async () => {}), submitFrontendActionResult: vi.fn(async () => {}) } as unknown as AgentApiClient;
|
||||
const first = createAgentRuntime(resources, "project:a", client, transport);
|
||||
const second = createAgentRuntime(resources, "project:a", client, transport);
|
||||
resources.activeAgent = first;
|
||||
first.sessionId = "server-a";
|
||||
const response = first.chat.sendMessage({ text: "核对城南压力" });
|
||||
await vi.waitFor(() => expect(controller).toBeDefined());
|
||||
controller.enqueue({ type: "start", messageId: "answer-a" });
|
||||
controller.enqueue({ type: "text-start", id: "text-a" });
|
||||
controller.enqueue({ type: "text-delta", id: "text-a", delta: "第一段" });
|
||||
await vi.waitFor(() => expect(first.chat.messages.at(-1)?.parts).toEqual(expect.arrayContaining([expect.objectContaining({ text: "第一段" })])));
|
||||
resources.activeAgent = second;
|
||||
controller.enqueue({ type: "data-progress", data: { session_id: "another-session", title: "迟到的其他会话事件" } });
|
||||
expect(runtimeRunning(first)).toBe(true);
|
||||
controller.enqueue({ type: "data-credential_refresh_required", data: { request_id: "refresh-a", session_id: "server-a" } });
|
||||
controller.enqueue({ type: "text-delta", id: "text-a", delta: ",后台完成" });
|
||||
controller.enqueue({ type: "text-end", id: "text-a" });
|
||||
controller.enqueue({ type: "finish", finishReason: "stop" });
|
||||
controller.close();
|
||||
await response;
|
||||
expect(first.chat.messages.at(-1)?.parts).toEqual(expect.arrayContaining([expect.objectContaining({ text: "第一段,后台完成" })]));
|
||||
expect(second.chat.messages).toEqual([]);
|
||||
expect(first.sessionId).toBe("server-a");
|
||||
expect(client.refreshCredential).toHaveBeenCalledWith("server-a", "refresh-a");
|
||||
resources.dispose();
|
||||
});
|
||||
it("merges overlapping pages and a newer authoritative tail without duplicates", () => {
|
||||
expect(mergeHistoryMessages([message("a"), message("b", "old")], [message("b", "new"), message("c")]))
|
||||
.toEqual([message("a"), message("b", "new"), message("c")]);
|
||||
expect(mergeHistoryMessages([message("c"), message("d")], [message("a"), message("b"), message("c", "new")]))
|
||||
.toEqual([message("a"), message("b"), message("c", "new"), message("d")]);
|
||||
});
|
||||
it("routes background browser actions to a scoped refusal and keeps old message pages on resume", async () => {
|
||||
const resources = new WorkbenchSessionResources();
|
||||
const submit = vi.fn(async () => {});
|
||||
const client = { submitFrontendActionResult: submit,
|
||||
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,
|
||||
} });
|
||||
options.onEvent({ type: "done", sessionId: "a", data: {} });
|
||||
}),
|
||||
} as unknown as AgentApiClient;
|
||||
const transport = {} as ChatTransport<AgentUiMessage>;
|
||||
const runtime = createAgentRuntime(resources, "project:a", client, transport, {
|
||||
id: "a", sessionId: "a", title: "压力复核", createdAt: 1, updatedAt: 1, purpose: "ordinary_chat",
|
||||
isTitleManuallyEdited: false, messages: [message("a"), message("b", "old")],
|
||||
});
|
||||
resumeAgentRuntime(resources, runtime);
|
||||
await vi.waitFor(() => expect(runtime.controller).toBeUndefined());
|
||||
expect(runtime.chat.messages).toEqual([message("a"), message("b", "new"), message("c")]);
|
||||
expect(submit).toHaveBeenCalledWith("a", "action-a", expect.objectContaining({
|
||||
status: "failed", error: expect.objectContaining({ code: "SESSION_NOT_ACTIVE" }),
|
||||
}));
|
||||
resources.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Chat } from "@ai-sdk/react";
|
||||
import type { ChatTransport } from "ai";
|
||||
import { parseFrontendActionRequest, type AgentApiClient, type AgentRunStatus, type AgentLoadedChatSession,
|
||||
type AgentMessagePagination, type AgentSessionStreamEvent, type AgentSessionPurpose, type AgentTaskDiscussionContext, type AnswerUiSelection } from "@/features/agent";
|
||||
import { showMapNotice } from "@/features/map/core";
|
||||
import { applySessionStreamEvent, getDataString, toAgentChatMessages, toAgentUiMessages,
|
||||
type AgentDataPart, type AgentUiMessage, type PermissionOverride, type QuestionOverride } from "./agent-session-message-state";
|
||||
import type { WorkbenchSessionResources } from "./workbench-session-resources";
|
||||
|
||||
export type AgentReadingPosition = { messageId?: string; offset: number; atBottom: boolean };
|
||||
export type AgentSessionRuntime = {
|
||||
key: string;
|
||||
scope: string;
|
||||
client: AgentApiClient;
|
||||
chat: Chat<AgentUiMessage>;
|
||||
sessionId: string | null;
|
||||
title: string;
|
||||
topicId?: string | null;
|
||||
purpose: AgentSessionPurpose;
|
||||
taskId?: string;
|
||||
draft: string;
|
||||
reference?: AnswerUiSelection;
|
||||
taskDiscussion?: { context: AgentTaskDiscussionContext; reportGeneration?: number | null };
|
||||
permissionOverrides: Record<string, PermissionOverride>;
|
||||
questionOverrides: Record<string, QuestionOverride>;
|
||||
messagePage?: AgentMessagePagination;
|
||||
readingPosition?: AgentReadingPosition;
|
||||
loadedAt: number;
|
||||
updatedAt: number;
|
||||
resumed: boolean;
|
||||
controller?: AbortController;
|
||||
error?: string;
|
||||
runStatus?: AgentRunStatus;
|
||||
handlers?: {
|
||||
onData: (part: AgentDataPart) => void;
|
||||
onEvent: (event: AgentSessionStreamEvent) => void;
|
||||
onError: (error: Error) => void;
|
||||
onFinish: (status: AgentRunStatus) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export function agentRuntimes(resources: WorkbenchSessionResources) {
|
||||
return [...resources.agents.values()] as AgentSessionRuntime[];
|
||||
}
|
||||
|
||||
export function runtimeRunning(runtime: AgentSessionRuntime) {
|
||||
return runtime.resumed || runtime.chat.status === "submitted" || runtime.chat.status === "streaming";
|
||||
}
|
||||
|
||||
export function runtimeNeedsInput(runtime: AgentSessionRuntime) {
|
||||
return toAgentChatMessages(runtime.chat.messages, runtime.permissionOverrides, runtime.questionOverrides).some((message) =>
|
||||
message.permissions?.some((permission) => permission.status === "pending") || message.questions?.some((question) => question.status === "pending"));
|
||||
}
|
||||
|
||||
/** Server snapshots replace matching messages, while previously loaded pages retain their order. */
|
||||
export function mergeHistoryMessages(current: AgentUiMessage[], incoming: AgentUiMessage[]) {
|
||||
if (!current.length) return incoming;
|
||||
if (!incoming.length) return current;
|
||||
const firstOverlap = incoming.findIndex((message) => current.some((existing) => existing.id === message.id));
|
||||
if (firstOverlap < 0) return [...current, ...incoming];
|
||||
const index = current.findIndex((message) => message.id === incoming[firstOverlap].id);
|
||||
const incomingById = new Map(incoming.map((message) => [message.id, message]));
|
||||
const prefix = current.slice(0, index).filter((message) => !incomingById.has(message.id));
|
||||
const suffix = current.slice(index).map((message) => incomingById.get(message.id) ?? message);
|
||||
const known = new Set(suffix.map((message) => message.id));
|
||||
return [...prefix, ...incoming.slice(0, firstOverlap), ...suffix, ...incoming.filter((message) => !known.has(message.id) && !incoming.slice(0, firstOverlap).includes(message))];
|
||||
}
|
||||
|
||||
export function createAgentRuntime(resources: WorkbenchSessionResources, scope: string, client: AgentApiClient,
|
||||
transport: ChatTransport<AgentUiMessage>, loaded?: AgentLoadedChatSession) {
|
||||
const runtime = {
|
||||
key: crypto.randomUUID(), scope, client, sessionId: loaded?.sessionId ?? null,
|
||||
title: loaded?.title ?? "新对话", topicId: loaded?.topicId, purpose: loaded?.purpose ?? "ordinary_chat", taskId: loaded?.taskId,
|
||||
draft: "", permissionOverrides: {}, questionOverrides: {}, messagePage: loaded?.messagePage,
|
||||
loadedAt: loaded ? Date.now() : 0, updatedAt: loaded?.updatedAt ?? Date.now(), resumed: false, runStatus: loaded?.runStatus,
|
||||
} as AgentSessionRuntime;
|
||||
const active = () => resources.activeAgent === runtime;
|
||||
const notify = () => queueMicrotask(() => resources.notify());
|
||||
runtime.chat = new Chat<AgentUiMessage>({
|
||||
id: runtime.key, transport, messages: toAgentUiMessages(loaded?.messages ?? []),
|
||||
onData: (part) => {
|
||||
if (resources.disposed) return;
|
||||
const data = part as AgentDataPart;
|
||||
const sessionId = getDataString(data.data, "session_id");
|
||||
if (sessionId && runtime.sessionId && sessionId !== runtime.sessionId) return;
|
||||
if (sessionId) runtime.sessionId = sessionId;
|
||||
if (data.type === "data-session_title") runtime.title = getDataString(data.data, "title") ?? runtime.title;
|
||||
if (data.type === "data-credential_refresh_required") {
|
||||
void refreshRuntimeCredential(runtime, data.data);
|
||||
} else if (!active() && data.type === "data-frontend_action") {
|
||||
void rejectBackgroundAction(resources, runtime, data.data);
|
||||
} else if (active()) runtime.handlers?.onData(data);
|
||||
notify();
|
||||
},
|
||||
onError: (error) => { if (resources.disposed) return; runtime.error = error.message; runtime.runStatus = "error"; if (active()) runtime.handlers?.onError(error); notify(); },
|
||||
onFinish: ({ isAbort, isError }) => {
|
||||
if (resources.disposed) return;
|
||||
runtime.loadedAt = Date.now();
|
||||
runtime.updatedAt = Date.now();
|
||||
runtime.runStatus = isAbort ? "aborted" : isError ? "error" : "completed";
|
||||
if (active()) runtime.handlers?.onFinish(runtime.runStatus);
|
||||
else notifyBackgroundCompletion(runtime);
|
||||
notify();
|
||||
},
|
||||
});
|
||||
resources.agents.set(runtime.key, runtime);
|
||||
resources.disposers.add(() => { runtime.handlers = undefined; runtime.controller?.abort(); void runtime.chat.stop(); });
|
||||
return runtime;
|
||||
}
|
||||
|
||||
const credentialRequests = new WeakMap<AgentSessionRuntime, Set<string>>();
|
||||
async function refreshRuntimeCredential(runtime: AgentSessionRuntime, data: Record<string, unknown>) {
|
||||
const requestId = getDataString(data, "request_id");
|
||||
if (!requestId || !runtime.sessionId) return;
|
||||
const pending = credentialRequests.get(runtime) ?? new Set<string>();
|
||||
credentialRequests.set(runtime, pending);
|
||||
if (pending.has(requestId)) return;
|
||||
pending.add(requestId);
|
||||
try { await runtime.client.refreshCredential(runtime.sessionId, requestId); }
|
||||
catch { pending.delete(requestId); runtime.error = "会话凭据续期失败,请重新登录后重试"; }
|
||||
}
|
||||
|
||||
async function rejectBackgroundAction(resources: WorkbenchSessionResources, runtime: AgentSessionRuntime, data: Record<string, unknown>) {
|
||||
const request = parseFrontendActionRequest(data);
|
||||
if (!request || request.sessionId !== runtime.sessionId) return;
|
||||
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",
|
||||
error: { code: "SESSION_NOT_ACTIVE", message: "返回对应会话后重新请求地图或工作区操作" }, completedAt: Date.now(),
|
||||
}).catch(() => { runtime.error = "后台展示操作回执失败"; });
|
||||
}
|
||||
|
||||
export function resumeAgentRuntime(resources: WorkbenchSessionResources, runtime: AgentSessionRuntime) {
|
||||
if (!runtime.sessionId || runtime.controller || runtime.chat.status === "submitted" || runtime.chat.status === "streaming") return;
|
||||
const controller = new AbortController();
|
||||
runtime.controller = controller;
|
||||
runtime.resumed = true;
|
||||
runtime.runStatus = "running";
|
||||
runtime.error = undefined;
|
||||
void runtime.client.streamSession(runtime.sessionId, { signal: controller.signal, messageLimit: 40,
|
||||
onEvent: (event) => {
|
||||
if (resources.disposed) return;
|
||||
const sessionId = event.type === "state" ? event.sessionId : event.sessionId ?? getDataString(event.data, "session_id");
|
||||
if (sessionId && sessionId !== runtime.sessionId) return;
|
||||
if (event.type === "state") {
|
||||
const tail = toAgentUiMessages(event.messages);
|
||||
const prior = runtime.chat.messages;
|
||||
// Start filling a disconnected interval before reconciling the live tail.
|
||||
if (prior.length && tail.length && !tail.some((message) => prior.some((old) => old.id === message.id))) {
|
||||
const boundary = event.messagePage?.beforeCursor;
|
||||
if (boundary) void fillHistoryGap(runtime, boundary, prior, controller.signal).then(() => resources.notify()).catch(() => {});
|
||||
}
|
||||
runtime.chat.messages = mergeHistoryMessages(prior, tail);
|
||||
if (!runtime.messagePage) runtime.messagePage = event.messagePage;
|
||||
runtime.resumed = event.isStreaming;
|
||||
runtime.runStatus = event.runStatus ?? (event.isStreaming ? "running" : "completed");
|
||||
} else {
|
||||
if (event.type === "credential_refresh_required") void refreshRuntimeCredential(runtime, event.data);
|
||||
else if (resources.activeAgent !== runtime && event.type === "frontend_action") void rejectBackgroundAction(resources, runtime, event.data);
|
||||
runtime.chat.messages = applySessionStreamEvent(runtime.chat.messages, event);
|
||||
if (event.type === "session_title") runtime.title = getDataString(event.data, "title") ?? runtime.title;
|
||||
if (event.type === "done" || event.type === "error") {
|
||||
runtime.resumed = false;
|
||||
runtime.runStatus = event.type === "error" ? "error" : "completed";
|
||||
runtime.updatedAt = Date.now();
|
||||
}
|
||||
}
|
||||
if (resources.activeAgent === runtime && event.type !== "credential_refresh_required") runtime.handlers?.onEvent(event);
|
||||
resources.notify();
|
||||
},
|
||||
}).catch((error: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
runtime.error = error instanceof Error ? error.message : "会话连接中断";
|
||||
if (resources.activeAgent === runtime) runtime.handlers?.onError(new Error(runtime.error));
|
||||
}
|
||||
}).finally(() => {
|
||||
if (runtime.resumed && !controller.signal.aborted && !runtime.error) runtime.error = "会话连接已断开,返回会话后重新连接";
|
||||
if (runtime.controller === controller) { runtime.controller = undefined; runtime.resumed = false; }
|
||||
runtime.loadedAt = Date.now();
|
||||
if (!resources.disposed && !controller.signal.aborted && resources.activeAgent !== runtime) notifyBackgroundCompletion(runtime);
|
||||
resources.notify();
|
||||
});
|
||||
}
|
||||
|
||||
function notifyBackgroundCompletion(runtime: AgentSessionRuntime) {
|
||||
showMapNotice({ id: `agent-background-${runtime.key}`, tone: runtime.error || runtime.runStatus === "error" ? "error" : "info",
|
||||
title: runtime.error || runtime.runStatus === "error" ? "后台对话处理失败" : runtime.runStatus === "aborted" ? "后台对话已停止" : "后台对话已完成",
|
||||
message: runtime.error ?? runtime.title });
|
||||
}
|
||||
|
||||
async function fillHistoryGap(runtime: AgentSessionRuntime, cursor: string, prior: AgentUiMessage[], signal: AbortSignal) {
|
||||
let before: string | null = cursor;
|
||||
const middle: AgentUiMessage[] = [];
|
||||
while (before && !signal.aborted) {
|
||||
const page = await runtime.client.loadOlderMessages(runtime.sessionId!, before, signal);
|
||||
const messages = toAgentUiMessages(page.messages);
|
||||
middle.unshift(...messages);
|
||||
if (messages.some((message) => prior.some((old) => old.id === message.id))) break;
|
||||
before = page.messagePage.beforeCursor;
|
||||
}
|
||||
if (!signal.aborted) runtime.chat.messages = mergeHistoryMessages(mergeHistoryMessages(prior, middle), runtime.chat.messages);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { SWRConfig } from "swr";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import type { AgentApiClient, AgentSessionPage } from "@/features/agent";
|
||||
import { useAgentHistory } from "./use-agent-history";
|
||||
import { WorkbenchSessionProvider } from "./workbench-session-provider";
|
||||
|
||||
it("keeps a late history refresh in its original project cache", async () => {
|
||||
const clock = vi.spyOn(Date, "now").mockReturnValue(100);
|
||||
const root = createRoot(document.createElement("div"));
|
||||
const page = (id: string): AgentSessionPage => ({ sessions: [{ id, title: id, createdAt: 1, updatedAt: 1, purpose: "ordinary_chat" }], hasMore: false, nextCursor: null });
|
||||
let finish!: (page: AgentSessionPage) => void;
|
||||
const first = vi.fn().mockResolvedValueOnce(page("project-a-original")).mockImplementationOnce(() => new Promise<AgentSessionPage>((resolve) => { finish = resolve; }));
|
||||
const second = vi.fn(async () => page("project-b-only"));
|
||||
const clients = { a: { listSessionPage: first }, b: { listSessionPage: second } } as unknown as Record<"a" | "b", AgentApiClient>;
|
||||
let history!: ReturnType<typeof useAgentHistory>;
|
||||
function Probe({ scope }: { scope: "a" | "b" }) { history = useAgentHistory(clients[scope], scope); return null; }
|
||||
const swrCache = new Map();
|
||||
const render = (scope: "a" | "b") => root.render(createElement(WorkbenchSessionProvider, null,
|
||||
createElement(SWRConfig, { value: { provider: () => swrCache, dedupingInterval: 0 } }, createElement(Probe, { scope }))));
|
||||
try {
|
||||
await act(async () => render("a"));
|
||||
expect(history.recentSessions[0]?.id).toBe("project-a-original");
|
||||
clock.mockReturnValue(61_000);
|
||||
let pending!: Promise<void>;
|
||||
await act(async () => { pending = history.ensure(); });
|
||||
await act(async () => render("b"));
|
||||
await act(async () => { finish(page("project-a-late-result")); await pending; });
|
||||
expect(history.sessions.map((session) => session.id)).toEqual(["project-b-only"]);
|
||||
expect(second).toHaveBeenCalledTimes(1);
|
||||
} finally { await act(async () => root.unmount()); clock.mockRestore(); }
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import type { AgentApiClient, AgentChatSessionSummary, AgentSessionPage } from "@/features/agent";
|
||||
import { useWorkbenchSessionResources } from "./workbench-session-resources";
|
||||
|
||||
const TTL = 60_000;
|
||||
|
||||
export function useAgentHistory(client: AgentApiClient, scope: string) {
|
||||
const resources = useWorkbenchSessionResources();
|
||||
const [topicFilter, setTopicFilter] = useState<{ scope: string; id?: string }>({ scope });
|
||||
const topicId = topicFilter.scope === scope ? topicFilter.id : undefined;
|
||||
const setTopicId = useCallback((id: string | undefined) => setTopicFilter({ scope, id }), [scope]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [pendingUpdateKey, setPendingUpdateKey] = useState<string | null>(null);
|
||||
const viewKey = JSON.stringify([scope, query, topicId]);
|
||||
const view = useRef({ scope, query, topicId });
|
||||
view.current = { scope, query, topicId };
|
||||
const staged = useRef<{ key: string; page: AgentSessionPage } | null>(null);
|
||||
const [orderVersion, setOrderVersion] = useState(0);
|
||||
const key = useCallback((q: string, cursor?: string | null, topic?: string) => JSON.stringify(["history", scope, q, cursor ?? null, topic ?? null]), [scope]);
|
||||
const fetchPage = useCallback((q: string, cursor?: string | null, force = false, topic?: string) => resources.cache.load(key(q, cursor, topic),
|
||||
() => client.listSessionPage({ q, topicId: topic, includeCollaboration: true, cursor: cursor ?? undefined, signal: AbortSignal.timeout(15_000) }), TTL, { force }), [client, key, resources]);
|
||||
const recent = useSWR(["agent-recent-sessions", scope], () => fetchPage(""), {
|
||||
revalidateOnFocus: false, shouldRetryOnError: false,
|
||||
});
|
||||
const pages = useSWRInfinite<AgentSessionPage>(
|
||||
(_index, previous: AgentSessionPage | null) => !enabled || (previous && !previous.hasMore) ? null
|
||||
: ["agent-history-page", scope, query, previous?.nextCursor ?? null, topicId ?? null],
|
||||
([, , q, cursor, topic]: [string, string, string, string | null, string | null]) => fetchPage(q, cursor, false, topic ?? undefined),
|
||||
{ revalidateOnFocus: false, revalidateFirstPage: false, shouldRetryOnError: false, persistSize: false },
|
||||
);
|
||||
const sessions = useMemo(() => [...new Map((pages.data ?? (query || topicId ? [] : recent.data ? [recent.data] : []))
|
||||
.flatMap((page) => page.sessions).map((session) => [session.id, session])).values()], [pages.data, query, recent.data, topicId]);
|
||||
const refresh = useCallback(async () => {
|
||||
const page = staged.current?.key === viewKey ? staged.current.page : await fetchPage(query, null, true, topicId);
|
||||
if (view.current.scope !== scope || view.current.query !== query || view.current.topicId !== topicId) return;
|
||||
staged.current = null;
|
||||
await pages.mutate([page], { revalidate: false });
|
||||
await pages.setSize(1);
|
||||
if (!query && !topicId) await recent.mutate(page, { revalidate: false });
|
||||
setPendingUpdateKey(null);
|
||||
setOrderVersion((version) => version + 1);
|
||||
}, [fetchPage, pages, query, recent, scope, topicId, viewKey]);
|
||||
const ensure = useCallback(async () => {
|
||||
setOpen(true);
|
||||
setEnabled(true);
|
||||
const stale = (resources.cache.peek(key(query, null, topicId))?.expiresAt ?? 0) <= Date.now();
|
||||
const page = await fetchPage(query, null, false, topicId);
|
||||
if (view.current.scope !== scope || view.current.query !== query || view.current.topicId !== topicId) return;
|
||||
// Opening a window never invalidates a fresh page or hides existing content.
|
||||
if (!pages.data?.length) await pages.mutate([page], { revalidate: false });
|
||||
else if (stale) { staged.current = { key: viewKey, page }; setPendingUpdateKey(viewKey); }
|
||||
if (!query && !topicId) await recent.mutate(page, { revalidate: false });
|
||||
}, [fetchPage, key, pages, query, recent, resources, scope, topicId, viewKey]);
|
||||
const update = useCallback(async (change: (sessions: AgentChatSessionSummary[]) => AgentChatSessionSummary[]) => {
|
||||
resources.cache.updateMatching<AgentSessionPage>((cacheKey) => {
|
||||
if (!cacheKey.startsWith('["history",')) return false;
|
||||
const value = JSON.parse(cacheKey) as unknown[];
|
||||
return value[0] === "history" && value[1] === scope;
|
||||
}, (page) => ({ ...page, sessions: change(page.sessions) }));
|
||||
const recentPage = resources.cache.peek<AgentSessionPage>(key(""))?.value;
|
||||
if (recentPage) {
|
||||
const next = { ...recentPage, sessions: change(recentPage.sessions) };
|
||||
resources.cache.set(key(""), next, TTL);
|
||||
if (view.current.scope === scope) await recent.mutate(next, { revalidate: false });
|
||||
}
|
||||
if (view.current.scope !== scope || view.current.query !== query || view.current.topicId !== topicId) return;
|
||||
await pages.mutate((current) => current?.map((page) => ({ ...page, sessions: change(page.sessions) })), { revalidate: false });
|
||||
// Search and later pages can now have different membership/order. Retain what is being read.
|
||||
setPendingUpdateKey(viewKey);
|
||||
}, [key, pages, query, recent, resources, scope, topicId, viewKey]);
|
||||
return {
|
||||
topicId, setTopicId, sessions, recentSessions: recent.data?.sessions ?? [], query, setQuery,
|
||||
loading: (open ? pages.isLoading : recent.isLoading), error: pages.error ?? recent.error,
|
||||
loadingMore: pages.isValidating, hasMore: pages.data?.at(-1)?.hasMore ?? recent.data?.hasMore ?? false,
|
||||
loadMore: () => pages.setSize(pages.size + 1), refresh, ensure, update,
|
||||
pendingUpdate: pendingUpdateKey === viewKey, isOpen: open, orderVersion, close: () => setOpen(false),
|
||||
};
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent
|
||||
} from "react";
|
||||
import {
|
||||
clampAgentPanelWidth,
|
||||
getWorkbenchViewportLayout
|
||||
} from "../layout/workbench-layout";
|
||||
|
||||
export function useAgentPanelResize(viewportWidth: number, conditionExpanded = false) {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
const [width, setWidth] = useState<number | null>(null);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
|
||||
useEffect(() => () => cleanupRef.current?.(), []);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0 || !panelRef.current) return;
|
||||
|
||||
event.preventDefault();
|
||||
cleanupRef.current?.();
|
||||
const startX = event.clientX;
|
||||
const startWidth = panelRef.current.getBoundingClientRect().width;
|
||||
const handle = event.currentTarget;
|
||||
const pointerId = event.pointerId;
|
||||
handle.setPointerCapture(pointerId);
|
||||
setResizing(true);
|
||||
|
||||
const handlePointerMove = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId !== pointerId) return;
|
||||
pointerEvent.preventDefault();
|
||||
setWidth(clampAgentPanelWidth(
|
||||
startWidth + pointerEvent.clientX - startX,
|
||||
window.innerWidth,
|
||||
conditionExpanded
|
||||
));
|
||||
};
|
||||
const stopResizing = (pointerEvent?: PointerEvent) => {
|
||||
if (pointerEvent && pointerEvent.pointerId !== pointerId) return;
|
||||
cleanupRef.current?.();
|
||||
setResizing(false);
|
||||
};
|
||||
const handleBlur = () => stopResizing();
|
||||
const cleanup = () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", stopResizing);
|
||||
window.removeEventListener("pointercancel", stopResizing);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
if (handle.hasPointerCapture(pointerId)) handle.releasePointerCapture(pointerId);
|
||||
cleanupRef.current = null;
|
||||
};
|
||||
|
||||
cleanupRef.current = cleanup;
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", stopResizing);
|
||||
window.addEventListener("pointercancel", stopResizing);
|
||||
window.addEventListener("blur", handleBlur);
|
||||
},
|
||||
[conditionExpanded]
|
||||
);
|
||||
|
||||
return {
|
||||
handlePointerDown,
|
||||
panelRef,
|
||||
resizing,
|
||||
width: clampAgentPanelWidth(
|
||||
width ?? getWorkbenchViewportLayout(viewportWidth).agentWidth,
|
||||
viewportWidth,
|
||||
conditionExpanded
|
||||
)
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectRecentAgentActivities } from "./use-agent-prompt-suggestions";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { collectRecentAgentActivities, useAgentPromptSuggestions } from "./use-agent-prompt-suggestions";
|
||||
import { WorkbenchSessionProvider } from "./workbench-session-provider";
|
||||
import type { ScheduledConditionItem } from "../types";
|
||||
import type { AgentChatSessionSummary } from "@/features/agent";
|
||||
|
||||
describe("recent activities for Agent suggestions", () => {
|
||||
@@ -21,3 +25,56 @@ describe("recent activities for Agent suggestions", () => {
|
||||
expect(activities.filter((item) => item.kind === "session").map((item) => item.title)).toEqual(["updated-old-conversation"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recommendation cache lifecycle", () => {
|
||||
let root: Root | undefined;
|
||||
afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; vi.restoreAllMocks(); });
|
||||
it("reuses recommendations across history refresh, panel reopen and a new empty conversation", async () => {
|
||||
const container = document.createElement("div");
|
||||
root = createRoot(container);
|
||||
const request = vi.fn(async () => [{ question: "核查当前压力", activityId: null }]);
|
||||
const empty: never[] = [];
|
||||
function Probe({ enabled, sessionId }: { enabled: boolean; sessionId: string | null }) {
|
||||
const result = useAgentPromptSuggestions({ enabled, model: "fixture/model", cacheScope: "project:a", sessionId,
|
||||
request, alerts: empty, alertDataSource: "backend", conditions: empty, runtimeDataSource: "backend", artifact: null, sessions: empty });
|
||||
return createElement("p", null, result.suggestions[0]?.question ?? "loading");
|
||||
}
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe, { enabled: false, sessionId: null }))));
|
||||
await act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe, { enabled: true, sessionId: `empty-${i}` }))));
|
||||
}
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).toBe("核查当前压力");
|
||||
});
|
||||
it("ignores task heartbeat timestamps but regenerates when an activity changes status", async () => {
|
||||
root = createRoot(document.createElement("div"));
|
||||
const request = vi.fn(async () => [{ question: "复核任务证据", activityId: "task:task-a" }]);
|
||||
const empty: never[] = [];
|
||||
function Probe({ time, status }: { time: number; status: "running" | "completed" }) {
|
||||
const condition: ScheduledConditionItem = { id: "task-a", kind: "condition", taskId: "scada-diagnosis", sessionId: "native-a",
|
||||
title: "SCADA 证据复核", summary: "检查压力监测", riskLevel: "normal", scheduledAt: "2026-10-22T01:00:00Z", updatedAt: time, status };
|
||||
useAgentPromptSuggestions({ enabled: true, model: "fixture/model", cacheScope: "project:a", sessionId: null, request,
|
||||
alerts: empty, alertDataSource: "backend", conditions: [condition], runtimeDataSource: "backend", artifact: null, sessions: empty });
|
||||
return null;
|
||||
}
|
||||
for (let i = 0; i < 10; i++) await act(async () => root!.render(createElement(WorkbenchSessionProvider, null,
|
||||
createElement(Probe, { time: 1000 + i * 5000, status: "running" }))));
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
await act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe, { time: 60000, status: "completed" }))));
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("isolates projects and caches generation errors instead of retrying each reopen", async () => {
|
||||
root = createRoot(document.createElement("div"));
|
||||
const request = vi.fn(async () => { throw new Error("unavailable"); });
|
||||
const empty: never[] = [];
|
||||
function Probe({ scope, enabled }: { scope: string; enabled: boolean }) {
|
||||
useAgentPromptSuggestions({ enabled, cacheScope: scope, model: "fixture/model", sessionId: null,
|
||||
request, alerts: empty, alertDataSource: "backend", conditions: empty, runtimeDataSource: "backend", artifact: null, sessions: empty });
|
||||
return null;
|
||||
}
|
||||
for (const [scope, enabled] of [["a", true], ["a", false], ["a", true], ["b", true]] as const) {
|
||||
await act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe, { scope, enabled }))));
|
||||
}
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { AgentApiClient, AgentChatSessionSummary, AgentPromptSuggestion, Ag
|
||||
import type { WorkbenchAlert, ScheduledConditionItem, WorkbenchAlertDataSource, WorkbenchRuntimeDataSource } from "../types";
|
||||
import type { AnalysisArtifact } from "../workspace/workspace-model";
|
||||
|
||||
import { useWorkbenchSessionResources } from "./workbench-session-resources";
|
||||
|
||||
type ActivityInputs = {
|
||||
alerts: WorkbenchAlert[];
|
||||
alertDataSource: WorkbenchAlertDataSource;
|
||||
@@ -44,38 +46,52 @@ 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, sessionId, request, alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions }: ActivityInputs & {
|
||||
export function useAgentPromptSuggestions({ enabled, model, cacheScope = "project", request, alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions }: ActivityInputs & {
|
||||
enabled: boolean;
|
||||
model: string;
|
||||
sessionId: string | null;
|
||||
cacheScope?: string;
|
||||
request: AgentApiClient["generatePromptSuggestions"];
|
||||
}) {
|
||||
const { cache } = useWorkbenchSessionResources();
|
||||
const activities = useMemo(() => collectRecentAgentActivities({ alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions }),
|
||||
[alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions]);
|
||||
const snapshot = useRef(activities);
|
||||
useEffect(() => { snapshot.current = activities; }, [activities]);
|
||||
const key = JSON.stringify([enabled, model, sessionId]);
|
||||
const [state, setState] = useState<{ key: string; suggestions: DisplaySuggestion[]; loading: boolean; unavailable: boolean }>({
|
||||
key: "", suggestions: [], loading: false, unavailable: false,
|
||||
// Heartbeat timestamps change while the activity itself stays the same.
|
||||
const digest = JSON.stringify([...activities].sort((a, b) => a.id.localeCompare(b.id)).map((activity) => ({
|
||||
id: activity.id, kind: activity.kind, title: activity.title, summary: activity.summary,
|
||||
status: activity.status, origin: activity.origin,
|
||||
})));
|
||||
const activitySnapshot = useRef(activities);
|
||||
activitySnapshot.current = activities;
|
||||
const key = JSON.stringify(["suggestions", cacheScope, model, digest]);
|
||||
const [state, setState] = useState<{ scope: string; suggestions: DisplaySuggestion[]; loading: boolean; unavailable: boolean }>({
|
||||
scope: cacheScope, suggestions: [], loading: false, unavailable: false,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const controller = new AbortController();
|
||||
const context = snapshot.current;
|
||||
setState({ key, suggestions: [], loading: true, unavailable: false });
|
||||
void request(context, model, controller.signal).then((suggestions) => {
|
||||
if (controller.signal.aborted) return;
|
||||
let disposed = false;
|
||||
const previous = cache.peek<DisplaySuggestion[]>(key);
|
||||
const apply = (suggestions: DisplaySuggestion[]) => {
|
||||
if (!disposed) setState({ scope: cacheScope, suggestions, loading: false, unavailable: false });
|
||||
};
|
||||
if (previous?.value) apply(previous.value);
|
||||
else setState((current) => ({ scope: cacheScope,
|
||||
suggestions: current.scope === cacheScope ? current.suggestions : [], loading: true, unavailable: false }));
|
||||
void cache.load(key, async () => {
|
||||
const context = activitySnapshot.current;
|
||||
const suggestions = await request(context, model, AbortSignal.timeout(60_000));
|
||||
const sourceById = new Map(context.map((item) => [item.id, item]));
|
||||
setState({ key, loading: false, unavailable: false, suggestions: suggestions.map((item) => {
|
||||
return suggestions.map((item) => {
|
||||
const source = item.activityId ? sourceById.get(item.activityId) : undefined;
|
||||
return { ...item, sourceLabel: source
|
||||
? `${source.origin === "demo" ? "演示" : ""}${activityLabels[source.kind]} · ${timeFormatter.format(new Date(source.occurred_at))}`
|
||||
: "供水任务建议" };
|
||||
}) });
|
||||
}).catch(() => {
|
||||
if (!controller.signal.aborted) setState({ key, suggestions: [], loading: false, unavailable: true });
|
||||
});
|
||||
}, 10 * 60_000, { errorTtl: 30_000 }).then(apply).catch(() => {
|
||||
if (!disposed) setState((current) => ({ scope: cacheScope,
|
||||
suggestions: current.scope === cacheScope ? current.suggestions : [], loading: false, unavailable: true }));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [enabled, key, model, request]);
|
||||
return state.key === key ? state : { suggestions: [], loading: enabled, unavailable: false };
|
||||
return () => { disposed = true; };
|
||||
}, [cache, cacheScope, digest, enabled, key, model, request]);
|
||||
return state.scope === cacheScope ? state : { suggestions: [], loading: enabled, unavailable: false };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { SWRConfig } from "swr";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import type { AgentApiClient, AgentTopic } from "@/features/agent";
|
||||
import { useAgentTopics } from "./use-agent-topics";
|
||||
import { WorkbenchSessionProvider } from "./workbench-session-provider";
|
||||
|
||||
it("reuses theme reads and keeps a late project result out of the personal theme list", async () => {
|
||||
const topic = (id: string): AgentTopic => ({ id, name: id, createdAt: "2026-10-22T01:00:00Z", updatedAt: "2026-10-22T01:00:00Z" });
|
||||
let finish!: (topics: AgentTopic[]) => void;
|
||||
const projectRead = vi.fn(() => new Promise<AgentTopic[]>((resolve) => { finish = resolve; }));
|
||||
const personalRead = vi.fn(async () => [topic("personal-theme")]);
|
||||
const clients = { project: { listTopics: projectRead }, personal: { listTopics: personalRead } } as unknown as Record<"project" | "personal", AgentApiClient>;
|
||||
const root = createRoot(document.createElement("div"));
|
||||
const cache = new Map();
|
||||
let topics!: ReturnType<typeof useAgentTopics>;
|
||||
function Probe({ scope }: { scope: "project" | "personal" }) { topics = useAgentTopics(clients[scope], scope); return null; }
|
||||
const render = (scope: "project" | "personal") => root.render(createElement(WorkbenchSessionProvider, null,
|
||||
createElement(SWRConfig, { value: { provider: () => cache, dedupingInterval: 0 } }, createElement(Probe, { scope }))));
|
||||
try {
|
||||
await act(async () => render("project"));
|
||||
let pending!: Promise<void>;
|
||||
await act(async () => { pending = topics.ensure(); });
|
||||
await act(async () => render("personal"));
|
||||
await act(async () => { finish([topic("project-theme")]); await pending; });
|
||||
expect(topics.topics.map((entry) => entry.id)).toEqual(["personal-theme"]);
|
||||
for (let i = 0; i < 10; i++) await act(async () => { await topics.ensure(); });
|
||||
expect(projectRead).toHaveBeenCalledTimes(1);
|
||||
expect(personalRead).toHaveBeenCalledTimes(1);
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
|
||||
it("keeps a newly created topic selectable when the open menu's older read finishes later", async () => {
|
||||
const topic = (id: string): AgentTopic => ({ id, name: id, createdAt: "2026-10-22T01:00:00Z", updatedAt: "2026-10-22T01:00:00Z" });
|
||||
let finish!: (topics: AgentTopic[]) => void;
|
||||
const client = {
|
||||
listTopics: vi.fn(() => new Promise<AgentTopic[]>((resolve) => { finish = resolve; })),
|
||||
createTopic: vi.fn(async () => topic("new-topic")),
|
||||
} as unknown as AgentApiClient;
|
||||
let topics!: ReturnType<typeof useAgentTopics>;
|
||||
function Probe() { topics = useAgentTopics(client, "project:slow-read"); return null; }
|
||||
const root = createRoot(document.createElement("div"));
|
||||
const cache = new Map();
|
||||
try {
|
||||
await act(async () => root.render(createElement(WorkbenchSessionProvider, null,
|
||||
createElement(SWRConfig, { value: { provider: () => cache, dedupingInterval: 0 } }, createElement(Probe)))));
|
||||
let reading!: Promise<void>;
|
||||
let creating!: Promise<AgentTopic>;
|
||||
await act(async () => { reading = topics.ensure(); });
|
||||
await act(async () => { creating = topics.create("new-topic"); });
|
||||
await act(async () => { finish([topic("existing-topic")]); await reading; await creating; });
|
||||
expect(topics.topics.map((entry) => entry.id)).toEqual(["existing-topic", "new-topic"]);
|
||||
await act(async () => { await topics.ensure(); });
|
||||
expect(topics.topics.map((entry) => entry.id)).toEqual(["existing-topic", "new-topic"]);
|
||||
expect(client.listTopics).toHaveBeenCalledTimes(1);
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import useSWR from "swr";
|
||||
import type { AgentApiClient, AgentTopic } from "@/features/agent";
|
||||
import { useWorkbenchSessionResources } from "./workbench-session-resources";
|
||||
|
||||
const TTL = 5 * 60_000;
|
||||
|
||||
export function useAgentTopics(client: AgentApiClient, scope: string) {
|
||||
const resources = useWorkbenchSessionResources();
|
||||
const currentScope = useRef(scope);
|
||||
currentScope.current = scope;
|
||||
const key = JSON.stringify(["agent-topics", scope]);
|
||||
const fetchTopics = useCallback(() => resources.cache.load(key, () => client.listTopics(), TTL), [client, key, resources]);
|
||||
const state = useSWR(["agent-topics", scope], fetchTopics, { revalidateOnFocus: false, shouldRetryOnError: false });
|
||||
const save = async (change: (current: AgentTopic[]) => AgentTopic[]) => {
|
||||
const next = change(resources.cache.peek<AgentTopic[]>(key)?.value ?? state.data ?? []);
|
||||
if (resources.disposed) return;
|
||||
resources.cache.set(key, next, TTL);
|
||||
if (currentScope.current === scope) await state.mutate(next, { revalidate: false });
|
||||
};
|
||||
const ensure = async () => {
|
||||
const topics = await fetchTopics();
|
||||
if (!resources.disposed && currentScope.current === scope) await state.mutate(topics, { revalidate: false });
|
||||
};
|
||||
return {
|
||||
topics: state.data ?? [], loading: state.isLoading, error: state.error,
|
||||
ensure,
|
||||
// Finish the shared read before writing, so a late menu snapshot cannot replace the mutation.
|
||||
create: async (name: string) => { await ensure(); const topic = await client.createTopic(name); await save((current) => [...current, topic]); return topic; },
|
||||
rename: async (id: string, name: string) => { await ensure(); const topic = await client.renameTopic(id, name); await save((current) => current.map((entry) => entry.id === id ? topic : entry)); },
|
||||
remove: async (id: string) => { await ensure(); await client.deleteTopic(id); await save((current) => current.filter((topic) => topic.id !== id)); },
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import {
|
||||
fetchElementGisProperties,
|
||||
fetchSourceGisFeatureProperties,
|
||||
@@ -18,7 +18,7 @@ const INITIAL_STATE: GisPropertiesState = { status: "idle", groups: [] };
|
||||
export function useElementGisProperties(
|
||||
feature: DetailFeature | null,
|
||||
projectId: string | null,
|
||||
getAccessToken?: AccessTokenProvider
|
||||
getAccessToken?: WorkbenchAccess
|
||||
) {
|
||||
const [state, setState] = useState<GisPropertiesState>(INITIAL_STATE);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import type { FeatureCollection } from "geojson";
|
||||
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import { fetchOperationalTask, fetchOperationalTaskPresentationCursor, type OperationalTask } from "../api/operational-task-client";
|
||||
import type { InvestigationMapTarget } from "../api/investigation-report";
|
||||
import type { InvestigationOverlay, InvestigationPresentationCommand } from "../api/investigation-presentation";
|
||||
@@ -13,7 +13,7 @@ import { InvestigationPresentationFollower } from "./investigation-presentation-
|
||||
export type InvestigationChart = Extract<InvestigationPresentationCommand, { action: "open_chart" }>;
|
||||
type Options = {
|
||||
enabled: boolean; task: OperationalTask | null; projectId: string | null;
|
||||
sync: WorkbenchSyncState; getAccessToken?: AccessTokenProvider;
|
||||
sync: WorkbenchSyncState; getAccessToken?: WorkbenchAccess;
|
||||
mapRef: RefObject<MapLibreMap | null>; mapReady: boolean;
|
||||
controller: WorkbenchMapController; getPadding: () => PaddingOptions;
|
||||
onClearLayers: () => void;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
useMapInteractions,
|
||||
clearMapFeatureInteractionState,
|
||||
isPointOccludedByCityBuilding,
|
||||
setMapFeatureInteractionState,
|
||||
@@ -71,3 +74,17 @@ describe("map feature interaction state", () => {
|
||||
expect(queryRenderedFeatures).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("suspends feature picking while the map is used to look up a place", () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const map = { on: vi.fn(), off: vi.fn(), getLayer: vi.fn(() => ({})), getCanvas: () => canvas };
|
||||
const props = { enabled: false, mapRef: { current: map as unknown as MapLibreMap },
|
||||
availableSourceIds: ["pipes"] as const, mapReady: true, onSelectFeature: vi.fn(), selectedFeature: null };
|
||||
const { rerender } = renderHook(useMapInteractions, { initialProps: props });
|
||||
expect(map.on).not.toHaveBeenCalled();
|
||||
rerender({ ...props, enabled: true });
|
||||
expect(map.on).toHaveBeenCalledWith("click", expect.any(Array), expect.any(Function));
|
||||
rerender(props);
|
||||
expect(map.off).toHaveBeenCalledWith("click", expect.any(Array), expect.any(Function));
|
||||
});
|
||||
|
||||
@@ -58,6 +58,7 @@ export function isPointOccludedByCityBuilding(map: CityOcclusionMap, point: Poin
|
||||
}
|
||||
|
||||
type UseMapInteractionsOptions = {
|
||||
enabled?: boolean;
|
||||
availableSourceIds: readonly WaterNetworkSourceId[];
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
@@ -66,6 +67,7 @@ type UseMapInteractionsOptions = {
|
||||
};
|
||||
|
||||
export function useMapInteractions({
|
||||
enabled = true,
|
||||
availableSourceIds,
|
||||
mapRef,
|
||||
mapReady,
|
||||
@@ -74,7 +76,7 @@ export function useMapInteractions({
|
||||
}: UseMapInteractionsOptions) {
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) return;
|
||||
if (!mapReady || !map || !enabled) return;
|
||||
|
||||
let hoveredFeature: FeatureReference | null = null;
|
||||
|
||||
@@ -127,12 +129,15 @@ export function useMapInteractions({
|
||||
map.on("click", hitLayerIds, handleClick);
|
||||
|
||||
return () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Read the live owner ref to detect map disposal.
|
||||
if (mapRef.current !== map) return;
|
||||
clearHoveredFeature();
|
||||
map.getCanvas().style.cursor = "";
|
||||
map.off("mousemove", hitLayerIds, handleMouseMove);
|
||||
map.off("mouseleave", hitLayerIds, handleMouseLeave);
|
||||
map.off("click", hitLayerIds, handleClick);
|
||||
};
|
||||
}, [availableSourceIds, mapRef, mapReady, onSelectFeature, selectedFeature]);
|
||||
}, [availableSourceIds, mapRef, mapReady, onSelectFeature, selectedFeature, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
@@ -142,7 +147,8 @@ export function useMapInteractions({
|
||||
setMapFeatureInteractionState(map, next, { selected: true, hovered: false });
|
||||
}
|
||||
return () => {
|
||||
if (next && map.getSource(next.source)) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Read the live owner ref to detect map disposal.
|
||||
if (mapRef.current === map && next && map.getSource(next.source)) {
|
||||
clearMapFeatureInteractionState(map, next, "selected");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import {
|
||||
acknowledgeOperationalTaskNotification,
|
||||
createOperationalRequestId,
|
||||
@@ -23,7 +23,7 @@ import type { WorkbenchEvent } from "../api/workbench-sync-client";
|
||||
import type { WorkbenchSyncState } from "./use-workbench-sync";
|
||||
|
||||
|
||||
export type ManualInvestigationKind = "water_quality_complaint" | "burst_report";
|
||||
export type ManualInvestigationKind = "water_quality_complaint" | "burst_report" | "scada_anomaly";
|
||||
export type CreateInvestigationInput = {
|
||||
kind: ManualInvestigationKind;
|
||||
title: string;
|
||||
@@ -66,7 +66,7 @@ export function selectHeaderNotifications(items: OperationalTaskNotification[])
|
||||
}
|
||||
|
||||
export function useOperationalTasks(
|
||||
getAccessToken: AccessTokenProvider | undefined,
|
||||
getAccessToken: WorkbenchAccess | undefined,
|
||||
sync: WorkbenchSyncState
|
||||
) {
|
||||
const syncEvents = sync.events;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,7 @@ export type WorkbenchDrawingAnnotation = {
|
||||
};
|
||||
|
||||
type UseWorkbenchDrawingOptions = {
|
||||
selectionEnabled?: boolean;
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
activeMode: WorkbenchDrawMode | null;
|
||||
@@ -47,6 +48,7 @@ type UseWorkbenchDrawingOptions = {
|
||||
};
|
||||
|
||||
export function useWorkbenchDrawing({
|
||||
selectionEnabled = true,
|
||||
mapRef,
|
||||
mapReady,
|
||||
activeMode,
|
||||
@@ -134,7 +136,7 @@ export function useWorkbenchDrawing({
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map || activeMode) {
|
||||
if (!mapReady || !map || activeMode || !selectionEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -167,6 +169,9 @@ export function useWorkbenchDrawing({
|
||||
});
|
||||
|
||||
return () => {
|
||||
// The map owner may already have disposed this instance during project switching.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Read the live owner ref to detect map disposal.
|
||||
if (mapRef.current !== activeMap) return;
|
||||
DRAWING_INTERACTIVE_LAYER_IDS.forEach((layerId) => {
|
||||
if (activeMap.getLayer(layerId)) {
|
||||
activeMap.off("click", layerId, handleDrawingClick);
|
||||
@@ -175,7 +180,7 @@ export function useWorkbenchDrawing({
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [activeMode, mapReady, mapRef]);
|
||||
}, [activeMode, mapReady, mapRef, selectionEnabled]);
|
||||
|
||||
const updateDraft = useCallback(
|
||||
(draft: DrawingDraftState | null) => {
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import type { Map as MapLibreMap, MapMouseEvent } from "maplibre-gl";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { searchPlaces, type PlaceSearchItem, type PlaceSearchResults } from "../api/place-search-client";
|
||||
import { reverseGeocodePoint, searchPlaces, type PlaceSearchItem, type PlaceSearchResults } from "../api/place-search-client";
|
||||
import { locateGeocodingResult, markGeocodingLocation } from "../map/geocoding-location";
|
||||
import { useWorkbenchGeocoding } from "./use-workbench-geocoding";
|
||||
|
||||
vi.mock("../api/place-search-client", () => ({ searchPlaces: vi.fn() }));
|
||||
vi.mock("../api/place-search-client", () => ({ searchPlaces: vi.fn(), reverseGeocodePoint: vi.fn() }));
|
||||
vi.mock("../map/geocoding-location", () => ({ locateGeocodingResult: vi.fn(), markGeocodingLocation: vi.fn() }));
|
||||
const location: PlaceSearchItem = { id: "place-1", name: "南桥路", address: "奉贤区南桥路", longitude: 121.44, latitude: 31.22, category: "道路名", district: "奉贤区" };
|
||||
const other = { ...location, id: "place-2", address: "奉贤区南桥路", longitude: 121.35, latitude: 30.77 };
|
||||
const results: PlaceSearchResults = { items: [location, other], total: 2, truncated: false, region: "奉贤区" };
|
||||
const remove = vi.fn();
|
||||
const options = {
|
||||
placeSearch: { enabled: true, provider: "tianditu" as const, admin_code: "310120", region: "奉贤区" },
|
||||
active: true, projectId: "project-1", mapRef: { current: {} as MapLibreMap }, mapReady: true,
|
||||
getPadding: () => ({ top: 48, right: 400, bottom: 48, left: 48 }), reducedMotion: true, onLocate: vi.fn()
|
||||
};
|
||||
@@ -22,6 +23,23 @@ beforeEach(() => {
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("place search selection lifecycle", () => {
|
||||
it("disables search without project configuration, even for the original workspace", async () => {
|
||||
const { result } = renderHook(useWorkbenchGeocoding, {
|
||||
initialProps: { ...options, placeSearch: null }
|
||||
});
|
||||
act(() => result.current.changeAddress("南桥路"));
|
||||
await act(() => result.current.search());
|
||||
expect(result.current.configured).toBe(false);
|
||||
expect(result.current.error).toBe("当前项目暂未配置地名搜索");
|
||||
expect(searchPlaces).not.toHaveBeenCalled();
|
||||
});
|
||||
it("uses the selected project's configured region", async () => {
|
||||
const { result } = renderHook(useWorkbenchGeocoding, {
|
||||
initialProps: { ...options, placeSearch: { ...options.placeSearch, admin_code: "341126", region: "凤阳县" } }
|
||||
});
|
||||
expect(result.current.configured).toBe(true);
|
||||
expect(result.current.region).toBe("凤阳县");
|
||||
});
|
||||
it("does not move until selection and replaces the previous marker", async () => {
|
||||
vi.mocked(searchPlaces).mockResolvedValue(results);
|
||||
const { result, rerender } = renderHook(useWorkbenchGeocoding, { initialProps: options });
|
||||
@@ -84,3 +102,95 @@ describe("place search selection lifecycle", () => {
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
function pickingMap() {
|
||||
const canvas = document.createElement("canvas");
|
||||
const listeners = new Map<string, (event: MapMouseEvent) => void>();
|
||||
const map = {
|
||||
on: vi.fn((name: string, handler: (event: MapMouseEvent) => void) => listeners.set(name, handler)),
|
||||
off: vi.fn((name: string) => listeners.delete(name)),
|
||||
getCanvas: () => canvas
|
||||
};
|
||||
return {
|
||||
map: map as unknown as MapLibreMap,
|
||||
canvas,
|
||||
click: (longitude = location.longitude, latitude = location.latitude) => listeners.get("click")?.({
|
||||
lngLat: { lng: longitude, lat: latitude }, preventDefault: vi.fn()
|
||||
} as unknown as MapMouseEvent)
|
||||
};
|
||||
}
|
||||
|
||||
describe("map point place lookup", () => {
|
||||
it("ends picking after a successful lookup and keeps the marker and camera unchanged", async () => {
|
||||
const map = pickingMap();
|
||||
const onPickingChange = vi.fn();
|
||||
const props = { ...options, picking: true, onPickingChange, mapRef: { current: map.map } };
|
||||
vi.mocked(reverseGeocodePoint).mockResolvedValue(location);
|
||||
const { result, rerender } = renderHook(useWorkbenchGeocoding, { initialProps: props });
|
||||
onPickingChange.mockClear();
|
||||
expect(map.canvas.style.cursor).toBe("crosshair");
|
||||
await act(async () => { map.click(); });
|
||||
expect(result.current.result).toEqual(location);
|
||||
expect(result.current.origin).toBe("map");
|
||||
expect(result.current.choosing).toBe(false);
|
||||
expect(onPickingChange).toHaveBeenCalledExactlyOnceWith(false);
|
||||
expect(markGeocodingLocation).toHaveBeenCalledWith(map.map, location);
|
||||
expect(locateGeocodingResult).not.toHaveBeenCalled();
|
||||
act(() => result.current.locate());
|
||||
expect(locateGeocodingResult).toHaveBeenCalledOnce();
|
||||
rerender({ ...props, picking: false });
|
||||
expect(map.canvas.style.cursor).toBe("");
|
||||
expect(map.map.off).toHaveBeenCalledWith("click", expect.any(Function));
|
||||
});
|
||||
|
||||
it("ignores an older point after a second click", async () => {
|
||||
const map = pickingMap();
|
||||
let first!: (value: PlaceSearchItem) => void;
|
||||
let second!: (value: PlaceSearchItem) => void;
|
||||
vi.mocked(reverseGeocodePoint)
|
||||
.mockReturnValueOnce(new Promise((resolve) => { first = resolve; }))
|
||||
.mockReturnValueOnce(new Promise((resolve) => { second = resolve; }));
|
||||
const { result } = renderHook(() => useWorkbenchGeocoding({ ...options, picking: true, mapRef: { current: map.map } }));
|
||||
act(() => map.click());
|
||||
const firstSignal = vi.mocked(reverseGeocodePoint).mock.calls[0][4];
|
||||
act(() => map.click(other.longitude, other.latitude));
|
||||
expect(firstSignal?.aborted).toBe(true);
|
||||
await act(async () => { second(other); });
|
||||
await act(async () => { first(location); });
|
||||
expect(result.current.result).toEqual(other);
|
||||
});
|
||||
|
||||
it.each(["close", "project", "escape"])("aborts an outstanding map lookup on %s", async (action) => {
|
||||
const map = pickingMap();
|
||||
let resolve!: (value: PlaceSearchItem) => void;
|
||||
vi.mocked(reverseGeocodePoint).mockReturnValue(new Promise((done) => { resolve = done; }));
|
||||
const props = { ...options, picking: true, onPickingChange: vi.fn(), mapRef: { current: map.map } };
|
||||
const { result, rerender } = renderHook(useWorkbenchGeocoding, { initialProps: props });
|
||||
act(() => map.click());
|
||||
const signal = vi.mocked(reverseGeocodePoint).mock.calls[0][4];
|
||||
if (action === "close") rerender({ ...props, active: false });
|
||||
if (action === "project") rerender({ ...props, projectId: "other-project" });
|
||||
if (action === "escape") act(() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
|
||||
expect(signal?.aborted).toBe(true);
|
||||
await act(async () => { resolve(location); });
|
||||
expect(result.current.result).toBeNull();
|
||||
expect(result.current.pending).toBe(false);
|
||||
expect(props.onPickingChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("shows a lookup error and accepts another point", async () => {
|
||||
const map = pickingMap();
|
||||
const onPickingChange = vi.fn();
|
||||
vi.mocked(reverseGeocodePoint).mockRejectedValueOnce(new Error("取点超出项目范围")).mockResolvedValueOnce(location);
|
||||
const { result } = renderHook(() => useWorkbenchGeocoding({ ...options, picking: true, onPickingChange, mapRef: { current: map.map } }));
|
||||
onPickingChange.mockClear();
|
||||
await act(async () => { map.click(); });
|
||||
expect(result.current.error).toBe("取点超出项目范围");
|
||||
expect(onPickingChange).not.toHaveBeenCalled();
|
||||
await act(async () => { map.click(); });
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.result).toEqual(location);
|
||||
expect(onPickingChange).toHaveBeenCalledExactlyOnceWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
|
||||
import type { Map as MapLibreMap, MapMouseEvent, PaddingOptions } from "maplibre-gl";
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { searchPlaces, type PlaceSearchItem, type PlaceSearchResults } from "../api/place-search-client";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import { reverseGeocodePoint, searchPlaces, type PlaceSearchItem, type PlaceSearchResults } from "../api/place-search-client";
|
||||
import { locateGeocodingResult, markGeocodingLocation } from "../map/geocoding-location";
|
||||
import type { PlaceSearchConfiguration } from "../api/project-client";
|
||||
|
||||
type GeocodingOptions = {
|
||||
placeSearch?: PlaceSearchConfiguration | null;
|
||||
active: boolean;
|
||||
picking?: boolean;
|
||||
onPickingChange?: (picking: boolean) => void;
|
||||
projectId: string | null;
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
getAccessToken?: WorkbenchAccess;
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
getPadding: () => PaddingOptions;
|
||||
@@ -16,6 +20,10 @@ type GeocodingOptions = {
|
||||
};
|
||||
|
||||
export function useWorkbenchGeocoding(options: GeocodingOptions) {
|
||||
const configured = Boolean(options.placeSearch?.enabled);
|
||||
const region = options.placeSearch?.region ?? "";
|
||||
const picking = Boolean(options.active && configured && options.mapReady && options.picking);
|
||||
const [origin, setOrigin] = useState<"search" | "map">("search");
|
||||
const [address, setAddress] = useState("");
|
||||
const [result, setResult] = useState<PlaceSearchItem | null>(null);
|
||||
const [results, setResults] = useState<PlaceSearchResults | null>(null);
|
||||
@@ -37,15 +45,20 @@ export function useWorkbenchGeocoding(options: GeocodingOptions) {
|
||||
setResults(null);
|
||||
setChoosing(true);
|
||||
setError(null);
|
||||
setOrigin("search");
|
||||
}, [cancel]);
|
||||
useEffect(() => {
|
||||
clear();
|
||||
setAddress("");
|
||||
optionsRef.current.onPickingChange?.(false);
|
||||
return () => requestRef.current?.abort();
|
||||
}, [options.projectId, options.getAccessToken, clear]);
|
||||
useEffect(() => {
|
||||
if (!options.active) cancel();
|
||||
}, [options.active, cancel]);
|
||||
if (!options.active || !configured) {
|
||||
cancel();
|
||||
optionsRef.current.onPickingChange?.(false);
|
||||
}
|
||||
}, [options.active, configured, cancel]);
|
||||
|
||||
const locate = useCallback(() => {
|
||||
const current = optionsRef.current;
|
||||
@@ -59,18 +72,75 @@ export function useWorkbenchGeocoding(options: GeocodingOptions) {
|
||||
const map = options.mapRef.current;
|
||||
if (!map || !options.mapReady || !result) return;
|
||||
const marker = markGeocodingLocation(map, result);
|
||||
locate();
|
||||
if (origin === "search") locate();
|
||||
return () => { marker.remove(); };
|
||||
}, [options.mapRef, options.mapReady, result, locate]);
|
||||
}, [options.mapRef, options.mapReady, result, origin, locate]);
|
||||
|
||||
async function lookupPoint(longitude: number, latitude: number) {
|
||||
const current = optionsRef.current;
|
||||
if (!current.active || !current.picking || !current.placeSearch?.enabled) return;
|
||||
clear();
|
||||
setOrigin("map");
|
||||
const controller = new AbortController();
|
||||
requestRef.current = controller;
|
||||
setPending(true);
|
||||
try {
|
||||
const item = await reverseGeocodePoint(longitude, latitude, current.projectId,
|
||||
current.getAccessToken, controller.signal, current.placeSearch);
|
||||
if (controller.signal.aborted || requestRef.current !== controller) return;
|
||||
setResult(item);
|
||||
setResults({ items: [item], total: 1, truncated: false, region: current.placeSearch.region });
|
||||
setChoosing(false);
|
||||
optionsRef.current.onPickingChange?.(false);
|
||||
} catch (failure) {
|
||||
if (controller.signal.aborted || requestRef.current !== controller) return;
|
||||
setError(failure instanceof Error && failure.name === "TimeoutError"
|
||||
? "地图取点查询超时,请重新取点"
|
||||
: failure instanceof Error ? failure.message : "地图取点查询失败,请重新取点");
|
||||
} finally {
|
||||
if (requestRef.current === controller) { requestRef.current = null; setPending(false); }
|
||||
}
|
||||
}
|
||||
const lookupPointRef = useRef(lookupPoint);
|
||||
lookupPointRef.current = lookupPoint;
|
||||
|
||||
useEffect(() => {
|
||||
const map = options.mapRef.current;
|
||||
if (!picking || !map) return;
|
||||
const canvas = map.getCanvas();
|
||||
const previousCursor = canvas.style.cursor;
|
||||
canvas.style.cursor = "crosshair";
|
||||
const handleClick = (event: MapMouseEvent) => {
|
||||
event.preventDefault();
|
||||
const longitude = ((event.lngLat.lng + 180) % 360 + 360) % 360 - 180;
|
||||
void lookupPointRef.current(longitude, event.lngLat.lat);
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
cancel();
|
||||
optionsRef.current.onPickingChange?.(false);
|
||||
};
|
||||
map.on("click", handleClick);
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => {
|
||||
map.off("click", handleClick);
|
||||
canvas.style.cursor = previousCursor;
|
||||
window.removeEventListener("keydown", handleKeyDown, true);
|
||||
};
|
||||
}, [picking, options.mapRef, cancel]);
|
||||
|
||||
async function search() {
|
||||
optionsRef.current.onPickingChange?.(false);
|
||||
clear();
|
||||
if (!configured) { setError("当前项目暂未配置地名搜索"); return; }
|
||||
if (!address.trim()) { setError("请输入需要搜索的地址或地名"); return; }
|
||||
const controller = new AbortController();
|
||||
requestRef.current = controller;
|
||||
setPending(true);
|
||||
try {
|
||||
const value = await searchPlaces(address, options.projectId, options.getAccessToken, controller.signal);
|
||||
const value = await searchPlaces(address, options.projectId, options.getAccessToken, controller.signal, options.placeSearch);
|
||||
if (controller.signal.aborted || requestRef.current !== controller) return;
|
||||
setResults(value);
|
||||
} catch (failure) {
|
||||
@@ -84,7 +154,12 @@ export function useWorkbenchGeocoding(options: GeocodingOptions) {
|
||||
}
|
||||
|
||||
return {
|
||||
address, result, results, choosing, pending, error, locate, search,
|
||||
address, result, results, choosing, pending, error, locate, search, region, origin, picking,
|
||||
togglePicking: () => {
|
||||
cancel();
|
||||
if (!picking) { clear(); setOrigin("map"); optionsRef.current.onLocate(); }
|
||||
optionsRef.current.onPickingChange?.(!picking);
|
||||
},
|
||||
select: (id: string) => {
|
||||
if (pending) return;
|
||||
const item = results?.items.find((candidate) => candidate.id === id);
|
||||
@@ -95,9 +170,10 @@ export function useWorkbenchGeocoding(options: GeocodingOptions) {
|
||||
}
|
||||
},
|
||||
changeSelection: () => setChoosing(true),
|
||||
enabled: options.mapReady,
|
||||
changeAddress: (value: string) => { clear(); setAddress(value); },
|
||||
clear: () => { clear(); setAddress(""); }
|
||||
configured,
|
||||
enabled: configured && options.mapReady,
|
||||
changeAddress: (value: string) => { optionsRef.current.onPickingChange?.(false); clear(); setAddress(value); },
|
||||
clear: () => { optionsRef.current.onPickingChange?.(false); clear(); setAddress(""); }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { WorkbenchMapController } from "../map/workbench-map-controller";
|
||||
|
||||
export function useWorkbenchMapController({
|
||||
mapRef,
|
||||
geoserverWorkspace,
|
||||
mapReady,
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
@@ -16,6 +17,7 @@ export function useWorkbenchMapController({
|
||||
getScadaFeatures,
|
||||
availableSourceIds
|
||||
}: {
|
||||
geoserverWorkspace?: string;
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
leftPanelOpen: boolean;
|
||||
@@ -42,6 +44,7 @@ export function useWorkbenchMapController({
|
||||
const controllerRef = useRef<WorkbenchMapController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new WorkbenchMapController({
|
||||
geoserverWorkspace,
|
||||
getMap: () => mapRef.current,
|
||||
isReady: () => valuesRef.current.mapReady,
|
||||
getPadding: () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
StyleSpecification
|
||||
} from "maplibre-gl";
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { fetchScadaSnapshot, type ScadaDevice } from "../api/scada-client";
|
||||
import type { DetailFeature } from "../types";
|
||||
@@ -62,6 +62,7 @@ declare global {
|
||||
}
|
||||
|
||||
type UseWorkbenchMapOptions = {
|
||||
featureInteractionsEnabled?: boolean;
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
impactVisible: boolean;
|
||||
layerVisibility: Record<string, boolean>;
|
||||
@@ -71,7 +72,8 @@ type UseWorkbenchMapOptions = {
|
||||
simulationOverlay: FeatureCollection<Geometry> | null;
|
||||
allowDemoSimulation: boolean;
|
||||
networkView?: NetworkViewParams | null;
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
getAccessToken?: WorkbenchAccess;
|
||||
geoserverWorkspace?: string;
|
||||
};
|
||||
|
||||
type UseWorkbenchMapResult = {
|
||||
@@ -132,6 +134,7 @@ const EMPTY_SIMULATION_COLLECTION: FeatureCollection = {
|
||||
};
|
||||
|
||||
export function useWorkbenchMap({
|
||||
featureInteractionsEnabled = true,
|
||||
containerRef,
|
||||
impactVisible,
|
||||
layerVisibility,
|
||||
@@ -141,6 +144,7 @@ export function useWorkbenchMap({
|
||||
simulationOverlay,
|
||||
allowDemoSimulation,
|
||||
networkView,
|
||||
geoserverWorkspace,
|
||||
getAccessToken
|
||||
}: UseWorkbenchMapOptions): UseWorkbenchMapResult {
|
||||
const mapRef = useRef<MapLibreMap | null>(null);
|
||||
@@ -199,7 +203,7 @@ export function useWorkbenchMap({
|
||||
);
|
||||
geoServerLayerProbeInFlightRef.current = true;
|
||||
|
||||
void resolveGeoServerLayerAvailability(requestAbortController.signal)
|
||||
void resolveGeoServerLayerAvailability(requestAbortController.signal, geoserverWorkspace)
|
||||
.then((availability) => {
|
||||
if (lifecycleAbortController.signal.aborted) return;
|
||||
setAvailableGeoServerSourceIds((current) =>
|
||||
@@ -237,7 +241,7 @@ export function useWorkbenchMap({
|
||||
geoServerLayerProbeInFlightRef.current = false;
|
||||
window.clearTimeout(probeTimeout);
|
||||
};
|
||||
}, [geoServerLayerRefreshRevision]);
|
||||
}, [geoServerLayerRefreshRevision, geoserverWorkspace]);
|
||||
|
||||
useEffect(() => {
|
||||
impactVisibleRef.current = impactVisible;
|
||||
@@ -306,7 +310,7 @@ export function useWorkbenchMap({
|
||||
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
|
||||
scadaCollectionRef.current
|
||||
);
|
||||
const sources = createWaterNetworkSources(REQUIRED_GEOSERVER_SOURCE_IDS);
|
||||
const sources = createWaterNetworkSources(REQUIRED_GEOSERVER_SOURCE_IDS, geoserverWorkspace);
|
||||
REQUIRED_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
|
||||
const source = sources[sourceId];
|
||||
if (source) map.addSource(sourceId, source);
|
||||
@@ -315,8 +319,10 @@ export function useWorkbenchMap({
|
||||
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
|
||||
map.addSource(SIMULATION_SOURCE_IDS.backendAnalysis, simulationSources.backendAnalysis);
|
||||
syncSimulationSourceData(map, simulationOverlayRef.current, allowDemoSimulationRef.current);
|
||||
await registerSupplyAssetImages(map);
|
||||
const scadaRegistration = await registerScadaImages(map);
|
||||
await registerSupplyAssetImages(map, scadaAbortController.signal);
|
||||
if (scadaAbortController.signal.aborted) return;
|
||||
const scadaRegistration = await registerScadaImages(map, scadaAbortController.signal);
|
||||
if (scadaAbortController.signal.aborted) return;
|
||||
const activeScadaBusinessLayers =
|
||||
scadaRegistration.status === "unavailable"
|
||||
? scadaFallbackBusinessLayers
|
||||
@@ -444,13 +450,13 @@ export function useWorkbenchMap({
|
||||
mapRef.current = null;
|
||||
delete window.__waterNetworkMap;
|
||||
};
|
||||
}, [containerRef, getAccessToken]);
|
||||
}, [containerRef, getAccessToken, geoserverWorkspace]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!mapReady || !map) return;
|
||||
syncDataDependentGeoServerLayers(map, availableGeoServerSourceIds, layerVisibility);
|
||||
}, [availableGeoServerSourceIds, layerVisibility, mapReady]);
|
||||
syncDataDependentGeoServerLayers(map, availableGeoServerSourceIds, layerVisibility, geoserverWorkspace);
|
||||
}, [availableGeoServerSourceIds, layerVisibility, mapReady, geoserverWorkspace]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
@@ -462,6 +468,7 @@ export function useWorkbenchMap({
|
||||
}, [mapReady, networkView]);
|
||||
|
||||
useMapInteractions({
|
||||
enabled: featureInteractionsEnabled,
|
||||
availableSourceIds: availableGeoServerSourceIds,
|
||||
mapRef,
|
||||
mapReady,
|
||||
@@ -557,7 +564,8 @@ function syncSimulationSourceData(
|
||||
function syncDataDependentGeoServerLayers(
|
||||
map: MapLibreMap,
|
||||
availableSourceIds: readonly GeoServerWaterNetworkSourceId[],
|
||||
layerVisibility: Record<string, boolean>
|
||||
layerVisibility: Record<string, boolean>,
|
||||
workspace?: string
|
||||
) {
|
||||
const availableSourceIdSet = new Set<GeoServerWaterNetworkSourceId>(availableSourceIds);
|
||||
|
||||
@@ -574,7 +582,7 @@ function syncDataDependentGeoServerLayers(
|
||||
const requestedOptionalSourceIds = DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.filter((sourceId) =>
|
||||
availableSourceIdSet.has(sourceId)
|
||||
);
|
||||
const sources = createWaterNetworkSources(requestedOptionalSourceIds);
|
||||
const sources = createWaterNetworkSources(requestedOptionalSourceIds, workspace);
|
||||
requestedOptionalSourceIds.forEach((sourceId) => {
|
||||
if (!map.getSource(sourceId) && sources[sourceId]) {
|
||||
map.addSource(sourceId, sources[sourceId]);
|
||||
@@ -620,7 +628,8 @@ function addMissingDataDependentLayers(
|
||||
layers: StyleSpecification["layers"],
|
||||
beforeLayerCandidates: readonly string[],
|
||||
availableSourceIdSet: ReadonlySet<GeoServerWaterNetworkSourceId>,
|
||||
layerVisibility: Record<string, boolean>
|
||||
layerVisibility: Record<string, boolean>,
|
||||
workspace?: string
|
||||
) {
|
||||
const beforeLayerId = beforeLayerCandidates.find((layerId) => map.getLayer(layerId));
|
||||
layers.forEach((layer) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ function presentation(
|
||||
systemRole: "user",
|
||||
isSystemAdmin: false,
|
||||
networkView: null,
|
||||
placeSearch: null,
|
||||
metadataAvailable: true,
|
||||
accessContextAvailable: true,
|
||||
...overrides
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import {
|
||||
fetchWorkbenchProjectPresentation,
|
||||
type WorkbenchProjectPresentation
|
||||
@@ -14,11 +14,17 @@ const PROJECT_ROLE_LABELS: Record<string, string> = {
|
||||
|
||||
export function useWorkbenchProjectPresentation(
|
||||
fallbackUser: WorkbenchUser,
|
||||
getAccessToken?: AccessTokenProvider
|
||||
getAccessToken?: WorkbenchAccess
|
||||
) {
|
||||
const [presentation, setPresentation] = useState<WorkbenchProjectPresentation | null>(null);
|
||||
const [presentation, setPresentation] = useState<WorkbenchProjectPresentation | null>(
|
||||
() => typeof getAccessToken === "object" ? getAccessToken.presentation ?? null : null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof getAccessToken === "object" && getAccessToken.presentation) {
|
||||
setPresentation(getAccessToken.presentation);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
void fetchWorkbenchProjectPresentation(getAccessToken, controller.signal)
|
||||
.then((value) => {
|
||||
@@ -39,6 +45,7 @@ export function useWorkbenchProjectPresentation(
|
||||
user,
|
||||
projectId: presentation?.projectId ?? null,
|
||||
networkView: presentation?.networkView ?? null,
|
||||
placeSearch: presentation?.placeSearch ?? null,
|
||||
dataSource: presentation?.metadataAvailable || presentation?.accessContextAvailable
|
||||
? "backend" as const
|
||||
: "fallback" as const
|
||||
|
||||
@@ -81,5 +81,6 @@ describe("workbench responsive layout", () => {
|
||||
|
||||
act(() => result.current.openAgentPanelForViewport());
|
||||
expect(result.current.mobileSheet).toBe("agent");
|
||||
expect(result.current.mobileSheetSnap).toBe("full");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,7 +96,7 @@ export function useWorkbenchResponsiveLayout({
|
||||
}
|
||||
|
||||
setMobileSheet("agent");
|
||||
setMobileSheetSnap("half");
|
||||
setMobileSheetSnap("full");
|
||||
}
|
||||
|
||||
function handleConditionExpandedChange(nextExpanded: boolean) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WorkbenchRequestContext } from "../api/request-context";
|
||||
import type { WorkbenchSyncState } from "./use-workbench-sync";
|
||||
import { useWorkbenchRuntimeData } from "./use-workbench-runtime-data";
|
||||
|
||||
vi.mock("../api/realtime-flow-client", () => ({
|
||||
fetchRealtimePipeFlows: vi.fn().mockResolvedValue({ records: [], observedAt: null })
|
||||
}));
|
||||
|
||||
const fengyangAccess: WorkbenchRequestContext = {
|
||||
project: {
|
||||
project_id: "b962c44e-8433-4a10-81e9-a64b70ac1152",
|
||||
name: "凤阳(新版)",
|
||||
code: "fengyang_v2",
|
||||
gs_workspace: "fengyang_v2",
|
||||
status: "active",
|
||||
project_role: "member"
|
||||
},
|
||||
getAccessToken: async () => "test-token"
|
||||
};
|
||||
|
||||
const sync = {
|
||||
snapshot: null,
|
||||
events: [],
|
||||
status: "live",
|
||||
error: null,
|
||||
detailResetGeneration: 0,
|
||||
acknowledgeEventsThrough: vi.fn(),
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
subscribePresentationEvents: vi.fn(() => () => undefined)
|
||||
} as unknown as WorkbenchSyncState;
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("project-scoped workbench runtime data", () => {
|
||||
it("shows mock scheduled conditions outside the demo project", async () => {
|
||||
const { result } = renderHook(() => useWorkbenchRuntimeData(fengyangAccess, sync));
|
||||
|
||||
await waitFor(() => expect(result.current.scheduledConditionsLoading).toBe(false));
|
||||
|
||||
expect(result.current.scheduledConditions.length).toBeGreaterThan(0);
|
||||
expect(result.current.scheduledConditions.every((condition) => condition.kind === "condition"))
|
||||
.toBe(true);
|
||||
expect(result.current.scenarios).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { getWorkbenchProject, type WorkbenchAccess } from "../api/request-context";
|
||||
import { env } from "@/shared/config/env";
|
||||
import {
|
||||
type WorkbenchRuntimeSnapshot
|
||||
@@ -128,9 +128,11 @@ function isSameLocalOperationalDate(value: string, operationalDate: Date) {
|
||||
}
|
||||
|
||||
export function useWorkbenchRuntimeData(
|
||||
getAccessToken: AccessTokenProvider | undefined,
|
||||
getAccessToken: WorkbenchAccess | undefined,
|
||||
sync: WorkbenchSyncState
|
||||
) {
|
||||
const scopedProject = getWorkbenchProject(getAccessToken);
|
||||
const allowDemoScenarios = !scopedProject || scopedProject.gs_workspace === "tjwater_next";
|
||||
const [headerDataTime, setHeaderDataTime] = useState(INITIAL_HEADER_DATA_TIME);
|
||||
const [mockConditions, setMockConditions] = useState<ScheduledConditionItem[]>([]);
|
||||
const backendSnapshot: WorkbenchRuntimeSnapshot | null = sync.snapshot?.runtime ?? null;
|
||||
@@ -252,7 +254,8 @@ export function useWorkbenchRuntimeData(
|
||||
scheduledConditionsLoading,
|
||||
realtimePipeFlows,
|
||||
flowDataSource: realtimePipeFlows ? "realtime" as const : "mock" as const,
|
||||
...resolvedRuntimeData
|
||||
...resolvedRuntimeData,
|
||||
scenarios: !allowDemoScenarios && resolvedRuntimeData.scenarioDataSource === "mock" ? [] : resolvedRuntimeData.scenarios
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import {
|
||||
consumeWorkbenchEvents,
|
||||
fetchWorkbenchSyncSnapshot,
|
||||
@@ -45,7 +45,7 @@ export function shouldApplyWorkbenchSnapshot(input: {
|
||||
&& (input.resetCursor || input.eventCursor >= input.currentCursor);
|
||||
}
|
||||
|
||||
export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): WorkbenchSyncState {
|
||||
export function useWorkbenchSync(getAccessToken?: WorkbenchAccess): WorkbenchSyncState {
|
||||
const channelRef = useRef(new WorkbenchEventChannel());
|
||||
const [snapshot, setSnapshot] = useState<WorkbenchSyncSnapshot | null>(null);
|
||||
const [events, setEvents] = useState<WorkbenchEvent[]>([]);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useEffect, useMemo, type ReactNode } from "react";
|
||||
import { WorkbenchSessionContext, WorkbenchSessionResources } from "./workbench-session-resources";
|
||||
|
||||
export function WorkbenchSessionProvider({ children, owner }: { children: ReactNode; owner?: unknown }) {
|
||||
const resources = useMemo(() => new WorkbenchSessionResources(owner), [owner]);
|
||||
useEffect(() => { resources.retain(); return () => resources.release(); }, [resources]);
|
||||
return <WorkbenchSessionContext.Provider key={resources.id} value={resources}>{children}</WorkbenchSessionContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import type { AgentSessionRuntime } from "./agent-session-runtime";
|
||||
import { ResourceCache } from "@/shared/cache/resource-cache";
|
||||
|
||||
export class WorkbenchSessionResources {
|
||||
constructor(readonly owner?: unknown) {}
|
||||
readonly id = crypto.randomUUID();
|
||||
disposed = false;
|
||||
readonly cache = new ResourceCache();
|
||||
readonly agents = new Map<string, AgentSessionRuntime>();
|
||||
readonly disposers = new Set<() => void>();
|
||||
activeAgent: AgentSessionRuntime | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
private revision = 0;
|
||||
private retainCount = 0;
|
||||
subscribe = (listener: () => void) => { this.listeners.add(listener); return () => this.listeners.delete(listener); };
|
||||
getSnapshot = () => this.revision;
|
||||
notify() { if (!this.disposed) { this.revision++; this.listeners.forEach((listener) => listener()); } }
|
||||
retain() { this.retainCount++; }
|
||||
release() {
|
||||
this.retainCount--;
|
||||
// StrictMode immediately reacquires its lease; a real unmount releases it.
|
||||
queueMicrotask(() => { if (this.retainCount === 0) this.dispose(); });
|
||||
}
|
||||
dispose() { this.disposed = true; this.activeAgent = null; this.disposers.forEach((dispose) => dispose()); this.disposers.clear(); this.cache.clear(); this.agents.clear(); }
|
||||
}
|
||||
|
||||
export const WorkbenchSessionContext = createContext<WorkbenchSessionResources | null>(null);
|
||||
|
||||
export function useWorkbenchSessionResources() {
|
||||
const provided = useContext(WorkbenchSessionContext);
|
||||
const [fallback] = useState(() => new WorkbenchSessionResources());
|
||||
return provided ?? fallback;
|
||||
}
|
||||
Reference in New Issue
Block a user