feat: initialize drainage network frontend
This commit is contained in:
@@ -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
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user