feat: align frontend runtime and Edge TTS
This commit is contained in:
+3
-3
@@ -22,13 +22,13 @@ test("renders the migrated WebGIS workbench shell", async ({ page }) => {
|
||||
expect(Object.keys(runtimeConfig as Record<string, unknown>)).not.toContainEqual(
|
||||
expect.stringMatching(/^(NEXT_PUBLIC_|VITE_)/)
|
||||
);
|
||||
expect(runtimeConfig).not.toHaveProperty("TJWATER_TTS_API_URL");
|
||||
expect(runtimeConfig).not.toHaveProperty("EDGE_TTS_VOICE");
|
||||
|
||||
const mapAsset = await page.request.get("/map/valve.png");
|
||||
expect(mapAsset.ok()).toBe(true);
|
||||
expect(mapAsset.headers()["content-type"]).toBe("image/png");
|
||||
await expect(page.locator('[aria-label="Agent 命令面板"] canvas').first()).toBeVisible({
|
||||
timeout: 25_000
|
||||
});
|
||||
await expect(page.getByLabel("Agent 状态").first()).toBeVisible();
|
||||
await expect.poll(() => riveRequests.length, { timeout: 25_000 }).toBe(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,28 +8,35 @@ import { AgentPersona } from "./agent-persona";
|
||||
|
||||
type AgentCollapsedRailProps = {
|
||||
personaState?: PersonaState;
|
||||
statusLabel?: string;
|
||||
onExpand: () => void;
|
||||
};
|
||||
|
||||
export function AgentCollapsedRail({ personaState, onExpand }: AgentCollapsedRailProps) {
|
||||
export function AgentCollapsedRail({
|
||||
personaState,
|
||||
statusLabel = "Agent 已就绪",
|
||||
onExpand
|
||||
}: AgentCollapsedRailProps) {
|
||||
const expandLabel = `展开 Agent 助手面板,当前状态:${statusLabel}`;
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Agent 折叠栏"
|
||||
className={cn(
|
||||
"agent-rail-enter acrylic-panel pointer-events-auto w-[136px] border p-1.5",
|
||||
"agent-rail-enter acrylic-panel pointer-events-auto w-[72px] border p-1.5",
|
||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="展开排水助手面板"
|
||||
title="展开排水助手面板"
|
||||
aria-label={expandLabel}
|
||||
title={expandLabel}
|
||||
onClick={onExpand}
|
||||
className="agent-rail-item surface-control group flex h-14 w-full items-center justify-center gap-4 rounded-xl border px-3 transition-[background-color,transform] hover:bg-white active:scale-95"
|
||||
className="agent-rail-item surface-control group relative flex h-14 w-full items-center justify-center rounded-xl border transition-[background-color,transform] hover:bg-white active:scale-95"
|
||||
>
|
||||
<AgentPersona className="h-12 w-12" state={personaState} />
|
||||
<span className="agent-panel-primary-icon grid h-8 w-8 shrink-0 place-items-center rounded-lg border-0! transition-transform group-hover:translate-x-0.5">
|
||||
<ChevronRight size={17} strokeWidth={2.25} aria-hidden="true" />
|
||||
<AgentPersona className="h-11 w-11" state={personaState} />
|
||||
<span className="agent-panel-primary-icon absolute bottom-0.5 right-0.5 grid h-5 w-5 place-items-center rounded-md border-0! shadow-xs transition-transform group-hover:translate-x-0.5">
|
||||
<ChevronRight size={13} strokeWidth={2.5} aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
@@ -104,7 +104,7 @@ function createOperationalBrief(messages: AgentChatMessage[], statusLabel: strin
|
||||
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() || "当前排水管网运行态势";
|
||||
const promptContext = lastUserMessage?.content.trim() || "当前供水管网运行态势";
|
||||
|
||||
return {
|
||||
task,
|
||||
|
||||
@@ -58,7 +58,11 @@ export function AgentPersona({ className, state = "idle" }: AgentPersonaProps) {
|
||||
const shouldRenderAnimation = !failed && visible;
|
||||
|
||||
return (
|
||||
<span ref={containerRef} className={cn("relative block shrink-0", className)}>
|
||||
<span
|
||||
ref={containerRef}
|
||||
aria-label="Agent 状态"
|
||||
className={cn("relative block shrink-0", className)}
|
||||
>
|
||||
{shouldRenderAnimation ? (
|
||||
<Suspense fallback={<PersonaFallback />}>
|
||||
<LazyPersona
|
||||
|
||||
@@ -20,6 +20,13 @@ describe("toTrustedMapAction", () => {
|
||||
})
|
||||
).toBeNull();
|
||||
expect(toTrustedMapAction("apply_layer_style", {})).toBeNull();
|
||||
expect(
|
||||
toTrustedMapAction("apply_layer_style", {
|
||||
layer_group_id: "pipes",
|
||||
layer_id: "scada",
|
||||
visible: true
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects out-of-range coordinates and zoom", () => {
|
||||
@@ -83,6 +90,7 @@ describe("toTrustedMapAction", () => {
|
||||
it("accepts supply layer visibility and scada locate actions", () => {
|
||||
expect(toTrustedMapAction("apply_layer_style", { layer_id: "scada", visible: false })).toMatchObject({
|
||||
type: "apply_layer_style",
|
||||
layerGroupId: "scada",
|
||||
layerId: "scada",
|
||||
visible: false
|
||||
});
|
||||
@@ -92,4 +100,20 @@ describe("toTrustedMapAction", () => {
|
||||
layer: "geo_scada"
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown layers and accepts known visibility controls", () => {
|
||||
expect(toTrustedMapAction("locate_features", { ids: ["P1"], layer: "geo_unknown" })).toBeNull();
|
||||
expect(toTrustedMapAction("apply_layer_style", { layer_id: "pipes", visible: true })).toMatchObject({
|
||||
type: "apply_layer_style",
|
||||
layerGroupId: "pipes",
|
||||
layerId: "pipes",
|
||||
visible: true
|
||||
});
|
||||
expect(toTrustedMapAction("apply_layer_style", { layer_group_id: "simulation", visible: false })).toMatchObject({
|
||||
type: "apply_layer_style",
|
||||
layerGroupId: "simulation",
|
||||
visible: false
|
||||
});
|
||||
expect(toTrustedMapAction("apply_layer_style", { layer_id: "conduits", visible: true })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ export type TrustedMapAction =
|
||||
}
|
||||
| {
|
||||
type: "apply_layer_style";
|
||||
layerGroupId?: string;
|
||||
layerGroupId: string;
|
||||
layerId?: string;
|
||||
visible?: boolean;
|
||||
fallbackText?: string;
|
||||
@@ -33,12 +33,14 @@ export function toTrustedMapAction(action: string, params: unknown, fallbackText
|
||||
if (action === "locate_features" || action in LEGACY_LOCATE_ACTION_LAYERS) {
|
||||
const featureIds = readLocateIds(params);
|
||||
if (featureIds.length === 0 || featureIds.length > 100 || featureIds.some((id) => id.length > 128)) return null;
|
||||
const layer =
|
||||
stringValue(params.layer ?? params.target_layer ?? params.targetLayer) ??
|
||||
LEGACY_LOCATE_ACTION_LAYERS[action];
|
||||
if (layer && !ALLOWED_LOCATE_LAYERS.has(layer)) return null;
|
||||
return {
|
||||
type: "locate_features",
|
||||
featureIds,
|
||||
layer:
|
||||
stringValue(params.layer ?? params.target_layer ?? params.targetLayer) ??
|
||||
LEGACY_LOCATE_ACTION_LAYERS[action],
|
||||
layer,
|
||||
fallbackText
|
||||
};
|
||||
}
|
||||
@@ -66,11 +68,18 @@ export function toTrustedMapAction(action: string, params: unknown, fallbackText
|
||||
if (action === "apply_layer_style") {
|
||||
const layerGroupId = stringValue(params.layer_group_id ?? params.layerGroupId);
|
||||
const layerId = stringValue(params.layer_id ?? params.layerId);
|
||||
const resolvedLayerGroupId = layerGroupId ?? layerId;
|
||||
const visible = booleanValue(params.visible);
|
||||
if ((!layerGroupId && !layerId) || visible === undefined || (layerId && !ALLOWED_LAYER_IDS.has(layerId))) return null;
|
||||
if (
|
||||
!resolvedLayerGroupId ||
|
||||
visible === undefined ||
|
||||
(layerGroupId && !ALLOWED_LAYER_GROUP_IDS.has(layerGroupId)) ||
|
||||
(layerId && !ALLOWED_LAYER_IDS.has(layerId)) ||
|
||||
(layerGroupId && layerId && layerGroupId !== layerId)
|
||||
) return null;
|
||||
return {
|
||||
type: "apply_layer_style",
|
||||
layerGroupId,
|
||||
layerGroupId: resolvedLayerGroupId,
|
||||
layerId,
|
||||
visible,
|
||||
fallbackText
|
||||
@@ -165,7 +174,21 @@ const LEGACY_LOCATE_ACTION_LAYERS: Record<string, string> = {
|
||||
};
|
||||
|
||||
const RESULT_REF_PATTERN = /^res-[A-Za-z0-9_-]{8,128}$/;
|
||||
const ALLOWED_LAYER_IDS = new Set(["junctions", "pipes", "valves", "reservoirs", "scada"]);
|
||||
const SUPPLY_SOURCE_IDS = ["junctions", "pipes", "valves", "reservoirs", "scada"];
|
||||
const ALLOWED_LAYER_IDS = new Set(SUPPLY_SOURCE_IDS);
|
||||
const ALLOWED_LAYER_GROUP_IDS = new Set([...SUPPLY_SOURCE_IDS, "simulation"]);
|
||||
const ALLOWED_LOCATE_LAYERS = new Set([
|
||||
...SUPPLY_SOURCE_IDS,
|
||||
"pumps",
|
||||
"tanks",
|
||||
"geo_junctions_mat",
|
||||
"geo_pipes_mat",
|
||||
"geo_valves",
|
||||
"geo_reservoirs",
|
||||
"geo_scada",
|
||||
"geo_pumps",
|
||||
"geo_tanks"
|
||||
]);
|
||||
const WEB_MERCATOR_RADIUS = 6378137;
|
||||
|
||||
const LOCATE_ID_PARAM_KEYS = [
|
||||
|
||||
@@ -152,7 +152,7 @@ export function MapDevPanel({ commands, controllerState, detailFeature, onClose
|
||||
<button type="button" onClick={commands.clearHighlight} className={secondaryButtonClassName}>清除高亮</button>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="全网水流" description="按管渠拓扑方向显示静态或动画流纹。">
|
||||
<PanelSection title="全网水流" description="按管线拓扑方向显示静态或动画流纹。">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button type="button" disabled={controllerState.pending || controllerState.flowVisible} onClick={() => void commands.setFlowVisible(true)} className={primaryButtonClassName}>开启水流</button>
|
||||
<button type="button" disabled={!controllerState.flowVisible} onClick={() => void commands.setFlowVisible(false)} className={secondaryButtonClassName}>关闭水流</button>
|
||||
|
||||
@@ -124,7 +124,7 @@ export const runningWorkflowDefinitions: Record<ScheduledConditionTaskId, Runnin
|
||||
{
|
||||
id: "calculate-energy",
|
||||
title: "计算单位电耗",
|
||||
detail: "按当前窗口排水量折算单位排水电耗和效率偏差。"
|
||||
detail: "按当前窗口供水量折算单位供水电耗和效率偏差。"
|
||||
},
|
||||
{
|
||||
id: "compare-efficiency",
|
||||
|
||||
@@ -92,7 +92,7 @@ const CONDITION_TASKS: ConditionTaskDefinition[] = [
|
||||
{
|
||||
id: "pump-energy",
|
||||
title: "泵站能耗检查",
|
||||
summary: "检查泵组负载、单位排水电耗、启停频率和供压稳定性。",
|
||||
summary: "检查泵组负载、单位供水电耗、启停频率和供压稳定性。",
|
||||
intervalMinutes: 60,
|
||||
durationMinutes: 10
|
||||
}
|
||||
@@ -709,16 +709,16 @@ function createTaskKpis(task: ConditionTaskDefinition, signal: TaskSignal): Sche
|
||||
return [
|
||||
{
|
||||
id: "energy-deviation",
|
||||
label: "单位排水电耗偏差",
|
||||
label: "单位供水电耗偏差",
|
||||
value: energyDeviation.toString(),
|
||||
unit: "%",
|
||||
threshold: "关注 > 10%,异常 > 15%",
|
||||
status: getMetricRisk(energyDeviation, 10, 15),
|
||||
description: "本轮单位排水电耗相对同窗基线的偏差。"
|
||||
description: "本轮单位供水电耗相对同窗基线的偏差。"
|
||||
},
|
||||
{
|
||||
id: "unit-energy",
|
||||
label: "单位排水电耗",
|
||||
label: "单位供水电耗",
|
||||
value: unitEnergy.toString(),
|
||||
unit: "kWh/m3",
|
||||
baseline: "同窗经济运行区间 0.28-0.34",
|
||||
@@ -780,10 +780,10 @@ function createDispatchInstructions(signal: TaskSignal): DispatchInstruction[] {
|
||||
action: "泵组组合优化",
|
||||
target: "1# 泵站 3# / 4# 泵组",
|
||||
command: "保持 3# 泵运行,延后 4# 泵启动 10 分钟,避免低效并联区间",
|
||||
verification: "复核单位排水电耗是否低于 0.34kWh/m3"
|
||||
verification: "复核单位供水电耗是否低于 0.34kWh/m3"
|
||||
},
|
||||
{
|
||||
action: "高区排水边界收敛",
|
||||
action: "高区供水边界收敛",
|
||||
target: "高区联络阀 HV-204",
|
||||
command: "开度下调 3%,减少高区向中区回流",
|
||||
verification: "确认高区末端压力不低于 0.22MPa"
|
||||
@@ -819,7 +819,7 @@ function createDispatchInstructions(signal: TaskSignal): DispatchInstruction[] {
|
||||
{
|
||||
action: "阀门开度回退",
|
||||
target: "华山路沿线阀门组 VG-11",
|
||||
command: "将昨日临时开度回退 2%,恢复常规排水边界",
|
||||
command: "将昨日临时开度回退 2%,恢复常规供水边界",
|
||||
verification: "观察华山路和人民路支线压差不超过 2.5m"
|
||||
},
|
||||
{
|
||||
@@ -911,7 +911,7 @@ function createGuidanceItems(
|
||||
|
||||
if (task.id === "pump-energy") {
|
||||
return [
|
||||
"关注单位排水电耗和启停频率,判断是否存在泵组组合不经济。",
|
||||
"关注单位供水电耗和启停频率,判断是否存在泵组组合不经济。",
|
||||
"若电耗偏差持续升高,建议比较相邻泵组效率曲线并优化启停策略。",
|
||||
"调整前需确认供压稳定性,避免节能动作引发末端低压。"
|
||||
];
|
||||
|
||||
@@ -23,5 +23,5 @@ export const WORKBENCH_SCENARIOS: WorkbenchScenario[] = [
|
||||
|
||||
export const WORKBENCH_USER: WorkbenchUser = {
|
||||
name: "调度员",
|
||||
role: "排水运行调度中心"
|
||||
role: "供水运行调度中心"
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DefaultChatTransport } from "ai";
|
||||
import useSWR from "swr";
|
||||
import useSWRImmutable from "swr/immutable";
|
||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { showMapNotice } from "@/features/map/core/components/notice-actions";
|
||||
import type {
|
||||
AgentApprovalMode,
|
||||
@@ -209,7 +210,7 @@ export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkben
|
||||
const transport = useMemo(
|
||||
() =>
|
||||
new DefaultChatTransport<AgentUiMessage>({
|
||||
api: "/api/v1/agent/chat/stream",
|
||||
api: `${env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")}/api/v1/agent/chat/stream`,
|
||||
prepareSendMessagesRequest({ id, messages, body, trigger, messageId }) {
|
||||
return {
|
||||
body: {
|
||||
|
||||
@@ -902,7 +902,7 @@ export function MapWorkbenchPage() {
|
||||
ref={mapContainerRef}
|
||||
className="map-grid"
|
||||
style={{ position: "absolute", inset: 0 }}
|
||||
aria-label="排水管网地图"
|
||||
aria-label="供水管网地图"
|
||||
/>
|
||||
|
||||
<WorkbenchTopBar
|
||||
@@ -969,7 +969,11 @@ export function MapWorkbenchPage() {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<AgentCollapsedRail personaState={agent.personaState} onExpand={agent.expandPanel} />
|
||||
<AgentCollapsedRail
|
||||
personaState={agent.personaState}
|
||||
statusLabel={agent.statusLabel}
|
||||
onExpand={agent.expandPanel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ const PROPERTY_LABELS: Record<string, string> = {
|
||||
sensor_name: "传感器名称",
|
||||
swmm_node: "SWMM 节点",
|
||||
topology_order: "拓扑序",
|
||||
distance_to_wwtp_m: "距污水厂距离",
|
||||
distance_to_wwtp_m: "距处理厂距离",
|
||||
upstream_node_count: "上游节点数",
|
||||
upstream_sensors: "上游传感器",
|
||||
downstream_path: "下游路径",
|
||||
@@ -103,7 +103,7 @@ const PROPERTY_LABELS: Record<string, string> = {
|
||||
device_icon_code: "设备图标编码",
|
||||
active: "运行状态",
|
||||
external_id: "设备编号",
|
||||
junction_id: "关联检查井",
|
||||
junction_id: "关联节点",
|
||||
kind: "设备类型",
|
||||
location_source: "位置来源",
|
||||
site_external_id: "站点编号",
|
||||
|
||||
@@ -98,7 +98,7 @@ export function createAlertQueueConversationPrompt({
|
||||
});
|
||||
|
||||
return [
|
||||
"请作为排水管网调度 Agent,汇总当前待复核工况队列。",
|
||||
"请作为供水管网调度 Agent,汇总当前待复核工况队列。",
|
||||
"",
|
||||
"目标:",
|
||||
"1. 汇总这一组待复核工况之间的关联关系和处理顺序。",
|
||||
|
||||
@@ -33,4 +33,12 @@ describe("runtime frontend configuration", () => {
|
||||
it("rejects invalid runtime URLs before the application starts", () => {
|
||||
expect(() => parseRuntimeConfig({ TJWATER_MAP_URL: "not-a-url" })).toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["TJWATER_MAP_URL", "file:///etc/passwd"],
|
||||
["TJWATER_AGENT_API_BASE_URL", "https://user:secret@agent.example.test"],
|
||||
["TJWATER_AGENT_API_BASE_URL", "https://agent.example.test/#secret"]
|
||||
])("rejects unsafe browser runtime address %s", (key, value) => {
|
||||
expect(() => parseRuntimeConfig({ [key]: value })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,11 +6,27 @@ const runtimeBoolean = (defaultValue: boolean) =>
|
||||
.default(defaultValue ? "true" : "false")
|
||||
.transform((value) => value === "true");
|
||||
|
||||
const browserHttpUrl = z
|
||||
.string()
|
||||
.url()
|
||||
.superRefine((value, context) => {
|
||||
const url = new URL(value);
|
||||
if (!["http:", "https:"].includes(url.protocol)) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, message: "仅支持 HTTP(S) 地址" });
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, message: "浏览器运行时地址不能包含凭据" });
|
||||
}
|
||||
if (url.hash) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, message: "浏览器运行时地址不能包含片段" });
|
||||
}
|
||||
});
|
||||
|
||||
const runtimeConfigSchema = z.object({
|
||||
TJWATER_MAPBOX_ACCESS_TOKEN: z.string().default(""),
|
||||
TJWATER_MAP_URL: z.string().url().default("https://geoserver.waternetwork.cn/geoserver"),
|
||||
TJWATER_GEOSERVER_WORKSPACE: z.string().min(1).default("tjwater"),
|
||||
TJWATER_AGENT_API_BASE_URL: z.string().url().default("http://127.0.0.1:8787"),
|
||||
TJWATER_MAP_URL: browserHttpUrl.default("https://geoserver.waternetwork.cn/geoserver"),
|
||||
TJWATER_GEOSERVER_WORKSPACE: z.string().trim().min(1).default("tjwater"),
|
||||
TJWATER_AGENT_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8787"),
|
||||
TJWATER_ENABLE_DEV_PANEL: runtimeBoolean(false),
|
||||
TJWATER_ENABLE_MSW: runtimeBoolean(false)
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const featuresRoot = join(process.cwd(), "src/features");
|
||||
const forbiddenDrainageTerms = [
|
||||
"排水管网",
|
||||
"单位排水电耗",
|
||||
"排水边界",
|
||||
"距污水厂距离",
|
||||
"关联检查井",
|
||||
"管渠拓扑"
|
||||
] as const;
|
||||
|
||||
function listRuntimeFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const target = join(directory, entry.name);
|
||||
if (entry.isDirectory()) return listRuntimeFiles(target);
|
||||
if (!/\.(ts|tsx)$/.test(entry.name) || /\.(test|e2e)\.(ts|tsx)$/.test(entry.name)) return [];
|
||||
return [target];
|
||||
});
|
||||
}
|
||||
|
||||
describe("supply domain boundary", () => {
|
||||
it("does not reintroduce drainage-only operating language", () => {
|
||||
const violations = listRuntimeFiles(featuresRoot).flatMap((file) => {
|
||||
const source = readFileSync(file, "utf8");
|
||||
return forbiddenDrainageTerms
|
||||
.filter((term) => source.includes(term))
|
||||
.map((term) => `${file.replace(`${featuresRoot}/`, "")}: ${term}`);
|
||||
});
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user