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
+148
View File
@@ -0,0 +1,148 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createAgentApiClient } from "./client";
describe("Agent API client sessions", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("lists normalized sessions in newest-first order", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(
JSON.stringify({
sessions: [
{ id: "old", title: "", created_at: 100, updated_at: 120 },
{ id: "new", title: " 新会话 ", created_at: 200, updated_at: 210, run_status: "running" }
]
}),
{ status: 200 }
)
)
);
const sessions = await createAgentApiClient("http://agent.local").listSessions();
expect(sessions).toEqual([
{
id: "new",
title: "新会话",
createdAt: 200,
updatedAt: 210,
status: undefined,
parentSessionId: undefined,
isStreaming: undefined,
runStatus: "running"
},
{
id: "old",
title: "新对话",
createdAt: 100,
updatedAt: 120,
status: undefined,
parentSessionId: undefined,
isStreaming: undefined,
runStatus: undefined
}
]);
});
it("returns null when loading a missing session", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ message: "missing" }), { status: 404 })));
await expect(createAgentApiClient("http://agent.local").loadSession("missing")).resolves.toBeNull();
});
it("falls back when a candidate is not the agent service", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ message: "not found" }), { status: 404 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
schema_version: "agent-ui-registry@1",
chart_grammars: [],
components: [],
actions: []
}),
{ status: 200 }
)
);
vi.stubGlobal("fetch", fetchMock);
await expect(createAgentApiClient(["http://not-agent.local", "http://agent.local"]).getUiRegistry()).resolves.toEqual({
schema_version: "agent-ui-registry@1",
chart_grammars: [],
components: [],
actions: []
});
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"http://not-agent.local/api/v1/agent/chat/ui-registry",
undefined
);
expect(fetchMock).toHaveBeenNthCalledWith(2, "http://agent.local/api/v1/agent/chat/ui-registry", undefined);
});
it("sends title update payloads using backend field names", async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
await createAgentApiClient("http://agent.local").updateSessionTitle("session-1", " 标题 ", {
isTitleManuallyEdited: true
});
expect(fetchMock).toHaveBeenCalledWith(
"http://agent.local/api/v1/agent/chat/session/session-1/title",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({
title: "标题",
is_title_manually_edited: true
})
})
);
});
it("streams session events from the backend SSE endpoint", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(
[
'event: state\ndata: {"session_id":"session-1","messages":[{"id":"u1","role":"user","parts":[{"type":"text","text":"检查压力"}]}],"is_streaming":true,"run_status":"running"}\n\n',
'event: token\ndata: {"session_id":"session-1","content":"处理中"}\n\n',
'event: done\ndata: {"session_id":"session-1","duration_ms":20}\n\n'
].join(""),
{ status: 200 }
)
)
);
const events: unknown[] = [];
await createAgentApiClient("http://agent.local").streamSession("session-1", {
onEvent: (event) => events.push(event)
});
expect(events).toEqual([
{
type: "state",
sessionId: "session-1",
messages: [{ id: "u1", role: "user", parts: [{ type: "text", text: "检查压力" }] }],
isStreaming: true,
runStatus: "running"
},
{
type: "token",
sessionId: "session-1",
data: { session_id: "session-1", content: "处理中" }
},
{
type: "done",
sessionId: "session-1",
data: { session_id: "session-1", duration_ms: 20 }
}
]);
});
});
+563
View File
@@ -0,0 +1,563 @@
export type AgentRunStatus = "running" | "completed" | "error" | "aborted";
export type AgentPermissionReply = "once" | "always" | "reject";
export type AgentModelOption = {
id: string;
label: string;
description: string;
icon: "bolt" | "sparkle";
};
export type AgentModelsResponse = {
defaultModel: string;
models: AgentModelOption[];
};
export type AgentChatSession = {
id?: string;
session_id: string;
title?: string;
status?: string;
run_status?: AgentRunStatus;
is_streaming?: boolean;
messages?: unknown[];
created_at?: number | string;
updated_at?: number | string;
is_title_manually_edited?: boolean;
parent_session_id?: string;
};
export type AgentChatSessionSummary = {
id: string;
title: string;
createdAt: number;
updatedAt: number;
status?: string;
parentSessionId?: string;
isStreaming?: boolean;
runStatus?: AgentRunStatus;
};
export type AgentLoadedChatSession = AgentChatSessionSummary & {
sessionId: string;
isTitleManuallyEdited: boolean;
messages: unknown[];
};
export type AgentSessionStreamDataEventType =
| "token"
| "progress"
| "todo_update"
| "permission_request"
| "permission_response"
| "question_request"
| "question_response"
| "tool_call"
| "ui_envelope"
| "session_title"
| "done"
| "error"
| "auth_required";
export type AgentSessionStreamEvent =
| {
type: "state";
sessionId: string;
messages: unknown[];
isStreaming: boolean;
runStatus?: AgentRunStatus;
}
| {
type: AgentSessionStreamDataEventType;
sessionId?: string;
data: Record<string, unknown>;
};
export type AgentApiClient = {
createSession: () => Promise<AgentChatSession>;
listSessions: () => Promise<AgentChatSessionSummary[]>;
loadSession: (sessionId: string) => Promise<AgentLoadedChatSession | null>;
streamSession: (
sessionId: string,
options: {
signal?: AbortSignal;
onEvent: (event: AgentSessionStreamEvent) => void;
}
) => Promise<void>;
updateSessionTitle: (
sessionId: string,
title: string,
options?: { isTitleManuallyEdited?: boolean }
) => Promise<void>;
deleteSession: (sessionId: string) => Promise<void>;
getModels: () => Promise<AgentModelsResponse>;
getUiRegistry: () => Promise<unknown>;
resolveRenderRef: (renderRef: string, sessionId: string) => Promise<unknown>;
replyPermission: (
requestId: string,
options: { sessionId: string; reply: AgentPermissionReply; message?: string }
) => Promise<void>;
replyQuestion: (
requestId: string,
options: { sessionId: string; answers: string[][] }
) => Promise<void>;
rejectQuestion: (requestId: string, options: { sessionId: string }) => Promise<void>;
abort: (sessionId: string) => Promise<void>;
};
const AGENT_API_BASE_URLS = process.env.NEXT_PUBLIC_AGENT_API_BASE_URL
? [process.env.NEXT_PUBLIC_AGENT_API_BASE_URL.replace(/\/$/, "")]
: ["", "http://127.0.0.1:8787"];
const CHAT_PATH = "/api/v1/agent/chat";
export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BASE_URLS): AgentApiClient {
const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, ""));
let activeBaseUrl = candidates[0] ?? "";
return {
async createSession() {
return requestJsonWithFallback<AgentChatSession>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
},
async listSessions() {
const payload = await requestJsonWithFallback<{ sessions?: unknown[] }>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
"/sessions"
);
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
},
async loadSession(sessionId) {
const response = await fetchWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/session/${encodeURIComponent(sessionId)}`);
const text = await response.text();
const data = text ? JSON.parse(text) : null;
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(getResponseErrorMessage(data, response.status));
}
return toLoadedSession(data);
},
async streamSession(sessionId, options) {
const response = await fetchWithFallback(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
`/session/${encodeURIComponent(sessionId)}/stream`,
{ signal: options.signal }
);
if (!response.ok) {
const text = await response.text();
const data = text ? JSON.parse(text) : null;
throw new Error(getResponseErrorMessage(data, response.status));
}
await readSessionSseStream(response, options.onEvent);
},
async updateSessionTitle(sessionId, title, options) {
const normalizedTitle = title.trim();
if (!normalizedTitle) {
return;
}
await requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
`/session/${encodeURIComponent(sessionId)}/title`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: normalizedTitle,
is_title_manually_edited: options?.isTitleManuallyEdited
})
}
);
},
async deleteSession(sessionId) {
await requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
`/session/${encodeURIComponent(sessionId)}`,
{ method: "DELETE" }
);
},
async getModels() {
const payload = await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/models");
return toModelsResponse(payload);
},
async getUiRegistry() {
return requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/ui-registry");
},
async resolveRenderRef(renderRef, sessionId) {
const params = new URLSearchParams({ session_id: sessionId });
return requestJsonWithFallback<unknown>(
candidates,
activeBaseUrl,
(nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
},
`/render-ref/${encodeURIComponent(renderRef)}?${params.toString()}`
);
},
async replyPermission(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/permission/${encodeURIComponent(requestId)}/reply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: options.sessionId,
reply: options.reply,
message: options.message
})
});
},
async replyQuestion(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/question/${encodeURIComponent(requestId)}/reply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: options.sessionId,
answers: options.answers
})
});
},
async rejectQuestion(requestId, options) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, `/question/${encodeURIComponent(requestId)}/reject`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: options.sessionId
})
});
},
async abort(sessionId) {
await requestJsonWithFallback<unknown>(candidates, activeBaseUrl, (nextBaseUrl) => {
activeBaseUrl = nextBaseUrl;
}, "/abort", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId })
});
}
};
}
async function requestJsonWithFallback<T>(
baseUrls: string[],
activeBaseUrl: string,
setActiveBaseUrl: (baseUrl: string) => void,
path: string,
init?: RequestInit
) {
const response = await fetchWithFallback(baseUrls, activeBaseUrl, setActiveBaseUrl, path, init);
const text = await response.text();
const data = text ? JSON.parse(text) : null;
if (!response.ok) {
throw new Error(getResponseErrorMessage(data, response.status));
}
return data as T;
}
async function fetchWithFallback(
baseUrls: string[],
activeBaseUrl: string,
setActiveBaseUrl: (baseUrl: string) => void,
path: string,
init?: RequestInit
) {
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
let lastError: unknown;
let lastResponse: Response | null = null;
for (const baseUrl of orderedBaseUrls) {
try {
const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, init);
if (response.ok) {
setActiveBaseUrl(baseUrl);
return response;
}
lastResponse = response;
if (shouldFallbackOnHttpStatus(response.status) && baseUrl !== orderedBaseUrls[orderedBaseUrls.length - 1]) {
await response.body?.cancel();
continue;
}
return response;
} catch (error) {
lastError = error;
}
}
if (lastResponse) {
return lastResponse;
}
throw lastError instanceof Error ? lastError : new Error("Agent API unavailable");
}
function shouldFallbackOnHttpStatus(status: number) {
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isPresent<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}
function toModelsResponse(value: unknown): AgentModelsResponse {
if (!isRecord(value)) {
return { defaultModel: "", models: [] };
}
const models = Array.isArray(value.models) ? value.models.map(toModelOption).filter(isPresent) : [];
const defaultModel = typeof value.default_model === "string" ? value.default_model : models[0]?.id ?? "";
return {
defaultModel,
models
};
}
function toModelOption(value: unknown): AgentModelOption | null {
if (!isRecord(value) || typeof value.id !== "string") {
return null;
}
const label = typeof value.label === "string" && value.label.trim() ? value.label : value.id;
const description =
typeof value.description === "string" && value.description.trim() ? value.description : label;
const icon = value.icon === "bolt" || value.icon === "sparkle" ? value.icon : "sparkle";
return {
id: value.id,
label,
description,
icon
};
}
function getResponseErrorMessage(data: unknown, status: number) {
return isRecord(data) && typeof data.message === "string"
? data.message
: `Agent API failed with HTTP ${status}`;
}
function toMillis(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim()) {
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : Date.now();
}
return Date.now();
}
function normalizeTitle(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : "新对话";
}
function readRunStatus(value: unknown): AgentRunStatus | undefined {
return value === "running" || value === "completed" || value === "error" || value === "aborted"
? value
: undefined;
}
function toSessionSummary(value: unknown): AgentChatSessionSummary | null {
if (!isRecord(value)) {
return null;
}
const id = typeof value.id === "string" ? value.id : typeof value.session_id === "string" ? value.session_id : "";
if (!id) {
return null;
}
return {
id,
title: normalizeTitle(value.title),
createdAt: toMillis(value.created_at),
updatedAt: toMillis(value.updated_at),
status: typeof value.status === "string" ? value.status : undefined,
parentSessionId: typeof value.parent_session_id === "string" ? value.parent_session_id : undefined,
isStreaming: typeof value.is_streaming === "boolean" ? value.is_streaming : undefined,
runStatus: readRunStatus(value.run_status)
};
}
function toLoadedSession(value: unknown): AgentLoadedChatSession {
const summary = toSessionSummary(value);
if (!summary || !isRecord(value)) {
throw new Error("Invalid agent session payload");
}
return {
...summary,
sessionId: typeof value.session_id === "string" ? value.session_id : summary.id,
isTitleManuallyEdited:
typeof value.is_title_manually_edited === "boolean" ? value.is_title_manually_edited : false,
messages: Array.isArray(value.messages) ? value.messages : []
};
}
async function readSessionSseStream(
response: Response,
onEvent: (event: AgentSessionStreamEvent) => void
) {
if (!response.body) {
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
let delimiterIndex = buffer.indexOf("\n\n");
while (delimiterIndex !== -1) {
const rawEvent = buffer.slice(0, delimiterIndex);
buffer = buffer.slice(delimiterIndex + 2);
emitSessionSseEvent(rawEvent, onEvent);
delimiterIndex = buffer.indexOf("\n\n");
}
if (done) {
break;
}
}
if (buffer.trim()) {
emitSessionSseEvent(buffer, onEvent);
}
}
function emitSessionSseEvent(
rawEvent: string,
onEvent: (event: AgentSessionStreamEvent) => void
) {
const lines = rawEvent.split(/\r?\n/);
const eventType = lines
.find((line) => line.startsWith("event:"))
?.slice("event:".length)
.trim();
const dataText = lines
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice("data:".length).trimStart())
.join("\n");
if (!eventType || !dataText) {
return;
}
const data = JSON.parse(dataText) as unknown;
if (!isRecord(data)) {
return;
}
if (eventType === "state") {
const sessionId = typeof data.session_id === "string" ? data.session_id : "";
onEvent({
type: "state",
sessionId,
messages: Array.isArray(data.messages) ? data.messages : [],
isStreaming: data.is_streaming === true,
runStatus: readRunStatus(data.run_status)
});
return;
}
if (!isSessionStreamDataEventType(eventType)) {
return;
}
onEvent({
type: eventType,
sessionId: typeof data.session_id === "string" ? data.session_id : undefined,
data
});
}
function isSessionStreamDataEventType(value: string): value is AgentSessionStreamDataEventType {
return (
value === "token" ||
value === "progress" ||
value === "todo_update" ||
value === "permission_request" ||
value === "permission_response" ||
value === "question_request" ||
value === "question_response" ||
value === "tool_call" ||
value === "ui_envelope" ||
value === "session_title" ||
value === "done" ||
value === "error" ||
value === "auth_required"
);
}
function compareSessionSummaries(left: AgentChatSessionSummary, right: AgentChatSessionSummary) {
const createdAtDiff = right.createdAt - left.createdAt;
if (createdAtDiff !== 0) {
return createdAtDiff;
}
const updatedAtDiff = right.updatedAt - left.updatedAt;
if (updatedAtDiff !== 0) {
return updatedAtDiff;
}
return right.id.localeCompare(left.id);
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import type { AgentApiClient } from "./client";
import { fetchAgentBootstrap, fetchAgentSessions } from "./swr";
describe("Agent API SWR helpers", () => {
it("loads bootstrap data with a normalized UI registry", async () => {
const client = {
getUiRegistry: vi.fn(async () => ({
schema_version: "agent-ui-registry@1",
chart_grammars: ["echarts-safe-subset"],
components: [{ id: "HistoryPanel", supportedSurfaces: ["side_panel", "unknown"] }],
actions: [{ id: "zoom_to_map", supportedSurfaces: ["map_overlay"] }]
})),
getModels: vi.fn(async () => ({
defaultModel: "model-a",
models: [{ id: "model-a", label: "Model A", description: "Default model", icon: "sparkle" }]
}))
} as Pick<AgentApiClient, "getUiRegistry" | "getModels"> as AgentApiClient;
await expect(fetchAgentBootstrap(client)).resolves.toEqual({
defaultModel: "model-a",
models: [{ id: "model-a", label: "Model A", description: "Default model", icon: "sparkle" }],
registry: {
schema_version: "agent-ui-registry@1",
chart_grammars: ["echarts-safe-subset"],
components: [{ id: "HistoryPanel", supportedSurfaces: ["side_panel"] }],
actions: [{ id: "zoom_to_map", supportedSurfaces: ["map_overlay"] }]
}
});
});
it("loads session summaries through the shared client", async () => {
const sessions = [
{
id: "session-1",
title: "会话",
createdAt: 1,
updatedAt: 2
}
];
const client = {
listSessions: vi.fn(async () => sessions)
} as Pick<AgentApiClient, "listSessions"> as AgentApiClient;
await expect(fetchAgentSessions(client)).resolves.toBe(sessions);
});
});
+32
View File
@@ -0,0 +1,32 @@
import type {
AgentApiClient,
AgentChatSessionSummary,
AgentModelOption
} from "./client";
import { parseUiRegistry, type UIRegistry } from "../ui-envelope";
export const agentBootstrapKey = ["agent", "bootstrap"] as const;
export const agentSessionsKey = ["agent", "sessions"] as const;
export type AgentBootstrapData = {
registry: UIRegistry | null;
models: AgentModelOption[];
defaultModel: string;
};
export async function fetchAgentBootstrap(client: AgentApiClient): Promise<AgentBootstrapData> {
const [rawRegistry, modelsResponse] = await Promise.all([
client.getUiRegistry(),
client.getModels()
]);
return {
registry: parseUiRegistry(rawRegistry),
models: modelsResponse.models,
defaultModel: modelsResponse.defaultModel
};
}
export async function fetchAgentSessions(client: AgentApiClient): Promise<AgentChatSessionSummary[]> {
return client.listSessions();
}
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { normalizeSafeChartData, parseChartSpec, pointToLabelValue } from "./chart-data";
describe("chart data normalization", () => {
it("parses chart spec aliases", () => {
expect(parseChartSpec({ title: "压力", chart_type: "bar", x_axis_name: "时间" })).toEqual({
title: "压力",
chartType: "bar",
xAxisName: "时间",
yAxisName: undefined
});
});
it("normalizes series with x_data and numeric strings", () => {
expect(
normalizeSafeChartData({
x_data: ["00:00", "01:00"],
series: [{ name: "压力", data: ["1.2", 1.4] }]
})
).toEqual({
xData: ["00:00", "01:00"],
series: [{ name: "压力", data: [1.2, 1.4], type: undefined }]
});
});
it("normalizes raw point arrays into a default series", () => {
expect(
normalizeSafeChartData({
series: [
["A", 12],
["B", "13"]
]
})
).toEqual({
xData: ["A", "B"],
series: [{ name: "数据", data: [12, 13], type: undefined }]
});
});
it("normalizes object points", () => {
expect(pointToLabelValue({ time: "10:00", value: "2.4" }, "fallback")).toEqual({
label: "10:00",
value: 2.4
});
});
it("clips chart points to the safe limit", () => {
const data = normalizeSafeChartData(
{
x_data: Array.from({ length: 30 }, (_, index) => String(index)),
series: [{ name: "流量", data: Array.from({ length: 30 }, (_, index) => index) }]
},
5
);
expect(data?.xData).toHaveLength(5);
expect(data?.series[0].data).toEqual([0, 1, 2, 3, 4]);
});
});
+183
View File
@@ -0,0 +1,183 @@
export type SafeChartSpec = {
title?: string;
chartType: "line" | "bar";
xAxisName?: string;
yAxisName?: string;
};
export type SafeChartSeries = {
name: string;
data: number[];
type?: "line" | "bar";
};
export type SafeChartData = {
xData: string[];
series: SafeChartSeries[];
};
type RawChartPoint = number | string | [unknown, unknown] | RawChartPointObject;
type RawChartPointObject = {
x?: unknown;
y?: unknown;
time?: unknown;
timestamp?: unknown;
label?: unknown;
name?: unknown;
value?: unknown;
};
type RawChartSeries = {
name?: unknown;
data?: unknown;
points?: unknown;
values?: unknown;
type?: unknown;
};
export function parseChartSpec(value: unknown): SafeChartSpec {
if (!isRecord(value)) {
return { chartType: "line" };
}
return {
title: stringValue(value.title),
chartType: value.chart_type === "bar" || value.chartType === "bar" ? "bar" : "line",
xAxisName: stringValue(value.x_axis_name ?? value.xAxisName),
yAxisName: stringValue(value.y_axis_name ?? value.yAxisName)
};
}
export function normalizeSafeChartData(value: unknown, maxPoints = 24): SafeChartData | null {
if (!isRecord(value)) {
return null;
}
const xData = normalizeXData(value.x_data ?? value.xData);
const rawSeriesItems = normalizeRawSeriesItems(value.series);
if (!rawSeriesItems.length) {
return null;
}
const normalizedSeries = rawSeriesItems
.map((rawItem, seriesIndex): SafeChartSeries | null => {
const item =
isRecord(rawItem) && !isRawChartPoint(rawItem) ? rawItem : ({ data: rawItem } satisfies RawChartSeries);
const rawData = item.data ?? item.points ?? item.values;
if (!Array.isArray(rawData)) {
return null;
}
const labelsFromPoints: string[] = [];
const data = rawData
.slice(0, maxPoints)
.map((point, index) => {
const parsed = pointToLabelValue(point as RawChartPoint, xData[index] ?? `${index + 1}`);
if (!parsed) {
return null;
}
labelsFromPoints[index] = parsed.label;
return parsed.value;
})
.filter((item): item is number => item !== null);
if (!data.length) {
return null;
}
if (!xData.length && labelsFromPoints.length) {
xData.push(...labelsFromPoints);
}
return {
name: stringValue(item.name) ?? `系列 ${seriesIndex + 1}`,
data,
type: item.type === "line" || item.type === "bar" ? item.type : undefined
};
})
.filter((item): item is SafeChartSeries => Boolean(item));
const clippedXData = xData.slice(0, maxPoints);
const clippedSeries = normalizedSeries.map((series) => ({
...series,
data: series.data.slice(0, clippedXData.length)
}));
return clippedXData.length > 0 && clippedSeries.length > 0
? {
xData: clippedXData,
series: clippedSeries
}
: null;
}
export function pointToLabelValue(point: RawChartPoint, fallbackLabel: string): { label: string; value: number } | null {
const directValue = toFiniteNumber(point);
if (directValue !== null) {
return { label: fallbackLabel, value: directValue };
}
if (Array.isArray(point)) {
const value = toFiniteNumber(point[1]);
if (value === null) {
return null;
}
return { label: String(point[0] ?? fallbackLabel), value };
}
if (isRecord(point)) {
const value = toFiniteNumber(point.value ?? point.y);
if (value === null) {
return null;
}
const label = point.x ?? point.time ?? point.timestamp ?? point.label ?? point.name ?? fallbackLabel;
return { label: String(label), value };
}
return null;
}
function normalizeXData(rawXData: unknown): string[] {
return Array.isArray(rawXData) ? rawXData.map((item) => String(item ?? "")).filter(Boolean) : [];
}
function normalizeRawSeriesItems(rawSeries: unknown): unknown[] {
if (!Array.isArray(rawSeries)) {
return isRecord(rawSeries) ? [rawSeries] : [];
}
return rawSeries.length > 0 && rawSeries.every(isRawChartPoint) ? [{ name: "数据", data: rawSeries }] : rawSeries;
}
function isRawChartPoint(item: unknown): boolean {
if (toFiniteNumber(item) !== null) {
return true;
}
if (Array.isArray(item)) {
return item.length >= 2 && toFiniteNumber(item[1]) !== null;
}
if (isRecord(item)) {
return item.data === undefined && item.points === undefined && item.values === undefined && toFiniteNumber(item.value ?? item.y) !== null;
}
return false;
}
function toFiniteNumber(value: unknown): number | null {
if (typeof value === "number") {
return Number.isFinite(value) ? value : null;
}
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function stringValue(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,79 @@
"use client";
import { ChevronRight } from "lucide-react";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { cn } from "@/lib/utils";
import {
MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import { AgentPersona } from "./agent-persona";
type AgentCollapsedRailProps = {
personaState?: PersonaState;
statusLabel?: string;
onExpand: () => void;
};
export function AgentCollapsedRail({ personaState, statusLabel = "Agent", onExpand }: AgentCollapsedRailProps) {
const state = getCollapsedAgentState(statusLabel, personaState);
return (
<aside className={cn("agent-rail-enter pointer-events-auto w-[188px] p-2", MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME)}>
<div className="agent-panel-control agent-rail-item rounded-2xl p-2">
<div className="flex items-center gap-2">
<AgentPersona
className="h-10 w-10"
fallbackClassName="h-10 w-10"
state={personaState}
statusLabel={statusLabel}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-slate-950">HydroAgent</p>
<p className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs font-semibold text-slate-500">
<span className={cn("h-1.5 w-1.5 shrink-0 rounded-full", state.dotClassName)} />
<span className="truncate" title={statusLabel}>{state.label}</span>
</p>
</div>
<button
type="button"
aria-label="展开 Agent 面板"
title="展开 Agent 面板"
onClick={onExpand}
className="agent-panel-primary-icon grid h-9 w-9 shrink-0 place-items-center rounded-xl transition"
>
<ChevronRight size={17} aria-hidden="true" />
</button>
</div>
</div>
</aside>
);
}
function getCollapsedAgentState(statusLabel: string, personaState?: PersonaState) {
if (personaState === "asleep" || /失败|不可用|错误|降级/.test(statusLabel)) {
return {
label: "离线",
dotClassName: "bg-red-500"
};
}
if (personaState === "listening") {
return {
label: "待确认",
dotClassName: "bg-orange-500"
};
}
if (personaState === "thinking") {
return {
label: "处理中",
dotClassName: "bg-blue-500"
};
}
return {
label: "在线",
dotClassName: "bg-emerald-500"
};
}
@@ -0,0 +1,629 @@
"use client";
import {
Bolt,
CheckCircle2,
ChevronsUpDown,
ChevronLeft,
Gauge,
History,
Plus,
SendHorizontal,
Sparkles
} from "lucide-react";
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useStickToBottomContext } from "use-stick-to-bottom";
import { Agent, AgentContent } from "@/shared/ai-elements/agent";
import {
Conversation,
ConversationContent,
ConversationScrollButton
} from "@/shared/ai-elements/conversation";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { Message, MessageContent } from "@/shared/ai-elements/message";
import {
PromptInput,
PromptInputBody,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea
} from "@/shared/ai-elements/prompt-input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { Button } from "@/shared/ui/button";
import { cn } from "@/lib/utils";
import {
MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import type { AgentChatSessionSummary } from "../api/client";
import { AgentPersona } from "./agent-persona";
import {
agentLayoutTransition,
agentPanelItemVariants,
agentPanelListVariants,
agentPanelSectionVariants
} from "./agent-motion";
import { AgentHistoryPanel } from "./agent-history-panel";
import { AgentMessageDetails } from "./agent-message-details";
import { AgentOperationalBrief } from "./agent-operational-brief";
import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer";
import { StreamingTokenResponse } from "./streaming-token-response";
import type {
AgentChatMessage,
AgentModelOption,
AgentPermissionReply,
AgentPermissionRequest,
AgentQuestionRequest,
AgentStreamRenderState,
AgentUiResult
} from "../types";
type AgentCommandPanelProps = {
collapsing?: boolean;
personaState?: PersonaState;
sessionTitle?: string;
sessionHistory?: AgentChatSessionSummary[];
sessionHistoryLoading?: boolean;
activeSessionId?: string | null;
statusLabel?: string;
streaming?: boolean;
messages?: AgentChatMessage[];
streamRenderState?: AgentStreamRenderState;
modelOptions?: AgentModelOption[];
selectedModel?: string;
uiResults?: AgentUiResult[];
onCollapse: () => void;
onRefreshHistory?: () => Promise<void> | void;
onStartNewSession?: () => Promise<void> | void;
onLoadHistorySession?: (sessionId: string) => Promise<void> | void;
onRenameHistorySession?: (sessionId: string, title: string) => Promise<void> | void;
onDeleteHistorySession?: (sessionId: string) => Promise<void> | void;
onSelectModel?: (model: string) => void;
onSubmitPrompt: (prompt: string) => Promise<void> | void;
onStopPrompt?: () => void;
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
};
export function AgentCommandPanel({
collapsing = false,
personaState,
sessionTitle = "新对话",
sessionHistory = [],
sessionHistoryLoading = false,
activeSessionId,
statusLabel = "Agent 后端连接中",
streaming = false,
messages = [],
streamRenderState = {},
modelOptions = [],
selectedModel = "",
uiResults = [],
onCollapse,
onRefreshHistory,
onStartNewSession,
onLoadHistorySession,
onRenameHistorySession,
onDeleteHistorySession,
onSelectModel,
onSubmitPrompt,
onStopPrompt,
onReplyPermission,
onReplyQuestion,
onRejectQuestion
}: AgentCommandPanelProps) {
const [prompt, setPrompt] = useState("");
const [historyOpen, setHistoryOpen] = useState(false);
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [scrollRequestId, setScrollRequestId] = useState(0);
const trimmedPrompt = prompt.trim();
useEffect(() => {
if (historyOpen) {
void Promise.resolve(onRefreshHistory?.());
}
}, [historyOpen, onRefreshHistory]);
const submitPrompt = (nextPrompt: string) => {
if (!nextPrompt || streaming) {
return;
}
setPrompt("");
setScrollRequestId((requestId) => requestId + 1);
void Promise.resolve(onSubmitPrompt(nextPrompt)).catch(() => {
setPrompt(nextPrompt);
});
};
return (
<MotionConfig reducedMotion="user">
<aside
className={cn(
"pointer-events-auto flex h-full min-h-0 w-full flex-col overflow-hidden",
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME,
collapsing ? "agent-panel-collapse" : "agent-panel-enter"
)}
>
<Agent className="flex h-full min-h-0 flex-col border-0 bg-transparent">
<div className="agent-panel-band border-b border-white/70">
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 flex-1 items-center gap-3 text-slate-900">
<AgentPersona
className="h-[72px] w-[72px]"
fallbackClassName="h-[72px] w-[72px] rounded-2xl"
state={personaState}
statusLabel={statusLabel}
streaming={streaming}
/>
<div className="min-w-0 flex-1 space-y-2">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<p className="shrink-0 text-xs font-semibold uppercase text-slate-500"></p>
<span className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-lg border border-slate-200/70 bg-white/75 px-2 text-xs font-semibold text-slate-600">
<span className={cn("h-1.5 w-1.5 rounded-full", getAgentConnectionClassName(statusLabel))} />
{getAgentConnectionLabel(statusLabel)}
</span>
</div>
<p className="truncate text-sm font-semibold text-slate-950" title={sessionTitle}>
{sessionTitle}
</p>
</div>
</div>
</div>
<button
type="button"
aria-label="新建 Agent 对话"
title="新建 Agent 对话"
onClick={() => {
setHistoryOpen(false);
setPrompt("");
void Promise.resolve(onStartNewSession?.());
}}
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
>
<Plus size={17} aria-hidden="true" />
</button>
<button
type="button"
aria-label="打开 Agent 历史记录"
title="打开 Agent 历史记录"
onClick={() => setHistoryOpen((open) => !open)}
className={cn(
"agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition",
historyOpen && "border-blue-200 bg-blue-50 text-blue-700"
)}
>
<History size={17} aria-hidden="true" />
</button>
<button
type="button"
aria-label="折叠 Agent 面板"
title="折叠 Agent 面板"
onClick={onCollapse}
className="agent-panel-icon-button grid h-9 w-9 shrink-0 place-items-center rounded-xl text-slate-600 transition"
>
<ChevronLeft size={17} aria-hidden="true" />
</button>
</div>
<AnimatePresence initial={false}>
{historyOpen ? (
<motion.div
key="history"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
<AgentHistoryPanel
activeSessionId={activeSessionId}
loadingSessionId={loadingSessionId}
loading={sessionHistoryLoading}
sessions={sessionHistory}
onRefresh={onRefreshHistory}
onSelectSession={(nextSessionId) => {
setLoadingSessionId(nextSessionId);
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
.then(() => setHistoryOpen(false))
.finally(() => setLoadingSessionId(null));
}}
onRenameSession={onRenameHistorySession}
onDeleteSession={onDeleteHistorySession}
/>
</motion.div>
) : null}
</AnimatePresence>
<AnimatePresence initial={false}>
{messages.length > 0 || streaming ? (
<motion.div
key="operational-brief"
className="space-y-2 px-3 pb-3"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
<AgentOperationalBrief
messages={messages}
statusLabel={statusLabel}
streaming={streaming}
onSubmitCommand={submitPrompt}
/>
</motion.div>
) : null}
</AnimatePresence>
</div>
<AgentContent className="flex min-h-0 flex-1 flex-col gap-0 p-0">
<Conversation className="agent-panel-conversation min-h-0">
<ConversationContent className="gap-4 p-3">
<AnimatePresence mode="popLayout" initial={false}>
{messages.length > 0 ? (
<BackendMessageList
key="messages"
messages={messages}
streaming={streaming}
streamRenderState={streamRenderState}
onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
) : (
<motion.div
key="empty"
className="w-full"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
<AgentEmptyState statusLabel={statusLabel} />
</motion.div>
)}
</AnimatePresence>
<AgentUiEnvelopeRenderer results={uiResults} />
</ConversationContent>
<AgentConversationScrollManager
activeSessionId={activeSessionId}
messages={messages}
scrollRequestId={scrollRequestId}
streaming={streaming}
uiResults={uiResults}
/>
<ConversationScrollButton className="bottom-3" />
</Conversation>
</AgentContent>
<div className="agent-panel-band border-t border-white/70 p-3">
<PromptInput
className="agent-panel-control rounded-2xl shadow-sm"
onSubmit={(message) => {
const nextPrompt = message.text.trim();
if (nextPrompt) {
submitPrompt(nextPrompt);
}
}}
>
<PromptInputBody>
<PromptInputTextarea
value={prompt}
onChange={(event) => setPrompt(event.currentTarget.value)}
className="min-h-14 text-sm"
placeholder="输入调度问题,Agent 将通过后端会话流式响应"
/>
</PromptInputBody>
<PromptInputFooter>
<span className="min-w-0 flex-1 truncate text-xs text-slate-500" title={statusLabel}>
{statusLabel}
</span>
<div className="flex shrink-0 items-center gap-1.5">
<AgentModelSelect
models={modelOptions}
value={selectedModel}
onValueChange={onSelectModel}
/>
<PromptInputSubmit
aria-label={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
title={streaming ? "停止 Agent 指令" : "发送 Agent 指令"}
className="agent-panel-primary-icon"
disabled={!streaming && !trimmedPrompt}
status={streaming ? "streaming" : "ready"}
onStop={onStopPrompt}
>
{streaming ? undefined : <SendHorizontal size={15} aria-hidden="true" />}
</PromptInputSubmit>
</div>
</PromptInputFooter>
</PromptInput>
</div>
</Agent>
</aside>
</MotionConfig>
);
}
type AgentConversationScrollSnapshot = {
activeSessionId?: string | null;
lastUserMessageId: string | null;
scrollRequestId: number;
signature: string;
};
function AgentConversationScrollManager({
activeSessionId,
messages,
scrollRequestId,
streaming,
uiResults
}: {
activeSessionId?: string | null;
messages: AgentChatMessage[];
scrollRequestId: number;
streaming: boolean;
uiResults: AgentUiResult[];
}) {
const { escapedFromLock, isAtBottom, scrollToBottom, state } = useStickToBottomContext();
const previousSnapshotRef = useRef<AgentConversationScrollSnapshot | null>(null);
const wasPinnedToBottom = !escapedFromLock && (isAtBottom || state.isAtBottom || state.isNearBottom);
const snapshot = useMemo<AgentConversationScrollSnapshot>(
() => ({
activeSessionId,
lastUserMessageId: getLastUserMessageId(messages),
scrollRequestId,
signature: createAgentConversationScrollSignature(messages, streaming, uiResults)
}),
[activeSessionId, messages, scrollRequestId, streaming, uiResults]
);
useEffect(() => {
const previousSnapshot = previousSnapshotRef.current;
previousSnapshotRef.current = snapshot;
const forceScroll =
!previousSnapshot ||
previousSnapshot.activeSessionId !== snapshot.activeSessionId ||
previousSnapshot.scrollRequestId !== snapshot.scrollRequestId ||
(Boolean(snapshot.lastUserMessageId) && previousSnapshot.lastUserMessageId !== snapshot.lastUserMessageId);
const contentChanged = !previousSnapshot || previousSnapshot.signature !== snapshot.signature;
if (!forceScroll && (!contentChanged || !wasPinnedToBottom)) {
return;
}
const animationFrameId = window.requestAnimationFrame(() => {
void scrollToBottom({
animation: "instant",
ignoreEscapes: forceScroll,
wait: !forceScroll
});
});
return () => window.cancelAnimationFrame(animationFrameId);
}, [scrollToBottom, snapshot, wasPinnedToBottom]);
return null;
}
function getLastUserMessageId(messages: AgentChatMessage[]) {
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index].role === "user") {
return messages[index].id;
}
}
return null;
}
function createAgentConversationScrollSignature(
messages: AgentChatMessage[],
streaming: boolean,
uiResults: AgentUiResult[]
) {
return [
streaming ? "streaming" : "idle",
messages.map(createAgentMessageScrollSignature).join("|"),
uiResults.map((result) => result.id).join("|")
].join("#");
}
function createAgentMessageScrollSignature(message: AgentChatMessage) {
return [
message.id,
message.role,
message.content.length,
message.progress?.map((item) => `${item.id}:${item.status}:${item.title.length}:${item.detail?.length ?? 0}`).join(",") ?? "",
message.todos?.todos.map((todo) => `${todo.id}:${todo.status}:${todo.content.length}`).join(",") ?? "",
message.permissions?.map((permission) => `${permission.requestId}:${permission.status}:${permission.error?.length ?? 0}`).join(",") ?? "",
message.questions?.map((question) => `${question.requestId}:${question.status}:${question.error?.length ?? 0}`).join(",") ?? ""
].join(":");
}
function AgentModelSelect({
models,
value,
onValueChange
}: {
models: AgentModelOption[];
value: string;
onValueChange?: (model: string) => void;
}) {
const selectedModel = models.find((model) => model.id === value) ?? models[0];
if (!models.length) {
return (
<span className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-slate-500">
</span>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className="h-8 w-[126px] shrink-0 justify-between rounded-md border-transparent bg-transparent px-2 text-xs text-slate-600 shadow-none hover:bg-white/80"
disabled={!onValueChange}
aria-label="选择 Agent 模型"
>
<span className="flex min-w-0 items-center gap-1.5">
<ModelIcon model={selectedModel} size={13} />
<span className="truncate">{selectedModel?.label ?? "模型"}</span>
</span>
<ChevronsUpDown size={13} className="shrink-0 opacity-50" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="end"
sideOffset={8}
className="agent-panel-control w-72 rounded-xl p-2 shadow-lg"
>
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-slate-500">
Agent
</DropdownMenuLabel>
<div className="space-y-1">
{models.map((model) => {
const selected = model.id === selectedModel?.id;
return (
<DropdownMenuItem
key={model.id}
className={cn(
"rounded-lg border border-transparent px-2.5 py-2 focus:bg-slate-50",
selected && "border-violet-200 bg-violet-50/80 focus:bg-violet-50"
)}
onSelect={() => onValueChange?.(model.id)}
>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-white text-slate-600 shadow-sm">
<ModelIcon model={model} size={15} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-slate-800">{model.label}</span>
<span className="block truncate text-xs leading-4 text-slate-500">{model.description}</span>
</span>
{selected ? <CheckCircle2 size={15} className="text-violet-600" aria-hidden="true" /> : null}
</DropdownMenuItem>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
function getAgentConnectionLabel(statusLabel: string) {
return /失败|不可用|错误|降级/.test(statusLabel) ? "离线" : "在线";
}
function getAgentConnectionClassName(statusLabel: string) {
return /失败|不可用|错误|降级/.test(statusLabel) ? "bg-red-500" : "bg-emerald-500";
}
function ModelIcon({ model, size }: { model?: AgentModelOption; size: number }) {
return model?.icon === "bolt" ? (
<Bolt size={size} className="text-amber-500" aria-hidden="true" />
) : (
<Sparkles size={size} className="text-violet-500" aria-hidden="true" />
);
}
function AgentEmptyState({ statusLabel }: { statusLabel: string }) {
return (
<Message from="assistant" className="max-w-full">
<MessageContent className="agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-600 shadow-sm">
<div className="flex items-center gap-2">
<Gauge size={15} className="text-blue-600" aria-hidden="true" />
<p className="font-semibold text-slate-900"></p>
</div>
<p className="mt-2"></p>
<p className="mt-1 text-xs text-slate-500">{statusLabel}</p>
</MessageContent>
</Message>
);
}
function BackendMessageList({
messages,
streaming,
streamRenderState,
onReplyPermission,
onReplyQuestion,
onRejectQuestion
}: {
messages: AgentChatMessage[];
streaming: boolean;
streamRenderState: AgentStreamRenderState;
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
}) {
return (
<motion.div className="flex w-full flex-col gap-4" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{messages.map((message, index) => (
<motion.div
key={message.id}
layout
className={cn("w-full", message.role === "user" && "flex justify-end")}
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
<Message
from={message.role}
className={message.role === "user" ? "max-w-[92%]" : "max-w-full"}
>
<MessageContent
className={cn(
message.role === "user" && "bg-blue-600 px-3 py-2 text-white",
message.role === "assistant" && "agent-panel-message w-full rounded-2xl p-3 text-sm leading-6 text-slate-700 shadow-sm"
)}
>
<div className="space-y-3">
{message.content ? (
message.role === "assistant" ? (
<StreamingTokenResponse
className="leading-6"
content={message.content}
messageId={message.id}
streamDone={streamRenderState[message.id]?.done ?? !streaming}
streaming={streaming && index === messages.length - 1}
/>
) : (
<p className="whitespace-pre-wrap">{message.content}</p>
)
) : streaming && index === messages.length - 1 ? (
<span className="text-slate-500">...</span>
) : null}
{message.role === "assistant" ? (
<AgentMessageDetails
message={message}
onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
) : null}
</div>
</MessageContent>
</Message>
</motion.div>
))}
</AnimatePresence>
</motion.div>
);
}
@@ -0,0 +1,296 @@
"use client";
import { Check, Loader2, Pencil, RefreshCw, Trash2, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { Input } from "@/shared/ui/input";
import { cn } from "@/lib/utils";
import type { AgentChatSessionSummary } from "../api/client";
import {
agentLayoutTransition,
agentPanelItemVariants,
agentPanelListVariants
} from "./agent-motion";
type AgentHistoryPanelProps = {
activeSessionId?: string | null;
loading: boolean;
loadingSessionId: string | null;
sessions: AgentChatSessionSummary[];
onRefresh?: () => Promise<void> | void;
onSelectSession: (sessionId: string) => void;
onRenameSession?: (sessionId: string, title: string) => Promise<void> | void;
onDeleteSession?: (sessionId: string) => Promise<void> | void;
};
export function AgentHistoryPanel({
activeSessionId,
loading,
loadingSessionId,
sessions,
onRefresh,
onSelectSession,
onRenameSession,
onDeleteSession
}: AgentHistoryPanelProps) {
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [draftTitle, setDraftTitle] = useState("");
const [savingSessionId, setSavingSessionId] = useState<string | null>(null);
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(null);
const [confirmingDeleteSessionId, setConfirmingDeleteSessionId] = useState<string | null>(null);
const beginRename = (session: AgentChatSessionSummary) => {
setEditingSessionId(session.id);
setDraftTitle(session.title);
};
const cancelRename = () => {
setEditingSessionId(null);
setDraftTitle("");
};
const saveRename = (session: AgentChatSessionSummary) => {
const normalizedTitle = draftTitle.trim();
if (!normalizedTitle || normalizedTitle === session.title) {
cancelRename();
return;
}
setSavingSessionId(session.id);
void Promise.resolve(onRenameSession?.(session.id, normalizedTitle))
.then(cancelRename)
.finally(() => setSavingSessionId(null));
};
const confirmDelete = (sessionId: string) => {
setDeletingSessionId(sessionId);
void Promise.resolve(onDeleteSession?.(sessionId))
.then(() => setConfirmingDeleteSessionId(null))
.finally(() => setDeletingSessionId(null));
};
return (
<div className="px-3 pb-3">
<div className="agent-panel-control rounded-2xl p-2.5 shadow-sm">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-semibold text-slate-950"></p>
<p className="mt-0.5 text-xs text-slate-500">{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}</p>
</div>
<button
type="button"
aria-label="刷新 Agent 历史记录"
title="刷新 Agent 历史记录"
className="grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition hover:bg-white hover:text-slate-950 disabled:cursor-not-allowed disabled:opacity-50"
disabled={loading}
onClick={() => void Promise.resolve(onRefresh?.())}
>
{loading ? (
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
) : (
<RefreshCw size={15} aria-hidden="true" />
)}
</button>
</div>
<motion.div className="max-h-56 space-y-1 overflow-y-auto pr-1" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{loading && sessions.length === 0 ? (
<motion.div
key="history-loading"
className="flex items-center gap-2 rounded-xl bg-slate-50 px-3 py-3 text-xs font-semibold text-slate-500"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
</motion.div>
) : sessions.length > 0 ? (
sessions.map((session) => {
const active = session.id === activeSessionId;
const itemLoading = loadingSessionId === session.id;
const editing = editingSessionId === session.id;
const saving = savingSessionId === session.id;
const deleting = deletingSessionId === session.id;
const confirmingDelete = confirmingDeleteSessionId === session.id;
return (
<motion.div
key={session.id}
layout
className={cn(
"grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition hover:bg-white",
active ? "border border-blue-200 bg-blue-50/80" : "border border-transparent bg-slate-50/80"
)}
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
<AnimatePresence initial={false} mode="popLayout">
{confirmingDelete ? (
<motion.div key="confirm-delete" className="min-w-0" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<span className="block truncate text-xs font-semibold text-red-700" title={session.title}>
{session.title}
</span>
<span className="mt-0.5 block truncate text-xs text-slate-500">
</span>
</motion.div>
) : editing ? (
<motion.form
key="rename"
className="min-w-0"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
onSubmit={(event) => {
event.preventDefault();
saveRename(session);
}}
>
<Input
autoFocus
className="h-8 rounded-lg bg-white text-xs"
value={draftTitle}
disabled={saving}
onChange={(event) => setDraftTitle(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
cancelRename();
}
}}
/>
</motion.form>
) : (
<motion.button
key="session-summary"
type="button"
className="min-w-0 text-left"
disabled={itemLoading || deleting}
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
onClick={() => onSelectSession(session.id)}
>
<span className="block truncate text-xs font-semibold text-slate-900" title={session.title}>
{session.title}
</span>
<span className="mt-0.5 block truncate text-xs text-slate-500">
{formatSessionUpdatedAt(session.updatedAt)}
</span>
</motion.button>
)}
</AnimatePresence>
<span className="flex items-center gap-1">
{session.runStatus === "running" || session.isStreaming ? (
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
) : null}
{itemLoading || saving || deleting ? (
<Loader2 size={13} className="animate-spin text-blue-600" aria-hidden="true" />
) : null}
{active && !editing && !confirmingDelete ? (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-semibold text-blue-700">
</span>
) : null}
{confirmingDelete ? (
<>
<button
type="button"
className="h-7 rounded-lg bg-red-600 px-2 text-xs font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
disabled={deleting}
onClick={() => confirmDelete(session.id)}
>
</button>
<button
type="button"
aria-label="取消删除"
title="取消删除"
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white disabled:opacity-50"
disabled={deleting}
onClick={() => setConfirmingDeleteSessionId(null)}
>
<X size={14} aria-hidden="true" />
</button>
</>
) : editing ? (
<>
<button
type="button"
aria-label="保存对话主题"
title="保存对话主题"
className="grid h-7 w-7 place-items-center rounded-lg text-blue-600 transition hover:bg-white disabled:opacity-50"
disabled={saving}
onClick={() => saveRename(session)}
>
<Check size={14} aria-hidden="true" />
</button>
<button
type="button"
aria-label="取消重命名"
title="取消重命名"
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white disabled:opacity-50"
disabled={saving}
onClick={cancelRename}
>
<X size={14} aria-hidden="true" />
</button>
</>
) : (
<>
<button
type="button"
aria-label="重命名对话"
title="重命名对话"
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-white hover:text-slate-950 disabled:opacity-50"
disabled={itemLoading || deleting || !onRenameSession}
onClick={() => beginRename(session)}
>
<Pencil size={14} aria-hidden="true" />
</button>
<button
type="button"
aria-label="删除对话"
title="删除对话"
className="grid h-7 w-7 place-items-center rounded-lg text-slate-500 transition hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
disabled={itemLoading || deleting || !onDeleteSession}
onClick={() => {
cancelRename();
setConfirmingDeleteSessionId(session.id);
}}
>
<Trash2 size={14} aria-hidden="true" />
</button>
</>
)}
</span>
</motion.div>
);
})
) : (
<motion.div key="history-empty" className="rounded-xl bg-slate-50 px-3 py-3 text-xs text-slate-500" initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
</div>
);
}
function formatSessionUpdatedAt(value: number) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
}).format(value);
}
@@ -0,0 +1,612 @@
"use client";
import {
CheckCircle2,
HelpCircle,
ListChecks,
LockKeyhole,
Loader2,
ShieldCheck,
XCircle,
type LucideIcon
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/shared/ui/button";
import {
agentLayoutTransition,
agentPanelItemVariants,
agentPanelListVariants,
agentPanelSectionVariants
} from "./agent-motion";
import type {
AgentChatMessage,
AgentChatProgress,
AgentPermissionReply,
AgentPermissionRequest,
AgentQuestionInfo,
AgentQuestionRequest,
AgentTodoUpdate
} from "../types";
type ProgressStatus = AgentChatProgress["status"];
type TodoStatus = AgentTodoUpdate["todos"][number]["status"];
type PermissionStatus = AgentPermissionRequest["status"];
type QuestionStatus = AgentQuestionRequest["status"];
const progressStatusMeta: Record<ProgressStatus, { label: string; className: string; dotClassName: string }> = {
running: {
label: "进行中",
className: "bg-blue-100 text-blue-700",
dotClassName: "border-blue-500 bg-blue-100"
},
completed: {
label: "完成",
className: "bg-emerald-100 text-emerald-700",
dotClassName: "border-emerald-500 bg-emerald-100"
},
error: {
label: "失败",
className: "bg-red-100 text-red-700",
dotClassName: "border-red-500 bg-red-100"
}
};
const todoStatusMeta: Record<TodoStatus, { label: string; className: string; dotClassName: string }> = {
completed: {
label: "完成",
className: "bg-emerald-100 text-emerald-700",
dotClassName: "border-emerald-500 bg-emerald-500"
},
in_progress: {
label: "进行中",
className: "bg-blue-100 text-blue-700",
dotClassName: "border-blue-500 bg-blue-100"
},
pending: {
label: "待办",
className: "bg-slate-100 text-slate-600",
dotClassName: "border-slate-300 bg-white"
},
cancelled: {
label: "取消",
className: "bg-slate-200 text-slate-500",
dotClassName: "border-slate-300 bg-slate-200"
}
};
const permissionStatusMeta: Record<PermissionStatus, { label: string; className: string }> = {
approved_always: { label: "已始终允许", className: "bg-emerald-100 text-emerald-700" },
approved_once: { label: "已允许一次", className: "bg-emerald-100 text-emerald-700" },
rejected: { label: "已拒绝", className: "bg-red-100 text-red-700" },
aborted: { label: "已中止", className: "bg-slate-200 text-slate-500" },
error: { label: "失败", className: "bg-red-100 text-red-700" },
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
pending: { label: "待确认", className: "bg-orange-100 text-orange-700" }
};
const questionStatusMeta: Record<QuestionStatus, { label: string; className: string }> = {
answered: { label: "已回答", className: "bg-emerald-100 text-emerald-700" },
rejected: { label: "已跳过", className: "bg-slate-200 text-slate-500" },
error: { label: "失败", className: "bg-red-100 text-red-700" },
submitting: { label: "提交中", className: "bg-blue-100 text-blue-700" },
pending: { label: "待回答", className: "bg-blue-100 text-blue-700" }
};
export function AgentMessageDetails({
message,
onReplyPermission,
onReplyQuestion,
onRejectQuestion
}: {
message: AgentChatMessage;
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
}) {
const hasDetails = Boolean(
message.progress?.length ||
message.todos?.todos.length ||
message.permissions?.length ||
message.questions?.length
);
if (!hasDetails) {
return null;
}
return (
<motion.div className="space-y-2" layout variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{message.progress?.length ? (
<motion.div key="progress" layout initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<ProgressList progress={message.progress} />
</motion.div>
) : null}
{message.todos ? (
<motion.div key="todos" layout initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<TodoCard todoUpdate={message.todos} />
</motion.div>
) : null}
{message.permissions?.length ? (
<motion.div key="permissions" layout initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<PermissionList permissions={message.permissions} onReplyPermission={onReplyPermission} />
</motion.div>
) : null}
{message.questions?.length ? (
<motion.div key="questions" layout initial="initial" animate="animate" exit="exit" variants={agentPanelItemVariants}>
<QuestionList
questions={message.questions}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
</motion.div>
) : null}
</AnimatePresence>
</motion.div>
);
}
function AgentNestedBlock({
icon: Icon,
iconClassName,
title,
children
}: {
icon: LucideIcon;
iconClassName: string;
title: string;
children: ReactNode;
}) {
return (
<div className="agent-panel-nested rounded-xl p-2.5">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-600">
<Icon size={14} className={iconClassName} aria-hidden="true" />
{title}
</div>
{children}
</div>
);
}
function AnimatedDetailItem({ children }: { children: ReactNode }) {
return (
<motion.div
layout
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
{children}
</motion.div>
);
}
function ProgressList({ progress }: { progress: AgentChatProgress[] }) {
return (
<AgentNestedBlock icon={Loader2} iconClassName="text-blue-600" title="执行进度">
<motion.ol className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{progress.slice(-6).map((item) => (
<motion.li
key={item.id}
layout
className="grid grid-cols-[16px_1fr_auto] items-start gap-2 text-xs"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
<span
className={cn(
"mt-0.5 h-3.5 w-3.5 rounded-full border",
progressStatusMeta[item.status].dotClassName
)}
/>
<span className="min-w-0">
<span className="block truncate font-semibold text-slate-800" title={item.title}>
{item.title}
</span>
{item.detail ? (
<span className="block line-clamp-2 whitespace-pre-wrap leading-5 text-slate-500">{item.detail}</span>
) : null}
</span>
<span className={cn("rounded-full px-2 py-0.5 font-semibold", progressStatusMeta[item.status].className)}>
{progressStatusMeta[item.status].label}
</span>
</motion.li>
))}
</AnimatePresence>
</motion.ol>
</AgentNestedBlock>
);
}
function TodoCard({ todoUpdate }: { todoUpdate: AgentTodoUpdate }) {
return (
<AgentNestedBlock icon={ListChecks} iconClassName="text-emerald-600" title="计划">
<motion.ol className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{todoUpdate.todos.slice(0, 6).map((todo) => (
<motion.li
key={todo.id}
layout
className="grid grid-cols-[16px_1fr_auto] items-start gap-2 text-xs"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
<span
className={cn(
"mt-0.5 grid h-3.5 w-3.5 place-items-center rounded-full border",
todoStatusMeta[todo.status].dotClassName
)}
>
{todo.status === "completed" ? <CheckCircle2 size={10} className="text-white" aria-hidden="true" /> : null}
</span>
<span className="min-w-0 truncate text-slate-700" title={todo.content}>
{todo.content}
</span>
<span className={cn("rounded-full px-2 py-0.5 font-semibold", todoStatusMeta[todo.status].className)}>
{todoStatusMeta[todo.status].label}
</span>
</motion.li>
))}
</AnimatePresence>
</motion.ol>
</AgentNestedBlock>
);
}
function PermissionList({
permissions,
onReplyPermission
}: {
permissions: AgentPermissionRequest[];
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
}) {
return (
<AgentNestedBlock icon={LockKeyhole} iconClassName="text-orange-600" title="权限请求">
<motion.div className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{permissions.slice(-4).map((permission) => (
<AnimatedDetailItem key={permission.requestId}>
<PermissionRequestCard
permission={permission}
onReplyPermission={onReplyPermission}
/>
</AnimatedDetailItem>
))}
</AnimatePresence>
</motion.div>
</AgentNestedBlock>
);
}
function PermissionRequestCard({
permission,
onReplyPermission
}: {
permission: AgentPermissionRequest;
onReplyPermission?: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => Promise<void> | void;
}) {
const actionable = permission.status === "pending" || permission.status === "error";
const submitting = permission.status === "submitting";
const canApproveAlways = permission.always.length > 0;
const disabled = !onReplyPermission || submitting;
const target = permission.target ?? (permission.patterns.join(" ") || permission.permission);
const reply = (nextReply: AgentPermissionReply) => {
if (!onReplyPermission || submitting) {
return;
}
void Promise.resolve(onReplyPermission(permission, nextReply));
};
return (
<div className="rounded-lg bg-white/90 px-2.5 py-2 text-xs">
<div className="grid grid-cols-[1fr_auto] gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-slate-800" title={permission.target ?? permission.permission}>
{getPermissionTitle(permission)}
</p>
<p className="mt-0.5 truncate font-mono text-xs text-slate-500" title={target}>
{target}
</p>
{permission.error ? (
<p className="mt-1 line-clamp-2 text-xs leading-4 text-red-600">{permission.error}</p>
) : null}
</div>
<span className={cn("self-start rounded-full px-2 py-0.5 font-semibold", permissionStatusMeta[permission.status].className)}>
{permissionStatusMeta[permission.status].label}
</span>
</div>
<AnimatePresence initial={false}>
{actionable ? (
<motion.div
key="permission-actions"
className="mt-2 flex flex-wrap items-center gap-1.5"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
<Button
type="button"
size="sm"
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
disabled={disabled}
onClick={() => reply("once")}
>
<CheckCircle2 size={13} aria-hidden="true" />
</Button>
{canApproveAlways ? (
<Button
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md border-blue-200 bg-blue-50 px-2 text-xs text-blue-700 hover:bg-blue-100"
disabled={disabled}
onClick={() => reply("always")}
>
<ShieldCheck size={13} aria-hidden="true" />
</Button>
) : null}
<Button
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md border-red-200 bg-red-50 px-2 text-xs text-red-700 hover:bg-red-100"
disabled={disabled}
onClick={() => reply("reject")}
>
<XCircle size={13} aria-hidden="true" />
</Button>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
function QuestionList({
questions,
onReplyQuestion,
onRejectQuestion
}: {
questions: AgentQuestionRequest[];
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
}) {
return (
<AgentNestedBlock icon={HelpCircle} iconClassName="text-blue-600" title="补充信息">
<motion.div className="space-y-1.5" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{questions.slice(-3).map((request) => (
<AnimatedDetailItem key={request.requestId}>
<QuestionRequestCard
request={request}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
</AnimatedDetailItem>
))}
</AnimatePresence>
</motion.div>
</AgentNestedBlock>
);
}
function QuestionRequestCard({
request,
onReplyQuestion,
onRejectQuestion
}: {
request: AgentQuestionRequest;
onReplyQuestion?: (request: AgentQuestionRequest, answers: string[][]) => Promise<void> | void;
onRejectQuestion?: (request: AgentQuestionRequest) => Promise<void> | void;
}) {
const [draftAnswers, setDraftAnswers] = useState(() => createInitialQuestionAnswers(request));
const actionable = request.status === "pending" || request.status === "error";
const submitting = request.status === "submitting";
const disabled = submitting || !onReplyQuestion;
const answers = normalizeDraftAnswers(draftAnswers);
const canSubmit = actionable && answers.length === request.questions.length && answers.every((answer) => answer.length > 0);
const submit = () => {
if (!onReplyQuestion || !canSubmit) {
return;
}
void Promise.resolve(onReplyQuestion(request, answers));
};
const reject = () => {
if (!onRejectQuestion || submitting) {
return;
}
void Promise.resolve(onRejectQuestion(request));
};
return (
<div className="rounded-lg bg-white/90 px-2.5 py-2 text-xs">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="min-w-0 truncate font-semibold text-slate-800">
{request.questions[0]?.header || "问题"}
</span>
<span className={cn("rounded-full px-2 py-0.5 font-semibold", questionStatusMeta[request.status].className)}>
{questionStatusMeta[request.status].label}
</span>
</div>
<div className="space-y-3">
{request.questions.map((question, questionIndex) => (
<QuestionInput
key={`${request.requestId}-${questionIndex}`}
question={question}
questionIndex={questionIndex}
value={draftAnswers[questionIndex] ?? { selected: [], custom: "" }}
disabled={!actionable || submitting}
onChange={(nextValue) =>
setDraftAnswers((current) => current.map((item, index) => (index === questionIndex ? nextValue : item)))
}
/>
))}
</div>
{request.error ? (
<p className="mt-2 line-clamp-2 text-xs leading-4 text-red-600">{request.error}</p>
) : null}
{request.answers?.length ? (
<p className="mt-2 truncate text-slate-500" title={request.answers.map((answer) => answer.join("、")).join("")}>
{request.answers.map((answer) => answer.join("、")).join("")}
</p>
) : null}
<AnimatePresence initial={false}>
{actionable ? (
<motion.div
key="question-actions"
className="mt-2 flex flex-wrap items-center gap-1.5"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelSectionVariants}
style={{ overflow: "hidden" }}
>
<Button
type="button"
size="sm"
className="agent-panel-primary-action h-7 rounded-md px-2 text-xs"
disabled={disabled || !canSubmit}
onClick={submit}
>
<CheckCircle2 size={13} aria-hidden="true" />
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md border-slate-200 bg-white px-2 text-xs text-slate-600 hover:bg-slate-50"
disabled={submitting || !onRejectQuestion}
onClick={reject}
>
<XCircle size={13} aria-hidden="true" />
</Button>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
type QuestionDraftAnswer = {
selected: string[];
custom: string;
};
function QuestionInput({
question,
questionIndex,
value,
disabled,
onChange
}: {
question: AgentQuestionInfo;
questionIndex: number;
value: QuestionDraftAnswer;
disabled: boolean;
onChange: (value: QuestionDraftAnswer) => void;
}) {
const name = `agent-question-${questionIndex}-${question.question}`;
const showCustomInput = question.custom || question.options.length === 0;
const toggleOption = (label: string, checked: boolean) => {
if (question.multiple) {
onChange({
...value,
selected: checked ? [...value.selected, label] : value.selected.filter((item) => item !== label)
});
return;
}
onChange({
...value,
selected: checked ? [label] : []
});
};
return (
<div className="space-y-1.5">
<p className="leading-5 text-slate-700">{question.question}</p>
{question.options.length ? (
<div className="space-y-1">
{question.options.map((option) => {
const checked = value.selected.includes(option.label);
return (
<label
key={option.label}
className="flex cursor-pointer items-start gap-2 rounded-md border border-slate-200 bg-white/90 px-2 py-1.5 text-slate-600"
>
<input
type={question.multiple ? "checkbox" : "radio"}
name={name}
className="mt-0.5 h-3.5 w-3.5 accent-blue-600"
checked={checked}
disabled={disabled}
onChange={(event) => toggleOption(option.label, event.currentTarget.checked)}
/>
<span className="min-w-0">
<span className="block font-semibold text-slate-700">{option.label}</span>
{option.description ? (
<span className="block leading-4 text-slate-500">{option.description}</span>
) : null}
</span>
</label>
);
})}
</div>
) : null}
{showCustomInput ? (
<textarea
className="min-h-16 w-full resize-none rounded-md border border-slate-200 bg-white/90 px-2 py-1.5 text-xs leading-5 text-slate-700 outline-none transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100 disabled:opacity-60"
placeholder="输入回答"
value={value.custom}
disabled={disabled}
onChange={(event) => onChange({ ...value, custom: event.currentTarget.value })}
/>
) : null}
</div>
);
}
function createInitialQuestionAnswers(request: AgentQuestionRequest): QuestionDraftAnswer[] {
return request.questions.map((question, index) => ({
selected: request.answers?.[index] ?? [],
custom: ""
}));
}
function normalizeDraftAnswers(draftAnswers: QuestionDraftAnswer[]) {
return draftAnswers.map((answer) => {
const custom = answer.custom.trim();
return custom ? [...answer.selected, custom] : answer.selected;
});
}
function getPermissionTitle(permission: AgentPermissionRequest) {
if (permission.permission === "bash") return "执行终端命令";
if (permission.permission === "edit") return "修改文件内容";
if (permission.permission === "external_directory") return "访问工作区外目录";
return permission.permission || "工具权限请求";
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import type { Variants } from "motion/react";
export const AGENT_MOTION_DURATION_MS = 180;
export const agentEase = [0.22, 1, 0.36, 1] as const;
export const agentPanelItemVariants: Variants = {
initial: {
opacity: 0,
y: 6,
scale: 0.985
},
animate: {
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.18,
ease: agentEase
}
},
exit: {
opacity: 0,
y: -4,
scale: 0.99,
transition: {
duration: 0.14,
ease: [0.5, 0, 0.2, 1]
}
}
};
export const agentPanelSectionVariants: Variants = {
initial: {
opacity: 0,
height: 0,
y: -4
},
animate: {
opacity: 1,
height: "auto",
y: 0,
transition: {
duration: 0.18,
ease: agentEase
}
},
exit: {
opacity: 0,
height: 0,
y: -4,
transition: {
duration: 0.14,
ease: [0.5, 0, 0.2, 1]
}
}
};
export const agentPanelListVariants: Variants = {
animate: {
transition: {
staggerChildren: 0.025
}
}
};
export const agentLayoutTransition = {
duration: 0.18,
ease: agentEase
};
@@ -0,0 +1,122 @@
"use client";
import {
ClipboardCheck,
Crosshair,
MapPinned,
Radio,
Route
} from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import type {
AgentChatMessage
} from "../types";
export function AgentOperationalBrief({
messages,
statusLabel,
streaming,
onSubmitCommand
}: {
messages: AgentChatMessage[];
statusLabel: string;
streaming: boolean;
onSubmitCommand: (prompt: string) => void;
}) {
const brief = createOperationalBrief(messages, statusLabel, streaming);
return (
<div className="agent-panel-control rounded-2xl p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full", brief.statusDotClassName)} />
<p className="truncate text-sm font-semibold text-slate-950">{brief.stateLabel}</p>
</div>
<p className="mt-1 truncate text-xs leading-5 text-slate-500" title={brief.task}>
{brief.task}
</p>
</div>
<span className="shrink-0 rounded-full bg-violet-100 px-2 py-1 text-xs font-semibold text-violet-700">
Agent
</span>
</div>
<dl className="mt-3 grid grid-cols-3 gap-2">
<AgentBriefMetric icon={<Radio size={13} aria-hidden="true" />} label="证据" value={brief.evidence} />
<AgentBriefMetric icon={<Route size={13} aria-hidden="true" />} label="计划" value={brief.plan} />
<AgentBriefMetric icon={<ClipboardCheck size={13} aria-hidden="true" />} label="确认" value={brief.confirmation} />
</dl>
<div className="mt-3 grid grid-cols-2 gap-2">
<button
type="button"
className="agent-panel-primary-action flex h-9 items-center justify-center gap-1.5 rounded-xl px-3 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-50"
disabled={streaming}
onClick={() => onSubmitCommand(brief.primaryCommand)}
>
<MapPinned size={14} aria-hidden="true" />
</button>
<button
type="button"
className="flex h-9 items-center justify-center gap-1.5 rounded-xl border border-slate-200 bg-white/90 px-3 text-xs font-semibold text-slate-700 transition hover:border-blue-200 hover:bg-blue-50 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
disabled={streaming}
onClick={() => onSubmitCommand(brief.secondaryCommand)}
>
<Crosshair size={14} aria-hidden="true" />
</button>
</div>
</div>
);
}
function AgentBriefMetric({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
return (
<div className="min-w-0 rounded-xl border border-slate-200/70 bg-slate-50/90 px-2 py-2">
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-500">
<span className="text-blue-600">{icon}</span>
{label}
</dt>
<dd className="mt-1 truncate text-xs font-semibold text-slate-900" title={value}>
{value}
</dd>
</div>
);
}
function createOperationalBrief(messages: AgentChatMessage[], statusLabel: string, streaming: boolean) {
const lastUserMessage = [...messages].reverse().find((message) => message.role === "user" && message.content.trim());
const progressItems = messages.flatMap((message) => message.progress ?? []);
const todoItems = messages.flatMap((message) => message.todos?.todos ?? []);
const permissionItems = messages.flatMap((message) => message.permissions ?? []);
const questionItems = messages.flatMap((message) => message.questions ?? []);
const latestProgress = progressItems.at(-1);
const activeTodo =
[...todoItems].reverse().find((todo) => todo.status === "in_progress") ??
[...todoItems].reverse().find((todo) => todo.status === "pending");
const pendingConfirmations =
permissionItems.filter((permission) => permission.status === "pending" || permission.status === "error").length +
questionItems.filter((question) => question.status === "pending" || question.status === "error").length;
const completedSteps = progressItems.filter((item) => item.status === "completed").length;
const task = lastUserMessage?.content.trim() || "等待调度指令";
const evidence = latestProgress?.detail || latestProgress?.title || statusLabel;
const plan = activeTodo?.content || (completedSteps > 0 ? `${completedSteps} 项已完成` : "待生成");
const confirmation = pendingConfirmations > 0 ? `${pendingConfirmations} 项待确认` : "无需确认";
const stateLabel = pendingConfirmations > 0 ? "等待人工确认" : streaming ? "Agent 分析中" : "分析完成";
const statusDotClassName = pendingConfirmations > 0 ? "bg-orange-500" : streaming ? "bg-blue-500" : "bg-emerald-500";
const promptContext = lastUserMessage?.content.trim() || "当前排水管网运行态势";
return {
task,
evidence,
plan,
confirmation,
stateLabel,
statusDotClassName,
primaryCommand: `请基于“${promptContext}”预览空间影响范围,并列出需要确认的操作。`,
secondaryCommand: `请检查“${promptContext}”的证据链,按数据来源、风险和不确定性汇总。`
};
}
@@ -0,0 +1,62 @@
"use client";
import { Bot } from "lucide-react";
import { useState } from "react";
import { Persona, type PersonaState, type PersonaVariant } from "@/shared/ai-elements/persona";
import { cn } from "@/lib/utils";
type AgentPersonaProps = {
className?: string;
fallbackClassName?: string;
state?: PersonaState;
statusLabel?: string;
streaming?: boolean;
variant?: PersonaVariant;
};
export function AgentPersona({
className,
fallbackClassName,
state,
statusLabel,
streaming = false,
variant = "obsidian"
}: AgentPersonaProps) {
const [failed, setFailed] = useState(false);
const personaState = state ?? getAgentPersonaState(statusLabel, streaming);
if (failed) {
return (
<span
className={cn(
"grid shrink-0 place-items-center rounded-xl bg-violet-100 text-violet-700",
className,
fallbackClassName
)}
>
<Bot size={19} aria-hidden="true" />
</span>
);
}
return (
<Persona
className={cn("shrink-0", className)}
onLoadError={() => setFailed(true)}
state={personaState}
variant={variant}
/>
);
}
export function getAgentPersonaState(statusLabel = "", streaming = false): PersonaState {
if (/失败|不可用|错误|降级/.test(statusLabel)) {
return "asleep";
}
if (streaming || /请求|分析|生成|连接中|加载/.test(statusLabel)) {
return "thinking";
}
return "idle";
}
@@ -0,0 +1,407 @@
"use client";
import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "lucide-react";
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data";
import type { AgentUiResult } from "../types";
import type { UIEnvelope } from "../ui-envelope";
import {
agentLayoutTransition,
agentPanelItemVariants,
agentPanelListVariants
} from "./agent-motion";
type AgentUiEnvelopeRendererProps = {
results: AgentUiResult[];
};
export function AgentUiEnvelopeRenderer({ results }: AgentUiEnvelopeRendererProps) {
if (results.length === 0) {
return null;
}
return (
<MotionConfig reducedMotion="user">
<motion.div className="space-y-3" variants={agentPanelListVariants} animate="animate">
<AnimatePresence initial={false} mode="popLayout">
{results.map((result) => (
<motion.div
key={result.id}
layout
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
<AgentUiResultCard result={result} />
</motion.div>
))}
</AnimatePresence>
</motion.div>
</MotionConfig>
);
}
function AgentUiResultCard({ result }: { result: AgentUiResult }) {
const { envelope } = result;
return (
<div className="agent-panel-message w-full rounded-2xl p-3 text-slate-700 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-blue-100 text-blue-700">
{getEnvelopeIcon(envelope)}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-950">{getEnvelopeTitle(envelope)}</p>
<p className="text-xs text-slate-500">{getSurfaceLabel(envelope.surface)}</p>
</div>
</div>
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600">
agent-ui@1
</span>
</div>
{envelope.kind === "chart" ? <SafeChartEnvelope envelope={envelope} /> : null}
{envelope.kind === "registered_component" ? <RegisteredComponentEnvelope envelope={envelope} /> : null}
{envelope.kind === "map_action" ? null : null}
</div>
);
}
function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind: "chart" }> }) {
const spec = parseChartSpec(envelope.spec);
const data = normalizeSafeChartData(envelope.data);
if (!data || data.series.length === 0 || data.xData.length === 0) {
return <FallbackText text={envelope.fallbackText ?? "图表数据未通过前端安全子集校验。"} />;
}
return (
<div className="agent-panel-nested rounded-xl p-3">
<div className="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-950" title={spec.title}>
{spec.title ?? "Agent 图表"}
</p>
<p className="mt-1 text-xs text-slate-500">
{spec.chartType === "bar" ? "柱状图" : "折线图"}
{spec.yAxisName ? ` · ${spec.yAxisName}` : ""}
</p>
</div>
<div className="flex shrink-0 flex-wrap justify-end gap-1.5">
{data.series.slice(0, 3).map((series, index) => (
<span key={series.name} className="flex items-center gap-1 text-xs text-slate-600">
<span className={cn("h-2 w-2 rounded-full", chartColorClass(index))} />
{series.name}
</span>
))}
</div>
</div>
<SafeChartSvg spec={spec} data={data} />
{spec.xAxisName ? <p className="mt-2 text-center text-xs text-slate-500">{spec.xAxisName}</p> : null}
</div>
);
}
function SafeChartSvg({ spec, data }: { spec: SafeChartSpec; data: SafeChartData }) {
const width = 360;
const height = 190;
const padding = { top: 16, right: 18, bottom: 32, left: 38 };
const plotWidth = width - padding.left - padding.right;
const plotHeight = height - padding.top - padding.bottom;
const values = data.series.flatMap((series) => series.data);
const min = Math.min(0, ...values);
const max = Math.max(...values);
const range = max - min || 1;
const xFor = (index: number) =>
padding.left + (data.xData.length === 1 ? plotWidth / 2 : (index / (data.xData.length - 1)) * plotWidth);
const yFor = (value: number) => padding.top + plotHeight - ((value - min) / range) * plotHeight;
const ticks = createTicks(min, max);
return (
<svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label={spec.title ?? "Agent 图表"} className="h-48 w-full">
<rect x="0" y="0" width={width} height={height} rx="12" className="fill-white/70" />
{ticks.map((tick) => {
const y = yFor(tick);
return (
<g key={tick}>
<line x1={padding.left} x2={width - padding.right} y1={y} y2={y} className="stroke-slate-200" />
<text x={padding.left - 8} y={y + 4} textAnchor="end" className="fill-slate-400 text-xs">
{formatChartNumber(tick)}
</text>
</g>
);
})}
<line x1={padding.left} x2={padding.left} y1={padding.top} y2={height - padding.bottom} className="stroke-slate-300" />
<line x1={padding.left} x2={width - padding.right} y1={height - padding.bottom} y2={height - padding.bottom} className="stroke-slate-300" />
{data.xData.map((label, index) => (
<text
key={`${label}-${index}`}
x={xFor(index)}
y={height - 12}
textAnchor="middle"
className="fill-slate-500 text-xs"
>
{shortLabel(label)}
</text>
))}
{data.series.slice(0, 4).map((series, seriesIndex) =>
(series.type ?? spec.chartType) === "bar" ? (
<BarSeries
key={series.name}
series={series}
seriesIndex={seriesIndex}
seriesCount={Math.min(data.series.length, 4)}
xFor={xFor}
yFor={yFor}
baselineY={yFor(0)}
/>
) : (
<LineSeries key={series.name} series={series} seriesIndex={seriesIndex} xFor={xFor} yFor={yFor} />
)
)}
</svg>
);
}
function LineSeries({
series,
seriesIndex,
xFor,
yFor
}: {
series: SafeChartSeries;
seriesIndex: number;
xFor: (index: number) => number;
yFor: (value: number) => number;
}) {
const points = series.data.map((value, index) => `${xFor(index)},${yFor(value)}`).join(" ");
const stroke = chartStroke(seriesIndex);
return (
<g>
<polyline
points={points}
fill="none"
stroke={stroke}
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="agent-chart-line"
/>
{series.data.map((value, index) => (
<circle
key={`${series.name}-${index}`}
cx={xFor(index)}
cy={yFor(value)}
r="2.8"
fill={stroke}
className="agent-chart-point"
style={{ animationDelay: `${80 + index * 18}ms` }}
/>
))}
</g>
);
}
function BarSeries({
series,
seriesIndex,
seriesCount,
xFor,
yFor,
baselineY
}: {
series: SafeChartSeries;
seriesIndex: number;
seriesCount: number;
xFor: (index: number) => number;
yFor: (value: number) => number;
baselineY: number;
}) {
const fill = chartStroke(seriesIndex);
const barWidth = Math.max(5, 22 / seriesCount);
const offset = (seriesIndex - (seriesCount - 1) / 2) * (barWidth + 2);
return (
<g>
{series.data.map((value, index) => {
const y = yFor(value);
return (
<rect
key={`${series.name}-${index}`}
x={xFor(index) - barWidth / 2 + offset}
y={Math.min(y, baselineY)}
width={barWidth}
height={Math.max(1, Math.abs(baselineY - y))}
rx="2"
fill={fill}
className="agent-chart-bar"
style={{ animationDelay: `${seriesIndex * 28 + index * 16}ms` }}
/>
);
})}
</g>
);
}
function RegisteredComponentEnvelope({
envelope
}: {
envelope: Extract<UIEnvelope, { kind: "registered_component" }>;
}) {
if (envelope.component === "HistoryPanel") {
return (
<TrustedPanelShell icon={<History size={15} aria-hidden="true" />} title="历史数据面板">
<PropRows props={envelope.props} labels={{ feature_id: "资产 ID", featureId: "资产 ID", metric: "指标", range: "时间范围" }} />
</TrustedPanelShell>
);
}
if (envelope.component === "ScadaPanel") {
return (
<TrustedPanelShell icon={<MonitorCog size={15} aria-hidden="true" />} title="SCADA 面板">
<PropRows props={envelope.props} labels={{ device_id: "设备 ID", deviceId: "设备 ID", metric: "指标", range: "时间范围" }} />
</TrustedPanelShell>
);
}
return <FallbackText text={envelope.fallbackText ?? "当前注册组件未接入渲染器。"} />;
}
function TrustedPanelShell({
icon,
title,
children
}: {
icon: ReactNode;
title: string;
children: ReactNode;
}) {
return (
<div className="agent-panel-nested rounded-xl p-3">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-950">
<span className="grid h-7 w-7 place-items-center rounded-lg bg-emerald-100 text-emerald-700">{icon}</span>
{title}
</div>
{children}
</div>
);
}
function PropRows({ props, labels }: { props: unknown; labels: Record<string, string> }) {
const rows = safePropRows(props, labels);
if (rows.length === 0) {
return <p className="text-sm text-slate-600"></p>;
}
return (
<dl className="grid grid-cols-2 gap-2">
{rows.map((row) => (
<div key={row.label} className="min-w-0 rounded-lg bg-white/90 px-2.5 py-2">
<dt className="truncate text-xs text-slate-500">{row.label}</dt>
<dd className="mt-1 truncate text-sm font-semibold text-slate-900" title={row.value}>
{row.value}
</dd>
</div>
))}
</dl>
);
}
function FallbackText({ text }: { text: string }) {
return (
<div className="agent-panel-nested rounded-xl p-3 text-sm leading-6 text-slate-600">
{text}
</div>
);
}
function safePropRows(props: unknown, labels: Record<string, string>) {
if (!isRecord(props)) {
return [];
}
return Object.entries(labels).flatMap(([key, label]) => {
const value = props[key];
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
return [];
}
return [{ label, value: String(value) }];
});
}
function getEnvelopeIcon(envelope: UIEnvelope) {
if (envelope.kind === "chart") {
return envelope.spec && isRecord(envelope.spec) && envelope.spec.chart_type === "bar" ? (
<BarChart3 size={16} aria-hidden="true" />
) : (
<LineChart size={16} aria-hidden="true" />
);
}
if (envelope.kind === "registered_component") {
return <Database size={16} aria-hidden="true" />;
}
return <Activity size={16} aria-hidden="true" />;
}
function getEnvelopeTitle(envelope: UIEnvelope) {
if (envelope.kind === "chart") {
const spec = parseChartSpec(envelope.spec);
return spec.title ?? "Agent 图表";
}
if (envelope.kind === "registered_component") {
return envelope.component;
}
return envelope.action;
}
function getSurfaceLabel(surface: UIEnvelope["surface"]) {
if (surface === "chat_inline") return "聊天内联展示";
if (surface === "side_panel") return "侧栏展示";
if (surface === "canvas") return "画布展示";
return "地图叠加";
}
function createTicks(min: number, max: number) {
const range = max - min || 1;
return [max, min + range * 0.5, min];
}
function chartStroke(index: number) {
return ["#2563eb", "#0aa6a6", "#ff7a45", "#0477bf"][index % 4] ?? "#2563eb";
}
function chartColorClass(index: number) {
return ["bg-blue-600", "bg-teal-600", "bg-orange-500", "bg-sky-700"][index % 4] ?? "bg-blue-600";
}
function formatChartNumber(value: number) {
if (Math.abs(value) >= 100) {
return value.toFixed(0);
}
if (Math.abs(value) >= 10) {
return value.toFixed(1);
}
return value.toFixed(2);
}
function shortLabel(value: string) {
return value.length > 8 ? `${value.slice(0, 7)}...` : value;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -0,0 +1,301 @@
export const DEFAULT_STREAMING_TOKEN_LOCALE = "zh";
export const STREAMING_TOKEN_BLOCK_SIZE = 4;
const FALLBACK_CHUNK_SIZE = 2;
export function splitStreamingText(text: string, locale = DEFAULT_STREAMING_TOKEN_LOCALE) {
if (!text) {
return [];
}
const segmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter(locale, { granularity: "word" })
: null;
if (segmenter) {
return Array.from(segmenter.segment(text), (item) => item.segment).filter(Boolean);
}
return splitStreamingTextFallback(text);
}
export function splitStreamingTextFallback(text: string) {
const codePoints = Array.from(text);
const chunks: string[] = [];
for (let index = 0; index < codePoints.length; index += FALLBACK_CHUNK_SIZE) {
chunks.push(codePoints.slice(index, index + FALLBACK_CHUNK_SIZE).join(""));
}
return chunks;
}
function takeStreamingTokenBlock(pending: string[], blockSize = STREAMING_TOKEN_BLOCK_SIZE) {
return pending.splice(0, blockSize).join("");
}
export type StreamingMarkdownBufferState = {
content: string;
displayContent: string;
locale: string;
};
type FenceMarker = {
char: "`" | "~";
length: number;
};
const FENCE_START_PATTERN = /^ {0,3}(`{3,}|~{3,})/;
const HEADING_OPENER_PATTERN = /^ {0,3}#{1,6}[ \t]+\S/;
const PARTIAL_HEADING_OPENER_PATTERN = /^ {0,3}#{1,6}[ \t]*$/;
const LIST_OPENER_PATTERN = /^ {0,3}(?:[-+*]|\d{1,9}[.)])[ \t]+\S/;
const PARTIAL_LIST_OPENER_PATTERN = /^ {0,3}(?:[-+*]|\d{1,9}[.)])[ \t]*$/;
const BLOCKQUOTE_OPENER_PATTERN = /^ {0,3}>[ \t]?\S/;
const PARTIAL_BLOCKQUOTE_OPENER_PATTERN = /^ {0,3}>[ \t]*$/;
const HORIZONTAL_RULE_PATTERN = /^ {0,3}(?:-{3,}|\*{3,}|_{3,})[ \t]*(?:\r?\n|$)/;
const TABLE_SEPARATOR_PATTERN = /^ {0,3}\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/;
const POSSIBLE_TABLE_HEADER_PATTERN = /^ {0,3}\|.*\||^ {0,3}\S.*\|.*\|/;
const LINE_START_PATTERN = /(?:^|\n)(?= {0,3}(?:#{1,6}|[-+*]|\d{1,9}[.)]|>|`{3,}|~{3,}|\|))/g;
function readFenceStart(line: string): FenceMarker | null {
const match = FENCE_START_PATTERN.exec(line);
if (!match) {
return null;
}
const marker = match[1];
return {
char: marker[0] as FenceMarker["char"],
length: marker.length,
};
}
function readLineEnd(content: string) {
const newlineIndex = content.indexOf("\n");
if (newlineIndex === -1) {
return content.length;
}
return newlineIndex + 1;
}
function isTableLine(line: string) {
return line.includes("|") && line.trim().length > 0;
}
function isTableSeparatorLine(line: string) {
return TABLE_SEPARATOR_PATTERN.test(line);
}
type MarkdownOpenerMatch = {
chunk: string;
};
export function createStreamingMarkdownBufferState(
content = "",
displayContent = "",
locale = DEFAULT_STREAMING_TOKEN_LOCALE
): StreamingMarkdownBufferState {
return { content, displayContent, locale };
}
export function resolveStreamingMarkdownDisplayContent(
content: string,
displayContent: string,
streamDone: boolean
) {
if (streamDone) {
return content;
}
return content.startsWith(displayContent) ? displayContent : "";
}
function isLineStart(content: string, offset: number) {
return offset === 0 || content[offset - 1] === "\n";
}
function takeMarkdownTableOpener(content: string): MarkdownOpenerMatch | null {
const headerEnd = readLineEnd(content);
const headerLine = content.slice(0, headerEnd).replace(/\r?\n$/, "");
if (!POSSIBLE_TABLE_HEADER_PATTERN.test(headerLine)) {
return null;
}
if (headerEnd >= content.length && !content.endsWith("\n")) {
return { chunk: "" };
}
const afterHeader = content.slice(headerEnd);
const separatorEnd = readLineEnd(afterHeader);
const separatorLine = afterHeader.slice(0, separatorEnd).replace(/\r?\n$/, "");
if (!separatorLine) {
return { chunk: "" };
}
if (!isTableSeparatorLine(separatorLine)) {
return null;
}
return {
chunk: content.slice(0, headerEnd + separatorEnd),
};
}
function isStreamingTableOpen(displayContent: string) {
const lines = displayContent.replace(/\r\n/g, "\n").split("\n");
while (lines.at(-1) === "") {
lines.pop();
}
const blockLines: string[] = [];
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index];
if (!line.trim()) {
break;
}
if (!isTableLine(line)) {
break;
}
blockLines.unshift(line);
}
return (
blockLines.length >= 2 &&
blockLines.every(isTableLine) &&
blockLines.some(isTableSeparatorLine)
);
}
function takeMarkdownOpener(
content: string,
{ allowTable = true }: { allowTable?: boolean } = {}
): MarkdownOpenerMatch | null {
if (!content) {
return null;
}
const lineEnd = readLineEnd(content);
const firstLine = content.slice(0, lineEnd);
const lineWithoutEnding = firstLine.replace(/\r?\n$/, "");
const fence = readFenceStart(lineWithoutEnding);
if (fence) {
return firstLine.endsWith("\n") ? { chunk: firstLine } : { chunk: "" };
}
const tableOpener = allowTable ? takeMarkdownTableOpener(content) : null;
if (tableOpener) {
return tableOpener;
}
const firstNonSyntaxIndex = (pattern: RegExp) => {
const match = pattern.exec(content);
return match ? match[0].length : 0;
};
const headingLength = firstNonSyntaxIndex(HEADING_OPENER_PATTERN);
if (headingLength > 0) {
return { chunk: content.slice(0, headingLength) };
}
if (PARTIAL_HEADING_OPENER_PATTERN.test(lineWithoutEnding)) {
return { chunk: "" };
}
const listLength = firstNonSyntaxIndex(LIST_OPENER_PATTERN);
if (listLength > 0) {
return { chunk: content.slice(0, listLength) };
}
if (PARTIAL_LIST_OPENER_PATTERN.test(lineWithoutEnding)) {
return { chunk: "" };
}
const blockquoteLength = firstNonSyntaxIndex(BLOCKQUOTE_OPENER_PATTERN);
if (blockquoteLength > 0) {
return { chunk: content.slice(0, blockquoteLength) };
}
if (PARTIAL_BLOCKQUOTE_OPENER_PATTERN.test(lineWithoutEnding)) {
return { chunk: "" };
}
const ruleMatch = HORIZONTAL_RULE_PATTERN.exec(content);
if (ruleMatch) {
return { chunk: ruleMatch[0] };
}
return null;
}
export function isMarkdownOpenerBoundary(content: string, offset = 0) {
if (!isLineStart(content, offset)) {
return false;
}
const opener = takeMarkdownOpener(content.slice(offset));
return Boolean(opener?.chunk);
}
function findNextPotentialMarkdownOpenerIndex(content: string) {
LINE_START_PATTERN.lastIndex = 0;
for (const match of content.matchAll(LINE_START_PATTERN)) {
const index = match.index + (match[0] === "\n" ? 1 : 0);
if (index > 0) {
return index;
}
}
return -1;
}
function takeSegmentedMarkdownChunk(
content: string,
locale: string,
blockSize = STREAMING_TOKEN_BLOCK_SIZE
) {
const pendingSegments = splitStreamingText(content, locale);
const candidate = takeStreamingTokenBlock(pendingSegments, blockSize);
if (!candidate) {
return "";
}
const nextOpenerIndex = findNextPotentialMarkdownOpenerIndex(candidate);
return nextOpenerIndex > 0 ? candidate.slice(0, nextOpenerIndex) : candidate;
}
export function takeNextStreamingMarkdownChunk(
state: StreamingMarkdownBufferState,
blockSize = STREAMING_TOKEN_BLOCK_SIZE
) {
const displayContent = state.content.startsWith(state.displayContent)
? state.displayContent
: "";
const pendingContent = state.content.slice(displayContent.length);
if (!pendingContent) {
return {
chunk: "",
state: { ...state, displayContent },
};
}
const opener = isLineStart(state.content, displayContent.length)
? takeMarkdownOpener(pendingContent, {
allowTable: !isStreamingTableOpen(displayContent),
})
: null;
const chunk = opener
? opener.chunk
: takeSegmentedMarkdownChunk(pendingContent, state.locale, blockSize);
const nextDisplayContent = `${displayContent}${chunk}`;
return {
chunk,
state: {
...state,
displayContent: nextDisplayContent,
},
};
}
@@ -0,0 +1,83 @@
"use client";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { MessageResponse } from "@/shared/ai-elements/message";
import {
createStreamingMarkdownBufferState,
resolveStreamingMarkdownDisplayContent,
takeNextStreamingMarkdownChunk,
} from "./streaming-token-response-state";
type StreamingTokenResponseProps = {
content: string;
streaming: boolean;
streamDone?: boolean;
className?: string;
messageId?: string;
};
const STREAMING_MARKDOWN_FRAME_MS = 45;
export function StreamingTokenResponse({
content,
streaming,
streamDone = !streaming,
className,
messageId,
}: StreamingTokenResponseProps) {
const [displayContent, setDisplayContent] = useState(() =>
streamDone ? content : ""
);
useEffect(() => {
setDisplayContent((currentDisplayContent) =>
resolveStreamingMarkdownDisplayContent(
content,
currentDisplayContent,
streamDone
)
);
}, [content, messageId, streamDone]);
useEffect(() => {
if (streamDone || !streaming) {
return;
}
const showNextChunk = () => {
setDisplayContent((currentDisplayContent) => {
const { chunk, state } = takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState(content, currentDisplayContent)
);
if (chunk || state.displayContent !== currentDisplayContent) {
return state.displayContent;
}
return currentDisplayContent;
});
};
showNextChunk();
const timer = window.setInterval(showNextChunk, STREAMING_MARKDOWN_FRAME_MS);
return () => window.clearInterval(timer);
}, [content, streamDone, streaming]);
if (!displayContent) {
return null;
}
return (
<MessageResponse
className={cn("agent-streaming-response", className)}
isAnimating={streaming}
mode="streaming"
parseIncompleteMarkdown={true}
>
{displayContent}
</MessageResponse>
);
}
+46
View File
@@ -0,0 +1,46 @@
export { AgentCollapsedRail } from "./components/agent-collapsed-rail";
export { AgentCommandPanel } from "./components/agent-command-panel";
export { AgentPersona } from "./components/agent-persona";
export { normalizeSafeChartData, parseChartSpec, pointToLabelValue } from "./chart-data";
export { parseAssistantMessageSections, parseContentWithToolCalls } from "./message-sections";
export {
applyPermissionResponse,
applyQuestionResponse,
cancelRunningTodos,
completeRunningProgress,
finalizeAssistantMessageAfterAbort,
toTodoUpdate,
updateMessageById,
upsertPermission,
upsertProgress,
upsertQuestion
} from "./session-state";
export type {
AgentChatMessage,
AgentChatProgress,
AgentInteractionToolRef,
AgentModelOption,
AgentPermissionReply,
AgentPermissionRequest,
AgentPermissionStatus,
AgentQuestionInfo,
AgentQuestionOption,
AgentQuestionRequest,
AgentStreamRenderChunk,
AgentStreamRenderMessageState,
AgentStreamRenderState,
AgentTodoItem,
AgentTodoUpdate,
AgentUiResult
} from "./types";
export type {
SafeChartData,
SafeChartSeries,
SafeChartSpec
} from "./chart-data";
export type {
AssistantMessageSections,
ContentSegment,
ParsedToolContent,
ToolCall
} from "./message-sections";
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import { parseAssistantMessageSections, parseContentWithToolCalls } from "./message-sections";
describe("parseAssistantMessageSections", () => {
it("returns plain assistant content when there is no thought block", () => {
expect(parseAssistantMessageSections("直接回答")).toEqual({
answer: "直接回答",
thought: null,
thoughtComplete: false
});
});
it("extracts a completed thought block and keeps the final answer visible", () => {
expect(parseAssistantMessageSections("<think>先分析需求</think>\n\n最终回答")).toEqual({
answer: "最终回答",
thought: "先分析需求",
thoughtComplete: true
});
});
it("supports streaming thought content before the closing tag arrives", () => {
expect(parseAssistantMessageSections("准备中...\n<think>继续推理中")).toEqual({
answer: "准备中...",
thought: "继续推理中",
thoughtComplete: false
});
});
it("merges multiple thought blocks into a single collapsed section", () => {
expect(
parseAssistantMessageSections(
"<think>第一段思考</think>\n答案开头\n<think>第二段思考</think>\n答案结尾"
)
).toEqual({
answer: "答案开头\n\n答案结尾",
thought: "第一段思考\n\n第二段思考",
thoughtComplete: true
});
});
});
describe("parseContentWithToolCalls", () => {
it("returns a single text segment when there are no tool calls", () => {
const result = parseContentWithToolCalls("普通文本回答");
expect(result.segments).toEqual([{ type: "text", content: "普通文本回答" }]);
expect(result.toolCalls).toHaveLength(0);
});
it("parses a complete tool_call block", () => {
const content =
'分析完成。\n<tool_call>{"tool":"locate_junctions","params":{"ids":["J1","J2"]}}</tool_call>\n以上是结果。';
const result = parseContentWithToolCalls(content);
expect(result.toolCalls).toHaveLength(1);
expect(result.toolCalls[0].tool).toBe("locate_junctions");
expect(result.toolCalls[0].params).toEqual({ ids: ["J1", "J2"] });
expect(result.segments).toHaveLength(3);
expect(result.segments[0]).toEqual({ type: "text", content: "分析完成。" });
expect(result.segments[1]).toMatchObject({
type: "tool_call",
toolCall: { tool: "locate_junctions" }
});
expect(result.segments[2]).toEqual({ type: "text", content: "以上是结果。" });
});
it("parses multiple tool_call blocks", () => {
const content =
'文本1\n<tool_call>{"tool":"locate_pipes","params":{"ids":["P1"]}}</tool_call>\n文本2\n<tool_call>{"tool":"chart","params":{"title":"图"}}</tool_call>';
const result = parseContentWithToolCalls(content);
expect(result.toolCalls).toHaveLength(2);
expect(result.toolCalls[0].tool).toBe("locate_pipes");
expect(result.toolCalls[1].tool).toBe("chart");
expect(result.segments).toHaveLength(4);
});
it("detects an unclosed tool_call tag as pending while streaming", () => {
const result = parseContentWithToolCalls('正在分析...\n<tool_call>{"tool":"locate_no');
expect(result.segments).toEqual([
{ type: "text", content: "正在分析..." },
{ type: "tool_call_pending" }
]);
expect(result.toolCalls).toHaveLength(0);
});
it("strips partial opening tags during streaming", () => {
const result = parseContentWithToolCalls("正在分析...\n<tool_c");
expect(result.segments).toEqual([{ type: "text", content: "正在分析..." }]);
});
it("handles malformed JSON gracefully", () => {
const result = parseContentWithToolCalls("前文\n<tool_call>{invalid json}</tool_call>\n后文");
expect(result.toolCalls).toHaveLength(0);
expect(result.segments.length).toBeGreaterThanOrEqual(2);
});
it("returns empty segments for empty content", () => {
const result = parseContentWithToolCalls("");
expect(result.segments).toHaveLength(0);
expect(result.toolCalls).toHaveLength(0);
});
});
+129
View File
@@ -0,0 +1,129 @@
export type AssistantMessageSections = {
answer: string;
thought: string | null;
thoughtComplete: boolean;
};
export type ToolCall = {
id: string;
tool: string;
params: Record<string, unknown>;
};
export type ContentSegment =
| { type: "text"; content: string }
| { type: "tool_call"; toolCall: ToolCall }
| { type: "tool_call_pending" };
export type ParsedToolContent = {
segments: ContentSegment[];
toolCalls: ToolCall[];
};
const THINK_BLOCK_PATTERN = /<think>([\s\S]*?)<\/think>/gi;
const THINK_OPEN_TAG = "<think>";
const THINK_CLOSE_TAG = "</think>";
export function parseAssistantMessageSections(content: string): AssistantMessageSections {
if (!content) {
return { answer: "", thought: null, thoughtComplete: false };
}
const thoughtParts: string[] = [];
let answer = content;
answer = answer.replace(THINK_BLOCK_PATTERN, (_, thoughtContent: string) => {
const trimmedThought = thoughtContent.trim();
if (trimmedThought) {
thoughtParts.push(trimmedThought);
}
return "\n";
});
const lastOpenIndex = answer.lastIndexOf(THINK_OPEN_TAG);
const lastCloseIndex = answer.lastIndexOf(THINK_CLOSE_TAG);
const hasUnclosedThought = lastOpenIndex !== -1 && lastOpenIndex > lastCloseIndex;
if (hasUnclosedThought) {
const streamingThought = answer.slice(lastOpenIndex + THINK_OPEN_TAG.length).trim();
if (streamingThought) {
thoughtParts.push(streamingThought);
}
answer = answer.slice(0, lastOpenIndex);
}
const normalizedAnswer = answer.replace(/\n{3,}/g, "\n\n").trim();
const normalizedThought = thoughtParts.join("\n\n").trim();
return {
answer: normalizedAnswer,
thought: normalizedThought || null,
thoughtComplete: Boolean(normalizedThought) && !hasUnclosedThought
};
}
const TOOL_CALL_OPEN_TAG = "<tool_call>";
const TOOL_CALL_PATTERN = /<tool_call>([\s\S]*?)<\/tool_call>/gi;
const PARTIAL_TOOL_TAG_TAIL = /<(?:t(?:o(?:o(?:l(?:_(?:c(?:a(?:l(?:l)?)?)?)?)?)?)?)?)?$/;
export function parseContentWithToolCalls(content: string): ParsedToolContent {
if (!content) {
return { segments: [], toolCalls: [] };
}
const segments: ContentSegment[] = [];
const toolCalls: ToolCall[] = [];
let lastIndex = 0;
let toolCallIndex = 0;
let match: RegExpExecArray | null;
while ((match = TOOL_CALL_PATTERN.exec(content)) !== null) {
const textBefore = content.slice(lastIndex, match.index);
if (textBefore.trim()) {
segments.push({ type: "text", content: textBefore.trim() });
}
try {
const parsed = JSON.parse(match[1].trim()) as {
tool?: string;
params?: Record<string, unknown>;
};
const toolCall: ToolCall = {
id: `tc-${toolCallIndex++}`,
tool: parsed.tool ?? "unknown",
params: parsed.params ?? {}
};
segments.push({ type: "tool_call", toolCall });
toolCalls.push(toolCall);
} catch {
segments.push({ type: "text", content: match[0] });
}
lastIndex = match.index + match[0].length;
}
const remaining = content.slice(lastIndex);
const unclosedIndex = remaining.lastIndexOf(TOOL_CALL_OPEN_TAG);
if (unclosedIndex !== -1) {
const textBefore = remaining.slice(0, unclosedIndex);
if (textBefore.trim()) {
segments.push({ type: "text", content: textBefore.trim() });
}
segments.push({ type: "tool_call_pending" });
} else {
const cleaned = remaining.replace(PARTIAL_TOOL_TAG_TAIL, "").trim();
if (cleaned) {
segments.push({ type: "text", content: cleaned });
}
}
if (segments.length === 0) {
segments.push({ type: "text", content });
}
return { segments, toolCalls };
}
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import {
applyPermissionResponse,
applyQuestionResponse,
finalizeAssistantMessageAfterAbort,
toTodoUpdate,
upsertPermission,
upsertProgress,
upsertQuestion
} from "./session-state";
import type { AgentChatMessage } from "./types";
describe("agent session state", () => {
it("upserts progress events by id", () => {
const first = upsertProgress(undefined, {
id: "tool-1",
phase: "tool",
status: "running",
title: "调用工具",
elapsed_ms: 20
});
const next = upsertProgress(first, {
id: "tool-1",
phase: "tool",
status: "completed",
title: "工具完成",
duration_ms: 40
});
expect(next).toHaveLength(1);
expect(next[0]).toMatchObject({ id: "tool-1", status: "completed", title: "工具完成", durationMs: 40 });
});
it("normalizes todo updates", () => {
expect(
toTodoUpdate({
session_id: "session-1",
todos: [{ id: "todo-1", content: "分析压力", status: "in_progress", priority: "high" }],
created_at: 123
})
).toEqual({
sessionId: "session-1",
messageId: undefined,
todos: [{ id: "todo-1", content: "分析压力", status: "in_progress", priority: "high" }],
createdAt: 123
});
});
it("tracks permission request and response", () => {
const permissions = upsertPermission(undefined, {
session_id: "session-1",
request_id: "permission-1",
permission: "bash",
patterns: ["pnpm test"],
always: [],
created_at: 100
});
const next = applyPermissionResponse(permissions, {
request_id: "permission-1",
reply: "once"
});
expect(next?.[0]).toMatchObject({
requestId: "permission-1",
status: "approved_once"
});
});
it("dedupes question requests by tool call id", () => {
const questions = upsertQuestion(undefined, {
session_id: "session-1",
request_id: "call-1",
tool: { messageID: "message-1", callID: "call-1" },
questions: [{ header: "范围", question: "选择范围", options: [] }],
created_at: 100
});
const next = upsertQuestion(questions, {
session_id: "session-1",
request_id: "question-1",
tool: { messageID: "message-1", callID: "call-1" },
questions: [{ header: "范围", question: "选择范围", options: [] }],
created_at: 120
});
const answered = applyQuestionResponse(next, {
request_id: "question-1",
answers: [["东部"]]
});
expect(next).toHaveLength(1);
expect(answered?.[0]).toMatchObject({
requestId: "question-1",
status: "answered",
answers: [["东部"]]
});
});
it("finalizes open assistant state after abort", () => {
const message: AgentChatMessage = {
id: "assistant-1",
role: "assistant",
content: "",
progress: [{ id: "run", phase: "run", status: "running", title: "处理中", startedAt: Date.now() }],
permissions: [
{
requestId: "permission-1",
sessionId: "session-1",
permission: "bash",
patterns: [],
always: [],
createdAt: 100,
status: "pending"
}
]
};
const next = finalizeAssistantMessageAfterAbort(message);
expect(next.content).toBe("请求已中止。");
expect(next.progress?.[0].status).toBe("completed");
expect(next.permissions?.[0].status).toBe("aborted");
});
});
+370
View File
@@ -0,0 +1,370 @@
import type {
AgentChatMessage,
AgentChatProgress,
AgentInteractionToolRef,
AgentPermissionRequest,
AgentPermissionStatus,
AgentQuestionInfo,
AgentQuestionRequest,
AgentTodoItem,
AgentTodoUpdate
} from "./types";
type RecordValue = Record<string, unknown>;
export function updateMessageById(
messages: AgentChatMessage[],
messageId: string,
updater: (message: AgentChatMessage) => AgentChatMessage
) {
return messages.map((message) => (message.id === messageId ? updater(message) : message));
}
export function upsertProgress(progress: AgentChatProgress[] | undefined, data: unknown) {
const payload = asRecord(data);
const id = readString(payload.id) ?? `progress-${Date.now().toString(36)}`;
const now = Date.now();
const index = progress?.findIndex((item) => item.id === id) ?? -1;
const existing = index >= 0 ? progress?.[index] : undefined;
const status = readProgressStatus(payload.status);
const startedAt = readNumber(payload.started_at) ?? readNumber(payload.startedAt) ?? existing?.startedAt;
const endedAt = readNumber(payload.ended_at) ?? readNumber(payload.endedAt);
const elapsedMs = readNumber(payload.elapsed_ms) ?? readNumber(payload.elapsedMs);
const durationMs = readNumber(payload.duration_ms) ?? readNumber(payload.durationMs);
const nextItem: AgentChatProgress = {
id,
phase: readString(payload.phase) ?? "progress",
status,
title: readString(payload.title) ?? "正在处理",
detail: readString(payload.detail),
startedAt,
endedAt,
elapsedMs: status === "running" ? elapsedMs : undefined,
elapsedSnapshotAt: status === "running" && elapsedMs !== undefined ? now : undefined,
durationMs: status === "running" ? undefined : durationMs
};
const next = [...(progress ?? [])];
if (index >= 0) {
next[index] = nextItem;
} else {
next.push(nextItem);
}
return next;
}
export function completeRunningProgress(progress: AgentChatProgress[] | undefined) {
return progress?.map((item) => {
if (item.status !== "running") {
return item;
}
const endedAt = Date.now();
return {
...item,
status: "completed" as const,
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs:
item.durationMs ??
(item.startedAt !== undefined ? Math.max(0, endedAt - item.startedAt) : item.elapsedMs)
};
});
}
export function toTodoUpdate(data: unknown): AgentTodoUpdate | null {
const payload = asRecord(data);
const sessionId = readString(payload.session_id) ?? readString(payload.sessionId);
const rawTodos = Array.isArray(payload.todos) ? payload.todos : [];
if (!sessionId || rawTodos.length === 0) {
return null;
}
return {
sessionId,
messageId: readString(payload.message_id) ?? readString(payload.messageId),
todos: rawTodos.map(toTodoItem).filter((item): item is AgentTodoItem => Boolean(item)),
createdAt: readNumber(payload.created_at) ?? readNumber(payload.createdAt) ?? Date.now()
};
}
export function cancelRunningTodos(todoUpdate: AgentTodoUpdate | undefined) {
return todoUpdate
? {
...todoUpdate,
todos: todoUpdate.todos.map((todo) =>
todo.status === "pending" || todo.status === "in_progress"
? {
...todo,
status: "cancelled" as const,
updatedAt: Date.now()
}
: todo
)
}
: undefined;
}
export function upsertPermission(
permissions: AgentPermissionRequest[] | undefined,
data: unknown
) {
const payload = asRecord(data);
const requestId = readString(payload.request_id) ?? readString(payload.requestId);
const sessionId = readString(payload.session_id) ?? readString(payload.sessionId);
if (!requestId || !sessionId) {
return permissions;
}
const nextItem: AgentPermissionRequest = {
requestId,
sessionId,
permission: readString(payload.permission) ?? "unknown",
patterns: readStringArray(payload.patterns),
target: readString(payload.target),
always: readStringArray(payload.always),
tool: toToolRef(payload.tool),
createdAt: readNumber(payload.created_at) ?? readNumber(payload.createdAt) ?? Date.now(),
status: "pending"
};
const next = [...(permissions ?? [])];
const index = next.findIndex((permission) => permission.requestId === requestId);
if (index >= 0) {
next[index] = {
...next[index],
...nextItem,
status: next[index].status === "submitting" ? "submitting" : nextItem.status
};
} else {
next.push(nextItem);
}
return next;
}
export function applyPermissionResponse(
permissions: AgentPermissionRequest[] | undefined,
data: unknown
) {
const payload = asRecord(data);
const requestId = readString(payload.request_id) ?? readString(payload.requestId);
if (!requestId) {
return permissions;
}
return permissions?.map((permission) =>
permission.requestId === requestId
? {
...permission,
status: toPermissionStatus(readString(payload.reply)),
repliedAt: Date.now(),
error: undefined
}
: permission
);
}
export function upsertQuestion(questions: AgentQuestionRequest[] | undefined, data: unknown) {
const payload = asRecord(data);
const requestId = readString(payload.request_id) ?? readString(payload.requestId);
const sessionId = readString(payload.session_id) ?? readString(payload.sessionId);
const rawQuestions = Array.isArray(payload.questions) ? payload.questions : [];
if (!requestId || !sessionId || rawQuestions.length === 0) {
return questions;
}
const nextItem: AgentQuestionRequest = {
requestId,
sessionId,
questions: rawQuestions.map(toQuestionInfo).filter((item): item is AgentQuestionInfo => Boolean(item)),
tool: toToolRef(payload.tool),
createdAt: readNumber(payload.created_at) ?? readNumber(payload.createdAt) ?? Date.now(),
status: "pending"
};
const next = [...(questions ?? [])];
const index = next.findIndex((question) => isSameQuestion(question, nextItem));
if (index >= 0) {
next[index] = {
...next[index],
...nextItem,
status: next[index].status === "submitting" ? "submitting" : nextItem.status
};
} else {
next.push(nextItem);
}
return next;
}
export function applyQuestionResponse(questions: AgentQuestionRequest[] | undefined, data: unknown) {
const payload = asRecord(data);
const requestId = readString(payload.request_id) ?? readString(payload.requestId);
if (!requestId) {
return questions;
}
const answers = Array.isArray(payload.answers)
? payload.answers.map((answer) => (Array.isArray(answer) ? answer.map(String) : []))
: undefined;
const rejected = payload.rejected === true;
return questions?.map((question) =>
question.requestId === requestId
? {
...question,
status: rejected ? ("rejected" as const) : ("answered" as const),
answers: answers ?? question.answers,
repliedAt: Date.now(),
error: undefined
}
: question
);
}
export function finalizeAssistantMessageAfterAbort(message: AgentChatMessage): AgentChatMessage {
const progress = completeRunningProgress(message.progress);
const todos = cancelRunningTodos(message.todos);
return {
...message,
content: message.content || "请求已中止。",
progress,
todos,
permissions: message.permissions?.map((permission) =>
permission.status === "pending" || permission.status === "submitting" || permission.status === "error"
? {
...permission,
status: "aborted" as const,
repliedAt: Date.now(),
error: undefined
}
: permission
),
questions: message.questions?.map((question) =>
question.status === "pending" || question.status === "submitting" || question.status === "error"
? {
...question,
status: "rejected" as const,
repliedAt: Date.now(),
error: undefined
}
: question
)
};
}
function toPermissionStatus(reply: string | undefined): AgentPermissionStatus {
if (reply === "always") {
return "approved_always";
}
if (reply === "once") {
return "approved_once";
}
return "rejected";
}
function isSameQuestion(left: AgentQuestionRequest, right: AgentQuestionRequest) {
if (left.requestId === right.requestId) {
return true;
}
return Boolean(left.tool?.callID && right.tool?.callID && left.tool.callID === right.tool.callID);
}
function toQuestionInfo(value: unknown): AgentQuestionInfo | null {
const payload = asRecord(value);
const question = readString(payload.question);
if (!question) {
return null;
}
return {
header: readString(payload.header) ?? "",
question,
options: Array.isArray(payload.options)
? payload.options
.map((option) => {
const item = asRecord(option);
const label = readString(item.label);
return label
? {
label,
description: readString(item.description) ?? ""
}
: null;
})
.filter((item): item is { label: string; description: string } => Boolean(item))
: [],
multiple: typeof payload.multiple === "boolean" ? payload.multiple : undefined,
custom: typeof payload.custom === "boolean" ? payload.custom : undefined
};
}
function toTodoItem(value: unknown): AgentTodoItem | null {
const payload = asRecord(value);
const id = readString(payload.id);
const content = readString(payload.content);
const status = readTodoStatus(payload.status);
if (!id || !content || !status) {
return null;
}
return {
id,
content,
status,
priority: readTodoPriority(payload.priority),
createdAt: readNumber(payload.created_at) ?? readNumber(payload.createdAt),
updatedAt: readNumber(payload.updated_at) ?? readNumber(payload.updatedAt)
};
}
function toToolRef(value: unknown): AgentInteractionToolRef | undefined {
const payload = asRecord(value);
const messageID = readString(payload.messageID);
const callID = readString(payload.callID);
return messageID && callID ? { messageID, callID } : undefined;
}
function readProgressStatus(value: unknown): AgentChatProgress["status"] {
return value === "completed" || value === "error" ? value : "running";
}
function readTodoStatus(value: unknown): AgentTodoItem["status"] | undefined {
if (value === "pending" || value === "in_progress" || value === "completed" || value === "cancelled") {
return value;
}
return undefined;
}
function readTodoPriority(value: unknown): AgentTodoItem["priority"] | undefined {
if (value === "low" || value === "medium" || value === "high") {
return value;
}
return undefined;
}
function asRecord(value: unknown): RecordValue {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as RecordValue)
: {};
}
function readString(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readNumber(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function readStringArray(value: unknown) {
return Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : [];
}
@@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
createStreamingMarkdownBufferState,
isMarkdownOpenerBoundary,
resolveStreamingMarkdownDisplayContent,
splitStreamingText,
splitStreamingTextFallback,
takeNextStreamingMarkdownChunk,
} from "./components/streaming-token-response-state";
describe("streaming token response state", () => {
it("splits streaming text without dropping content", () => {
const content = "管网压力异常需要继续分析";
const chunks = splitStreamingText(content, "zh");
expect(chunks.length).toBeGreaterThan(0);
expect(chunks.join("")).toBe(content);
});
it("uses bounded code point chunks for fallback segmentation", () => {
expect(splitStreamingTextFallback("管网压力")).toEqual(["管网", "压力"]);
});
it("emits continuous Chinese text without waiting for markdown block closure", () => {
let state = createStreamingMarkdownBufferState("正在分析管网压力异常需要继续分析");
const chunks: string[] = [];
for (let index = 0; index < 20; index += 1) {
const next = takeNextStreamingMarkdownChunk(state);
state = next.state;
if (!next.chunk) {
break;
}
chunks.push(next.chunk);
}
expect(chunks.length).toBeGreaterThan(0);
expect(chunks.join("")).toBe("正在分析管网压力异常需要继续分析");
});
it("flushes a heading opener atomically", () => {
const next = takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState("### 压力分析")
);
expect(next.chunk).toBe("### 压");
expect(isMarkdownOpenerBoundary("### 压力分析")).toBe(true);
});
it("waits for a heading opener to include text", () => {
const next = takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState("### ")
);
expect(next.chunk).toBe("");
expect(next.state.displayContent).toBe("");
});
it("flushes list and quote openers atomically", () => {
expect(
takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState("- 节点 A")
).chunk
).toBe("- 节");
expect(
takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState("> 注意压力")
).chunk
).toBe("> 注");
});
it("flushes code and mermaid fence openers as complete lines", () => {
expect(
takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState("```ts\nconst pressure = 1;")
).chunk
).toBe("```ts\n");
expect(
takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState("```mermaid\ngraph TD")
).chunk
).toBe("```mermaid\n");
});
it("waits for fenced code openers to reach the line ending", () => {
const next = takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState("```ts")
);
expect(next.chunk).toBe("");
expect(next.state.displayContent).toBe("");
});
it("flushes table header and separator rows atomically", () => {
const content = "| 节点 | 压力 |\n| --- | --- |\n| J1 | 0.32 |\n\n后续";
expect(
takeNextStreamingMarkdownChunk(
createStreamingMarkdownBufferState(content)
).chunk
).toBe("| 节点 | 压力 |\n| --- | --- |\n");
});
it("grows table rows after the table opener is visible", () => {
const tableOpener = "| 节点 | 压力 |\n| --- | --- |\n";
const state = createStreamingMarkdownBufferState(
`${tableOpener}| J1 | 0.32 |`,
tableOpener
);
expect(takeNextStreamingMarkdownChunk(state).chunk).not.toBe("");
});
it("flushes all display content after streaming is done", () => {
const content = "前文\n\n```ts\nconst pressure = 1;";
expect(resolveStreamingMarkdownDisplayContent(content, "前文", true)).toBe(content);
});
it("StreamingTokenResponse does not use closed-block markdown gating", () => {
const source = readFileSync(
join(process.cwd(), "features/agent/components/streaming-token-response.tsx"),
"utf8"
);
expect(source).not.toContain("splitStreamingMarkdownContent");
});
});
+119
View File
@@ -0,0 +1,119 @@
export type AgentChatMessage = {
id: string;
role: "user" | "assistant";
content: string;
progress?: AgentChatProgress[];
permissions?: AgentPermissionRequest[];
questions?: AgentQuestionRequest[];
todos?: AgentTodoUpdate;
};
export type AgentStreamRenderChunk = {
id: number;
text: string;
};
export type AgentStreamRenderMessageState = {
chunks: AgentStreamRenderChunk[];
done: boolean;
};
export type AgentStreamRenderState = Record<string, AgentStreamRenderMessageState>;
export type AgentChatProgress = {
id: string;
phase: string;
status: "running" | "completed" | "error";
title: string;
detail?: string;
startedAt?: number;
endedAt?: number;
elapsedMs?: number;
elapsedSnapshotAt?: number;
durationMs?: number;
};
export type AgentPermissionStatus =
| "pending"
| "submitting"
| "approved_once"
| "approved_always"
| "rejected"
| "aborted"
| "error";
export type AgentPermissionReply = "once" | "always" | "reject";
export type AgentModelOption = {
id: string;
label: string;
description: string;
icon: "bolt" | "sparkle";
};
export type AgentPermissionRequest = {
requestId: string;
sessionId: string;
permission: string;
patterns: string[];
target?: string;
always: string[];
tool?: AgentInteractionToolRef;
createdAt: number;
repliedAt?: number;
status: AgentPermissionStatus;
error?: string;
};
export type AgentQuestionOption = {
label: string;
description: string;
};
export type AgentQuestionInfo = {
header: string;
question: string;
options: AgentQuestionOption[];
multiple?: boolean;
custom?: boolean;
};
export type AgentQuestionRequest = {
requestId: string;
sessionId: string;
questions: AgentQuestionInfo[];
tool?: AgentInteractionToolRef;
createdAt: number;
repliedAt?: number;
status: "pending" | "submitting" | "answered" | "rejected" | "error";
answers?: string[][];
error?: string;
};
export type AgentTodoItem = {
id: string;
content: string;
status: "pending" | "in_progress" | "completed" | "cancelled";
priority?: "low" | "medium" | "high";
createdAt?: number;
updatedAt?: number;
};
export type AgentTodoUpdate = {
sessionId: string;
messageId?: string;
todos: AgentTodoItem[];
createdAt: number;
};
export type AgentInteractionToolRef = {
messageID: string;
callID: string;
};
export type AgentUiResult = {
id: string;
sessionId: string;
createdAt: number;
envelope: import("./ui-envelope").UIEnvelope;
};
+8
View File
@@ -0,0 +1,8 @@
export type { UIEnvelope, UIEnvelopePayload, UIRegistry, UISurface } from "./types";
export { toTrustedMapAction, type TrustedMapAction } from "./map-actions";
export {
isUiEnvelopeAllowed,
parseUiEnvelope,
parseUiEnvelopePayload,
parseUiRegistry
} from "./validator";
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { toTrustedMapAction } from "./map-actions";
describe("toTrustedMapAction", () => {
it("rejects zoom_to_map actions without a valid center", () => {
expect(toTrustedMapAction("zoom_to_map", { zoom: 14 })).toBeNull();
expect(toTrustedMapAction("zoom_to_map", { center: ["east", 39.1] })).toBeNull();
});
it("rejects unsupported map actions", () => {
expect(toTrustedMapAction("run_javascript", { code: "alert(1)" })).toBeNull();
});
it("downgrades malformed apply_layer_style params to safe undefined fields", () => {
expect(
toTrustedMapAction("apply_layer_style", {
layer_group_id: 123,
layer_id: [],
visible: "true"
})
).toEqual({
type: "apply_layer_style",
layerGroupId: undefined,
layerId: undefined,
visible: undefined,
fallbackText: undefined
});
});
it("accepts valid zoom_to_map coordinates", () => {
expect(toTrustedMapAction("zoom_to_map", { lng: 117.2, lat: 39.1, zoom: 13 })).toEqual({
type: "zoom_to_map",
center: [117.2, 39.1],
zoom: 13,
fallbackText: undefined
});
});
it("accepts legacy zoom coordinate aliases and numeric strings", () => {
expect(toTrustedMapAction("zoom_to_map", { coordinate: ["117.2", "39.1"], zoom: "13" })).toEqual({
type: "zoom_to_map",
center: [117.2, 39.1],
zoom: 13,
fallbackText: undefined
});
expect(toTrustedMapAction("zoom_to_map", { x: "117.2", y: "39.1" })).toMatchObject({
type: "zoom_to_map",
center: [117.2, 39.1]
});
});
it("normalizes legacy locate tool actions", () => {
expect(toTrustedMapAction("locate_pipes", { pipe_ids: "P1,P2" })).toEqual({
type: "locate_features",
featureIds: ["P1", "P2"],
layer: "geo_pipes_mat",
fallbackText: undefined
});
});
it("normalizes numeric locate ids", () => {
expect(toTrustedMapAction("locate_junctions", { junction_id: 42 })).toMatchObject({
type: "locate_features",
featureIds: ["42"],
layer: "geo_junctions_mat"
});
});
});
+185
View File
@@ -0,0 +1,185 @@
export type TrustedMapAction =
| {
type: "locate_features";
featureIds: string[];
layer?: string;
fallbackText?: string;
}
| {
type: "zoom_to_map";
center: [number, number];
zoom?: number;
fallbackText?: string;
}
| {
type: "apply_layer_style";
layerGroupId?: string;
layerId?: string;
visible?: boolean;
fallbackText?: string;
}
| {
type: "render_junctions";
renderRef: string;
fallbackText?: string;
};
export function toTrustedMapAction(action: string, params: unknown, fallbackText?: string): TrustedMapAction | null {
if (!isRecord(params)) {
return null;
}
if (action === "locate_features" || action in LEGACY_LOCATE_ACTION_LAYERS) {
const featureIds = readLocateIds(params);
return {
type: "locate_features",
featureIds,
layer:
stringValue(params.layer ?? params.target_layer ?? params.targetLayer) ??
LEGACY_LOCATE_ACTION_LAYERS[action],
fallbackText
};
}
if (action === "zoom_to_map") {
const center = parseCenter(params);
if (!center) {
return null;
}
return {
type: "zoom_to_map",
center,
zoom: numberValue(params.zoom),
fallbackText
};
}
if (action === "apply_layer_style") {
return {
type: "apply_layer_style",
layerGroupId: stringValue(params.layer_group_id ?? params.layerGroupId),
layerId: stringValue(params.layer_id ?? params.layerId),
visible: booleanValue(params.visible),
fallbackText
};
}
if (action === "render_junctions") {
const renderRef = stringValue(params.render_ref ?? params.renderRef);
if (!renderRef) {
return null;
}
return {
type: "render_junctions",
renderRef,
fallbackText
};
}
return null;
}
function parseCenter(params: Record<string, unknown>): [number, number] | null {
if (Array.isArray(params.center) && params.center.length >= 2) {
const lng = numberValue(params.center[0]);
const lat = numberValue(params.center[1]);
return lng === undefined || lat === undefined ? null : [lng, lat];
}
const rawCoordinate = params.coordinate ?? params.coordinates;
if (Array.isArray(rawCoordinate) && rawCoordinate.length >= 2) {
const lng = numberValue(rawCoordinate[0]);
const lat = numberValue(rawCoordinate[1]);
return lng === undefined || lat === undefined ? null : [lng, lat];
}
const lng = numberValue(params.lng ?? params.lon ?? params.longitude ?? params.x);
const lat = numberValue(params.lat ?? params.latitude ?? params.y);
return lng === undefined || lat === undefined ? null : [lng, lat];
}
function stringValue(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function numberValue(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function booleanValue(value: unknown) {
return typeof value === "boolean" ? value : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
const LEGACY_LOCATE_ACTION_LAYERS: Record<string, string> = {
locate_junctions: "geo_junctions_mat",
locate_pipes: "geo_pipes_mat",
locate_valves: "geo_valves",
locate_reservoirs: "geo_reservoirs",
locate_pumps: "geo_pumps",
locate_tanks: "geo_tanks"
};
const LOCATE_ID_PARAM_KEYS = [
"feature_ids",
"feature_id",
"featureIds",
"featureId",
"ids",
"id",
"node_ids",
"node_id",
"junction_ids",
"junction_id",
"pipe_ids",
"pipe_id",
"valve_ids",
"valve_id",
"reservoir_ids",
"reservoir_id",
"pump_ids",
"pump_id",
"tank_ids",
"tank_id"
] as const;
function readLocateIds(params: Record<string, unknown>) {
for (const key of LOCATE_ID_PARAM_KEYS) {
const value = params[key];
const ids = normalizeIds(value);
if (ids.length > 0) {
return ids;
}
}
return [];
}
function normalizeIds(value: unknown) {
if (Array.isArray(value)) {
return value.map((item) => String(item).trim()).filter(Boolean);
}
if (typeof value === "string" || typeof value === "number") {
return String(value)
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
return [];
}
+43
View File
@@ -0,0 +1,43 @@
export type UISurface = "chat_inline" | "side_panel" | "canvas" | "map_overlay";
export type UIEnvelope =
| {
kind: "registered_component";
schemaVersion: "agent-ui@1";
component: string;
surface: UISurface;
props: unknown;
data?: unknown;
fallbackText?: string;
}
| {
kind: "chart";
schemaVersion: "agent-ui@1";
grammar: "echarts-safe-subset";
surface: "chat_inline" | "side_panel" | "canvas";
spec: unknown;
data: unknown;
fallbackText?: string;
}
| {
kind: "map_action";
schemaVersion: "agent-ui@1";
action: string;
surface: "map_overlay";
params: unknown;
fallbackText?: string;
};
export type UIEnvelopePayload = {
session_id: string;
envelope_id: string;
created_at: number;
envelope: UIEnvelope;
};
export type UIRegistry = {
schema_version: "agent-ui-registry@1";
chart_grammars: string[];
components: Array<{ id: string; supportedSurfaces: UISurface[] }>;
actions: Array<{ id: string; supportedSurfaces: UISurface[] }>;
};
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { isUiEnvelopeAllowed, parseUiEnvelope, parseUiRegistry } from "./validator";
import type { UIRegistry } from "./types";
const registry: UIRegistry = {
schema_version: "agent-ui-registry@1",
chart_grammars: ["echarts-safe-subset"],
components: [
{ id: "HistoryPanel", supportedSurfaces: ["side_panel", "canvas"] },
{ id: "ScadaPanel", supportedSurfaces: ["side_panel", "canvas"] }
],
actions: [{ id: "zoom_to_map", supportedSurfaces: ["map_overlay"] }]
};
describe("UIEnvelope validation", () => {
it("rejects registered components outside the frontend allowlist", () => {
const envelope = parseUiEnvelope({
kind: "registered_component",
schemaVersion: "agent-ui@1",
component: "UnsafePanel",
surface: "side_panel",
props: {}
});
expect(envelope).not.toBeNull();
expect(envelope ? isUiEnvelopeAllowed(envelope, registry) : false).toBe(false);
});
it("rejects registered components on unsupported surfaces", () => {
const envelope = parseUiEnvelope({
kind: "registered_component",
schemaVersion: "agent-ui@1",
component: "HistoryPanel",
surface: "chat_inline",
props: {}
});
expect(envelope).not.toBeNull();
expect(envelope ? isUiEnvelopeAllowed(envelope, registry) : false).toBe(false);
});
it("accepts only the echarts-safe-subset chart grammar", () => {
expect(
parseUiEnvelope({
kind: "chart",
schemaVersion: "agent-ui@1",
grammar: "vega-lite",
surface: "chat_inline",
spec: {},
data: {}
})
).toBeNull();
const envelope = parseUiEnvelope({
kind: "chart",
schemaVersion: "agent-ui@1",
grammar: "echarts-safe-subset",
surface: "chat_inline",
spec: {},
data: {}
});
expect(envelope).not.toBeNull();
expect(envelope ? isUiEnvelopeAllowed(envelope, registry) : false).toBe(true);
});
it("filters invalid registry surfaces before allowlist checks", () => {
const parsed = parseUiRegistry({
schema_version: "agent-ui-registry@1",
chart_grammars: ["echarts-safe-subset"],
components: [{ id: "HistoryPanel", supportedSurfaces: ["side_panel", "unsafe_surface"] }],
actions: []
});
expect(parsed?.components[0]?.supportedSurfaces).toEqual(["side_panel"]);
});
});
+141
View File
@@ -0,0 +1,141 @@
import type { UIEnvelope, UIEnvelopePayload, UIRegistry, UISurface } from "./types";
const surfaces = new Set<UISurface>(["chat_inline", "side_panel", "canvas", "map_overlay"]);
export function parseUiEnvelopePayload(value: unknown): UIEnvelopePayload | null {
if (!isRecord(value)) {
return null;
}
const envelope = parseUiEnvelope(value.envelope);
if (!envelope) {
return null;
}
return {
session_id: typeof value.session_id === "string" ? value.session_id : "",
envelope_id: typeof value.envelope_id === "string" ? value.envelope_id : "",
created_at: typeof value.created_at === "number" ? value.created_at : Date.now(),
envelope
};
}
export function parseUiEnvelope(value: unknown): UIEnvelope | null {
if (!isRecord(value) || value.schemaVersion !== "agent-ui@1") {
return null;
}
if (value.kind === "registered_component") {
if (typeof value.component !== "string" || !isSurface(value.surface)) {
return null;
}
return {
kind: "registered_component",
schemaVersion: "agent-ui@1",
component: value.component,
surface: value.surface,
props: value.props,
data: value.data,
fallbackText: optionalString(value.fallbackText)
};
}
if (value.kind === "chart") {
if (value.grammar !== "echarts-safe-subset" || !isChartSurface(value.surface)) {
return null;
}
return {
kind: "chart",
schemaVersion: "agent-ui@1",
grammar: "echarts-safe-subset",
surface: value.surface,
spec: value.spec,
data: value.data,
fallbackText: optionalString(value.fallbackText)
};
}
if (value.kind === "map_action") {
if (typeof value.action !== "string" || value.surface !== "map_overlay") {
return null;
}
return {
kind: "map_action",
schemaVersion: "agent-ui@1",
action: value.action,
surface: "map_overlay",
params: value.params,
fallbackText: optionalString(value.fallbackText)
};
}
return null;
}
export function isUiEnvelopeAllowed(envelope: UIEnvelope, registry: UIRegistry | null) {
if (!registry) {
return true;
}
if (envelope.kind === "registered_component") {
const manifest = registry.components.find((item) => item.id === envelope.component);
return Boolean(manifest?.supportedSurfaces.includes(envelope.surface));
}
if (envelope.kind === "chart") {
return registry.chart_grammars.includes(envelope.grammar);
}
const manifest = registry.actions.find((item) => item.id === envelope.action);
return Boolean(manifest?.supportedSurfaces.includes(envelope.surface));
}
export function parseUiRegistry(value: unknown): UIRegistry | null {
if (!isRecord(value) || value.schema_version !== "agent-ui-registry@1") {
return null;
}
return {
schema_version: "agent-ui-registry@1",
chart_grammars: stringArray(value.chart_grammars),
components: manifestArray(value.components),
actions: manifestArray(value.actions)
};
}
function manifestArray(value: unknown) {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((item) => {
if (!isRecord(item) || typeof item.id !== "string") {
return [];
}
return [{ id: item.id, supportedSurfaces: stringArray(item.supportedSurfaces).filter(isSurface) }];
});
}
function stringArray(value: unknown) {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
}
function isChartSurface(value: unknown): value is "chat_inline" | "side_panel" | "canvas" {
return value === "chat_inline" || value === "side_panel" || value === "canvas";
}
function isSurface(value: unknown): value is UISurface {
return typeof value === "string" && surfaces.has(value as UISurface);
}
function optionalString(value: unknown) {
return typeof value === "string" ? value : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -0,0 +1,101 @@
import type { LucideIcon } from "lucide-react";
import { Download, ListChecks, MapPinned, Plus, Share2 } from "lucide-react";
import { cn } from "@/lib/utils";
import {
MapActionRow,
MapPanelSection
} from "./control-panel";
import {
MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME
} from "./map-control-styles";
export type MapAnnotationItem = {
id: string;
label: string;
description: string;
icon?: LucideIcon;
status?: string;
selected?: boolean;
muted?: boolean;
onClick?: () => void;
};
export type MapAnnotationShareAction = {
id: string;
label: string;
description: string;
icon?: LucideIcon;
onClick?: () => void;
};
export type MapAnnotationPanelProps = {
annotations: MapAnnotationItem[];
shareActions?: MapAnnotationShareAction[];
emptyText?: string;
};
export function MapAnnotationPanel({
annotations,
shareActions = [],
emptyText = "暂无标注"
}: MapAnnotationPanelProps) {
return (
<div className="space-y-3">
<MapPanelSection title="标注列表">
{annotations.length > 0 ? (
annotations.map((annotation) => (
<MapActionRow
key={annotation.id}
icon={annotation.icon ?? getDefaultAnnotationIcon(annotation.status)}
label={annotation.label}
description={annotation.description}
status={annotation.status}
selected={annotation.selected}
muted={annotation.muted}
onClick={annotation.onClick}
/>
))
) : (
<div className={cn("px-3 py-2 text-xs text-slate-500", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}>
{emptyText}
</div>
)}
</MapPanelSection>
{shareActions.length > 0 ? (
<MapPanelSection title="共享">
{shareActions.map((action) => (
<MapActionRow
key={action.id}
icon={action.icon ?? getDefaultShareIcon(action.id)}
label={action.label}
description={action.description}
onClick={action.onClick}
/>
))}
</MapPanelSection>
) : null}
</div>
);
}
function getDefaultAnnotationIcon(status?: string) {
if (status) {
return ListChecks;
}
return MapPinned;
}
function getDefaultShareIcon(actionId: string) {
if (actionId.includes("export") || actionId.includes("download")) {
return Download;
}
if (actionId.includes("share")) {
return Share2;
}
return Plus;
}
@@ -0,0 +1,228 @@
"use client";
import { Check, Map } from "lucide-react";
import { type FocusEvent, useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import {
MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME,
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
} from "./map-control-styles";
export type BaseLayerOption = {
id: string;
label: string;
description?: string;
disabled?: boolean;
swatchClassName?: string;
};
type BaseLayersControlProps = {
options: BaseLayerOption[];
activeLayerId: string;
onSelectLayer: (id: string) => void;
title?: string;
variant?: "floating" | "panel";
className?: string;
};
export function BaseLayersControl({
options,
activeLayerId,
onSelectLayer,
title = "底图",
variant = "floating",
className = ""
}: BaseLayersControlProps) {
const activeLayer = options.find((option) => option.id === activeLayerId) ?? options[0];
const [isOpen, setIsOpen] = useState(false);
const closeTimerRef = useRef<number | null>(null);
const clearCloseTimer = useCallback(() => {
if (closeTimerRef.current) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
const openControl = useCallback(() => {
clearCloseTimer();
setIsOpen(true);
}, [clearCloseTimer]);
const scheduleClose = useCallback(() => {
clearCloseTimer();
closeTimerRef.current = window.setTimeout(() => {
setIsOpen(false);
closeTimerRef.current = null;
}, 260);
}, [clearCloseTimer]);
const handleBlur = useCallback(
(event: FocusEvent<HTMLElement>) => {
if (event.currentTarget.contains(event.relatedTarget)) {
return;
}
scheduleClose();
},
[scheduleClose]
);
useEffect(() => clearCloseTimer, [clearCloseTimer]);
if (variant === "panel") {
return (
<section
aria-label={title}
className={cn(
"pointer-events-auto w-[276px] p-3",
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
className
)}
>
<div className="flex items-center gap-2 px-1">
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
<Map size={17} aria-hidden="true" />
</span>
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
<p className="mt-0.5 truncate text-xs text-slate-500">
{activeLayer?.label ?? "--"}
</p>
</div>
</div>
<div className="mt-3 grid grid-cols-3 gap-2">
{options.map((option) => {
const active = option.id === activeLayerId;
return (
<button
key={option.id}
type="button"
disabled={option.disabled}
aria-pressed={active}
onClick={() => onSelectLayer(option.id)}
className={cn(
"flex min-h-[76px] flex-col p-1 text-left transition duration-150",
MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME,
active
? "border-blue-500 text-blue-700 shadow-sm ring-2 ring-blue-100"
: "text-slate-700 hover:border-blue-300 hover:bg-blue-50/40",
option.disabled && "cursor-not-allowed opacity-50"
)}
>
<BaseLayerSwatch option={option} active={active} />
<span className="mt-1 block w-full truncate px-0.5 text-xs font-semibold leading-none">
{option.label}
</span>
</button>
);
})}
</div>
</section>
);
}
return (
<section
aria-label={title}
onMouseEnter={openControl}
onMouseLeave={scheduleClose}
onFocus={openControl}
onBlur={handleBlur}
className={cn(
"pointer-events-auto relative flex h-[96px] items-end justify-end",
isOpen ? "w-[384px]" : "w-[96px]",
className
)}
>
<div
className={cn(
"absolute bottom-0 right-[104px] grid h-[96px] grid-cols-[repeat(3,84px)] gap-2 p-1.5 transition-opacity duration-150",
MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME,
isOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
)}
>
{options.map((option) => {
const active = option.id === activeLayerId;
return (
<button
key={option.id}
type="button"
disabled={option.disabled}
aria-pressed={active}
onClick={() => onSelectLayer(option.id)}
className={cn(
"flex h-[84px] w-[84px] flex-col p-1 text-center transition duration-150",
MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME,
active ? "border-blue-500 shadow-sm ring-2 ring-blue-100" : "border-slate-200 hover:border-blue-300 hover:bg-blue-50/40",
option.disabled && "cursor-not-allowed opacity-50"
)}
>
<BaseLayerSwatch option={option} active={active} />
<span
data-base-layer-label="true"
className="block h-4 shrink-0 truncate px-0.5 pt-1 text-xs font-semibold leading-none text-slate-800"
>
{option.label}
</span>
</button>
);
})}
</div>
<div className={cn("absolute bottom-0 right-0 grid h-[96px] w-[96px] place-items-center p-1.5", MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME)}>
<button
type="button"
aria-label={`${title}:当前 ${activeLayer?.label ?? ""}`}
title={`${title}:当前 ${activeLayer?.label ?? ""}`}
className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-sm transition hover:border-blue-300 hover:text-blue-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
>
<span className={cn("relative h-full w-full overflow-hidden border border-white shadow-inner", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
{activeLayer ? (
<span className={cn("absolute inset-0", activeLayer.swatchClassName ?? "bg-slate-100")} />
) : (
<Map size={18} aria-hidden="true" className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
)}
<span className="absolute bottom-1 left-1 right-1 rounded bg-white/95 px-1 py-0.5 text-xs font-semibold leading-none text-slate-800 shadow-sm">
{activeLayer?.label ?? title}
</span>
</span>
</button>
</div>
</section>
);
}
type BaseLayerSwatchProps = {
option: BaseLayerOption;
active: boolean;
};
function BaseLayerSwatch({ option, active }: BaseLayerSwatchProps) {
return (
<span
className={cn(
"relative block min-h-0 flex-1 overflow-hidden border border-white shadow-inner",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
option.swatchClassName ?? "bg-slate-100"
)}
>
{active ? (
<span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-blue-600 text-white shadow-sm">
<Check size={10} aria-hidden="true" />
</span>
) : null}
</span>
);
}
@@ -0,0 +1,219 @@
import { ChevronRight, type LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME,
MAP_READABLE_SURFACE_STRONG_CLASS_NAME,
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
} from "./map-control-styles";
export type MapControlPanelProps = {
title: string;
description?: string;
icon: LucideIcon;
widthClassName?: string;
visible?: boolean;
children: ReactNode;
};
export function MapControlPanel({
title,
description,
icon: Icon,
widthClassName = "w-[292px]",
visible = true,
children
}: MapControlPanelProps) {
return (
<section
aria-label={title}
aria-hidden={!visible}
className={cn(
"relative origin-top-right p-3 transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none",
visible
? "pointer-events-auto opacity-100 translate-x-0 scale-100 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-right-2 motion-safe:zoom-in-95 motion-safe:duration-150 motion-safe:ease-out"
: "pointer-events-none translate-x-2 scale-[0.98] opacity-0",
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
widthClassName
)}
>
<div className={cn("relative mb-3 flex items-center gap-2.5 px-2.5 py-2", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}>
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-100 text-blue-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
<Icon size={17} aria-hidden="true" />
</span>
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
{description ? <p className="mt-0.5 truncate text-xs text-slate-500">{description}</p> : null}
</div>
</div>
<div className="relative space-y-3">{children}</div>
</section>
);
}
export function MapPanelSection({ title, children }: { title: string; children: ReactNode }) {
return (
<section>
<div className="mb-1.5 px-1 text-xs font-semibold text-slate-500">{title}</div>
<div className="space-y-1.5">{children}</div>
</section>
);
}
export type MapModeButtonProps = {
icon: LucideIcon;
label: string;
active?: boolean;
disabled?: boolean;
onClick?: () => void;
};
export function MapModeButton({ icon: Icon, label, active = false, disabled = false, onClick }: MapModeButtonProps) {
return (
<button
type="button"
aria-pressed={active}
disabled={disabled}
onClick={onClick}
className={cn(
"flex h-14 flex-col items-center justify-center gap-1 text-xs font-semibold transition duration-150",
MAP_COMPACT_RADIUS_CLASS_NAME,
active ? "bg-blue-600 text-white shadow-sm" : "bg-white/95 text-slate-600 hover:text-blue-700",
disabled && "cursor-not-allowed opacity-55 hover:text-slate-600"
)}
>
<Icon size={17} aria-hidden="true" />
<span>{label}</span>
</button>
);
}
export function MapActionChip({
icon: Icon,
label,
disabled = false,
onClick
}: {
icon: LucideIcon;
label: string;
disabled?: boolean;
onClick?: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
"flex h-10 items-center justify-center gap-1.5 text-xs font-semibold text-slate-700 transition duration-150 hover:border-blue-200 hover:text-blue-700",
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME,
disabled && "cursor-not-allowed opacity-55 hover:border-white/70 hover:text-slate-700"
)}
>
<Icon size={14} aria-hidden="true" />
<span>{label}</span>
</button>
);
}
export type MapActionRowProps = {
icon: LucideIcon;
label: string;
description: string;
status?: string;
strong?: boolean;
variant?: "default" | "primary";
selected?: boolean;
muted?: boolean;
disabled?: boolean;
tone?: "default" | "danger";
onClick?: () => void;
};
export function MapActionRow({
icon: Icon,
label,
description,
status,
strong = false,
variant = "default",
selected = false,
muted = false,
disabled = false,
tone = "default",
onClick
}: MapActionRowProps) {
const isDanger = tone === "danger";
const isPrimary = variant === "primary";
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
"flex min-h-12 w-full items-center gap-2.5 border px-2.5 py-2 text-left transition duration-150 active:translate-y-px active:scale-[0.99]",
MAP_COMPACT_RADIUS_CLASS_NAME,
isPrimary
? "border-blue-600 bg-blue-600 text-white shadow-lg shadow-blue-600/25 hover:bg-blue-700"
: strong
? "border-blue-100 bg-blue-50/95 text-blue-800"
: "border-white/70 bg-white/95 text-slate-700 hover:border-blue-100 hover:bg-white hover:text-blue-700",
isDanger && "hover:border-red-100 hover:text-red-700",
selected && !isPrimary && "border-blue-200 bg-blue-50 text-blue-800",
muted && "opacity-70",
disabled &&
(isPrimary
? "cursor-not-allowed opacity-55 hover:border-blue-600 hover:bg-blue-600 hover:text-white"
: "cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-white/95 hover:text-slate-700"),
disabled && "active:translate-y-0 active:scale-100"
)}
>
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
isPrimary
? "bg-white/16 text-white"
: selected || strong
? "bg-blue-600 text-white"
: "bg-slate-100 text-slate-500",
isDanger && "bg-red-50 text-red-600"
)}
>
<Icon size={15} aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">{label}</span>
<span className={cn("block truncate text-xs", isPrimary ? "text-blue-100" : "text-slate-500")}>{description}</span>
</span>
{status ? (
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold",
isPrimary ? "bg-white/15 text-white" : "bg-slate-100 text-slate-500"
)}
>
{status}
</span>
) : (
<ChevronRight size={15} aria-hidden="true" className={cn("shrink-0", isPrimary ? "text-blue-100" : "text-slate-400")} />
)}
</button>
);
}
export function MapMetricTile({ label, value }: { label: string; value: string }) {
return (
<div className={cn("px-2.5 py-2", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}>
<div className="text-xs text-slate-500">{label}</div>
<div className="mt-1 truncate text-sm font-semibold text-slate-900">{value}</div>
</div>
);
}
@@ -0,0 +1,136 @@
"use client";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@/shared/ui/tooltip";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME
} from "./map-control-styles";
export type MapDrawTool = {
id: string;
label: string;
icon: LucideIcon;
active?: boolean;
disabled?: boolean;
tone?: "default" | "danger";
onClick?: () => void;
};
export type MapDrawToolbarProps = {
items: MapDrawTool[];
label?: string;
className?: string;
variant?: "compact" | "panel";
};
export function MapDrawToolbar({ items, label = "绘制工具", className = "", variant = "compact" }: MapDrawToolbarProps) {
if (variant === "panel") {
return (
<div
role="toolbar"
aria-label={label}
className={cn("pointer-events-auto grid grid-cols-2 gap-1.5", className)}
>
{items.map((item) => {
const Icon = item.icon;
const isDanger = item.tone === "danger";
return (
<button
key={item.id}
type="button"
aria-pressed={item.active}
disabled={item.disabled}
onClick={item.onClick}
className={cn(
"flex min-h-10 items-center gap-2 border px-2.5 py-2 text-left text-xs font-semibold transition duration-150",
MAP_COMPACT_RADIUS_CLASS_NAME,
item.active
? "border-blue-600 bg-blue-600 text-white shadow-md shadow-blue-600/20"
: "text-slate-700 hover:border-blue-100 hover:bg-white hover:text-blue-700",
!item.active && MAP_READABLE_SURFACE_CLASS_NAME,
isDanger && "hover:border-red-100 hover:text-red-700",
item.disabled &&
"cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-white/95 hover:text-slate-700"
)}
>
<span
className={cn(
"grid h-7 w-7 shrink-0 place-items-center",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
item.active ? "bg-white/16 text-white" : "bg-slate-100 text-slate-500",
isDanger && "bg-red-50 text-red-600"
)}
>
<Icon size={15} aria-hidden="true" />
</span>
<span className="min-w-0 truncate">{item.label}</span>
</button>
);
})}
</div>
);
}
return (
<TooltipProvider delayDuration={180}>
<div
role="toolbar"
aria-label={label}
className={cn(
"pointer-events-auto inline-flex p-1",
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME,
className
)}
>
{items.map((item) => {
const Icon = item.icon;
const isDanger = item.tone === "danger";
return (
<Tooltip key={item.id}>
<TooltipTrigger asChild>
<button
type="button"
aria-label={item.label}
aria-pressed={item.active}
disabled={item.disabled}
onClick={item.onClick}
className={cn(
"grid h-8 w-8 shrink-0 place-items-center text-slate-600 transition duration-150",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
item.active
? "bg-blue-600 text-white shadow-sm"
: "hover:bg-blue-50 hover:text-blue-700",
isDanger && "hover:bg-red-50 hover:text-red-700",
item.disabled && "cursor-not-allowed opacity-45 hover:bg-transparent hover:text-slate-600"
)}
>
<Icon size={16} aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent
side="top"
sideOffset={7}
className="border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
>
{item.label}
</TooltipContent>
</Tooltip>
);
})}
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,103 @@
import { Eye, EyeOff, Layers3, Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
} from "./map-control-styles";
export type MapLayerControlItem = {
id: string;
label: string;
description?: string;
visible: boolean;
disabled?: boolean;
locked?: boolean;
};
type MapLayerControlProps = {
title?: string;
items: MapLayerControlItem[];
onToggleLayer: (id: string, visible: boolean) => void;
className?: string;
};
export function MapLayerControl({
title = "图层",
items,
onToggleLayer,
className = ""
}: MapLayerControlProps) {
return (
<section
aria-label={title}
className={cn(
"pointer-events-auto w-[276px] p-3",
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
className
)}
>
<div className="flex items-center justify-between gap-3 px-1">
<div className="flex min-w-0 items-center gap-2">
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
<Layers3 size={17} aria-hidden="true" />
</span>
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">{title}</h2>
<p className="mt-0.5 text-xs text-slate-500">
{items.filter((item) => item.visible).length}/{items.length}
</p>
</div>
</div>
</div>
<div className="mt-3 space-y-1.5">
{items.map((item) => {
const Icon = item.visible ? Eye : EyeOff;
const disabled = item.disabled || item.locked;
return (
<button
key={item.id}
type="button"
disabled={disabled}
aria-pressed={item.visible}
onClick={() => onToggleLayer(item.id, !item.visible)}
className={cn(
"flex min-h-12 w-full items-center gap-3 border px-2.5 text-left text-sm transition duration-150",
MAP_COMPACT_RADIUS_CLASS_NAME,
item.visible
? "border-blue-100 bg-white/95 text-slate-900 shadow-md shadow-blue-950/10"
: "border-white/70 bg-white/95 text-slate-500 hover:border-slate-200 hover:bg-white",
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
)}
>
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
item.visible ? "bg-blue-600 text-white" : "bg-slate-100 text-slate-400"
)}
>
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold">{item.label}</span>
{item.description ? <span className="block truncate text-xs text-slate-500">{item.description}</span> : null}
</span>
<span
className={cn(
"h-2.5 w-2.5 rounded-full",
item.visible ? "bg-blue-600" : "bg-slate-300"
)}
aria-hidden="true"
/>
</button>
);
})}
</div>
</section>
);
}
@@ -0,0 +1,162 @@
import { Check, Eye, EyeOff, Lock, Map } from "lucide-react";
import { cn } from "@/lib/utils";
import type { BaseLayerOption } from "./base-layers-control";
import type { MapLayerControlItem } from "./layer-control";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME,
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
} from "./map-control-styles";
export type MapLayerPanelProps = {
layerItems: MapLayerControlItem[];
baseLayerOptions: BaseLayerOption[];
activeBaseLayerId: string;
onSelectBaseLayer: (id: string) => void;
onToggleLayer: (id: string, visible: boolean) => void;
};
export function MapLayerPanel({
layerItems,
baseLayerOptions,
activeBaseLayerId,
onSelectBaseLayer,
onToggleLayer
}: MapLayerPanelProps) {
return (
<>
<LayerVisibilitySection items={layerItems} onToggleLayer={onToggleLayer} />
<BaseLayerSection
options={baseLayerOptions}
activeLayerId={activeBaseLayerId}
onSelectLayer={onSelectBaseLayer}
/>
</>
);
}
type LayerVisibilitySectionProps = {
items: MapLayerControlItem[];
onToggleLayer: (id: string, visible: boolean) => void;
};
function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySectionProps) {
return (
<div>
<div className="mb-1.5 px-1 text-xs font-semibold text-slate-500"></div>
<div className="space-y-1.5">
{items.map((item) => {
const Icon = item.visible ? Eye : EyeOff;
const disabled = item.disabled || item.locked;
return (
<button
key={item.id}
type="button"
disabled={disabled}
aria-pressed={item.visible}
onClick={() => onToggleLayer(item.id, !item.visible)}
className={cn(
"flex min-h-12 w-full items-center gap-3 border px-2.5 text-left text-sm transition duration-150",
MAP_COMPACT_RADIUS_CLASS_NAME,
item.visible
? "border-blue-100 bg-white/95 text-slate-900"
: "border-white/70 bg-white/95 text-slate-500 hover:border-slate-200 hover:bg-white",
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
)}
>
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
item.visible ? "bg-blue-600 text-white" : "bg-slate-100 text-slate-400"
)}
>
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold">{item.label}</span>
{item.description ? <span className="block truncate text-xs text-slate-500">{item.description}</span> : null}
</span>
<span
className={cn("h-2.5 w-2.5 rounded-full", item.visible ? "bg-blue-600" : "bg-slate-300")}
aria-hidden="true"
/>
</button>
);
})}
</div>
</div>
);
}
type BaseLayerSectionProps = {
options: BaseLayerOption[];
activeLayerId: string;
onSelectLayer: (id: string) => void;
};
function BaseLayerSection({ options, activeLayerId, onSelectLayer }: BaseLayerSectionProps) {
return (
<div>
<div className="mb-1.5 flex items-center justify-between px-1">
<span className="text-xs font-semibold text-slate-500"></span>
<Map size={14} aria-hidden="true" className="text-slate-400" />
</div>
<div className="grid grid-cols-3 gap-2">
{options.map((option) => {
const active = option.id === activeLayerId;
return (
<button
key={option.id}
type="button"
disabled={option.disabled}
aria-pressed={active}
onClick={() => onSelectLayer(option.id)}
className={cn(
"flex min-h-[76px] flex-col p-1 text-left transition duration-150",
MAP_READABLE_RADIUS_CLASS_NAME,
active ? MAP_READABLE_SURFACE_STRONG_CLASS_NAME : MAP_READABLE_SURFACE_CLASS_NAME,
active
? "border-blue-500 text-blue-700 ring-2 ring-blue-100"
: "text-slate-700 hover:border-blue-300 hover:bg-blue-50/40",
option.disabled && "cursor-not-allowed opacity-50"
)}
>
<BaseLayerSwatch option={option} active={active} />
<span className="mt-1 block w-full truncate px-0.5 text-xs font-semibold leading-none">
{option.label}
</span>
</button>
);
})}
</div>
</div>
);
}
type BaseLayerSwatchProps = {
option: BaseLayerOption;
active: boolean;
};
function BaseLayerSwatch({ option, active }: BaseLayerSwatchProps) {
return (
<span
className={cn(
"relative block min-h-0 flex-1 overflow-hidden border border-white shadow-inner",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
option.swatchClassName ?? "bg-slate-100"
)}
>
{active ? (
<span className="absolute right-0.5 top-0.5 grid h-4 w-4 place-items-center rounded-full bg-blue-600 text-white">
<Check size={10} aria-hidden="true" />
</span>
) : null}
</span>
);
}
+86
View File
@@ -0,0 +1,86 @@
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME,
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
} from "./map-control-styles";
export type MapLegendItem = {
id: string;
label: string;
description?: string;
color: string;
icon?: LucideIcon;
shape?: "line" | "dot" | "square";
};
type MapLegendProps = {
title?: string;
items: MapLegendItem[];
className?: string;
};
export function MapLegend({ title = "图例", items, className = "" }: MapLegendProps) {
return (
<section
aria-label={title}
className={cn(
"pointer-events-auto w-[236px] p-3",
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_TOOL_PANEL_SURFACE_CLASS_NAME,
className
)}
>
<h2 className="px-1 text-sm font-semibold leading-tight text-slate-950">{title}</h2>
<p className="mt-0.5 px-1 text-xs text-slate-500"></p>
<MapLegendList items={items} className="mt-3" />
</section>
);
}
type MapLegendListProps = {
items: MapLegendItem[];
className?: string;
showStatusDot?: boolean;
};
export function MapLegendList({ items, className = "", showStatusDot = false }: MapLegendListProps) {
return (
<ul className={cn("space-y-1.5", className)}>
{items.map((item) => {
const Icon = item.icon;
return (
<li
key={item.id}
className={cn("flex min-h-10 items-center gap-2.5 px-2.5 py-2 text-sm text-slate-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
>
<span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
{Icon ? <Icon size={15} style={{ color: item.color }} /> : <MapLegendSwatch color={item.color} shape={item.shape} />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold">{item.label}</span>
{item.description ? <span className="block truncate text-xs text-slate-500">{item.description}</span> : null}
</span>
{showStatusDot ? <span className="h-2 w-2 rounded-full bg-emerald-500" aria-hidden="true" /> : null}
</li>
);
})}
</ul>
);
}
export function MapLegendSwatch({ color, shape = "dot" }: Pick<MapLegendItem, "color" | "shape">) {
if (shape === "line") {
return <span className="h-1 w-6 rounded-full" style={{ backgroundColor: color }} />;
}
if (shape === "square") {
return <span className="h-4 w-4 rounded-sm" style={{ backgroundColor: color }} />;
}
return <span className="h-4 w-4 rounded-full ring-2 ring-white" style={{ backgroundColor: color }} />;
}
@@ -0,0 +1,24 @@
export const MAP_MAJOR_PANEL_RADIUS_CLASS_NAME = "rounded-2xl";
export const MAP_CONTROL_RADIUS_CLASS_NAME = "rounded-xl";
export const MAP_READABLE_RADIUS_CLASS_NAME = "rounded-xl";
export const MAP_COMPACT_RADIUS_CLASS_NAME = "rounded-xl";
export const MAP_ICON_CELL_RADIUS_CLASS_NAME = "rounded-lg";
export const MAP_CONTROL_SURFACE_CLASS_NAME =
"border border-white/75 bg-white/95 shadow-lg shadow-slate-900/20 ring-1 ring-slate-900/5 backdrop-blur-xl";
export const MAP_TOOL_PANEL_SURFACE_CLASS_NAME =
"border border-white/70 bg-white/80 shadow-2xl shadow-blue-950/20 backdrop-blur-xl";
export const MAP_READABLE_SURFACE_CLASS_NAME =
"border border-white/70 bg-white/95";
export const MAP_READABLE_SURFACE_STRONG_CLASS_NAME =
"border border-white/80 bg-white/95";
export const MAP_CONTROL_HOVER_CLASS_NAME =
"hover:bg-blue-100/70 hover:text-blue-700 hover:ring-1 hover:ring-blue-200/80 active:bg-blue-100";
@@ -0,0 +1,163 @@
import type { LucideIcon } from "lucide-react";
import { Copy, Ruler, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import {
MapActionRow,
MapModeButton,
MapPanelSection
} from "./control-panel";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME,
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
} from "./map-control-styles";
export type MapMeasureMode = {
id: string;
label: string;
icon: LucideIcon;
disabled?: boolean;
};
export type MapMeasureUnit = {
id: string;
label: string;
};
export type MapMeasureResultMetric = {
label: string;
value: string;
};
export type MapMeasureAction = {
id: string;
label: string;
description: string;
icon?: LucideIcon;
strong?: boolean;
variant?: "default" | "primary";
tone?: "default" | "danger";
disabled?: boolean;
onClick?: () => void;
};
export type MapMeasurePanelProps = {
modes: MapMeasureMode[];
activeModeId: string;
resultLabel: string;
resultValue: string;
resultUnit: string;
metrics?: MapMeasureResultMetric[];
units: MapMeasureUnit[];
activeUnitId: string;
actions: MapMeasureAction[];
onSelectMode?: (id: string) => void;
onSelectUnit?: (id: string) => void;
};
export function MapMeasurePanel({
modes,
activeModeId,
resultLabel,
resultValue,
resultUnit,
metrics = [],
units,
activeUnitId,
actions,
onSelectMode,
onSelectUnit
}: MapMeasurePanelProps) {
return (
<div className="space-y-3">
<MapPanelSection title="模式">
<div className={cn("grid grid-cols-3 gap-1.5 border border-white/60 bg-white/80 p-1.5", MAP_READABLE_RADIUS_CLASS_NAME)}>
{modes.map((mode) => (
<MapModeButton
key={mode.id}
icon={mode.icon}
label={mode.label}
active={mode.id === activeModeId}
disabled={mode.disabled}
onClick={() => onSelectMode?.(mode.id)}
/>
))}
</div>
</MapPanelSection>
<MapPanelSection title="结果">
<div className={cn("border border-blue-100 bg-blue-50/95 p-2", MAP_READABLE_RADIUS_CLASS_NAME)}>
<div className={cn("p-3", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}>
<div className="min-w-0 border-b border-slate-100 pb-3">
<div className="text-xs font-semibold text-blue-700">{resultLabel}</div>
<div className="mt-1 flex min-w-0 items-baseline gap-1.5 text-slate-950">
<span className="truncate text-2xl font-semibold leading-none">{resultValue}</span>
<span className="shrink-0 text-xs font-semibold text-slate-500">{resultUnit}</span>
</div>
</div>
{metrics.length > 0 ? (
<div className="mt-2 grid grid-cols-[repeat(auto-fit,minmax(92px,1fr))] gap-1.5">
{metrics.map((metric) => (
<div
key={metric.label}
className={cn("min-w-0 px-2.5 py-1.5 text-left", MAP_COMPACT_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)}
>
<div className="truncate text-xs text-slate-500">{metric.label}</div>
<div className="mt-0.5 truncate text-sm font-semibold text-slate-900">{metric.value}</div>
</div>
))}
</div>
) : null}
</div>
<div className="mt-3 grid grid-cols-[repeat(auto-fit,minmax(0,1fr))] gap-1.5">
{units.map((unit) => (
<button
key={unit.id}
type="button"
onClick={() => onSelectUnit?.(unit.id)}
className={cn(
"h-7 text-xs font-semibold transition",
MAP_COMPACT_RADIUS_CLASS_NAME,
unit.id === activeUnitId
? "bg-blue-600 text-white"
: "bg-white/95 text-slate-600 hover:text-blue-700"
)}
>
{unit.label}
</button>
))}
</div>
</div>
</MapPanelSection>
<MapPanelSection title="操作">
{actions.map((action) => (
<MapActionRow
key={action.id}
icon={action.icon ?? getDefaultActionIcon(action.id)}
label={action.label}
description={action.description}
strong={action.strong}
variant={action.variant}
tone={action.tone}
disabled={action.disabled}
onClick={action.onClick}
/>
))}
</MapPanelSection>
</div>
);
}
function getDefaultActionIcon(actionId: string) {
if (actionId.includes("copy")) {
return Copy;
}
if (actionId.includes("clear") || actionId.includes("delete")) {
return Trash2;
}
return Ruler;
}
+354
View File
@@ -0,0 +1,354 @@
"use client";
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { useEffect } from "react";
import { toast, Toaster } from "sonner";
import { cn } from "@/lib/utils";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME
} from "./map-control-styles";
export type MapNoticeTone = "info" | "success" | "warning" | "error" | "loading";
export type MapNoticePosition = "top-center" | "top-right" | "bottom-left" | "bottom-right";
export type MapNoticeOptions = {
id?: string | number;
title?: string;
message: string;
tone?: MapNoticeTone;
duration?: number;
position?: MapNoticePosition;
actionLabel?: string;
onAction?: () => void;
dismissible?: boolean;
};
type SonnerPosition = NonNullable<ComponentProps<typeof Toaster>["position"]>;
const DEFAULT_POSITION: MapNoticePosition = "top-center";
const positionClassNames: Record<MapNoticePosition, string> = {
"top-center": "top-[84px]",
"top-right": "top-[84px] right-[72px]",
"bottom-left": "bottom-12 left-4",
"bottom-right": "bottom-12 right-4"
};
const sonnerPositions: Record<MapNoticePosition, SonnerPosition> = {
"top-center": "top-center",
"top-right": "top-right",
"bottom-left": "bottom-left",
"bottom-right": "bottom-right"
};
const toneClassNames: Record<MapNoticeTone, string> = {
info: "border-white/80 bg-white/95 text-slate-900",
success: "border-white/80 bg-white/95 text-slate-900",
warning: "border-white/80 bg-white/95 text-slate-900",
error: "border-white/80 bg-white/95 text-slate-900",
loading: "border-white/80 bg-white/95 text-slate-900"
};
const iconClassNames: Record<MapNoticeTone, string> = {
info: "text-blue-600",
success: "text-green-600",
warning: "text-orange-500",
error: "text-red-500",
loading: "text-blue-600"
};
const noticeAccentClassNames: Record<MapNoticeTone, string> = {
info: "bg-blue-600",
success: "bg-green-500",
warning: "bg-orange-500",
error: "bg-red-500",
loading: "bg-blue-600"
};
const noticeIconSurfaceClassNames: Record<MapNoticeTone, string> = {
info: "border-blue-100 bg-blue-50",
success: "border-green-100 bg-green-50",
warning: "border-orange-100 bg-orange-50",
error: "border-red-100 bg-red-50",
loading: "border-blue-100 bg-blue-50"
};
export function MapToaster() {
return (
<Toaster
closeButton={false}
expand
visibleToasts={4}
position={sonnerPositions[DEFAULT_POSITION]}
toastOptions={{
unstyled: true,
classNames: {
toast: "group pointer-events-auto",
closeButton:
"absolute right-2 top-2 grid h-6 w-6 place-items-center rounded-lg border border-transparent text-slate-400 transition hover:bg-white/70 hover:text-slate-700"
}
}}
/>
);
}
export function showMapNotice(options: MapNoticeOptions) {
const tone = options.tone ?? "info";
const position = options.position ?? DEFAULT_POSITION;
const duration = options.duration ?? (tone === "info" || tone === "success" ? 3200 : Infinity);
const dismissible = options.dismissible ?? tone !== "loading";
return toast.custom(
() => <MapNoticeContent {...options} tone={tone} />,
{
id: options.id,
duration,
position: sonnerPositions[position],
dismissible,
cancel: dismissible
? {
label: <MapNoticeCloseIcon />,
onClick: (event) => event.stopPropagation()
}
: undefined,
classNames: {
cancelButton: cn(
"absolute right-2 top-2 z-10 grid h-7 w-7 place-items-center border border-transparent bg-transparent p-0 text-slate-400 transition hover:border-slate-200 hover:bg-slate-50 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25",
MAP_ICON_CELL_RADIUS_CLASS_NAME
)
},
className: cn("!fixed !z-[60]", positionClassNames[position])
}
);
}
export function dismissMapNotice(id?: string | number) {
toast.dismiss(id);
}
export function MapErrorNotice({ message, id = "map-error" }: { message: string; id?: string | number }) {
useEffect(() => {
showMapNotice({
id,
tone: "error",
title: "地图异常",
message,
duration: Infinity
});
return () => dismissMapNotice(id);
}, [id, message]);
return null;
}
export function MapLoadingNotice({
message = "正在初始化地图...",
title = "地图加载中",
id = "map-loading"
}: {
message?: string;
title?: string;
id?: string | number;
}) {
useEffect(() => {
showMapNotice({
id,
tone: "loading",
title,
message,
duration: Infinity,
dismissible: false
});
return () => dismissMapNotice(id);
}, [id, message, title]);
return null;
}
export type MapSourceStatus = "online" | "degraded" | "offline";
export type MapSourceStatusNoticeProps = {
sourceName: string;
status: MapSourceStatus;
message?: string;
id?: string | number;
actionLabel?: string;
onAction?: () => void;
};
export function MapSourceStatusNotice({
sourceName,
status,
message,
id = `map-source-${sourceName}`,
actionLabel,
onAction
}: MapSourceStatusNoticeProps) {
useEffect(() => {
showMapNotice({
id,
tone: getSourceStatusTone(status),
title: getSourceStatusTitle(sourceName, status),
message: message ?? getSourceStatusMessage(status),
duration: status === "online" ? 2600 : Infinity,
actionLabel,
onAction
});
return () => dismissMapNotice(id);
}, [actionLabel, id, message, onAction, sourceName, status]);
return null;
}
function MapNoticeContent({
title,
message,
tone = "info",
actionLabel,
onAction
}: MapNoticeOptions) {
const Icon = getNoticeIcon(tone);
return (
<MapNoticeFrame tone={tone}>
<MapNoticeIcon tone={tone} icon={Icon} />
<MapNoticeBody
title={title}
message={message}
action={actionLabel && onAction ? <MapNoticeAction label={actionLabel} onClick={onAction} /> : null}
/>
</MapNoticeFrame>
);
}
function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: ReactNode }) {
return (
<div
role={tone === "error" || tone === "warning" ? "alert" : "status"}
className={cn(
"relative flex w-[min(420px,calc(100vw-24px))] items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm shadow-lg shadow-slate-900/20 backdrop-blur-xl",
MAP_READABLE_RADIUS_CLASS_NAME,
toneClassNames[tone]
)}
>
<span className={cn("absolute bottom-0 left-0 top-0 w-1", noticeAccentClassNames[tone])} aria-hidden="true" />
{children}
</div>
);
}
function MapNoticeIcon({ tone, icon: Icon }: { tone: MapNoticeTone; icon: ReturnType<typeof getNoticeIcon> }) {
return (
<span
className={cn(
"mt-0.5 grid h-7 w-7 shrink-0 place-items-center border",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
iconClassNames[tone],
noticeIconSurfaceClassNames[tone]
)}
>
<Icon size={17} aria-hidden="true" className={tone === "loading" ? "animate-spin" : undefined} />
</span>
);
}
function MapNoticeBody({
title,
message,
action
}: {
title?: string;
message: string;
action?: ReactNode;
}) {
return (
<span className="min-w-0 flex-1">
{title ? <span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span> : null}
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}>{message}</span>
{action}
</span>
);
}
function MapNoticeAction({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className={cn("mt-2 inline-flex h-7 items-center justify-center border border-blue-100 bg-blue-50 px-2.5 text-xs font-semibold text-blue-700 transition hover:bg-blue-100", MAP_COMPACT_RADIUS_CLASS_NAME)}
>
{label}
</button>
);
}
function MapNoticeCloseIcon() {
return (
<>
<span className="sr-only"></span>
<X size={15} aria-hidden="true" />
</>
);
}
function getNoticeIcon(tone: MapNoticeTone) {
if (tone === "success") {
return CheckCircle2;
}
if (tone === "warning") {
return AlertTriangle;
}
if (tone === "error") {
return XCircle;
}
if (tone === "loading") {
return Loader2;
}
return Info;
}
function getSourceStatusTone(status: MapSourceStatus): MapNoticeTone {
if (status === "online") {
return "success";
}
if (status === "degraded") {
return "warning";
}
return "error";
}
function getSourceStatusTitle(sourceName: string, status: MapSourceStatus) {
if (status === "online") {
return `${sourceName} 已连接`;
}
if (status === "degraded") {
return `${sourceName} 降级运行`;
}
return `${sourceName} 不可用`;
}
function getSourceStatusMessage(status: MapSourceStatus) {
if (status === "online") {
return "地图数据源已恢复正常。";
}
if (status === "degraded") {
return "部分地图能力不可用,系统已使用可用替代方案。";
}
return "地图数据源请求失败,请检查网络或稍后重试。";
}
+223
View File
@@ -0,0 +1,223 @@
"use client";
import type { Map as MapLibreMap } from "maplibre-gl";
import { type RefObject, useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { MAP_CONTROL_SURFACE_CLASS_NAME } from "./map-control-styles";
type ScaleLineState = {
label: string;
width: number;
zoom: string;
};
type MapScaleLineProps = {
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
maxWidth?: number;
coordinatePrecision?: number;
className?: string;
};
type CoordinateState = {
lng: number;
lat: number;
};
const DEFAULT_SCALE: ScaleLineState = {
label: "500 m",
width: 80,
zoom: "Z --"
};
const MIN_SCALE_WIDTH = 34;
const COORDINATE_READOUT_WIDTH_CLASS_NAME = "w-[160px]";
export function MapScaleLine({
mapRef,
mapReady,
maxWidth = 96,
coordinatePrecision = 5,
className = ""
}: MapScaleLineProps) {
const [scale, setScale] = useState<ScaleLineState>(DEFAULT_SCALE);
const [coordinate, setCoordinate] = useState<CoordinateState | null>(null);
const coordinateReadout = coordinate
? createCoordinateReadout(coordinate, coordinatePrecision)
: null;
const coordinateLabel = coordinateReadout
? `${coordinateReadout.mercatorText} · ${coordinateReadout.lngLatText}`
: "移动鼠标读取坐标";
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map) {
return;
}
const updateScale = () => {
setScale(createScaleLineState(map, maxWidth));
};
updateScale();
map.on("move", updateScale);
map.on("zoom", updateScale);
map.on("resize", updateScale);
return () => {
map.off("move", updateScale);
map.off("zoom", updateScale);
map.off("resize", updateScale);
};
}, [mapReady, mapRef, maxWidth]);
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map) {
return;
}
const handleMove = (event: { lngLat: { lng: number; lat: number } }) => {
setCoordinate({
lng: event.lngLat.lng,
lat: event.lngLat.lat
});
};
const handleLeave = () => {
setCoordinate(null);
};
map.on("mousemove", handleMove);
map.on("mouseout", handleLeave);
return () => {
map.off("mousemove", handleMove);
map.off("mouseout", handleLeave);
};
}, [mapReady, mapRef]);
return (
<div
aria-label={`比例尺 ${scale.label}${coordinateLabel}`}
className={cn(
"pointer-events-none inline-flex w-auto max-w-full select-none items-center gap-2.5 overflow-hidden rounded-none rounded-tl-xl border-b-0 border-r-0 px-2.5 py-1.5 text-xs font-semibold leading-none text-slate-800",
MAP_CONTROL_SURFACE_CLASS_NAME,
className
)}
>
<div className="flex shrink-0 items-center gap-2">
<span className="min-w-11 whitespace-nowrap text-right tabular-nums">{scale.label}</span>
<div
className="relative h-2 shrink-0 border-x-2 border-b-2 border-slate-800 transition-[width,border-color] duration-200 ease-out motion-reduce:transition-none"
style={{ width: `${scale.width}px` }}
aria-hidden="true"
/>
</div>
<ScaleLineDivider />
<div className="flex min-w-0 items-center justify-end gap-2.5 overflow-hidden whitespace-nowrap text-slate-700">
<span className="tabular-nums text-slate-800">{scale.zoom}</span>
<span className="text-slate-600">EPSG:3857</span>
{coordinateReadout ? (
<span className={cn(COORDINATE_READOUT_WIDTH_CLASS_NAME, "min-w-0 truncate text-right tabular-nums text-slate-800")} title={coordinateReadout.lngLatText}>
{coordinateReadout.mercatorText}
</span>
) : (
<span className={cn(COORDINATE_READOUT_WIDTH_CLASS_NAME, "min-w-0 truncate text-right tabular-nums")}>{coordinateLabel}</span>
)}
<span className="text-slate-500">Mapbox/OSM</span>
</div>
</div>
);
}
function ScaleLineDivider() {
return (
<div
className="h-3.5 w-px shrink-0 bg-slate-300/70"
aria-hidden="true"
/>
);
}
function createScaleLineState(map: MapLibreMap, maxWidth: number): ScaleLineState {
const canvas = map.getCanvas();
const canvasWidth = canvas.clientWidth || canvas.width;
const center = map.project(map.getCenter());
const halfWidth = Math.min(maxWidth / 2, Math.max(1, canvasWidth / 2));
const y = Math.round(center.y);
const start = map.unproject([Math.max(0, center.x - halfWidth), y]);
const end = map.unproject([Math.min(canvasWidth, center.x + halfWidth), y]);
const measuredWidth = Math.max(1, Math.min(canvasWidth, center.x + halfWidth) - Math.max(0, center.x - halfWidth));
const metersPerMaxWidth = distanceInMeters(start.lng, start.lat, end.lng, end.lat);
if (!Number.isFinite(metersPerMaxWidth) || metersPerMaxWidth <= 0) {
return {
...DEFAULT_SCALE,
zoom: `Z ${map.getZoom().toFixed(1)}`
};
}
const niceMeters = getNiceDistance(metersPerMaxWidth);
const widthPx = Math.min(
measuredWidth,
Math.max(Math.min(MIN_SCALE_WIDTH, measuredWidth), Math.round((niceMeters / metersPerMaxWidth) * measuredWidth))
);
return {
label: formatDistance(niceMeters),
width: widthPx,
zoom: `Z ${map.getZoom().toFixed(1)}`
};
}
function getNiceDistance(maxMeters: number) {
const target = Math.max(1, maxMeters);
const magnitude = 10 ** Math.floor(Math.log10(target));
const normalized = target / magnitude;
const multiplier = normalized >= 5 ? 5 : normalized >= 2 ? 2 : 1;
return multiplier * magnitude;
}
function formatDistance(meters: number) {
if (meters >= 1000) {
return `${Number((meters / 1000).toFixed(meters >= 10000 ? 0 : 1))} km`;
}
return `${Math.round(meters)} m`;
}
function createCoordinateReadout(coordinate: CoordinateState, precision: number) {
return {
lngLatText: `Lng ${coordinate.lng.toFixed(precision)} Lat ${coordinate.lat.toFixed(precision)}`,
mercatorText: formatWebMercator(coordinate.lng, coordinate.lat)
};
}
function formatWebMercator(lng: number, lat: number) {
const earthRadius = 6378137;
const clampedLat = Math.max(-85.05112878, Math.min(85.05112878, lat));
const x = earthRadius * toRadians(lng);
const y = earthRadius * Math.log(Math.tan(Math.PI / 4 + toRadians(clampedLat) / 2));
return `X ${Math.round(x).toLocaleString("en-US")} Y ${Math.round(y).toLocaleString("en-US")}`;
}
function distanceInMeters(startLng: number, startLat: number, endLng: number, endLat: number) {
const earthRadius = 6371008.8;
const startPhi = toRadians(startLat);
const endPhi = toRadians(endLat);
const deltaPhi = toRadians(endLat - startLat);
const deltaLambda = toRadians(endLng - startLng);
const a =
Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
Math.cos(startPhi) * Math.cos(endPhi) * Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2);
return 2 * earthRadius * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function toRadians(degrees: number) {
return (degrees * Math.PI) / 180;
}
+126
View File
@@ -0,0 +1,126 @@
"use client";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@/shared/ui/tooltip";
import {
MAP_CONTROL_HOVER_CLASS_NAME,
MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME
} from "./map-control-styles";
export type MapToolbarItem = {
id: string;
label: string;
description?: string;
icon: LucideIcon;
active?: boolean;
disabled?: boolean;
badge?: string | number;
onClick?: () => void;
};
type MapToolbarProps = {
items: MapToolbarItem[];
label?: string;
orientation?: "vertical" | "horizontal";
className?: string;
};
export function MapToolbar({
items,
label = "地图工具",
orientation = "vertical",
className = ""
}: MapToolbarProps) {
const isVertical = orientation === "vertical";
return (
<TooltipProvider delayDuration={180}>
<nav
aria-label={label}
className={cn(
"pointer-events-auto p-[3px]",
MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME,
isVertical ? "flex w-[46px] flex-col" : "inline-flex",
className
)}
>
{items.map((tool, index) => {
const Icon = tool.icon;
const title = tool.description ? `${tool.label}${tool.description}` : tool.label;
return (
<div key={tool.id} className={cn(isVertical ? "contents" : "flex items-center")}>
{index > 0 ? <ToolbarDivider vertical={isVertical} /> : null}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={title}
aria-pressed={tool.active}
disabled={tool.disabled}
onClick={tool.onClick}
className={cn(
"relative grid h-[38px] w-[38px] shrink-0 place-items-center text-slate-700 transition duration-150",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
"focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
tool.active
? "bg-blue-50 text-blue-800 ring-1 ring-blue-300/70 hover:bg-blue-100 hover:text-blue-900"
: MAP_CONTROL_HOVER_CLASS_NAME,
tool.disabled && "cursor-not-allowed text-slate-400 opacity-60 hover:bg-transparent hover:text-slate-400 hover:ring-0 active:bg-transparent"
)}
>
<Icon size={20} strokeWidth={2.15} aria-hidden="true" />
<span className="sr-only">{tool.label}</span>
{tool.badge ? (
<span className="absolute -right-1 -top-1 grid min-w-4 place-items-center rounded-full bg-red-500 px-1 text-xs font-semibold leading-4 text-white ring-2 ring-white">
{tool.badge}
</span>
) : null}
</button>
</TooltipTrigger>
<TooltipContent
side={isVertical ? "left" : "top"}
sideOffset={8}
className="border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
>
{title}
</TooltipContent>
</Tooltip>
</div>
);
})}
</nav>
</TooltipProvider>
);
}
function ToolbarDivider({ vertical }: { vertical: boolean }) {
if (vertical) {
return (
<div
className="my-0.5 flex h-px w-full items-center justify-center"
aria-hidden="true"
>
<span className="block h-px w-3.5 bg-slate-300/70" />
</div>
);
}
return (
<div
className="mx-0.5 flex h-full w-px items-center justify-center"
aria-hidden="true"
>
<span className="block h-3.5 w-px bg-slate-300/70" />
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
import type { Map as MapLibreMap } from "maplibre-gl";
import { Home, Minus, Plus, type LucideIcon } from "lucide-react";
import { type RefObject, useCallback } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@/shared/ui/tooltip";
import { cn } from "@/lib/utils";
import {
MAP_CONTROL_HOVER_CLASS_NAME,
MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME
} from "./map-control-styles";
type MapZoomProps = {
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
onHome: () => void;
className?: string;
};
export function MapZoom({ mapRef, mapReady, onHome, className = "" }: MapZoomProps) {
const handleZoomIn = useCallback(() => {
mapRef.current?.zoomIn({ duration: 260 });
}, [mapRef]);
const handleZoomOut = useCallback(() => {
mapRef.current?.zoomOut({ duration: 260 });
}, [mapRef]);
return (
<TooltipProvider delayDuration={180}>
<div
aria-label="地图缩放"
className={cn(
"pointer-events-auto flex w-[46px] flex-col p-[3px]",
MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME,
className
)}
>
<ControlButton
icon={Plus}
label="放大"
disabled={!mapReady}
onClick={handleZoomIn}
/>
<Divider />
<ControlButton
icon={Minus}
label="缩小"
disabled={!mapReady}
onClick={handleZoomOut}
/>
<Divider />
<ControlButton
icon={Home}
label="缩放到全局"
disabled={!mapReady}
onClick={onHome}
/>
</div>
</TooltipProvider>
);
}
type ControlButtonProps = {
icon: LucideIcon;
label: string;
disabled: boolean;
onClick: () => void;
};
function ControlButton({ icon: Icon, label, disabled, onClick }: ControlButtonProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={label}
disabled={disabled}
onClick={onClick}
className={cn(
"grid h-[38px] w-[38px] place-items-center transition duration-150",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-slate-900 focus-visible:outline-offset-2",
"bg-transparent text-slate-700",
MAP_CONTROL_HOVER_CLASS_NAME,
disabled &&
"cursor-not-allowed text-slate-400 opacity-55 hover:bg-transparent hover:text-slate-400 active:bg-transparent"
)}
>
<Icon size={18} strokeWidth={2.2} aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={8}
className="border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-800 shadow-lg"
>
{label}
</TooltipContent>
</Tooltip>
);
}
function Divider() {
return (
<div
className="my-0.5 h-px w-3.5 self-center bg-slate-300/70"
aria-hidden="true"
/>
);
}
@@ -0,0 +1,88 @@
import {
getRunningWorkflowDefinition,
getRunningWorkflowState
} from "../data/running-workflows";
import type {
ScheduledConditionRecord
} from "../types";
export type ConditionStepTickerItem = {
id: string;
title: string;
detail: string;
};
export type ConditionStepTickerView = {
title: string;
progressLabel: string;
progressValue: number;
currentStep: ConditionStepTickerItem | null;
};
export type TickerStackCard = {
condition: ScheduledConditionRecord;
view: ConditionStepTickerView;
stackIndex: number;
};
const TICKER_STACK_DEPTH = 3;
export function selectTickerCondition(
runningConditions: ScheduledConditionRecord[],
rotationIndex: number
): ScheduledConditionRecord | null {
if (runningConditions.length === 0) {
return null;
}
return runningConditions[rotationIndex % runningConditions.length] ?? runningConditions[0] ?? null;
}
export function createVisibleTickerCards(
runningConditions: ScheduledConditionRecord[],
rotationIndex: number,
nowMs: number
): TickerStackCard[] {
if (runningConditions.length === 0) {
return [];
}
const visibleCount = Math.min(runningConditions.length, TICKER_STACK_DEPTH);
return Array.from({ length: visibleCount }, (_, stackIndex) => {
const conditionIndex = (rotationIndex + stackIndex) % runningConditions.length;
const condition = runningConditions[conditionIndex] ?? runningConditions[0];
return {
condition,
view: createConditionStepView(condition, nowMs, runningConditions.length),
stackIndex
};
});
}
function createConditionStepView(
condition: ScheduledConditionRecord,
nowMs: number,
runningConditionCount: number
): ConditionStepTickerView {
const workflow = getRunningWorkflowDefinition(condition);
const workflowState = getRunningWorkflowState(condition, workflow, nowMs);
const currentStep = workflow.steps[workflowState.currentStepIndex];
const currentStepItem: ConditionStepTickerItem | null = currentStep
? {
id: currentStep.id,
title: `步骤 ${workflowState.currentStepIndex + 1}/${workflow.steps.length} ${currentStep.title}`,
detail: currentStep.detail
}
: null;
return {
title: runningConditionCount > 1
? `${workflow.title} · ${condition.title} · ${runningConditionCount} 个执行中`
: `${workflow.title} · ${condition.title}`,
progressLabel: `${workflowState.progress}%`,
progressValue: workflowState.progress,
currentStep: currentStepItem
};
}
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import type { ScheduledConditionRecord, ScheduledConditionTaskId } from "../types";
import { createVisibleTickerCards } from "./agent-task-ticker-utils";
function createRunningCondition(id: string, taskId: ScheduledConditionTaskId): ScheduledConditionRecord {
return {
id,
kind: "condition",
taskId,
scheduledAt: "2026-07-08T10:00:00+08:00",
title: id,
summary: "Running condition",
status: "running",
riskLevel: "normal",
updatedAt: Date.parse("2026-07-08T10:01:00+08:00"),
sessionId: id,
durationMinutes: 10
};
}
describe("createVisibleTickerCards", () => {
it("wraps the final running condition back to the first cards", () => {
const conditions = [
createRunningCondition("condition-a", "scada-diagnosis"),
createRunningCondition("condition-b", "smart-dispatch"),
createRunningCondition("condition-c", "pump-energy"),
createRunningCondition("condition-d", "network-simulation")
];
const cards = createVisibleTickerCards(
conditions,
conditions.length - 1,
Date.parse("2026-07-08T10:01:00+08:00")
);
expect(cards).toHaveLength(3);
expect(cards.map((card) => card.condition.id)).toEqual([
"condition-d",
"condition-a",
"condition-b"
]);
expect(cards.map((card) => card.stackIndex)).toEqual([0, 1, 2]);
});
it("does not duplicate the same task to fill stack slots", () => {
const conditions = [
createRunningCondition("condition-a", "scada-diagnosis")
];
const cards = createVisibleTickerCards(
conditions,
0,
Date.parse("2026-07-08T10:01:00+08:00")
);
expect(cards).toHaveLength(1);
expect(cards.map((card) => card.condition.id)).toEqual(["condition-a"]);
});
it("uses two stack slots for two running tasks", () => {
const conditions = [
createRunningCondition("condition-a", "scada-diagnosis"),
createRunningCondition("condition-b", "smart-dispatch")
];
const cards = createVisibleTickerCards(
conditions,
1,
Date.parse("2026-07-08T10:01:00+08:00")
);
expect(cards).toHaveLength(2);
expect(cards.map((card) => card.condition.id)).toEqual([
"condition-b",
"condition-a"
]);
expect(cards.map((card) => card.stackIndex)).toEqual([0, 1]);
});
});
@@ -0,0 +1,474 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, type Variants } from "motion/react";
import { cn } from "@/lib/utils";
import type {
ScheduledConditionRecord
} from "@/features/workbench/types";
import {
createVisibleTickerCards,
type TickerStackCard
} from "./agent-task-ticker-utils";
type AgentTaskTickerProps = {
conditions: ScheduledConditionRecord[];
className?: string;
};
type TickerTransitionDirection = -1 | 1;
const tickerEase = [0.22, 1, 0.36, 1] as const;
const tickerSwitchAnimationMs = 380;
const tickerCardSurfaceClassName =
"border border-white/75 bg-white/[0.92] ring-1 ring-slate-900/5 backdrop-blur-xl";
const tickerCardVariants: Variants = {
initial: (direction: TickerTransitionDirection) => ({
opacity: 0.62,
x: 0,
y: direction > 0 ? 36 : -28,
rotate: 0,
filter: "brightness(0.955) saturate(0.95)"
}),
exit: (direction: TickerTransitionDirection) => ({
opacity: [1, 1, 0],
x: 0,
y: direction > 0 ? 36 : -28,
rotate: 0,
filter: "brightness(1.02) saturate(1)",
transition: {
duration: 0.3,
times: [0, 0.65, 1],
ease: [0.5, 0, 0.2, 1]
}
})
};
const tickerContentVariants: Variants = {
initial: {
clipPath: "inset(0 100% 0 0)"
},
animate: {
clipPath: "inset(0 0% 0 0)",
transition: {
duration: 0.18,
ease: tickerEase
}
},
exit: {
clipPath: "inset(0 0 0 100%)",
transition: {
duration: 0.14,
ease: [0.5, 0, 0.2, 1]
}
}
};
const tickerIndicatorVariants: Variants = {
initial: {
opacity: 0,
x: 4,
scale: 0.96
},
animate: {
opacity: 1,
x: 0,
scale: 1,
transition: {
duration: 0.16,
ease: tickerEase
}
},
exit: {
opacity: 0,
x: 4,
scale: 0.96,
transition: {
duration: 0.12,
ease: [0.5, 0, 0.2, 1]
}
}
};
export function AgentTaskTicker({
conditions,
className
}: AgentTaskTickerProps) {
const sectionRef = useRef<HTMLElement | null>(null);
const isTaskSwitchLockedRef = useRef(false);
const taskSwitchUnlockTimeoutRef = useRef<number | null>(null);
const [nowMs, setNowMs] = useState(() => Date.now());
const [rotationIndex, setRotationIndex] = useState(0);
const [transitionDirection, setTransitionDirection] = useState<TickerTransitionDirection>(1);
const [taskSwitchAnimation, setTaskSwitchAnimation] = useState(() => ({
id: 0,
active: false
}));
const [isHovered, setIsHovered] = useState(false);
const runningConditions = conditions;
const visibleTickerCards = useMemo(
() => createVisibleTickerCards(runningConditions, rotationIndex, nowMs),
[runningConditions, rotationIndex, nowMs]
);
const hasMultipleRunningTasks = runningConditions.length > 1;
const activeIndicatorIndex = runningConditions.length > 0 ? rotationIndex % runningConditions.length : 0;
const tickerLabel = hasMultipleRunningTasks
? `工况任务步骤,${runningConditions.length} 个执行中,可滚动切换`
: "工况任务步骤";
const releaseTaskSwitchLock = useCallback(() => {
if (taskSwitchUnlockTimeoutRef.current !== null) {
window.clearTimeout(taskSwitchUnlockTimeoutRef.current);
taskSwitchUnlockTimeoutRef.current = null;
}
isTaskSwitchLockedRef.current = false;
}, []);
const finishTaskSwitchAnimation = useCallback(() => {
setTaskSwitchAnimation((current) => (
current.active ? { ...current, active: false } : current
));
}, []);
const lockTaskSwitch = useCallback(() => {
isTaskSwitchLockedRef.current = true;
if (taskSwitchUnlockTimeoutRef.current !== null) {
window.clearTimeout(taskSwitchUnlockTimeoutRef.current);
}
taskSwitchUnlockTimeoutRef.current = window.setTimeout(() => {
isTaskSwitchLockedRef.current = false;
taskSwitchUnlockTimeoutRef.current = null;
finishTaskSwitchAnimation();
}, tickerSwitchAnimationMs);
}, [finishTaskSwitchAnimation]);
const shiftTask = useCallback((direction: TickerTransitionDirection) => {
if (runningConditions.length <= 1 || isTaskSwitchLockedRef.current) {
return;
}
lockTaskSwitch();
setTransitionDirection(direction);
setTaskSwitchAnimation((current) => ({
id: current.id + 1,
active: true
}));
setRotationIndex((current) => (
(current + direction + runningConditions.length) % runningConditions.length
));
}, [lockTaskSwitch, runningConditions.length]);
const showPreviousTask = useCallback(() => {
shiftTask(-1);
}, [shiftTask]);
const showNextTask = useCallback(() => {
shiftTask(1);
}, [shiftTask]);
const handleWheel = useCallback((event: WheelEvent) => {
if (!hasMultipleRunningTasks || event.deltaY === 0) {
return;
}
event.preventDefault();
if (event.deltaY > 0) {
showNextTask();
return;
}
showPreviousTask();
}, [hasMultipleRunningTasks, showNextTask, showPreviousTask]);
useEffect(() => {
setNowMs(Date.now());
const intervalId = window.setInterval(() => {
setNowMs(Date.now());
}, 1_000);
return () => window.clearInterval(intervalId);
}, []);
useEffect(() => {
setRotationIndex(0);
releaseTaskSwitchLock();
finishTaskSwitchAnimation();
}, [finishTaskSwitchAnimation, releaseTaskSwitchLock, runningConditions.length]);
useEffect(() => releaseTaskSwitchLock, [releaseTaskSwitchLock]);
useEffect(() => {
const section = sectionRef.current;
if (!section) {
return;
}
section.addEventListener("wheel", handleWheel, { passive: false });
return () => section.removeEventListener("wheel", handleWheel);
}, [handleWheel]);
useEffect(() => {
if (!hasMultipleRunningTasks || isHovered) {
return;
}
const intervalId = window.setInterval(() => {
shiftTask(1);
}, 6_000);
return () => window.clearInterval(intervalId);
}, [hasMultipleRunningTasks, isHovered, runningConditions.length, shiftTask]);
if (visibleTickerCards.length === 0) {
return null;
}
return (
<section
ref={sectionRef}
className={cn(
"agent-task-ticker pointer-events-auto relative h-[78px] overflow-visible",
className
)}
aria-label={tickerLabel}
title={tickerLabel}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="absolute inset-0">
<AnimatePresence custom={transitionDirection} mode="popLayout" initial={false}>
{visibleTickerCards
.slice()
.reverse()
.map((card) => (
<TickerTaskCard
key={`ticker-stack-slot-${card.stackIndex}-${card.stackIndex === 0 ? taskSwitchAnimation.id : 0}`}
card={card}
isActive={card.stackIndex === 0}
animateContentChanges={card.stackIndex === 0 && !taskSwitchAnimation.active}
transitionDirection={transitionDirection}
/>
))}
</AnimatePresence>
<div className="pointer-events-none absolute right-3 top-8 z-40 -translate-y-1/2" aria-hidden="true">
<AnimatePresence initial={false}>
{isHovered && hasMultipleRunningTasks ? (
<motion.div
key="task-indicator"
className="agent-task-ticker-indicator flex flex-col gap-1 rounded-full bg-white/80 px-1 py-1 shadow-sm shadow-blue-950/10 backdrop-blur"
variants={tickerIndicatorVariants}
initial="initial"
animate="animate"
exit="exit"
>
{runningConditions.map((runningCondition, index) => (
<span
key={runningCondition.id}
className={cn(
"block h-1.5 w-1.5 rounded-sm transition-colors duration-150",
index === activeIndicatorIndex ? "bg-blue-600" : "bg-slate-300"
)}
/>
))}
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>
</section>
);
}
function TickerTaskCard({
card,
isActive,
animateContentChanges,
transitionDirection
}: {
card: TickerStackCard;
isActive: boolean;
animateContentChanges: boolean;
transitionDirection: TickerTransitionDirection;
}) {
const { condition, view, stackIndex } = card;
const stackStyle = getTickerStackStyle(stackIndex);
const contentKey = view.currentStep
? `${condition.id}-${view.currentStep.id}`
: `${condition.id}-pending`;
return (
<motion.article
layout="position"
className={cn(
"absolute inset-x-0 top-0 h-16 overflow-hidden rounded-[22px] px-3 py-2",
tickerCardSurfaceClassName,
isActive ? "z-30" : "pointer-events-none z-10"
)}
style={{ transformOrigin: "center top" }}
custom={transitionDirection}
variants={tickerCardVariants}
initial="initial"
animate={{
opacity: stackStyle.opacity,
x: stackStyle.x,
y: stackStyle.y,
rotate: stackStyle.rotate,
filter: stackStyle.filter,
transition: {
duration: isActive ? 0.34 : 0.3,
ease: tickerEase
}
}}
exit="exit"
aria-hidden={!isActive}
>
<div
className={cn(
"h-full",
isActive ? "opacity-100" : "opacity-0"
)}
>
<AnimatePresence key={condition.id} initial={false} mode="wait">
<motion.div
key={contentKey}
className="h-full overflow-hidden"
variants={tickerContentVariants}
initial={animateContentChanges ? "initial" : false}
animate={animateContentChanges ? "animate" : undefined}
exit={animateContentChanges ? "exit" : undefined}
>
<TickerTaskCardContent view={view} />
</motion.div>
</AnimatePresence>
</div>
</motion.article>
);
}
function TickerTaskCardContent({ view }: { view: TickerStackCard["view"] }) {
if (!view.currentStep) {
return (
<div className="grid h-full grid-cols-[14px_minmax(0,1fr)] items-center gap-3 pr-4">
<TickerStatusDot progressValue={view.progressValue} />
<div className="min-w-0">
<span className="block truncate text-[13px] font-semibold leading-5 text-slate-500">
{view.title}
</span>
<TickerProgressBar progressLabel={view.progressLabel} muted />
</div>
</div>
);
}
return (
<div className="grid h-full grid-cols-[14px_minmax(0,1fr)] items-center gap-3 pr-5">
<TickerStatusDot progressValue={view.progressValue} />
<div className="flex min-w-0 flex-col justify-center">
<div className="min-w-0 pr-1">
<span className="block truncate text-[13px] font-semibold leading-4 text-slate-500">
{view.title}
</span>
</div>
<div className="min-w-0" title={`${view.currentStep.title}${view.currentStep.detail}`}>
<div className="grid min-w-0 grid-cols-[auto_4px_minmax(0,1fr)] items-center gap-2">
<span className="min-w-0 truncate text-[13px] font-bold leading-5 text-slate-950">
{view.currentStep.title}
</span>
<span className="h-1 w-1 rounded-full bg-slate-300" aria-hidden="true" />
<span className="min-w-0 truncate text-[13px] font-medium leading-5 text-slate-500">
{view.currentStep.detail}
</span>
</div>
</div>
<TickerProgressBar progressLabel={view.progressLabel} />
</div>
</div>
);
}
function TickerProgressBar({
progressLabel,
muted = false
}: {
progressLabel: string;
muted?: boolean;
}) {
return (
<div
className={cn(
"mt-0.5 h-0.5 overflow-hidden rounded-full",
muted ? "bg-slate-200/60" : "bg-slate-200/80"
)}
aria-hidden="true"
>
<motion.span
className={cn("block h-full rounded-full", muted ? "bg-blue-300" : "bg-blue-500")}
animate={{ width: progressLabel }}
initial={false}
transition={{ duration: 0.34, ease: tickerEase }}
/>
</div>
);
}
function TickerStatusDot({ progressValue }: { progressValue?: number }) {
const progress = typeof progressValue === "number" ? Math.min(100, Math.max(0, progressValue)) : 0;
const dashOffset = 44 - (44 * progress) / 100;
return (
<span className="grid h-3.5 w-3.5 place-items-center" aria-hidden="true">
<svg className="h-3.5 w-3.5 -rotate-90" viewBox="0 0 16 16">
<circle cx="8" cy="8" r="7" fill="none" strokeWidth="2" className="stroke-blue-100/90" />
<motion.circle
cx="8"
cy="8"
r="7"
fill="none"
strokeWidth="2"
strokeLinecap="round"
className="stroke-blue-600"
strokeDasharray="44"
animate={{ strokeDashoffset: dashOffset }}
initial={false}
transition={{ duration: 0.34, ease: tickerEase }}
/>
<circle cx="8" cy="8" r="3.5" className="fill-blue-600" />
</svg>
</span>
);
}
function getTickerStackStyle(stackIndex: number) {
if (stackIndex === 1) {
return {
opacity: 1,
x: 0,
y: 7,
rotate: 0,
filter: "brightness(0.985) saturate(0.98)"
};
}
if (stackIndex === 2) {
return {
opacity: 1,
x: 0,
y: 14,
rotate: 0,
filter: "brightness(0.965) saturate(0.96)"
};
}
return {
opacity: 1,
x: 0,
y: 0,
rotate: 0,
filter: "brightness(1) saturate(1)"
};
}
@@ -0,0 +1,120 @@
"use client";
import { Target, TriangleAlert, X } from "lucide-react";
import { useMemo } from "react";
import type { DetailFeature } from "../types";
import {
formatFeaturePropertyValue,
getLocalizedFeatureProperties
} from "../utils/feature-properties";
type FeatureInsightPanelProps = {
feature: DetailFeature | null;
onClose: () => void;
};
export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelProps) {
const metrics = useMemo(() => {
if (!feature) {
return [];
}
if (feature.layer === "pipes") {
return [
["管径", formatFeaturePropertyValue("diameter", feature.properties.diameter)],
["长度", formatFeaturePropertyValue("length", feature.properties.length)],
["粗糙系数", formatFeaturePropertyValue("roughness", feature.properties.roughness)],
["状态", formatFeaturePropertyValue("status", feature.properties.status)]
];
}
return [
["高程", formatFeaturePropertyValue("elevation", feature.properties.elevation)],
["需水量", formatFeaturePropertyValue("demand", feature.properties.demand)],
["对象类型", "管网节点"],
["状态", "在线"]
];
}, [feature]);
const propertyEntries = useMemo(() => {
if (!feature) {
return [];
}
return getLocalizedFeatureProperties(feature.properties);
}, [feature]);
return (
<aside className="pointer-events-auto flex h-full min-h-0 flex-col rounded border border-white/60 bg-white/88 shadow-glass backdrop-blur">
<div className="border-b border-slate-200/80 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-medium tracking-[0.14em] text-slate-500"></p>
<h2 className="mt-1 text-lg font-semibold text-slate-950">
{feature ? feature.title : "选择一个管网对象"}
</h2>
</div>
<button
type="button"
aria-label="关闭详情"
title="关闭详情"
onClick={onClose}
className="grid h-9 w-9 place-items-center rounded border border-slate-200 bg-white text-slate-500 hover:text-slate-900"
>
<X size={16} aria-hidden="true" />
</button>
</div>
<p className="mt-2 text-sm text-slate-600">
{feature ? feature.subtitle : "点击地图上的管线或节点后,这里会展示属性、SCADA 摘要和 Agent 解释。"}
</p>
</div>
<div className="min-h-0 flex-1 overflow-auto p-4">
{feature ? (
<>
<div className="grid grid-cols-2 gap-2">
{metrics.map(([label, value]) => (
<div key={label} className="rounded border border-slate-200 bg-white p-3">
<p className="text-xs text-slate-500">{label}</p>
<p className="mt-1 truncate text-sm font-semibold text-slate-900">{value}</p>
</div>
))}
</div>
<section className="mt-4 rounded border border-orange-100 bg-orange-50/70 p-3">
<div className="flex items-center gap-2 text-sm font-semibold text-orange-900">
<TriangleAlert size={16} aria-hidden="true" />
Agent
</div>
<p className="mt-2 text-sm leading-6 text-slate-700">
GeoServer MVT
</p>
</section>
<section className="mt-4">
<h3 className="text-sm font-semibold text-slate-900"></h3>
<dl className="mt-2 divide-y divide-slate-100 rounded border border-slate-200 bg-white">
{propertyEntries.map((entry) => (
<div key={entry.key} className="grid grid-cols-[110px_1fr] gap-2 px-3 py-2 text-sm">
<dt className="truncate text-slate-500">{entry.label}</dt>
<dd className="truncate font-medium text-slate-800">{entry.value}</dd>
</div>
))}
</dl>
</section>
</>
) : (
<div className="flex h-full flex-col items-center justify-center text-center">
<div className="grid h-12 w-12 place-items-center rounded border border-slate-200 bg-white text-slate-500">
<Target size={22} aria-hidden="true" />
</div>
<p className="mt-3 text-sm font-medium text-slate-800"></p>
<p className="mt-1 max-w-[260px] text-sm leading-6 text-slate-500">
Agent
</p>
</div>
)}
</div>
</aside>
);
}
@@ -0,0 +1,55 @@
"use client";
import { X } from "lucide-react";
import {
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import { cn } from "@/lib/utils";
import type { DetailFeature } from "../types";
import { getLocalizedFeatureProperties } from "../utils/feature-properties";
type FeaturePopoverProps = {
feature: DetailFeature | null;
onClose: () => void;
};
export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
if (!feature) {
return null;
}
const entries = getLocalizedFeatureProperties(feature.properties).slice(0, 4);
return (
<aside className={cn("pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(360px,calc(100vw-32px))] -translate-x-1/2 border border-white/60 bg-white/70 p-4 shadow-2xl shadow-blue-950/20 backdrop-blur-2xl", MAP_MAJOR_PANEL_RADIUS_CLASS_NAME)}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold text-blue-600">{feature.layer === "pipes" ? "管线要素" : "节点要素"}</p>
<h3 className="mt-1 truncate text-base font-semibold text-slate-950">{feature.title}</h3>
<p className="mt-1 truncate text-sm text-slate-500">{feature.subtitle}</p>
</div>
<button
type="button"
aria-label="关闭要素信息"
title="关闭要素信息"
onClick={onClose}
className="grid h-8 w-8 shrink-0 place-items-center rounded-full text-slate-500 hover:bg-slate-100 hover:text-slate-900"
>
<X size={16} aria-hidden="true" />
</button>
</div>
{entries.length > 0 ? (
<dl className="mt-3 grid grid-cols-2 gap-2">
{entries.map((entry) => (
<div key={entry.key} className={cn("bg-white/95 px-3 py-2 shadow-lg shadow-blue-950/10", MAP_READABLE_RADIUS_CLASS_NAME)}>
<dt className="truncate text-xs font-medium text-slate-500">{entry.label}</dt>
<dd className="mt-1 truncate text-sm font-semibold text-slate-800">{entry.value}</dd>
</div>
))}
</dl>
) : null}
</aside>
);
}
@@ -0,0 +1,24 @@
type IconButtonProps = {
label: string;
children: React.ReactNode;
onClick?: () => void;
active?: boolean;
};
export function IconButton({ label, children, onClick, active = false }: IconButtonProps) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={`grid h-10 w-10 place-items-center rounded border transition ${
active
? "border-slate-800 bg-slate-900 text-white"
: "border-white/60 bg-white/80 text-slate-700 shadow-sm hover:bg-white"
}`}
>
{children}
</button>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,211 @@
import {
AlertTriangle,
CheckCircle2,
Clock3,
Loader2,
XCircle
} from "lucide-react";
import type {
ScheduledConditionItem,
ScheduledConditionStatus,
ScheduledWorkOrderItem,
ScheduledWorkOrderStage
} from "@/features/workbench/types";
export const CONDITION_FILTER_ALL = "__all__";
export const statusLabels: Record<ScheduledConditionStatus, string> = {
completed: "完成",
running: "执行中",
warning: "关注",
error: "异常",
pending: "预约"
};
export const statusClassNames: Record<ScheduledConditionStatus, string> = {
completed: "border-green-100 bg-green-50 text-green-700",
running: "border-blue-100 bg-blue-50 text-blue-700",
warning: "border-orange-100 bg-orange-50 text-orange-700",
error: "border-red-100 bg-red-50 text-red-700",
pending: "border-slate-200 bg-slate-100 text-slate-600"
};
const statusIcons: Record<ScheduledConditionStatus, typeof CheckCircle2> = {
completed: CheckCircle2,
running: Loader2,
warning: AlertTriangle,
error: XCircle,
pending: Clock3
};
const statusIconClassNames: Record<ScheduledConditionStatus, string> = {
completed: "bg-green-50 text-green-700",
running: "bg-blue-50 text-blue-700",
warning: "bg-orange-50 text-orange-700",
error: "bg-red-50 text-red-700",
pending: "bg-slate-100 text-slate-600"
};
const conditionStatusOrder: ScheduledConditionStatus[] = ["running", "warning", "error", "completed", "pending"];
export type ConditionTaskFilterValue = typeof CONDITION_FILTER_ALL | string;
export type ConditionStatusFilterValue = typeof CONDITION_FILTER_ALL | ScheduledConditionStatus;
export type ConditionTaskFilterOption = {
value: ConditionTaskFilterValue;
label: string;
count: number;
};
export type ConditionStatusFilterOption = {
value: ScheduledConditionStatus;
label: string;
count: number;
};
export function groupScheduledConditions(conditions: ScheduledConditionItem[]) {
const recentConditions = conditions
.filter((condition) => condition.kind === "condition")
.sort((a, b) => getConditionTime(b) - getConditionTime(a));
const futureWorkOrders = conditions
.filter((condition) => condition.kind === "work_order")
.sort((a, b) => getConditionTime(a) - getConditionTime(b));
return { recentConditions, futureWorkOrders };
}
export function createTaskFilterOptions(conditions: ScheduledConditionItem[]): ConditionTaskFilterOption[] {
const taskCounts = new Map<string, number>();
conditions.forEach((condition) => {
taskCounts.set(condition.title, (taskCounts.get(condition.title) ?? 0) + 1);
});
return [...taskCounts.entries()]
.map(([label, count]) => ({ value: label, label, count }))
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, "zh-CN"));
}
export function createStatusFilterOptions(conditions: ScheduledConditionItem[]): ConditionStatusFilterOption[] {
const statusCounts = new Map<ScheduledConditionStatus, number>();
conditions.forEach((condition) => {
statusCounts.set(condition.status, (statusCounts.get(condition.status) ?? 0) + 1);
});
return conditionStatusOrder.map((status) => ({
value: status,
label: statusLabels[status],
count: statusCounts.get(status) ?? 0
}));
}
export function getConditionTime(condition: ScheduledConditionItem) {
return Date.parse(condition.scheduledAt);
}
export function formatTime(value: string) {
const match = value.match(/T(\d{2}:\d{2})/);
return match?.[1] ?? value;
}
export function formatTimeRange(value: string, durationMinutes: number | undefined) {
const startTime = formatTime(value);
if (typeof durationMinutes !== "number") {
return startTime;
}
const endDate = new Date(Date.parse(value) + durationMinutes * 60_000);
const endTime = `${endDate.getHours().toString().padStart(2, "0")}:${endDate.getMinutes().toString().padStart(2, "0")}`;
return `${startTime} - ${endTime}`;
}
export function formatDuration(durationMinutes: number | undefined, status: ScheduledConditionStatus) {
if (status === "running") {
return "执行中";
}
if (typeof durationMinutes !== "number") {
return "未记录";
}
return `${durationMinutes} 分钟`;
}
export function getRiskLabel(riskLevel: ScheduledConditionItem["riskLevel"]) {
if (riskLevel === "critical") {
return "高风险";
}
if (riskLevel === "attention") {
return "关注";
}
return "正常";
}
export function getKpiStatusClassName(riskLevel: ScheduledConditionItem["riskLevel"]) {
if (riskLevel === "critical") {
return "border-red-100 bg-red-50 text-red-700";
}
if (riskLevel === "attention") {
return "border-orange-100 bg-orange-50 text-orange-700";
}
return "border-green-100 bg-green-50 text-green-700";
}
export function getWorkOrderCurrentStage(condition: ScheduledWorkOrderItem) {
return (
condition.stages.find((stage) => stage.status === "current") ??
condition.stages.find((stage) => stage.status === "pending") ??
condition.stages.at(-1) ??
null
);
}
export function getWorkOrderStagePillClassName(stageId?: ScheduledWorkOrderStage["id"]) {
if (stageId === "execution") {
return "border-blue-100 bg-blue-50 text-blue-700";
}
if (stageId === "reply") {
return "border-green-100 bg-green-50 text-green-700";
}
return "border-indigo-100 bg-indigo-50 text-indigo-700";
}
export function getDetailAccentClassName(status: ScheduledConditionStatus, isWorkOrder: boolean) {
if (isWorkOrder) {
return "bg-blue-500";
}
if (status === "completed") {
return "bg-green-500";
}
if (status === "running") {
return "bg-blue-500";
}
if (status === "warning") {
return "bg-orange-500";
}
if (status === "error") {
return "bg-red-500";
}
return "bg-slate-400";
}
export function getStatusIcon(status: ScheduledConditionStatus) {
return statusIcons[status];
}
export function getStatusIconClassName(status: ScheduledConditionStatus) {
return statusIconClassNames[status];
}
@@ -0,0 +1,753 @@
"use client";
import {
CalendarClock,
ChevronDown,
ChevronRight,
ClipboardList,
History,
XCircle
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import {
MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import { showMapNotice } from "@/features/map/core/components/notice";
import { isGeneratedScheduledConditionSessionId } from "@/features/workbench/data/scheduled-conditions";
import { createConditionConversationPrompt } from "@/features/workbench/utils/scheduled-condition-prompts";
import type { ScheduledConditionItem } from "@/features/workbench/types";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { ConditionDetailPanel, StatusPill } from "./scheduled-condition-detail-panel";
import {
CONDITION_FILTER_ALL,
createStatusFilterOptions,
createTaskFilterOptions,
formatTime,
getConditionTime,
getStatusIcon,
getStatusIconClassName,
groupScheduledConditions,
type ConditionStatusFilterOption,
type ConditionStatusFilterValue,
type ConditionTaskFilterOption,
type ConditionTaskFilterValue
} from "./scheduled-condition-feed-utils";
type ScheduledConditionFeedProps = {
conditions?: ScheduledConditionItem[];
expanded?: boolean;
focusRequest?: ScheduledConditionFeedFocusRequest | null;
loading?: boolean;
selectedConditionId?: string | null;
onExpandedChange?: (expanded: boolean) => void;
onFocusRequestHandled?: (requestId: number) => void;
onSelectedConditionChange?: (conditionId: string | null) => void;
onExpandAgent: () => void;
onLoadHistorySession: (sessionId: string) => void | Promise<void>;
onSubmitPrompt: (prompt: string) => void | Promise<void>;
};
type ScheduledConditionFeedFocusRequest = {
conditionId: string;
requestId: number;
};
export function ScheduledConditionFeed({
conditions = [],
expanded: controlledExpanded,
focusRequest,
loading = false,
selectedConditionId: controlledSelectedConditionId,
onExpandedChange,
onFocusRequestHandled,
onSelectedConditionChange,
onExpandAgent,
onLoadHistorySession,
onSubmitPrompt
}: ScheduledConditionFeedProps) {
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<string | null>(null);
const [taskFilter, setTaskFilter] = useState<ConditionTaskFilterValue>(CONDITION_FILTER_ALL);
const [statusFilter, setStatusFilter] = useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
const expanded = controlledExpanded ?? uncontrolledExpanded;
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
() => groupScheduledConditions(conditions),
[conditions]
);
const allTimelineConditions = useMemo(
() => [...allRecentConditions, ...allFutureWorkOrders],
[allFutureWorkOrders, allRecentConditions]
);
const taskFilterOptions = useMemo(() => createTaskFilterOptions(conditions), [conditions]);
const taskFilteredConditions = useMemo(
() => conditions.filter((condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter),
[conditions, taskFilter]
);
const statusFilterOptions = useMemo(() => createStatusFilterOptions(taskFilteredConditions), [taskFilteredConditions]);
const taskFilteredConditionCount = taskFilteredConditions.length;
const filteredConditions = useMemo(
() =>
taskFilteredConditions.filter(
(condition) => statusFilter === CONDITION_FILTER_ALL || condition.status === statusFilter
),
[statusFilter, taskFilteredConditions]
);
const { recentConditions, futureWorkOrders } = useMemo(() => groupScheduledConditions(filteredConditions), [filteredConditions]);
const timelineConditions = useMemo(
() => [...recentConditions, ...futureWorkOrders],
[futureWorkOrders, recentConditions]
);
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
const totalConditionCount = allRecentConditions.length + allFutureWorkOrders.length;
const filteredConditionCount = recentConditions.length + futureWorkOrders.length;
const headerSummary = loading
? "正在加载工况任务"
: expanded && filterActive
? `筛选 ${filteredConditionCount}/${totalConditionCount} 条 · 预约 ${futureWorkOrders.length}/${allFutureWorkOrders.length}`
: expanded
? `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length}`
: `最近 ${recentConditions.length}`;
const selectedCondition =
timelineConditions.find((condition) => condition.id === selectedConditionId) ??
recentConditions[0] ??
futureWorkOrders[0] ??
null;
const focusConditionId = focusRequest?.conditionId;
const focusRequestId = focusRequest?.requestId;
const updateExpanded = useCallback(
(nextExpanded: boolean) => {
if (controlledExpanded === undefined) {
setUncontrolledExpanded(nextExpanded);
}
onExpandedChange?.(nextExpanded);
},
[controlledExpanded, onExpandedChange]
);
const updateSelectedConditionId = useCallback(
(nextConditionId: string | null) => {
if (controlledSelectedConditionId === undefined) {
setUncontrolledSelectedConditionId(nextConditionId);
}
onSelectedConditionChange?.(nextConditionId);
},
[controlledSelectedConditionId, onSelectedConditionChange]
);
useEffect(() => {
if (taskFilter === CONDITION_FILTER_ALL) {
return;
}
if (!taskFilterOptions.some((option) => option.value === taskFilter)) {
setTaskFilter(CONDITION_FILTER_ALL);
}
}, [taskFilter, taskFilterOptions]);
useEffect(() => {
if (!expanded) {
return;
}
const nextSelectedConditionId = selectedCondition?.id ?? null;
if (selectedConditionId !== nextSelectedConditionId) {
updateSelectedConditionId(nextSelectedConditionId);
}
}, [expanded, selectedCondition?.id, selectedConditionId, updateSelectedConditionId]);
useEffect(() => {
if (!focusConditionId || focusRequestId === undefined) {
return;
}
const targetCondition = allTimelineConditions.find((condition) => condition.id === focusConditionId);
if (!targetCondition) {
return;
}
setTaskFilter(CONDITION_FILTER_ALL);
setStatusFilter(CONDITION_FILTER_ALL);
updateSelectedConditionId(targetCondition.id);
updateExpanded(true);
onFocusRequestHandled?.(focusRequestId);
}, [allTimelineConditions, focusConditionId, focusRequestId, onFocusRequestHandled, updateExpanded, updateSelectedConditionId]);
function handleToggleExpanded() {
updateExpanded(!expanded);
updateSelectedConditionId(selectedConditionId ?? recentConditions[0]?.id ?? futureWorkOrders[0]?.id ?? null);
}
function handleSelectTimelineCondition(conditionId: string) {
updateSelectedConditionId(conditionId);
if (!expanded) {
updateExpanded(true);
}
}
function handleContinueConversation(condition: ScheduledConditionItem) {
if (condition.kind === "work_order") {
return;
}
onExpandAgent();
if (!condition.sessionId) {
showMapNotice({
id: "scheduled-condition-missing-session",
tone: "error",
title: "无法打开历史会话",
message: "这条工况没有可加载的会话记录。"
});
return;
}
if (isGeneratedScheduledConditionSessionId(condition.sessionId)) {
void Promise.resolve(onSubmitPrompt(createConditionConversationPrompt(condition)))
.then(() => {
showMapNotice({
id: "scheduled-condition-generated-session",
tone: "success",
title: "已发起工况对话",
message: "已将当前工况上下文发送到 Agent 面板。"
});
})
.catch((submitError) => {
showMapNotice({
id: "scheduled-condition-generated-session",
tone: "error",
title: "工况对话发起失败",
message: submitError instanceof Error ? submitError.message : "无法将当前工况发送到 Agent。"
});
});
return;
}
void Promise.resolve(onLoadHistorySession(condition.sessionId));
}
return (
<section
aria-label="工况任务"
className={cn(
"scheduled-feed-panel-shell pointer-events-auto max-w-[calc(100vw-6rem)] overflow-hidden p-3 motion-reduce:transition-none",
expanded ? "flex max-h-[calc(100dvh-8rem)] w-[880px] flex-col 2xl:w-[960px]" : "w-[432px]",
"rounded-2xl",
MAP_CONTROL_SURFACE_CLASS_NAME
)}
>
<header className={cn("flex items-center gap-2.5 px-2.5 py-2", "rounded-xl", MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}>
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", "rounded-lg")}>
<CalendarClock size={17} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950"></h2>
<p className="mt-0.5 truncate text-xs text-slate-500">{headerSummary}</p>
</div>
<button
type="button"
aria-expanded={expanded}
onClick={handleToggleExpanded}
className={cn(
"grid h-8 w-8 shrink-0 place-items-center text-slate-500 transition hover:bg-blue-50 hover:text-blue-700",
"rounded-lg"
)}
>
<ChevronDown
size={17}
aria-hidden="true"
className={cn("transition-transform duration-150", expanded && "rotate-180")}
/>
<span className="sr-only">{expanded ? "收起工况任务" : "展开工况任务"}</span>
</button>
</header>
<div
className={cn(
"scheduled-feed-layout mt-3 grid min-h-0",
expanded ? "scheduled-feed-layout-expanded" : "scheduled-feed-layout-collapsed"
)}
>
<div
className={cn(
"min-h-0 min-w-0 overflow-hidden",
expanded ? "scheduled-feed-detail-enter" : "pointer-events-none opacity-0"
)}
aria-hidden={!expanded}
>
{loading && expanded ? (
<ConditionDetailSkeleton />
) : expanded && selectedCondition ? (
<ConditionDetailPanel
condition={selectedCondition}
onContinueConversation={() => handleContinueConversation(selectedCondition)}
/>
) : null}
</div>
<div className="min-h-0 min-w-0 overflow-hidden">
<ConditionTimelinePanel
expanded={expanded}
loading={loading}
recentConditions={recentConditions}
futureWorkOrders={futureWorkOrders}
taskFilter={taskFilter}
statusFilter={statusFilter}
taskFilterOptions={taskFilterOptions}
statusFilterOptions={statusFilterOptions}
selectedConditionId={expanded ? selectedCondition?.id ?? null : null}
totalConditionCount={totalConditionCount}
taskFilteredConditionCount={taskFilteredConditionCount}
filteredConditionCount={filteredConditionCount}
onTaskFilterChange={setTaskFilter}
onStatusFilterChange={setStatusFilter}
onResetFilters={() => {
setTaskFilter(CONDITION_FILTER_ALL);
setStatusFilter(CONDITION_FILTER_ALL);
}}
onSelectCondition={handleSelectTimelineCondition}
/>
</div>
</div>
</section>
);
}
type ConditionTimelinePanelProps = {
expanded: boolean;
loading: boolean;
recentConditions: ScheduledConditionItem[];
futureWorkOrders: ScheduledConditionItem[];
taskFilter: ConditionTaskFilterValue;
statusFilter: ConditionStatusFilterValue;
taskFilterOptions: ConditionTaskFilterOption[];
statusFilterOptions: ConditionStatusFilterOption[];
selectedConditionId: string | null;
totalConditionCount: number;
taskFilteredConditionCount: number;
filteredConditionCount: number;
onTaskFilterChange: (value: ConditionTaskFilterValue) => void;
onStatusFilterChange: (value: ConditionStatusFilterValue) => void;
onResetFilters: () => void;
onSelectCondition: (conditionId: string) => void;
};
function ConditionTimelinePanel({
expanded,
loading,
recentConditions,
futureWorkOrders,
taskFilter,
statusFilter,
taskFilterOptions,
statusFilterOptions,
selectedConditionId,
totalConditionCount,
taskFilteredConditionCount,
filteredConditionCount,
onTaskFilterChange,
onStatusFilterChange,
onResetFilters,
onSelectCondition
}: ConditionTimelinePanelProps) {
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
return (
<section
aria-busy={loading}
className={cn(
"flex min-h-0 flex-col overflow-hidden p-2.5",
expanded ? "h-[calc(100dvh-14rem)] max-h-[calc(100dvh-14rem)]" : "h-[420px] max-h-[420px]",
"rounded-xl",
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
)}
>
{expanded && !loading ? (
<ConditionFilterBar
taskFilter={taskFilter}
statusFilter={statusFilter}
taskFilterOptions={taskFilterOptions}
statusFilterOptions={statusFilterOptions}
filteredConditionCount={filteredConditionCount}
totalConditionCount={totalConditionCount}
taskFilteredConditionCount={taskFilteredConditionCount}
onTaskFilterChange={onTaskFilterChange}
onStatusFilterChange={onStatusFilterChange}
onResetFilters={onResetFilters}
/>
) : null}
{expanded && !loading && futureWorkOrders.length > 0 ? (
<section className="mb-2 rounded-xl border border-blue-100 bg-blue-50/55 p-2">
<ConditionGroupHeader icon={ClipboardList} title="预约任务" count={futureWorkOrders.length} compact />
<div className="scheduled-feed-scroll max-h-[132px] overflow-y-auto pr-1">
<ConditionRows
conditions={futureWorkOrders}
selectedConditionId={selectedConditionId}
onSelectCondition={onSelectCondition}
/>
</div>
</section>
) : null}
<ConditionGroupHeader icon={History} title="最近工况" count={loading ? 0 : recentConditions.length} />
<div className="scheduled-feed-scroll -mr-2 min-h-0 flex-1 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5">
{loading ? (
<ConditionTimelineSkeleton rows={expanded ? 7 : 6} />
) : recentConditions.length > 0 ? (
<ConditionRows
conditions={recentConditions}
selectedConditionId={selectedConditionId}
onSelectCondition={onSelectCondition}
/>
) : (
<ConditionEmptyState
title={filterActive ? "没有符合筛选的最近工况" : "暂无最近工况"}
description={filterActive ? "调整任务或状态筛选后查看其他工况记录。" : "新的工况任务完成后会出现在这里。"}
/>
)}
</div>
</section>
);
}
type ConditionFilterBarProps = {
taskFilter: ConditionTaskFilterValue;
statusFilter: ConditionStatusFilterValue;
taskFilterOptions: ConditionTaskFilterOption[];
statusFilterOptions: ConditionStatusFilterOption[];
filteredConditionCount: number;
totalConditionCount: number;
taskFilteredConditionCount: number;
onTaskFilterChange: (value: ConditionTaskFilterValue) => void;
onStatusFilterChange: (value: ConditionStatusFilterValue) => void;
onResetFilters: () => void;
};
function ConditionFilterBar({
taskFilter,
statusFilter,
taskFilterOptions,
statusFilterOptions,
filteredConditionCount,
totalConditionCount,
taskFilteredConditionCount,
onTaskFilterChange,
onStatusFilterChange,
onResetFilters
}: ConditionFilterBarProps) {
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
return (
<div className="mb-2 rounded-xl border border-slate-200/70 bg-slate-50/95 p-2 ring-1 ring-white/80">
<div className="mb-2 flex items-center justify-between gap-2 px-1">
<span className="text-xs font-semibold text-slate-700"></span>
<span className="shrink-0 text-xs font-semibold text-slate-400">
{filteredConditionCount}/{totalConditionCount}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<ConditionFilterDropdown
label="任务"
value={taskFilter}
onValueChange={onTaskFilterChange}
options={[
{ value: CONDITION_FILTER_ALL, label: "全部任务", count: totalConditionCount },
...taskFilterOptions
]}
/>
<ConditionFilterDropdown
label="状态"
value={statusFilter}
onValueChange={(value) => onStatusFilterChange(value as ConditionStatusFilterValue)}
options={[
{ value: CONDITION_FILTER_ALL, label: "全部状态", count: taskFilteredConditionCount },
...statusFilterOptions
]}
/>
</div>
{filterActive ? (
<button
type="button"
onClick={onResetFilters}
className="mt-2 inline-flex h-7 w-full items-center justify-center gap-1.5 rounded-lg border border-blue-100 bg-white px-2 text-xs font-semibold text-blue-700 shadow-sm transition hover:border-blue-200 hover:bg-blue-50"
>
<XCircle size={12} aria-hidden="true" />
</button>
) : null}
</div>
);
}
type ConditionFilterDropdownProps<TValue extends string> = {
label: string;
value: TValue;
options: {
value: TValue;
label: string;
count: number;
}[];
onValueChange: (value: TValue) => void;
};
function ConditionFilterDropdown<TValue extends string>({
label,
value,
options,
onValueChange
}: ConditionFilterDropdownProps<TValue>) {
const selectedOption = options.find((option) => option.value === value) ?? options[0];
return (
<div className="min-w-0">
<span className="mb-1 block text-xs font-semibold text-slate-400">{label}</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"group flex h-9 w-full min-w-0 items-center gap-2 border border-white/80 bg-white px-2 text-left text-xs shadow-sm outline-none transition hover:border-blue-100 hover:bg-blue-50/35 focus-visible:border-blue-200 focus-visible:ring-2 focus-visible:ring-blue-100 data-[state=open]:border-blue-200 data-[state=open]:bg-blue-50/60 data-[state=open]:ring-2 data-[state=open]:ring-blue-100",
"rounded-xl"
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-slate-800" title={selectedOption?.label}>
{selectedOption?.label ?? "全部"}
</span>
<span className="block text-xs leading-3 text-slate-400">{selectedOption?.count ?? 0} </span>
</span>
<ChevronDown
size={13}
aria-hidden="true"
className="shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={6}
className="scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border border-white/80 bg-white p-1.5 text-slate-700 shadow-xl shadow-slate-900/15 backdrop-blur-xl"
>
<DropdownMenuRadioGroup value={value} onValueChange={(nextValue) => onValueChange(nextValue as TValue)}>
{options.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
className={cn(
"my-0.5 min-h-9 gap-2 border border-transparent py-1.5 pl-7 pr-2 text-xs transition focus:bg-blue-50/80 focus:text-blue-800 data-[state=checked]:border-blue-100 data-[state=checked]:bg-blue-50/90 data-[state=checked]:text-blue-800",
"rounded-xl"
)}
>
<span className="min-w-0 flex-1 truncate font-semibold">{option.label}</span>
<span className="shrink-0 rounded-full border border-slate-200 bg-white px-1.5 py-0.5 text-xs font-semibold leading-4 text-slate-500">
{option.count}
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
function ConditionEmptyState({ title, description }: { title: string; description: string }) {
return (
<div className="flex min-h-full flex-col items-center justify-center rounded-xl border border-dashed border-slate-200 bg-white/65 px-4 py-5 text-center">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-slate-100 text-slate-400">
<ChevronRight size={15} aria-hidden="true" />
</span>
<p className="mt-2 text-xs font-semibold text-slate-700">{title}</p>
<p className="mt-1 text-xs leading-4 text-slate-500">{description}</p>
</div>
);
}
function ConditionTimelineSkeleton({ rows }: { rows: number }) {
return (
<div className="space-y-1.5" role="status" aria-label="工况任务加载中">
{Array.from({ length: rows }).map((_, index) => (
<div
key={index}
className="grid min-h-[60px] grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl px-1 py-1"
>
<span className="mt-2 h-4 w-9 animate-pulse rounded-lg bg-slate-200/80" />
<span className="relative flex min-h-12 justify-center pt-1.5">
{index < rows - 1 ? (
<span className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200" aria-hidden="true" />
) : null}
<span className="relative z-10 h-6 w-6 animate-pulse rounded-full bg-slate-200 ring-4 ring-white" />
</span>
<span className="min-w-0 rounded-xl border border-white/80 bg-white/95 px-2 py-1.5">
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 space-y-1.5">
<span className="block h-3.5 w-24 animate-pulse rounded-full bg-slate-200/90" />
<span className="block h-3 w-full max-w-[260px] animate-pulse rounded-full bg-slate-100" />
</span>
<span className="h-5 w-12 shrink-0 animate-pulse rounded-full bg-slate-100" />
</span>
</span>
</div>
))}
</div>
);
}
function ConditionDetailSkeleton() {
return (
<DetailScroll>
<div
className={cn(
"relative min-h-[420px] overflow-hidden border border-slate-200/80 bg-white/95 px-4 py-3",
"rounded-xl"
)}
role="status"
aria-label="工况详情加载中"
>
<div className="absolute inset-y-0 left-0 w-1 bg-blue-300" aria-hidden="true" />
<div className="flex gap-2.5">
<span className={cn("h-9 w-9 shrink-0 animate-pulse bg-slate-100", "rounded-lg")} />
<div className="min-w-0 flex-1 space-y-2">
<div className="flex gap-2">
<span className="h-4 w-12 animate-pulse rounded-full bg-slate-200" />
<span className="h-5 w-14 animate-pulse rounded-full bg-blue-100" />
</div>
<span className="block h-4 w-44 animate-pulse rounded-full bg-slate-200" />
<span className="block h-3 w-full animate-pulse rounded-full bg-slate-100" />
<span className="block h-3 w-4/5 animate-pulse rounded-full bg-slate-100" />
</div>
</div>
<div className="mt-4 grid gap-2">
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="rounded-xl border border-slate-200/70 bg-slate-50/80 px-3 py-3">
<span className="block h-3.5 w-28 animate-pulse rounded-full bg-slate-200" />
<span className="mt-2 block h-3 w-full animate-pulse rounded-full bg-slate-100" />
<span className="mt-1.5 block h-3 w-3/4 animate-pulse rounded-full bg-slate-100" />
</div>
))}
</div>
</div>
</DetailScroll>
);
}
function DetailScroll({ children }: { children: React.ReactNode }) {
return (
<section className="scheduled-feed-scroll scheduled-feed-detail-scroll max-h-[calc(100dvh-14rem)] min-h-0 overflow-y-scroll py-0.5 pl-0.5 pr-3">
{children}
</section>
);
}
type ConditionGroupHeaderProps = {
icon: typeof History;
title: string;
count: number;
compact?: boolean;
};
function ConditionGroupHeader({ icon: Icon, title, count, compact = false }: ConditionGroupHeaderProps) {
return (
<div className={cn("flex items-center justify-between px-1 py-1 text-xs font-semibold text-slate-500", compact ? "mb-1" : "mb-2")}>
<span className="flex items-center gap-1.5">
<Icon size={13} aria-hidden="true" />
<span>{title}</span>
</span>
<span>{count}</span>
</div>
);
}
type ConditionRowsProps = {
conditions: ScheduledConditionItem[];
selectedConditionId: string | null;
onSelectCondition: (conditionId: string) => void;
};
function ConditionRows({ conditions, selectedConditionId, onSelectCondition }: ConditionRowsProps) {
return (
<div className="space-y-1.5">
{conditions.map((condition, index) => (
<ConditionTimelineRow
key={condition.id}
condition={condition}
selected={selectedConditionId === condition.id}
isLast={index === conditions.length - 1}
onSelect={() => onSelectCondition(condition.id)}
/>
))}
</div>
);
}
type ConditionTimelineRowProps = {
condition: ScheduledConditionItem;
selected: boolean;
isLast: boolean;
onSelect: () => void;
};
function ConditionTimelineRow({ condition, selected, isLast, onSelect }: ConditionTimelineRowProps) {
const StatusIcon = getStatusIcon(condition.status);
const isWorkOrder = condition.kind === "work_order";
return (
<button
type="button"
aria-pressed={selected}
onClick={onSelect}
className={cn(
"group grid min-h-[60px] w-full grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl px-1 py-1 text-left outline-none transition-colors focus-visible:bg-blue-50/40 focus-visible:ring-2 focus-visible:ring-blue-200/80 focus-visible:ring-offset-1 focus-visible:ring-offset-white",
selected ? "bg-blue-50/70" : "hover:bg-blue-50/45"
)}
>
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-slate-500">{formatTime(condition.scheduledAt)}</span>
<span
className="relative flex min-h-12 justify-center pt-1.5"
>
{!isLast ? (
<span
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200 transition-colors group-hover:bg-blue-200 group-focus-visible:bg-blue-300"
aria-hidden="true"
/>
) : null}
<span
className={cn(
"relative z-10 grid h-6 w-6 place-items-center rounded-full ring-4 ring-white",
selected
? "bg-blue-600 text-white group-focus-visible:ring-blue-100"
: isWorkOrder
? "bg-blue-50 text-blue-700 group-focus-visible:bg-blue-100 group-focus-visible:text-blue-800"
: getStatusIconClassName(condition.status)
)}
>
<StatusIcon size={12} aria-hidden="true" className={condition.status === "running" ? "animate-spin" : undefined} />
</span>
</span>
<span
className={cn(
"min-w-0 rounded-xl border px-2 py-1.5 transition-colors group-focus-visible:border-blue-300 group-focus-visible:bg-white",
selected
? "border-blue-300 bg-white text-blue-900 shadow-sm shadow-blue-600/10 ring-1 ring-blue-300/40"
: "border-white/80 bg-white/95 text-slate-700 group-hover:border-blue-100 group-hover:bg-white"
)}
>
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold leading-4">{condition.title}</span>
<span className="block truncate text-xs leading-4 text-slate-500">{condition.summary}</span>
</span>
<StatusPill condition={condition} className="shrink-0 text-xs" />
</span>
</span>
</button>
);
}
@@ -0,0 +1,440 @@
import {
Activity,
Camera,
Circle,
Copy,
Database,
Dot,
Download,
Keyboard,
Layers3,
MapPinned,
Pentagon,
Redo2,
RefreshCw,
Route,
Ruler,
Share2,
Trash2,
Undo2,
Waypoints,
type LucideIcon
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
MapAnnotationPanel,
type MapAnnotationItem,
type MapAnnotationShareAction
} from "@/features/map/core/components/annotation-panel";
import type { BaseLayerOption } from "@/features/map/core/components/base-layers-control";
import {
MapActionRow,
MapControlPanel,
MapMetricTile,
MapPanelSection
} from "@/features/map/core/components/control-panel";
import { MapDrawToolbar } from "@/features/map/core/components/draw-toolbar";
import { MapLayerPanel } from "@/features/map/core/components/layer-panel";
import type { MapLayerControlItem } from "@/features/map/core/components/layer-control";
import { MapLegendList, type MapLegendItem } from "@/features/map/core/components/legend";
import { MapMeasurePanel } from "@/features/map/core/components/measure-panel";
import {
type WorkbenchDrawingAnnotation,
type WorkbenchDrawMode
} from "../hooks/use-workbench-drawing";
import {
type WorkbenchMeasurementResult,
type WorkbenchMeasureMode,
type WorkbenchMeasureUnit
} from "../hooks/use-workbench-measurement";
import { waterNetworkToolbarItems } from "../map/toolbar-config";
export type ToolbarToolId = "layers" | "measure" | "analysis" | "annotation" | "more";
export type ExportViewPreset = "current" | "4k";
type MeasureModeOption = {
id: WorkbenchMeasureMode;
label: string;
icon: LucideIcon;
};
const MEASURE_MODES: MeasureModeOption[] = [
{ id: "distance", label: "距离", icon: Route },
{ id: "area", label: "面积", icon: Pentagon },
{ id: "segment", label: "管段", icon: Waypoints }
];
const MEASURE_UNITS: Array<{ id: WorkbenchMeasureUnit; label: string }> = [
{ id: "m", label: "m" },
{ id: "km", label: "km" }
];
const ANNOTATION_SHARE_ACTIONS: MapAnnotationShareAction[] = [
{
id: "export",
icon: Share2,
label: "导出标注",
description: "GeoJSON / 截图报告"
}
];
const TOOL_PANEL_EXIT_MS = 160;
const DRAW_TOOL_OPTIONS: Array<{
id: WorkbenchDrawMode | "undo" | "redo" | "delete" | "clear";
label: string;
icon: LucideIcon;
tone?: "danger";
}> = [
{ id: "point", label: "点标注", icon: Dot },
{ id: "line", label: "线标注", icon: Route },
{ id: "polygon", label: "范围标注", icon: Pentagon },
{ id: "circle", label: "圆形标注", icon: Circle },
{ id: "undo", label: "撤销", icon: Undo2 },
{ id: "redo", label: "重做", icon: Redo2 },
{ id: "delete", label: "删除选中", icon: Trash2, tone: "danger" },
{ id: "clear", label: "清空全部", icon: Trash2, tone: "danger" }
];
export type ToolbarPanelProps = {
activeToolId: ToolbarToolId | null;
activeMeasureModeId: WorkbenchMeasureMode;
activeMeasureUnitId: WorkbenchMeasureUnit;
measuring: boolean;
measureResult: WorkbenchMeasurementResult;
measureEnabled: boolean;
hasMeasurePoints: boolean;
activeDrawToolId: WorkbenchDrawMode | null;
drawingEnabled: boolean;
drawingAnnotations: WorkbenchDrawingAnnotation[];
canUndoDrawing: boolean;
canRedoDrawing: boolean;
canDeleteSelectedDrawing: boolean;
hasDrawingFeatures: boolean;
layerItems: MapLayerControlItem[];
legendItems: MapLegendItem[];
baseLayerOptions: BaseLayerOption[];
activeBaseLayerId: string;
onSelectMeasureMode: (id: WorkbenchMeasureMode) => void;
onSelectMeasureUnit: (id: WorkbenchMeasureUnit) => void;
onStartMeasure: () => void;
onStopMeasure: () => void;
onClearMeasure: () => void;
onCopyMeasure: () => void;
onSelectDrawTool: (id: WorkbenchDrawMode | null) => void;
onUndoDrawing: () => void;
onRedoDrawing: () => void;
onDeleteSelectedDrawing: () => void;
onClearDrawing: () => void;
onExportAnnotations: () => void;
onSelectBaseLayer: (id: string) => void;
onToggleLayer: (id: string, visible: boolean) => void;
onExportView: (preset: ExportViewPreset) => void;
onRefreshTiles: () => void;
onShowDataStatus: () => void;
onShowShortcuts: () => void;
onExportConfig: () => void;
panelWidthClassName?: string;
};
export function ToolbarPanel({
activeToolId,
activeMeasureModeId,
activeMeasureUnitId,
measuring,
measureResult,
measureEnabled,
hasMeasurePoints,
activeDrawToolId,
drawingEnabled,
drawingAnnotations,
canUndoDrawing,
canRedoDrawing,
canDeleteSelectedDrawing,
hasDrawingFeatures,
layerItems,
legendItems,
baseLayerOptions,
activeBaseLayerId,
onSelectMeasureMode,
onSelectMeasureUnit,
onStartMeasure,
onStopMeasure,
onClearMeasure,
onCopyMeasure,
onSelectDrawTool,
onUndoDrawing,
onRedoDrawing,
onDeleteSelectedDrawing,
onClearDrawing,
onExportAnnotations,
onSelectBaseLayer,
onToggleLayer,
onExportView,
onRefreshTiles,
onShowDataStatus,
onShowShortcuts,
onExportConfig,
panelWidthClassName
}: ToolbarPanelProps) {
const annotationItems = useMemo<MapAnnotationItem[]>(
() =>
drawingAnnotations.map((annotation) => ({
id: annotation.id,
icon: getDrawingAnnotationIcon(annotation.kind),
label: annotation.label,
description: annotation.description,
status: annotation.hidden ? "隐藏" : "显示",
selected: annotation.selected,
muted: annotation.hidden,
onClick: annotation.onToggleVisibility
})),
[drawingAnnotations]
);
const annotationShareActions = useMemo(
() =>
ANNOTATION_SHARE_ACTIONS.map((action) => ({
...action,
onClick: action.id === "export" ? onExportAnnotations : action.onClick
})),
[onExportAnnotations]
);
const [renderedToolId, setRenderedToolId] = useState<ToolbarToolId | null>(activeToolId);
const panelVisible = Boolean(activeToolId);
useEffect(() => {
if (activeToolId) {
setRenderedToolId(activeToolId);
return;
}
if (!renderedToolId) {
return;
}
const exitTimer = window.setTimeout(() => {
setRenderedToolId(null);
}, TOOL_PANEL_EXIT_MS);
return () => window.clearTimeout(exitTimer);
}, [activeToolId, renderedToolId]);
if (!renderedToolId) {
return null;
}
const activeTool = waterNetworkToolbarItems.find((item) => item.id === renderedToolId);
const Icon = activeTool?.icon ?? Layers3;
if (renderedToolId === "layers") {
return (
<MapControlPanel
key="layers"
title="图层"
description={`${layerItems.filter((item) => item.visible).length}/${layerItems.length} 个业务图层可见`}
icon={Icon}
widthClassName={panelWidthClassName}
visible={panelVisible}
>
<MapLayerPanel
layerItems={layerItems}
baseLayerOptions={baseLayerOptions}
activeBaseLayerId={activeBaseLayerId}
onSelectBaseLayer={onSelectBaseLayer}
onToggleLayer={onToggleLayer}
/>
</MapControlPanel>
);
}
if (renderedToolId === "measure") {
return (
<MapControlPanel key="measure" title="测量" description="距离与范围测量" icon={Icon} widthClassName={panelWidthClassName ?? "w-[312px]"} visible={panelVisible}>
<MapMeasurePanel
modes={MEASURE_MODES}
activeModeId={activeMeasureModeId}
resultLabel={measureResult.label}
resultValue={measureResult.value}
resultUnit={measureResult.unit}
metrics={measureResult.metrics}
units={MEASURE_UNITS}
activeUnitId={activeMeasureUnitId}
actions={[
{
id: measuring ? "stop" : "start",
icon: Ruler,
label: measuring ? "停止测量" : "开始测量",
description:
activeMeasureModeId === "segment"
? measuring
? "暂停管线选择"
: "点击管线选择资产"
: measuring
? "暂停地图取点"
: "在地图上连续点击取点",
variant: measuring ? "default" : "primary",
disabled: !measureEnabled,
onClick: measuring ? onStopMeasure : onStartMeasure
},
{
id: "copy",
icon: Copy,
label: "复制结果",
description: "复制当前读数",
disabled: !hasMeasurePoints,
onClick: onCopyMeasure
},
{
id: "clear",
icon: Trash2,
label: "清除测量",
description: activeMeasureModeId === "segment" ? "清空已选管段" : "移除临时测量线",
tone: "danger",
disabled: !hasMeasurePoints,
onClick: onClearMeasure
}
]}
onSelectMode={(id) => onSelectMeasureMode(id as WorkbenchMeasureMode)}
onSelectUnit={(id) => onSelectMeasureUnit(id as WorkbenchMeasureUnit)}
/>
</MapControlPanel>
);
}
if (renderedToolId === "analysis") {
return (
<MapControlPanel key="analysis" title="分析" description="图例与当前覆盖" icon={Icon} widthClassName={panelWidthClassName ?? "w-[316px]"} visible={panelVisible}>
<AnalysisPanel legendItems={legendItems} />
</MapControlPanel>
);
}
if (renderedToolId === "annotation") {
return (
<MapControlPanel key="annotation" title="标注" description="创建与管理地图标注" icon={Icon} widthClassName={panelWidthClassName ?? "w-[312px]"} visible={panelVisible}>
<MapPanelSection title="绘制">
<MapDrawToolbar
variant="panel"
items={DRAW_TOOL_OPTIONS.map((item) => ({
...item,
active: activeDrawToolId === item.id,
disabled:
!drawingEnabled ||
(item.id === "undo" && !canUndoDrawing) ||
(item.id === "redo" && !canRedoDrawing) ||
(item.id === "delete" && !canDeleteSelectedDrawing) ||
(item.id === "clear" && !canUndoDrawing && !hasDrawingFeatures),
onClick: () => {
if (item.id === "undo") {
onUndoDrawing();
return;
}
if (item.id === "redo") {
onRedoDrawing();
return;
}
if (item.id === "delete") {
onDeleteSelectedDrawing();
return;
}
if (item.id === "clear") {
onClearDrawing();
return;
}
onSelectDrawTool(activeDrawToolId === item.id ? null : item.id);
}
}))}
/>
</MapPanelSection>
<MapAnnotationPanel annotations={annotationItems} shareActions={annotationShareActions} />
</MapControlPanel>
);
}
return (
<MapControlPanel key="more" title="更多" description={activeTool?.description} icon={Icon} widthClassName={panelWidthClassName ?? "w-[292px]"} visible={panelVisible}>
<MorePanel
onExportView={onExportView}
onRefreshTiles={onRefreshTiles}
onShowDataStatus={onShowDataStatus}
onShowShortcuts={onShowShortcuts}
onExportConfig={onExportConfig}
/>
</MapControlPanel>
);
}
function AnalysisPanel({ legendItems }: { legendItems: MapLegendItem[] }) {
return (
<div className="space-y-3">
<MapPanelSection title="图例">
<MapLegendList items={legendItems} showStatusDot />
</MapPanelSection>
<MapPanelSection title="当前覆盖">
<div className="grid grid-cols-2 gap-2">
<MapMetricTile label="影响范围" value="未开启" />
<MapMetricTile label="风险预览" value="待生成" />
</div>
<MapActionRow icon={Activity} label="模拟结果" description="影响面、事故点与标签" />
<MapActionRow icon={MapPinned} label="选中对象" description="点击管线或节点查看详情" />
</MapPanelSection>
</div>
);
}
type MorePanelProps = {
onExportView: (preset: ExportViewPreset) => void;
onRefreshTiles: () => void;
onShowDataStatus: () => void;
onShowShortcuts: () => void;
onExportConfig: () => void;
};
function MorePanel({
onExportView,
onRefreshTiles,
onShowDataStatus,
onShowShortcuts,
onExportConfig
}: MorePanelProps) {
return (
<div className="space-y-3">
<MapPanelSection title="导出视图">
<MapActionRow icon={Camera} label="当前分辨率" description="按当前地图画布尺寸导出" onClick={() => onExportView("current")} />
<MapActionRow icon={Camera} label="4K 分辨率" description="长边约 3840px 的 PNG" onClick={() => onExportView("4k")} />
</MapPanelSection>
<MapPanelSection title="数据">
<MapActionRow icon={RefreshCw} label="刷新瓦片" description="请求地图重新渲染" onClick={onRefreshTiles} />
<MapActionRow icon={Database} label="数据状态" description="查看当前数据源状态" status="查看" onClick={onShowDataStatus} />
</MapPanelSection>
<MapPanelSection title="系统">
<MapActionRow icon={Keyboard} label="快捷键" description="查看地图操作键位" onClick={onShowShortcuts} />
<MapActionRow icon={Download} label="导出配置" description="保存当前图层与工具状态" onClick={onExportConfig} />
</MapPanelSection>
</div>
);
}
function getDrawingAnnotationIcon(kind: WorkbenchDrawMode) {
if (kind === "line") {
return Route;
}
if (kind === "polygon") {
return Pentagon;
}
if (kind === "circle") {
return Circle;
}
return MapPinned;
}
@@ -0,0 +1,486 @@
"use client";
import {
Activity,
Bell,
Bot,
CheckCircle2,
ChevronDown,
ChevronRight,
Download,
FileText,
Keyboard,
LogOut,
PlayCircle,
RefreshCw,
ShieldCheck,
SlidersHorizontal,
UserRound,
type LucideIcon
} from "lucide-react";
import type { ReactNode } from "react";
import {
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
const scenarioStatusLabels: Record<WorkbenchScenario["status"], string> = {
active: "运行中",
draft: "草案",
review: "待复核"
};
const menuSurfaceClassName = cn(
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
"border border-white/80 bg-white/95 p-2 text-slate-900 shadow-xl shadow-slate-900/10 ring-1 ring-slate-900/5 backdrop-blur-xl"
);
const menuReadableRowClassName =
"bg-white/80 focus:bg-blue-50/95 focus:text-slate-950";
const headerControlButtonClassName =
"inline-flex h-8 items-center gap-2 rounded-full border border-transparent bg-transparent text-sm font-semibold text-slate-700 transition-colors hover:bg-white/70 hover:text-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/20 focus-visible:ring-offset-2 data-[state=open]:bg-white/90 data-[state=open]:text-blue-700";
const headerControlIconClassName =
"grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-100/80 text-slate-500 transition-colors group-hover:bg-blue-50 group-hover:text-blue-700 group-data-[state=open]:bg-blue-50 group-data-[state=open]:text-blue-700";
export function ScenarioMenu({
open,
onOpenChange,
scenarios,
activeScenarioId,
activeScenarioName,
onSelectScenario,
onPreviewScenario,
onCompareScenario,
onExportScenarioReport
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
scenarios: WorkbenchScenario[];
activeScenarioId: string;
activeScenarioName: string;
onSelectScenario: (scenarioId: string) => void;
onPreviewScenario: () => void;
onCompareScenario: () => void;
onExportScenarioReport: () => void;
}) {
return (
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn("group max-w-[220px] px-2", headerControlButtonClassName)}
aria-label="切换模拟方案"
>
<span className={headerControlIconClassName}>
<SlidersHorizontal size={14} aria-hidden="true" />
</span>
<span className="truncate"> · {activeScenarioName}</span>
<ChevronDown size={14} className="shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600" aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className={cn("w-[340px]", menuSurfaceClassName)}>
<MenuHeader title="场景控制" meta={`当前:${activeScenarioName}`} />
<MenuSectionLabel></MenuSectionLabel>
<DropdownMenuRadioGroup
value={activeScenarioId}
onValueChange={(nextScenarioId) => {
onSelectScenario(nextScenarioId);
onOpenChange(false);
}}
>
{scenarios.map((scenario) => (
<DropdownMenuRadioItem
key={scenario.id}
value={scenario.id}
className={cn(
"my-0.5 items-start border border-transparent py-2 pr-2 transition data-[state=checked]:border-blue-100 data-[state=checked]:bg-blue-50/80",
MAP_COMPACT_RADIUS_CLASS_NAME,
menuReadableRowClassName
)}
>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span className="truncate font-semibold text-slate-900">{scenario.name}</span>
<span className="shrink-0 rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-xs font-medium text-slate-500">
{scenarioStatusLabels[scenario.status]}
</span>
</span>
<span className="mt-1 block text-xs leading-5 text-slate-500">{scenario.description}</span>
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<MenuSeparator />
<MenuSectionLabel></MenuSectionLabel>
<MenuAction icon={PlayCircle} label="预览影响范围" description="打开模拟图层与影响区" tone="primary" onSelect={onPreviewScenario} />
<MenuAction icon={SlidersHorizontal} label="比较候选方案" description="影响、阀门与保供风险差异" onSelect={onCompareScenario} />
<MenuAction icon={FileText} label="导出场景报告" description="生成调度复核材料" onSelect={onExportScenarioReport} />
</DropdownMenuContent>
</DropdownMenu>
);
}
export function AlertMenu({
open,
onOpenChange,
alerts,
compact = false,
conditionFeedVisible,
onSelectAlert,
onToggleConditionFeed,
onAnalyzeAlertsWithAgent
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
alerts: WorkbenchAlert[];
compact?: boolean;
conditionFeedVisible: boolean;
onSelectAlert: (alert: WorkbenchAlert) => void;
onToggleConditionFeed: () => void;
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
}) {
const hasAlerts = alerts.length > 0;
const latestAlertTime = alerts[0]?.time;
return (
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"group relative px-2",
headerControlButtonClassName,
hasAlerts && "hover:bg-red-50/60 hover:text-red-800 data-[state=open]:bg-red-50/70 data-[state=open]:text-red-800",
compact && "h-10 w-10 justify-center px-0"
)}
aria-label={`查看异常处置面板,当前 ${alerts.length} 条待复核工况`}
>
<span
className={cn(
headerControlIconClassName,
hasAlerts &&
"bg-red-50 text-red-700 group-hover:bg-red-50 group-hover:text-red-800 group-data-[state=open]:bg-red-50 group-data-[state=open]:text-red-800"
)}
>
<Bell size={14} aria-hidden="true" />
</span>
<span className={compact ? "sr-only" : undefined}></span>
{compact ? (
<span className={cn("absolute right-0 top-0 grid h-4 min-w-4 place-items-center rounded-full border px-1 text-xs font-semibold leading-none", hasAlerts ? "border-red-100 bg-red-50 text-red-800" : "border-slate-200 bg-slate-50 text-slate-500")}>
{alerts.length}
</span>
) : (
<span className={cn("rounded-full border px-1.5 py-0.5 text-xs font-semibold leading-none", hasAlerts ? "border-red-100 bg-red-50 text-red-800" : "border-slate-200 bg-slate-50 text-slate-500")}>
{alerts.length}
</span>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8} className={cn("w-[min(392px,calc(100vw-24px))]", menuSurfaceClassName)}>
<AlertQueueSummary
count={alerts.length}
latestAlertTime={latestAlertTime}
conditionFeedVisible={conditionFeedVisible}
/>
<MenuSectionLabel></MenuSectionLabel>
<div className="scheduled-feed-scroll max-h-[min(52dvh,420px)] overflow-y-auto pr-1">
{hasAlerts ? alerts.map((alert) => (
<DropdownMenuItem
key={alert.id}
className={cn(
"group my-1 grid min-h-[72px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-transparent px-2.5 py-2.5 transition-colors focus:border-red-100",
MAP_COMPACT_RADIUS_CLASS_NAME,
menuReadableRowClassName
)}
onSelect={() => onSelectAlert(alert)}
>
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-2">
<span className="h-2 w-2 shrink-0 rounded-full bg-red-300 ring-2 ring-red-50" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-sm font-semibold leading-5 text-slate-950">{alert.title}</span>
<span className="shrink-0 font-mono text-xs font-semibold leading-4 text-slate-400 tabular-nums">{alert.time}</span>
</span>
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-slate-500">{alert.description}</span>
</span>
<span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50 text-slate-400 transition-colors group-focus:text-red-700", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
<ChevronRight size={14} />
</span>
</DropdownMenuItem>
)) : (
<div className={cn("my-1 grid min-h-[76px] place-items-center border border-dashed border-slate-200 bg-white/70 px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
<div className="min-w-0">
<CheckCircle2 size={18} className="mx-auto text-emerald-600" aria-hidden="true" />
<p className="mt-1 text-xs font-semibold text-slate-700"></p>
<p className="mt-0.5 text-xs leading-4 text-slate-500"></p>
</div>
</div>
)}
</div>
<div className="mt-2 grid grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-slate-200/70 pt-2">
<MenuPlainButton
label={conditionFeedVisible ? "工况面板已打开" : "打开工况面板"}
description="查看时间线与处置详情"
disabled={conditionFeedVisible}
onClick={() => {
onToggleConditionFeed();
onOpenChange(false);
}}
/>
<MenuPrimaryButton
disabled={!hasAlerts}
icon={Bot}
label={hasAlerts ? "工况汇总" : "等待提醒"}
onClick={() => {
void onAnalyzeAlertsWithAgent();
onOpenChange(false);
}}
/>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function UserMenu({
open,
onOpenChange,
user,
onRefreshTiles,
onShowDataStatus,
onShowShortcuts,
onExportConfig
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
user: WorkbenchUser;
onRefreshTiles: () => void;
onShowDataStatus: () => void;
onShowShortcuts: () => void;
onExportConfig: () => void;
}) {
return (
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn("group px-2", headerControlButtonClassName)}
aria-label="打开用户菜单"
>
<span className={headerControlIconClassName}>
<UserRound size={14} aria-hidden="true" />
</span>
<span className="hidden text-sm font-semibold md:inline">{user.name}</span>
<ChevronDown size={14} className="hidden shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600 sm:block" aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className={cn("w-72", menuSurfaceClassName)}>
<MenuHeader title={user.name} meta={user.role} icon={ShieldCheck} />
<MenuSectionLabel></MenuSectionLabel>
<MenuAction icon={Activity} label="查看运行状态" description="检查数据源与运行健康度" onSelect={onShowDataStatus} />
<MenuAction icon={RefreshCw} label="刷新地图瓦片" description="请求地图重新渲染业务图层" onSelect={onRefreshTiles} />
<MenuAction icon={Download} label="导出审计配置" description="保存当前地图和工具状态" onSelect={onExportConfig} />
<MenuAction icon={Keyboard} label="操作参考" description="查看绘制与测量操作提示" onSelect={onShowShortcuts} />
<MenuSeparator />
<DropdownMenuItem disabled className={cn("px-2 py-2 text-slate-400", MAP_COMPACT_RADIUS_CLASS_NAME)}>
<LogOut size={15} aria-hidden="true" />
退
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function AlertQueueSummary({
count,
latestAlertTime,
conditionFeedVisible
}: {
count: number;
latestAlertTime?: string;
conditionFeedVisible: boolean;
}) {
const hasAlerts = count > 0;
return (
<div className={cn("overflow-hidden bg-white/90 px-3 py-3 ring-1 ring-white/80", MAP_READABLE_RADIUS_CLASS_NAME)}>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2.5">
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
hasAlerts ? "bg-red-50 text-red-700" : "bg-emerald-50 text-emerald-700"
)}
>
{hasAlerts ? <Bell size={16} aria-hidden="true" /> : <CheckCircle2 size={16} aria-hidden="true" />}
</span>
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold leading-5 text-slate-950"></h2>
<p className="mt-0.5 text-xs leading-5 text-slate-500">
{hasAlerts ? `${count} 条需复核${latestAlertTime ? ` · 最近 ${latestAlertTime}` : ""}` : "当前无待复核工况"}
</p>
</div>
</div>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold leading-5",
hasAlerts ? "bg-red-50 text-red-800 ring-1 ring-red-100" : "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-100"
)}
>
{hasAlerts ? "需复核" : "正常"}
</span>
</div>
<div className="mt-2 flex items-center gap-1.5 text-xs leading-4 text-slate-500">
<span className={cn("h-1.5 w-1.5 rounded-full", conditionFeedVisible ? "bg-blue-500" : "bg-slate-300")} aria-hidden="true" />
<span className="truncate">{conditionFeedVisible ? "工况面板已显示,可查看详情。" : "点击条目可打开工况任务。"}</span>
</div>
</div>
);
}
function MenuPlainButton({
label,
description,
disabled = false,
onClick
}: {
label: string;
description: string;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
"flex h-11 min-w-0 flex-col justify-center border px-3 text-left transition-colors disabled:cursor-default disabled:opacity-70",
MAP_COMPACT_RADIUS_CLASS_NAME,
disabled
? "border-slate-200 bg-slate-50 text-slate-500"
: "border-slate-200 bg-white text-slate-800 hover:border-blue-100 hover:bg-blue-50/55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-100"
)}
>
<span className="truncate text-xs font-semibold leading-4">{label}</span>
<span className="truncate text-xs font-medium leading-4 text-slate-500">{description}</span>
</button>
);
}
function MenuPrimaryButton({
icon: Icon,
label,
disabled = false,
onClick
}: {
icon: LucideIcon;
label: string;
disabled?: boolean;
onClick: () => void | Promise<void>;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
"inline-flex h-11 shrink-0 items-center justify-center gap-2 border px-3 text-xs font-semibold transition-colors disabled:cursor-not-allowed",
MAP_COMPACT_RADIUS_CLASS_NAME,
disabled
? "border-slate-200 bg-slate-50 text-slate-400"
: "border-blue-200 bg-blue-600 text-white shadow-lg shadow-blue-600/20 hover:bg-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-200"
)}
>
<Icon size={14} aria-hidden="true" />
<span className="max-w-[92px] truncate">{label}</span>
</button>
);
}
function MenuHeader({
title,
meta,
tone = "default",
icon: Icon
}: {
title: string;
meta: string;
tone?: "default" | "warning";
icon?: LucideIcon;
}) {
return (
<div className={cn("flex items-center justify-between gap-3 border border-white/70 bg-white/90 px-2 py-2", MAP_READABLE_RADIUS_CLASS_NAME)}>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-1.5">
{Icon ? <Icon size={13} className="shrink-0 text-slate-500" aria-hidden="true" /> : null}
<span className="truncate text-sm font-semibold text-slate-950">{title}</span>
</div>
<p className={cn("mt-0.5 truncate text-xs text-slate-500", tone === "warning" && "text-red-600")}>{meta}</p>
</div>
{tone === "warning" ? <span className="h-2 w-2 shrink-0 rounded-full bg-red-500" aria-hidden="true" /> : null}
</div>
);
}
function MenuSectionLabel({ children }: { children: ReactNode }) {
return <DropdownMenuLabel className="px-2 pb-1 pt-2.5 text-xs font-semibold uppercase text-slate-500">{children}</DropdownMenuLabel>;
}
function MenuSeparator() {
return <DropdownMenuSeparator className="my-2 bg-slate-200/70" />;
}
function MenuAction({
icon: Icon,
label,
description,
tone = "default",
onSelect
}: {
icon: LucideIcon;
label: string;
description: string;
tone?: "default" | "primary" | "warning";
onSelect: () => void;
}) {
return (
<DropdownMenuItem
className={cn(
"my-0.5 items-center border border-transparent px-2 py-2 transition",
MAP_COMPACT_RADIUS_CLASS_NAME,
menuReadableRowClassName
)}
onSelect={onSelect}
>
<span
className={cn(
"grid h-7 w-7 shrink-0 place-items-center border",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
tone === "primary" && "border-blue-100 bg-blue-50 text-blue-700",
tone === "warning" && "border-orange-100 bg-orange-50 text-orange-700",
tone === "default" && "border-slate-100 bg-slate-50 text-slate-600"
)}
>
<Icon size={15} aria-hidden="true" />
</span>
<span className="min-w-0">
<span className="block text-sm font-semibold leading-5 text-slate-900">{label}</span>
<span className="mt-0.5 block truncate text-xs text-slate-500">{description}</span>
</span>
</DropdownMenuItem>
);
}
@@ -0,0 +1,241 @@
"use client";
import { useState } from "react";
import {
Box,
CalendarClock,
Clock3,
Droplets,
Eye,
EyeOff,
type LucideIcon
} from "lucide-react";
import {
MAP_ICON_CELL_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import { cn } from "@/lib/utils";
import type { WorkbenchAlert, WorkbenchScenario, WorkbenchUser } from "../types";
import { AlertMenu, ScenarioMenu, UserMenu } from "./workbench-top-bar-menus";
export type WorkbenchTopBarProps = {
dataTime: string;
modelName: string;
scenarios: WorkbenchScenario[];
activeScenarioId: string;
alerts: WorkbenchAlert[];
user: WorkbenchUser;
conditionFeedVisible: boolean;
taskTickerAvailable: boolean;
taskTickerVisible: boolean;
onSelectScenario: (scenarioId: string) => void;
onSelectAlert: (alert: WorkbenchAlert) => void;
onToggleConditionFeed: () => void;
onToggleTaskTicker: () => void;
onPreviewScenario: () => void;
onCompareScenario: () => void;
onExportScenarioReport: () => void;
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
onShowDataStatus: () => void;
onRefreshTiles: () => void;
onShowShortcuts: () => void;
onExportConfig: () => void;
};
const headerControlButtonClassName =
"inline-flex h-8 items-center gap-2 rounded-full border border-transparent bg-transparent text-sm font-semibold text-slate-700 transition-colors hover:bg-white/70 hover:text-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/20 focus-visible:ring-offset-2 data-[state=open]:bg-white/90 data-[state=open]:text-blue-700";
const headerControlIconClassName =
"grid h-5 w-5 shrink-0 place-items-center rounded-full bg-slate-100/80 text-slate-500 transition-colors group-hover:bg-blue-50 group-hover:text-blue-700 group-data-[state=open]:bg-blue-50 group-data-[state=open]:text-blue-700";
type HeaderMenuId = "alerts" | "compact-alerts" | "scenario" | "user";
export function WorkbenchTopBar({
dataTime,
modelName,
scenarios,
activeScenarioId,
alerts,
user,
conditionFeedVisible,
taskTickerAvailable,
taskTickerVisible,
onSelectScenario,
onSelectAlert,
onToggleConditionFeed,
onToggleTaskTicker,
onPreviewScenario,
onCompareScenario,
onExportScenarioReport,
onAnalyzeAlertsWithAgent,
onShowDataStatus,
onRefreshTiles,
onShowShortcuts,
onExportConfig
}: WorkbenchTopBarProps) {
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
const activeScenario = scenarios.find((scenario) => scenario.id === activeScenarioId) ?? scenarios[0];
const createMenuOpenChange = (menuId: HeaderMenuId) => (open: boolean) => {
setOpenMenu(open ? menuId : null);
};
return (
<header className="pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between border-b border-white/65 bg-white/80 px-3 shadow-lg shadow-slate-900/5 backdrop-blur-2xl sm:px-4 md:px-5">
<div className="flex min-w-0 items-center gap-2.5">
<div className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-600 text-white shadow-lg shadow-blue-600/20", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
<Droplets size={18} aria-hidden="true" />
</div>
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold leading-5 text-slate-950"></h1>
<p className="hidden truncate text-xs leading-4 text-slate-500 sm:block"></p>
</div>
</div>
<div className="hidden min-w-0 items-center gap-3 text-sm text-slate-700 lg:flex xl:gap-4">
<HeaderReadout icon={Clock3} label="数据时间" value={dataTime} />
<HeaderDivider />
<HeaderReadout icon={Box} label="模型版本" value={modelName} />
<HeaderDivider />
<TaskTickerToggle
available={taskTickerAvailable}
visible={taskTickerVisible}
onToggle={onToggleTaskTicker}
/>
{taskTickerAvailable ? <HeaderDivider /> : null}
<ConditionFeedToggle visible={conditionFeedVisible} onToggle={onToggleConditionFeed} />
<HeaderDivider />
<AlertMenu
open={openMenu === "alerts"}
onOpenChange={createMenuOpenChange("alerts")}
alerts={alerts}
conditionFeedVisible={conditionFeedVisible}
onSelectAlert={onSelectAlert}
onToggleConditionFeed={onToggleConditionFeed}
onAnalyzeAlertsWithAgent={onAnalyzeAlertsWithAgent}
/>
<HeaderDivider />
{activeScenario ? (
<ScenarioMenu
open={openMenu === "scenario"}
onOpenChange={createMenuOpenChange("scenario")}
scenarios={scenarios}
activeScenarioId={activeScenario.id}
activeScenarioName={activeScenario.name}
onSelectScenario={onSelectScenario}
onPreviewScenario={onPreviewScenario}
onCompareScenario={onCompareScenario}
onExportScenarioReport={onExportScenarioReport}
/>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<div className="lg:hidden">
<AlertMenu
compact
open={openMenu === "compact-alerts"}
onOpenChange={createMenuOpenChange("compact-alerts")}
alerts={alerts}
conditionFeedVisible={conditionFeedVisible}
onSelectAlert={onSelectAlert}
onToggleConditionFeed={onToggleConditionFeed}
onAnalyzeAlertsWithAgent={onAnalyzeAlertsWithAgent}
/>
</div>
<UserMenu
open={openMenu === "user"}
onOpenChange={createMenuOpenChange("user")}
user={user}
onRefreshTiles={onRefreshTiles}
onShowDataStatus={onShowDataStatus}
onShowShortcuts={onShowShortcuts}
onExportConfig={onExportConfig}
/>
</div>
</header>
);
}
function TaskTickerToggle({
available,
visible,
onToggle
}: {
available: boolean;
visible: boolean;
onToggle: () => void;
}) {
const Icon = visible ? EyeOff : Eye;
if (!available) {
return null;
}
return (
<button
type="button"
aria-label={visible ? "隐藏任务浮条" : "显示任务浮条"}
aria-pressed={visible}
title={visible ? "隐藏任务浮条" : "显示任务浮条"}
onClick={onToggle}
className={cn(
"group px-2",
headerControlButtonClassName,
visible && "bg-blue-50/80 text-blue-700"
)}
>
<span
className={cn(
headerControlIconClassName,
visible && "bg-blue-600/10 text-blue-700"
)}
>
<Icon size={14} aria-hidden="true" />
</span>
<span></span>
</button>
);
}
function ConditionFeedToggle({
visible,
onToggle
}: {
visible: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
aria-pressed={visible}
onClick={onToggle}
className={cn(
"group px-2",
headerControlButtonClassName,
visible && "bg-blue-50/80 text-blue-700"
)}
>
<span
className={cn(
headerControlIconClassName,
visible && "bg-blue-600/10 text-blue-700"
)}
>
<CalendarClock size={14} aria-hidden="true" />
</span>
<span></span>
</button>
);
}
function HeaderReadout({ icon: Icon, label, value }: { icon?: LucideIcon; label: string; value: string }) {
return (
<div className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
{Icon ? <Icon size={14} className="shrink-0 text-blue-700" aria-hidden="true" /> : null}
<span className="text-xs font-medium text-slate-500">{label}</span>
<span className="truncate text-sm font-semibold text-slate-800">{value}</span>
</div>
);
}
function HeaderDivider() {
return <div className="h-5 w-px shrink-0 bg-slate-200/80" />;
}
@@ -0,0 +1,202 @@
import type {
ScheduledConditionRecord,
ScheduledConditionStatus,
ScheduledConditionTaskId
} from "@/features/workbench/types";
export type RunningWorkflowStepDefinition = {
id: string;
title: string;
detail: string;
};
export type RunningWorkflowDefinition = {
title: string;
reportLabel: string;
steps: RunningWorkflowStepDefinition[];
};
export type RunningWorkflowStepStatus = "done" | "current" | "pending";
export const runningWorkflowDefinitions: Record<ScheduledConditionTaskId, RunningWorkflowDefinition> = {
"scada-diagnosis": {
title: "SCADA 诊断流程",
reportLabel: "生成诊断报告",
steps: [
{
id: "ingest-telemetry",
title: "读取 SCADA 遥测",
detail: "拉取压力、流量和关键阀门测点,校验时间戳与回传频率。"
},
{
id: "freshness-check",
title: "检查新鲜度完整率",
detail: "按测点组计算缺测、延迟和连续异常样本比例。"
},
{
id: "consistency-check",
title: "压流一致性诊断",
detail: "比对压力变化、流量变化和相邻测点趋势是否符合水力逻辑。"
},
{
id: "isolate-sources",
title: "标记异常数据源",
detail: "圈定疑似通信异常或越界测点,给后续模拟和调度提供屏蔽建议。"
},
{
id: "diagnosis-report",
title: "整理诊断结论",
detail: "汇总数据质量、异常证据和是否允许进入后续工况任务。"
}
]
},
"smart-dispatch": {
title: "智能调度流程",
reportLabel: "生成调度指令",
steps: [
{
id: "read-condition",
title: "读取实时工况",
detail: "汇总目标分区压力、流量、泵站出水和阀门开度。"
},
{
id: "candidate-actions",
title: "生成候选动作",
detail: "枚举泵站目标压力、阀门边界和高频复核测点的可执行动作。"
},
{
id: "simulate-impact",
title: "模拟影响范围",
detail: "预估指令执行后的压力改善、分区压差和关键用户影响。"
},
{
id: "rank-actions",
title: "排序调度方案",
detail: "按改善幅度、操作代价和数据可信度筛选建议指令。"
},
{
id: "dispatch-draft",
title: "形成复核清单",
detail: "输出待调度员确认的管网要素调整指令和复核条件。"
}
]
},
"network-simulation": {
title: "定时模拟流程",
reportLabel: "生成模拟报告",
steps: [
{
id: "sync-boundary",
title: "同步模型边界",
detail: "读取最新泵站启停、阀门开度、需水量和 SCADA 边界条件。"
},
{
id: "run-hydraulic-model",
title: "运行水力模型",
detail: "滚动计算全网节点压力、管段流量和分区边界平衡。"
},
{
id: "compare-telemetry",
title: "比对实测偏差",
detail: "将模拟压力、流量与在线测点回传值进行偏差校验。"
},
{
id: "locate-deviation",
title: "定位偏差来源",
detail: "识别模型参数、边界条件或遥测源导致的主要偏差区域。"
},
{
id: "simulation-report",
title: "输出模型复核",
detail: "给出可信度、关注指标和是否允许用于调度决策。"
}
]
},
"pump-energy": {
title: "泵站能耗流程",
reportLabel: "生成能耗报告",
steps: [
{
id: "read-pump-load",
title: "读取泵组负载",
detail: "采集泵站频率、电流、出水压力、出水量和启停记录。"
},
{
id: "calculate-energy",
title: "计算单位电耗",
detail: "按当前窗口排水量折算单位排水电耗和效率偏差。"
},
{
id: "compare-efficiency",
title: "比对经济区间",
detail: "结合泵组效率曲线判断当前组合是否处于低效并联区间。"
},
{
id: "pressure-guard",
title: "校验供压约束",
detail: "复核节能建议不会引发末端低压或分区边界异常。"
},
{
id: "energy-advice",
title: "形成节能建议",
detail: "输出启停优化、压力设定和继续观察条件。"
}
]
}
};
export function getRunningWorkflowDefinition(condition: ScheduledConditionRecord) {
return runningWorkflowDefinitions[condition.taskId];
}
export function getRunningWorkflowState(
condition: ScheduledConditionRecord,
workflow: RunningWorkflowDefinition,
nowMs: number
) {
if (condition.status !== "running") {
return { currentStepIndex: workflow.steps.length - 1, progress: getCompletedProgress(condition.status) };
}
const scheduledAtMs = Date.parse(condition.scheduledAt);
const startedAtMs = Number.isFinite(scheduledAtMs) ? scheduledAtMs : nowMs;
const durationMs = Math.max(condition.durationMinutes ?? 1, 1) * 60_000;
const elapsedMs = Math.max(0, nowMs - startedAtMs);
const rawRatio = elapsedMs / durationMs;
const runningRatio = Math.min(Math.max(rawRatio, 0), 0.999);
const currentStepIndex = Math.min(
workflow.steps.length - 1,
Math.floor(runningRatio * workflow.steps.length)
);
const progress = Math.min(99, Math.max(0, Math.floor(runningRatio * 100)));
return { currentStepIndex, progress };
}
export function getWorkflowStepStatus(
condition: ScheduledConditionRecord,
stepIndex: number,
currentStepIndex: number
): RunningWorkflowStepStatus {
if (condition.status !== "running") {
return "done";
}
if (stepIndex < currentStepIndex) {
return "done";
}
if (stepIndex === currentStepIndex) {
return "current";
}
return "pending";
}
function getCompletedProgress(status: ScheduledConditionStatus) {
if (status === "error") {
return 0;
}
return 100;
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { createOperationalScheduledConditions } from "./scheduled-conditions";
describe("createOperationalScheduledConditions", () => {
it("keeps a scheduled condition running during the previous gap window", () => {
const conditions = createOperationalScheduledConditions(new Date("2026-07-07T11:43:30+08:00"));
const runningCondition = conditions.find(
(condition) => condition.kind === "condition" && condition.status === "running"
);
expect(runningCondition?.title).toBe("SCADA 数据诊断");
expect(runningCondition?.durationMinutes).toBe(5);
expect(runningCondition?.scheduledAt).toContain("T11:40:");
});
it("rolls the running condition into history when the next interval starts", () => {
const conditions = createOperationalScheduledConditions(new Date("2026-07-07T11:45:30+08:00"));
const previousCondition = conditions.find(
(condition) => condition.kind === "condition" && condition.id.includes("scada-diagnosis-202607071140")
);
const currentCondition = conditions.find(
(condition) => condition.kind === "condition" && condition.id.includes("scada-diagnosis-202607071145")
);
expect(previousCondition?.status).not.toBe("running");
expect(previousCondition?.durationMinutes).toBe(5);
expect(currentCondition?.status).toBe("running");
expect(currentCondition?.durationMinutes).toBe(5);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
import type { WorkbenchScenario, WorkbenchUser } from "../types";
export const WORKBENCH_SCENARIOS: WorkbenchScenario[] = [
{
id: "scenario-a",
name: "方案 A",
description: "关闭事故点上下游阀门,优先保障核心片区压力。",
status: "active"
},
{
id: "scenario-b",
name: "方案 B",
description: "扩大隔离范围,降低抢修期间二次爆管风险。",
status: "review"
},
{
id: "scenario-c",
name: "夜间保供",
description: "按夜间低峰工况调整泵站与分区调度策略。",
status: "draft"
}
];
export const WORKBENCH_USER: WorkbenchUser = {
name: "调度员",
role: "排水运行调度中心"
};
@@ -0,0 +1,73 @@
import type { MapAnnotation } from "../types";
export const mapAnnotations: MapAnnotation[] = [
{
id: "burst",
label: "爆管位置",
coordinate: [121.5215, 30.9084],
kind: "burst"
},
{
id: "dn600",
label: "DN600",
coordinate: [121.512, 30.9102],
kind: "pipe-label"
},
{
id: "east-gate",
label: "东直门小区",
coordinate: [121.545, 30.951],
kind: "district"
},
{
id: "impact",
label: "影响范围 约 1.82 km²",
coordinate: [121.567, 30.896],
kind: "tooltip"
}
];
export const impactAreaPolygon = {
type: "FeatureCollection" as const,
features: [
{
type: "Feature" as const,
properties: {
id: "impact-area",
label: "影响范围",
area: "1.82 km²"
},
geometry: {
type: "Polygon" as const,
coordinates: [
[
[121.5205, 30.9084],
[121.548, 30.928],
[121.604, 30.918],
[121.626, 30.886],
[121.584, 30.862],
[121.538, 30.873],
[121.506, 30.895],
[121.5205, 30.9084]
]
]
}
}
]
};
export const annotationPoints = {
type: "FeatureCollection" as const,
features: mapAnnotations.map((annotation) => ({
type: "Feature" as const,
properties: {
id: annotation.id,
label: annotation.label,
kind: annotation.kind
},
geometry: {
type: "Point" as const,
coordinates: annotation.coordinate
}
}))
};
@@ -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
});
}
+1
View File
@@ -0,0 +1 @@
export { MapWorkbenchPage } from "./map-workbench-page";
+979
View File
@@ -0,0 +1,979 @@
"use client";
import type { Map as MapLibreMap } from "maplibre-gl";
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
import { X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
AgentCollapsedRail,
AgentCommandPanel,
AgentPersona,
type AgentUiResult
} from "@/features/agent";
import { toTrustedMapAction, type UIEnvelopePayload } from "@/features/agent/ui-envelope";
import {
MapErrorNotice,
MapLoadingNotice,
MapSourceStatusNotice,
showMapNotice,
type MapSourceStatus
} from "@/features/map/core/components/notice";
import { MapScaleLine } from "@/features/map/core/components/scale-line";
import { MapToolbar, type MapToolbarItem } from "@/features/map/core/components/toolbar";
import { MapZoom } from "@/features/map/core/components/zoom";
import {
MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import { cn } from "@/lib/utils";
import { AgentTaskTicker } from "./components/agent-task-ticker";
import { FeaturePopover } from "./components/feature-popover";
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
import { ToolbarPanel, type ExportViewPreset, type ToolbarPanelProps, type ToolbarToolId } from "./components/toolbar-panel";
import { WorkbenchTopBar } from "./components/workbench-top-bar";
import {
SCHEDULED_CONDITION_REFRESH_INTERVAL_MS,
createOperationalScheduledConditions
} from "./data/scheduled-conditions";
import { WORKBENCH_SCENARIOS, WORKBENCH_USER } from "./data/workbench-session";
import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
import { useWorkbenchMap } from "./hooks/use-workbench-map";
import { useWorkbenchMeasurement, type WorkbenchMeasureMode, type WorkbenchMeasureUnit } from "./hooks/use-workbench-measurement";
import { exportMapViewImage } from "./map/export-view";
import {
BASE_LAYER_IDS,
BASE_LAYER_OPTIONS,
INITIAL_LAYER_VISIBILITY,
MAP_LEGEND_ITEMS,
WORKBENCH_LAYER_GROUPS,
createLayerControlItems
} from "./map/map-control-config";
import { waterNetworkToolbarItems } from "./map/toolbar-config";
import type { DetailFeature, ScheduledConditionItem, ScheduledConditionRecord, WorkbenchAlert } from "./types";
import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-prompts";
const HEADER_DATA_TIME_STEP_MINUTES = 5;
function formatHeaderDataTime(date: Date) {
const snappedMinutes = Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(snappedMinutes).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
function getMsUntilNextHeaderDataTimeTick(date: Date) {
const nextTick = new Date(date);
const nextMinutes =
Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES +
HEADER_DATA_TIME_STEP_MINUTES;
nextTick.setMinutes(nextMinutes, 0, 0);
return Math.max(nextTick.getTime() - date.getTime(), 1_000);
}
export function MapWorkbenchPage() {
const hasMapboxToken = Boolean(process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN);
const mapContainerRef = useRef<HTMLDivElement | null>(null);
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
const [impactVisible, setImpactVisible] = useState(false);
const [isLargeScreen, setIsLargeScreen] = useState(
() => typeof window !== "undefined" && window.matchMedia("(min-width: 1024px)").matches
);
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
const [conditionFeedVisible, setConditionFeedVisible] = useState(true);
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
const [taskTickerVisible, setTaskTickerVisible] = useState(true);
const [selectedConditionId, setSelectedConditionId] = useState<string | null>(null);
const [conditionFocusRequest, setConditionFocusRequest] = useState<{
conditionId: string;
requestId: number;
} | null>(null);
const [activeBaseLayerId, setActiveBaseLayerId] = useState(hasMapboxToken ? "mapbox-light" : "none");
const [activeScenarioId, setActiveScenarioId] = useState(WORKBENCH_SCENARIOS[0]?.id ?? "");
const [activeMeasureModeId, setActiveMeasureModeId] = useState<WorkbenchMeasureMode>("distance");
const [activeMeasureUnitId, setActiveMeasureUnitId] = useState<WorkbenchMeasureUnit>("km");
const [activeDrawToolId, setActiveDrawToolId] = useState<WorkbenchDrawMode | null>(null);
const [layerVisibility, setLayerVisibility] = useState<Record<string, boolean>>(INITIAL_LAYER_VISIBILITY);
const [agentUiResults, setAgentUiResults] = useState<AgentUiResult[]>([]);
const [scheduledConditions, setScheduledConditions] = useState<ScheduledConditionItem[]>([]);
const [scheduledConditionsLoading, setScheduledConditionsLoading] = useState(true);
const [headerDataTime, setHeaderDataTime] = useState(() => formatHeaderDataTime(new Date()));
const baseLayerOptions = useMemo(
() =>
BASE_LAYER_OPTIONS.map((option) => ({
...option,
disabled: option.id !== "none" && !hasMapboxToken
})),
[hasMapboxToken]
);
const handleSelectFeature = useCallback((feature: DetailFeature) => {
setDetailFeature(feature);
}, []);
useEffect(() => {
const mediaQuery = window.matchMedia("(min-width: 1024px)");
const handleChange = () => setIsLargeScreen(mediaQuery.matches);
handleChange();
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);
useEffect(() => {
let timeoutId: number;
const refreshHeaderDataTime = () => {
const now = new Date();
setHeaderDataTime(formatHeaderDataTime(now));
timeoutId = window.setTimeout(refreshHeaderDataTime, getMsUntilNextHeaderDataTimeTick(now));
};
refreshHeaderDataTime();
return () => window.clearTimeout(timeoutId);
}, []);
useEffect(() => {
const refreshScheduledConditions = () => {
setScheduledConditions(createOperationalScheduledConditions(new Date()));
setScheduledConditionsLoading(false);
};
refreshScheduledConditions();
const intervalId = window.setInterval(refreshScheduledConditions, SCHEDULED_CONDITION_REFRESH_INTERVAL_MS);
return () => window.clearInterval(intervalId);
}, []);
const shouldShowConditionFeed = conditionFeedVisible && !activeToolId;
useEffect(() => {
if (shouldShowConditionFeed) {
setConditionFeedMounted(true);
return;
}
const timeoutId = window.setTimeout(() => {
setConditionFeedMounted(false);
}, 170);
return () => window.clearTimeout(timeoutId);
}, [shouldShowConditionFeed]);
const leftPanelOpen = isLargeScreen;
const rightPanelOpen = false;
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
containerRef: mapContainerRef,
leftPanelOpen,
rightPanelOpen,
impactVisible,
onSelectFeature: handleSelectFeature
});
const agent = useWorkbenchAgent({
onUiEnvelope: handleAgentUiEnvelope
});
const activeAgentUiResults = useMemo(
() => agentUiResults.filter((result) => result.sessionId === agent.sessionId),
[agent.sessionId, agentUiResults]
);
const conditionAlerts = useMemo(
() => createScheduledConditionAlerts(scheduledConditions),
[scheduledConditions]
);
const runningTickerConditions = useMemo(
() =>
scheduledConditions.filter(
(condition): condition is ScheduledConditionRecord =>
condition.kind === "condition" && condition.status === "running"
),
[scheduledConditions]
);
const workbenchAlerts = conditionAlerts;
const drawing = useWorkbenchDrawing({
mapRef,
mapReady,
activeMode: activeDrawToolId,
onModeChange: setActiveDrawToolId
});
const measurement = useWorkbenchMeasurement({
mapRef,
mapReady,
mode: activeMeasureModeId,
unit: activeMeasureUnitId
});
const prefersReducedMotion = useReducedMotion();
const taskTickerAvailable = runningTickerConditions.length > 0;
const shouldShowTaskTicker = !agent.mobileOpen && taskTickerVisible && taskTickerAvailable;
const taskTickerEnterState = prefersReducedMotion
? {
opacity: 1,
scale: 1,
y: 0,
filter: "blur(0px)"
}
: {
opacity: 1,
scale: 1,
y: 0,
filter: "blur(0px)",
transition: {
type: "spring" as const,
stiffness: 420,
damping: 30,
mass: 0.75
}
};
const taskTickerExitState = prefersReducedMotion
? {
opacity: 0,
scale: 1,
y: 0,
filter: "blur(0px)",
transition: { duration: 0 }
}
: {
opacity: 0,
scale: 0.96,
y: -8,
filter: "blur(4px)",
transition: {
duration: 0.18,
ease: [0.5, 0, 0.2, 1] as const
}
};
useEffect(() => {
if (!mapReady || !mapRef.current) {
return;
}
applyBaseLayerVisibility(mapRef.current, activeBaseLayerId);
}, [activeBaseLayerId, mapReady, mapRef]);
const toggleToolbarTool = useCallback((toolId: ToolbarToolId) => {
setActiveToolId((current) => {
return current === toolId ? null : toolId;
});
}, []);
const handleConditionFocusRequestHandled = useCallback((requestId: number) => {
setConditionFocusRequest((current) => (current?.requestId === requestId ? null : current));
}, []);
const toolbarItems = useMemo<MapToolbarItem[]>(
() =>
waterNetworkToolbarItems.map((item) => ({
...item,
active: activeToolId === item.id,
disabled: !mapReady,
onClick: () => toggleToolbarTool(item.id as ToolbarToolId)
})),
[activeToolId, mapReady, toggleToolbarTool]
);
const layerControlItems = useMemo(() => createLayerControlItems(layerVisibility), [layerVisibility]);
function handleToggleLayer(layerGroupId: string, visible: boolean) {
const map = mapRef.current;
setLayerVisibility((current) => ({ ...current, [layerGroupId]: visible }));
if (layerGroupId === "simulation") {
setImpactVisible(visible);
}
if (!map || !mapReady) {
return;
}
WORKBENCH_LAYER_GROUPS[layerGroupId]?.forEach((layerId) => {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none");
}
});
}
async function handleExportView(preset: ExportViewPreset) {
const map = mapRef.current;
if (!map || !mapReady) {
showMapNotice({ tone: "warning", message: "地图尚未就绪,无法导出视图。" });
return;
}
const targetLongEdge = getExportTargetLongEdge(preset);
const exportLabel = getExportPresetLabel(preset);
showMapNotice({
id: "map-view-export",
tone: "loading",
title: "正在导出",
message: `正在生成${exportLabel}地图截图...`,
duration: Infinity
});
try {
const result = await exportMapViewImage(map, {
scale: preset === "current" ? 1 : undefined,
targetLongEdge
});
showMapNotice({
id: "map-view-export",
tone: "success",
message: `已导出 ${result.width} × ${result.height} PNG。`
});
} catch {
showMapNotice({
id: "map-view-export",
tone: "error",
title: "导出失败",
message: "当前底图或瓦片可能限制了画布导出。"
});
}
}
function handleRefreshTiles() {
const map = mapRef.current;
if (!map || !mapReady) {
showMapNotice({ tone: "warning", message: "地图尚未就绪,无法刷新瓦片。" });
return;
}
map.triggerRepaint();
showMapNotice({ tone: "success", message: "已请求地图重新渲染,业务瓦片将在视图变化时刷新。" });
}
function handleShowDataStatus() {
if (sourceStatuses.length === 0) {
showMapNotice({ tone: "info", message: "数据源状态正在初始化。" });
return;
}
showMapNotice({
tone: sourceStatuses.some((sourceStatus) => sourceStatus.status === "offline") ? "warning" : "info",
title: "数据状态",
message: sourceStatuses.map((sourceStatus) => `${sourceStatus.sourceName}${sourceStatus.status}`).join(" · "),
duration: 5000
});
}
function handleShowShortcuts() {
showMapNotice({
tone: "info",
title: "快捷键",
message: "双击完成线/面绘制;圆形标注先点圆心,再点半径;Esc 可作为后续快捷键扩展。",
duration: 6000
});
}
function handleSelectScenario(scenarioId: string) {
const scenario = WORKBENCH_SCENARIOS.find((item) => item.id === scenarioId);
setActiveScenarioId(scenarioId);
showMapNotice({
tone: "success",
title: "方案已切换",
message: `当前方案:${scenario?.name ?? scenarioId}。地图模拟结果暂未自动切换。`
});
}
function handlePreviewScenario() {
setImpactVisible(true);
setLayerVisibility((current) => ({ ...current, simulation: true }));
if (mapRef.current && mapReady) {
WORKBENCH_LAYER_GROUPS.simulation.forEach((layerId) => {
if (mapRef.current?.getLayer(layerId)) {
mapRef.current.setLayoutProperty(layerId, "visibility", "visible");
}
});
}
showMapNotice({
tone: "info",
title: "预览影响范围",
message: "已打开模拟结果图层,可在右侧图层面板继续调整显示。"
});
}
function handleCompareScenario() {
showMapNotice({
tone: "info",
title: "方案比较",
message: "候选方案比较将汇总影响范围、阀门操作与保供风险。"
});
}
function handleExportScenarioReport() {
showMapNotice({
tone: "success",
title: "场景报告",
message: "已生成当前场景报告任务,可继续导出地图截图或配置。"
});
}
async function handleAnalyzeAlertsWithAgent() {
if (workbenchAlerts.length === 0) {
showMapNotice({
tone: "info",
title: "当前无待复核工况",
message: "当前没有需要汇总的工况提醒。"
});
return;
}
if (agent.streaming) {
showMapNotice({ tone: "warning", message: "Agent 正在处理上一条指令。" });
return;
}
agent.expandPanel();
try {
await agent.submitPrompt(
createAlertQueueConversationPrompt({
alerts: workbenchAlerts,
conditions: scheduledConditions
})
);
showMapNotice({
tone: "success",
title: "已发起工况汇总",
message: "已将待复核工况发送到 Agent。"
});
} catch (error) {
showMapNotice({
tone: "error",
title: "工况汇总发起失败",
message: error instanceof Error ? error.message : "无法将待复核工况发送到 Agent。"
});
}
}
function handleSelectAlert(alert: WorkbenchAlert) {
const conditionId = getScheduledConditionIdFromAlertId(alert.id);
if (conditionId) {
const condition = scheduledConditions.find((item) => item.id === conditionId);
if (!condition) {
showMapNotice({
tone: "warning",
title: "工况任务未找到",
message: `未找到提醒关联的工况任务:${conditionId}`
});
return;
}
setConditionFeedVisible(true);
setActiveToolId(null);
setConditionFocusRequest((current) => ({
conditionId,
requestId: (current?.requestId ?? 0) + 1
}));
return;
}
showMapNotice({
tone: "warning",
title: alert.title,
message: alert.description
});
}
async function handleAgentUiEnvelope(payload: UIEnvelopePayload, sessionId: string) {
const { envelope } = payload;
if (envelope.kind === "map_action") {
await handleAgentMapAction(envelope.action, envelope.params, sessionId, envelope.fallbackText);
return;
}
setAgentUiResults((current) => [
...current.slice(-5),
{
id: payload.envelope_id || `agent-ui-${Date.now().toString(36)}`,
sessionId,
createdAt: payload.created_at,
envelope
}
]);
showMapNotice({
tone: "info",
title: envelope.kind === "chart" ? "Agent 图表已渲染" : "Agent 面板已渲染",
message:
envelope.fallbackText ??
(envelope.kind === "chart" ? "已在 Agent 面板显示可信图表。" : "已在 Agent 面板显示可信组件。")
});
}
async function handleAgentMapAction(action: string, params: unknown, sessionId: string, fallbackText?: string) {
const trustedAction = toTrustedMapAction(action, params, fallbackText);
if (!trustedAction) {
showMapNotice({
tone: "warning",
title: "地图动作已降级",
message: fallbackText ?? "Agent 地图动作参数未通过前端校验。"
});
return;
}
if (trustedAction.type === "zoom_to_map") {
const map = mapRef.current;
if (!map || !mapReady) {
showMapNotice({ tone: "warning", message: "地图尚未就绪,无法执行 Agent 缩放动作。" });
return;
}
map.easeTo({
center: trustedAction.center,
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
duration: 500
});
showMapNotice({
tone: "info",
title: "Agent 地图定位",
message: trustedAction.fallbackText ?? "已根据 Agent 建议缩放到目标位置。"
});
return;
}
if (trustedAction.type === "locate_features") {
fitNetworkBounds();
showMapNotice({
tone: "info",
title: "Agent 资产定位",
message:
trustedAction.fallbackText ??
(trustedAction.featureIds.length > 0
? `已接收 ${trustedAction.featureIds.length} 个目标资产,当前先回到全网视图。`
: "已根据 Agent 建议回到全网视图。")
});
return;
}
if (trustedAction.type === "apply_layer_style") {
if (trustedAction.layerGroupId && typeof trustedAction.visible === "boolean") {
handleToggleLayer(trustedAction.layerGroupId, trustedAction.visible);
}
showMapNotice({
tone: "info",
title: "Agent 图层样式",
message: trustedAction.fallbackText ?? "已接收 Agent 图层样式建议,当前仅执行受控图层显隐。"
});
return;
}
try {
await agent.resolveRenderRef(trustedAction.renderRef, sessionId);
showMapNotice({
tone: "info",
title: "Agent 节点渲染",
message: trustedAction.fallbackText ?? "已验证 render_ref,节点分类渲染器将在后续接入。"
});
} catch (error) {
showMapNotice({
tone: "error",
title: "render_ref 解析失败",
message: error instanceof Error ? error.message : "无法读取 Agent 渲染结果。"
});
}
}
function handleExportConfig() {
const config = {
exportedAt: new Date().toISOString(),
activeBaseLayerId,
layerVisibility,
activeToolId,
activeMeasureModeId,
activeMeasureUnitId
};
const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "drainage-network-map-config.json";
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
showMapNotice({ tone: "success", message: "当前地图配置已导出。" });
}
const toolbarPanelProps: ToolbarPanelProps = {
activeToolId,
activeMeasureModeId,
activeMeasureUnitId,
measuring: measurement.measuring,
measureResult: measurement.result,
measureEnabled: mapReady,
hasMeasurePoints: measurement.hasPoints,
activeDrawToolId,
drawingEnabled: mapReady,
drawingAnnotations: drawing.annotations,
canUndoDrawing: drawing.canUndo,
canRedoDrawing: drawing.canRedo,
canDeleteSelectedDrawing: drawing.canDeleteSelected,
hasDrawingFeatures: drawing.hasFeatures,
layerItems: layerControlItems,
legendItems: MAP_LEGEND_ITEMS,
baseLayerOptions,
activeBaseLayerId,
onSelectMeasureMode: setActiveMeasureModeId,
onSelectMeasureUnit: setActiveMeasureUnitId,
onStartMeasure: measurement.start,
onStopMeasure: measurement.stop,
onClearMeasure: measurement.clear,
onCopyMeasure: measurement.copyResult,
onSelectDrawTool: setActiveDrawToolId,
onUndoDrawing: drawing.undo,
onRedoDrawing: drawing.redo,
onDeleteSelectedDrawing: drawing.deleteSelected,
onClearDrawing: drawing.clear,
onExportAnnotations: drawing.exportGeoJSON,
onSelectBaseLayer: setActiveBaseLayerId,
onToggleLayer: handleToggleLayer,
onExportView: handleExportView,
onRefreshTiles: handleRefreshTiles,
onShowDataStatus: handleShowDataStatus,
onShowShortcuts: handleShowShortcuts,
onExportConfig: handleExportConfig
};
const agentPanelProps: Omit<ComponentProps<typeof AgentCommandPanel>, "collapsing" | "onCollapse"> = {
personaState: agent.personaState,
sessionTitle: agent.sessionTitle,
sessionHistory: agent.sessionHistory,
sessionHistoryLoading: agent.sessionHistoryLoading,
activeSessionId: agent.sessionId,
statusLabel: agent.statusLabel,
streaming: agent.streaming,
messages: agent.messages,
streamRenderState: agent.streamRenderState,
modelOptions: agent.modelOptions,
selectedModel: agent.selectedModel,
uiResults: activeAgentUiResults,
onRefreshHistory: agent.refreshSessionHistory,
onStartNewSession: agent.startNewSession,
onLoadHistorySession: agent.loadHistorySession,
onRenameHistorySession: agent.renameHistorySession,
onDeleteHistorySession: agent.deleteHistorySession,
onSelectModel: agent.setSelectedModel,
onSubmitPrompt: agent.submitPrompt,
onStopPrompt: agent.stopPrompt,
onReplyPermission: agent.replyPermission,
onReplyQuestion: agent.replyQuestion,
onRejectQuestion: agent.rejectQuestion
};
return (
<main className="relative h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900">
<div
ref={mapContainerRef}
className="map-grid"
style={{ position: "absolute", inset: 0 }}
aria-label="排水管网地图"
/>
<div className="pointer-events-none absolute inset-0 z-10 bg-gradient-to-b from-white/25 via-white/5 to-slate-100/20" />
<WorkbenchTopBar
dataTime={headerDataTime}
modelName="DrainFlow 1.0.0"
scenarios={WORKBENCH_SCENARIOS}
activeScenarioId={activeScenarioId}
alerts={workbenchAlerts}
user={WORKBENCH_USER}
conditionFeedVisible={shouldShowConditionFeed}
taskTickerAvailable={taskTickerAvailable}
taskTickerVisible={taskTickerVisible}
onSelectScenario={handleSelectScenario}
onSelectAlert={handleSelectAlert}
onToggleConditionFeed={() => {
setConditionFeedVisible((current) => !current);
setActiveToolId(null);
}}
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
onPreviewScenario={handlePreviewScenario}
onCompareScenario={handleCompareScenario}
onExportScenarioReport={handleExportScenarioReport}
onAnalyzeAlertsWithAgent={handleAnalyzeAlertsWithAgent}
onShowDataStatus={handleShowDataStatus}
onRefreshTiles={handleRefreshTiles}
onShowShortcuts={handleShowShortcuts}
onExportConfig={handleExportConfig}
/>
<div className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[460px] 2xl:w-[500px] lg:block">
{agent.panelOpen ? (
<AgentCommandPanel
{...agentPanelProps}
collapsing={agent.panelCollapsing}
onCollapse={agent.collapsePanel}
/>
) : (
<AgentCollapsedRail
personaState={agent.personaState}
statusLabel={agent.statusLabel}
onExpand={agent.expandPanel}
/>
)}
</div>
<div className="absolute right-2 top-24 z-20">
<MapToolbar items={toolbarItems} className="hidden lg:block" />
</div>
{conditionFeedMounted ? (
<div
className={cn(
"absolute right-[62px] top-24 z-20 hidden lg:block",
shouldShowConditionFeed ? "scheduled-feed-shell-enter" : "scheduled-feed-shell-exit"
)}
>
<ScheduledConditionFeed
conditions={scheduledConditions}
expanded={conditionFeedExpanded}
focusRequest={conditionFocusRequest}
loading={scheduledConditionsLoading}
selectedConditionId={selectedConditionId}
onExpandedChange={setConditionFeedExpanded}
onFocusRequestHandled={handleConditionFocusRequestHandled}
onSelectedConditionChange={setSelectedConditionId}
onExpandAgent={agent.expandPanel}
onLoadHistorySession={agent.loadHistorySession}
onSubmitPrompt={agent.submitPrompt}
/>
</div>
) : null}
<div className="absolute right-[62px] top-24 z-20 hidden lg:block">
<ToolbarPanel {...toolbarPanelProps} />
</div>
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
{agent.mobileOpen ? (
<div className="h-[calc(100dvh-8.5rem)] min-h-[420px]">
<AgentCommandPanel
{...agentPanelProps}
collapsing={agent.mobilePanelCollapsing}
onCollapse={agent.closeMobilePanel}
/>
</div>
) : null}
</div>
<MobileAgentToggle
open={agent.mobileOpen}
personaState={agent.personaState}
statusLabel={agent.statusLabel}
streaming={agent.streaming}
onOpen={agent.openMobilePanel}
onClose={agent.closeMobilePanel}
/>
{!agent.mobileOpen ? (
<div className="absolute bottom-20 left-3 right-3 z-30 lg:hidden">
<ToolbarPanel {...toolbarPanelProps} panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto" />
</div>
) : null}
<div className="pointer-events-none absolute bottom-0 right-0 z-30 hidden flex-col items-end gap-2 md:flex">
<div className="flex items-end gap-2 pr-2">
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
</div>
<MapScaleLine mapRef={mapRef} mapReady={mapReady} />
</div>
<AnimatePresence initial={false}>
{shouldShowTaskTicker ? (
<motion.div
key="agent-task-ticker-shell"
className="fixed left-3 right-3 top-24 z-20 w-auto [translate:0] md:absolute md:left-1/2 md:right-auto md:w-[460px] md:[translate:-50%_0]"
style={{ transformOrigin: "top center" }}
initial={
prefersReducedMotion
? false
: {
opacity: 0,
scale: 0.96,
y: -8,
filter: "blur(6px)"
}
}
animate={taskTickerEnterState}
exit={taskTickerExitState}
>
<AgentTaskTicker
className="w-full"
conditions={runningTickerConditions}
/>
</motion.div>
) : null}
</AnimatePresence>
<div className="absolute bottom-3 left-3 right-3 z-20 md:hidden">
{!agent.mobileOpen ? (
<MapToolbar items={toolbarItems} orientation="horizontal" className="w-full justify-center" />
) : null}
</div>
<FeaturePopover feature={detailFeature} onClose={() => setDetailFeature(null)} />
{!mapReady && !mapError ? <MapLoadingNotice message="正在加载底图样式、管网数据源和交互图层。" /> : null}
{!hasMapboxToken ? (
<MapSourceStatusNotice
sourceName="Mapbox 底图"
status="degraded"
message="未检测到 Mapbox token,当前已切换为无底图模式,仅展示业务图层。"
/>
) : null}
{sourceStatuses.filter(isNoticeSourceStatus).map((sourceStatus) => (
<MapSourceStatusNotice
key={sourceStatus.id}
id={`map-source-runtime-${sourceStatus.id}`}
sourceName={sourceStatus.sourceName}
status={sourceStatus.status}
message={sourceStatus.message}
/>
))}
{mapError ? <MapErrorNotice message={mapError} /> : null}
</main>
);
}
function isNoticeSourceStatus(sourceStatus: {
status: string;
}): sourceStatus is { id: string; sourceName: string; status: MapSourceStatus; message: string } {
return sourceStatus.status === "online" || sourceStatus.status === "degraded" || sourceStatus.status === "offline";
}
function applyBaseLayerVisibility(map: MapLibreMap, activeBaseLayerId: string) {
BASE_LAYER_IDS.forEach((layerId) => {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, "visibility", activeBaseLayerId === layerId ? "visible" : "none");
}
});
}
function getExportTargetLongEdge(preset: ExportViewPreset) {
if (preset === "4k") {
return 3840;
}
return undefined;
}
function getExportPresetLabel(preset: ExportViewPreset) {
if (preset === "4k") {
return " 4K ";
}
return "当前分辨率";
}
function createScheduledConditionAlerts(conditions: ScheduledConditionItem[]): WorkbenchAlert[] {
return conditions
.filter(isAlertCondition)
.sort((a, b) => getScheduledConditionTimestamp(b) - getScheduledConditionTimestamp(a))
.map((condition) => {
return {
id: `scheduled-condition-alert-${condition.id}`,
title: `待复核:${condition.title}`,
description: condition.detail ?? condition.summary,
time: formatScheduledConditionTime(condition.scheduledAt)
};
});
}
function isAlertCondition(condition: ScheduledConditionItem) {
return condition.kind === "condition" && condition.status === "error";
}
function getScheduledConditionTimestamp(condition: ScheduledConditionItem) {
const scheduledAt = Date.parse(condition.scheduledAt);
return Number.isNaN(scheduledAt) ? condition.updatedAt : scheduledAt;
}
function formatScheduledConditionTime(value: string) {
const match = value.match(/T(\d{2}:\d{2})/);
return match?.[1] ?? value;
}
const SCHEDULED_CONDITION_ALERT_ID_PREFIX = "scheduled-condition-alert-";
function getScheduledConditionIdFromAlertId(alertId: string) {
if (!alertId.startsWith(SCHEDULED_CONDITION_ALERT_ID_PREFIX)) {
return null;
}
return alertId.slice(SCHEDULED_CONDITION_ALERT_ID_PREFIX.length);
}
function MobileAgentToggle({
open,
personaState,
statusLabel,
streaming,
onOpen,
onClose
}: {
open: boolean;
personaState: ComponentProps<typeof AgentPersona>["state"];
statusLabel: string;
streaming: boolean;
onOpen: () => void;
onClose: () => void;
}) {
if (open) {
return (
<div className="absolute bottom-20 right-3 z-40 lg:hidden">
<button
type="button"
aria-label="关闭 Agent 面板"
title="关闭 Agent 面板"
onClick={onClose}
className="agent-panel-icon-button grid h-10 w-10 place-items-center rounded-2xl text-slate-700 transition"
>
<X size={18} aria-hidden="true" />
</button>
</div>
);
}
return (
<div className="absolute bottom-20 left-3 z-30 lg:hidden">
<button
type="button"
aria-label="打开 Agent 面板"
title="打开 Agent 面板"
onClick={onOpen}
className={cn(
"grid h-12 w-12 place-items-center text-violet-700",
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME
)}
>
<AgentPersona
className="h-10 w-10"
fallbackClassName="h-10 w-10"
state={personaState}
statusLabel={statusLabel}
streaming={streaming}
/>
</button>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
import type { GeoJSONSourceSpecification, StyleSpecification } from "maplibre-gl";
import { annotationPoints, impactAreaPolygon } from "../data/workbench-simulation";
export const SIMULATION_SOURCE_IDS = {
impactArea: "simulation-impact-area",
annotations: "simulation-annotations"
} as const;
export const simulationSources = {
impactArea: {
type: "geojson",
data: impactAreaPolygon
} satisfies GeoJSONSourceSpecification,
annotations: {
type: "geojson",
data: annotationPoints
} satisfies GeoJSONSourceSpecification
};
export const simulationAnnotationLayers: StyleSpecification["layers"] = [
{
id: "simulation-impact-fill",
type: "fill",
source: SIMULATION_SOURCE_IDS.impactArea,
paint: {
"fill-color": "#ef4444",
"fill-opacity": 0.18
}
},
{
id: "simulation-impact-outline",
type: "line",
source: SIMULATION_SOURCE_IDS.impactArea,
paint: {
"line-color": "#ef4444",
"line-width": 1.5,
"line-dasharray": [2, 2],
"line-opacity": 0.72
}
},
{
id: "simulation-burst-halo",
type: "circle",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "burst"],
paint: {
"circle-color": "#fef2f2",
"circle-radius": 18,
"circle-stroke-color": "#ef4444",
"circle-stroke-width": 3,
"circle-opacity": 0.88
}
},
{
id: "simulation-burst-point",
type: "circle",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "burst"],
paint: {
"circle-color": "#ef4444",
"circle-radius": 7,
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 3
}
},
{
id: "simulation-pipe-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "pipe-label"],
layout: {
"text-field": ["get", "label"],
"text-size": 13,
"text-font": ["Open Sans Bold"],
"text-allow-overlap": true
},
paint: {
"text-color": "#ffffff",
"text-halo-color": "#2563eb",
"text-halo-width": 9
}
},
{
id: "simulation-impact-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "tooltip"],
layout: {
"text-field": ["get", "label"],
"text-size": 14,
"text-font": ["Open Sans Bold"],
"text-offset": [0, 0],
"text-allow-overlap": true
},
paint: {
"text-color": "#dc2626",
"text-halo-color": "#ffffff",
"text-halo-width": 4
}
},
{
id: "simulation-district-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "district"],
layout: {
"text-field": ["get", "label"],
"text-size": 13,
"text-font": ["Open Sans Regular"],
"text-allow-overlap": true
},
paint: {
"text-color": "#334155",
"text-halo-color": "#ffffff",
"text-halo-width": 3
}
},
{
id: "simulation-burst-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "burst"],
layout: {
"text-field": "爆管位置\nDN600 给水管线\n压力:0.18 MPa\n时间:09:00",
"text-size": 12,
"text-font": ["Open Sans Regular"],
"text-offset": [5.2, -3.4],
"text-anchor": "left",
"text-allow-overlap": true
},
paint: {
"text-color": "#0f172a",
"text-halo-color": "#ffffff",
"text-halo-width": 5
}
}
];
export const simulationLayerIds = simulationAnnotationLayers.map((layer) => layer.id);
+35
View File
@@ -0,0 +1,35 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { describe, expect, it, vi } from "vitest";
import { fitNetworkBounds } from "./camera";
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
describe("fitNetworkBounds", () => {
it("fits the global WebMercator bbox through MapLibre lng/lat bounds", () => {
const fitBounds = vi.fn();
const map = {
fitBounds,
getCanvas: () => ({
clientWidth: 1280,
clientHeight: 720,
width: 1280,
height: 720
})
} as unknown as MapLibreMap;
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
expect(fitBounds).toHaveBeenCalledTimes(1);
const [bounds, options] = fitBounds.mock.calls[0] ?? [];
expect(bounds[0][0]).toBeCloseTo(121.351633, 6);
expect(bounds[0][1]).toBeCloseTo(30.810505, 6);
expect(bounds[1][0]).toBeCloseTo(121.772485, 6);
expect(bounds[1][1]).toBeCloseTo(31.007214, 6);
expect(options).toMatchObject({
duration: 600,
padding: { top: 0, right: 0, bottom: 0, left: 0 }
});
expect(options).not.toHaveProperty("zoom");
expect(options).not.toHaveProperty("maxZoom");
});
});
+80
View File
@@ -0,0 +1,80 @@
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
type LngLatBounds = [[number, number], [number, number]];
type WebMercatorBbox = readonly [number, number, number, number];
export type NetworkViewParams = {
bbox3857: WebMercatorBbox;
};
const WEB_MERCATOR_RADIUS = 6378137;
const NO_PADDING: PaddingOptions = { top: 0, right: 0, bottom: 0, left: 0 };
export function getWorkbenchPadding(leftPanelOpen: boolean, rightPanelOpen: boolean): PaddingOptions {
return {
top: 88,
left: leftPanelOpen ? 530 : 88,
right: rightPanelOpen ? 430 : 120,
bottom: 96
};
}
export function getResponsiveWorkbenchPadding(
map: MapLibreMap,
leftPanelOpen: boolean,
rightPanelOpen: boolean
): PaddingOptions {
return getResponsivePadding(map, getWorkbenchPadding(leftPanelOpen, rightPanelOpen));
}
export function fitNetworkBounds(
map: MapLibreMap,
viewParams: NetworkViewParams = WATER_NETWORK_GLOBAL_VIEW,
padding: PaddingOptions = NO_PADDING
) {
map.fitBounds(
getLngLatBoundsFromBbox3857(viewParams.bbox3857),
{ padding: getResponsivePadding(map, padding), duration: 600 }
);
}
function getResponsivePadding(map: MapLibreMap, padding: PaddingOptions): PaddingOptions {
const canvas = map.getCanvas();
const width = canvas.clientWidth || canvas.width;
const height = canvas.clientHeight || canvas.height;
const horizontalPadding = padding.left + padding.right;
const verticalPadding = padding.top + padding.bottom;
const maxHorizontalPadding = width * 0.58;
const maxVerticalPadding = height * 0.58;
const horizontalScale = horizontalPadding > 0 ? maxHorizontalPadding / horizontalPadding : 1;
const verticalScale = verticalPadding > 0 ? maxVerticalPadding / verticalPadding : 1;
const scale = Math.min(1, horizontalScale, verticalScale);
if (scale >= 1) {
return padding;
}
return {
top: Math.round(padding.top * scale),
right: Math.round(padding.right * scale),
bottom: Math.round(padding.bottom * scale),
left: Math.round(padding.left * scale)
};
}
function getLngLatBoundsFromBbox3857(bbox3857: WebMercatorBbox): LngLatBounds {
const [minX, minY, maxX, maxY] = bbox3857;
return [
webMercatorToLngLat(minX, minY),
webMercatorToLngLat(maxX, maxY)
];
}
function webMercatorToLngLat(x: number, y: number): [number, number] {
const lng = (x / WEB_MERCATOR_RADIUS) * (180 / Math.PI);
const lat = (2 * Math.atan(Math.exp(y / WEB_MERCATOR_RADIUS)) - Math.PI / 2) * (180 / Math.PI);
return [lng, lat];
}
+291
View File
@@ -0,0 +1,291 @@
import type {
FilterSpecification,
GeoJSONSource,
GeoJSONSourceSpecification,
StyleSpecification
} from "maplibre-gl";
import type { FeatureCollection, Geometry, LineString, Point, Polygon } from "geojson";
import { createCircleCoordinates } from "./geo";
export const DRAWING_SOURCE_IDS = {
features: "workbench-drawing-features",
draft: "workbench-drawing-draft"
} as const;
export const DRAWING_INTERACTIVE_LAYER_IDS = [
"workbench-drawing-selected-polygon-outline",
"workbench-drawing-selected-line",
"workbench-drawing-selected-point",
"workbench-drawing-polygon-fill",
"workbench-drawing-polygon-outline",
"workbench-drawing-line",
"workbench-drawing-point"
];
export type DrawingFeatureProperties = {
id: string;
label: string;
kind: "point" | "line" | "polygon" | "circle";
createdAt: string;
};
export type DrawingFeatureCollection = FeatureCollection<Geometry, DrawingFeatureProperties>;
export const emptyDrawingFeatureCollection: DrawingFeatureCollection = {
type: "FeatureCollection",
features: []
};
export const drawingSources = {
features: {
type: "geojson",
data: emptyDrawingFeatureCollection
} satisfies GeoJSONSourceSpecification,
draft: {
type: "geojson",
data: emptyDrawingFeatureCollection
} satisfies GeoJSONSourceSpecification
};
export const drawingLayers: StyleSpecification["layers"] = [
{
id: "workbench-drawing-polygon-fill",
type: "fill",
source: DRAWING_SOURCE_IDS.features,
filter: ["==", ["geometry-type"], "Polygon"],
paint: {
"fill-color": "#2563eb",
"fill-opacity": 0.16
}
},
{
id: "workbench-drawing-polygon-outline",
type: "line",
source: DRAWING_SOURCE_IDS.features,
filter: ["==", ["geometry-type"], "Polygon"],
paint: {
"line-color": "#2563eb",
"line-width": 2,
"line-opacity": 0.82
}
},
{
id: "workbench-drawing-line",
type: "line",
source: DRAWING_SOURCE_IDS.features,
filter: ["==", ["geometry-type"], "LineString"],
paint: {
"line-color": "#2563eb",
"line-width": 3,
"line-opacity": 0.88
}
},
{
id: "workbench-drawing-point",
type: "circle",
source: DRAWING_SOURCE_IDS.features,
filter: ["==", ["geometry-type"], "Point"],
paint: {
"circle-color": "#2563eb",
"circle-radius": 6,
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 2
}
},
{
id: "workbench-drawing-selected-polygon-outline",
type: "line",
source: DRAWING_SOURCE_IDS.features,
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", ["get", "id"], ""]],
paint: {
"line-color": "#ff7a45",
"line-width": 4,
"line-opacity": 0.86
}
},
{
id: "workbench-drawing-selected-line",
type: "line",
source: DRAWING_SOURCE_IDS.features,
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", ["get", "id"], ""]],
paint: {
"line-color": "#ff7a45",
"line-width": 6,
"line-opacity": 0.72
}
},
{
id: "workbench-drawing-selected-point",
type: "circle",
source: DRAWING_SOURCE_IDS.features,
filter: ["all", ["==", ["geometry-type"], "Point"], ["==", ["get", "id"], ""]],
paint: {
"circle-color": "#ff7a45",
"circle-radius": 10,
"circle-opacity": 0.2,
"circle-stroke-color": "#ff7a45",
"circle-stroke-width": 2
}
},
{
id: "workbench-drawing-draft-polygon-fill",
type: "fill",
source: DRAWING_SOURCE_IDS.draft,
filter: ["==", ["geometry-type"], "Polygon"],
paint: {
"fill-color": "#ff7a45",
"fill-opacity": 0.12
}
},
{
id: "workbench-drawing-draft-line",
type: "line",
source: DRAWING_SOURCE_IDS.draft,
filter: ["any", ["==", ["geometry-type"], "LineString"], ["==", ["geometry-type"], "Polygon"]],
paint: {
"line-color": "#ff7a45",
"line-width": 2,
"line-dasharray": [2, 2],
"line-opacity": 0.9
}
},
{
id: "workbench-drawing-draft-vertex",
type: "circle",
source: DRAWING_SOURCE_IDS.draft,
filter: ["==", ["geometry-type"], "Point"],
paint: {
"circle-color": "#ff7a45",
"circle-radius": 4,
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 1.5
}
}
];
export function ensureDrawingLayers(map: {
getSource: (id: string) => unknown;
addSource: (id: string, source: GeoJSONSourceSpecification) => void;
getLayer: (id: string) => unknown;
addLayer: (layer: NonNullable<StyleSpecification["layers"]>[number]) => void;
}) {
if (!map.getSource(DRAWING_SOURCE_IDS.features)) {
map.addSource(DRAWING_SOURCE_IDS.features, drawingSources.features);
}
if (!map.getSource(DRAWING_SOURCE_IDS.draft)) {
map.addSource(DRAWING_SOURCE_IDS.draft, drawingSources.draft);
}
drawingLayers.forEach((layer) => {
if (!map.getLayer(layer.id)) {
map.addLayer(layer);
}
});
}
export function setDrawingSourceData(
map: { getSource: (id: string) => unknown },
sourceId: string,
data: DrawingFeatureCollection
) {
const source = map.getSource(sourceId) as GeoJSONSource | undefined;
source?.setData(data);
}
export function setSelectedDrawingFeatureId(
map: { getLayer: (id: string) => unknown; setFilter: (id: string, filter?: FilterSpecification) => void },
featureId: string | null
) {
const id = featureId ?? "";
[
["workbench-drawing-selected-polygon-outline", "Polygon"],
["workbench-drawing-selected-line", "LineString"],
["workbench-drawing-selected-point", "Point"]
].forEach(([layerId, geometryType]) => {
if (map.getLayer(layerId)) {
map.setFilter(layerId, ["all", ["==", ["geometry-type"], geometryType], ["==", ["get", "id"], id]]);
}
});
}
export function createPointFeature(
id: string,
coordinates: [number, number],
label: string
): DrawingFeatureCollection["features"][number] {
return {
type: "Feature",
id,
properties: createDrawingProperties(id, label, "point"),
geometry: {
type: "Point",
coordinates
} satisfies Point
};
}
export function createLineFeature(
id: string,
coordinates: [number, number][],
label: string
): DrawingFeatureCollection["features"][number] {
return {
type: "Feature",
id,
properties: createDrawingProperties(id, label, "line"),
geometry: {
type: "LineString",
coordinates
} satisfies LineString
};
}
export function createPolygonFeature(
id: string,
coordinates: [number, number][],
label: string
): DrawingFeatureCollection["features"][number] {
return {
type: "Feature",
id,
properties: createDrawingProperties(id, label, "polygon"),
geometry: {
type: "Polygon",
coordinates: [[...coordinates, coordinates[0]]]
} satisfies Polygon
};
}
export function createCircleFeature(
id: string,
center: [number, number],
radiusMeters: number,
label: string
): DrawingFeatureCollection["features"][number] {
const coordinates = createCircleCoordinates(center, radiusMeters);
return {
type: "Feature",
id,
properties: createDrawingProperties(id, label, "circle"),
geometry: {
type: "Polygon",
coordinates: [coordinates]
} satisfies Polygon
};
}
function createDrawingProperties(
id: string,
label: string,
kind: DrawingFeatureProperties["kind"]
): DrawingFeatureProperties {
return {
id,
label,
kind,
createdAt: new Date().toISOString()
};
}
+89
View File
@@ -0,0 +1,89 @@
import {
createCircleFeature,
createLineFeature,
createPolygonFeature,
type DrawingFeatureCollection
} from "./drawing-layers";
import { getDistanceMeters, type LngLatTuple } from "./geo";
export type WorkbenchDrawMode = "point" | "line" | "polygon" | "circle";
export type DrawingDraftState =
| {
mode: "line" | "polygon";
coordinates: LngLatTuple[];
}
| {
mode: "circle";
center: LngLatTuple;
edge?: LngLatTuple;
};
export function createDrawingId(mode: WorkbenchDrawMode, index: number) {
return `${mode}-${index.toString().padStart(3, "0")}`;
}
export function createDrawingLabel(mode: WorkbenchDrawMode, index: number) {
return `${getDrawingKindLabel(mode)} ${index.toString().padStart(2, "0")}`;
}
export function createFeatureFromDraft(
id: string,
draft: DrawingDraftState,
label: string
): DrawingFeatureCollection["features"][number] | null {
if (draft.mode === "line") {
return createLineFeature(id, draft.coordinates, label);
}
if (draft.mode === "polygon") {
return createPolygonFeature(id, draft.coordinates, label);
}
if (draft.mode !== "circle" || !draft.edge) {
return null;
}
return createCircleFeature(id, draft.center, getDistanceMeters(draft.center, draft.edge), label);
}
export function getDraftPointCount(draft: DrawingDraftState) {
if (draft.mode === "circle") {
return draft.edge ? 2 : 1;
}
return draft.coordinates.length;
}
export function getDrawingKindLabel(kind: WorkbenchDrawMode) {
if (kind === "point") {
return "点标注";
}
if (kind === "line") {
return "线标注";
}
if (kind === "circle") {
return "圆形标注";
}
return "范围标注";
}
export function getVisibleFeatureCollection(
featureCollection: DrawingFeatureCollection,
hiddenFeatureIds: Set<string>
): DrawingFeatureCollection {
return {
type: "FeatureCollection",
features: featureCollection.features.filter((feature) => !hiddenFeatureIds.has(feature.properties.id))
};
}
export function formatDrawingTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit"
}).format(new Date(value));
}
+115
View File
@@ -0,0 +1,115 @@
import maplibregl, { type Map as MapLibreMap, type StyleSpecification } from "maplibre-gl";
const MAX_EXPORT_DIMENSION = 4096;
const EXPORT_IDLE_TIMEOUT_MS = 6000;
export type ExportMapViewOptions = {
scale?: number;
targetLongEdge?: number;
filename?: string;
};
export async function exportMapViewImage(map: MapLibreMap, { scale, targetLongEdge, filename }: ExportMapViewOptions = {}) {
const canvas = map.getCanvas();
const sourceWidth = canvas.clientWidth || canvas.width;
const sourceHeight = canvas.clientHeight || canvas.height;
if (!sourceWidth || !sourceHeight) {
throw new Error("地图画布尺寸不可用。");
}
const requestedScale = targetLongEdge ? targetLongEdge / Math.max(sourceWidth, sourceHeight) : scale ?? 1;
const exportScale = Math.max(1, Math.min(requestedScale, MAX_EXPORT_DIMENSION / sourceWidth, MAX_EXPORT_DIMENSION / sourceHeight));
const exportWidth = Math.round(sourceWidth * exportScale);
const exportHeight = Math.round(sourceHeight * exportScale);
const container = createExportContainer(exportWidth, exportHeight);
const style = cloneMapStyle(map.getStyle());
const bounds = map.getBounds();
document.body.appendChild(container);
const exportMap = new maplibregl.Map({
container,
style,
center: map.getCenter(),
zoom: map.getZoom(),
pitch: map.getPitch(),
bearing: map.getBearing(),
preserveDrawingBuffer: true,
interactive: false,
attributionControl: false,
fadeDuration: 0
});
try {
await onceMapEvent(exportMap, "load");
exportMap.fitBounds(bounds, {
padding: 0,
duration: 0
});
await waitForMapIdle(exportMap);
const link = document.createElement("a");
link.download = filename ?? getDefaultExportFilename(getExportLabel(targetLongEdge, exportScale));
link.href = exportMap.getCanvas().toDataURL("image/png");
link.click();
return {
width: exportWidth,
height: exportHeight,
scale: exportScale
};
} finally {
exportMap.remove();
container.remove();
}
}
function createExportContainer(width: number, height: number) {
const container = document.createElement("div");
container.style.position = "fixed";
container.style.left = "-10000px";
container.style.top = "0";
container.style.width = `${width}px`;
container.style.height = `${height}px`;
container.style.pointerEvents = "none";
container.style.opacity = "0";
return container;
}
function cloneMapStyle(style: StyleSpecification) {
if (typeof structuredClone === "function") {
return structuredClone(style);
}
return JSON.parse(JSON.stringify(style)) as StyleSpecification;
}
function onceMapEvent(map: MapLibreMap, eventName: "load" | "idle") {
return new Promise<void>((resolve, reject) => {
map.once(eventName, () => resolve());
map.once("error", (event) => reject(event.error ?? new Error("地图导出渲染失败。")));
});
}
function waitForMapIdle(map: MapLibreMap) {
return Promise.race([
onceMapEvent(map, "idle"),
new Promise<void>((resolve) => {
window.setTimeout(resolve, EXPORT_IDLE_TIMEOUT_MS);
})
]);
}
function getExportLabel(targetLongEdge: number | undefined, scale: number) {
if (targetLongEdge === 3840) {
return "4k";
}
return `${scale.toFixed(1)}x`;
}
function getDefaultExportFilename(label: string) {
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
return `drainage-network-view-${label}-${timestamp}.png`;
}
+27
View File
@@ -0,0 +1,27 @@
import type { MapGeoJSONFeature } from "maplibre-gl";
import type { DetailFeature } from "../types";
import { formatValue } from "../utils/format-value";
import { SOURCE_LAYERS } from "./sources";
export function getFeatureId(feature: MapGeoJSONFeature) {
const raw = feature.properties?.id ?? feature.id;
return raw === undefined || raw === null ? "" : String(raw);
}
export function toDetailFeature(feature: MapGeoJSONFeature): DetailFeature {
const isPipe = feature.sourceLayer === SOURCE_LAYERS.pipes;
const id = getFeatureId(feature);
const diameter = feature.properties?.diameter;
const length = feature.properties?.length;
const demand = feature.properties?.demand;
return {
id,
layer: isPipe ? "pipes" : "junctions",
title: isPipe ? `管线 ${id || "未命名"}` : `节点 ${id || "未命名"}`,
subtitle: isPipe
? `DN${formatValue(diameter)} · ${formatValue(length)} m`
: `需水量 ${formatValue(demand)} · 高程 ${formatValue(feature.properties?.elevation)} m`,
properties: feature.properties ?? {}
};
}
+96
View File
@@ -0,0 +1,96 @@
export type LngLatTuple = [number, number];
const EARTH_RADIUS_METERS = 6371008.8;
const SAME_COORDINATE_EPSILON = 0.0000001;
export function getDistanceMeters(from: LngLatTuple, to: LngLatTuple) {
const fromLatitude = toRadians(from[1]);
const toLatitude = toRadians(to[1]);
const deltaLatitude = toRadians(to[1] - from[1]);
const deltaLongitude = toRadians(to[0] - from[0]);
const a =
Math.sin(deltaLatitude / 2) ** 2 +
Math.cos(fromLatitude) * Math.cos(toLatitude) * Math.sin(deltaLongitude / 2) ** 2;
return 2 * EARTH_RADIUS_METERS * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
export function getLineDistanceMeters(coordinates: LngLatTuple[]) {
return coordinates.reduce((total, coordinate, index) => {
if (index === 0) {
return total;
}
return total + getDistanceMeters(coordinates[index - 1], coordinate);
}, 0);
}
export function getLastSegmentDistanceMeters(coordinates: LngLatTuple[]) {
if (coordinates.length < 2) {
return 0;
}
return getDistanceMeters(coordinates[coordinates.length - 2], coordinates[coordinates.length - 1]);
}
export function getPolygonAreaSquareMeters(coordinates: LngLatTuple[]) {
if (coordinates.length < 3) {
return 0;
}
const closedCoordinates = [...coordinates, coordinates[0]];
const area = closedCoordinates.slice(0, -1).reduce((total, coordinate, index) => {
const nextCoordinate = closedCoordinates[index + 1];
return (
total +
toRadians(nextCoordinate[0] - coordinate[0]) *
(2 + Math.sin(toRadians(coordinate[1])) + Math.sin(toRadians(nextCoordinate[1])))
);
}, 0);
return Math.abs((area * EARTH_RADIUS_METERS * EARTH_RADIUS_METERS) / 2);
}
export function createCircleCoordinates(center: LngLatTuple, radiusMeters: number, steps = 48) {
const angularDistance = radiusMeters / EARTH_RADIUS_METERS;
const centerLongitude = toRadians(center[0]);
const centerLatitude = toRadians(center[1]);
const coordinates: LngLatTuple[] = [];
for (let index = 0; index <= steps; index += 1) {
const bearing = (2 * Math.PI * index) / steps;
const latitude = Math.asin(
Math.sin(centerLatitude) * Math.cos(angularDistance) +
Math.cos(centerLatitude) * Math.sin(angularDistance) * Math.cos(bearing)
);
const longitude =
centerLongitude +
Math.atan2(
Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(centerLatitude),
Math.cos(angularDistance) - Math.sin(centerLatitude) * Math.sin(latitude)
);
coordinates.push([toDegrees(longitude), toDegrees(latitude)]);
}
return coordinates;
}
export function isSameCoordinate(previous: LngLatTuple | undefined, next: LngLatTuple) {
if (!previous) {
return false;
}
return (
Math.abs(previous[0] - next[0]) < SAME_COORDINATE_EPSILON &&
Math.abs(previous[1] - next[1]) < SAME_COORDINATE_EPSILON
);
}
export function toRadians(value: number) {
return (value * Math.PI) / 180;
}
function toDegrees(value: number) {
return (value * 180) / Math.PI;
}
+136
View File
@@ -0,0 +1,136 @@
import type { StyleSpecification } from "maplibre-gl";
import { SOURCE_LAYERS } from "./sources";
export const waterNetworkLayers: StyleSpecification["layers"] = [
{
id: "pipes-casing",
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: 9,
paint: {
"line-color": "rgba(255,255,255,0.86)",
"line-width": [
"interpolate",
["linear"],
["zoom"],
9,
1,
13,
2.4,
17,
["+", 5, ["/", ["coalesce", ["get", "diameter"], 100], 280]]
],
"line-opacity": 0.9
}
},
{
id: "pipes-flow",
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: 9,
paint: {
"line-color": [
"case",
["==", ["get", "status"], "Closed"],
"#9ca3af",
[">=", ["coalesce", ["get", "diameter"], 0], 600],
"#0477bf",
[">=", ["coalesce", ["get", "diameter"], 0], 300],
"#0aa6a6",
"#3dbf7f"
],
"line-width": [
"interpolate",
["linear"],
["zoom"],
9,
0.8,
13,
1.8,
17,
["+", 2.5, ["/", ["coalesce", ["get", "diameter"], 100], 360]]
],
"line-opacity": 0.82
}
},
{
id: "pipes-risk-glow",
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: 12,
filter: [">=", ["coalesce", ["get", "diameter"], 0], 600],
paint: {
"line-color": "#ff7a45",
"line-width": ["interpolate", ["linear"], ["zoom"], 12, 2.6, 17, 7.5],
"line-blur": 2.5,
"line-opacity": 0.22
}
},
{
id: "pipes-hover",
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: 9,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": "#111827",
"line-width": 7,
"line-opacity": 0.35
}
},
{
id: "junctions-halo",
type: "circle",
source: "junctions",
"source-layer": SOURCE_LAYERS.junctions,
minzoom: 13,
paint: {
"circle-color": "#ffffff",
"circle-radius": ["interpolate", ["linear"], ["zoom"], 13, 2.8, 17, 6],
"circle-opacity": 0.82,
"circle-stroke-color": "rgba(15,23,42,0.22)",
"circle-stroke-width": 1
}
},
{
id: "junctions",
type: "circle",
source: "junctions",
"source-layer": SOURCE_LAYERS.junctions,
minzoom: 13,
paint: {
"circle-color": [
"interpolate",
["linear"],
["coalesce", ["get", "demand"], 0],
0,
"#70c1b3",
20,
"#247ba0",
60,
"#f25f5c"
],
"circle-radius": ["interpolate", ["linear"], ["zoom"], 13, 1.5, 17, 3.8],
"circle-opacity": 0.86
}
},
{
id: "junctions-hover",
type: "circle",
source: "junctions",
"source-layer": SOURCE_LAYERS.junctions,
minzoom: 13,
filter: ["==", ["get", "id"], ""],
paint: {
"circle-color": "#111827",
"circle-radius": ["interpolate", ["linear"], ["zoom"], 13, 5, 17, 10],
"circle-opacity": 0.2,
"circle-stroke-color": "#111827",
"circle-stroke-width": 2
}
}
];
@@ -0,0 +1,78 @@
import type { BaseLayerOption } from "@/features/map/core/components/base-layers-control";
import type { MapLayerControlItem } from "@/features/map/core/components/layer-control";
import type { MapLegendItem } from "@/features/map/core/components/legend";
export const WORKBENCH_LAYER_GROUPS: Record<string, string[]> = {
pipes: ["pipes-casing", "pipes-flow", "pipes-risk-glow", "pipes-hover"],
junctions: ["junctions-halo", "junctions", "junctions-hover"],
simulation: [
"simulation-impact-fill",
"simulation-impact-outline",
"simulation-burst-halo",
"simulation-burst-point",
"simulation-pipe-label",
"simulation-impact-label",
"simulation-district-label",
"simulation-burst-label"
]
};
export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = {
pipes: true,
junctions: true,
simulation: false
};
export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
{ id: "major-pipe", label: "DN600 及以上管线", color: "#0477bf", shape: "line" },
{ id: "medium-pipe", label: "DN300-DN600 管线", color: "#0aa6a6", shape: "line" },
{ id: "minor-pipe", label: "DN300 以下管线", color: "#3dbf7f", shape: "line" },
{ id: "junction-demand", label: "节点需水强度", color: "#f25f5c", shape: "dot" },
{ id: "impact-area", label: "影响范围", color: "#ef4444", shape: "square" }
];
export const BASE_LAYER_IDS = ["mapbox-light", "mapbox-satellite"] as const;
export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
{
id: "mapbox-light",
label: "浅色",
description: "道路底图",
swatchClassName: "bg-gradient-to-br from-slate-50 via-slate-100 to-blue-100"
},
{
id: "mapbox-satellite",
label: "影像",
description: "卫星街道",
swatchClassName: "bg-gradient-to-br from-emerald-950 via-emerald-700 to-yellow-200"
},
{
id: "none",
label: "无底图",
description: "仅业务图层",
swatchClassName: "bg-slate-100"
}
];
export function createLayerControlItems(layerVisibility: Record<string, boolean>): MapLayerControlItem[] {
return [
{
id: "pipes",
label: "管线",
description: "主干、支线与风险高亮",
visible: layerVisibility.pipes
},
{
id: "junctions",
label: "节点",
description: "用水节点与悬停反馈",
visible: layerVisibility.junctions
},
{
id: "simulation",
label: "模拟结果",
description: "影响范围与事故标注",
visible: layerVisibility.simulation
}
];
}
@@ -0,0 +1,153 @@
import type {
FilterSpecification,
GeoJSONSource,
GeoJSONSourceSpecification,
StyleSpecification
} from "maplibre-gl";
import type { FeatureCollection, Geometry, LineString, Point, Polygon } from "geojson";
import { SOURCE_LAYERS } from "./sources";
export const MEASUREMENT_SOURCE_ID = "workbench-measurement";
export const MEASUREMENT_SELECTED_PIPES_LAYER_ID = "workbench-measurement-selected-pipes";
export type MeasurementFeatureCollection = FeatureCollection<Geometry, { kind: "vertex" | "line" | "polygon" }>;
export const emptyMeasurementFeatureCollection: MeasurementFeatureCollection = {
type: "FeatureCollection",
features: []
};
export const measurementSource = {
type: "geojson",
data: emptyMeasurementFeatureCollection
} satisfies GeoJSONSourceSpecification;
export const measurementLayers: StyleSpecification["layers"] = [
{
id: MEASUREMENT_SELECTED_PIPES_LAYER_ID,
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: 9,
filter: ["in", ["get", "id"], ["literal", []]],
paint: {
"line-color": "#2563eb",
"line-width": ["interpolate", ["linear"], ["zoom"], 9, 4, 13, 7, 17, 11],
"line-opacity": 0.68
}
},
{
id: "workbench-measurement-polygon-fill",
type: "fill",
source: MEASUREMENT_SOURCE_ID,
filter: ["==", ["get", "kind"], "polygon"],
paint: {
"fill-color": "#2563eb",
"fill-opacity": 0.12
}
},
{
id: "workbench-measurement-line",
type: "line",
source: MEASUREMENT_SOURCE_ID,
filter: ["any", ["==", ["get", "kind"], "line"], ["==", ["get", "kind"], "polygon"]],
paint: {
"line-color": "#2563eb",
"line-width": 2.5,
"line-dasharray": [2, 1.5],
"line-opacity": 0.9
}
},
{
id: "workbench-measurement-vertex",
type: "circle",
source: MEASUREMENT_SOURCE_ID,
filter: ["==", ["get", "kind"], "vertex"],
paint: {
"circle-color": "#2563eb",
"circle-radius": 4,
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 1.5
}
}
];
export function ensureMeasurementLayers(map: {
getSource: (id: string) => unknown;
addSource: (id: string, source: GeoJSONSourceSpecification) => void;
getLayer: (id: string) => unknown;
addLayer: (layer: NonNullable<StyleSpecification["layers"]>[number]) => void;
}) {
if (!map.getSource(MEASUREMENT_SOURCE_ID)) {
map.addSource(MEASUREMENT_SOURCE_ID, measurementSource);
}
measurementLayers.forEach((layer) => {
if (!map.getLayer(layer.id)) {
map.addLayer(layer);
}
});
}
export function setMeasurementSourceData(
map: { getSource: (id: string) => unknown },
data: MeasurementFeatureCollection
) {
const source = map.getSource(MEASUREMENT_SOURCE_ID) as GeoJSONSource | undefined;
source?.setData(data);
}
export function setSelectedMeasurementPipeIds(
map: { getLayer: (id: string) => unknown; setFilter: (id: string, filter?: FilterSpecification) => void },
pipeIds: string[]
) {
if (!map.getLayer(MEASUREMENT_SELECTED_PIPES_LAYER_ID)) {
return;
}
map.setFilter(MEASUREMENT_SELECTED_PIPES_LAYER_ID, ["in", ["get", "id"], ["literal", pipeIds]]);
}
export function createMeasurementFeatureCollection(
coordinates: [number, number][],
mode: "distance" | "area" | "segment"
): MeasurementFeatureCollection {
const features: MeasurementFeatureCollection["features"] = coordinates.map((coordinate, index) => ({
type: "Feature",
id: `measurement-vertex-${index}`,
properties: { kind: "vertex" },
geometry: {
type: "Point",
coordinates: coordinate
} satisfies Point
}));
if (coordinates.length >= 2) {
features.push({
type: "Feature",
id: "measurement-line",
properties: { kind: "line" },
geometry: {
type: "LineString",
coordinates
} satisfies LineString
});
}
if (mode === "area" && coordinates.length >= 3) {
features.push({
type: "Feature",
id: "measurement-polygon",
properties: { kind: "polygon" },
geometry: {
type: "Polygon",
coordinates: [[...coordinates, coordinates[0]]]
} satisfies Polygon
});
}
return {
type: "FeatureCollection",
features
};
}
@@ -0,0 +1,12 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { simulationLayerIds } from "./annotation-layers";
export function setSimulationLayersVisibility(map: MapLibreMap, visible: boolean) {
const visibility = visible ? "visible" : "none";
simulationLayerIds.forEach((layerId) => {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, "visibility", visibility);
}
});
}
+101
View File
@@ -0,0 +1,101 @@
import type { StyleSpecification } from "maplibre-gl";
export const WATER_NETWORK_GLOBAL_VIEW = {
bbox3857: [
13508802,
3608164,
13555651,
3633686
]
} as const;
export const SOURCE_LAYERS = {
pipes: "geo_pipes_mat",
junctions: "geo_junctions_mat"
} as const;
export function createWaterNetworkSources() {
return {
pipes: {
type: "vector" as const,
tiles: [
"https://geoserver.waternetwork.cn/geoserver/gwc/service/wmts/rest/tjwater:geo_pipes_mat/line/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile"
],
minzoom: 0,
maxzoom: 24
},
junctions: {
type: "vector" as const,
tiles: [
"https://geoserver.waternetwork.cn/geoserver/gwc/service/wmts/rest/tjwater:geo_junctions_mat/generic/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile"
],
minzoom: 0,
maxzoom: 24
}
};
}
export function createBaseStyle(mapboxToken?: string): StyleSpecification {
const sources: StyleSpecification["sources"] = {};
const layers: StyleSpecification["layers"] = [
{
id: "background",
type: "background",
paint: {
"background-color": "#eef3f7"
}
}
];
if (mapboxToken) {
sources["mapbox-light"] = {
type: "raster",
tiles: [
`https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/512/{z}/{x}/{y}@2x?access_token=${mapboxToken}`
],
tileSize: 512,
attribution:
'© <a href="https://www.mapbox.com/about/maps/">Mapbox</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
};
sources["mapbox-satellite"] = {
type: "raster",
tiles: [
`https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/512/{z}/{x}/{y}@2x?access_token=${mapboxToken}`
],
tileSize: 512,
attribution:
'© <a href="https://www.mapbox.com/about/maps/">Mapbox</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
};
layers.push({
id: "mapbox-light",
type: "raster",
source: "mapbox-light",
paint: {
"raster-opacity": 0.82,
"raster-saturation": -0.22,
"raster-contrast": 0.04
}
});
layers.push({
id: "mapbox-satellite",
type: "raster",
source: "mapbox-satellite",
layout: {
visibility: "none"
},
paint: {
"raster-opacity": 0.86,
"raster-saturation": -0.16,
"raster-contrast": 0.02
}
});
}
return {
version: 8,
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
sources,
layers
};
}
+35
View File
@@ -0,0 +1,35 @@
import { BarChart3, Layers3, MapPinned, MoreHorizontal, Ruler } from "lucide-react";
import type { MapToolbarItem } from "@/features/map/core/components/toolbar";
export const waterNetworkToolbarItems: MapToolbarItem[] = [
{
id: "layers",
label: "图层",
description: "管理地图图层",
icon: Layers3
},
{
id: "measure",
label: "测量",
description: "距离与范围测量",
icon: Ruler
},
{
id: "analysis",
label: "分析",
description: "查看地图分析图例",
icon: BarChart3
},
{
id: "annotation",
label: "标注",
description: "管理地图标注",
icon: MapPinned
},
{
id: "more",
label: "更多",
description: "更多地图操作",
icon: MoreHorizontal
}
];
+135
View File
@@ -0,0 +1,135 @@
import type { Map as MapLibreMap } from "maplibre-gl";
export type DetailFeature = {
id: string;
layer: "pipes" | "junctions";
title: string;
subtitle: string;
properties: Record<string, unknown>;
};
export type WorkbenchMode = "simulation" | "feature-detail";
export type WorkbenchScenario = {
id: string;
name: string;
description: string;
status: "draft" | "active" | "review";
};
export type WorkbenchAlert = {
id: string;
description: string;
time: string;
title: string;
};
export type WorkbenchUser = {
name: string;
role: string;
};
export type ScheduledConditionStatus = "pending" | "running" | "completed" | "warning" | "error";
export type ScheduledConditionRiskLevel = "normal" | "attention" | "critical";
export type ScheduledConditionTaskId = "scada-diagnosis" | "smart-dispatch" | "pump-energy" | "network-simulation";
export type ScheduledConditionSolutionOption = {
id: string;
title: string;
scenario: string;
action: string;
tradeoff: string;
};
export type ScheduledConditionAnalysisInsight = {
summary: string;
notes: string[];
escalationCriteria?: string[];
solutionOptions?: ScheduledConditionSolutionOption[];
};
export type ScheduledConditionKpi = {
id: string;
label: string;
value: string;
unit?: string;
baseline?: string;
threshold?: string;
status: ScheduledConditionRiskLevel;
description: string;
};
export type ScheduledConditionReport = {
title: string;
conclusion: string;
sections: {
title: string;
items: string[];
}[];
};
type ScheduledConditionBase = {
id: string;
scheduledAt: string;
title: string;
summary: string;
status: ScheduledConditionStatus;
riskLevel: ScheduledConditionRiskLevel;
updatedAt: number;
detail?: string;
recommendation?: string;
durationMinutes?: number;
};
export type ScheduledConditionRecord = ScheduledConditionBase & {
kind: "condition";
taskId: ScheduledConditionTaskId;
sessionId: string;
evidence?: string[];
modelName?: string;
analysisInsight?: ScheduledConditionAnalysisInsight;
kpis?: ScheduledConditionKpi[];
report?: ScheduledConditionReport;
};
export type ScheduledWorkOrderItem = ScheduledConditionBase & {
kind: "work_order";
code: string;
source: string;
location: string;
dispatcher: string;
assignee: string;
priority: "normal" | "urgent";
replyWindowMinutes: number;
stages: ScheduledWorkOrderStage[];
replyRequirements: string[];
evidence?: never;
modelName?: never;
sessionId?: never;
};
export type ScheduledWorkOrderStageId = "dispatch" | "execution" | "reply";
export type ScheduledWorkOrderStageStatus = "done" | "current" | "pending";
export type ScheduledWorkOrderStage = {
id: ScheduledWorkOrderStageId;
title: string;
status: ScheduledWorkOrderStageStatus;
owner: string;
timeLabel: string;
description: string;
};
export type ScheduledConditionItem = ScheduledConditionRecord | ScheduledWorkOrderItem;
export type MapAnnotation = {
id: string;
label: string;
coordinate: [number, number];
kind: "burst" | "pipe-label" | "district" | "tooltip";
};
export type WorkbenchMap = MapLibreMap;
@@ -0,0 +1,111 @@
import { formatValue } from "./format-value";
export type LocalizedFeatureProperty = {
key: string;
label: string;
value: string;
};
const PROPERTY_LABELS: Record<string, string> = {
id: "编号",
gid: "要素编号",
name: "名称",
code: "编码",
type: "类型",
node1: "起点节点",
node2: "终点节点",
from_node: "起点节点",
to_node: "终点节点",
diameter: "管径",
dn: "管径",
length: "长度",
roughness: "粗糙系数",
status: "状态",
material: "材质",
elevation: "高程",
demand: "需水量",
pressure: "压力",
flow: "流量",
velocity: "流速",
zone: "分区",
district: "片区",
source: "来源",
updated_at: "更新时间"
};
const PROPERTY_ORDER: Record<string, number> = {
id: 10,
gid: 11,
name: 12,
code: 13,
diameter: 20,
dn: 20,
length: 21,
roughness: 22,
material: 23,
node1: 24,
from_node: 24,
node2: 25,
to_node: 25,
elevation: 30,
demand: 31,
pressure: 32,
flow: 33,
velocity: 34,
status: 90
};
const STATUS_LABELS: Record<string, string> = {
active: "运行中",
inactive: "停用",
online: "在线",
offline: "离线",
open: "开启",
closed: "关闭",
normal: "正常",
warning: "告警",
critical: "严重"
};
export function getLocalizedFeatureProperties(properties: Record<string, unknown>): LocalizedFeatureProperty[] {
return Object.entries(properties).map(([key, value]) => ({
key,
label: getFeaturePropertyLabel(key),
value: formatFeaturePropertyValue(key, value)
})).sort((first, second) => getPropertyOrder(first.key) - getPropertyOrder(second.key));
}
export function getFeaturePropertyLabel(key: string) {
const normalizedKey = normalizePropertyKey(key);
return PROPERTY_LABELS[normalizedKey] ?? key;
}
export function formatFeaturePropertyValue(key: string, value: unknown) {
const normalizedKey = normalizePropertyKey(key);
if (isStatusKey(key) && typeof value === "string") {
return STATUS_LABELS[value.toLowerCase()] ?? value;
}
if ((normalizedKey === "diameter" || normalizedKey === "dn") && value !== null && value !== undefined && value !== "") {
return `DN${formatValue(value)}`;
}
if ((normalizedKey === "length" || normalizedKey === "elevation") && value !== null && value !== undefined && value !== "") {
return `${formatValue(value)} m`;
}
return formatValue(value);
}
function getPropertyOrder(key: string) {
return PROPERTY_ORDER[normalizePropertyKey(key)] ?? 100;
}
function normalizePropertyKey(key: string) {
return key.trim().replaceAll("-", "_").toLowerCase();
}
function isStatusKey(key: string) {
return normalizePropertyKey(key) === "status";
}
+11
View File
@@ -0,0 +1,11 @@
export function formatValue(value: unknown) {
if (typeof value === "number") {
return Number.isInteger(value) ? value.toString() : value.toFixed(2);
}
if (value === null || value === undefined || value === "") {
return "暂无";
}
return String(value);
}
@@ -0,0 +1,142 @@
import { getRunningWorkflowDefinition } from "@/features/workbench/data/running-workflows";
import type { ScheduledConditionItem, ScheduledConditionStatus, WorkbenchAlert } from "@/features/workbench/types";
const SCHEDULED_CONDITION_ALERT_ID_PREFIX = "scheduled-condition-alert-";
const statusLabels: Record<ScheduledConditionStatus, string> = {
completed: "完成",
running: "执行中",
warning: "关注",
error: "异常",
pending: "预约"
};
export function createConditionConversationPrompt(condition: ScheduledConditionItem) {
const workflow =
condition.kind === "condition"
? getRunningWorkflowDefinition(condition)
: null;
const evidence = condition.evidence?.map((item, index) => `${index + 1}. ${item}`).join("\n") || "无";
const kpis =
condition.kind === "condition" && condition.kpis?.length
? condition.kpis
.map((kpi, index) => {
const value = `${kpi.value}${kpi.unit ?? ""}`;
const threshold = kpi.threshold ? `,阈值:${kpi.threshold}` : "";
return `${index + 1}. ${kpi.label}${value},状态:${getRiskLabel(kpi.status)}${threshold}`;
})
.join("\n")
: "无";
const report =
condition.kind === "condition" && condition.report
? [
condition.report.conclusion,
...condition.report.sections.map(
(section) => `${section.title}${section.items.join("")}`
)
].join("\n")
: null;
return [
"请继续分析这条工况任务,并基于当前上下文给出下一步调度建议。",
"",
`时间:${formatTime(condition.scheduledAt)}`,
"类型:历史工况",
workflow ? `任务工作流:${workflow.title}` : null,
workflow ? `工作流步骤:${workflow.steps.map((step, index) => `${index + 1}. ${step.title}`).join("")}` : null,
`标题:${condition.title}`,
`状态:${statusLabels[condition.status]}`,
`风险:${getRiskLabel(condition.riskLevel)}`,
`摘要:${condition.summary}`,
condition.detail ? `详情:${condition.detail}` : null,
`关键证据:\n${evidence}`,
`关键指标:\n${kpis}`,
report ? `工况报告:\n${report}` : null,
condition.recommendation ? `已有建议:${condition.recommendation}` : null,
condition.sessionId ? `sessionId${condition.sessionId}` : null,
"",
"请先说明当前判断,再列出需要人工确认的事项和建议动作。"
]
.filter(Boolean)
.join("\n");
}
export function createAlertQueueConversationPrompt({
alerts,
conditions
}: {
alerts: WorkbenchAlert[];
conditions: ScheduledConditionItem[];
}) {
const alertSummaries = alerts.map((alert, index) => {
const condition = findConditionForAlert(alert, conditions);
const baseSummary = [
`${index + 1}. ${alert.title}`,
"状态:待复核",
`时间:${alert.time}`,
`描述:${alert.description}`
];
if (!condition) {
return baseSummary.join("\n");
}
return [
...baseSummary,
`关联工况:${condition.title}`,
`工况状态:${statusLabels[condition.status]}`,
`风险等级:${getRiskLabel(condition.riskLevel)}`,
condition.detail ? `工况详情:${condition.detail}` : null,
condition.evidence?.length ? `关键证据:\n${condition.evidence.map((item, evidenceIndex) => `${evidenceIndex + 1}. ${item}`).join("\n")}` : null,
condition.recommendation ? `已有建议:${condition.recommendation}` : null,
condition.kind === "condition" && condition.analysisInsight?.solutionOptions?.length
? `已有处置方案:\n${condition.analysisInsight.solutionOptions.map((option, optionIndex) => `${optionIndex + 1}. ${option.title}${option.action};代价:${option.tradeoff}`).join("\n")}`
: null
]
.filter(Boolean)
.join("\n");
});
return [
"请作为排水管网调度 Agent,汇总当前待复核工况队列。",
"",
"目标:",
"1. 汇总这一组待复核工况之间的关联关系和处理顺序。",
"2. 列出支撑判断的关键证据和不确定性。",
"3. 给出处置方案,包含建议动作、适用条件、影响范围和需要人工确认的事项。",
"4. 不要自动执行阀门、泵站或工单动作,先给调度员确认。",
"",
`待复核工况数量:${alerts.length}`,
"",
"待复核工况上下文:",
alertSummaries.join("\n\n"),
"",
"请先给出总体判断,再按工况列出处置建议,最后列出需要补充核查的数据。"
].join("\n");
}
function findConditionForAlert(alert: WorkbenchAlert, conditions: ScheduledConditionItem[]) {
if (!alert.id.startsWith(SCHEDULED_CONDITION_ALERT_ID_PREFIX)) {
return null;
}
const conditionId = alert.id.slice(SCHEDULED_CONDITION_ALERT_ID_PREFIX.length);
return conditions.find((condition) => condition.id === conditionId) ?? null;
}
function formatTime(value: string) {
const match = value.match(/T(\d{2}:\d{2})/);
return match?.[1] ?? value;
}
function getRiskLabel(riskLevel: ScheduledConditionItem["riskLevel"]) {
if (riskLevel === "critical") {
return "需重点复核";
}
if (riskLevel === "attention") {
return "关注";
}
return "正常";
}