feat: initialize drainage network frontend

This commit is contained in:
2026-07-10 16:16:17 +08:00
commit 66de96d9e4
133 changed files with 33057 additions and 0 deletions
@@ -0,0 +1,484 @@
import type { UIMessage } from "ai";
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import type {
AgentChatMessage,
AgentPermissionReply,
AgentPermissionStatus,
AgentQuestionRequest,
AgentStreamRenderState
} from "@/features/agent";
import type { AgentSessionStreamEvent } from "@/features/agent/api/client";
import {
applyPermissionResponse,
applyQuestionResponse,
toTodoUpdate,
upsertPermission,
upsertProgress,
upsertQuestion
} from "@/features/agent/session-state";
type AgentUiDataParts = {
progress: Record<string, unknown>;
todo_update: Record<string, unknown>;
permission_request: Record<string, unknown>;
permission_response: Record<string, unknown>;
question_request: Record<string, unknown>;
question_response: Record<string, unknown>;
stream_token: Record<string, unknown>;
tool_call: Record<string, unknown>;
ui_envelope: Record<string, unknown>;
session_title: Record<string, unknown>;
};
export type AgentUiMessage = UIMessage<unknown, AgentUiDataParts>;
export type AgentDataPart = Extract<AgentUiMessage["parts"][number], { type: `data-${string}` }>;
export type PermissionOverride = {
status: AgentPermissionStatus;
error?: string;
};
export type QuestionOverride = {
status: AgentQuestionRequest["status"];
answers?: string[][];
error?: string;
};
type StreamRenderStateSetter = Dispatch<SetStateAction<AgentStreamRenderState>>;
type StreamRenderChunkIdRef = MutableRefObject<number>;
export function appendStreamRenderToken(
data: Record<string, unknown>,
messages: AgentUiMessage[],
setStreamRenderState: StreamRenderStateSetter,
chunkIdRef: StreamRenderChunkIdRef
) {
const text = getDataString(data, "content");
if (!text) {
return;
}
const messageId = getDataString(data, "message_id") ?? getLastAssistantUiMessageId(messages);
if (!messageId) {
return;
}
const chunkId = chunkIdRef.current;
chunkIdRef.current += 1;
setStreamRenderState((current) => {
const previous = current[messageId] ?? { chunks: [], done: false };
return {
...current,
[messageId]: {
chunks: [...previous.chunks, { id: chunkId, text }],
done: false
}
};
});
}
export function createCompletedStreamRenderState(messages: AgentUiMessage[]): AgentStreamRenderState {
return messages.reduce<AgentStreamRenderState>((next, message) => {
if (message.role === "assistant") {
next[message.id] = {
chunks: [],
done: true
};
}
return next;
}, {});
}
export function markLastAssistantStreamDone(
messages: AgentUiMessage[],
setStreamRenderState: StreamRenderStateSetter
) {
const messageId = getLastAssistantUiMessageId(messages);
setStreamRenderState((current) => {
if (!messageId) {
return Object.fromEntries(
Object.entries(current).map(([id, state]) => [id, { ...state, done: true }])
);
}
return {
...current,
[messageId]: {
chunks: current[messageId]?.chunks ?? [],
done: true
}
};
});
}
export function toAgentChatMessages(
messages: AgentUiMessage[],
permissionOverrides: Record<string, PermissionOverride>,
questionOverrides: Record<string, QuestionOverride>
): AgentChatMessage[] {
return messages.flatMap((message) => {
if (message.role !== "user" && message.role !== "assistant") {
return [];
}
let next: AgentChatMessage = {
id: message.id,
role: message.role,
content: collectMessageText(message)
};
for (const part of message.parts) {
if (!isAgentDataPart(part)) {
continue;
}
if (part.type === "data-progress") {
next = {
...next,
progress: upsertProgress(next.progress, part.data)
};
} else if (part.type === "data-todo_update") {
const todoUpdate = toTodoUpdate(part.data);
if (todoUpdate) {
next = {
...next,
todos: todoUpdate
};
}
} else if (part.type === "data-permission_request") {
next = {
...next,
permissions: upsertPermission(next.permissions, part.data)
};
} else if (part.type === "data-permission_response") {
next = {
...next,
permissions: applyPermissionResponse(next.permissions, part.data)
};
} else if (part.type === "data-question_request") {
next = {
...next,
questions: upsertQuestion(next.questions, part.data)
};
} else if (part.type === "data-question_response") {
next = {
...next,
questions: applyQuestionResponse(next.questions, part.data)
};
}
}
return [applyQuestionOverrides(applyPermissionOverrides(next, permissionOverrides), questionOverrides)];
});
}
export function toAgentUiMessages(messages: unknown[]): AgentUiMessage[] {
return messages.flatMap((message) => {
if (isAgentUiMessage(message)) {
return [message];
}
const legacyMessage = toAgentUiMessageFromLegacy(message);
return legacyMessage ? [legacyMessage] : [];
});
}
export function applySessionStreamEvent(messages: AgentUiMessage[], event: AgentSessionStreamEvent): AgentUiMessage[] {
if (event.type === "state") {
return toAgentUiMessages(event.messages);
}
if (event.type === "token") {
const token = getDataString(event.data, "content");
return token ? updateLastAssistantUiMessage(messages, (message) => appendTextToUiMessage(message, token)) : messages;
}
if (
event.type === "progress" ||
event.type === "todo_update" ||
event.type === "permission_request" ||
event.type === "permission_response" ||
event.type === "question_request" ||
event.type === "question_response" ||
event.type === "ui_envelope" ||
event.type === "session_title"
) {
return updateLastAssistantUiMessage(messages, (message) =>
upsertUiDataPart(message, event.type, event.data)
);
}
if (event.type === "error") {
const message = getDataString(event.data, "message") ?? "Agent stream failed";
return updateLastAssistantUiMessage(messages, (item) =>
appendTextToUiMessage(item, item.parts.some((part) => part.type === "text") ? `\n\n错误:${message}` : `错误:${message}`)
);
}
return messages;
}
export function toPermissionStatus(reply: AgentPermissionReply): AgentPermissionStatus {
if (reply === "always") {
return "approved_always";
}
if (reply === "once") {
return "approved_once";
}
return "rejected";
}
export function getDataString(data: unknown, key: string) {
if (typeof data !== "object" || data === null) {
return undefined;
}
const value = (data as Record<string, unknown>)[key];
return typeof value === "string" ? value : undefined;
}
export function readBodyString(body: unknown, key: string) {
if (typeof body !== "object" || body === null) {
return undefined;
}
const value = (body as Record<string, unknown>)[key];
return typeof value === "string" ? value : undefined;
}
function getLastAssistantUiMessageId(messages: AgentUiMessage[]) {
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index].role === "assistant") {
return messages[index].id;
}
}
return undefined;
}
function isAgentUiMessage(value: unknown): value is AgentUiMessage {
if (typeof value !== "object" || value === null) {
return false;
}
const message = value as Record<string, unknown>;
return (
typeof message.id === "string" &&
(message.role === "user" || message.role === "assistant") &&
Array.isArray(message.parts)
);
}
function toAgentUiMessageFromLegacy(value: unknown): AgentUiMessage | null {
if (typeof value !== "object" || value === null) {
return null;
}
const message = value as Record<string, unknown>;
if (
typeof message.id !== "string" ||
(message.role !== "user" && message.role !== "assistant")
) {
return null;
}
const parts: AgentUiMessage["parts"] = [];
if (typeof message.content === "string" && message.content) {
parts.push({ type: "text", text: message.content });
}
appendLegacyDataParts(parts, "progress", message.progress);
appendLegacyDataParts(parts, "permission_request", message.permissions);
appendLegacyDataParts(parts, "question_request", message.questions);
appendLegacyDataParts(parts, "todo_update", message.todos);
return {
id: message.id,
role: message.role,
parts
} as AgentUiMessage;
}
function appendLegacyDataParts(
parts: AgentUiMessage["parts"],
eventType: string,
value: unknown
) {
if (!value) {
return;
}
const values = Array.isArray(value) ? value : [value];
values.forEach((item, index) => {
if (typeof item !== "object" || item === null) {
return;
}
const data = normalizeLegacyDataPart(eventType, item as Record<string, unknown>);
const id =
getDataString(data, "id") ??
getDataString(data, "request_id") ??
`${eventType}-${index}`;
parts.push({
type: `data-${eventType}`,
id,
data
} as AgentDataPart);
});
}
function normalizeLegacyDataPart(eventType: string, value: Record<string, unknown>) {
if (eventType === "progress") {
return {
...value,
started_at: value.started_at ?? value.startedAt,
ended_at: value.ended_at ?? value.endedAt,
elapsed_ms: value.elapsed_ms ?? value.elapsedMs,
duration_ms: value.duration_ms ?? value.durationMs
};
}
if (eventType === "permission_request" || eventType === "question_request") {
return {
...value,
session_id: value.session_id ?? value.sessionId,
request_id: value.request_id ?? value.requestId,
created_at: value.created_at ?? value.createdAt
};
}
if (eventType === "todo_update") {
return {
...value,
session_id: value.session_id ?? value.sessionId,
message_id: value.message_id ?? value.messageId,
created_at: value.created_at ?? value.createdAt
};
}
return value;
}
function updateLastAssistantUiMessage(
messages: AgentUiMessage[],
updater: (message: AgentUiMessage) => AgentUiMessage
) {
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index].role === "assistant") {
const next = [...messages];
next[index] = updater(messages[index]);
return next;
}
}
return [...messages, updater(createAssistantUiMessage())];
}
function createAssistantUiMessage(): AgentUiMessage {
return {
id: `assistant-${Date.now().toString(36)}`,
role: "assistant",
parts: []
} as AgentUiMessage;
}
function appendTextToUiMessage(message: AgentUiMessage, text: string): AgentUiMessage {
const textIndex = message.parts.findIndex((part) => part.type === "text");
if (textIndex === -1) {
return {
...message,
parts: [{ type: "text", text }, ...message.parts]
};
}
return {
...message,
parts: message.parts.map((part, index) =>
index === textIndex && part.type === "text"
? { ...part, text: `${part.text}${text}` }
: part
)
};
}
function upsertUiDataPart(
message: AgentUiMessage,
eventType: string,
data: Record<string, unknown>
): AgentUiMessage {
const type = `data-${eventType}`;
const id =
getDataString(data, "id") ??
getDataString(data, "request_id") ??
getDataString(data, "envelope_id");
const existingIndex =
id === undefined
? -1
: message.parts.findIndex((part) => part.type === type && "id" in part && part.id === id);
const part = (id ? { type, id, data } : { type, data }) as AgentDataPart;
if (existingIndex === -1) {
return {
...message,
parts: [...message.parts, part]
};
}
return {
...message,
parts: message.parts.map((item, index) => (index === existingIndex ? part : item))
};
}
function applyPermissionOverrides(
message: AgentChatMessage,
permissionOverrides: Record<string, PermissionOverride>
) {
if (!message.permissions?.length) {
return message;
}
return {
...message,
permissions: message.permissions.map((permission) => {
const override = permissionOverrides[permission.requestId];
return override
? {
...permission,
status: override.status,
error: override.error,
repliedAt: override.status === "submitting" || override.status === "error" ? permission.repliedAt : Date.now()
}
: permission;
})
};
}
function applyQuestionOverrides(
message: AgentChatMessage,
questionOverrides: Record<string, QuestionOverride>
) {
if (!message.questions?.length) {
return message;
}
return {
...message,
questions: message.questions.map((question) => {
const override = questionOverrides[question.requestId];
return override
? {
...question,
status: override.status,
answers: override.answers ?? question.answers,
error: override.error,
repliedAt: override.status === "submitting" || override.status === "error" ? question.repliedAt : Date.now()
}
: question;
})
};
}
function collectMessageText(message: AgentUiMessage) {
return message.parts
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("");
}
function isAgentDataPart(part: AgentUiMessage["parts"][number]): part is AgentDataPart {
return typeof part.type === "string" && part.type.startsWith("data-") && "data" in part;
}
@@ -0,0 +1,66 @@
"use client";
import type { Map as MapLibreMap, MapLayerMouseEvent } from "maplibre-gl";
import type { RefObject } from "react";
import { useEffect } from "react";
import type { DetailFeature } from "../types";
import { getFeatureId, toDetailFeature } from "../map/feature-adapter";
import { SOURCE_LAYERS } from "../map/sources";
const INTERACTIVE_LAYER_IDS = ["pipes-flow", "junctions"];
type UseMapInteractionsOptions = {
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
onSelectFeature: (feature: DetailFeature) => void;
};
export function useMapInteractions({
mapRef,
mapReady,
onSelectFeature
}: UseMapInteractionsOptions) {
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map) {
return;
}
const activeMap = map;
function handleMouseMove(event: MapLayerMouseEvent) {
activeMap.getCanvas().style.cursor = "pointer";
const feature = event.features?.[0];
if (!feature) {
return;
}
const id = getFeatureId(feature);
const layerId = feature.sourceLayer === SOURCE_LAYERS.pipes ? "pipes-hover" : "junctions-hover";
activeMap.setFilter(layerId, ["==", ["get", "id"], id]);
}
function handleMouseLeave() {
activeMap.getCanvas().style.cursor = "";
activeMap.setFilter("pipes-hover", ["==", ["get", "id"], ""]);
activeMap.setFilter("junctions-hover", ["==", ["get", "id"], ""]);
}
function handleClick(event: MapLayerMouseEvent) {
const feature = event.features?.[0];
if (feature) {
onSelectFeature(toDetailFeature(feature));
}
}
activeMap.on("mousemove", INTERACTIVE_LAYER_IDS, handleMouseMove);
activeMap.on("mouseleave", INTERACTIVE_LAYER_IDS, handleMouseLeave);
activeMap.on("click", INTERACTIVE_LAYER_IDS, handleClick);
return () => {
activeMap.off("mousemove", INTERACTIVE_LAYER_IDS, handleMouseMove);
activeMap.off("mouseleave", INTERACTIVE_LAYER_IDS, handleMouseLeave);
activeMap.off("click", INTERACTIVE_LAYER_IDS, handleClick);
};
}, [mapRef, mapReady, onSelectFeature]);
}
@@ -0,0 +1,27 @@
"use client";
import type { Map as MapLibreMap } from "maplibre-gl";
import type { RefObject } from "react";
import { useEffect } from "react";
import { setSimulationLayersVisibility } from "../map/simulation-layers";
type UseSimulationLayerVisibilityOptions = {
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
visible: boolean;
};
export function useSimulationLayerVisibility({
mapRef,
mapReady,
visible
}: UseSimulationLayerVisibilityOptions) {
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map) {
return;
}
setSimulationLayersVisibility(map, visible);
}, [mapRef, mapReady, visible]);
}
@@ -0,0 +1,836 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import useSWR from "swr";
import useSWRImmutable from "swr/immutable";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { showMapNotice } from "@/features/map/core/components/notice";
import type {
AgentChatMessage,
AgentModelOption,
AgentPermissionReply,
AgentPermissionRequest,
AgentQuestionRequest,
AgentStreamRenderState
} from "@/features/agent";
import {
createAgentApiClient,
type AgentSessionStreamEvent
} from "@/features/agent/api/client";
import {
agentBootstrapKey,
agentSessionsKey,
fetchAgentBootstrap,
fetchAgentSessions
} from "@/features/agent/api/swr";
import {
isUiEnvelopeAllowed,
parseUiEnvelopePayload,
type UIEnvelopePayload,
type UIRegistry
} from "@/features/agent/ui-envelope";
import {
appendStreamRenderToken,
applySessionStreamEvent,
createCompletedStreamRenderState,
getDataString,
markLastAssistantStreamDone,
readBodyString,
toAgentChatMessages,
toAgentUiMessages,
toPermissionStatus,
type AgentDataPart,
type AgentUiMessage,
type PermissionOverride,
type QuestionOverride
} from "./agent-session-message-state";
const AGENT_PANEL_COLLAPSE_MS = 180;
type AgentRuntimeAvailability = "connecting" | "connected" | "unavailable" | "failed";
type UseWorkbenchAgentOptions = {
onUiEnvelope: (payload: UIEnvelopePayload, sessionId: string) => Promise<void> | void;
};
export function useWorkbenchAgent({ onUiEnvelope }: UseWorkbenchAgentOptions) {
const collapseTimerRef = useRef<number | null>(null);
const mobileCollapseTimerRef = useRef<number | null>(null);
const sessionStreamAbortRef = useRef<AbortController | null>(null);
const sessionStreamIdRef = useRef<string | null>(null);
const streamRenderChunkIdRef = useRef(0);
const clientRef = useRef(createAgentApiClient());
const sessionIdRef = useRef<string | null>(null);
const registryRef = useRef<UIRegistry | null>(null);
const [panelOpen, setPanelOpen] = useState(true);
const [panelCollapsing, setPanelCollapsing] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const [registry, setRegistry] = useState<UIRegistry | null>(null);
const [sessionTitle, setSessionTitle] = useState("新对话");
const [statusLabel, setStatusLabel] = useState("Agent 后端连接中");
const [runtimeAvailability, setRuntimeAvailability] = useState<AgentRuntimeAvailability>("connecting");
const [resumedStreaming, setResumedStreaming] = useState(false);
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState("");
const [streamRenderState, setStreamRenderState] = useState<AgentStreamRenderState>({});
const [permissionOverrides, setPermissionOverrides] = useState<Record<string, PermissionOverride>>({});
const [questionOverrides, setQuestionOverrides] = useState<Record<string, QuestionOverride>>({});
useEffect(() => {
sessionIdRef.current = sessionId;
}, [sessionId]);
useEffect(() => {
registryRef.current = registry;
}, [registry]);
const applySessionId = useCallback((nextSessionId: string | undefined) => {
if (!nextSessionId || sessionIdRef.current === nextSessionId) {
return;
}
sessionIdRef.current = nextSessionId;
setSessionId(nextSessionId);
}, []);
const clearSessionId = useCallback(() => {
sessionIdRef.current = null;
setSessionId(null);
}, []);
const resolveRenderRef = useCallback((renderRef: string, nextSessionId: string) => {
return clientRef.current.resolveRenderRef(renderRef, nextSessionId);
}, []);
const handleDataPart = useCallback(
(part: AgentDataPart) => {
const eventSessionId = getDataString(part.data, "session_id");
if (eventSessionId && sessionIdRef.current && eventSessionId !== sessionIdRef.current) {
return;
}
applySessionId(eventSessionId);
if (part.type === "data-stream_token") {
appendStreamRenderToken(
part.data,
chatRef.current.messages,
setStreamRenderState,
streamRenderChunkIdRef
);
return;
}
if (part.type === "data-progress") {
const title = getDataString(part.data, "title");
if (title) {
setStatusLabel(title);
}
return;
}
if (part.type === "data-session_title") {
const title = getDataString(part.data, "title");
if (title) {
setSessionTitle(title);
}
return;
}
if (part.type !== "data-ui_envelope") {
return;
}
const payload = parseUiEnvelopePayload(part.data);
const activeSessionId = getDataString(part.data, "session_id") ?? sessionIdRef.current;
if (!payload || !activeSessionId || !isUiEnvelopeAllowed(payload.envelope, registryRef.current)) {
showMapNotice({
tone: "warning",
title: "Agent 展示已降级",
message: "收到的 UIEnvelope 未通过前端白名单校验。"
});
return;
}
void Promise.resolve(onUiEnvelope(payload, activeSessionId)).catch((error) => {
showMapNotice({
tone: "error",
title: "Agent UIEnvelope 处理失败",
message: error instanceof Error ? error.message : "工作台处理 Agent 展示结果失败。"
});
});
},
[applySessionId, onUiEnvelope]
);
const transport = useMemo(
() =>
new DefaultChatTransport<AgentUiMessage>({
api: "/api/v1/agent/chat/stream",
prepareSendMessagesRequest({ id, messages, body, trigger, messageId }) {
return {
body: {
...body,
id,
messages,
trigger,
messageId,
session_id: readBodyString(body, "session_id") ?? sessionIdRef.current ?? undefined,
approval_mode: readBodyString(body, "approval_mode") ?? "request"
}
};
}
}),
[]
);
const chat = useChat<AgentUiMessage>({
transport,
onData: (part) => handleDataPart(part as AgentDataPart),
onError: (error) => {
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
setRuntimeAvailability("failed");
setStatusLabel("Agent 后端请求失败");
showMapNotice({
id: "agent-stream-status",
tone: "error",
title: "Agent 请求失败",
message: error.message || "请确认后端服务已启动。"
});
},
onFinish: () => {
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
setStatusLabel("Agent 分析完成");
}
});
const chatRef = useRef(chat);
useEffect(() => {
chatRef.current = chat;
}, [chat]);
const {
data: backendConnection,
error: backendConnectionError
} = useSWRImmutable(agentBootstrapKey, () => fetchAgentBootstrap(clientRef.current), {
shouldRetryOnError: false
});
const {
data: sessionHistory = [],
isLoading: sessionHistoryLoading,
isValidating: sessionHistoryValidating,
mutate: mutateSessionHistory
} = useSWR(agentSessionsKey, () => fetchAgentSessions(clientRef.current), {
revalidateOnFocus: false,
shouldRetryOnError: false
});
const clearCollapseTimer = useCallback(() => {
if (collapseTimerRef.current !== null) {
window.clearTimeout(collapseTimerRef.current);
collapseTimerRef.current = null;
}
}, []);
const clearMobileCollapseTimer = useCallback(() => {
if (mobileCollapseTimerRef.current !== null) {
window.clearTimeout(mobileCollapseTimerRef.current);
mobileCollapseTimerRef.current = null;
}
}, []);
const stopSessionStreamSubscription = useCallback(() => {
sessionStreamAbortRef.current?.abort();
sessionStreamAbortRef.current = null;
sessionStreamIdRef.current = null;
setResumedStreaming(false);
}, []);
useEffect(() => {
return () => {
clearCollapseTimer();
clearMobileCollapseTimer();
stopSessionStreamSubscription();
chatRef.current.stop();
};
}, [clearCollapseTimer, clearMobileCollapseTimer, stopSessionStreamSubscription]);
useEffect(() => {
if (!backendConnection) {
return;
}
registryRef.current = backendConnection.registry;
setRegistry(backendConnection.registry);
setModelOptions(backendConnection.models);
setSelectedModel((current) => current || backendConnection.defaultModel || backendConnection.models[0]?.id || "");
if (chatRef.current.status === "ready") {
setRuntimeAvailability("connected");
setStatusLabel("Agent 后端已连接");
}
}, [backendConnection]);
useEffect(() => {
if (!backendConnectionError) {
return;
}
setRuntimeAvailability("unavailable");
setStatusLabel("Agent 后端不可用");
}, [backendConnectionError]);
const collapsePanel = useCallback(() => {
clearCollapseTimer();
if (prefersReducedMotion()) {
setPanelOpen(false);
setPanelCollapsing(false);
return;
}
setPanelCollapsing(true);
collapseTimerRef.current = window.setTimeout(() => {
setPanelOpen(false);
setPanelCollapsing(false);
collapseTimerRef.current = null;
}, AGENT_PANEL_COLLAPSE_MS);
}, [clearCollapseTimer]);
const expandPanel = useCallback(() => {
clearCollapseTimer();
setPanelCollapsing(false);
setPanelOpen(true);
}, [clearCollapseTimer]);
const openMobilePanel = useCallback(() => {
clearMobileCollapseTimer();
setMobilePanelCollapsing(false);
setMobileOpen(true);
}, [clearMobileCollapseTimer]);
const closeMobilePanel = useCallback(() => {
clearMobileCollapseTimer();
if (prefersReducedMotion()) {
setMobileOpen(false);
setMobilePanelCollapsing(false);
return;
}
setMobilePanelCollapsing(true);
mobileCollapseTimerRef.current = window.setTimeout(() => {
setMobileOpen(false);
setMobilePanelCollapsing(false);
mobileCollapseTimerRef.current = null;
}, AGENT_PANEL_COLLAPSE_MS);
}, [clearMobileCollapseTimer]);
const streaming = chat.status === "submitted" || chat.status === "streaming" || resumedStreaming;
const messages = useMemo(
() => toAgentChatMessages(chat.messages, permissionOverrides, questionOverrides),
[chat.messages, permissionOverrides, questionOverrides]
);
const personaState = useMemo(
() => deriveAgentPersonaState(messages, {
chatStatus: chat.status,
runtimeAvailability,
statusLabel
}),
[chat.status, messages, runtimeAvailability, statusLabel]
);
const refreshSessionHistory = useCallback(async () => {
try {
await mutateSessionHistory();
} catch (error) {
showMapNotice({
id: "agent-history-status",
tone: "error",
title: "Agent 历史记录加载失败",
message: error instanceof Error ? error.message : "无法获取 Agent 会话历史。"
});
}
}, [mutateSessionHistory]);
const handleSessionStreamEvent = useCallback(
(event: AgentSessionStreamEvent, expectedSessionId: string) => {
if (event.type === "state") {
if (sessionIdRef.current !== expectedSessionId && sessionIdRef.current !== event.sessionId) {
return;
}
const nextMessages = toAgentUiMessages(event.messages);
chatRef.current.setMessages(nextMessages);
setStreamRenderState(createCompletedStreamRenderState(nextMessages));
setResumedStreaming(event.isStreaming);
setStatusLabel(event.isStreaming ? "Agent 会话流已恢复" : "Agent 历史会话已打开");
return;
}
const eventSessionId = event.sessionId;
if (eventSessionId && eventSessionId !== sessionIdRef.current) {
return;
}
if (event.type === "session_title") {
const title = getDataString(event.data, "title");
if (title) {
setSessionTitle(title);
}
} else if (event.type === "token") {
appendStreamRenderToken(
event.data,
chatRef.current.messages,
setStreamRenderState,
streamRenderChunkIdRef
);
} else if (event.type === "ui_envelope") {
const payload = parseUiEnvelopePayload(event.data);
const activeSessionId = eventSessionId ?? sessionIdRef.current;
if (!payload || !activeSessionId || !isUiEnvelopeAllowed(payload.envelope, registryRef.current)) {
showMapNotice({
tone: "warning",
title: "Agent 展示已降级",
message: "收到的 UIEnvelope 未通过前端白名单校验。"
});
return;
}
void Promise.resolve(onUiEnvelope(payload, activeSessionId)).catch((error) => {
showMapNotice({
tone: "error",
title: "Agent UIEnvelope 处理失败",
message: error instanceof Error ? error.message : "工作台处理 Agent 展示结果失败。"
});
});
} else if (event.type === "progress") {
const title = getDataString(event.data, "title");
if (title) {
setStatusLabel(title);
}
} else if (event.type === "done") {
setResumedStreaming(false);
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
setStatusLabel("Agent 分析完成");
void refreshSessionHistory();
} else if (event.type === "error") {
setResumedStreaming(false);
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
setStatusLabel("Agent 后端请求失败");
void refreshSessionHistory();
}
chatRef.current.setMessages((current) => applySessionStreamEvent(current, event));
},
[onUiEnvelope, refreshSessionHistory]
);
const startSessionStreamSubscription = useCallback(
(nextSessionId: string) => {
stopSessionStreamSubscription();
const controller = new AbortController();
sessionStreamAbortRef.current = controller;
sessionStreamIdRef.current = nextSessionId;
setResumedStreaming(true);
void clientRef.current
.streamSession(nextSessionId, {
signal: controller.signal,
onEvent: (event) => handleSessionStreamEvent(event, nextSessionId)
})
.catch((error) => {
if (!controller.signal.aborted) {
setRuntimeAvailability("failed");
setStatusLabel("Agent 会话流恢复失败");
showMapNotice({
id: "agent-stream-status",
tone: "error",
title: "Agent 会话流恢复失败",
message: error instanceof Error ? error.message : "无法恢复这个运行中的会话。"
});
}
})
.finally(() => {
if (sessionStreamAbortRef.current === controller) {
sessionStreamAbortRef.current = null;
sessionStreamIdRef.current = null;
setResumedStreaming(false);
void refreshSessionHistory();
}
});
},
[handleSessionStreamEvent, refreshSessionHistory, stopSessionStreamSubscription]
);
const loadHistorySession = useCallback(
async (nextSessionId: string) => {
if (streaming) {
chat.stop();
stopSessionStreamSubscription();
}
try {
const loadedSession = await clientRef.current.loadSession(nextSessionId);
if (!loadedSession) {
showMapNotice({
id: "agent-history-status",
tone: "warning",
title: "Agent 历史记录不可用",
message: "这个历史会话不存在或已被删除。"
});
await refreshSessionHistory();
return;
}
applySessionId(loadedSession.sessionId);
setSessionTitle(loadedSession.title);
setStatusLabel("Agent 历史会话已打开");
setPermissionOverrides({});
setQuestionOverrides({});
const nextMessages = toAgentUiMessages(loadedSession.messages);
chat.setMessages(nextMessages);
setStreamRenderState(createCompletedStreamRenderState(nextMessages));
if (loadedSession.runStatus === "running" || loadedSession.isStreaming) {
startSessionStreamSubscription(loadedSession.sessionId);
}
await refreshSessionHistory();
} catch (error) {
showMapNotice({
id: "agent-history-status",
tone: "error",
title: "Agent 历史记录打开失败",
message: error instanceof Error ? error.message : "无法打开这个历史会话。"
});
}
},
[applySessionId, chat, refreshSessionHistory, startSessionStreamSubscription, stopSessionStreamSubscription, streaming]
);
const renameHistorySession = useCallback(
async (nextSessionId: string, title: string) => {
const normalizedTitle = title.trim();
if (!normalizedTitle) {
showMapNotice({ tone: "warning", message: "对话主题不能为空。" });
return;
}
try {
await clientRef.current.updateSessionTitle(nextSessionId, normalizedTitle, {
isTitleManuallyEdited: true
});
await mutateSessionHistory(
(current = []) =>
current.map((session) =>
session.id === nextSessionId
? {
...session,
title: normalizedTitle,
updatedAt: Date.now()
}
: session
),
{ revalidate: false }
);
if (sessionIdRef.current === nextSessionId) {
setSessionTitle(normalizedTitle);
}
await refreshSessionHistory();
} catch (error) {
showMapNotice({
id: "agent-history-status",
tone: "error",
title: "Agent 对话重命名失败",
message: error instanceof Error ? error.message : "无法更新这个对话主题。"
});
}
},
[mutateSessionHistory, refreshSessionHistory]
);
const resetDraftSession = useCallback(
(nextStatusLabel = "Agent 新对话已准备") => {
clearSessionId();
setSessionTitle("新对话");
setStatusLabel(nextStatusLabel);
setRuntimeAvailability("connected");
setPermissionOverrides({});
setQuestionOverrides({});
setStreamRenderState({});
chat.setMessages([]);
},
[chat, clearSessionId]
);
const deleteHistorySession = useCallback(
async (nextSessionId: string) => {
if (streaming) {
showMapNotice({ tone: "warning", message: "Agent 正在处理上一条指令,完成后再删除会话。" });
return;
}
try {
await clientRef.current.deleteSession(nextSessionId);
await mutateSessionHistory(
(current = []) => current.filter((session) => session.id !== nextSessionId),
{ revalidate: false }
);
if (sessionIdRef.current === nextSessionId) {
resetDraftSession();
}
await refreshSessionHistory();
} catch (error) {
showMapNotice({
id: "agent-history-status",
tone: "error",
title: "Agent 历史记录删除失败",
message: error instanceof Error ? error.message : "无法删除这个历史会话。"
});
}
},
[mutateSessionHistory, refreshSessionHistory, resetDraftSession, streaming]
);
const startNewSession = useCallback(async () => {
if (chat.status === "submitted" || chat.status === "streaming") {
chat.stop();
}
stopSessionStreamSubscription();
resetDraftSession();
}, [chat, resetDraftSession, stopSessionStreamSubscription]);
const submitPrompt = useCallback(
async (prompt: string) => {
const normalizedPrompt = prompt.trim();
if (!normalizedPrompt) {
return;
}
if (streaming) {
showMapNotice({ tone: "warning", message: "Agent 正在处理上一条指令。" });
return;
}
setStatusLabel("Agent 正在分析");
await chat.sendMessage(
{ text: normalizedPrompt },
{
body: {
session_id: sessionIdRef.current ?? undefined,
model: selectedModel || undefined,
approval_mode: "request"
}
}
);
},
[chat, selectedModel, streaming]
);
const stopPrompt = useCallback(() => {
chat.stop();
stopSessionStreamSubscription();
const activeSessionId = sessionIdRef.current;
if (activeSessionId) {
void clientRef.current.abort(activeSessionId).catch(() => undefined);
}
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
}, [chat, stopSessionStreamSubscription]);
const replyPermission = useCallback(async (permission: AgentPermissionRequest, reply: AgentPermissionReply) => {
setPermissionOverrides((current) => ({
...current,
[permission.requestId]: { status: "submitting" }
}));
try {
await clientRef.current.replyPermission(permission.requestId, {
sessionId: permission.sessionId,
reply
});
setPermissionOverrides((current) => ({
...current,
[permission.requestId]: {
status: toPermissionStatus(reply)
}
}));
} catch (error) {
const message = error instanceof Error ? error.message : "权限请求提交失败。";
setPermissionOverrides((current) => ({
...current,
[permission.requestId]: {
status: "error",
error: message
}
}));
showMapNotice({
id: "agent-permission-status",
tone: "error",
title: "Agent 权限提交失败",
message
});
}
}, []);
const replyQuestion = useCallback(async (question: AgentQuestionRequest, answers: string[][]) => {
setQuestionOverrides((current) => ({
...current,
[question.requestId]: { status: "submitting" }
}));
try {
await clientRef.current.replyQuestion(question.requestId, {
sessionId: question.sessionId,
answers
});
setQuestionOverrides((current) => ({
...current,
[question.requestId]: {
status: "answered",
answers
}
}));
} catch (error) {
const message = error instanceof Error ? error.message : "问题回复提交失败。";
setQuestionOverrides((current) => ({
...current,
[question.requestId]: {
status: "error",
error: message
}
}));
showMapNotice({
id: "agent-question-status",
tone: "error",
title: "Agent 问题提交失败",
message
});
}
}, []);
const rejectQuestion = useCallback(async (question: AgentQuestionRequest) => {
setQuestionOverrides((current) => ({
...current,
[question.requestId]: { status: "submitting" }
}));
try {
await clientRef.current.rejectQuestion(question.requestId, {
sessionId: question.sessionId
});
setQuestionOverrides((current) => ({
...current,
[question.requestId]: {
status: "rejected"
}
}));
} catch (error) {
const message = error instanceof Error ? error.message : "问题跳过提交失败。";
setQuestionOverrides((current) => ({
...current,
[question.requestId]: {
status: "error",
error: message
}
}));
showMapNotice({
id: "agent-question-status",
tone: "error",
title: "Agent 问题提交失败",
message
});
}
}, []);
return {
collapsePanel,
expandPanel,
mobileOpen,
mobilePanelCollapsing,
messages,
modelOptions,
openMobilePanel,
closeMobilePanel,
panelCollapsing,
panelOpen,
personaState,
refreshSessionHistory,
startNewSession,
loadHistorySession,
renameHistorySession,
deleteHistorySession,
sessionHistory,
sessionHistoryLoading: sessionHistoryLoading || sessionHistoryValidating,
sessionId,
sessionTitle,
statusLabel,
streamRenderState,
selectedModel,
setSelectedModel,
stopPrompt,
streaming,
rejectQuestion,
replyPermission,
replyQuestion,
resolveRenderRef,
submitPrompt
};
}
function deriveAgentPersonaState(
messages: AgentChatMessage[],
{
chatStatus,
runtimeAvailability,
statusLabel
}: {
chatStatus: string;
runtimeAvailability: AgentRuntimeAvailability;
statusLabel: string;
}
): PersonaState {
if (
runtimeAvailability === "unavailable" ||
runtimeAvailability === "failed" ||
/失败|不可用|错误|降级/.test(statusLabel)
) {
return "asleep";
}
if (hasPendingOperatorInput(messages)) {
return "listening";
}
if (
chatStatus === "submitted" ||
chatStatus === "streaming" ||
hasRunningSessionWork(messages) ||
runtimeAvailability === "connecting"
) {
return "thinking";
}
return "idle";
}
function hasPendingOperatorInput(messages: AgentChatMessage[]) {
return messages.some(
(message) =>
message.permissions?.some((permission) => permission.status === "pending" || permission.status === "error") ||
message.questions?.some((question) => question.status === "pending" || question.status === "error")
);
}
function hasRunningSessionWork(messages: AgentChatMessage[]) {
const latestProgress = messages.flatMap((message) => message.progress ?? []).at(-1);
const latestTodo = messages.flatMap((message) => message.todos?.todos ?? []).at(-1);
return latestProgress?.status === "running" || latestTodo?.status === "in_progress";
}
function prefersReducedMotion() {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
@@ -0,0 +1,476 @@
"use client";
import type { Map as MapLibreMap, MapMouseEvent } from "maplibre-gl";
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
DRAWING_INTERACTIVE_LAYER_IDS,
DRAWING_SOURCE_IDS,
createCircleFeature,
createLineFeature,
createPointFeature,
createPolygonFeature,
emptyDrawingFeatureCollection,
ensureDrawingLayers,
setSelectedDrawingFeatureId,
setDrawingSourceData,
type DrawingFeatureCollection
} from "../map/drawing-layers";
import {
createDrawingId,
createDrawingLabel,
createFeatureFromDraft,
formatDrawingTime,
getDraftPointCount,
getDrawingKindLabel,
getVisibleFeatureCollection,
type DrawingDraftState,
type WorkbenchDrawMode
} from "../map/drawing-model";
import { getDistanceMeters, isSameCoordinate } from "../map/geo";
export type { WorkbenchDrawMode };
export type WorkbenchDrawingAnnotation = {
id: string;
label: string;
description: string;
kind: WorkbenchDrawMode;
hidden: boolean;
selected: boolean;
onToggleVisibility: () => void;
onSelect: () => void;
};
type UseWorkbenchDrawingOptions = {
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
activeMode: WorkbenchDrawMode | null;
onModeChange: (mode: WorkbenchDrawMode | null) => void;
};
export function useWorkbenchDrawing({
mapRef,
mapReady,
activeMode,
onModeChange
}: UseWorkbenchDrawingOptions) {
const [featureCollection, setFeatureCollection] = useState<DrawingFeatureCollection>(emptyDrawingFeatureCollection);
const [hiddenFeatureIds, setHiddenFeatureIds] = useState<Set<string>>(() => new Set());
const [selectedFeatureId, setSelectedFeatureId] = useState<string | null>(null);
const [, setHistoryVersion] = useState(0);
const [draftPointCount, setDraftPointCount] = useState(0);
const featureCollectionRef = useRef(featureCollection);
const hiddenFeatureIdsRef = useRef(hiddenFeatureIds);
const undoStackRef = useRef<DrawingFeatureCollection[]>([]);
const redoStackRef = useRef<DrawingFeatureCollection[]>([]);
const draftRef = useRef<DrawingDraftState | null>(null);
const featureCounterRef = useRef(0);
useEffect(() => {
featureCollectionRef.current = featureCollection;
}, [featureCollection]);
useEffect(() => {
hiddenFeatureIdsRef.current = hiddenFeatureIds;
}, [hiddenFeatureIds]);
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map) {
return;
}
ensureDrawingLayers(map);
setDrawingSourceData(map, DRAWING_SOURCE_IDS.features, getVisibleFeatureCollection(featureCollectionRef.current, hiddenFeatureIdsRef.current));
setDrawingSourceData(map, DRAWING_SOURCE_IDS.draft, emptyDrawingFeatureCollection);
}, [mapReady, mapRef]);
const clearDraft = useCallback(() => {
draftRef.current = null;
const map = mapRef.current;
if (map) {
setDrawingSourceData(map, DRAWING_SOURCE_IDS.draft, emptyDrawingFeatureCollection);
}
}, [mapRef]);
const updateFeatures = useCallback(
(nextCollection: DrawingFeatureCollection) => {
featureCollectionRef.current = nextCollection;
setFeatureCollection(nextCollection);
const map = mapRef.current;
if (map) {
setDrawingSourceData(map, DRAWING_SOURCE_IDS.features, getVisibleFeatureCollection(nextCollection, hiddenFeatureIdsRef.current));
}
},
[mapRef]
);
const commitFeatures = useCallback(
(nextCollection: DrawingFeatureCollection) => {
undoStackRef.current.push(featureCollectionRef.current);
redoStackRef.current = [];
updateFeatures(nextCollection);
setHistoryVersion((current) => current + 1);
},
[updateFeatures]
);
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map) {
return;
}
setDrawingSourceData(map, DRAWING_SOURCE_IDS.features, getVisibleFeatureCollection(featureCollection, hiddenFeatureIds));
}, [featureCollection, hiddenFeatureIds, mapReady, mapRef]);
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map) {
return;
}
setSelectedDrawingFeatureId(map, selectedFeatureId);
}, [mapReady, mapRef, selectedFeatureId]);
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map || activeMode) {
return;
}
const activeMap = map;
function handleDrawingClick(event: MapMouseEvent) {
const feature = activeMap.queryRenderedFeatures(event.point, {
layers: DRAWING_INTERACTIVE_LAYER_IDS
})[0];
const id = feature?.properties?.id;
if (typeof id === "string" && id) {
setSelectedFeatureId((current) => (current === id ? null : id));
}
}
function handleMouseEnter() {
activeMap.getCanvas().style.cursor = "pointer";
}
function handleMouseLeave() {
activeMap.getCanvas().style.cursor = "";
}
DRAWING_INTERACTIVE_LAYER_IDS.forEach((layerId) => {
if (activeMap.getLayer(layerId)) {
activeMap.on("click", layerId, handleDrawingClick);
activeMap.on("mouseenter", layerId, handleMouseEnter);
activeMap.on("mouseleave", layerId, handleMouseLeave);
}
});
return () => {
DRAWING_INTERACTIVE_LAYER_IDS.forEach((layerId) => {
if (activeMap.getLayer(layerId)) {
activeMap.off("click", layerId, handleDrawingClick);
activeMap.off("mouseenter", layerId, handleMouseEnter);
activeMap.off("mouseleave", layerId, handleMouseLeave);
}
});
};
}, [activeMode, mapReady, mapRef]);
const updateDraft = useCallback(
(draft: DrawingDraftState | null) => {
draftRef.current = draft;
const map = mapRef.current;
if (!map) {
return;
}
if (!draft || getDraftPointCount(draft) === 0) {
setDraftPointCount(0);
setDrawingSourceData(map, DRAWING_SOURCE_IDS.draft, emptyDrawingFeatureCollection);
return;
}
setDraftPointCount(getDraftPointCount(draft));
const draftFeatures: DrawingFeatureCollection["features"] =
draft.mode === "circle"
? [createPointFeature("draft-circle-center", draft.center, "圆心")]
: draft.coordinates.map((coordinates, index) =>
createPointFeature(`draft-vertex-${index}`, coordinates, `顶点 ${index + 1}`)
);
if (draft.mode !== "circle" && draft.coordinates.length >= 2) {
draftFeatures.push(createLineFeature("draft-line", draft.coordinates, "绘制草稿"));
}
if (draft.mode === "polygon" && draft.coordinates.length >= 3) {
draftFeatures.push(createPolygonFeature("draft-polygon", draft.coordinates, "范围草稿"));
}
if (draft.mode === "circle" && draft.edge) {
draftFeatures.push(createPointFeature("draft-circle-edge", draft.edge, "半径"));
draftFeatures.push(createLineFeature("draft-circle-radius", [draft.center, draft.edge], "圆形半径"));
draftFeatures.push(createCircleFeature("draft-circle", draft.center, getDistanceMeters(draft.center, draft.edge), "圆形草稿"));
}
setDrawingSourceData(map, DRAWING_SOURCE_IDS.draft, {
type: "FeatureCollection",
features: draftFeatures
});
},
[mapRef]
);
const finishDraft = useCallback(() => {
const draft = draftRef.current;
if (!draft) {
return;
}
const minimumPoints = draft.mode === "line" ? 2 : draft.mode === "polygon" ? 3 : 2;
const draftPointCount = getDraftPointCount(draft);
if (draftPointCount < minimumPoints) {
return;
}
const id = createDrawingId(draft.mode, featureCounterRef.current + 1);
featureCounterRef.current += 1;
const label = createDrawingLabel(draft.mode, featureCounterRef.current);
const feature = createFeatureFromDraft(id, draft, label);
if (!feature) {
return;
}
commitFeatures({
type: "FeatureCollection",
features: [...featureCollectionRef.current.features, feature]
});
updateDraft(null);
onModeChange(null);
}, [commitFeatures, onModeChange, updateDraft]);
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map || !activeMode) {
clearDraft();
return;
}
const drawingMode = activeMode;
const canvas = map.getCanvas();
const previousCursor = canvas.style.cursor;
canvas.style.cursor = "crosshair";
if (drawingMode !== "point") {
map.doubleClickZoom.disable();
}
function handleClick(event: MapMouseEvent) {
event.preventDefault();
const coordinates: [number, number] = [event.lngLat.lng, event.lngLat.lat];
if (drawingMode === "point") {
const nextNumber = featureCounterRef.current + 1;
const id = createDrawingId(drawingMode, nextNumber);
featureCounterRef.current = nextNumber;
const feature = createPointFeature(id, coordinates, createDrawingLabel("point", nextNumber));
commitFeatures({
type: "FeatureCollection",
features: [...featureCollectionRef.current.features, feature]
});
return;
}
if (drawingMode === "circle") {
const currentDraft = draftRef.current?.mode === "circle" ? draftRef.current : null;
if (!currentDraft) {
updateDraft({
mode: "circle",
center: coordinates
});
return;
}
updateDraft({
mode: "circle",
center: currentDraft.center,
edge: coordinates
});
finishDraft();
return;
}
const draftMode: DrawingDraftState["mode"] = drawingMode === "polygon" ? "polygon" : "line";
const currentDraft = draftRef.current?.mode === draftMode ? draftRef.current : { mode: draftMode, coordinates: [] };
if (isSameCoordinate(currentDraft.coordinates[currentDraft.coordinates.length - 1], coordinates)) {
return;
}
updateDraft({
mode: draftMode,
coordinates: [...currentDraft.coordinates, coordinates]
});
}
function handleDoubleClick(event: MapMouseEvent) {
event.preventDefault();
finishDraft();
}
function handleMouseMove(event: MapMouseEvent) {
if (drawingMode !== "circle") {
return;
}
const currentDraft = draftRef.current;
if (currentDraft?.mode !== "circle") {
return;
}
updateDraft({
mode: "circle",
center: currentDraft.center,
edge: [event.lngLat.lng, event.lngLat.lat]
});
}
map.on("click", handleClick);
map.on("dblclick", handleDoubleClick);
map.on("mousemove", handleMouseMove);
return () => {
map.off("click", handleClick);
map.off("dblclick", handleDoubleClick);
map.off("mousemove", handleMouseMove);
canvas.style.cursor = previousCursor;
if (drawingMode !== "point") {
map.doubleClickZoom.enable();
}
};
}, [activeMode, clearDraft, commitFeatures, finishDraft, mapReady, mapRef, updateDraft]);
const undo = useCallback(() => {
const draft = draftRef.current;
if (draft && getDraftPointCount(draft) > 0) {
if (draft.mode === "circle") {
updateDraft(draft.edge ? { mode: "circle", center: draft.center } : null);
} else {
updateDraft({
mode: draft.mode,
coordinates: draft.coordinates.slice(0, -1)
});
}
return;
}
const previousCollection = undoStackRef.current.pop();
if (!previousCollection) {
return;
}
redoStackRef.current.push(featureCollectionRef.current);
updateFeatures(previousCollection);
setHistoryVersion((current) => current + 1);
}, [updateDraft, updateFeatures]);
const redo = useCallback(() => {
const nextCollection = redoStackRef.current.pop();
if (!nextCollection) {
return;
}
undoStackRef.current.push(featureCollectionRef.current);
updateFeatures(nextCollection);
setHistoryVersion((current) => current + 1);
}, [updateFeatures]);
const deleteSelected = useCallback(() => {
if (!selectedFeatureId) {
return;
}
const nextFeatures = featureCollectionRef.current.features.filter((feature) => feature.properties.id !== selectedFeatureId);
if (nextFeatures.length === featureCollectionRef.current.features.length) {
return;
}
setSelectedFeatureId(null);
setHiddenFeatureIds((current) => {
const next = new Set(current);
next.delete(selectedFeatureId);
return next;
});
commitFeatures({
type: "FeatureCollection",
features: nextFeatures
});
}, [commitFeatures, selectedFeatureId]);
const clear = useCallback(() => {
clearDraft();
setHiddenFeatureIds(new Set());
setSelectedFeatureId(null);
commitFeatures(emptyDrawingFeatureCollection);
onModeChange(null);
}, [clearDraft, commitFeatures, onModeChange]);
const toggleVisibility = useCallback((featureId: string) => {
setHiddenFeatureIds((current) => {
const next = new Set(current);
if (next.has(featureId)) {
next.delete(featureId);
} else {
next.add(featureId);
}
return next;
});
}, []);
const exportGeoJSON = useCallback(() => {
const data = JSON.stringify(featureCollectionRef.current, null, 2);
const blob = new Blob([data], { type: "application/geo+json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "workbench-annotations.geojson";
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}, []);
const annotations = useMemo<WorkbenchDrawingAnnotation[]>(
() =>
featureCollection.features.map((feature) => ({
id: feature.properties.id,
label: feature.properties.label,
description: `${getDrawingKindLabel(feature.properties.kind)} · ${formatDrawingTime(feature.properties.createdAt)}`,
kind: feature.properties.kind,
hidden: hiddenFeatureIds.has(feature.properties.id),
selected: selectedFeatureId === feature.properties.id,
onToggleVisibility: () => toggleVisibility(feature.properties.id),
onSelect: () => setSelectedFeatureId(feature.properties.id)
})),
[featureCollection, hiddenFeatureIds, selectedFeatureId, toggleVisibility]
);
return {
annotations,
canUndo: Boolean(draftPointCount || undoStackRef.current.length),
canRedo: Boolean(redoStackRef.current.length),
canDeleteSelected: Boolean(selectedFeatureId),
hasFeatures: featureCollection.features.length > 0,
undo,
redo,
deleteSelected,
clear,
exportGeoJSON
};
}
@@ -0,0 +1,305 @@
"use client";
import "maplibre-gl/dist/maplibre-gl.css";
import maplibregl, { type Map as MapLibreMap, type MapSourceDataEvent } from "maplibre-gl";
import { useEffect, useRef, useState, type RefObject } from "react";
import type { DetailFeature } from "../types";
import {
SIMULATION_SOURCE_IDS,
simulationAnnotationLayers,
simulationSources
} from "../map/annotation-layers";
import {
fitNetworkBounds,
getResponsiveWorkbenchPadding
} from "../map/camera";
import { waterNetworkLayers } from "../map/layers";
import { setSimulationLayersVisibility } from "../map/simulation-layers";
import {
createBaseStyle,
createWaterNetworkSources,
WATER_NETWORK_GLOBAL_VIEW
} from "../map/sources";
import { useMapInteractions } from "./use-map-interactions";
import { useSimulationLayerVisibility } from "./use-simulation-layer-visibility";
declare global {
interface Window {
__waterNetworkMap?: MapLibreMap;
}
}
type UseWorkbenchMapOptions = {
containerRef: RefObject<HTMLDivElement | null>;
leftPanelOpen: boolean;
rightPanelOpen: boolean;
impactVisible: boolean;
onSelectFeature: (feature: DetailFeature) => void;
};
type UseWorkbenchMapResult = {
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
mapError: string | null;
sourceStatuses: WorkbenchSourceStatus[];
fitNetworkBounds: () => void;
};
export type WorkbenchSourceStatusValue = "loading" | "online" | "degraded" | "offline";
export type WorkbenchSourceStatus = {
id: string;
sourceName: string;
status: WorkbenchSourceStatusValue;
message: string;
};
const SOURCE_STATUS_LABELS: Record<string, string> = {
"mapbox-base": "Mapbox 底图",
"geoserver-mvt": "GeoServer MVT",
"annotation-source": "业务标注源"
};
const SOURCE_GROUP_BY_ID: Record<string, string> = {
"mapbox-light": "mapbox-base",
"mapbox-satellite": "mapbox-base",
pipes: "geoserver-mvt",
junctions: "geoserver-mvt",
[SIMULATION_SOURCE_IDS.impactArea]: "annotation-source",
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source"
};
export function useWorkbenchMap({
containerRef,
leftPanelOpen,
rightPanelOpen,
impactVisible,
onSelectFeature
}: UseWorkbenchMapOptions): UseWorkbenchMapResult {
const mapRef = useRef<MapLibreMap | null>(null);
const impactVisibleRef = useRef(impactVisible);
const layoutOpenRef = useRef({ leftPanelOpen, rightPanelOpen });
const appliedLayoutPaddingRef = useRef<{ leftPanelOpen: boolean; rightPanelOpen: boolean } | null>(null);
const [mapReady, setMapReady] = useState(false);
const [mapError, setMapError] = useState<string | null>(null);
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
useEffect(() => {
layoutOpenRef.current = { leftPanelOpen, rightPanelOpen };
}, [leftPanelOpen, rightPanelOpen]);
useEffect(() => {
impactVisibleRef.current = impactVisible;
}, [impactVisible]);
useEffect(() => {
if (!containerRef.current || mapRef.current) {
return;
}
const mapboxToken = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN;
const map = new maplibregl.Map({
container: containerRef.current,
style: createBaseStyle(mapboxToken),
pitch: 0,
bearing: 0,
preserveDrawingBuffer: true,
attributionControl: false
});
window.__waterNetworkMap = map;
map.on("load", () => {
map.resize();
const sources = createWaterNetworkSources();
map.addSource("pipes", sources.pipes);
map.addSource("junctions", sources.junctions);
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
waterNetworkLayers.forEach((layer) => map.addLayer(layer));
simulationAnnotationLayers.forEach((layer) => map.addLayer(layer));
setSimulationLayersVisibility(map, impactVisibleRef.current);
setMapReady(true);
updateSourceStatus(setSourceStatuses, "annotation-source", "online", "业务标注源已就绪。");
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
});
const resizeMap = () => map.resize();
window.addEventListener("resize", resizeMap);
window.setTimeout(resizeMap, 0);
window.setTimeout(resizeMap, 300);
window.setTimeout(resizeMap, 1000);
map.on("sourcedata", (event) => {
updateStatusFromSourceEvent(event, "online", setSourceStatuses);
});
map.on("sourcedataabort", (event) => {
updateStatusFromSourceEvent(event, "degraded", setSourceStatuses);
});
map.on("error", (event) => {
const message = event.error?.message ?? "地图渲染异常";
if (!message.includes("AbortError")) {
const sourceGroupId = getSourceGroupFromErrorEvent(event);
if (sourceGroupId) {
updateSourceStatus(setSourceStatuses, sourceGroupId, "offline", getSourceErrorMessage(sourceGroupId, message));
} else {
setMapError(message);
}
}
});
mapRef.current = map;
return () => {
window.removeEventListener("resize", resizeMap);
map.remove();
mapRef.current = null;
delete window.__waterNetworkMap;
};
}, [containerRef]);
useMapInteractions({
mapRef,
mapReady,
onSelectFeature
});
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map) {
return;
}
const previousLayout = appliedLayoutPaddingRef.current;
const currentLayout = { leftPanelOpen, rightPanelOpen };
appliedLayoutPaddingRef.current = currentLayout;
if (
!previousLayout ||
(previousLayout.leftPanelOpen === currentLayout.leftPanelOpen &&
previousLayout.rightPanelOpen === currentLayout.rightPanelOpen)
) {
return;
}
map.easeTo({
padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen),
duration: 350
});
}, [leftPanelOpen, rightPanelOpen, mapReady]);
useSimulationLayerVisibility({
mapRef,
mapReady,
visible: impactVisible
});
function fitToNetworkBounds() {
const map = mapRef.current;
if (map) {
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
}
}
return {
mapRef,
mapReady,
mapError,
sourceStatuses: Object.values(sourceStatuses),
fitNetworkBounds: fitToNetworkBounds
};
}
function updateStatusFromSourceEvent(
event: MapSourceDataEvent,
status: WorkbenchSourceStatusValue,
setSourceStatuses: (updater: (current: Record<string, WorkbenchSourceStatus>) => Record<string, WorkbenchSourceStatus>) => void
) {
const sourceGroupId = SOURCE_GROUP_BY_ID[event.sourceId];
if (!sourceGroupId) {
return;
}
if (status === "online" && !event.isSourceLoaded) {
return;
}
updateSourceStatus(setSourceStatuses, sourceGroupId, status, getSourceStatusMessage(sourceGroupId, status));
}
function updateSourceStatus(
setSourceStatuses: (updater: (current: Record<string, WorkbenchSourceStatus>) => Record<string, WorkbenchSourceStatus>) => void,
sourceGroupId: string,
status: WorkbenchSourceStatusValue,
message: string
) {
setSourceStatuses((current) => {
const previous = current[sourceGroupId];
if (previous?.status === status && previous.message === message) {
return current;
}
return {
...current,
[sourceGroupId]: {
id: sourceGroupId,
sourceName: SOURCE_STATUS_LABELS[sourceGroupId] ?? sourceGroupId,
status,
message
}
};
});
}
function getSourceGroupFromErrorEvent(event: { sourceId?: string; error?: { message?: string } }) {
if (event.sourceId && SOURCE_GROUP_BY_ID[event.sourceId]) {
return SOURCE_GROUP_BY_ID[event.sourceId];
}
const message = event.error?.message ?? "";
if (message.includes("geoserver.waternetwork.cn")) {
return "geoserver-mvt";
}
if (message.includes("api.mapbox.com") || message.includes("mapbox")) {
return "mapbox-base";
}
return null;
}
function getSourceStatusMessage(sourceGroupId: string, status: WorkbenchSourceStatusValue) {
if (sourceGroupId === "mapbox-base") {
return status === "online" ? "底图瓦片已恢复正常。" : "底图瓦片请求中断,业务图层仍可继续使用。";
}
if (sourceGroupId === "geoserver-mvt") {
return status === "online" ? "管线和节点矢量瓦片已加载。" : "管线或节点瓦片请求中断,当前视图可能不完整。";
}
if (sourceGroupId === "annotation-source") {
return status === "online" ? "业务标注和影响范围已加载。" : "业务标注源加载中断,模拟标注可能暂不可见。";
}
return status === "online" ? "地图数据源已恢复正常。" : "地图数据源请求中断。";
}
function getSourceErrorMessage(sourceGroupId: string, errorMessage: string) {
if (sourceGroupId === "mapbox-base") {
return `底图瓦片请求失败:${errorMessage}`;
}
if (sourceGroupId === "geoserver-mvt") {
return `GeoServer 矢量瓦片请求失败:${errorMessage}`;
}
if (sourceGroupId === "annotation-source") {
return `业务标注源请求失败:${errorMessage}`;
}
return errorMessage;
}
@@ -0,0 +1,293 @@
"use client";
import type { Map as MapLibreMap, MapGeoJSONFeature, MapMouseEvent } from "maplibre-gl";
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
createMeasurementFeatureCollection,
emptyMeasurementFeatureCollection,
ensureMeasurementLayers,
setSelectedMeasurementPipeIds,
setMeasurementSourceData
} from "../map/measurement-layers";
import { getFeatureId } from "../map/feature-adapter";
import {
getLastSegmentDistanceMeters,
getLineDistanceMeters,
getPolygonAreaSquareMeters,
isSameCoordinate,
type LngLatTuple
} from "../map/geo";
export type WorkbenchMeasureMode = "distance" | "area" | "segment";
export type WorkbenchMeasureUnit = "m" | "km";
export type WorkbenchMeasurementResult = {
label: string;
value: string;
unit: string;
metrics: Array<{ label: string; value: string }>;
};
type UseWorkbenchMeasurementOptions = {
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
mode: WorkbenchMeasureMode;
unit: WorkbenchMeasureUnit;
};
const SNAP_RADIUS_PIXELS = 14;
const SNAP_LAYER_IDS = ["pipes-flow"];
type SelectedPipe = {
id: string;
lengthMeters?: number;
};
export function useWorkbenchMeasurement({
mapRef,
mapReady,
mode,
unit
}: UseWorkbenchMeasurementOptions) {
const [coordinates, setCoordinates] = useState<LngLatTuple[]>([]);
const [selectedPipes, setSelectedPipes] = useState<SelectedPipe[]>([]);
const [measuring, setMeasuring] = useState(false);
const coordinatesRef = useRef(coordinates);
const selectedPipesRef = useRef(selectedPipes);
useEffect(() => {
coordinatesRef.current = coordinates;
}, [coordinates]);
useEffect(() => {
selectedPipesRef.current = selectedPipes;
}, [selectedPipes]);
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map) {
return;
}
ensureMeasurementLayers(map);
setMeasurementSourceData(map, emptyMeasurementFeatureCollection);
}, [mapReady, mapRef]);
const syncMeasurementSource = useCallback(
(nextCoordinates: LngLatTuple[], nextMode = mode) => {
const map = mapRef.current;
if (!map) {
return;
}
setMeasurementSourceData(
map,
nextMode !== "segment" && nextCoordinates.length > 0
? createMeasurementFeatureCollection(nextCoordinates, nextMode)
: emptyMeasurementFeatureCollection
);
},
[mapRef, mode]
);
useEffect(() => {
syncMeasurementSource(coordinates, mode);
}, [coordinates, mode, syncMeasurementSource]);
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map) {
return;
}
setSelectedMeasurementPipeIds(
map,
mode === "segment" ? selectedPipes.map((pipe) => pipe.id) : []
);
}, [mapReady, mapRef, mode, selectedPipes]);
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map || !measuring) {
return;
}
const activeMap = map;
const canvas = map.getCanvas();
const previousCursor = canvas.style.cursor;
canvas.style.cursor = "crosshair";
function handleClick(event: MapMouseEvent) {
event.preventDefault();
if (mode === "segment") {
const selectedPipe = getSelectedPipe(activeMap, event);
if (selectedPipe) {
setSelectedPipes((current) => addUniquePipe(current, selectedPipe));
}
return;
}
const coordinate: LngLatTuple = [event.lngLat.lng, event.lngLat.lat];
setCoordinates((current) => {
if (isSameCoordinate(current[current.length - 1], coordinate)) {
return current;
}
return [...current, coordinate];
});
}
map.on("click", handleClick);
return () => {
map.off("click", handleClick);
canvas.style.cursor = previousCursor;
};
}, [mapReady, mapRef, measuring, mode]);
const clear = useCallback(() => {
setCoordinates([]);
setSelectedPipes([]);
setMeasuring(false);
syncMeasurementSource([]);
}, [syncMeasurementSource]);
const copyResult = useCallback(() => {
if (!navigator.clipboard) {
return;
}
void navigator.clipboard.writeText(getResultText(mode, unit, coordinatesRef.current, selectedPipesRef.current));
}, [mode, unit]);
const result = useMemo<WorkbenchMeasurementResult>(() => {
const distanceMeters = getLineDistanceMeters(coordinates);
const areaSquareMeters = mode === "area" ? getPolygonAreaSquareMeters(coordinates) : 0;
const segmentCount = Math.max(coordinates.length - 1, 0);
const lastSegmentMeters = getLastSegmentDistanceMeters(coordinates);
const selectedPipeLengthMeters = selectedPipes.reduce((total, pipe) => total + (pipe.lengthMeters ?? 0), 0);
if (mode === "area") {
return {
label: "当前面积",
value: formatArea(areaSquareMeters, unit),
unit: unit === "km" ? "km²" : "m²",
metrics: [
{ label: "顶点", value: String(coordinates.length) },
{ label: "周长", value: `${formatDistance(distanceMeters, unit)} ${unit}` }
]
};
}
if (mode === "segment") {
return {
label: "资产长度",
value: formatDistance(selectedPipeLengthMeters, unit),
unit,
metrics: [
{ label: "管段", value: `${selectedPipes.length}` }
]
};
}
return {
label: "当前长度",
value: formatDistance(distanceMeters, unit),
unit,
metrics: [
{ label: "段数", value: String(segmentCount) },
{ label: "末段", value: `${formatDistance(lastSegmentMeters, unit)} ${unit}` }
]
};
}, [coordinates, mode, selectedPipes, unit]);
return {
measuring,
result,
hasPoints: mode === "segment" ? selectedPipes.length > 0 : coordinates.length > 0,
start: () => setMeasuring(true),
stop: () => setMeasuring(false),
clear,
copyResult
};
}
function getResultText(
mode: WorkbenchMeasureMode,
unit: WorkbenchMeasureUnit,
coordinates: LngLatTuple[],
selectedPipes: SelectedPipe[]
) {
const distanceMeters = getLineDistanceMeters(coordinates);
if (mode === "area") {
return `面积:${formatArea(getPolygonAreaSquareMeters(coordinates), unit)} ${unit === "km" ? "km²" : "m²"}`;
}
if (mode === "segment") {
const lengthMeters = selectedPipes.reduce((total, pipe) => total + (pipe.lengthMeters ?? 0), 0);
return `资产长度:${formatDistance(lengthMeters, unit)} ${unit}`;
}
return `长度:${formatDistance(distanceMeters, unit)} ${unit}`;
}
function getSelectedPipe(map: MapLibreMap, event: MapMouseEvent): SelectedPipe | null {
const point = event.point;
const features = map.queryRenderedFeatures(
[
[point.x - SNAP_RADIUS_PIXELS, point.y - SNAP_RADIUS_PIXELS],
[point.x + SNAP_RADIUS_PIXELS, point.y + SNAP_RADIUS_PIXELS]
],
{ layers: SNAP_LAYER_IDS }
);
for (const feature of features) {
const selectedPipe = toSelectedPipe(feature);
if (selectedPipe) {
return selectedPipe;
}
}
return null;
}
function toSelectedPipe(feature: MapGeoJSONFeature): SelectedPipe | null {
const id = getFeatureId(feature);
if (!id || feature.layer.id !== "pipes-flow") {
return null;
}
return {
id,
lengthMeters: getNumericProperty(feature.properties?.length)
};
}
function getNumericProperty(value: unknown) {
const numberValue = Number(value);
return Number.isFinite(numberValue) && numberValue > 0 ? numberValue : undefined;
}
function addUniquePipe(current: SelectedPipe[], next: SelectedPipe) {
if (current.some((pipe) => pipe.id === next.id)) {
return current;
}
return [...current, next];
}
function formatDistance(valueMeters: number, unit: WorkbenchMeasureUnit) {
const value = unit === "km" ? valueMeters / 1000 : valueMeters;
return value.toLocaleString("zh-CN", {
maximumFractionDigits: unit === "km" ? 2 : 1,
minimumFractionDigits: 0
});
}
function formatArea(valueSquareMeters: number, unit: WorkbenchMeasureUnit) {
const value = unit === "km" ? valueSquareMeters / 1_000_000 : valueSquareMeters;
return value.toLocaleString("zh-CN", {
maximumFractionDigits: unit === "km" ? 3 : 1,
minimumFractionDigits: 0
});
}