From 42cb70a3ab252208a0bd8e900cc1e9a43e83ce73 Mon Sep 17 00:00:00 2001 From: =?utf8?q?wang=2Eyu=28=E7=8E=8B=E7=91=9C=29?= Date: Mon, 24 Aug 2026 10:23:33 +0800 Subject: [PATCH 1/1] =?utf8?q?=E9=87=8D=E6=96=B0=E5=88=9D=E5=A7=8B?= =?utf8?q?=E5=8C=96=E4=BB=93=E5=BA=93=EF=BC=9A=E6=8E=A8=E9=80=81=E5=BD=93?= =?utf8?q?=E5=89=8D=E6=9C=80=E6=96=B0=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit 以当前工作树为准重建仓库历史(原分叉历史已废弃)。包含: - 前端:12 场景框架、侧栏折叠/展开、Header 固定、移动端抽屉菜单与遮罩、index.css 清理、vite-env.d.ts、页脚 ICP 备案号 - 后端:5 个接口(含 /api/mu-period-compare 运行环比) - 备案信息.md 与 README --- .gitignore | 32 + README.md | 81 + backend/.env.example | 6 + backend/main.py | 984 +++++++++ backend/requirements.txt | 4 + frontend/index.html | 12 + frontend/package-lock.json | 1750 +++++++++++++++++ frontend/package.json | 22 + frontend/src/App.tsx | 1351 +++++++++++++ frontend/src/api.ts | 119 ++ frontend/src/index.css | 285 +++ frontend/src/main.tsx | 10 + frontend/src/vite-env.d.ts | 1 + frontend/tsconfig.json | 21 + frontend/tsconfig.node.json | 11 + frontend/vite.config.ts | 17 + ...07\346\241\210\344\277\241\346\201\257.md" | 76 + 17 files changed, 4782 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/main.py create mode 100644 backend/requirements.txt create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 "\345\244\207\346\241\210\344\277\241\346\201\257.md" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f86a9fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# 依赖 +node_modules/ +frontend/node_modules/ +.pnpm-store/ +backend/.venv/ +backend/venv/ +__pycache__/ +*.pyc + +# 构建产物 +frontend/dist/ +*.tsbuildinfo +# tsc -b 为 vite.config 生成的临时产物(composite 构建副产物) +frontend/vite.config.js +frontend/vite.config.d.ts + +# 环境 / 密钥 +.env +backend/.env +*.local + +# 编辑器 / 系统 +.vscode/ +.idea/ +.DS_Store + +# 设计源(仅作参考,不部署;如需纳入版本控制可删除此行) +design-source/ +Station-AI-Copilot-Design-Source.zip + +# 损坏仓库备份(勿提交) +.git.broken/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..7d6a277 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# StationAIDemo + +在 **wise-insightapm.com**(`desk-desk-cluster01.cn.wise-insightapm.com`)站点之上构建的 +**AI 分析 Demo**。硬性约束:**必须使用该站点的真实 API 与真实数据**,禁止凭空造或使用无关数据源。 + +由设计源 `Station-AI-Copilot`(WISE-IoT 风格)移植而来,部署形态为 **Vite + React 前端 + +FastAPI 后端(BFF)**,贴合既有 1061/2341 技术栈。AI 分析能力使用 **CodeBuddy 模型(默认 hy3)**。 + +--- + +## 架构 + +``` +浏览器 + └─ /api/* → FastAPI 后端(BFF) + ├─ /api/ai/analyze 调用 CodeBuddy(hy3) 对真实数据分析(未配密钥时本地规则兜底) + ├─ /api/mu-connect 转发 MU 认证并探测真实 APM 端点(cURL/EIToken/Bearer) + └─ /api/mu-period-compare 按资产组计算本月与上月同期真实运行环比 +``` + +后端代理的真实端点(见 `backend/main.py`): +- `/api/v1/asset/group/list` +- `/api/v1/asset/node/root` +- `/api/v1/event/latest` +- `/api/v1/event/hist`(运行环比:本月截至当前 vs 上月同期) + +目标环境中的实际服务根地址为 `https://portal-apm-<环境域名>/api-apm`,不能把上述路径直接拼接到 Desk 门户地址。 + +## 目录 + +``` +StationAIDemo/ +├── frontend/ # Vite + React + TypeScript(UI 与真实数据接入) +├── backend/ # FastAPI(AI 分析 + MU 代理 BFF) +├── design-source/ # 设计源(参考,不部署) +└── README.md +``` + +## 本地开发 + +> 注意:`localhost` 无法直接复用 MU 门户 Cookie。开发环境请在 MU 的 Network 面板复制一条真实 +> `portal-apm-.../api-apm/api/v1/...` 请求为 cURL,粘贴到“MU 数据源配置”;也可以使用 +> EIToken/Bearer 和对应的 `org-id`。凭据只保存在页面内存中。 + +### 1. 后端(FastAPI,端口 8000) +```bash +cd backend +python -m venv .venv && source .venv/Scripts/activate # Windows: .venv\Scripts\activate +pip install -r requirements.txt +cp .env.example .env # 填入 CodeBuddy 接入地址与密钥(可选,缺省走本地兜底) +uvicorn main:app --reload --port 8000 +``` + +### 2. 前端(Vite,端口 5173) +```bash +cd frontend +npm install +npm run dev +``` +访问 http://localhost:5173 。前端 `/api` 已代理到后端 8000。 + +## 生产部署(云端 Linux + 同域微应用) + +1. 构建前端静态产物:`cd frontend && npm install && npm run build`(产物在 `frontend/dist`)。 +2. 用 nginx 等将前端静态文件与后端 `/api` 暴露到统一入口;后端负责访问实际的 APM 服务。 +3. 后端以 `uvicorn main:app --port 8000` 运行(systemd 托管),并由同域反向代理转发 `/api`。 +4. 配置 `.env` 中的 CodeBuddy 接入信息后重启后端。 + +## AI 分析接线的现状 + +- **设备问答(QA)** 与 **首页 AI 实时分析** 已接入 `/api/ai/analyze`,把真实设备/告警数据 + 送入 CodeBuddy(hy3) 生成结论;未配置密钥时回退到本地规则分析。 +- 日报 / 诊断 / 健康 / 预测 / 控制等模块的“AI 结论”目前为设计源示例文本,后续按你的 + **规格** 逐模块替换为真·AI 分析(接线点统一为 `frontend/src/api.ts` 的 `analyzeModule`)。 + +## 待办(按你的规格推进) + +- [ ] 各分析模块的 AI 接线与提示词打磨 +- [ ] CodeBuddy 真实接入地址 / 密钥确认 +- [ ] 云端同域微应用的反代与路径规划 +- [ ] 各模块 UI 在真实数据下的细节校准 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..1ded057 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,6 @@ +# CodeBuddy(AI 分析的“脑”)接入配置 +# 由你提供真实接入地址与密钥;未配置时后端自动回退到本地规则分析。 +# 默认模型:hy3(后续按需要切换) +CODEBUDDY_API_BASE= +CODEBUDDY_API_KEY= +CODEBUDDY_MODEL=hy3 diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..506f5ba --- /dev/null +++ b/backend/main.py @@ -0,0 +1,984 @@ +import os +import re +import json +import base64 +import asyncio +from datetime import datetime, timedelta +from typing import Any, Optional +from urllib.parse import unquote, urlparse, urlunparse + +from dotenv import load_dotenv +import httpx +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +load_dotenv() + +app = FastAPI(title="Station AI Copilot Demo Backend") + +# 开发期允许前端(Vite 5173)跨域调用;生产期建议同域部署,可收紧 allow_origins +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# ---- CodeBuddy(AI 脑)配置 ---- +CODEBUDDY_API_BASE = os.getenv("CODEBUDDY_API_BASE", "") +CODEBUDDY_API_KEY = os.getenv("CODEBUDDY_API_KEY", "") +CODEBUDDY_MODEL = os.getenv("CODEBUDDY_MODEL", "hy3") + +# 真实站点 API 契约。设备监控微应用将 APM 请求发送至: +# https://portal-apm-<环境域名>/api-apm/api/v1/... +PROBE_ENDPOINTS = [ + "/api/v1/asset/group/list?count=1000", + "/api/v1/asset/node/root", + "/api/v1/event/latest", +] + + +# ---- 请求模型 ---- +class MuConfig(BaseModel): + baseUrl: str + apiBaseUrl: str = "" + authMode: str = "session" + username: str = "" + tenant: str = "" + orgCode: str = "" + orgId: str = "" + tenantId: str = "" + rawCurl: str = "" + loginPath: str = "/api/v1/auth/login" + usernameField: str = "username" + passwordField: str = "password" + + +class MuConnectRequest(BaseModel): + config: MuConfig + secret: str = "" + selectedGroupId: str = "" + selectedGroupName: str = "" + + +class MuPeriodCompareRequest(MuConnectRequest): + comparisonMode: str = "month" + + +class AnalyzeRequest(BaseModel): + module: str = "summary" + prompt: str = "" + context: dict = {} + + +def extract_payload_rows(value: Any) -> list[dict]: + """兼容 MU 各微服务常见包装,并递归寻找真正的行数组。""" + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + if not isinstance(value, dict): + return [] + for key in ("e", "data", "result", "list", "rows", "records", "items", "nodes", "nodeList", "content", "elements", "children"): + if key not in value: + continue + rows = extract_payload_rows(value.get(key)) + if rows: + return rows + for nested in value.values(): + rows = extract_payload_rows(nested) + if rows: + return rows + return [] + + +# ---- 工具 ---- +def validate_base_url(raw: str) -> str: + try: + url = urlparse(raw) + except Exception: + raise ValueError("MU 站点地址格式不正确") + if url.scheme != "https": + raise ValueError("MU 站点必须使用 HTTPS") + if url.username or url.password: + raise ValueError("请不要把账号或密码写在 URL 中") + host = url.hostname or "" + if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", host) or host == "localhost": + raise ValueError("为防止内部网络探测,请使用 MU 站点域名,不要填写 IP 地址") + return raw.rstrip("/") + + +def extract_curl_url(text: str) -> str: + """提取 Copy as cURL 中的首个 HTTPS URL。""" + match = re.search(r"https://[^\s'\"`\]\)]+", text or "", re.I) + if not match: + return "" + return match.group(0).rstrip("\\^").replace("\\&", "&").replace("\\_", "_") + + +def resolve_apm_base(config: MuConfig) -> str: + """从 Desk 门户地址自动推导设备监控实际使用的 APM API 根地址。""" + explicit = (config.apiBaseUrl or "").strip() + if explicit: + return validate_base_url(explicit) + + curl_url = extract_curl_url(config.rawCurl) + source = curl_url or config.baseUrl + validated = validate_base_url(source) + parsed = urlparse(validated) + host = parsed.hostname or "" + + if host.startswith("desk-"): + host = "portal-apm-" + host[len("desk-") :] + elif host.startswith("portal-m2i-"): + host = "portal-apm-" + host[len("portal-m2i-") :] + + netloc = host + if parsed.port: + netloc += f":{parsed.port}" + path = parsed.path or "" + marker = "/api-apm" + if marker in path: + path = path[: path.index(marker) + len(marker)] + elif host.startswith("portal-apm-"): + path = marker + else: + path = "" + return urlunparse((parsed.scheme, netloc, path.rstrip("/"), "", "", "")) + + +def find_token(value: Any) -> Optional[str]: + if not isinstance(value, dict): + return None + for k in ["access_token", "accessToken", "token", "jwt"]: + if isinstance(value.get(k), str): + return value[k] + for k in ["data", "result"]: + t = find_token(value.get(k)) + if t: + return t + return None + + +def sanitize_cookie_header(raw_cookie: str) -> str: + """只保留 APM 认证需要且可安全作为 HTTP 头发送的 Cookie。""" + allowed = { + "eitoken", + "einame", + "tenantid", + "orgid", + "state", + "nonce", + "lang", + "langclass", + "username", + "themecolor", + } + result = [] + for match in re.finditer(r"(?:^|;\s*)([A-Za-z0-9_-]+)=([^;]*)", raw_cookie or ""): + name, value = match.group(1), match.group(2).strip() + if name.lower() not in allowed: + continue + try: + value.encode("ascii") + except UnicodeEncodeError: + continue + result.append(f"{name}={value}") + return "; ".join(result) + + +def sanitize_forward_headers(headers: dict) -> dict: + """Drop browser-only/non-ASCII headers that httpx cannot safely encode.""" + safe: dict = {} + for raw_key, raw_value in headers.items(): + key, value = str(raw_key).strip(), str(raw_value).strip() + if not key or not value: + continue + try: + key.encode("ascii") + value.encode("ascii") + except UnicodeEncodeError: + continue + safe[key] = value + return safe + + +def parse_curl_headers(text: str) -> dict: + """从 curl 命令或原始请求头文本中提取 Key: Value 头,用于原样复刻浏览器请求。""" + parsed: dict = {} + if not text: + return parsed + # 1) 尝试解析 curl 的 -H '...' / -H "..." 片段 + for m in re.finditer(r"-H\s+['\"]([^'\"]+)['\"]", text): + kv = m.group(1) + if ":" in kv: + k, v = kv.split(":", 1) + parsed[k.strip()] = v.strip() + # 2) 若没有 -H,则按"每行 Key: Value"解析(浏览器复制的原始头) + if not parsed: + for line in text.splitlines(): + if ":" in line and not line.strip().startswith(("http", "curl", "{", "}")): + k, v = line.split(":", 1) + parsed[k.strip()] = v.strip() + + # Chrome 的 Copy as cURL 常把 Cookie 输出为 -b $'...',而不是 -H 'Cookie: ...'。 + cookie_match = re.search( + r"(?:^|\s)(?:-b|--cookie)\s+\$?(['\"])(.*?)\1", + text, + re.I | re.S, + ) + if cookie_match and not any(key.lower() == "cookie" for key in parsed): + parsed["Cookie"] = cookie_match.group(2).strip() + + for key in list(parsed): + if key.lower() == "cookie": + parsed[key] = sanitize_cookie_header(parsed[key]) + + # 只转发认证和租户上下文相关的头,避免 Host、Content-Length 等浏览器头干扰 BFF 请求。 + allowed = { + "accept", + "accept-language", + "authorization", + "cookie", + "org-id", + "tenant-id", + "x-grafana-org-id", + "x-requested-with", + "x-xsrf-token", + } + return sanitize_forward_headers( + {key: value for key, value in parsed.items() if key.lower() in allowed} + ) + + +def normalize_authorization(value: str) -> str: + token = unquote((value or "").strip()) + if token.lower().startswith(("bearer ", "basic ")): + return token + return f"Bearer {token}" if token else "" + + +def cookie_and_authorization(raw_secret: str) -> tuple[str, str]: + """兼容整段 Cookie、Cookie: 前缀、EIToken=xxx 和裸 Token。""" + raw = (raw_secret or "").strip() + if raw.lower().startswith("cookie:"): + raw = raw.split(":", 1)[1].strip() + if ";" in raw or re.search(r"(?:^|;\s*)EIToken=", raw, re.I): + match = re.search(r"(?:^|;\s*)EIToken=([^;]+)", raw, re.I) + token = match.group(1).strip() if match else "" + return sanitize_cookie_header(raw), normalize_authorization(token) + token = raw[len("EIToken=") :].strip() if raw.startswith("EIToken=") else raw + authorization = normalize_authorization(token) + cookie_token = unquote(token) + return f"EIToken={cookie_token}", authorization + + +def build_mu_request_headers(config: MuConfig, secret: str) -> tuple[dict, str]: + """为只读分析接口复用 MU 的认证和组织上下文。""" + headers = {"Accept": "application/json"} + if config.orgId: + headers["org-id"] = config.orgId + if config.tenantId: + headers["Tenant-Id"] = config.tenantId + + raw_mode = bool(config.rawCurl and config.rawCurl.strip()) + if raw_mode: + headers.update(parse_curl_headers(config.rawCurl)) + elif config.authMode == "eitoken": + cookie, authorization = cookie_and_authorization(secret) + headers["Cookie"] = cookie + if authorization: + headers["Authorization"] = authorization + elif config.authMode == "bearer": + headers["Authorization"] = normalize_authorization(secret) + elif config.authMode == "basic": + token = base64.b64encode(f"{config.username}:{secret}".encode()).decode() + headers["Authorization"] = f"Basic {token}" + elif config.authMode == "session": + return {}, "localhost 无法复用 MU 浏览器登录态,请使用原始请求、EIToken 或 Bearer" + else: + return {}, "运行环比暂不支持登录接口模式,请使用原始请求、EIToken 或 Bearer" + + headers = sanitize_forward_headers(headers) + if not any(key.lower() in ("cookie", "authorization") for key in headers): + return {}, "未取得可用的 Cookie 或 Authorization" + return headers, "" + + +def pick_value(row: dict, *keys: str) -> Any: + for key in keys: + value = row.get(key) + if value is not None and value != "": + return value + return None + + +def timestamp_ms(value: Any) -> Optional[int]: + if isinstance(value, (int, float)): + number = int(value) + return number * 1000 if number < 10_000_000_000 else number + if isinstance(value, str) and value.strip(): + text = value.strip() + if text.isdigit(): + return timestamp_ms(int(text)) + try: + return int(datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp() * 1000) + except ValueError: + return None + return None + + +def month_window() -> tuple[datetime, datetime, datetime, datetime]: + """返回本月截至当前与上月同期窗口,避免完整上月和半个月本月直接比较。""" + now = datetime.now().astimezone() + current_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + previous_month_end = current_start + previous_start = (current_start - timedelta(days=1)).replace(day=1) + elapsed = now - current_start + previous_end = min(previous_start + elapsed, previous_month_end) + return current_start, now, previous_start, previous_end + + +def event_summary(rows: list[dict], start: datetime, end: datetime, node_count: int) -> dict: + duration_ms = 0 + high_count = 0 + affected: set[str] = set() + for row in rows: + node_key = str(pick_value(row, "nodeId", "assetId", "deviceId", "nodeName", "deviceName") or "") + if node_key: + affected.add(node_key) + level = str(pick_value(row, "level", "severity", "priority", "alarmLevel") or "") + if re.search(r"P?1|critical|high|严重|紧急", level, re.I): + high_count += 1 + begin = timestamp_ms(pick_value(row, "startTs", "createTs", "occurTs", "eventTs", "ts")) + finish = timestamp_ms(pick_value(row, "endTs", "recoverTs", "closeTs", "finishTs", "updateTs")) + if begin: + capped_start = max(begin, int(start.timestamp() * 1000)) + capped_end = min(finish or int(end.timestamp() * 1000), int(end.timestamp() * 1000)) + duration_ms += max(0, capped_end - capped_start) + hours = round(duration_ms / 3_600_000, 1) + window_hours = max((end - start).total_seconds() / 3600, 0.01) + availability = None + if node_count: + availability = round(max(0.0, 1 - hours / (node_count * window_hours)) * 100, 2) + return { + "eventCount": len(rows), + "affectedNodes": len(affected), + "highPriority": high_count, + "impactHours": hours, + "availabilityEstimate": availability, + "windowHours": round(window_hours, 1), + } + + +def compare_delta(current: Optional[float], previous: Optional[float]) -> Optional[float]: + if current is None or previous in (None, 0): + return None + return round((current - previous) / abs(previous) * 100, 1) + + +SYSTEM_PROMPT = ( + "你是 Station AI Copilot,一个面向燃气站、水站、污水处理厂、新能源场站与 AIDC 的场站运营分析助手。" + "请从当前场站或资产组角度回答,设备、告警与测点只作为场站结论的证据。用简体中文给出简洁、可落地、可追溯的分析结论。" + "不要编造数据中不存在的事实。" +) + + +async def call_codebuddy(messages: list) -> Optional[str]: + """调用 CodeBuddy 模型(默认 hy3)。未配置时返回 None,由调用方回退。""" + if not CODEBUDDY_API_BASE or not CODEBUDDY_API_KEY: + return None + url = CODEBUDDY_API_BASE.rstrip("/") + "/chat/completions" + headers = { + "Authorization": f"Bearer {CODEBUDDY_API_KEY}", + "Content-Type": "application/json", + } + payload = {"model": CODEBUDDY_MODEL, "messages": messages, "temperature": 0.3} + async with httpx.AsyncClient(timeout=30) as client: + r = await client.post(url, headers=headers, json=payload) + r.raise_for_status() + data = r.json() + return data["choices"][0]["message"]["content"] + + +def local_analysis(module: str, prompt: str, context: dict) -> str: + """未配置 CodeBuddy 密钥时的本地规则兜底分析(仍基于真实数据)。""" + devices = context.get("devices") or [] + if module == "station-qa": + stats = context.get("statistics") or {} + group = context.get("assetGroup") or {} + group_name = str(group.get("name") or "当前资产组") + station_name = str(group.get("station") or "当前站点") + rows = [item for item in devices if isinstance(item, dict)] + + def names_for(statuses: tuple[str, ...]) -> list[str]: + return [ + f"{item.get('name', '未命名设备')}({item.get('site', station_name)})" + for item in rows if str(item.get("status", "")) in statuses + ] + + offline_items = names_for(("离线", "offline", "Offline")) + fault_items = names_for(("故障", "fault", "Fault")) + warning_items = names_for(("告警", "预警", "alarm", "warning")) + running_items = names_for(("运行", "正常", "online", "normal", "running")) + total = int(stats.get("total") or len(rows)) + unknown = int(stats.get("unknown") or 0) + question = (prompt or "").strip() + + if "离线" in question: + detail = "、".join(offline_items[:12]) or "无" + return f"{group_name}当前识别到 {len(offline_items)} 台离线设备:{detail}。建议按站点层级先核查通信链路、供电和维护停用状态;当前结果来自资产组节点状态,不等同于未闭环报警。" + if "故障" in question or "异常" in question or "告警" in question: + fault_detail = "、".join(fault_items[:8]) or "无" + warning_detail = "、".join(warning_items[:8]) or "无" + return f"{group_name}当前识别到故障 {len(fault_items)} 台、告警 {len(warning_items)} 台。故障证据:{fault_detail};告警证据:{warning_detail}。建议先确认故障对泵站供水连续性的影响,再核查告警持续时间与关联测点。" + if "完整" in question or "可信" in question or "发布" in question or "正式" in question: + known = max(0, total - unknown) + return f"{group_name}当前状态数据覆盖 {known}/{total} 台,仍有 {unknown} 台待补齐。可用于值班预览和异常排查;若要发布正式日报,建议先补齐节点状态,并接通报警闭环、工单和保养数据。" + if "在线" in question or "运行" in question: + detail = "、".join(running_items[:10]) or "无" + return f"{group_name}当前识别到 {len(running_items)} 台处于运行/正常状态:{detail}。该统计基于当前节点状态;状态待补齐设备未计入在线数。" + + return ( + f"{group_name}({station_name})当前纳管 {total} 台,运行 {len(running_items)} 台、" + f"故障 {len(fault_items)} 台、告警 {len(warning_items)} 台、离线 {len(offline_items)} 台," + f"另有 {unknown} 台状态待补齐。建议优先处理故障与集中离线问题;报警闭环、工单和保养尚未接入,因此不对这些数量作推测。" + ) + if module == "station-insight": + stats = context.get("statistics") or {} + group = context.get("assetGroup") or {} + abnormal = context.get("abnormalDevices") or [] + group_name = group.get("name") or "当前资产组" + fault_names = [str(item.get("name")) for item in abnormal if isinstance(item, dict) and item.get("status") == "故障"] + warning_names = [str(item.get("name")) for item in abnormal if isinstance(item, dict) and item.get("status") == "告警"] + faults = int(stats.get("faults") or 0) + warnings = int(stats.get("warnings") or 0) + offline = int(stats.get("offline") or 0) + unknown = int(stats.get("unknown") or 0) + first = ( + f"{group_name}存在 {faults} 台故障设备({('、'.join(fault_names[:2]) or '详见证据清单')}),可能影响站点运行连续性,建议立即确认影响范围并建立处置任务。" + if faults else f"{group_name}当前未识别到故障状态,建议继续关注关键功能系统的运行连续性。" + ) + second = ( + f"当前有 {warnings} 台告警、{offline} 台离线设备({('、'.join(warning_names[:2]) or '详见证据清单')}),建议按站点层级核查通信、供电和关联过程量。" + if warnings or offline else "当前未识别到告警或离线设备,站点状态总体可控。" + ) + third = ( + f"仍有 {unknown} 台设备状态待补齐,当前结论仅覆盖可追溯数据;建议取得完整节点状态后再发布正式日报。" + if unknown else "节点状态数据已完整,可基于当前统计发布正式站点日报。" + ) + return "\n".join([first, second, third]) + if module == "period-analysis": + group = context.get("assetGroup") or {} + current = context.get("current") or {} + previous = context.get("previous") or {} + deltas = context.get("deltas") or {} + sites = context.get("siteContributions") or [] + name = str(group.get("name") or "当前资产组") + if not context.get("historyScoped"): + return f"{name}的历史事件接口尚未返回可验证的资产组范围,当前不能生成可信环比结论。请检查事件记录中的 nodeId/nodeName 映射。" + event_delta = deltas.get("eventCount") + impact_delta = deltas.get("impactHours") + availability_delta = deltas.get("availabilityEstimate") + event_text = "缺少上月基数" if event_delta is None else f"{event_delta:+.1f}%" + impact_text = "缺少上月基数" if impact_delta is None else f"{impact_delta:+.1f}%" + availability_text = "无法计算" if availability_delta is None else f"{availability_delta:+.2f} 个百分点" + top_site = sites[0].get("name") if sites and isinstance(sites[0], dict) else "暂无集中区域" + return ( + f"{name}本月截至当前记录 {current.get('eventCount', 0)} 条场站事件,较上月同期 {event_text};" + f"事件影响时长 {current.get('impactHours', 0)} 小时,环比 {impact_text}。" + f"事件影响后的运行可用率估算变化 {availability_text},当前事件最集中的层级为“{top_site}”。" + f"建议优先核查高频区域及重复事件;该结论基于真实事件历史和资产组节点映射,不包含尚未接入的能耗与工单数据。" + ) + if isinstance(devices, list) and devices: + n = len(devices) + n_attention = len( + [d for d in devices if isinstance(d, dict) and d.get("status") in ("告警", "预警", "故障", "离线", "alarm", "warning", "fault", "offline")] + ) + n_offline = len( + [d for d in devices if isinstance(d, dict) and d.get("status") in ("离线", "offline", "Offline")] + ) + top = sorted([d for d in devices if isinstance(d, dict)], key=lambda x: x.get("score", 100))[:3] + lines = [f"基于已加载的 {n} 台设备实时数据:需关注 {n_attention} 台,离线 {n_offline} 台。"] + if top: + lines.append("风险最高的设备:") + for d in top: + lines.append(f" - {d.get('site', '')} · {d.get('name', '')}({d.get('status', '')},{d.get('alarm', '')})") + lines.append("建议:优先核查上述设备的通信与过程量,必要时按站点 SOP 切换备用设备,并安排点检。") + return "\n".join(lines) + return "当前为预览数据,未获取到真实设备列表。请在“MU 数据源配置”中完成真实连接后重试。" + + +# ---- 接口 ---- +@app.post("/api/ai/analyze") +async def analyze(req: AnalyzeRequest): + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + { + "role": "user", + "content": ( + f"分析模块:{req.module}\n" + f"用户问题:{req.prompt}\n" + f"真实数据(JSON):\n{json.dumps(req.context, ensure_ascii=False)}" + ), + }, + ] + try: + text = await call_codebuddy(messages) + if text: + return {"ok": True, "analysis": text, "model": CODEBUDDY_MODEL, "fallback": False} + except Exception: + # 调用失败则回退到本地规则分析 + pass + return {"ok": True, "analysis": local_analysis(req.module, req.prompt, req.context), "model": "local-rule", "fallback": True} + + +@app.post("/api/mu-connect") +async def mu_connect(req: MuConnectRequest): + config = req.config + secret = req.secret or "" + try: + portal_base = validate_base_url(config.baseUrl) + apm_base = resolve_apm_base(config) + except ValueError as e: + return {"ok": False, "message": str(e)} + + raw_mode = bool(config.rawCurl and config.rawCurl.strip()) + if config.authMode == "session" and not raw_mode: + return { + "ok": False, + "message": "localhost 无法读取 MU 门户登录态。请使用“原始请求”或 EIToken/Bearer 方式连接。", + "api_base": apm_base, + } + if not raw_mode and not secret: + msg = {"bearer":"请输入访问令牌","eitoken":"请输入 EIToken","basic":"请输入密码","login":"请输入密码"}.get(config.authMode,"请输入密码") + return {"ok": False, "message": msg} + if not raw_mode and config.authMode in ("basic","login") and not config.username: + return {"ok": False, "message": "请输入账号"} + + headers = {"Accept": "application/json"} + if config.orgId: + headers["org-id"] = config.orgId + if config.tenantId: + headers["Tenant-Id"] = config.tenantId + + if raw_mode: + headers.update(parse_curl_headers(config.rawCurl)) + elif config.authMode == "basic": + token = base64.b64encode(f"{config.username}:{secret}".encode()).decode() + headers["Authorization"] = f"Basic {token}" + elif config.authMode == "bearer": + headers["Authorization"] = normalize_authorization(secret) + elif config.authMode == "eitoken": + cookie, authorization = cookie_and_authorization(secret) + headers["Cookie"] = cookie + if authorization: + headers["Authorization"] = authorization + elif config.authMode == "login": + if (urlparse(portal_base).hostname or "").endswith("wise-insightapm.com"): + return { + "ok": False, + "message": "该站点使用门户 SSO,不能通过通用账号密码接口登录。请复制真实请求 cURL,或使用 EIToken/Bearer。", + "api_base": apm_base, + } + try: + async with httpx.AsyncClient(base_url=portal_base, timeout=15, follow_redirects=False) as login_client: + login_response = await login_client.post( + config.loginPath, + json={config.usernameField: config.username, config.passwordField: secret}, + ) + if login_response.status_code >= 400: + return {"ok": False, "message": f"登录接口返回 {login_response.status_code}"} + try: + login_payload = login_response.json() + except Exception: + login_payload = {} + token = find_token(login_payload) + if token: + headers["Authorization"] = normalize_authorization(token) + if login_client.cookies: + headers["Cookie"] = "; ".join(f"{key}={value}" for key, value in login_client.cookies.items()) + if not token and not login_client.cookies: + return {"ok": False, "message": "登录成功,但未找到会话 Cookie 或访问令牌"} + except Exception as e: + return {"ok": False, "message": f"登录请求失败:{e}"} + + headers = sanitize_forward_headers(headers) + if raw_mode and not any(key.lower() in ("cookie", "authorization") for key in headers): + return { + "ok": False, + "message": "原始请求中没有可用的 Cookie 或 Authorization。请从浏览器开发者工具重新 Copy as cURL (bash)。", + "api_base": apm_base, + } + + async with httpx.AsyncClient(base_url=apm_base, headers=headers, timeout=20, follow_redirects=False) as client: + async def probe(path: str) -> dict: + try: + response = await client.get(path) + response_text = response.text or "" + try: + payload = json.loads(response_text) if response_text else None + except Exception: + payload = {"text": response_text[:800]} + return { + "path": path, + "status": response.status_code, + "ok": 200 <= response.status_code < 300, + "payload": payload, + "snippet": json.dumps(payload, ensure_ascii=False)[:500] if isinstance(payload, (dict, list)) else response_text[:500], + } + except Exception as e: + return {"path": path, "status": 0, "ok": False, "error": str(e)[:200]} + + results = await asyncio.gather(*(probe(path) for path in PROBE_ENDPOINTS)) + + # 资产组列表是已验证可用的入口。连接成功后继续按当前选择拉取节点清单, + # 让前端日报拿到真正属于该资产组的数据,而不是把“资产组行”误当作设备。 + group_result = next( + (item for item in results if item.get("path", "").startswith("/api/v1/asset/group/list") and item.get("ok")), + None, + ) + asset_groups = extract_payload_rows(group_result.get("payload") if group_result else None) + + selected_group = None + if req.selectedGroupId: + selected_group = next( + (item for item in asset_groups if str(item.get("id", "")) == str(req.selectedGroupId)), + None, + ) + if selected_group is None and req.selectedGroupName: + selected_group = next( + (item for item in asset_groups if str(item.get("name", "")) == req.selectedGroupName), + None, + ) + if selected_group is None: + selected_group = next( + (item for item in asset_groups if "水务" in str(item.get("name", ""))), + asset_groups[0] if asset_groups else None, + ) + + nodes_payload = None + node_rows_count = 0 + if selected_group: + group_id = str(selected_group.get("id", "")) + group_name = str(selected_group.get("name", "")) + node_path = "/api/v1/asset/node/list" + # 源码确认的是同一个 node/list 接口;不同 MU 版本对 groupId/groupName + # 的必填组合不一致,因此只在该真实接口上依次尝试三种参数组合。 + node_queries = [ + {"groupId": group_id, "groupName": group_name, "index": 0, "count": 10000}, + {"groupId": group_id, "index": 0, "count": 10000}, + {"groupName": group_name, "index": 0, "count": 10000}, + ] + for params in node_queries: + query_label = "&".join(f"{key}={value}" for key, value in params.items()) + try: + node_response = await client.get(node_path, params=params) + node_text = node_response.text or "" + try: + candidate_payload = json.loads(node_text) if node_text else None + except Exception: + candidate_payload = {"text": node_text[:800]} + candidate_rows = extract_payload_rows(candidate_payload) + candidate_count = len(candidate_rows) + if nodes_payload is None or candidate_count > node_rows_count: + nodes_payload = candidate_payload + node_rows_count = candidate_count + results.append({ + "path": f"{node_path}?{query_label}", + "status": node_response.status_code, + "ok": 200 <= node_response.status_code < 300, + "payload": candidate_payload, + "row_count": candidate_count, + "snippet": json.dumps(candidate_payload, ensure_ascii=False)[:500] if isinstance(candidate_payload, (dict, list)) else node_text[:500], + }) + if 200 <= node_response.status_code < 300 and candidate_count: + break + except Exception as e: + results.append({"path": f"{node_path}?{query_label}", "status": 0, "ok": False, "error": str(e)[:200]}) + sent_headers = [ + key for key in client.headers.keys() if key.lower() not in ("cookie", "authorization") + ] + for header_name in ("Authorization", "Cookie"): + if header_name.lower() in client.headers: + sent_headers.append(header_name) + + successful = [r for r in results if r["ok"]] + if not successful: + status_text = ";".join(f"{r['path']} -> {r.get('status') or r.get('error','连接失败')}" for r in results) + snippets = " ".join(str(r.get("snippet", "")) for r in results).lower() + auth_rejected = any(r.get("status") in (401, 403) for r in results) or "no auth" in snippets or "token lack" in snippets + wrong_base = all(r.get("status") == 404 for r in results) + if auth_rejected: + reason = "已到达正确的 APM 服务,但 Token/Cookie 未被接受" + elif wrong_base: + reason = "APM API 地址不正确" + else: + reason = "APM 服务已到达,但接口未返回可用数据" + return { + "ok": False, + "message": f"{reason}:{status_text}", + "endpoints": results, + "sent_headers": sent_headers, + "api_base": apm_base, + } + return { + "ok": True, + "message": f"已连接 {apm_base},通过 {len(successful)}/{len(results)} 个接口验证", + "payloads": [r.get("payload") for r in successful if r.get("payload") is not None], + "asset_groups": asset_groups, + "selected_group": selected_group, + "nodes_payload": nodes_payload, + "node_rows_count": node_rows_count, + "endpoints": results, + "sent_headers": sent_headers, + "api_base": apm_base, + } + + +class MuFetchRequest(BaseModel): + config: MuConfig + secret: str = "" + path: str = "" + + +@app.post("/api/mu-fetch") +async def mu_fetch(req: MuFetchRequest): + """透传鉴权并拉取指定路径,供前端在连接成功后获取真实设备/告警数据。""" + config = req.config + secret = req.secret or "" + path = (req.path or "").strip() + if not path: + return {"ok": False, "message": "缺少 path"} + if not path.startswith("/"): + return {"ok": False, "message": "path 必须以 / 开头"} + try: + apm_base = resolve_apm_base(config) + except ValueError as e: + return {"ok": False, "message": str(e)} + + headers = {"Accept": "application/json"} + if config.orgId: + headers["org-id"] = config.orgId + if config.tenantId: + headers["Tenant-Id"] = config.tenantId + + raw_mode = bool(config.rawCurl and config.rawCurl.strip()) + if config.authMode == "session" and not raw_mode: + return {"ok": False, "message": "localhost 无法复用 MU 浏览器登录态,请使用原始请求或 Token"} + if raw_mode: + headers.update(parse_curl_headers(config.rawCurl)) + elif config.authMode == "eitoken": + cookie, authorization = cookie_and_authorization(secret) + headers["Cookie"] = cookie + if authorization: + headers["Authorization"] = authorization + elif config.authMode == "bearer": + headers["Authorization"] = normalize_authorization(secret) + elif config.authMode == "basic": + token = base64.b64encode(f"{config.username}:{secret}".encode()).decode() + headers["Authorization"] = f"Basic {token}" + + headers = sanitize_forward_headers(headers) + if raw_mode and not any(key.lower() in ("cookie", "authorization") for key in headers): + return {"ok": False, "message": "原始请求中没有可用的 Cookie 或 Authorization"} + + async with httpx.AsyncClient(base_url=apm_base, headers=headers, timeout=20, follow_redirects=False) as client: + try: + r = await client.get(path) + except Exception as e: + return {"ok": False, "message": f"请求失败:{e}"} + text = r.text or "" + try: + data = json.loads(text) if text else None + except Exception: + data = {"text": text[:1000]} + return { + "ok": 200 <= r.status_code < 300, + "status": r.status_code, + "data": data, + "snippet": (json.dumps(data, ensure_ascii=False)[:800] if isinstance(data, (dict, list)) else text[:800]), + "api_base": apm_base, + } + + +@app.post("/api/mu-period-compare") +async def mu_period_compare(req: MuPeriodCompareRequest): + """按资产组计算本月与上月同期的真实事件运行环比。""" + try: + apm_base = resolve_apm_base(req.config) + except ValueError as e: + return {"ok": False, "message": str(e)} + headers, auth_error = build_mu_request_headers(req.config, req.secret or "") + if auth_error: + return {"ok": False, "message": auth_error, "api_base": apm_base} + + current_start, current_end, previous_start, previous_end = month_window() + endpoints: list[dict] = [] + + async with httpx.AsyncClient(base_url=apm_base, headers=headers, timeout=25, follow_redirects=False) as client: + async def get_json(path: str, params: Optional[dict] = None) -> tuple[Optional[Any], dict]: + try: + response = await client.get(path, params=params) + text = response.text or "" + try: + payload = json.loads(text) if text else None + except Exception: + payload = {"text": text[:800]} + trace = { + "path": str(response.request.url).replace(apm_base, ""), + "status": response.status_code, + "ok": 200 <= response.status_code < 300, + "rowCount": len(extract_payload_rows(payload)), + } + endpoints.append(trace) + return payload, trace + except Exception as e: + trace = {"path": path, "status": 0, "ok": False, "error": str(e)[:200], "rowCount": 0} + endpoints.append(trace) + return None, trace + + group_payload, group_trace = await get_json("/api/v1/asset/group/list", {"count": 1000}) + groups = extract_payload_rows(group_payload) + selected_group = None + if req.selectedGroupId: + selected_group = next((row for row in groups if str(pick_value(row, "id", "groupId") or "") == str(req.selectedGroupId)), None) + if selected_group is None and req.selectedGroupName: + selected_group = next((row for row in groups if str(pick_value(row, "name", "groupName") or "") == req.selectedGroupName), None) + if selected_group is None: + if not group_trace.get("ok"): + status = group_trace.get("status") or "连接失败" + return { + "ok": False, + "message": f"真实资产组接口返回 {status},Cookie / Token 可能已失效", + "endpoints": endpoints, + "api_base": apm_base, + } + return { + "ok": False, + "message": f"资产组“{req.selectedGroupName or req.selectedGroupId}”未在真实资产组接口中找到", + "endpoints": endpoints, + "api_base": apm_base, + } + + group_id = str(pick_value(selected_group, "id", "groupId") or "") + group_name = str(pick_value(selected_group, "name", "groupName") or req.selectedGroupName) + nodes_payload = None + nodes: list[dict] = [] + for params in ( + {"groupId": group_id, "groupName": group_name, "index": 0, "count": 10000}, + {"groupId": group_id, "index": 0, "count": 10000}, + {"groupName": group_name, "index": 0, "count": 10000}, + ): + candidate, trace = await get_json("/api/v1/asset/node/list", params) + candidate_rows = extract_payload_rows(candidate) + if candidate_rows: + nodes_payload, nodes = candidate, candidate_rows + break + if nodes_payload is None and trace.get("ok"): + nodes_payload = candidate + + node_ids = {str(pick_value(row, "id", "nodeId", "deviceId", "assetId")) for row in nodes if pick_value(row, "id", "nodeId", "deviceId", "assetId") is not None} + node_names = {str(pick_value(row, "name", "nodeName", "deviceName", "assetName")) for row in nodes if pick_value(row, "name", "nodeName", "deviceName", "assetName") is not None} + node_count = int(pick_value(selected_group, "deviceCount", "nodeCount", "assetCount") or len(nodes)) + + async def get_events(start: datetime, end: datetime) -> tuple[list[dict], bool]: + payload, trace = await get_json( + "/api/v1/event/hist", + { + "startTs": int(start.timestamp() * 1000), + "endTs": int(end.timestamp() * 1000), + "groupId": group_id, + "groupName": group_name, + "index": 0, + "count": 10000, + "sortType": "desc", + }, + ) + rows = extract_payload_rows(payload) + if not trace.get("ok"): + return [], False + rows_with_identity = [ + row for row in rows + if pick_value(row, "nodeId", "assetId", "deviceId", "nodeName", "deviceName", "assetName") is not None + ] + if rows_with_identity and (node_ids or node_names): + scoped = [] + for row in rows_with_identity: + row_id = str(pick_value(row, "nodeId", "assetId", "deviceId") or "") + row_name = str(pick_value(row, "nodeName", "deviceName", "assetName") or "") + if row_id in node_ids or row_name in node_names: + scoped.append(row) + return scoped, True + # 空结果是有效历史结果;非空但没有节点身份时不能证明属于当前资产组。 + return ([], True) if not rows else ([], False) + + (current_events, current_scoped), (previous_events, previous_scoped) = await asyncio.gather( + get_events(current_start, current_end), + get_events(previous_start, previous_end), + ) + + current_summary = event_summary(current_events, current_start, current_end, node_count) + previous_summary = event_summary(previous_events, previous_start, previous_end, node_count) + + node_lookup: dict[str, dict] = {} + for row in nodes: + row_id = str(pick_value(row, "id", "nodeId", "deviceId", "assetId") or "") + row_name = str(pick_value(row, "name", "nodeName", "deviceName", "assetName") or "") + if row_id: + node_lookup[row_id] = row + if row_name: + node_lookup[row_name] = row + + def event_breakdown(rows: list[dict]) -> tuple[list[dict], list[dict]]: + sites: dict[str, int] = {} + devices: dict[str, dict] = {} + for event in rows: + identity = str(pick_value(event, "nodeId", "assetId", "deviceId", "nodeName", "deviceName", "assetName") or "未映射节点") + node = node_lookup.get(identity, {}) + name = str(pick_value(node, "name", "nodeName", "deviceName", "assetName") or pick_value(event, "nodeName", "deviceName", "assetName") or identity) + site = str(pick_value(node, "pathName", "fullPathName", "parentName", "siteName", "stationName") or "当前资产组") + sites[site] = sites.get(site, 0) + 1 + device = devices.setdefault(identity, {"id": identity, "name": name, "site": site, "eventCount": 0, "highPriority": 0}) + device["eventCount"] += 1 + level = str(pick_value(event, "level", "severity", "priority", "alarmLevel") or "") + if re.search(r"P?1|critical|high|严重|紧急", level, re.I): + device["highPriority"] += 1 + site_rows = [{"name": name, "eventCount": count} for name, count in sorted(sites.items(), key=lambda item: item[1], reverse=True)] + device_rows = sorted(devices.values(), key=lambda item: (item["eventCount"], item["highPriority"]), reverse=True) + return site_rows[:8], device_rows[:12] + + site_rows, device_rows = event_breakdown(current_events) + return { + "ok": True, + "message": "已按当前资产组计算本月与上月同期运行环比" if current_scoped and previous_scoped else "接口已返回,但历史事件缺少可验证的资产组节点身份", + "api_base": apm_base, + "assetGroup": {"id": group_id, "name": group_name, "deviceCount": node_count}, + "periods": { + "current": {"start": current_start.isoformat(), "end": current_end.isoformat(), "label": f"{current_start:%Y-%m-%d} 至 {current_end:%Y-%m-%d}"}, + "previous": {"start": previous_start.isoformat(), "end": previous_end.isoformat(), "label": f"{previous_start:%Y-%m-%d} 至 {previous_end:%Y-%m-%d}"}, + }, + "current": current_summary, + "previous": previous_summary, + "deltas": { + "eventCount": compare_delta(current_summary["eventCount"], previous_summary["eventCount"]), + "affectedNodes": compare_delta(current_summary["affectedNodes"], previous_summary["affectedNodes"]), + "impactHours": compare_delta(current_summary["impactHours"], previous_summary["impactHours"]), + "availabilityEstimate": None if current_summary["availabilityEstimate"] is None or previous_summary["availabilityEstimate"] is None else round(current_summary["availabilityEstimate"] - previous_summary["availabilityEstimate"], 2), + }, + "siteContributions": site_rows, + "topDevices": device_rows, + "historyScoped": current_scoped and previous_scoped, + "nodesPayload": nodes_payload, + "endpoints": endpoints, + } + + +@app.get("/api/health") +async def health(): + return { + "ok": True, + "connection_version": "apm-bff-v2", + "ai_model": CODEBUDDY_MODEL, + "ai_configured": bool(CODEBUDDY_API_BASE and CODEBUDDY_API_KEY), + } diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..9d3c140 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,4 @@ +fastapi +uvicorn[standard] +httpx +python-dotenv diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..047f8e9 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Station AI Copilot · Demo + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..a975e3c --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1750 @@ +{ + "name": "station-ai-demo-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "station-ai-demo-frontend", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", + "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz", + "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz", + "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz", + "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz", + "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz", + "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz", + "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz", + "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz", + "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz", + "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz", + "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz", + "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz", + "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz", + "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz", + "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz", + "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz", + "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz", + "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz", + "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz", + "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz", + "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz", + "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz", + "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz", + "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz", + "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.16.tgz", + "integrity": "sha512-H/bNPUFHewJHyCTdjn1n3Pit5+2GmWT6mmeHImPX+8MA9NA6b67jO4gYmi4jTbCJb2otq34KMZnovndDPqJwhQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.412", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.412.tgz", + "integrity": "sha512-z4rMe3esBzlzovKHj4gxJnsCGZRK5l4baUvm+gCGJBPE+gsyUMKsuU9tnEUtI1dOebXz1ytAPGjvXhmQ7rIPwA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.5.tgz", + "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.5", + "@rollup/rollup-android-arm64": "4.62.5", + "@rollup/rollup-darwin-arm64": "4.62.5", + "@rollup/rollup-darwin-x64": "4.62.5", + "@rollup/rollup-freebsd-arm64": "4.62.5", + "@rollup/rollup-freebsd-x64": "4.62.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", + "@rollup/rollup-linux-arm-musleabihf": "4.62.5", + "@rollup/rollup-linux-arm64-gnu": "4.62.5", + "@rollup/rollup-linux-arm64-musl": "4.62.5", + "@rollup/rollup-linux-loong64-gnu": "4.62.5", + "@rollup/rollup-linux-loong64-musl": "4.62.5", + "@rollup/rollup-linux-ppc64-gnu": "4.62.5", + "@rollup/rollup-linux-ppc64-musl": "4.62.5", + "@rollup/rollup-linux-riscv64-gnu": "4.62.5", + "@rollup/rollup-linux-riscv64-musl": "4.62.5", + "@rollup/rollup-linux-s390x-gnu": "4.62.5", + "@rollup/rollup-linux-x64-gnu": "4.62.5", + "@rollup/rollup-linux-x64-musl": "4.62.5", + "@rollup/rollup-openbsd-x64": "4.62.5", + "@rollup/rollup-openharmony-arm64": "4.62.5", + "@rollup/rollup-win32-arm64-msvc": "4.62.5", + "@rollup/rollup-win32-ia32-msvc": "4.62.5", + "@rollup/rollup-win32-x64-gnu": "4.62.5", + "@rollup/rollup-win32-x64-msvc": "4.62.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..fd2485d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "station-ai-demo-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..ae901af --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,1351 @@ +import { useEffect, useMemo, useState, type CSSProperties } from "react"; +import { analyzeChat, analyzeModule, analyzeSummary, loadPeriodComparison, type PeriodComparisonResponse } from "./api"; + +type ScenarioKey = "daily" | "qa" | "period" | "benchmark" | "maintenance" | "health" | "prediction" | "alarm" | "efficiency" | "capacity" | "strategy" | "emergency"; +type DesignSectionKey = ScenarioKey | "architecture"; +type SkillKey = ScenarioKey | "settings" | "design"; +type ConnectionState = "connecting" | "live" | "preview"; +type Device = { id: string; name: string; site: string; type: string; status: string; score: number; alarm: string; metric: string }; +type AssetGroup = { id: string; name: string; deviceCount?: number; orgName?: string; source: "api" | "snapshot" }; +type AuthMode = "session" | "basic" | "bearer" | "eitoken" | "login"; +type MuConfig = { + baseUrl: string; + apiBaseUrl: string; + authMode: AuthMode; + username: string; + tenant: string; + orgCode: string; + orgId: string; + tenantId: string; + rawCurl: string; + loginPath: string; + usernameField: string; + passwordField: string; +}; + +const API_BASE = "https://desk-desk-cluster01.cn.wise-insightapm.com"; +const DEFAULT_MU_CONFIG: MuConfig = { + baseUrl: API_BASE, + apiBaseUrl: "", + authMode: "session", + username: "", + tenant: "", + orgCode: "", + orgId: "", + tenantId: "", + rawCurl: "", + loginPath: "/api/v1/auth/login", + usernameField: "username", + passwordField: "password", +}; +// 路径由后端拼接到自动推导出的 portal-apm-.../api-apm 服务根地址。 +const API_ENDPOINTS = [ + "/api/v1/asset/group/list?count=1000", + "/api/v1/asset/node/root", + "/api/v1/event/latest", +]; + +const fallbackDevices: Device[] = [ + { id:"a1", name:"冷冻水泵 P-03", site:"AIDC-01", type:"冷冻水泵", status:"告警", score:66, alarm:"冷冻水供水温度高", metric:"10.8 °C" }, + { id:"w1", name:"1# 鼓风机", site:"东区污水处理厂", type:"曝气鼓风机", status:"预警", score:61, alarm:"轴承振动持续上升", metric:"7.2 mm/s" }, + { id:"g1", name:"入口压力 PT-101", site:"北区燃气调压站", type:"压力变送器", status:"离线", score:72, alarm:"通信中断", metric:"—" }, + { id:"s1", name:"逆变器 INV-08", site:"光伏场站 A", type:"逆变器", status:"正常", score:78, alarm:"效率偏离同组基线", metric:"96.1 %" }, + { id:"p1", name:"加压泵 P-02", site:"城南供水站", type:"变频泵", status:"正常", score:87, alarm:"无", metric:"0.43 MPa" }, +]; + +type ScenarioDefinition = { + key: ScenarioKey; + section: "场站运营" | "运营分析" | "风险与策略"; + icon: string; + title: string; + short: string; + tone: string; + summary: string; + metrics: Array<{label:string;value:string;delta:string;tone?:"good"|"warn"|"bad"}>; + bars: number[]; + insights: string[]; + sources: string[]; + formula: string; + maturity: string; + visual: "report" | "qa" | "compare" | "operations" | "health" | "forecast" | "alarm" | "efficiency" | "capacity" | "strategy" | "emergency"; +}; + +type ApiStatus = "verified" | "source" | "external"; +type ApiKey = "groups" | "nodes" | "nodeStatus" | "properties" | "latest" | "history" | "events" | "alarmEvents" | "alarmProcess" | "workOrders" | "maintenance" | "stationParams" | "controlLogs" | "schedules" | "execution" | "external"; +type ApiDefinition = { label:string; method:string; path:string; status:ApiStatus; note:string }; +type ScenarioDesign = { + feasibility:string; + sourceText:string; + apiKeys:ApiKey[]; + gaps:string; + kpiCalcs:string[]; + visualPanel:string; + visualCalc:string; + insightCalc:string; + summaryCalc:string; +}; +type PageNarrative = { audience:string; value:string; whyAI:string; feasibilityEvidence:string }; +type PanelSpec = { name:string; kind:string; source:string; apis:ApiDefinition[]; calc:string }; + +const skills: ScenarioDefinition[] = [ + {key:"daily",section:"场站运营",icon:"报",title:"场站智能日报",short:"基于当前资产组节点状态生成可追溯日结",tone:"cyan",visual:"report",summary:"日报按当前资产组重新统计纳管、在线、离线、告警、故障及状态完整度;未接入的数据域明确显示为待补齐。",metrics:[{label:"纳管设备",value:"动态",delta:"资产组节点总数"},{label:"当前在线",value:"动态",delta:"按当前节点状态统计",tone:"good"},{label:"当前离线",value:"动态",delta:"不沿用其他资产组数据",tone:"warn"},{label:"告警 / 故障",value:"动态",delta:"设备当前状态口径",tone:"warn"}],bars:[0,0,0,0,0,0,0],insights:["按故障、告警、离线和数据完整度生成关注事项","设备名称只作为场站结论的证据","报警闭环、工单与保养未接入时不推测"],sources:["资产组清单","节点清单与当前状态","报警闭环(待接入)","工单与保养(待接入)"],formula:"当前资产组节点确定性统计 + 可编辑提示词的 AI 表述",maturity:"首版已实现 · 运维域待接入"}, + {key:"qa",section:"场站运营",icon:"问",title:"场站问答",short:"围绕当前资产组的节点现状进行证据化问答",tone:"blue",visual:"qa",summary:"问答始终锁定当前资产组,当前可回答资产组范围、节点数量、运行状态、故障、告警、离线和数据完整度。",metrics:[{label:"当前资产组",value:"动态",delta:"组织与场站范围"},{label:"已加载证据",value:"动态",delta:"节点状态记录",tone:"good"},{label:"可回答数据域",value:"2 / 5",delta:"资产组、节点状态"},{label:"数据完整度",value:"动态",delta:"已知状态 / 纳管总数",tone:"warn"}],bars:[0,0,0,0,0,0,0],insights:["本站当前有哪些故障和告警设备?","哪些设备离线,集中在哪个层级?","当前数据能否发布正式日报?"],sources:["资产组清单","节点清单与当前状态","报警流程/工单/保养(待接入)","SOP 知识库(待接入)"],formula:"问题识别 → 当前资产组证据筛选 → 确定性统计 → AI/本地规则回答",maturity:"节点状态问答首版已实现"}, + {key:"period",section:"运营分析",icon:"环",title:"运行环比",short:"按真实历史事件比较本月截至当前与上月同期",tone:"violet",visual:"compare",summary:"页面只在历史事件成功返回、可映射到当前资产组节点且时间字段有效时生成环比;否则显示数据缺口,不展示示例结论。",metrics:[{label:"事件影响可用率(估算)",value:"动态",delta:"同长度周期"},{label:"历史事件",value:"动态",delta:"当前资产组事件"},{label:"影响节点",value:"动态",delta:"按节点去重"},{label:"事件影响时长",value:"动态",delta:"事件持续时间合计"}],bars:[0,0,0,0,0,0,0],insights:["历史事件必须与当前资产组节点身份匹配","本月截至当前与上月同期采用相同窗口时长","能耗、工单和保养未接入,不参与本页环比"],sources:["资产组与节点范围","历史事件","事件持续时间","资产层级映射"],formula:"同长度窗口 + 资产组节点过滤 + 事件确定性统计 + AI 解释",maturity:"框架已实现 · 历史事件待有效认证实测"}, + {key:"benchmark",section:"运营分析",icon:"标",title:"同类场站对标",short:"按容量、负荷和场景归一化横向比较",tone:"blue",visual:"compare",summary:"本站综合表现位于同类 AIDC 场站前 35%,可用率领先,但制冷能效与运维响应仍有改进空间。",metrics:[{label:"同类排名",value:"7/20",delta:"前 35%",tone:"good"},{label:"可用率分位",value:"P82",delta:"领先同类",tone:"good"},{label:"能效分位",value:"P46",delta:"低于标杆",tone:"warn"},{label:"运维分位",value:"P61",delta:"中上水平"}],bars:[68,76,55,82,63,71,77],insights:["以 IT 负载、气候区和设计等级筛选同类站","标杆站制冷系统单位能耗低 8.6%","避免用设备绝对数量直接排名"],sources:["多场站快照","设计容量","负荷/处理量","天气与场站标签"],formula:"同类型分组 + 单位产出归一化 + 分位数排名",maturity:"需统一主数据口径"}, + {key:"maintenance",section:"运营分析",icon:"维",title:"运维履约",short:"比较计划、响应、关闭和维护后效果",tone:"orange",visual:"operations",summary:"今日计划任务完成率 80%,两张工单存在延期风险;近 30 日一次修复率较上期提升 6%。",metrics:[{label:"计划完成率",value:"80%",delta:"8 / 10",tone:"warn"},{label:"SLA 按时率",value:"92%",delta:"较上期 +4%",tone:"good"},{label:"一次修复率",value:"84%",delta:"较上期 +6%",tone:"good"},{label:"延期风险",value:"2",delta:"22:00 前到期",tone:"bad"}],bars:[58,66,73,69,78,84,92],insights:["WO-20260822-17 距 SLA 仅剩 42 分钟","维护后 24 小时报警下降 31%","夜班巡检还有 2 项未提交"],sources:["工单流转","巡检计划","保养记录","关联报警"],formula:"响应/到场/修复/关闭时长 + 维护前后效果",maturity:"接入运维模块即可落地"}, + {key:"health",section:"风险与策略",icon:"健",title:"场站运营健康度",short:"六维评分并解释场站级扣分来源",tone:"green",visual:"health",summary:"本站健康度 86 分,运行可用性良好;主要扣分来自能效偏离、未闭环事件和两项逾期风险。",metrics:[{label:"综合健康度",value:"86",delta:"等级:良好",tone:"good"},{label:"运行可用性",value:"94",delta:"权重 30%",tone:"good"},{label:"报警风险",value:"78",delta:"权重 20%",tone:"warn"},{label:"运维履约",value:"88",delta:"权重 20%",tone:"good"}],bars:[91,89,87,84,82,85,86],insights:["能效与产出维度扣 5.4 分","报警风险维度扣 4.1 分","评分均可下钻至系统和原始证据"],sources:["运行可用性","报警风险","运维履约","能效与数据质量"],formula:"六维规则评分,按场站类型配置权重",maturity:"可先规则化上线"}, + {key:"prediction",section:"风险与策略",icon:"预",title:"场站风险预测",short:"预测未来 24—72 小时的运行影响",tone:"violet",visual:"forecast",summary:"未来 24 小时整体风险可控;明日 14:00—17:00 高负荷时段,冷却能力裕度可能降至 12%。",metrics:[{label:"24h 综合风险",value:"中低",delta:"置信度 82%",tone:"good"},{label:"高风险窗口",value:"1",delta:"明日 14:00—17:00",tone:"warn"},{label:"能力最低裕度",value:"12%",delta:"建议阈值 15%",tone:"bad"},{label:"数据完整度",value:"96%",delta:"模型输入良好",tone:"good"}],bars:[28,31,36,48,63,71,54,42],insights:["高温与 IT 负荷共同压缩制冷裕度","未完成保养使风险概率增加 7%","建议提前确认备用冷机可用状态"],sources:["历史时序","天气/负荷","报警密度","未完工单"],formula:"趋势基线 + 时序预测 + 约束规则联合判断",maturity:"需积累 1—3 个月"}, + {key:"alarm",section:"风险与策略",icon:"警",title:"报警事件治理",short:"将报警风暴归并为可处置的场站事件",tone:"red",visual:"alarm",summary:"过去 2 小时的 27 条报警已归并为 3 个场站事件,压缩率 88.9%;其中 1 个事件尚未闭环。",metrics:[{label:"原始报警",value:"27",delta:"过去 2 小时"},{label:"归并事件",value:"3",delta:"压缩 88.9%",tone:"good"},{label:"未闭环事件",value:"1",delta:"优先级 P2",tone:"warn"},{label:"平均确认时长",value:"6m",delta:"较昨日 -3m",tone:"good"}],bars:[22,48,83,61,34,27,18],insights:["冷却水能力下降:关联 16 条报警","通信质量波动:关联 8 条报警","环境温度短时越限:关联 3 条报警"],sources:["报警历史","资产拓扑","测点相关性","确认/关闭记录"],formula:"时间窗口 + 拓扑关系 + 因果顺序进行事件聚类",maturity:"规则归并可立即实施"}, + {key:"efficiency",section:"运营分析",icon:"效",title:"资源与能效",short:"用单位产出指标发现场站级优化空间",tone:"green",visual:"efficiency",summary:"本站单位 IT 负荷制冷能耗较基线高 6.2%,冷却塔和二次泵合计贡献了 71% 的偏差。",metrics:[{label:"综合能效指数",value:"93.8",delta:"低于基线 6.2%",tone:"warn"},{label:"今日 PUE",value:"1.36",delta:"目标 1.32",tone:"warn"},{label:"可节约空间",value:"4.1%",delta:"约 860 kWh/日",tone:"good"},{label:"数据覆盖率",value:"91%",delta:"能源表计完整",tone:"good"}],bars:[74,79,83,88,92,86,81],insights:["二次泵频率在低负荷时段偏高","冷却塔逼近温度较基线增加 1.4℃","优化需保持温度、冗余和启停次数约束"],sources:["能源表计","负荷/处理量","天气数据","运行模式"],formula:"总资源消耗 ÷ 有效产出,并按工况建立基线",maturity:"需接入产出与能源数据"}, + {key:"capacity",section:"风险与策略",icon:"荷",title:"负荷与能力预测",short:"预测需求、产出和场站运行裕度",tone:"blue",visual:"capacity",summary:"明日峰值负荷预计达到设计能力的 84%,总体容量充足;高温时段建议预留至少一套冗余系统。",metrics:[{label:"明日峰值负荷",value:"84%",delta:"14:30 预计峰值",tone:"warn"},{label:"当前能力裕度",value:"23%",delta:"满足 N+1",tone:"good"},{label:"预测误差",value:"±4.8%",delta:"近 30 日回测",tone:"good"},{label:"受限时段",value:"3h",delta:"高温重载窗口",tone:"warn"}],bars:[48,55,61,70,84,78,66,54],insights:["天气升温预计带来 9% 冷量需求增量","峰时能力仍满足安全约束","建议 13:30 前完成备用系统检查"],sources:["历史负荷","设计能力","天气/日历","上下游需求"],formula:"多变量负荷预测 + 容量与冗余约束",maturity:"三个月数据可形成初版"}, + {key:"strategy",section:"风险与策略",icon:"策",title:"场站运行策略建议",short:"从协同运行角度生成可审批的优化方案",tone:"cyan",visual:"strategy",summary:"建议在低负荷窗口调整冷源组合并小步提高供水设定值,预计节能 3.2%,同时维持冗余与温度边界。",metrics:[{label:"预计节能",value:"3.2%",delta:"约 670 kWh/日",tone:"good"},{label:"风险等级",value:"低",delta:"约束校验通过",tone:"good"},{label:"建议窗口",value:"22:00",delta:"低负荷时段"},{label:"审批状态",value:"待审批",delta:"仅展示,不执行",tone:"warn"}],bars:[82,78,70,63,58,55,57],insights:["建议对象是场站运行组合,不是单设备控制","执行链路:建议→仿真→审批→分步执行→回退","燃气站首期仅提供建议,不进入自动执行"],sources:["实时测点","运行边界","控制状态","指令审计"],formula:"约束优化/仿真产生方案,AI 负责解释收益与风险",maturity:"建议模式可先落地"}, + {key:"emergency",section:"风险与策略",icon:"应",title:"应急辅助与复盘",short:"事件影响、SOP、处置进度与复盘闭环",tone:"red",visual:"emergency",summary:"当前无一级应急事件。最近一次事件持续 38 分钟,影响冷却冗余但未影响核心业务,处置过程形成 3 项改进任务。",metrics:[{label:"当前应急事件",value:"0",delta:"运行平稳",tone:"good"},{label:"最近事件时长",value:"38m",delta:"已闭环"},{label:"SOP 执行率",value:"100%",delta:"8 / 8 步",tone:"good"},{label:"改进任务",value:"3",delta:"1 项待完成",tone:"warn"}],bars:[18,62,88,74,49,31,20],insights:["10:15 事件触发并完成影响分析","10:21 启用备用运行路径","10:53 指标恢复并进入观察期"],sources:["事件时间线","SOP 知识库","控制记录","工单与人员"],formula:"拓扑影响分析 + SOP 检索 + 处置过程自动归档",maturity:"检索与摘要可先上线"}, +]; + +// 2026-08-22 从用户已登录的 Demo Org / 水务资产组页面读取的可见设备快照。 +// 源页面显示总计 25 台;首屏可追溯到以下 20 台,剩余 5 台由连接后的节点 API 自动补齐。 +const waterGroupSnapshot: Device[] = [ + {id:"water-01",name:"水泵1",site:"良泉水厂 / 一号泵站",type:"水泵",status:"告警",score:68,alarm:"源站当前状态:告警",metric:"—"}, + {id:"water-02",name:"水泵2",site:"良泉水厂 / 一号泵站",type:"水泵",status:"告警",score:68,alarm:"源站当前状态:告警",metric:"—"}, + {id:"water-03",name:"水箱1",site:"良泉水厂 / 一号泵站",type:"水箱",status:"运行",score:92,alarm:"无",metric:"—"}, + {id:"water-04",name:"水箱2",site:"良泉水厂 / 一号泵站",type:"水箱",status:"运行",score:92,alarm:"无",metric:"—"}, + {id:"water-05",name:"水阀1",site:"良泉水厂 / 一号泵站",type:"阀门",status:"运行",score:91,alarm:"无",metric:"—"}, + {id:"water-06",name:"水阀2",site:"良泉水厂 / 一号泵站",type:"阀门",status:"运行",score:91,alarm:"无",metric:"—"}, + {id:"water-07",name:"压力表1",site:"良泉水厂 / 一号泵站",type:"仪表",status:"告警",score:70,alarm:"源站当前状态:告警",metric:"—"}, + {id:"water-08",name:"气罐1",site:"良泉水厂 / 一号泵站",type:"气罐",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"water-09",name:"流量计1",site:"良泉水厂 / 一号泵站",type:"仪表",status:"离线",score:55,alarm:"通信/在线状态异常",metric:"—"}, + {id:"water-10",name:"控制柜1",site:"良泉水厂 / 一号泵站",type:"控制柜",status:"离线",score:55,alarm:"通信/在线状态异常",metric:"—"}, + {id:"water-11",name:"水质分析仪1",site:"良泉水厂 / 一号泵站",type:"水质仪表",status:"离线",score:55,alarm:"通信/在线状态异常",metric:"—"}, + {id:"water-12",name:"配电柜",site:"良泉水厂 / 一号泵站",type:"配电",status:"离线",score:55,alarm:"通信/在线状态异常",metric:"—"}, + {id:"water-13",name:"水表",site:"良泉水厂 / 一号泵站",type:"仪表",status:"离线",score:55,alarm:"通信/在线状态异常",metric:"—"}, + {id:"water-14",name:"电表",site:"良泉水厂 / 一号泵站",type:"仪表",status:"离线",score:55,alarm:"通信/在线状态异常",metric:"—"}, + {id:"water-15",name:"水泵3",site:"良泉水厂 / 一号泵站",type:"水泵",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"water-16",name:"水泵3_1ikvuvsvk",site:"良泉水厂 / 一号泵站",type:"水泵",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"water-17",name:"machine",site:"良泉水厂 / 一号泵站",type:"其他",status:"未知",score:75,alarm:"源站未显示状态",metric:"—"}, + {id:"water-18",name:"水箱1_1j69i1emc",site:"良泉水厂 / 一号泵站",type:"水箱",status:"离线",score:55,alarm:"通信/在线状态异常",metric:"—"}, + {id:"water-19",name:"1#卧式泵",site:"良泉水厂 / 二号泵站",type:"水泵",status:"故障",score:42,alarm:"源站当前状态:故障",metric:"—"}, + {id:"water-20",name:"2#卧式泵",site:"良泉水厂 / 二号泵站",type:"水泵",status:"运行",score:90,alarm:"无",metric:"—"}, +]; + +// 2026-08-22 从用户已登录的 Demo Org / SMT车间页面逐行核对的 20 台设备。 +const smtGroupSnapshot: Device[] = [ + {id:"smt-01",name:"上板机",site:"SMT车间 / 1#产线",type:"SMT设备",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"smt-02",name:"锡膏印刷机",site:"SMT车间 / 1#产线",type:"SMT设备",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"smt-03",name:"高速贴片机FUJI",site:"SMT车间 / 1#产线",type:"SMT设备",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"smt-04",name:"高速贴片机_FUJJ",site:"SMT车间 / 1#产线",type:"SMT设备",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"smt-05",name:"回流焊炉(报警分析)",site:"SMT车间 / 1#产线",type:"SMT设备",status:"告警",score:68,alarm:"源站当前状态:告警",metric:"—"}, + {id:"smt-06",name:"AOI",site:"SMT车间 / 1#产线",type:"SMT设备",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"smt-07",name:"下板机",site:"SMT车间 / 1#产线",type:"SMT设备",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"smt-08",name:"双轨上板机",site:"SMT车间 / 2#产线",type:"SMT设备",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"smt-09",name:"专用印刷机",site:"SMT车间 / 2#产线",type:"SMT设备",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"smt-10",name:"微米贴片机",site:"SMT车间 / 2#产线",type:"SMT设备",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"smt-11",name:"真空回流焊炉",site:"SMT车间 / 2#产线",type:"SMT设备",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"smt-12",name:"高分辨率AOI",site:"SMT车间 / 2#产线",type:"SMT设备",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"smt-13",name:"X-Ray检测机",site:"SMT车间 / 2#产线",type:"SMT设备",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"smt-14",name:"自动上板机-1",site:"SMT车间 / 3#产线",type:"SMT设备",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"smt-15",name:"自动上板机-2",site:"SMT车间 / 3#产线",type:"SMT设备",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"smt-16",name:"双轨印刷机",site:"SMT车间 / 3#产线",type:"SMT设备",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"smt-17",name:"双通道回流焊炉",site:"SMT车间 / 3#产线",type:"SMT设备",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"smt-18",name:"高速AOI-1",site:"SMT车间 / 3#产线",type:"SMT设备",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"smt-19",name:"高速AOI-2",site:"SMT车间 / 3#产线",type:"SMT设备",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"smt-20",name:"轨迹模拟",site:"SMT车间",type:"站点节点",status:"运行",score:90,alarm:"无",metric:"—"}, +]; + +// PMS 源页确认共 61 台;以下为当前第一页可追溯的 20 台,剩余 41 台由节点 API 补齐。 +const pmsGroupSnapshot: Device[] = [ + {id:"滚压机1-3_1ie3beb4p2",name:"滚压机1-3",site:"A部门 / A五产线",type:"PMS设备",status:"离线",score:55,alarm:"通信/在线状态异常",metric:"—"}, + {id:"滚压机1-4_1ie3beb4p1",name:"滚压机1-4",site:"A部门 / A五产线",type:"PMS设备",status:"离线",score:55,alarm:"通信/在线状态异常",metric:"—"}, + {id:"machine_1i9qo1tmi",name:"滚压机1-1",site:"A部门 / 一产线",type:"PMS设备",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"machine_1i9qo22",name:"滚压机1-2",site:"A部门 / 一产线",type:"PMS设备",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"machine_1i9qo2596",name:"滚压机1-3",site:"A部门 / 一产线",type:"PMS设备",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"machine_1i9qo281j",name:"滚压机1-4",site:"A部门 / 一产线",type:"PMS设备",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"machine_1i9qo29b5",name:"滚压机1-5",site:"A部门 / 一产线",type:"PMS设备",status:"运行",score:90,alarm:"无",metric:"—"}, + {id:"滚压机1-3_1ie3bdqko4",name:"滚压机1-3",site:"A部门 / A四产线",type:"PMS设备",status:"离线",score:55,alarm:"通信/在线状态异常",metric:"—"}, + {id:"滚压机1-4_1ie3bdqko3",name:"滚压机1-4",site:"A部门 / A四产线",type:"PMS设备",status:"离线",score:55,alarm:"通信/在线状态异常",metric:"—"}, + {id:"滚压机1-5_1ie3bdqko2",name:"滚压机1-5",site:"A部门 / A四产线",type:"PMS设备",status:"离线",score:55,alarm:"通信/在线状态异常",metric:"—"}, + {id:"滚压机1-1_1ie3bdqko1",name:"滚压机1-1",site:"A部门 / A四产线",type:"PMS设备",status:"离线",score:55,alarm:"通信/在线状态异常",metric:"—"}, + {id:"滚压机1-2_1ie3bdqko0",name:"滚压机1-2",site:"A部门 / A四产线",type:"PMS设备",status:"离线",score:55,alarm:"通信/在线状态异常",metric:"—"}, + {id:"machine_1i9qo5lje",name:"刨渣机2-1",site:"A部门 / 二产线",type:"PMS设备",status:"离线",score:55,alarm:"通信/在线状态异常",metric:"—"}, + {id:"刨渣机2-21i9qo7bjc",name:"刨渣机2-2",site:"A部门 / 二产线",type:"PMS设备",status:"离线",score:55,alarm:"通信/在线状态异常",metric:"—"}, + {id:"刨渣机2-31i9qo7jkd",name:"刨渣机2-3",site:"A部门 / 二产线",type:"PMS设备",status:"离线",score:55,alarm:"通信/在线状态异常",metric:"—"}, + {id:"滚压机1-3_1ie3bcv3e2",name:"滚压机1-3",site:"A部门 / 三产线",type:"PMS设备",status:"离线",score:55,alarm:"通信/在线状态异常",metric:"—"}, + {id:"滚压机1-4_1ie3bcv3e1",name:"滚压机1-4",site:"A部门 / 三产线",type:"PMS设备",status:"离线",score:55,alarm:"通信/在线状态异常",metric:"—"}, + {id:"滚压机1-5_1ie3bcv3e0",name:"滚压机1-5",site:"A部门 / 三产线",type:"PMS设备",status:"离线",score:55,alarm:"通信/在线状态异常",metric:"—"}, + {id:"滚压机1-1_1ie3bcv3e4",name:"滚压机1-1",site:"A部门 / 三产线",type:"PMS设备",status:"离线",score:55,alarm:"通信/在线状态异常",metric:"—"}, + {id:"滚压机1-2_1ie3bcv3e3",name:"滚压机1-2",site:"A部门 / 三产线",type:"PMS设备",status:"离线",score:55,alarm:"通信/在线状态异常",metric:"—"}, +]; + +const snapshotAssetGroups: AssetGroup[] = [ + {id:"snapshot-smt",name:"SMT车间",deviceCount:20,orgName:"Demo Org",source:"snapshot"}, + {id:"snapshot-pms",name:"PMS",deviceCount:61,orgName:"Demo Org",source:"snapshot"}, + {id:"snapshot-water",name:"水务资产组",deviceCount:25,orgName:"良泉水厂",source:"snapshot"}, +]; + +function snapshotDevicesForGroup(groupName?:string): Device[] { + if (groupName === "SMT车间") return smtGroupSnapshot; + if (groupName === "PMS") return pmsGroupSnapshot; + if (groupName === "水务资产组") return waterGroupSnapshot; + return []; +} + +const apiCatalog: Record = { + groups:{label:"资产组 / 场站清单",method:"GET",path:"{APM}/api/v1/asset/group/list?count=1000",status:"verified",note:"Demo 连接实测 HTTP 200;资产组作为场站主键和查询入口。"}, + nodes:{label:"场站节点清单",method:"GET",path:"{APM}/api/v1/asset/node/list",status:"source",note:"源码确认参数 groupName、groupId、index、count;需按已选资产组发起请求。"}, + nodeStatus:{label:"节点当前状态",method:"GET",path:"{APM}/api/v1/asset/node/status/ids",status:"source",note:"源码确认按节点 ID 查询;状态枚举需和 MU 页面口径映射。"}, + properties:{label:"节点属性与测点",method:"GET",path:"{APM}/api/v1/node/property/byid/list",status:"source",note:"源码确认支持 nodeIds、componentName、name、queryType、分页参数。"}, + latest:{label:"实时测点",method:"POST",path:"{APM}/api/v1/latest/data",status:"source",note:"源码确认;请求体需从源站成功请求中固化测点 ID、聚合和时间口径。"}, + history:{label:"历史测点",method:"POST",path:"{APM}/api/v1/hist/raw/data 或 /api/v1/hist/span/data",status:"source",note:"源码确认原始值与时间聚合两类接口;区间、步长和缺失值规则待映射。"}, + events:{label:"报警历史 / 当前事件",method:"GET",path:"{APM}/api/v1/event/hist 或 /api/v1/event/latest",status:"source",note:"路径与查询参数已从源模块确认;运行环比当前调用 hist,但仍需在有效认证下验证 HTTP 成功、返回结构、时间字段及资产组节点映射。"}, + alarmEvents:{label:"报警流程事件列表",method:"POST",path:"{alarm-process}/api/v1/event/list",status:"source",note:"源码确认;用于事件状态、级别、确认与闭环时间。网关基址待抓包映射。"}, + alarmProcess:{label:"报警处置日志",method:"GET/POST",path:"{alarm-process}/api/v1/event/process/log",status:"source",note:"源码确认单条/批量日志及 process 接口;用于还原处置链。"}, + workOrders:{label:"工单列表与状态",method:"POST",path:"{api-ticket}/api/v1/tickets",status:"source",note:"源码确认;服务网关地址、工单状态和 SLA 字段待从成功请求映射。"}, + maintenance:{label:"保养计划关联工单",method:"POST",path:"{api-ticket}/api/v1.0/audit/maintenance/schedules/tickets",status:"source",note:"源码确认;用于计划、到期、完成和维护效果关联。"}, + stationParams:{label:"场站关键参数",method:"GET",path:"{m2i}/asset/station/keyparameters/byids",status:"source",note:"源码确认;用于配置容量、边界、关键测点及场站口径。"}, + controlLogs:{label:"控制指令与状态日志",method:"POST",path:"{rest-bridge}/api/v1/reversecontrol/log",status:"source",note:"源码确认 log、status、exec、batch;首期只读,用于审计和效果验证。"}, + schedules:{label:"智能控制排程",method:"POST",path:"{api-m2icore}/api/v1.0/intelli-control/schedules/list",status:"source",note:"源码确认排程列表、状态和详情接口;不代表 Demo 获得执行权限。"}, + execution:{label:"控制执行记录",method:"POST",path:"{api-m2icore}/api/v1.0/intelli-control/execution-records",status:"source",note:"源码确认;用于建议、审批、执行与回退的闭环审计。"}, + external:{label:"外部与经营主数据",method:"待接入",path:"天气 / 电价 / 处理量或负荷 / 设计容量 / 班次人员 / SOP 知识库",status:"external",note:"当前 MU 已确认接口不能完整提供,需由场站侧数据平台或主数据服务补齐。"}, +}; + +const scenarioDesigns: Record = { + daily:{feasibility:"首版已经实现。资产组可切换,节点清单返回后会重新统计当前范围;水务、SMT、PMS 的源站可见快照只用于接口未返回明细时保留已确认事实,并明确标注数据模式。",sourceText:"以当前资产组作为场站边界,只使用该组节点清单和节点当前状态计算纳管、在线、离线、告警、故障及状态完整度;不会跨资产组复用数据。",apiKeys:["groups","nodes","nodeStatus"],gaps:"报警流程闭环、工单、保养、关键测点和按日历史尚未接入,因此当前日报不生成未处理报警数、工单数、保养数和趋势结论。",kpiCalcs:["优先取当前资产组节点清单长度;若接口明细未返回,只显示源站已确认的资产组设备总数并标注快照模式。","在线数 = 当前资产组中除离线及未知外的节点数;同时单列明确为运行/正常的节点数和状态覆盖数。","离线数 = 当前资产组节点状态映射为“离线”的记录数,不把未知状态计为离线。","分别统计当前节点状态为“告警”和“故障”的记录;该值不是报警流程事件条数。"],visualPanel:"站点智能日报与异常证据",visualCalc:"以当前节点状态生成今日结论、运行健康估算和状态分布;异常清单按故障→告警→离线排序,最多展示 12 条可追溯节点证据。",insightCalc:"将确定性统计与异常节点作为上下文,按事实、场站影响、建议动作生成三条关注事项;提示词可查看、编辑并重新生成。",summaryCalc:"汇总纳管、运行、告警、故障、离线和未知状态,并明确列出尚未接入的数据域;模型不可补写缺失数字。"}, + qa:{feasibility:"节点状态问答首版已经实现。切换资产组后,问题、回答和证据会清空并重新绑定当前范围;回答可由 AI 服务生成,也可回退到本地确定性规则。",sourceText:"问题始终绑定当前资产组 id/name;当前只把资产组、节点清单、节点状态统计和最多 8 条相关节点作为回答证据。",apiKeys:["groups","nodes","nodeStatus"],gaps:"报警流程闭环、工单、保养、历史趋势、测点值和 SOP 尚未接入,遇到这些问题应明确说明数据不足,不能把源站演示页面内容当作答案。",kpiCalcs:["使用资产组 id/name 锁定全部上下文;资产组切换时清空上一组的问答状态。","已加载证据 = 当前资产组实际载入的节点记录数;纳管总数单独显示。","当前可回答域为资产组与节点状态,共 2/5;其余域在上下文中标记为 unavailableDomains。","数据完整度 = (纳管总数 − 未知状态数) ÷ 纳管总数 × 100%;无纳管基数时显示缺口。"],visualPanel:"向当前场站提问",visualCalc:"识别故障、告警、离线、运行和日报可发布性等意图,筛选当前资产组节点并形成带数据模式、API 路径和节点清单的回答。",insightCalc:"推荐问题只覆盖当前已具备的节点状态域;相关证据按问题关键词过滤,最多展示 8 条。",summaryCalc:"回答包含场站范围、确定性统计、证据节点、数据来源和未接入域;AI 服务不可用时使用同一证据生成本地规则答案。"}, + period:{feasibility:"真实数据框架已经实现,但正式环比结论是有条件的:必须取得有效认证,历史事件请求成功,返回记录可映射到当前资产组节点,且具备可解析的开始/结束时间。当前不以源站“综合数据分析”的演示内容作为可行性证据。",sourceText:"先用资产组和节点清单建立当前场站边界,再分别查询本月截至当前与上月同期的历史事件,只保留可归属于当前资产组节点的记录。",apiKeys:["groups","nodes","events"],gaps:"历史事件接口仍需在有效认证下完成实测和字段校准;事件重叠尚未去重。能耗、产出、工单、保养及真实运行时长均未接入,不参与当前页面。",kpiCalcs:["事件影响可用率(估算) = 1 − 事件影响时长 ÷(纳管节点数 × 比较窗口小时);本月值减上月值显示百分点变化。","分别统计两个同长度窗口内、可归属当前资产组的历史事件记录数;事件量环比 = (本期 − 基期) ÷ 基期。","影响节点数 = 当前周期出现历史事件的节点 ID 去重数;无法映射的事件不得归入当前资产组。","事件影响时长 = 每条事件结束时间减开始时间后求和;仍未结束的事件截至窗口结束,重叠事件未去重时结果可能偏保守。"],visualPanel:"本月与上月同期事件对比",visualCalc:"展示事件影响可用率估算、历史事件、影响节点、影响时长四项本期/基期数据;无有效记录时显示数据缺口而不是示例值。",insightCalc:"按本月事件数量汇总场站层级贡献,并列出高频影响节点;只有通过资产组过滤的事件才能进入排名。",summaryCalc:"将两个窗口、四项确定性指标、层级贡献和高频节点交给 AI 解释;请求失败或映射不足时停止生成结论并展示请求轨迹。"}, + benchmark:{feasibility:"有条件可做。MU 可提供站内数据,但横向对标还需要多场站统一主数据、设计容量、负荷和场站标签。",sourceText:"从多个资产组抽取同口径指标,按场站类型、容量、气候区和运行模式分群后再比较。",apiKeys:["groups","nodes","history","events","workOrders","stationParams","external"],gaps:"需要建立跨场站指标版本、容量/等级标签和数据授权;样本太少时不展示排名。",kpiCalcs:["本站综合标准分在同类有效样本中的降序排名;同时显示样本量。","以可用率在同类分布中的百分位 P 值表示,检修时段按统一规则处理。","以单位产出能耗反向计算百分位;越低越优,需控制气候与负荷差异。","由 SLA 按时率、一次修复率、逾期率加权后计算同类百分位。"],visualPanel:"同类场站标准化对比",visualCalc:"先按场站标签筛同类样本,再把四类指标转为分位数或标准分,不直接比较设备数量。",insightCalc:"计算本站与 P75 标杆的差距,并定位最有改进空间的系统和运营环节。",summaryCalc:"解释同类样本选择、本站位置、主要优势和差距,禁止在样本不足时生成虚假排名。"}, + maintenance:{feasibility:"接口层面可做。工单、流程和保养计划路径已确认,仍需映射部署网关与状态字段。",sourceText:"以工单和保养计划为主数据,关联报警事件及维护前后关键测点,评价计划、响应、处置、验证四段履约。",apiKeys:["workOrders","maintenance","alarmEvents","alarmProcess","history","nodes"],gaps:"需取得 api-ticket 网关地址,确认计划时间、响应、到场、完成、关闭和责任班组字段。",kpiCalcs:["今日已完成计划任务数 ÷ 今日应完成计划任务数;取消任务单列。","在 SLA 截止前达到指定流程状态的工单数 ÷ 应考核工单数。","一次处置后在观察窗内未重开/未重复报警的工单数 ÷ 已完成工单数。","根据剩余 SLA、当前状态、历史同类耗时和责任资源预测未来 24 小时逾期数。"],visualPanel:"今日运维履约漏斗",visualCalc:"按计划→响应→处置→效果验证统计同一批任务的留存数,并展示每一步流失原因。",insightCalc:"优先提示临近 SLA、缺责任人、处置后复发和巡检未提交的任务。",summaryCalc:"汇总完成率、SLA、一次修复和高风险任务,给出责任环节而非归责到单台设备。"}, + health:{feasibility:"规则版可做。已有资产、状态、报警、工单和测点即可形成首版;能效与安全维度需补场站参数。",sourceText:"从场站运行可用性、报警风险、运维履约、能效产出、数据可信度、安全合规六个维度评分。",apiKeys:["nodes","nodeStatus","latest","history","alarmEvents","workOrders","maintenance","stationParams","external"],gaps:"权重、阈值和红线需按燃气站、水站、污水、新能源、AIDC 分类型配置并由业务确认。",kpiCalcs:["六维得分按场站类型权重加权;任一安全红线触发时总等级封顶。","在线时长、关键系统冗余满足率和计划停机规则组合评分。","以未闭环级别、持续时间、影响范围和重复率计算 0—100 反向得分。","由 SLA 按时率、计划完成率、一次修复率和逾期率合成。"],visualPanel:"六维运营健康度",visualCalc:"每维 0—100 分,展示权重、扣分项和原始证据;不以模型黑盒分数替代规则。",insightCalc:"按总分扣分贡献排序,并把设备证据归并成场站系统级问题。",summaryCalc:"解释总等级、前三项扣分来源及可改善动作,同时输出数据完整性。"}, + prediction:{feasibility:"需要数据积累后可做。当前接口具备时序、事件和工单输入,但至少需 1—3 个月连续数据和故障/影响标签。",sourceText:"融合关键测点趋势、报警密度、未完工单、天气与负荷预测未来 24—72 小时场站影响。",apiKeys:["history","latest","events","workOrders","maintenance","stationParams","external"],gaps:"需沉淀高风险窗口标签、场站能力边界、天气/需求预测和历史干预结果;先规则预警再训练模型。",kpiCalcs:["对未来 24 小时每个时间片计算风险概率,再按最高影响等级映射为低/中/高。","连续超过风险阈值且持续达到最小时长的时间段合并为窗口。","(可用能力-预测需求)÷可用能力;考虑检修、冗余和环境降额。","模型所需有效时间点数 ÷ 应有时间点数,并对关键变量设最低覆盖要求。"],visualPanel:"未来 24—72 小时风险曲线",visualCalc:"规则阈值、时序预测和场站约束联合输出风险区间,并显示置信带与关键驱动因素。",insightCalc:"按风险增量贡献排序天气、负荷、工单和测点趋势,给出可验证的提前动作。",summaryCalc:"描述风险窗口、影响系统、置信度和建议,不把预测概率表述为确定故障。"}, + alarm:{feasibility:"规则归并可先落地。报警历史、流程事件和资产关系均有对应接口;因果模型需后续数据验证。",sourceText:"在当前场站内按时间窗口、资产拓扑、相同根因特征和恢复顺序,把重复报警归并为可处置事件。",apiKeys:["events","alarmEvents","alarmProcess","nodes","properties","history"],gaps:"需映射 alarm-process 网关、事件状态及关联规则;首期规则结果应允许人工拆分/合并。",kpiCalcs:["统计窗口内原始报警记录数,按消息唯一键去重。","对原始报警执行时间+拓扑+规则聚类后的事件簇数量。","状态未关闭且仍有影响的事件簇数量,不等同于未恢复报警条数。","已确认事件的 ackTs-createTs 平均值,并同时保留 P50/P90。"],visualPanel:"报警归并为场站事件",visualCalc:"按 5—15 分钟滑动窗口建立关联图,再结合上下游拓扑与状态传播顺序形成事件簇。",insightCalc:"按影响系统、事件等级、持续时间和未闭环状态计算优先级。",summaryCalc:"以事件为单位说明影响、可能关联和处置进度,不对尚无证据的根因作确定判断。"}, + efficiency:{feasibility:"有条件可做。历史测点可读取,但必须明确能源表计、有效产出/负荷、天气和运行模式。",sourceText:"聚合场站能源表计,并以供水量、处理量、发电量、输气量或 IT 负荷作为有效产出建立同工况基线。",apiKeys:["properties","history","latest","stationParams","external"],gaps:"需建立能源表与产出表的测点映射、计量边界、倍率、缺数补齐和气候/负荷归一化规则。",kpiCalcs:["实际单位产出能耗 ÷ 同工况基线 ×100 的反向指数;页面中 100 为基准。","AIDC:场站总耗电 ÷ IT 负载耗电;其他站型替换为对应行业核心能效指标。","在安全与产出约束下,基线能耗减优化模拟能耗,再除以基线能耗。","有有效质量标记的能源与产出采样点 ÷ 应有采样点。"],visualPanel:"单位产出能耗与偏差贡献",visualCalc:"用可解释基线估算期望能耗,实际与期望之差按系统分摊贡献,不跨计量边界重复累计。",insightCalc:"按可节约量、持续时间和操作风险排序场站系统级优化空间。",summaryCalc:"说明偏差、主要贡献系统、潜在收益及约束条件;节能值标注为估算而非结算。"}, + capacity:{feasibility:"预测原型可做,但正式结论依赖设计容量、运行降额、需求/产出和天气等外部数据。",sourceText:"使用历史负荷和日历天气预测需求,再与场站设计能力、当前可用能力和冗余要求比较。",apiKeys:["history","latest","nodeStatus","stationParams","maintenance","external"],gaps:"需配置设计能力曲线、N+1/N+2 规则、环境降额、计划检修和上下游需求数据。",kpiCalcs:["未来 24 小时预测需求最大值 ÷ 对应时刻可用能力,显示峰值时间。","(当前可用能力-当前需求-必需冗余)÷当前可用能力。","近 30 日滚动回测 MAPE;低负荷和零值场景使用 WAPE 辅助。","预测需求超过安全能力阈值的连续时段总小时数。"],visualPanel:"需求与能力裕度预测",visualCalc:"多变量预测产生需求曲线,叠加可用能力、降额和冗余线,识别受限窗口。",insightCalc:"量化天气、计划检修和需求变化对最小裕度的贡献,并提示提前准备时间。",summaryCalc:"说明峰值、最小裕度、受限窗口、误差和建议,不将设计容量等同实时可用能力。"}, + strategy:{feasibility:"建议模式可以先做,自动执行暂不建议。实时/历史测点和控制日志可支撑方案计算与事后验证。",sourceText:"围绕场站级协同目标生成运行组合建议,读取控制状态和边界;经仿真、审批后才可进入执行网关。",apiKeys:["latest","history","stationParams","controlLogs","schedules","execution","external"],gaps:"需建立控制点白名单、联锁/回退条件、变更窗口、审批责任和数字仿真;燃气站首期仅建议。",kpiCalcs:["基线能耗减策略仿真能耗,再除以基线能耗;同时校验产出和安全约束。","取约束违规概率、模型不确定性和回退可用性中的最不利等级。","在预测低负荷、无计划变更、数据完整且人员可用的时段中选最早窗口。","读取建议审批流程状态;没有明确授权时始终保持“待审批/仅展示”。"],visualPanel:"场站协同运行建议",visualCalc:"约束优化生成候选组合,依次通过收益预测、边界校验、仿真、人工审批和执行验证。",insightCalc:"比较候选策略的收益、风险、影响范围和可回退性,输出推荐顺序。",summaryCalc:"解释场站级收益与边界,不突出单设备优化;任何执行动作都必须有独立授权和审计。"}, + emergency:{feasibility:"应急检索与复盘可先做;实时指挥必须由人员负责。事件、处置日志、控制记录和工单可还原时间线。",sourceText:"把场站事件、报警处置、控制审计、SOP 步骤和后续工单按统一时间轴关联。",apiKeys:["alarmEvents","alarmProcess","events","controlLogs","execution","workOrders","external"],gaps:"需接入正式 SOP、应急等级、岗位通讯录和人工确认记录;AI 不替代应急指挥与联锁。",kpiCalcs:["当前状态为处置中/观察中且达到应急等级阈值的事件数。","最近一次已闭环事件的 closeTs-startTs,暂停/观察时段按制度定义。","有完成证据且顺序符合要求的 SOP 步骤数 ÷ 应执行步骤数。","事件关闭后创建的复盘改进工单数,并按待办/进行中/完成分组。"],visualPanel:"场站事件处置时间线",visualCalc:"按时间戳合并报警、人工确认、控制动作、指标恢复和工单,形成可审计时间线。",insightCalc:"识别响应间隔过长、步骤缺失、指标反复和未落实改进项。",summaryCalc:"生成影响范围、关键处置、恢复依据和改进任务摘要;未记录动作不做推断。"}, +}; + +const pageNarratives: Record = { + daily:{audience:"场站负责人、值班主管、区域运营负责人和需要快速查看场站现状的企业管理者。",value:"把当前资产组的纳管规模、运行状态、异常节点和数据完整度整理成一页可追溯日结,减少手工点查和口头汇报;未接入内容会明确显示,不制造完整但不可靠的日报。",whyAI:"节点统计可以由规则完成,但管理者更需要场站影响、优先顺序和建议动作。AI 用于把已确认事实转成不同角色易读的摘要,且允许查看并人工优化提示词后重新生成。",feasibilityEvidence:"当前页面已经能切换水务、SMT、PMS 等资产组,并按当前组加载节点清单/状态或源站已确认快照,实时计算数量与异常证据。现阶段可形成节点状态版日报;报警闭环、工单、保养和历史趋势尚未接入,设计说明不把这些域列为已实现。"}, + qa:{audience:"值班人员、场站负责人、区域支持团队和希望快速了解本站现状的企业管理者。",value:"把资产组筛选、节点状态统计和异常查找变成自然语言问答;用户可以快速知道本站有多少节点、哪些故障/告警/离线,以及当前数据能否支持正式日报。",whyAI:"同一事实会被不同角色用不同问法提出。AI 负责理解问题、组织场站级答案和解释证据;数量、状态分布与数据完整度仍由确定性规则计算,AI 服务不可用时可回退本地回答。",feasibilityEvidence:"当前页面已经实现资产组切换、节点证据加载、快捷问题、答案生成、相关节点筛选和 API 证据展示。可行性来自已经接线的资产组与节点数据,不来自尚未验证的历史、报警流程、工单或 SOP 页面。"}, + period:{audience:"场站负责人、区域运营经理和需要比较场站运行变化的运维团队负责人。",value:"在相同时间长度和相同资产组边界下比较事件影响,帮助判断本月异常是否增多、影响范围是否扩大,以及问题集中在哪个场站层级或节点。",whyAI:"周期指标必须由规则计算,但层级贡献与变化原因较难快速阅读。AI 只对已经计算并通过范围校验的结果做解释;没有有效历史记录时不生成“看似合理”的结论。",feasibilityEvidence:"当前页面已经具备资产组切换、节点范围建立、双周期请求、事件指标计算、层级贡献、高频节点、AI 解读和请求轨迹的实现框架。可行性仍取决于 `/api/v1/event/hist` 在有效认证下返回可映射、带时间字段的真实记录;源站“综合数据分析”菜单为演示数据,已明确排除,不能作为历史积累证据。"}, + benchmark:{audience:"集团或区域管理者、多站点运营负责人、能效负责人和投资规划团队。",value:"识别本站在同类场站中的真实位置,发现可复制的标杆做法,避免只看绝对设备数量或单一能耗造成错误判断。",whyAI:"横向比较需要自动完成同类筛选、容量与负荷归一化、异常样本识别和差距解释,AI 能把复杂统计转成管理者可行动的结论。",feasibilityEvidence:"源站以资产组管理多个场站,并已积累各场站的状态、历史测点和事件数据;这为多站点指标抽取提供基础,但正式排名仍需补充统一容量、气候区和运行等级主数据。"}, + maintenance:{audience:"运维团队负责人、场站负责人、值班主管、服务商经理和 SLA 管理人员。",value:"把计划、响应、处置、关闭和维护后效果放在同一视图,及时发现延期风险、责任卡点和重复故障,提升履约率与一次修复率。",whyAI:"工单流程、报警复发和维护前后测点分散在不同记录中。AI 能自动关联同一事件链、预测逾期并生成班组层面的改善建议。",feasibilityEvidence:"源模块中已确认工单列表、工作流、保养计划关联工单、报警流程日志和历史测点路径,具备计算响应时长、完成率及维护效果的原始记录;尚需映射 api-ticket 网关和状态字段。"}, + health:{audience:"企业管理者、区域运营负责人、场站负责人、风险与安全管理人员。",value:"用一个可解释的场站级健康度统一呈现运行、报警、运维、能效、数据和安全状态,使管理者快速比较、排序和追踪改善。",whyAI:"单一分数本身价值有限,关键是解释扣分来源、影响范围和改善路径。AI 适合把规则评分与底层证据组织成面向不同角色的解释。",feasibilityEvidence:"源站已具备设备状态、报警事件、历史测点、工单保养和场站参数等健康度输入,能够先上线透明规则版评分;各站型权重与安全红线需由业务配置。"}, + prediction:{audience:"场站负责人、值班主管、区域风险管理团队、运维计划人员和应急准备人员。",value:"把被动告警前移为未来 24—72 小时的风险窗口,使人员、备用能力和维护工作提前准备,减少场站能力受损和突发处置。",whyAI:"风险通常由负荷、环境、趋势、报警密度和未完工单共同驱动,AI/时序模型适合识别非线性组合并解释主要驱动因素。",feasibilityEvidence:"源站已经长期保存历史曲线、趋势、事件和工单记录,具备形成时序特征的基础;初期可用规则预警,积累 1—3 个月连续数据及影响标签后再训练预测模型。"}, + alarm:{audience:"值班人员、报警中心、运维负责人、场站负责人和区域事件治理团队。",value:"把大量重复报警压缩为少数场站事件,降低报警疲劳,突出真正影响场站能力的事项,并缩短确认和闭环时间。",whyAI:"报警关联涉及时间、拓扑、状态传播和文本描述。AI 可以辅助事件聚类与摘要,让人员围绕事件处置,而不是逐条阅读报警。",feasibilityEvidence:"源站已提供报警历史、当前事件、报警流程日志和资产拓扑相关接口,且页面本身具备报警查看与处置能力;首期可采用可解释规则归并并允许人工拆分、合并。"}, + efficiency:{audience:"能源管理人员、场站负责人、企业运营管理者、成本负责人和节能项目团队。",value:"将总耗能转成单位产出指标,定位系统级偏差与可节约空间,为运行优化、节能项目和成本复盘提供量化依据。",whyAI:"能效受负荷、天气、运行模式和设备组合共同影响。AI 能建立同工况基线、解释偏差贡献,并生成带安全约束的节能机会。",feasibilityEvidence:"源站已经具备历史曲线、用量分析、关键测点和场站参数功能,可提供能源与运行数据积累;需要进一步配置能源表计、有效产出测点及天气等外部数据。"}, + capacity:{audience:"场站负责人、运行调度、规划建设团队、企业管理者和业务容量管理人员。",value:"提前看到需求峰值、场站可用能力和冗余裕度,支持排程、备用系统准备、扩容决策及高负荷风险控制。",whyAI:"需求会受到天气、日历、业务负荷和检修计划共同影响,AI 适合进行多变量预测,并将预测结果与能力约束自动比对。",feasibilityEvidence:"源站已积累历史负荷、实时状态、维护计划和关键参数,能够形成预测原型;正式能力结论还需补齐设计能力曲线、环境降额和上下游需求数据。"}, + strategy:{audience:"场站负责人、能源优化团队、值班主管、运行策略审批人和区域运营负责人。",value:"从场站整体协同角度提出可审批的运行组合建议,在保证安全、冗余和产出的前提下获得节能或能力收益。",whyAI:"候选策略需要同时考虑多系统边界、收益、风险和回退条件。AI/优化算法适合生成候选方案并把复杂约束解释成人员可审核的建议。",feasibilityEvidence:"源站已经具备实时/历史测点、控制管理、快速控制、排程计划、控制模板和执行记录等功能,说明控制状态与审计数据基础存在;首期仅做建议与仿真,不自动执行。"}, + emergency:{audience:"场站负责人、应急指挥人员、值班团队、EHS/安全人员和企业管理层。",value:"在事件期间快速汇总影响、进度和 SOP,在事件后自动形成完整时间线与改进任务,减少信息遗漏并提升复盘质量。",whyAI:"应急信息来自报警、人工确认、控制动作、指标恢复和工单等多条时间线。AI 适合检索 SOP、合并记录和生成复盘初稿,但不替代指挥决策。",feasibilityEvidence:"源站已经积累报警事件、处置日志、控制记录、执行记录和工单,能够还原大部分事件时间线;接入正式 SOP、应急等级和人员确认记录后即可形成完整闭环。"}, +}; + +type ArchitectureLayer = {id:string;index:string;title:string;role:string;items:string[];tone:string}; +type ArchitectureItem = {code:string;name:string;purpose:string;details:string;phase:string}; + +const architectureLayers:ArchitectureLayer[] = [ + {id:"experience",index:"01",title:"场站应用与体验层",role:"面向管理者和场站团队,呈现 12 个业务页面,不暴露源系统复杂度。",items:["资产组切换","证据下钻","提示词工作台","Word / MD 导出"],tone:"cyan"}, + {id:"orchestration",index:"02",title:"Agent 编排与治理层",role:"识别意图、选择 Skill、拆解任务、合并结果,并执行范围、质量和安全校验。",items:["总控编排","数据落地","指标计算","解释生成","安全审计"],tone:"violet"}, + {id:"skills",index:"03",title:"场站 Skill 能力层",role:"沉淀可版本化的场站业务流程、指标口径、提示模板和降级规则。",items:["12 个场景 Skill","场站上下文","证据引用","报告导出"],tone:"blue"}, + {id:"mcp",index:"04",title:"MCP 工具与领域服务层",role:"公司 API 全部按领域封装为 MCP Tool,并遵循公司既有 MCP 建设与治理规范。",items:["资产与时序","事件与报警","运维工单","知识与外部数据","控制能力"],tone:"green"}, + {id:"source",index:"05",title:"MU 与企业数据源层",role:"保留现有系统作为事实源;AI 不直接抓页面,也不把演示页面数值作为生产数据。",items:["MU / APM API","Alarm Process","Ticket / Maintenance","Control Core","SOP / 天气 / 电价"],tone:"orange"}, +]; + +const requiredSkills:ArchitectureItem[] = [ + {code:"SK-00",name:"场站上下文与权限",purpose:"统一 tenant、org、assetGroup、时间范围和站型,阻止跨场站取数。",details:"所有业务 Skill 的前置依赖;输出标准 StationContext 和权限范围。",phase:"P0"}, + {code:"SK-01",name:"证据与数据质量",purpose:"给每项结论附接口、时间、范围、记录 ID、完整度和缺口。",details:"负责“可回答/不可回答”判断,禁止使用演示值补空。",phase:"P0"}, + {code:"SK-02",name:"场站智能日报",purpose:"生成节点状态版日报,并逐步接入报警闭环、工单、保养和关键测点。",details:"对应场站智能日报页面;支持提示词编辑和重新生成。",phase:"P0"}, + {code:"SK-03",name:"场站问答",purpose:"将问题绑定当前资产组,编排工具并生成带证据回答。",details:"先覆盖资产与状态,再扩展事件、运维、趋势和 SOP。",phase:"P0"}, + {code:"SK-04",name:"运行环比",purpose:"按同长度窗口和相同场站边界计算事件及后续运营指标变化。",details:"当前先用历史事件;无有效记录时输出数据缺口。",phase:"P0"}, + {code:"SK-05",name:"同类场站对标",purpose:"按站型、容量、气候和运行模式筛选可比样本并归一化。",details:"要求跨站统一指标版本、主数据和授权。",phase:"P2"}, + {code:"SK-06",name:"运维履约分析",purpose:"计算计划完成、SLA、一次修复、逾期风险和维护后效果。",details:"关联工单、保养、报警流程和维护前后测点。",phase:"P1"}, + {code:"SK-07",name:"场站运营健康度",purpose:"按站型配置透明权重和红线,形成可解释的六维评分。",details:"分数必须能下钻到确定性指标和原始证据。",phase:"P2"}, + {code:"SK-08",name:"场站风险预测",purpose:"预测未来 24—72 小时的场站影响窗口。",details:"先规则预警,积累连续数据和影响标签后再训练模型。",phase:"P2"}, + {code:"SK-09",name:"报警事件治理",purpose:"按时间、拓扑和状态传播把报警归并为场站事件。",details:"首期采用可解释规则,并允许人工拆分与合并。",phase:"P1"}, + {code:"SK-10",name:"资源与能效",purpose:"建立单位产出能耗和同工况基线,解释系统级偏差。",details:"需要能源表计、产出/负荷、天气及计量边界。",phase:"P2"}, + {code:"SK-11",name:"负荷与能力预测",purpose:"比较预测需求、实时可用能力和冗余裕度。",details:"需要设计能力曲线、环境降额、检修和外部需求。",phase:"P2"}, + {code:"SK-12",name:"场站运行策略建议",purpose:"在安全、产出和冗余约束内生成可审批的运行组合。",details:"只生成建议和仿真结果;控制执行必须独立授权。",phase:"P3"}, + {code:"SK-13",name:"应急辅助与复盘",purpose:"检索 SOP、组织处置时间线并生成复盘与改进任务。",details:"AI 不替代应急指挥、保护联锁和人工确认。",phase:"P1"}, + {code:"SK-14",name:"文档与交付物生成",purpose:"将场站报告、证据、口径和数据缺口导出为 Word/MD。",details:"所有导出复用页面确定性结果,保留版本和生成时间。",phase:"P0"}, +]; + +const requiredMcps:ArchitectureItem[] = [ + {code:"MCP-01",name:"mu-context-mcp",purpose:"封装租户、组织、资产组和站点上下文。",details:"Tools:list_asset_groups、get_station_context;映射 group/list 等接口。当前优先级最高。",phase:"P0"}, + {code:"MCP-02",name:"mu-asset-telemetry-mcp",purpose:"封装节点、状态、属性、实时值与历史时序。",details:"Tools:list_nodes、get_node_status、list_properties、get_latest_values、query_history;映射 asset/node、node/property、latest、hist 系列接口。",phase:"P0"}, + {code:"MCP-03",name:"mu-event-alarm-mcp",purpose:"封装当前/历史事件、报警流程和处置日志。",details:"Tools:query_events、get_open_events、get_event_process、correlate_event_evidence;映射 event/hist、event/latest 与 alarm-process。",phase:"P0 / P1"}, + {code:"MCP-04",name:"mu-om-mcp",purpose:"封装工单、巡检、保养计划、SLA 和责任信息。",details:"Tools:list_work_orders、get_work_order、list_maintenance_tasks、get_sla_status;映射 api-ticket 与 maintenance。",phase:"P1"}, + {code:"MCP-05",name:"station-masterdata-mcp",purpose:"提供站型、容量、关键参数、层级映射和指标版本。",details:"Tools:get_station_profile、get_capacity_rules、resolve_asset_hierarchy、get_metric_dictionary;汇集 station/keyparameters 和企业主数据。",phase:"P1"}, + {code:"MCP-06",name:"station-knowledge-mcp",purpose:"检索 SOP、操作规程、应急预案、维护手册和历史案例。",details:"Tools:search_sop、get_procedure、search_incident_case;返回文档版本、段落引用和适用站型。",phase:"P1"}, + {code:"MCP-07",name:"external-context-mcp",purpose:"封装天气、电价、负荷/处理量、业务日历等外部上下文。",details:"Tools:get_weather、get_tariff、get_business_load、get_calendar_context;缺失时业务 Skill 必须降级。",phase:"P2"}, + {code:"MCP-08",name:"mu-control-audit-mcp",purpose:"隔离控制状态、排程、执行记录及未来的受控下发。",details:"Tools:list_control_logs、list_schedules、simulate_strategy、request_approval;默认只读,写操作需审批、白名单、联锁校验和全量审计。",phase:"P3"}, +]; + +const requiredAgents:ArchitectureItem[] = [ + {code:"AG-00",name:"Station Copilot Orchestrator",purpose:"总控 Agent:识别场景、选择 Skill、拆解步骤、调用其他 Agent 并合并结果。",details:"强制携带 StationContext;负责超时、重试、降级和最终响应结构。",phase:"P0"}, + {code:"AG-01",name:"Data Grounding Agent",purpose:"将业务问题转换为 MCP Tool 调用并形成可追溯证据包。",details:"只取数不写结论;校验资产组范围、时间窗、字段映射和数据新鲜度。",phase:"P0"}, + {code:"AG-02",name:"Metric & Rule Agent",purpose:"执行日报、环比、健康度、SLA 等确定性计算。",details:"公式版本化、可单元测试;大模型不得代替基础数值计算。",phase:"P0"}, + {code:"AG-03",name:"Station QA Agent",purpose:"理解自然语言问题,选择所需证据并生成带引用答案。",details:"遇到未接入数据域时明确拒答或降级,不跨资产组推断。",phase:"P0"}, + {code:"AG-04",name:"Report & Insight Agent",purpose:"把确定性结果转成日报、管理摘要、洞察和 Word/MD 交付物。",details:"支持可编辑提示词;输出必须引用指标和数据缺口。",phase:"P0"}, + {code:"AG-05",name:"Event Diagnosis Agent",purpose:"归并报警、梳理事件链、关联工单与维护效果。",details:"区分“相关性”和“已确认根因”,保留人工拆分/合并结果。",phase:"P1"}, + {code:"AG-06",name:"Forecast Agent",purpose:"承担风险、负荷、能力和能效基线预测。",details:"输出置信度、回测指标、输入覆盖率和失效条件。",phase:"P2"}, + {code:"AG-07",name:"Strategy Advisor Agent",purpose:"生成场站级运行组合、收益预测、仿真和回退建议。",details:"不直接控制设备;所有执行动作转交审批与控制安全链。",phase:"P3"}, + {code:"AG-08",name:"Governance & Audit Agent",purpose:"在发布前检查证据覆盖、权限、敏感信息、口径版本和控制边界。",details:"可阻断无来源结论、过期数据、跨站数据和未经审批的控制请求。",phase:"P0"}, +]; + +const deliveryPhases = [ + {phase:"P0 · 事实层闭环",goal:"完成现有三页生产化",scope:"Context/Asset/Event MCP;总控、数据、计算、问答、报告、审计 Agent;SK-00—04、SK-14。"}, + {phase:"P1 · 运维与知识闭环",goal:"补足日报与事件处置",scope:"O&M、Masterdata、Knowledge MCP;事件诊断 Agent;运维履约、报警治理、应急复盘 Skill。"}, + {phase:"P2 · 跨站与预测",goal:"形成管理分析和预测能力",scope:"External Context MCP;预测 Agent;对标、健康度、风险、能效、容量 Skill。"}, + {phase:"P3 · 建议到受控执行",goal:"先建议、仿真、审批,再考虑执行",scope:"Control Audit MCP;策略 Agent;白名单、联锁、回退、双人审批和审计。"}, +]; + +const apiStatusLabel:Record = {verified:"已验证",source:"源码确认",external:"待外部接入"}; + +function getPanelItems(scenario:ScenarioDefinition,design:ScenarioDesign):PanelSpec[] { + if (scenario.key === "daily") return [ + {name:"资产组切换与数据状态",kind:"公共 Panel",source:"当前组织下的资产组清单,以及所选资产组的节点清单/源站已确认快照。",apis:[apiCatalog.groups,apiCatalog.nodes],calc:"资产组 id/name 作为页面唯一场站边界;切换后重新加载节点,不保留上一资产组统计。数据状态明确区分节点 API、源站可见快照和仅有总数。"}, + {name:"KPI · 纳管设备",kind:"指标 Panel",source:"当前资产组节点清单;接口未返回节点时可显示源站已确认的资产组总数。",apis:[apiCatalog.groups,apiCatalog.nodes],calc:design.kpiCalcs[0]}, + {name:"KPI · 当前在线",kind:"指标 Panel",source:"当前资产组节点当前状态。",apis:[apiCatalog.nodes,apiCatalog.nodeStatus],calc:design.kpiCalcs[1]}, + {name:"KPI · 当前离线",kind:"指标 Panel",source:"当前资产组节点当前状态。",apis:[apiCatalog.nodes,apiCatalog.nodeStatus],calc:design.kpiCalcs[2]}, + {name:"KPI · 告警 / 故障",kind:"指标 Panel",source:"当前资产组节点状态枚举;不是报警流程事件。",apis:[apiCatalog.nodes,apiCatalog.nodeStatus],calc:design.kpiCalcs[3]}, + {name:"站点智能日报",kind:"主报告 Panel",source:"本页四项 KPI、状态覆盖数和当前资产组名称。",apis:[apiCatalog.groups,apiCatalog.nodes,apiCatalog.nodeStatus],calc:design.visualCalc}, + {name:"异常设备证据清单",kind:"证据 Panel",source:"当前资产组中状态为故障、告警或离线的节点记录。",apis:[apiCatalog.nodes,apiCatalog.nodeStatus],calc:"按故障→告警→离线排序,最多展示 12 条;设备只作为场站结论的证据,不把页面转为设备管理。"}, + {name:"AI 场站洞察与提示词",kind:"AI Panel",source:"确定性状态统计、异常节点证据、当前资产组和 unavailableDomains。",apis:[apiCatalog.groups,apiCatalog.nodes,apiCatalog.nodeStatus],calc:design.insightCalc}, + {name:"场站运营摘要与数据边界",kind:"摘要 / 质量 Panel",source:"当前资产组确定性统计及接口就绪状态。",apis:[apiCatalog.groups,apiCatalog.nodes,apiCatalog.nodeStatus],calc:design.summaryCalc}, + ]; + if (scenario.key === "qa") return [ + {name:"资产组切换与问答上下文",kind:"公共 Panel",source:"当前组织的资产组清单、所选资产组节点及数据模式。",apis:[apiCatalog.groups,apiCatalog.nodes],calc:"锁定资产组 id/name;切换资产组时清空旧问题、答案和证据,随后重新加载当前组节点。"}, + {name:"KPI · 当前资产组",kind:"指标 Panel",source:"资产组及其组织/站点名称。",apis:[apiCatalog.groups],calc:design.kpiCalcs[0]}, + {name:"KPI · 已加载证据",kind:"指标 Panel",source:"当前资产组实际载入的节点记录。",apis:[apiCatalog.nodes,apiCatalog.nodeStatus],calc:design.kpiCalcs[1]}, + {name:"KPI · 可回答数据域",kind:"指标 Panel",source:"当前已接线的数据域清单和 unavailableDomains。",apis:[apiCatalog.groups,apiCatalog.nodes,apiCatalog.nodeStatus],calc:design.kpiCalcs[2]}, + {name:"KPI · 数据完整度",kind:"指标 Panel",source:"资产组纳管总数与有明确状态的节点数。",apis:[apiCatalog.groups,apiCatalog.nodes,apiCatalog.nodeStatus],calc:design.kpiCalcs[3]}, + {name:"向当前场站提问",kind:"问答 Panel",source:"问题文本、当前资产组统计、节点清单、接口路径及未接入域。",apis:[apiCatalog.groups,apiCatalog.nodes,apiCatalog.nodeStatus],calc:design.visualCalc}, + {name:"相关节点证据",kind:"证据 Panel",source:"当前资产组节点状态;依据问题中的故障、告警、离线或运行关键词筛选。",apis:[apiCatalog.nodes,apiCatalog.nodeStatus],calc:"最多展示 8 条相关节点,保留名称、层级、类型、状态和数据模式;不存在证据时明确显示空状态。"}, + {name:"答案来源与接口依据",kind:"质量 Panel",source:"本次回答使用的数据模式、资产组过滤条件和实际接口路径。",apis:[apiCatalog.groups,apiCatalog.nodes],calc:"显示 AI 模型或本地规则模式,并列出可追溯 API;未接入域不能成为回答依据。"}, + {name:"场站运营摘要",kind:"摘要 Panel",source:"当前资产组与已加载节点状态证据。",apis:[apiCatalog.groups,apiCatalog.nodes,apiCatalog.nodeStatus],calc:design.summaryCalc}, + ]; + if (scenario.key === "period") return [ + {name:"资产组切换与比较范围",kind:"公共 Panel",source:"资产组和节点清单用于建立当前场站的节点身份集合。",apis:[apiCatalog.groups,apiCatalog.nodes],calc:"资产组切换后重新发起双周期查询;本期为本月 1 日至当前,基期为上月 1 日起相同已过去时长。"}, + {name:"真实数据校验门",kind:"质量 Panel",source:"历史事件接口状态、返回行数、时间字段及节点映射结果。",apis:[apiCatalog.events,apiCatalog.nodes],calc:"只有请求成功、存在记录、时间可解析且能映射到当前资产组节点时才进入计算;否则展示 DATA GAP 并停止生成结论。"}, + {name:"本月与上月同期",kind:"主对比 Panel",source:"两个同长度窗口内、通过当前资产组节点过滤的历史事件。",apis:[apiCatalog.groups,apiCatalog.nodes,apiCatalog.events],calc:design.visualCalc}, + {name:"指标 · 事件影响可用率(估算)",kind:"指标 Panel",source:"纳管节点数、窗口小时数和事件影响时长。",apis:[apiCatalog.nodes,apiCatalog.events],calc:design.kpiCalcs[0]}, + {name:"指标 · 历史事件",kind:"指标 Panel",source:"当前资产组双周期历史事件记录。",apis:[apiCatalog.events,apiCatalog.nodes],calc:design.kpiCalcs[1]}, + {name:"指标 · 影响节点",kind:"指标 Panel",source:"可映射到当前资产组的事件节点身份。",apis:[apiCatalog.events,apiCatalog.nodes],calc:design.kpiCalcs[2]}, + {name:"指标 · 事件影响时长",kind:"指标 Panel",source:"历史事件开始、结束时间及比较窗口边界。",apis:[apiCatalog.events],calc:design.kpiCalcs[3]}, + {name:"本月事件贡献层级",kind:"贡献 Panel",source:"本月当前资产组内事件的场站层级字段。",apis:[apiCatalog.events,apiCatalog.nodes],calc:"按层级分组统计事件数并降序排列;无法确认层级或节点归属的记录不进入排名。"}, + {name:"高频影响节点",kind:"证据 Panel",source:"本月当前资产组内可映射到节点的历史事件。",apis:[apiCatalog.events,apiCatalog.nodes],calc:"按节点汇总事件数和高优先级事件数,降序展示,作为场站变化解释的下钻证据。"}, + {name:"运行变化解读",kind:"AI Panel",source:"双周期四项指标、层级贡献、高频节点、窗口口径和数据质量。",apis:[apiCatalog.events,apiCatalog.nodes],calc:design.summaryCalc}, + {name:"本次真实请求",kind:"审计 Panel",source:"前端调用后端代理时记录的接口路径、HTTP 状态和返回行数。",apis:[apiCatalog.groups,apiCatalog.nodes,apiCatalog.events],calc:"逐接口展示成功/失败、状态码和行数;该轨迹是判断是否可以发布环比结论的直接依据。"}, + ]; + const apiItems = design.apiKeys.map(key=>apiCatalog[key]); + return [ + {name:"场站上下文栏",kind:"公共 Panel",source:"资产组确定当前场站;节点、场站关键参数和最近成功数据时间确定范围、模式与数据状态。",apis:[apiCatalog.groups,apiCatalog.nodes,apiCatalog.stationParams],calc:"以资产组 ID 作为所有请求的场站过滤条件;统计范围使用页面选择值;数据时点取关键请求最后成功时间。"}, + ...scenario.metrics.map((metric,index)=>({name:`KPI · ${metric.label}`,kind:"指标 Panel",source:design.sourceText,apis:apiItems,calc:design.kpiCalcs[index]})), + {name:design.visualPanel,kind:"主分析 Panel",source:design.sourceText,apis:apiItems,calc:design.visualCalc}, + {name:"需要关注的场站事项",kind:"AI 洞察 Panel",source:"只使用本页面已经计算完成的场站指标、事件、趋势与待办,不让大模型直接计算基础数值。",apis:apiItems,calc:design.insightCalc}, + {name:"场站运营摘要",kind:"AI 摘要 Panel",source:"将确定性计算结果、异常证据 ID、数据时点和数据缺口组成结构化上下文,再交给模型生成说明。",apis:apiItems,calc:design.summaryCalc}, + {name:"数据来源与实施成熟度",kind:"质量 Panel",source:"汇总本页面各接口的连通、字段映射、数据完整性和刷新时间,展示真实成熟度。",apis:apiItems,calc:"已验证仅表示当前环境真实请求成功;源码确认表示方法与路径已找到;待外部接入表示 MU 当前数据域不足。"}, + ]; +} + +function downloadTextFile(content:string,fileName:string,mime:string) { + const blob = new Blob([content],{type:mime}); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href=url; anchor.download=fileName; document.body.appendChild(anchor); anchor.click(); anchor.remove(); + window.setTimeout(()=>URL.revokeObjectURL(url),1000); + return blob.size; +} + +function escapeHtml(value:string) { + return value.replace(/[&<>\"]/g,char=>({"&":"&","<":"<",">":">",'"':"""}[char] ?? char)); +} + +function buildArchitectureMarkdownLines() { + const lines = [ + "# 整体架构", + "", + "## 总体架构图", + "", + "```mermaid", + "flowchart TB", + " UI[场站应用与体验层\\n12 个业务页面 / 资产组切换 / 证据 / 导出]", + " ORCH[Station Copilot Orchestrator\\n意图识别 / Skill 选择 / 任务编排 / 降级]", + " AG[专业 Agent\\n数据落地 / 指标规则 / 问答 / 报告 / 诊断 / 预测 / 策略 / 审计]", + " SK[场站 Skill 能力层\\n业务流程 / 指标口径 / 提示模板 / 数据要求 / 降级规则]", + " MCP[MCP 工具与领域服务层\\n公司 API 的领域工具封装]", + " SRC[MU 与企业数据源\\nAPM / Alarm / Ticket / Control / SOP / 外部数据]", + " UI --> ORCH", + " ORCH --> AG", + " ORCH --> SK", + " AG --> SK", + " AG --> MCP", + " MCP --> SRC", + " AUDIT[治理护栏\\nTenant/Org/AssetGroup 隔离 · 证据 · 权限 · 控制审批] -.贯穿.-> ORCH", + " AUDIT -.贯穿.-> MCP", + "```", + "", + "Skill 定义‘如何完成一个场站任务’,Agent 负责‘理解、计划与协作’,MCP 负责‘以受控方式访问事实和动作’。浏览器页面不保存生产凭据,也不直接调用公司 API。", + "", + "## 分层说明", + "", + ]; + architectureLayers.forEach(layer=>lines.push(`### ${layer.index}. ${layer.title}`,"",layer.role,"",...layer.items.map(item=>`- ${item}`),"")); + lines.push("## 需要建设的 Skills","","> Skill 是可版本化的业务能力包,包含适用场景、输入输出、MCP 依赖、确定性计算、提示模板、缺口处理和验收样例。",""); + requiredSkills.forEach(item=>lines.push(`### ${item.code} · ${item.name}`,"",`- 目的:${item.purpose}`,`- 实施:${item.details}`,`- 阶段:${item.phase}`,"")); + lines.push("## 需要建设的 MCP","","> 按领域建设 MCP Server,不按单个 API 拆服务;每个公司 API 只通过相应 MCP Tool 暴露给 Agent。",""); + requiredMcps.forEach(item=>lines.push(`### ${item.code} · ${item.name}`,"",`- 目的:${item.purpose}`,`- Tools / API:${item.details}`,`- 阶段:${item.phase}`,"")); + lines.push("## 需要建设的 Agents","","> Agent 是逻辑职责。P0 可部署在一个 Agent Runtime 中,通过清晰角色和工具权限隔离;规模、性能或合规要求出现后再拆成独立服务。",""); + requiredAgents.forEach(item=>lines.push(`### ${item.code} · ${item.name}`,"",`- 职责:${item.purpose}`,`- 边界:${item.details}`,`- 阶段:${item.phase}`,"")); + lines.push("## 分阶段实施","",...deliveryPhases.flatMap(item=>[`### ${item.phase} — ${item.goal}`,"",item.scope,""])); + return lines; +} + +function buildArchitectureWordHtml() { + const layerHtml = architectureLayers.map(layer=>`
${escapeHtml(layer.index)}

${escapeHtml(layer.title)}

${escapeHtml(layer.role)}

${layer.items.map(escapeHtml).join(" · ")}
`).join("
↓
"); + const table = (title:string,intro:string,items:ArchitectureItem[])=>`

${escapeHtml(title)}

${escapeHtml(intro)}

${items.map(item=>``).join("")}
编号名称目的实施与边界阶段
${escapeHtml(item.code)}${escapeHtml(item.name)}${escapeHtml(item.purpose)}${escapeHtml(item.details)}${escapeHtml(item.phase)}
`; + return `

整体架构

Skill 定义“如何完成任务”,Agent 负责“理解、计划与协作”,MCP 负责“访问事实和动作”;具体 MCP 建设与治理遵循公司既有规范。

总体架构图

${layerHtml}

分层说明

${architectureLayers.map(layer=>`

${escapeHtml(layer.index)}. ${escapeHtml(layer.title)}

${escapeHtml(layer.role)}

    ${layer.items.map(item=>`
  • ${escapeHtml(item)}
  • `).join("")}
`).join("")}${table("需要建设的 Skills","Skill 是版本化业务能力包,包含输入输出、MCP 依赖、计算口径、提示模板、降级规则和验收样例。",requiredSkills)}${table("需要建设的 MCP","按领域建设 MCP Server;每个公司 API 只通过相应 MCP Tool 暴露给 Agent,具体规范沿用公司要求。",requiredMcps)}${table("需要建设的 Agents","P0 可在一个 Agent Runtime 中按逻辑角色隔离,后续再按性能和合规要求拆服务。",requiredAgents)}

分阶段实施

${deliveryPhases.map(item=>`

${escapeHtml(item.phase)} — ${escapeHtml(item.goal)}

${escapeHtml(item.scope)}

`).join("")}
`; +} + +function buildDesignMarkdown() { + const lines=["# 场站 AI 页面与数据口径设计说明","",`- 口径版本:V0.5 · 2026-08-22`,`- 覆盖范围:整体架构 + 12 个场站 AI 页面全部 Panel`,`- 当前实现基线:场站智能日报、场站问答、运行环比`,`- 接口状态:已验证 / 源码确认 / 待外部接入`,"","## 统一实施原则","","所有数字先由 API 数据和确定性规则计算,再由 AI 解释;设备、测点只作为场站结论的证据下钻。源站“综合数据分析”演示页面不作为真实数据或可行性证据;控制相关接口首期只读,不自动下发指令。","",...buildArchitectureMarkdownLines(),"","---",""]; + skills.forEach((scenario,index)=>{ + const design=scenarioDesigns[scenario.key], narrative=pageNarratives[scenario.key], panels=getPanelItems(scenario,design); + lines.push(`# ${index+1}. ${scenario.title}`,"",`**目标客户:** ${narrative.audience}`,"",`**页面价值:** ${narrative.value}`,"",`**为什么使用 AI:** ${narrative.whyAI}`,"",`**为什么可行:** ${narrative.feasibilityEvidence}`,"",`**可实现性结论:** ${design.feasibility}`,"",`**场站级数据组织:** ${design.sourceText}`,"",`**当前数据缺口:** ${design.gaps}`,"","## Panel 数据说明",""); + panels.forEach((panel,panelIndex)=>{ + lines.push(`### ${panelIndex+1}. ${panel.name}`,"",`- Panel 类型:${panel.kind}`,`- 数据来源:${panel.source}`,`- 计算方法:${panel.calc}`,"","对应 API:",""); + panel.apis.forEach(api=>lines.push(`- [${apiStatusLabel[api.status]}] \`${api.method} ${api.path}\` — ${api.note}`)); + lines.push(""); + }); + lines.push("---",""); + }); + return lines.join("\n"); +} + +function buildDesignWordHtml() { + const architectureHtml=buildArchitectureWordHtml(); + const pages=skills.map((scenario,index)=>{ + const design=scenarioDesigns[scenario.key], narrative=pageNarratives[scenario.key], panels=getPanelItems(scenario,design); + const panelHtml=panels.map((panel,panelIndex)=>`

${panelIndex+1}. ${escapeHtml(panel.name)}

Panel 类型:${escapeHtml(panel.kind)}

数据来源:${escapeHtml(panel.source)}

计算方法:${escapeHtml(panel.calc)}

对应 API

    ${panel.apis.map(api=>`
  • [${apiStatusLabel[api.status]}] ${escapeHtml(api.method)} ${escapeHtml(api.path)}
    ${escapeHtml(api.note)}
  • `).join("")}
`).join(""); + return `

${escapeHtml(scenario.title)}

目标客户:${escapeHtml(narrative.audience)}

页面价值:${escapeHtml(narrative.value)}

为什么使用 AI:${escapeHtml(narrative.whyAI)}

为什么可行:${escapeHtml(narrative.feasibilityEvidence)}

数据与实施口径

可实现性结论:${escapeHtml(design.feasibility)}

场站级数据组织:${escapeHtml(design.sourceText)}

当前数据缺口:${escapeHtml(design.gaps)}

Panel 数据说明

${panelHtml}
`; + }).join(""); + return `场站AI页面与数据口径设计说明

场站 AI 页面与数据口径设计说明

口径版本:V0.5 · 2026-08-22

覆盖范围:整体架构 + 12 个场站 AI 页面全部 Panel

当前实现基线:场站智能日报、场站问答、运行环比

统一实施原则
所有数字先由 API 数据和确定性规则计算,再由 AI 解释;设备、测点只作为场站结论的证据下钻。源站“综合数据分析”演示页面不作为真实数据或可行性证据;控制相关接口首期只读,不自动下发指令。
${architectureHtml}${pages}`; +} + +const siteControl = { + "AIDC": { title:"冷却系统节能建议", device:"冷水机组、冷冻/冷却水泵、冷却塔、CRAH/CRAC", suggestion:"冷冻水供水设定值 7.0°C → 7.5°C", result:"预计节能 2.8%—4.1%,机柜进风温度最高 25.6°C", avoid:"消防/应急状态、冗余不足、传感器异常、UPS/BMS 或保护系统告警" }, + "燃气站": { title:"压缩机与调压运行建议", device:"压缩机、调压器、流量/压力/温度变送器、可燃气体探测器", suggestion:"按来气量预测调整压缩机启停顺序", result:"降低空载能耗并减少频繁启停,保持出口压力约束", avoid:"泄漏报警、ESD、放散/泄压或消防联锁动作期间" }, + "水站": { title:"泵组调度与压力优化", device:"取水泵、提升泵、加压泵、变频器、清水池与流量压力仪表", suggestion:"低谷时段优先补水,联动水位与末端压力调整频率", result:"降低峰时功率并维持最小供水压力与 N+1 冗余", avoid:"水质异常、低液位、最小频率或启停次数即将越限" }, + "污水处理厂": { title:"曝气与回流优化建议", device:"鼓风机、曝气系统、提升泵、回流泵、加药设备、DO/ORP/pH/氨氮仪表", suggestion:"根据 DO 与进水负荷小步调整鼓风机频率", result:"目标是在出水约束内降低曝气能耗,单步变化受限", avoid:"水质仪表失准、负荷突变、工艺约束或应急旁路状态" }, + "新能源场站": { title:"功率与储能协同建议", device:"光伏逆变器、风机、箱变、PCS/BMS/EMS、气象站与汇流箱", suggestion:"结合功率预测优化储能充放电和无功补偿时段", result:"减少弃电与偏差考核,维持 SOC 和并网约束", avoid:"电网调度指令、保护定值、紧急停机和电池保护动作" }, +}; + +function extractArray(value: unknown): unknown[] { + if (Array.isArray(value)) return value; + if (!value || typeof value !== "object") return []; + const obj = value as Record; + for (const key of ["e","data","list","rows","result","records","items","nodes","nodeList","content","elements","children"]) { + const next = obj[key]; + if (Array.isArray(next)) return next; + const nested = extractArray(next); + if (nested.length) return nested; + } + return []; +} + +function extractNodeRows(value: unknown): Record[] { + const result: Record[] = []; + const visit = (current:unknown) => { + if (Array.isArray(current)) { + current.forEach(visit); + return; + } + if (!current || typeof current !== "object") return; + const row = current as Record; + const id = row.id ?? row.nodeId ?? row.deviceId ?? row.assetId; + const name = row.name ?? row.nodeName ?? row.deviceName ?? row.assetName; + const childList = [row.children,row.nodes,row.nodeList].find(Array.isArray) as unknown[]|undefined; + const hasDeviceSignal = ["status","statusName","onlineStatus","deviceStatus","state","typeName","deviceType","templateName","parentName","pathName"].some(key=>row[key] != null); + if (id != null && name != null && (!childList?.length || hasDeviceSignal)) result.push(row); + Object.values(row).forEach(visit); + }; + visit(value); + return result; +} + +function normalizeDevices(payloads: unknown[]): Device[] { + const rows = payloads.flatMap(extractNodeRows); + return rows.slice(0,10000).map((row,index) => { + const pick = (...keys:string[]) => keys.map(k=>row[k]).find(v=>typeof v === "string" || typeof v === "number"); + const rawStatus = String(pick("statusName","status","onlineStatus","deviceStatus","state") ?? "未知"); + const status = /fault|error|故障/i.test(rawStatus) ? "故障" : /offline|^0$|离线/i.test(rawStatus) ? "离线" : /alarm|warn|告警|预警/i.test(rawStatus) ? "告警" : /online|normal|running|运行|正常|^1$/i.test(rawStatus) ? "运行" : "未知"; + return { + id:String(pick("id","nodeId","deviceId","assetId") ?? `live-${index}`), + name:String(pick("name","nodeName","deviceName","assetName") ?? `设备 ${index+1}`), + site:String(pick("pathName","fullPathName","parentName","stationName","siteName","orgName","groupName") ?? "当前资产组"), + type:String(pick("typeName","deviceTypeName","deviceType","categoryName","templateName") ?? "站点设备"), + status, score: status === "运行" ? 90 : status === "离线" ? 55 : status === "故障" ? 42 : status === "告警" ? 68 : 75, + alarm:status === "运行" ? "无" : status === "未知" ? "状态字段待映射" : `源站当前状态:${status}`, + metric:String(pick("value","latestValue","metricValue") ?? "实时"), + }; + }).filter((item,index,self)=>self.findIndex(x=>x.id===item.id)===index); +} + +function normalizeAssetGroups(value: unknown): AssetGroup[] { + return extractArray(value).filter((x):x is Record=>!!x&&typeof x==="object").map((row,index)=>{ + const name = String(row.name ?? row.groupName ?? `资产组 ${index+1}`); + const known = snapshotAssetGroups.find(group=>group.name===name); + return { + id:String(row.id ?? row.groupId ?? `group-${index}`), + name, + deviceCount:Number(row.deviceCount ?? row.nodeCount ?? row.assetCount ?? 0) || known?.deviceCount, + orgName:String(row.orgName ?? row.organizationName ?? "") || known?.orgName, + source:"api" as const, + }; + }).filter((item,index,self)=>item.name&&self.findIndex(x=>x.id===item.id)===index); +} + +function parseInsightText(text: string): string[] { + return text.split(/\r?\n/) + .map(line=>line.trim().replace(/^[-•*]\s*/,"").replace(/^\d+[.、)]\s*/,"")) + .filter(line=>line.length>3&&!/^风险最高的设备[::]?$/.test(line)) + .slice(0,5); +} + +function buildStationQaFallback(question:string,devices:Device[],group:AssetGroup,total:number,unknown:number):string { + const pick=(statuses:string[])=>devices.filter(device=>statuses.includes(device.status)); + const names=(items:Device[],limit=10)=>items.slice(0,limit).map(device=>`${device.name}(${device.site})`).join("、")||"无"; + const offline=pick(["离线"]), faults=pick(["故障"]), warnings=pick(["告警"]), running=pick(["运行","正常"]); + if(question.includes("离线")) return `${group.name}当前识别到 ${offline.length} 台离线设备:${names(offline,12)}。建议按泵站层级先核查通信链路、供电和维护停用状态;该结果来自节点当前状态,不等同于未闭环报警。`; + if(/故障|异常|告警/.test(question)) return `${group.name}当前识别到故障 ${faults.length} 台、告警 ${warnings.length} 台。故障证据:${names(faults,8)};告警证据:${names(warnings,8)}。建议先确认故障对场站连续运行的影响,再核查告警持续时间与关联测点。`; + if(/完整|可信|发布|正式/.test(question)) return `${group.name}当前状态数据覆盖 ${Math.max(0,total-unknown)}/${total||0} 台,仍有 ${unknown} 台待补齐。可用于值班预览和异常排查;若要发布正式日报,建议先补齐节点状态,并接通报警闭环、工单和保养数据。`; + if(/在线|运行/.test(question)) return `${group.name}当前识别到 ${running.length} 台处于运行/正常状态:${names(running)}。状态待补齐设备未计入在线数。`; + return `${group.name}当前纳管 ${total||0} 台,运行 ${running.length} 台、故障 ${faults.length} 台、告警 ${warnings.length} 台、离线 ${offline.length} 台,另有 ${unknown} 台状态待补齐。建议优先处理故障和集中离线问题;未接入的报警闭环、工单和保养数量不作推测。`; +} + +function MiniBars({values=[38,42,45,51,57,64,78,86],tone="violet"}:{values?:number[];tone?:string}) { + return
{values.map((v,i)=>)}
; +} + +export default function App() { + const [active,setActive] = useState("daily"); + const [navCollapsed,setNavCollapsed] = useState(false); + const [mobileMenuOpen,setMobileMenuOpen] = useState(false); + // 手机端切换场景后自动关闭菜单 + useEffect(()=>{ if(mobileMenuOpen) setMobileMenuOpen(false); },[active]); + const [connection,setConnection] = useState("connecting"); + const [devices,setDevices] = useState(waterGroupSnapshot); + const [deviceDataMode,setDeviceDataMode] = useState<"api"|"snapshot">("snapshot"); + const [assetGroups,setAssetGroups] = useState(snapshotAssetGroups); + const [selectedGroupId,setSelectedGroupId] = useState("snapshot-water"); + const [updatedAt,setUpdatedAt] = useState("—"); + const [query,setQuery] = useState("列出过去 2 小时离线且伴随高优先级告警的设备"); + const [asked,setAsked] = useState(false); + const [controlSite,setControlSite] = useState("AIDC"); + const [approval,setApproval] = useState("待审批"); + const [muConfig,setMuConfig] = useState(DEFAULT_MU_CONFIG); + const [muSecret,setMuSecret] = useState(""); + const [connectionDetail,setConnectionDetail] = useState("尚未完成真实数据连接"); + const [connectionEndpoints,setConnectionEndpoints] = useState>([]); + const [connectionSentHeaders,setConnectionSentHeaders] = useState([]); + const [qaAnswer,setQaAnswer] = useState(""); + const [qaLoading,setQaLoading] = useState(false); + const [summary,setSummary] = useState(""); + const [summaryLoading,setSummaryLoading] = useState(false); + + async function connectData(config: MuConfig = muConfig, secret: string = muSecret, groupId: string = selectedGroupId) { + setConnection("connecting"); + const requestedGroup = assetGroups.find(group=>group.id===groupId) ?? snapshotAssetGroups.find(group=>group.id===groupId); + const controller = new AbortController(); + const timeout = window.setTimeout(()=>controller.abort(),30000); + try { + const response = await fetch("/api/mu-connect",{ + method:"POST", + signal:controller.signal, + headers:{"Content-Type":"application/json"}, + body:JSON.stringify({ + config, + secret, + selectedGroupId:requestedGroup?.source==="api"?requestedGroup.id:"", + selectedGroupName:requestedGroup?.name ?? "水务资产组", + }), + }); + const responseText = await response.text(); + let result: {ok?:boolean;payloads?:unknown[];asset_groups?:unknown;selected_group?:Record;nodes_payload?:unknown;message?:string;endpoints?:Array<{path:string;status:number;ok:boolean;snippet?:string;error?:string}>;sent_headers?:string[]}; + try { + result = JSON.parse(responseText); + } catch { + throw new Error(`后端服务返回异常(HTTP ${response.status}),请稍后重试或查看后端日志`); + } + setConnectionEndpoints(result.endpoints ?? []); + setConnectionSentHeaders(result.sent_headers ?? []); + if (!response.ok || !result.ok) throw new Error(result.message || `连接失败(${response.status})`); + const detail = result.message ?? "认证成功"; + const liveGroups = normalizeAssetGroups(result.asset_groups); + if (liveGroups.length) setAssetGroups(liveGroups); + const apiSelectedId = result.selected_group ? String(result.selected_group.id ?? result.selected_group.groupId ?? "") : ""; + const apiSelectedName = result.selected_group ? String(result.selected_group.name ?? result.selected_group.groupName ?? "") : ""; + if (apiSelectedId) setSelectedGroupId(apiSelectedId); + const normalized = normalizeDevices(result.nodes_payload == null ? [] : [result.nodes_payload]); + const resolvedGroupName = apiSelectedName || requestedGroup?.name; + if (normalized.length) { + setDevices(normalized); + setDeviceDataMode("api"); + } else { + setDevices(snapshotDevicesForGroup(resolvedGroupName)); + setDeviceDataMode("snapshot"); + } + setConnection("live"); + setConnectionDetail(normalized.length ? `${detail};已加载 ${normalized.length} 台节点` : `${detail};节点清单暂未返回,当前显示源站可见快照`); + setUpdatedAt(new Date().toLocaleTimeString("zh-CN",{hour:"2-digit",minute:"2-digit",second:"2-digit"})); + } catch (error) { + setDevices(snapshotDevicesForGroup(requestedGroup?.name ?? "水务资产组")); + setDeviceDataMode("snapshot"); + setConnection("preview"); + setConnectionDetail(error instanceof Error && error.message !== "cors-or-auth" ? error.message : "已连接服务,但没有返回可用的设备数据"); + setUpdatedAt("预览数据"); + } finally { window.clearTimeout(timeout); } + } + + async function runQa() { + setQaLoading(true); + try { + const data = await analyzeChat(query, devices, alarms); + setQaAnswer(data?.analysis || "(AI 未返回内容)"); + } catch (e) { + setQaAnswer("AI 分析调用失败:" + (e instanceof Error ? e.message : String(e))); + } finally { setQaLoading(false); } + } + + async function runSummary() { + setSummaryLoading(true); + try { + const data = await analyzeSummary(devices, alarms); + setSummary(data?.analysis || "(AI 未返回内容)"); + } catch (e) { + setSummary("AI 分析调用失败:" + (e instanceof Error ? e.message : String(e))); + } finally { setSummaryLoading(false); } + } + + useEffect(()=>{ + const saved = window.sessionStorage.getItem("station-ai-mu-config"); + if (saved) { + try { + const restored = {...DEFAULT_MU_CONFIG,...JSON.parse(saved)} as MuConfig; + setMuConfig(restored); + connectData(restored,""); + return; + } catch { window.sessionStorage.removeItem("station-ai-mu-config"); } + } + connectData(DEFAULT_MU_CONFIG,""); + },[]); + + function saveMuConfig() { + window.sessionStorage.setItem("station-ai-mu-config",JSON.stringify(muConfig)); + setConnectionDetail("配置已保存到当前浏览器会话;密码或令牌不会保存"); + } + const alarms = useMemo(()=>devices.filter(d=>d.status==="告警"||d.status==="故障"),[devices]); + const activeSkill = skills.find(s=>s.key===active) ?? skills[0]; + const selectedGroup = assetGroups.find(group=>group.id===selectedGroupId) ?? snapshotAssetGroups.find(group=>group.id===selectedGroupId) ?? snapshotAssetGroups[2]; + + function changeAssetGroup(nextId:string) { + const next = assetGroups.find(group=>group.id===nextId) ?? snapshotAssetGroups.find(group=>group.id===nextId); + setSelectedGroupId(nextId); + setDevices(snapshotDevicesForGroup(next?.name)); + setDeviceDataMode("snapshot"); + connectData(muConfig,muSecret,nextId); + } + + return ( + <>
+
+
+ + +
+
+ + + + + ● +
+
+ {mobileMenuOpen &&
setMobileMenuOpen(false)} aria-hidden="true" />} + + + {active==="settings" ? connectData(muConfig,muSecret)} onSave={saveMuConfig} onReset={()=>{setMuConfig(DEFAULT_MU_CONFIG);setMuSecret("");window.sessionStorage.removeItem("station-ai-mu-config");setConnectionDetail("已恢复默认配置");setConnectionEndpoints([]);setConnectionSentHeaders([])}} /> : active==="design" ? + : connectData(muConfig,muSecret,selectedGroupId)} />} +
+
+ StationAIDemo · 场站 AI 分析 + · + ICP 备案号:{import.meta.env.VITE_BEIAN ?? "待备案"} + · + ICP 备案查询 +
+ ); +} + +function ScenarioWorkspace({scenario,connection,devices,alarms,updatedAt,assetGroups,selectedGroup,deviceDataMode,muConfig,muSecret,onGroupChange,onRefresh}:{scenario:ScenarioDefinition;connection:ConnectionState;devices:Device[];alarms:number;updatedAt:string;assetGroups:AssetGroup[];selectedGroup:AssetGroup;deviceDataMode:"api"|"snapshot";muConfig:MuConfig;muSecret:string;onGroupChange:(id:string)=>void;onRefresh:()=>void}) { + const [generatedAt,setGeneratedAt] = useState(()=>new Date().toLocaleTimeString("zh-CN",{hour:"2-digit",minute:"2-digit"})); + const [promptOpen,setPromptOpen] = useState(false); + const [insightPrompt,setInsightPrompt] = useState(""); + const [generatedInsights,setGeneratedInsights] = useState(null); + const [insightLoading,setInsightLoading] = useState(false); + const [insightMeta,setInsightMeta] = useState("基于当前数据自动生成"); + const isDaily = scenario.key === "daily"; + const isQa = scenario.key === "qa"; + const isPeriod = scenario.key === "period"; + const isSourceSnapshot = deviceDataMode === "snapshot"; + const total = selectedGroup.deviceCount || devices.length; + const running = devices.filter(device=>device.status==="运行"||device.status==="正常").length; + const offline = devices.filter(device=>device.status==="离线").length; + const faults = devices.filter(device=>device.status==="故障").length; + const warnings = devices.filter(device=>device.status==="告警").length; + const known = devices.filter(device=>device.status!=="未知").length; + const unknown = Math.max(0,total-known); + const online = Math.max(0,total-offline-unknown); + const health = total ? Math.round((running*100+warnings*60+faults*20+offline*15+unknown*50)/total) : 0; + const dailyMetrics = [ + {label:"纳管设备",value:String(total||"—"),delta:deviceDataMode==="api"?"节点清单实时统计":total?`源站显示共计 ${total} 台`:"等待节点 API 返回",tone:total?"good" as const:"warn" as const}, + {label:"当前在线",value:devices.length?(isSourceSnapshot&&known0?"good" as const:"warn" as const}, + {label:"当前离线",value:String(offline),delta:"按节点当前状态统计",tone:offline?"bad" as const:"good" as const}, + {label:"告警 / 故障",value:`${warnings} / ${faults}`,delta:"当前设备状态,非报警流程条数",tone:(warnings+faults)?"warn" as const:"good" as const}, + ]; + const qaMetrics = [ + {label:"当前资产组",value:selectedGroup.name,delta:`Demo Org · ${selectedGroup.orgName||(selectedGroup.name==="水务资产组"?"良泉水厂":"按资产组范围")}`,tone:"good" as const}, + {label:"已加载证据",value:String(devices.length),delta:`节点状态 · 纳管 ${total||"—"} 台`,tone:devices.length?"good" as const:"warn" as const}, + {label:"可回答数据域",value:"2 / 5",delta:"资产组、节点状态已就绪",tone:"warn" as const}, + {label:"数据完整度",value:total?`${Math.round(Math.max(0,total-unknown)/total*100)}%`:"—",delta:`状态覆盖 ${Math.max(0,total-unknown)}/${total||0}`,tone:unknown?"warn" as const:"good" as const}, + ]; + const dailyInsights = total ? [ + faults ? `${faults} 台故障设备需优先处置:${devices.filter(d=>d.status==="故障").slice(0,2).map(d=>d.name).join("、")}` : "当前未识别到故障状态设备", + offline ? `${offline} 台设备离线,建议先核查一号泵站的通信链路与供电` : "当前未识别到离线设备", + unknown ? `仍有 ${unknown} 台设备状态待节点 API 补齐,日报已显式标注数据完整度` : `已取得 ${total} 台设备完整状态,可直接生成正式日报`, + ] : ["已切换至新的资产组,正在等待节点清单","不会沿用水务资产组的设备与状态统计","连接成功后将自动生成该资产组的独立日报"]; + const displayMetrics = isDaily ? dailyMetrics : isQa ? qaMetrics : scenario.metrics; + const baseInsights = isDaily ? dailyInsights : scenario.insights; + const dailySummary = total ? `${selectedGroup.name}当前纳管 ${total} 台设备;已识别运行 ${running} 台、告警 ${warnings} 台、故障 ${faults} 台、离线 ${offline} 台。${unknown?`尚有 ${unknown} 台状态待接口补齐。`:"节点状态完整。"} 当前日报只陈述源站可追溯事实,未接通的报警闭环、工单与保养数据不做推测。` : `${selectedGroup.name}已选中,暂未取得节点数据。请刷新数据或在“数据源配置”检查认证信息。`; + const defaultInsightPrompt = `你是面向场站运营管理者的 AI 分析助手。请仅分析当前资产组“${selectedGroup.name}”,不要扩展到工厂或其他资产组。\n\n已确认数据:纳管 ${total} 台;运行 ${running} 台;告警 ${warnings} 台;故障 ${faults} 台;离线 ${offline} 台;状态待补齐 ${unknown} 台。\n\n生成要求:\n1. 输出 3 条最值得关注的场站级事项,每行一条;\n2. 按影响范围和紧迫度排序,先故障,再告警、离线和数据完整性;\n3. 设备名称只作为场站结论的证据,不把页面写成设备管理页面;\n4. 不推测未接入的报警闭环、工单和保养数字;\n5. 每条包含“事实 + 场站影响 + 建议动作”,语言简洁。`; + useEffect(()=>{ + setInsightPrompt(defaultInsightPrompt); + setGeneratedInsights(null); + setInsightMeta("基于当前数据自动生成"); + },[defaultInsightPrompt]); + const displayInsights = generatedInsights ?? baseInsights; + + async function regenerateInsights() { + if (!insightPrompt.trim()) { + setInsightMeta("提示词不能为空"); + return; + } + setInsightLoading(true); + setInsightMeta("正在根据优化后的提示词生成…"); + try { + const result = await analyzeModule("station-insight",insightPrompt,{ + assetGroup:{id:selectedGroup.id,name:selectedGroup.name,organization:"Demo Org",station:selectedGroup.orgName||"按资产组范围"}, + statistics:{total,running,warnings,faults,offline,unknown,health,dataMode:deviceDataMode}, + abnormalDevices:devices.filter(device=>["故障","告警","离线"].includes(device.status)).slice(0,20), + unavailableDomains:["报警流程闭环","工单","保养"], + }); + const analysisText = result.analysis||""; + const parsed = parseInsightText(analysisText); + const staleBackendFallback = result.fallback && /当前为预览数据|未获取到真实设备/.test(analysisText); + setGeneratedInsights(parsed.length&&!staleBackendFallback?parsed:baseInsights); + setInsightMeta(result.fallback?"已重新生成 · 本地规则模式":"已重新生成 · AI 模型"); + setGeneratedAt(new Date().toLocaleTimeString("zh-CN",{hour:"2-digit",minute:"2-digit"})); + } catch (error) { + setGeneratedInsights(baseInsights); + setInsightMeta(`AI 服务暂不可用,已采用确定性规则 · ${error instanceof Error?error.message:"请求失败"}`); + } finally { + setInsightLoading(false); + } + } + return
+
+
{scenario.icon}
+
+ {scenario.section} / {scenario.title} +

{scenario.title}

+

{scenario.short}

+
+
+ + +
+
+
+
当前资产组
+
组织 / 站点Demo Org · {selectedGroup.orgName || (selectedGroup.name==="水务资产组"?"良泉水厂":"按资产组范围")}
+
统计范围今日 00:00—当前
+
数据状态{deviceDataMode==="api"?`节点 API · ${updatedAt}`:devices.length?`源站可见快照 · ${updatedAt}`:"仅有源站总数 · 等待节点明细"}
+
+ {total>0&&devices.length===0&&
{selectedGroup.name} 已确认纳管 {total} 台节点明细接口本次未返回数据,因此没有借用其他资产组的设备。请刷新源数据;若仍为空,请到“数据连接”更新 Cookie / Token。
} +
+
+ {scenario.key!=="period"&&
+ {displayMetrics.map(metric=>
+ {metric.label}{metric.value}{metric.delta} +
)} +
} + + {!isQa&&
+
AI INSIGHT

AI 场站洞察

把设备证据归纳为场站影响和行动优先级

{insightMeta}
+ {promptOpen&&
+
生成提示词可人工补充关注重点、输出语气或排序规则
Prompt 可编辑
+