Sfoglia il codice sorgente

feat: 新增只读Dashboard并升级插件到v5.5.0

lingfengQAQ 3 mesi fa
parent
commit
235a1df822

+ 1 - 1
.claude-plugin/marketplace.json

@@ -11,7 +11,7 @@
     {
       "name": "webnovel-writer",
       "description": "长篇网文创作系统(skills + agents + data chain + RAG)",
-      "version": "5.4.4",
+      "version": "5.5.0",
       "author": {
         "name": "lingfengQAQ"
       },

+ 2 - 0
.gitignore

@@ -22,6 +22,8 @@ Thumbs.db
 node_modules/
 .npm-cache/
 dist/
+!webnovel-writer/dashboard/frontend/dist/
+!webnovel-writer/dashboard/frontend/dist/**
 nul
 .tmp/
 .tmp_*

+ 12 - 1
README.md

@@ -72,11 +72,22 @@ RERANK_API_KEY=your_rerank_api_key
 /webnovel-review 1-5
 ```
 
+### 6) 启动可视化面板(可选)
+
+```bash
+/webnovel-dashboard
+```
+
+说明:
+- Dashboard 为只读面板(项目状态、实体图谱、章节/大纲浏览、追读力查看)。
+- 前端构建产物已随插件发布,使用者无需本地 `npm build`。
+
 ## 更新简介
 
 | 版本 | 说明 |
 |------|------|
-| **v5.4.4 (当前)** | 引入官方 Plugin Marketplace 安装机制;统一修复 Skills/Agents/References 的 CLI 调用(`CLAUDE_PLUGIN_ROOT` 单路径,透传命令统一 `--`) |
+| **v5.5.0 (当前)** | 新增只读可视化 Dashboard Skill(`/webnovel-dashboard`)与实时刷新能力;支持插件目录启动与预构建前端分发 |
+| **v5.4.4** | 引入官方 Plugin Marketplace 安装机制;统一修复 Skills/Agents/References 的 CLI 调用(`CLAUDE_PLUGIN_ROOT` 单路径,透传命令统一 `--`) |
 | **v5.4.3** | 增强智能 RAG 上下文辅助(`auto/graph_hybrid` 回退 BM25) |
 | **v5.3** | 引入追读力系统(Hook / Cool-point / 微兑现 / 债务追踪) |
 

+ 1 - 1
webnovel-writer/.claude-plugin/plugin.json

@@ -1,6 +1,6 @@
 {
   "name": "webnovel-writer",
-  "version": "5.4.4",
+  "version": "5.5.0",
   "description": "长篇网文创作系统(skills + agents + data chain + RAG)",
   "author": {
     "name": "lingfengQAQ"

+ 1 - 0
webnovel-writer/dashboard/__init__.py

@@ -0,0 +1 @@
+# Webnovel Dashboard - 可视化小说管理面板

+ 4 - 0
webnovel-writer/dashboard/__main__.py

@@ -0,0 +1,4 @@
+"""Allow running as `python -m dashboard`."""
+from .server import main
+
+main()

+ 362 - 0
webnovel-writer/dashboard/app.py

@@ -0,0 +1,362 @@
+"""
+Webnovel Dashboard - FastAPI 主应用
+
+仅提供 GET 接口(严格只读);所有文件读取经过 path_guard 防穿越校验。
+"""
+
+import asyncio
+import json
+import os
+import sqlite3
+from pathlib import Path
+from typing import Optional
+
+from fastapi import FastAPI, HTTPException, Query
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import StreamingResponse, FileResponse, HTMLResponse
+from fastapi.staticfiles import StaticFiles
+
+from .path_guard import safe_resolve
+from .watcher import FileWatcher
+
+# ---------------------------------------------------------------------------
+# 全局状态
+# ---------------------------------------------------------------------------
+_project_root: Path | None = None
+_watcher = FileWatcher()
+
+STATIC_DIR = Path(__file__).parent / "frontend" / "dist"
+
+
+def _get_project_root() -> Path:
+    if _project_root is None:
+        raise HTTPException(status_code=500, detail="项目根目录未配置")
+    return _project_root
+
+
+def _webnovel_dir() -> Path:
+    return _get_project_root() / ".webnovel"
+
+
+# ---------------------------------------------------------------------------
+# 应用工厂
+# ---------------------------------------------------------------------------
+
+def create_app(project_root: str | Path | None = None) -> FastAPI:
+    global _project_root
+
+    if project_root:
+        _project_root = Path(project_root).resolve()
+
+    app = FastAPI(title="Webnovel Dashboard", version="0.1.0")
+
+    app.add_middleware(
+        CORSMiddleware,
+        allow_origins=["*"],
+        allow_methods=["GET"],
+        allow_headers=["*"],
+    )
+
+    # --- 生命周期 ---
+    @app.on_event("startup")
+    async def _startup():
+        webnovel = _webnovel_dir()
+        if webnovel.is_dir():
+            _watcher.start(webnovel, asyncio.get_event_loop())
+
+    @app.on_event("shutdown")
+    async def _shutdown():
+        _watcher.stop()
+
+    # ===========================================================
+    # API:项目元信息
+    # ===========================================================
+
+    @app.get("/api/project/info")
+    def project_info():
+        """返回 state.json 完整内容(只读)。"""
+        state_path = _webnovel_dir() / "state.json"
+        if not state_path.is_file():
+            raise HTTPException(404, "state.json 不存在")
+        return json.loads(state_path.read_text(encoding="utf-8"))
+
+    # ===========================================================
+    # API:实体数据库(index.db 只读查询)
+    # ===========================================================
+
+    def _get_db() -> sqlite3.Connection:
+        db_path = _webnovel_dir() / "index.db"
+        if not db_path.is_file():
+            raise HTTPException(404, "index.db 不存在")
+        conn = sqlite3.connect(str(db_path))
+        conn.row_factory = sqlite3.Row
+        return conn
+
+    @app.get("/api/entities")
+    def list_entities(
+        entity_type: Optional[str] = Query(None, alias="type"),
+        include_archived: bool = False,
+    ):
+        """列出所有实体(可按类型过滤)。"""
+        conn = _get_db()
+        try:
+            q = "SELECT * FROM entities"
+            params: list = []
+            clauses: list[str] = []
+            if entity_type:
+                clauses.append("type = ?")
+                params.append(entity_type)
+            if not include_archived:
+                clauses.append("is_archived = 0")
+            if clauses:
+                q += " WHERE " + " AND ".join(clauses)
+            q += " ORDER BY last_appearance DESC"
+            rows = conn.execute(q, params).fetchall()
+            return [dict(r) for r in rows]
+        finally:
+            conn.close()
+
+    @app.get("/api/entities/{entity_id}")
+    def get_entity(entity_id: str):
+        conn = _get_db()
+        try:
+            row = conn.execute("SELECT * FROM entities WHERE id = ?", (entity_id,)).fetchone()
+            if not row:
+                raise HTTPException(404, "实体不存在")
+            return dict(row)
+        finally:
+            conn.close()
+
+    @app.get("/api/relationships")
+    def list_relationships(entity: Optional[str] = None, limit: int = 200):
+        conn = _get_db()
+        try:
+            if entity:
+                rows = conn.execute(
+                    "SELECT * FROM relationships WHERE from_entity = ? OR to_entity = ? ORDER BY chapter DESC LIMIT ?",
+                    (entity, entity, limit),
+                ).fetchall()
+            else:
+                rows = conn.execute(
+                    "SELECT * FROM relationships ORDER BY chapter DESC LIMIT ?",
+                    (limit,),
+                ).fetchall()
+            return [dict(r) for r in rows]
+        finally:
+            conn.close()
+
+    @app.get("/api/relationship-events")
+    def list_relationship_events(
+        entity: Optional[str] = None,
+        from_chapter: Optional[int] = None,
+        to_chapter: Optional[int] = None,
+        limit: int = 200,
+    ):
+        conn = _get_db()
+        try:
+            q = "SELECT * FROM relationship_events"
+            params: list = []
+            clauses: list[str] = []
+            if entity:
+                clauses.append("(from_entity = ? OR to_entity = ?)")
+                params.extend([entity, entity])
+            if from_chapter is not None:
+                clauses.append("chapter >= ?")
+                params.append(from_chapter)
+            if to_chapter is not None:
+                clauses.append("chapter <= ?")
+                params.append(to_chapter)
+            if clauses:
+                q += " WHERE " + " AND ".join(clauses)
+            q += " ORDER BY chapter DESC, id DESC LIMIT ?"
+            params.append(limit)
+            rows = conn.execute(q, params).fetchall()
+            return [dict(r) for r in rows]
+        finally:
+            conn.close()
+
+    @app.get("/api/chapters")
+    def list_chapters():
+        conn = _get_db()
+        try:
+            rows = conn.execute("SELECT * FROM chapters ORDER BY chapter ASC").fetchall()
+            return [dict(r) for r in rows]
+        finally:
+            conn.close()
+
+    @app.get("/api/scenes")
+    def list_scenes(chapter: Optional[int] = None, limit: int = 500):
+        conn = _get_db()
+        try:
+            if chapter is not None:
+                rows = conn.execute(
+                    "SELECT * FROM scenes WHERE chapter = ? ORDER BY scene_index ASC", (chapter,)
+                ).fetchall()
+            else:
+                rows = conn.execute(
+                    "SELECT * FROM scenes ORDER BY chapter ASC, scene_index ASC LIMIT ?", (limit,)
+                ).fetchall()
+            return [dict(r) for r in rows]
+        finally:
+            conn.close()
+
+    @app.get("/api/reading-power")
+    def list_reading_power(limit: int = 50):
+        conn = _get_db()
+        try:
+            rows = conn.execute(
+                "SELECT * FROM chapter_reading_power ORDER BY chapter DESC LIMIT ?", (limit,)
+            ).fetchall()
+            return [dict(r) for r in rows]
+        finally:
+            conn.close()
+
+    @app.get("/api/review-metrics")
+    def list_review_metrics(limit: int = 20):
+        conn = _get_db()
+        try:
+            rows = conn.execute(
+                "SELECT * FROM review_metrics ORDER BY end_chapter DESC LIMIT ?", (limit,)
+            ).fetchall()
+            return [dict(r) for r in rows]
+        finally:
+            conn.close()
+
+    @app.get("/api/state-changes")
+    def list_state_changes(entity: Optional[str] = None, limit: int = 100):
+        conn = _get_db()
+        try:
+            if entity:
+                rows = conn.execute(
+                    "SELECT * FROM state_changes WHERE entity_id = ? ORDER BY chapter DESC LIMIT ?",
+                    (entity, limit),
+                ).fetchall()
+            else:
+                rows = conn.execute(
+                    "SELECT * FROM state_changes ORDER BY chapter DESC LIMIT ?", (limit,)
+                ).fetchall()
+            return [dict(r) for r in rows]
+        finally:
+            conn.close()
+
+    @app.get("/api/aliases")
+    def list_aliases(entity: Optional[str] = None):
+        conn = _get_db()
+        try:
+            if entity:
+                rows = conn.execute(
+                    "SELECT * FROM aliases WHERE entity_id = ?", (entity,)
+                ).fetchall()
+            else:
+                rows = conn.execute("SELECT * FROM aliases").fetchall()
+            return [dict(r) for r in rows]
+        finally:
+            conn.close()
+
+    # ===========================================================
+    # API:文档浏览(正文/大纲/设定集 —— 只读)
+    # ===========================================================
+
+    @app.get("/api/files/tree")
+    def file_tree():
+        """列出 正文/、大纲/、设定集/ 三个目录的树结构。"""
+        root = _get_project_root()
+        result = {}
+        for folder_name in ("正文", "大纲", "设定集"):
+            folder = root / folder_name
+            if not folder.is_dir():
+                result[folder_name] = []
+                continue
+            result[folder_name] = _walk_tree(folder, root)
+        return result
+
+    @app.get("/api/files/read")
+    def file_read(path: str):
+        """只读读取一个文件内容(限 正文/大纲/设定集 目录)。"""
+        root = _get_project_root()
+        resolved = safe_resolve(root, path)
+
+        # 二次限制:只允许三大目录
+        allowed_parents = [root / n for n in ("正文", "大纲", "设定集")]
+        if not any(_is_child(resolved, p) for p in allowed_parents):
+            raise HTTPException(403, "仅允许读取 正文/大纲/设定集 目录下的文件")
+
+        if not resolved.is_file():
+            raise HTTPException(404, "文件不存在")
+
+        # 文本文件直接读;其他情况返回占位信息
+        try:
+            content = resolved.read_text(encoding="utf-8")
+        except UnicodeDecodeError:
+            content = "[二进制文件,无法预览]"
+
+        return {"path": path, "content": content}
+
+    # ===========================================================
+    # SSE:实时变更推送
+    # ===========================================================
+
+    @app.get("/api/events")
+    async def sse():
+        """Server-Sent Events 端点,推送 .webnovel/ 下的文件变更。"""
+        q = _watcher.subscribe()
+
+        async def _gen():
+            try:
+                while True:
+                    msg = await q.get()
+                    yield f"data: {msg}\n\n"
+            except asyncio.CancelledError:
+                pass
+            finally:
+                _watcher.unsubscribe(q)
+
+        return StreamingResponse(_gen(), media_type="text/event-stream")
+
+    # ===========================================================
+    # 前端静态文件托管
+    # ===========================================================
+
+    if STATIC_DIR.is_dir():
+        app.mount("/assets", StaticFiles(directory=str(STATIC_DIR / "assets")), name="assets")
+
+        @app.get("/{full_path:path}")
+        def serve_spa(full_path: str):
+            """SPA fallback:任何非 /api 路径都返回 index.html。"""
+            index = STATIC_DIR / "index.html"
+            if index.is_file():
+                return FileResponse(str(index))
+            raise HTTPException(404, "前端尚未构建")
+    else:
+        @app.get("/")
+        def no_frontend():
+            return HTMLResponse(
+                "<h2>Webnovel Dashboard API is running</h2>"
+                "<p>前端尚未构建。请先在 <code>dashboard/frontend</code> 目录执行 <code>npm run build</code>。</p>"
+                '<p>API 文档:<a href="/docs">/docs</a></p>'
+            )
+
+    return app
+
+
+# ---------------------------------------------------------------------------
+# 辅助函数
+# ---------------------------------------------------------------------------
+
+def _walk_tree(folder: Path, root: Path) -> list[dict]:
+    items = []
+    for child in sorted(folder.iterdir()):
+        rel = str(child.relative_to(root)).replace("\\", "/")
+        if child.is_dir():
+            items.append({"name": child.name, "type": "dir", "path": rel, "children": _walk_tree(child, root)})
+        else:
+            items.append({"name": child.name, "type": "file", "path": rel, "size": child.stat().st_size})
+    return items
+
+
+def _is_child(path: Path, parent: Path) -> bool:
+    try:
+        path.resolve().relative_to(parent.resolve())
+        return True
+    except ValueError:
+        return False

File diff suppressed because it is too large
+ 0 - 0
webnovel-writer/dashboard/frontend/dist/assets/index-Cw6rJgHT.css


File diff suppressed because it is too large
+ 16 - 0
webnovel-writer/dashboard/frontend/dist/assets/index-lD60Rg1m.js


+ 17 - 0
webnovel-writer/dashboard/frontend/dist/index.html

@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Webnovel Dashboard</title>
+    <meta name="description" content="网文创作管理面板 - 可视化查看小说项目状态、实体图谱与章节内容" />
+    <link rel="preconnect" href="https://fonts.googleapis.com" />
+    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet" />
+    <script type="module" crossorigin src="/assets/index-lD60Rg1m.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Cw6rJgHT.css">
+  </head>
+  <body>
+    <div id="root"></div>

+  </body>
+</html>

+ 16 - 0
webnovel-writer/dashboard/frontend/index.html

@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Webnovel Dashboard</title>
+    <meta name="description" content="网文创作管理面板 - 可视化查看小说项目状态、实体图谱与章节内容" />
+    <link rel="preconnect" href="https://fonts.googleapis.com" />
+    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet" />
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/main.jsx"></script>
+  </body>
+</html>

+ 2251 - 0
webnovel-writer/dashboard/frontend/package-lock.json

@@ -0,0 +1,2251 @@
+{
+  "name": "webnovel-dashboard",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "webnovel-dashboard",
+      "version": "0.1.0",
+      "dependencies": {
+        "react": "^19.0.0",
+        "react-dom": "^19.0.0",
+        "react-force-graph-3d": "^1.29.1"
+      },
+      "devDependencies": {
+        "@types/react": "^19.0.0",
+        "@types/react-dom": "^19.0.0",
+        "@vitejs/plugin-react": "^4.4.0",
+        "vite": "^6.2.0"
+      }
+    },
+    "node_modules/@babel/code-frame": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz",
+      "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-validator-identifier": "^7.28.5",
+        "js-tokens": "^4.0.0",
+        "picocolors": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/compat-data": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.0.tgz",
+      "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/core": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz",
+      "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.29.0",
+        "@babel/generator": "^7.29.0",
+        "@babel/helper-compilation-targets": "^7.28.6",
+        "@babel/helper-module-transforms": "^7.28.6",
+        "@babel/helpers": "^7.28.6",
+        "@babel/parser": "^7.29.0",
+        "@babel/template": "^7.28.6",
+        "@babel/traverse": "^7.29.0",
+        "@babel/types": "^7.29.0",
+        "@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.1",
+      "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz",
+      "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.0",
+        "@babel/types": "^7.29.0",
+        "@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.28.6",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+      "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/compat-data": "^7.28.6",
+        "@babel/helper-validator-option": "^7.27.1",
+        "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.28.0",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+      "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-imports": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+      "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/traverse": "^7.28.6",
+        "@babel/types": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-transforms": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+      "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-module-imports": "^7.28.6",
+        "@babel/helper-validator-identifier": "^7.28.5",
+        "@babel/traverse": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-plugin-utils": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+      "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-option": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+      "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helpers": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.28.6.tgz",
+      "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/template": "^7.28.6",
+        "@babel/types": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.0.tgz",
+      "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.29.0"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-react-jsx-self": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+      "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-react-jsx-source": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+      "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/runtime": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.6.tgz",
+      "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/template": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz",
+      "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.28.6",
+        "@babel/parser": "^7.28.6",
+        "@babel/types": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/traverse": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz",
+      "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.29.0",
+        "@babel/generator": "^7.29.0",
+        "@babel/helper-globals": "^7.28.0",
+        "@babel/parser": "^7.29.0",
+        "@babel/template": "^7.28.6",
+        "@babel/types": "^7.29.0",
+        "debug": "^4.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz",
+      "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+      "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+      "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+      "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+      "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+      "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+      "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+      "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+      "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+      "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+      "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+      "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+      "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+      "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+      "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+      "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+      "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+      "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+      "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+      "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+      "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+      "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+      "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+      "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+      "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+      "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+      "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.13",
+      "resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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/@rolldown/pluginutils": {
+      "version": "1.0.0-beta.27",
+      "resolved": "https://registry.npmmirror.com/@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.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
+      "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
+      "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
+      "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
+      "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
+      "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
+      "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
+      "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
+      "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
+      "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
+      "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
+      "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
+      "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
+      "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
+      "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
+      "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
+      "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
+      "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
+      "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
+      "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
+      "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
+      "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
+      "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
+      "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
+      "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.59.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
+      "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@tweenjs/tween.js": {
+      "version": "25.0.0",
+      "resolved": "https://registry.npmmirror.com/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
+      "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
+      "license": "MIT"
+    },
+    "node_modules/@types/babel__core": {
+      "version": "7.20.5",
+      "resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.8",
+      "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/react": {
+      "version": "19.2.14",
+      "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz",
+      "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "csstype": "^3.2.2"
+      }
+    },
+    "node_modules/@types/react-dom": {
+      "version": "19.2.3",
+      "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz",
+      "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "^19.2.0"
+      }
+    },
+    "node_modules/@vitejs/plugin-react": {
+      "version": "4.7.0",
+      "resolved": "https://registry.npmmirror.com/@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/3d-force-graph": {
+      "version": "1.79.1",
+      "resolved": "https://registry.npmmirror.com/3d-force-graph/-/3d-force-graph-1.79.1.tgz",
+      "integrity": "sha512-iscIVt4jWjJ11KEEswgOIOWk8Ew4EFKHRyERJXJ0ouycqzHCtWwb9E5imnxS5rYF1f1IESkFNAfB+h3EkU0Irw==",
+      "license": "MIT",
+      "dependencies": {
+        "accessor-fn": "1",
+        "kapsule": "^1.16",
+        "three": ">=0.118 <1",
+        "three-forcegraph": "1",
+        "three-render-objects": "^1.35"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/accessor-fn": {
+      "version": "1.5.3",
+      "resolved": "https://registry.npmmirror.com/accessor-fn/-/accessor-fn-1.5.3.tgz",
+      "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/baseline-browser-mapping": {
+      "version": "2.10.0",
+      "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
+      "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "baseline-browser-mapping": "dist/cli.cjs"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/browserslist": {
+      "version": "4.28.1",
+      "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz",
+      "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+      "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.9.0",
+        "caniuse-lite": "^1.0.30001759",
+        "electron-to-chromium": "^1.5.263",
+        "node-releases": "^2.0.27",
+        "update-browserslist-db": "^1.2.0"
+      },
+      "bin": {
+        "browserslist": "cli.js"
+      },
+      "engines": {
+        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001776",
+      "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz",
+      "integrity": "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==",
+      "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.npmmirror.com/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.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/d3-array": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz",
+      "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+      "license": "ISC",
+      "dependencies": {
+        "internmap": "1 - 2"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-binarytree": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
+      "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
+      "license": "MIT"
+    },
+    "node_modules/d3-color": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz",
+      "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-dispatch": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+      "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-force-3d": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmmirror.com/d3-force-3d/-/d3-force-3d-3.0.6.tgz",
+      "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==",
+      "license": "MIT",
+      "dependencies": {
+        "d3-binarytree": "1",
+        "d3-dispatch": "1 - 3",
+        "d3-octree": "1",
+        "d3-quadtree": "1 - 3",
+        "d3-timer": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-format": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz",
+      "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-interpolate": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+      "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-color": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-octree": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/d3-octree/-/d3-octree-1.1.0.tgz",
+      "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
+      "license": "MIT"
+    },
+    "node_modules/d3-quadtree": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
+      "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-scale": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz",
+      "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-array": "2.10.0 - 3",
+        "d3-format": "1 - 3",
+        "d3-interpolate": "1.2.0 - 3",
+        "d3-time": "2.1.1 - 3",
+        "d3-time-format": "2 - 4"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-scale-chromatic": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+      "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-color": "1 - 3",
+        "d3-interpolate": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-selection": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz",
+      "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-time": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz",
+      "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-array": "2 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-time-format": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz",
+      "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+      "license": "ISC",
+      "dependencies": {
+        "d3-time": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-timer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz",
+      "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/data-bind-mapper": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/data-bind-mapper/-/data-bind-mapper-1.0.3.tgz",
+      "integrity": "sha512-QmU3lyEnbENQPo0M1F9BMu4s6cqNNp8iJA+b/HP2sSb7pf3dxwF3+EP1eO69rwBfH9kFJ1apmzrtogAmVt2/Xw==",
+      "license": "MIT",
+      "dependencies": {
+        "accessor-fn": "1"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmmirror.com/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.302",
+      "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
+      "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/esbuild": {
+      "version": "0.25.12",
+      "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz",
+      "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.25.12",
+        "@esbuild/android-arm": "0.25.12",
+        "@esbuild/android-arm64": "0.25.12",
+        "@esbuild/android-x64": "0.25.12",
+        "@esbuild/darwin-arm64": "0.25.12",
+        "@esbuild/darwin-x64": "0.25.12",
+        "@esbuild/freebsd-arm64": "0.25.12",
+        "@esbuild/freebsd-x64": "0.25.12",
+        "@esbuild/linux-arm": "0.25.12",
+        "@esbuild/linux-arm64": "0.25.12",
+        "@esbuild/linux-ia32": "0.25.12",
+        "@esbuild/linux-loong64": "0.25.12",
+        "@esbuild/linux-mips64el": "0.25.12",
+        "@esbuild/linux-ppc64": "0.25.12",
+        "@esbuild/linux-riscv64": "0.25.12",
+        "@esbuild/linux-s390x": "0.25.12",
+        "@esbuild/linux-x64": "0.25.12",
+        "@esbuild/netbsd-arm64": "0.25.12",
+        "@esbuild/netbsd-x64": "0.25.12",
+        "@esbuild/openbsd-arm64": "0.25.12",
+        "@esbuild/openbsd-x64": "0.25.12",
+        "@esbuild/openharmony-arm64": "0.25.12",
+        "@esbuild/sunos-x64": "0.25.12",
+        "@esbuild/win32-arm64": "0.25.12",
+        "@esbuild/win32-ia32": "0.25.12",
+        "@esbuild/win32-x64": "0.25.12"
+      }
+    },
+    "node_modules/escalade": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
+      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/float-tooltip": {
+      "version": "1.7.5",
+      "resolved": "https://registry.npmmirror.com/float-tooltip/-/float-tooltip-1.7.5.tgz",
+      "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==",
+      "license": "MIT",
+      "dependencies": {
+        "d3-selection": "2 - 3",
+        "kapsule": "^1.16",
+        "preact": "10"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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/internmap": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz",
+      "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/jerrypick": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/jerrypick/-/jerrypick-1.1.2.tgz",
+      "integrity": "sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "license": "MIT"
+    },
+    "node_modules/jsesc": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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/kapsule": {
+      "version": "1.16.3",
+      "resolved": "https://registry.npmmirror.com/kapsule/-/kapsule-1.16.3.tgz",
+      "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==",
+      "license": "MIT",
+      "dependencies": {
+        "lodash-es": "4"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/lodash-es": {
+      "version": "4.17.23",
+      "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.23.tgz",
+      "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
+      "license": "MIT"
+    },
+    "node_modules/loose-envify": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "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/ngraph.events": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmmirror.com/ngraph.events/-/ngraph.events-1.4.0.tgz",
+      "integrity": "sha512-NeDGI4DSyjBNBRtA86222JoYietsmCXbs8CEB0dZ51Xeh4lhVl1y3wpWLumczvnha8sFQIW4E0vvVWwgmX2mGw==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/ngraph.forcelayout": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmmirror.com/ngraph.forcelayout/-/ngraph.forcelayout-3.3.1.tgz",
+      "integrity": "sha512-MKBuEh1wujyQHFTW57y5vd/uuEOK0XfXYxm3lC7kktjJLRdt/KEKEknyOlc6tjXflqBKEuYBBcu7Ax5VY+S6aw==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "ngraph.events": "^1.0.0",
+        "ngraph.merge": "^1.0.0",
+        "ngraph.random": "^1.0.0"
+      }
+    },
+    "node_modules/ngraph.graph": {
+      "version": "20.1.2",
+      "resolved": "https://registry.npmmirror.com/ngraph.graph/-/ngraph.graph-20.1.2.tgz",
+      "integrity": "sha512-W/G3GBR3Y5UxMLHTUCPP9v+pbtpzwuAEIqP5oZV+9IwgxAIEZwh+Foc60iPc1idlnK7Zxu0p3puxAyNmDvBd0Q==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "ngraph.events": "^1.4.0"
+      }
+    },
+    "node_modules/ngraph.merge": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/ngraph.merge/-/ngraph.merge-1.0.0.tgz",
+      "integrity": "sha512-5J8YjGITUJeapsomtTALYsw7rFveYkM+lBj3QiYZ79EymQcuri65Nw3knQtFxQBU1r5iOaVRXrSwMENUPK62Vg==",
+      "license": "MIT"
+    },
+    "node_modules/ngraph.random": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/ngraph.random/-/ngraph.random-1.2.0.tgz",
+      "integrity": "sha512-4EUeAGbB2HWX9njd6bP6tciN6ByJfoaAvmVL9QTaZSeXrW46eNGA9GajiXiPBbvFqxUWFkEbyo6x5qsACUuVfA==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/node-releases": {
+      "version": "2.0.27",
+      "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz",
+      "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz",
+      "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/polished": {
+      "version": "4.3.1",
+      "resolved": "https://registry.npmmirror.com/polished/-/polished-4.3.1.tgz",
+      "integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.17.8"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.8",
+      "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz",
+      "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+      "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.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/preact": {
+      "version": "10.28.4",
+      "resolved": "https://registry.npmmirror.com/preact/-/preact-10.28.4.tgz",
+      "integrity": "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/preact"
+      }
+    },
+    "node_modules/prop-types": {
+      "version": "15.8.1",
+      "resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz",
+      "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.4.0",
+        "object-assign": "^4.1.1",
+        "react-is": "^16.13.1"
+      }
+    },
+    "node_modules/react": {
+      "version": "19.2.4",
+      "resolved": "https://registry.npmmirror.com/react/-/react-19.2.4.tgz",
+      "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "19.2.4",
+      "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.4.tgz",
+      "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
+      "license": "MIT",
+      "dependencies": {
+        "scheduler": "^0.27.0"
+      },
+      "peerDependencies": {
+        "react": "^19.2.4"
+      }
+    },
+    "node_modules/react-force-graph-3d": {
+      "version": "1.29.1",
+      "resolved": "https://registry.npmmirror.com/react-force-graph-3d/-/react-force-graph-3d-1.29.1.tgz",
+      "integrity": "sha512-5Vp+PGpYnO+zLwgK2NvNqdXHvsWLrFzpDfJW1vUA1twjo9SPvXqfUYQrnRmAbD+K2tOxkZw1BkbH31l5b4TWHg==",
+      "license": "MIT",
+      "dependencies": {
+        "3d-force-graph": "^1.79",
+        "prop-types": "15",
+        "react-kapsule": "^2.5"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "peerDependencies": {
+        "react": "*"
+      }
+    },
+    "node_modules/react-is": {
+      "version": "16.13.1",
+      "resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz",
+      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+      "license": "MIT"
+    },
+    "node_modules/react-kapsule": {
+      "version": "2.5.7",
+      "resolved": "https://registry.npmmirror.com/react-kapsule/-/react-kapsule-2.5.7.tgz",
+      "integrity": "sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==",
+      "license": "MIT",
+      "dependencies": {
+        "jerrypick": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "peerDependencies": {
+        "react": ">=16.13.1"
+      }
+    },
+    "node_modules/react-refresh": {
+      "version": "0.17.0",
+      "resolved": "https://registry.npmmirror.com/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.59.0",
+      "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.59.0.tgz",
+      "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.8"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.59.0",
+        "@rollup/rollup-android-arm64": "4.59.0",
+        "@rollup/rollup-darwin-arm64": "4.59.0",
+        "@rollup/rollup-darwin-x64": "4.59.0",
+        "@rollup/rollup-freebsd-arm64": "4.59.0",
+        "@rollup/rollup-freebsd-x64": "4.59.0",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
+        "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
+        "@rollup/rollup-linux-arm64-gnu": "4.59.0",
+        "@rollup/rollup-linux-arm64-musl": "4.59.0",
+        "@rollup/rollup-linux-loong64-gnu": "4.59.0",
+        "@rollup/rollup-linux-loong64-musl": "4.59.0",
+        "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
+        "@rollup/rollup-linux-ppc64-musl": "4.59.0",
+        "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
+        "@rollup/rollup-linux-riscv64-musl": "4.59.0",
+        "@rollup/rollup-linux-s390x-gnu": "4.59.0",
+        "@rollup/rollup-linux-x64-gnu": "4.59.0",
+        "@rollup/rollup-linux-x64-musl": "4.59.0",
+        "@rollup/rollup-openbsd-x64": "4.59.0",
+        "@rollup/rollup-openharmony-arm64": "4.59.0",
+        "@rollup/rollup-win32-arm64-msvc": "4.59.0",
+        "@rollup/rollup-win32-ia32-msvc": "4.59.0",
+        "@rollup/rollup-win32-x64-gnu": "4.59.0",
+        "@rollup/rollup-win32-x64-msvc": "4.59.0",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/scheduler": {
+      "version": "0.27.0",
+      "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz",
+      "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+      "license": "MIT"
+    },
+    "node_modules/semver": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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/three": {
+      "version": "0.183.2",
+      "resolved": "https://registry.npmmirror.com/three/-/three-0.183.2.tgz",
+      "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==",
+      "license": "MIT"
+    },
+    "node_modules/three-forcegraph": {
+      "version": "1.43.0",
+      "resolved": "https://registry.npmmirror.com/three-forcegraph/-/three-forcegraph-1.43.0.tgz",
+      "integrity": "sha512-1AqLmTCjjjwcuccObG96fCxiRnNJjCLdA5Mozl7XK+ROwTJ6QEJPo2XJ6uxWeuAmPE7ukMhgv4lj28oZSfE4wg==",
+      "license": "MIT",
+      "dependencies": {
+        "accessor-fn": "1",
+        "d3-array": "1 - 3",
+        "d3-force-3d": "2 - 3",
+        "d3-scale": "1 - 4",
+        "d3-scale-chromatic": "1 - 3",
+        "data-bind-mapper": "1",
+        "kapsule": "^1.16",
+        "ngraph.forcelayout": "3",
+        "ngraph.graph": "20",
+        "tinycolor2": "1"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "peerDependencies": {
+        "three": ">=0.118.3"
+      }
+    },
+    "node_modules/three-render-objects": {
+      "version": "1.40.4",
+      "resolved": "https://registry.npmmirror.com/three-render-objects/-/three-render-objects-1.40.4.tgz",
+      "integrity": "sha512-Ukpu1pei3L5r809izvjsZxwuRcYLiyn6Uvy3lZ9bpMTdvj3i6PeX6w++/hs2ZS3KnEzGjb6YvTvh4UQuwHTDJg==",
+      "license": "MIT",
+      "dependencies": {
+        "@tweenjs/tween.js": "18 - 25",
+        "accessor-fn": "1",
+        "float-tooltip": "^1.7",
+        "kapsule": "^1.16",
+        "polished": "4"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "peerDependencies": {
+        "three": ">=0.168"
+      }
+    },
+    "node_modules/tinycolor2": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmmirror.com/tinycolor2/-/tinycolor2-1.6.0.tgz",
+      "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
+      "license": "MIT"
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.15",
+      "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
+      "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/update-browserslist-db": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+      "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+      "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": "6.4.1",
+      "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.1.tgz",
+      "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.25.0",
+        "fdir": "^6.4.4",
+        "picomatch": "^4.0.2",
+        "postcss": "^8.5.3",
+        "rollup": "^4.34.9",
+        "tinyglobby": "^0.2.13"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || ^20.0.0 || >=22.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 || >=22.0.0",
+        "jiti": ">=1.21.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/yallist": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
+      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+      "dev": true,
+      "license": "ISC"
+    }
+  }
+}

+ 22 - 0
webnovel-writer/dashboard/frontend/package.json

@@ -0,0 +1,22 @@
+{
+  "name": "webnovel-dashboard",
+  "private": true,
+  "version": "0.1.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "react": "^19.0.0",
+    "react-dom": "^19.0.0",
+    "react-force-graph-3d": "^1.29.1"
+  },
+  "devDependencies": {
+    "@types/react": "^19.0.0",
+    "@types/react-dom": "^19.0.0",
+    "@vitejs/plugin-react": "^4.4.0",
+    "vite": "^6.2.0"
+  }
+}

+ 709 - 0
webnovel-writer/dashboard/frontend/src/App.jsx

@@ -0,0 +1,709 @@
+import { useState, useEffect, useCallback, useRef } from 'react'
+import { fetchJSON, subscribeSSE } from './api.js'
+import ForceGraph3D from 'react-force-graph-3d'
+
+// ====================================================================
+// 主应用
+// ====================================================================
+
+export default function App() {
+    const [page, setPage] = useState('dashboard')
+    const [projectInfo, setProjectInfo] = useState(null)
+    const [refreshKey, setRefreshKey] = useState(0)
+    const [connected, setConnected] = useState(false)
+
+    const loadProjectInfo = useCallback(() => {
+        fetchJSON('/api/project/info')
+            .then(setProjectInfo)
+            .catch(() => setProjectInfo(null))
+    }, [])
+
+    useEffect(() => { loadProjectInfo() }, [loadProjectInfo, refreshKey])
+
+    // SSE 订阅
+    useEffect(() => {
+        setConnected(true)
+        const unsub = subscribeSSE(() => {
+            setRefreshKey(k => k + 1)
+        })
+        return () => { unsub(); setConnected(false) }
+    }, [])
+
+    const title = projectInfo?.project_info?.title || '未加载'
+
+    return (
+        <div className="app-layout">
+            <aside className="sidebar">
+                <div className="sidebar-header">
+                    <h1>📖 Dashboard</h1>
+                    <div className="subtitle">{title}</div>
+                </div>
+                <nav className="sidebar-nav">
+                    {NAV_ITEMS.map(item => (
+                        <button
+                            key={item.id}
+                            className={`nav-item ${page === item.id ? 'active' : ''}`}
+                            onClick={() => setPage(item.id)}
+                        >
+                            <span className="icon">{item.icon}</span>
+                            <span>{item.label}</span>
+                        </button>
+                    ))}
+                </nav>
+                <div className="live-indicator">
+                    <span className={`live-dot ${connected ? '' : 'disconnected'}`} />
+                    {connected ? '实时同步中' : '未连接'}
+                </div>
+            </aside>
+
+            <main className="main-content">
+                {page === 'dashboard' && <DashboardPage data={projectInfo} key={refreshKey} />}
+                {page === 'entities' && <EntitiesPage key={refreshKey} />}
+                {page === 'graph' && <GraphPage key={refreshKey} />}
+                {page === 'chapters' && <ChaptersPage key={refreshKey} />}
+                {page === 'files' && <FilesPage />}
+                {page === 'reading' && <ReadingPowerPage key={refreshKey} />}
+            </main>
+        </div>
+    )
+}
+
+const NAV_ITEMS = [
+    { id: 'dashboard', icon: '📊', label: '数据总览' },
+    { id: 'entities', icon: '👤', label: '设定词典' },
+    { id: 'graph', icon: '🕸️', label: '关系图谱' },
+    { id: 'chapters', icon: '📝', label: '章节一览' },
+    { id: 'files', icon: '📁', label: '文档浏览' },
+    { id: 'reading', icon: '🔥', label: '追读力' },
+]
+
+
+// ====================================================================
+// 页面 1:数据总览
+// ====================================================================
+
+function DashboardPage({ data }) {
+    if (!data) return <div className="loading">加载中…</div>
+
+    const info = data.project_info || {}
+    const progress = data.progress || {}
+    const protagonist = data.protagonist_state || {}
+    const strand = data.strand_tracker || {}
+    const foreshadowing = data.plot_threads?.foreshadowing || []
+
+    const totalWords = progress.total_words || 0
+    const targetWords = info.target_words || 2000000
+    const pct = targetWords > 0 ? Math.min(100, (totalWords / targetWords * 100)).toFixed(1) : 0
+
+    const unresolvedForeshadow = foreshadowing.filter(f => {
+        const s = (f.status || '').toLowerCase()
+        return s !== '已回收' && s !== '已兑现' && s !== 'resolved'
+    })
+
+    // Strand 历史统计
+    const history = strand.history || []
+    const strandCounts = { quest: 0, fire: 0, constellation: 0 }
+    history.forEach(h => { if (strandCounts[h.strand] !== undefined) strandCounts[h.strand]++ })
+    const total = history.length || 1
+
+    return (
+        <>
+            <div className="page-header">
+                <h2>📊 数据总览</h2>
+                <span className="card-badge badge-blue">{info.genre || '未知题材'}</span>
+            </div>
+
+            <div className="dashboard-grid">
+                <div className="card stat-card">
+                    <span className="stat-label">总字数</span>
+                    <span className="stat-value">{formatNumber(totalWords)}</span>
+                    <span className="stat-sub">目标 {formatNumber(targetWords)} 字 · {pct}%</span>
+                    <div className="progress-track">
+                        <div className="progress-fill" style={{ width: `${pct}%` }} />
+                    </div>
+                </div>
+
+                <div className="card stat-card">
+                    <span className="stat-label">当前章节</span>
+                    <span className="stat-value">第 {progress.current_chapter || 0} 章</span>
+                    <span className="stat-sub">目标 {info.target_chapters || '?'} 章 · 卷 {progress.current_volume || 1}</span>
+                </div>
+
+                <div className="card stat-card">
+                    <span className="stat-label">主角状态</span>
+                    <span className="stat-value plain">{protagonist.name || '未设定'}</span>
+                    <span className="stat-sub">
+                        {protagonist.power?.realm || '未知境界'}
+                        {protagonist.location?.current ? ` · ${protagonist.location.current}` : ''}
+                    </span>
+                </div>
+
+                <div className="card stat-card">
+                    <span className="stat-label">未回收伏笔</span>
+                    <span className="stat-value" style={{ color: unresolvedForeshadow.length > 10 ? 'var(--accent-red)' : 'var(--accent-amber)' }}>
+                        {unresolvedForeshadow.length}
+                    </span>
+                    <span className="stat-sub">总计 {foreshadowing.length} 条伏笔</span>
+                </div>
+            </div>
+
+            {/* Strand Weave 比例 */}
+            <div className="card" style={{ marginBottom: 20 }}>
+                <div className="card-header">
+                    <span className="card-title">Strand Weave 节奏分布</span>
+                    <span className="card-badge badge-purple">{strand.current_dominant || '?'}</span>
+                </div>
+                <div className="strand-bar" style={{ marginBottom: 14 }}>
+                    <div className="segment strand-quest" style={{ width: `${(strandCounts.quest / total * 100).toFixed(1)}%` }} />
+                    <div className="segment strand-fire" style={{ width: `${(strandCounts.fire / total * 100).toFixed(1)}%` }} />
+                    <div className="segment strand-constellation" style={{ width: `${(strandCounts.constellation / total * 100).toFixed(1)}%` }} />
+                </div>
+                <div style={{ display: 'flex', gap: 24, fontSize: 13, color: 'var(--text-secondary)' }}>
+                    <span>🔵 Quest {(strandCounts.quest / total * 100).toFixed(0)}%</span>
+                    <span>🔴 Fire {(strandCounts.fire / total * 100).toFixed(0)}%</span>
+                    <span>🟣 Constellation {(strandCounts.constellation / total * 100).toFixed(0)}%</span>
+                </div>
+            </div>
+
+            {/* 伏笔列表 */}
+            {unresolvedForeshadow.length > 0 ? (
+                <div className="card">
+                    <div className="card-header">
+                        <span className="card-title">⚠️ 待回收伏笔 (Top 20)</span>
+                    </div>
+                    <table className="data-table">
+                        <thead><tr><th>内容</th><th>状态</th><th>埋设章</th></tr></thead>
+                        <tbody>
+                            {unresolvedForeshadow.slice(0, 20).map((f, i) => (
+                                <tr key={i}>
+                                    <td className="truncate" style={{ maxWidth: 400 }}>{f.content || f.description || '—'}</td>
+                                    <td><span className="card-badge badge-amber">{f.status || '未知'}</span></td>
+                                    <td>{f.chapter || f.planted_chapter || '—'}</td>
+                                </tr>
+                            ))}
+                        </tbody>
+                    </table>
+                </div>
+            ) : null}
+        </>
+    )
+}
+
+
+// ====================================================================
+// 页面 2:设定词典
+// ====================================================================
+
+function EntitiesPage() {
+    const [entities, setEntities] = useState([])
+    const [typeFilter, setTypeFilter] = useState('')
+    const [selected, setSelected] = useState(null)
+    const [changes, setChanges] = useState([])
+
+    useEffect(() => {
+        fetchJSON('/api/entities', typeFilter ? { type: typeFilter } : {}).then(setEntities).catch(() => { })
+    }, [typeFilter])
+
+    useEffect(() => {
+        if (selected) {
+            fetchJSON('/api/state-changes', { entity: selected.id, limit: 30 }).then(setChanges).catch(() => setChanges([]))
+        }
+    }, [selected])
+
+    const types = [...new Set(entities.map(e => e.type))].sort()
+
+    return (
+        <>
+            <div className="page-header">
+                <h2>👤 设定词典</h2>
+                <span className="card-badge badge-green">{entities.length} 个实体</span>
+            </div>
+
+            <div className="filter-group">
+                <button className={`filter-btn ${typeFilter === '' ? 'active' : ''}`} onClick={() => setTypeFilter('')}>全部</button>
+                {types.map(t => (
+                    <button key={t} className={`filter-btn ${typeFilter === t ? 'active' : ''}`} onClick={() => setTypeFilter(t)}>{t}</button>
+                ))}
+            </div>
+
+            <div style={{ display: 'flex', gap: 20 }}>
+                <div style={{ flex: 1 }}>
+                    <div className="card">
+                        <table className="data-table">
+                            <thead><tr><th>名称</th><th>类型</th><th>层级</th><th>首现</th><th>末现</th></tr></thead>
+                            <tbody>
+                                {entities.map(e => (
+                                    <tr
+                                        key={e.id}
+                                        role="button"
+                                        tabIndex={0}
+                                        onKeyDown={evt => (evt.key === 'Enter' || evt.key === ' ') && (evt.preventDefault(), setSelected(e))}
+                                        onClick={() => setSelected(e)}
+                                        style={{ cursor: 'pointer', background: selected?.id === e.id ? 'var(--bg-card-hover)' : undefined }}
+                                    >
+                                        <td style={{ fontWeight: 600, color: e.is_protagonist ? 'var(--accent-amber)' : 'var(--text-primary)' }}>
+                                            {e.canonical_name} {e.is_protagonist ? '⭐' : ''}
+                                        </td>
+                                        <td><span className="card-badge badge-blue">{e.type}</span></td>
+                                        <td>{e.tier}</td>
+                                        <td>{e.first_appearance || '—'}</td>
+                                        <td>{e.last_appearance || '—'}</td>
+                                    </tr>
+                                ))}
+                            </tbody>
+                        </table>
+                    </div>
+                </div>
+
+                {selected && (
+                    <div style={{ width: 360, minWidth: 320 }}>
+                        <div className="card">
+                            <div className="card-header">
+                                <span className="card-title">{selected.canonical_name}</span>
+                                <span className="card-badge badge-purple">{selected.tier}</span>
+                            </div>
+                            <div style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.8 }}>
+                                <p><strong>类型:</strong>{selected.type}</p>
+                                <p><strong>ID:</strong><code style={{ fontSize: 12, color: 'var(--text-muted)' }}>{selected.id}</code></p>
+                                {selected.desc && <p style={{ marginTop: 8 }}>{selected.desc}</p>}
+                                {selected.current_json && (
+                                    <div style={{ marginTop: 12 }}>
+                                        <strong>当前状态:</strong>
+                                        <pre style={{ marginTop: 4, padding: 10, background: 'var(--bg-input)', borderRadius: 'var(--radius-sm)', fontSize: 12, overflow: 'auto', maxHeight: 200 }}>
+                                            {formatJSON(selected.current_json)}
+                                        </pre>
+                                    </div>
+                                )}
+                            </div>
+                            {changes.length > 0 ? (
+                                <div style={{ marginTop: 16 }}>
+                                    <div className="card-title" style={{ marginBottom: 8, fontSize: 14 }}>状态变化历史</div>
+                                    <table className="data-table">
+                                        <thead><tr><th>章</th><th>字段</th><th>变化</th></tr></thead>
+                                        <tbody>
+                                            {changes.map((c, i) => (
+                                                <tr key={i}>
+                                                    <td>{c.chapter}</td>
+                                                    <td>{c.field}</td>
+                                                    <td style={{ fontSize: 12 }}>{c.old_value} → {c.new_value}</td>
+                                                </tr>
+                                            ))}
+                                        </tbody>
+                                    </table>
+                                </div>
+                            ) : null}
+                        </div>
+                    </div>
+                )}
+            </div>
+        </>
+    )
+}
+
+
+// ====================================================================
+// 页面 3:3D 宇宙关系图谱
+// ====================================================================
+
+function GraphPage() {
+    const [relationships, setRelationships] = useState([])
+    const [graphData, setGraphData] = useState({ nodes: [], links: [] })
+
+    useEffect(() => {
+        Promise.all([
+            fetchJSON('/api/relationships', { limit: 1000 }),
+            fetchJSON('/api/entities'),
+        ]).then(([rels, ents]) => {
+            setRelationships(rels)
+            const typeColors = {
+                '角色': '#4f8ff7', '地点': '#34d399', '星球': '#22d3ee', '神仙': '#f59e0b',
+                '势力': '#8b5cf6', '招式': '#ef4444', '法宝': '#ec4899'
+            }
+            const relatedIds = new Set()
+            rels.forEach(r => { relatedIds.add(r.from_entity); relatedIds.add(r.to_entity) })
+            const entityMap = {}
+            ents.forEach(e => { entityMap[e.id] = e })
+
+            const nodes = [...relatedIds].map(id => ({
+                id,
+                name: entityMap[id]?.canonical_name || id,
+                val: (entityMap[id]?.tier === 'S' ? 8 : entityMap[id]?.tier === 'A' ? 5 : 2),
+                color: typeColors[entityMap[id]?.type] || '#5c6078'
+            }))
+            const links = rels.map(r => ({
+                source: r.from_entity,
+                target: r.to_entity,
+                name: r.type
+            }))
+            setGraphData({ nodes, links })
+        }).catch(() => { })
+    }, [])
+
+    return (
+        <>
+            <div className="page-header">
+                <h2>🕸️ 关系图谱</h2>
+                <span className="card-badge badge-blue">{relationships.length} 条引力链接</span>
+            </div>
+            <div className="card" style={{ padding: 0, overflow: 'hidden', height: 'calc(100vh - 180px)', minHeight: 600 }}>
+                <ForceGraph3D
+                    graphData={graphData}
+                    nodeLabel="name"
+                    nodeColor="color"
+                    nodeRelSize={6}
+                    linkColor={() => 'rgba(139, 92, 246, 0.25)'}
+                    linkWidth={1}
+                    linkDirectionalParticles={2}
+                    linkDirectionalParticleWidth={1.5}
+                    linkDirectionalParticleSpeed={d => 0.005 + Math.random() * 0.005}
+                    backgroundColor="#080a12"
+                    showNavInfo={false}
+                />
+            </div>
+        </>
+    )
+}
+
+
+
+// ====================================================================
+// 页面 4:章节一览
+// ====================================================================
+
+function ChaptersPage() {
+    const [chapters, setChapters] = useState([])
+
+    useEffect(() => {
+        fetchJSON('/api/chapters').then(setChapters).catch(() => { })
+    }, [])
+
+    const totalWords = chapters.reduce((s, c) => s + (c.word_count || 0), 0)
+
+    return (
+        <>
+            <div className="page-header">
+                <h2>📝 章节一览</h2>
+                <span className="card-badge badge-green">{chapters.length} 章 · {formatNumber(totalWords)} 字</span>
+            </div>
+            <div className="card">
+                <table className="data-table">
+                    <thead><tr><th>章节</th><th>标题</th><th>字数</th><th>地点</th><th>角色</th></tr></thead>
+                    <tbody>
+                        {chapters.map(c => (
+                            <tr key={c.chapter}>
+                                <td style={{ fontWeight: 600 }}>第 {c.chapter} 章</td>
+                                <td>{c.title || '—'}</td>
+                                <td>{formatNumber(c.word_count || 0)}</td>
+                                <td>{c.location || '—'}</td>
+                                <td className="truncate" style={{ fontSize: 12, maxWidth: 200 }}>{c.characters || '—'}</td>
+                            </tr>
+                        ))}
+                    </tbody>
+                </table>
+                {chapters.length === 0 ? <div className="empty-state"><div className="empty-icon">📭</div><p>暂无章节数据</p></div> : null}
+            </div>
+        </>
+    )
+}
+
+
+// ====================================================================
+// 页面 5:文档浏览
+// ====================================================================
+
+function FilesPage() {
+    const [tree, setTree] = useState({})
+    const [selectedPath, setSelectedPath] = useState(null)
+    const [content, setContent] = useState('')
+
+    useEffect(() => {
+        fetchJSON('/api/files/tree').then(setTree).catch(() => { })
+    }, [])
+
+    useEffect(() => {
+        if (selectedPath) {
+            fetchJSON('/api/files/read', { path: selectedPath })
+                .then(d => setContent(d.content))
+                .catch(() => setContent('[读取失败]'))
+        }
+    }, [selectedPath])
+
+    return (
+        <>
+            <div className="page-header">
+                <h2>📁 文档浏览</h2>
+            </div>
+            <div style={{ display: 'flex', gap: 20 }}>
+                <div style={{ width: 280, minWidth: 240, maxHeight: '80vh', overflowY: 'auto' }}>
+                    {Object.entries(tree).map(([folder, items]) => (
+                        <div key={folder} style={{ marginBottom: 12 }}>
+                            <div style={{ fontWeight: 600, fontSize: 14, padding: '6px 0', color: 'var(--text-primary)' }}>📂 {folder}</div>
+                            <ul className="file-tree">
+                                <TreeNodes items={items} selected={selectedPath} onSelect={setSelectedPath} />
+                            </ul>
+                        </div>
+                    ))}
+                </div>
+                <div style={{ flex: 1 }}>
+                    {selectedPath ? (
+                        <div>
+                            <div style={{ marginBottom: 12, fontSize: 13, color: 'var(--text-muted)' }}>{selectedPath}</div>
+                            <div className="file-preview">{content}</div>
+                        </div>
+                    ) : (
+                        <div className="empty-state"><div className="empty-icon">📄</div><p>选择左侧文件以预览内容</p></div>
+                    )}
+                </div>
+            </div>
+        </>
+    )
+}
+
+
+// ====================================================================
+// 页面 6:追读力
+// ====================================================================
+
+function ReadingPowerPage() {
+    const [data, setData] = useState([])
+
+    useEffect(() => {
+        fetchJSON('/api/reading-power', { limit: 50 }).then(setData).catch(() => { })
+    }, [])
+
+    return (
+        <>
+            <div className="page-header">
+                <h2>🔥 追读力分析</h2>
+                <span className="card-badge badge-amber">{data.length} 章数据</span>
+            </div>
+            <div className="card">
+                <table className="data-table">
+                    <thead><tr><th>章节</th><th>钩子类型</th><th>钩子强度</th><th>过渡章</th><th>Override</th><th>债务余额</th></tr></thead>
+                    <tbody>
+                        {data.map(r => (
+                            <tr key={r.chapter}>
+                                <td style={{ fontWeight: 600 }}>第 {r.chapter} 章</td>
+                                <td>{r.hook_type || '—'}</td>
+                                <td>
+                                    <span className={`card-badge ${r.hook_strength === 'strong' ? 'badge-green' : r.hook_strength === 'weak' ? 'badge-red' : 'badge-amber'}`}>
+                                        {r.hook_strength || '—'}
+                                    </span>
+                                </td>
+                                <td>{r.is_transition ? '✅' : '—'}</td>
+                                <td>{r.override_count || 0}</td>
+                                <td style={{ color: r.debt_balance > 0 ? 'var(--accent-red)' : 'var(--text-muted)' }}>{(r.debt_balance || 0).toFixed(2)}</td>
+                            </tr>
+                        ))}
+                    </tbody>
+                </table>
+                {data.length === 0 ? <div className="empty-state"><div className="empty-icon">🔥</div><p>暂无追读力数据</p></div> : null}
+            </div>
+        </>
+    )
+}
+
+
+// ====================================================================
+// 子组件:文件树递归
+// ====================================================================
+
+function TreeNodes({ items, selected, onSelect, depth = 0 }) {
+    const [expanded, setExpanded] = useState({})
+    if (!items || items.length === 0) return null
+
+    return items.map((item, i) => {
+        const key = item.path || `${depth}-${i}`
+        if (item.type === 'dir') {
+            const isOpen = expanded[key]
+            return (
+                <li key={key}>
+                    <div
+                        className="tree-item"
+                        role="button"
+                        tabIndex={0}
+                        onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), setExpanded(prev => ({ ...prev, [key]: !prev[key] })))}
+                        onClick={() => setExpanded(prev => ({ ...prev, [key]: !prev[key] }))}
+                    >
+                        <span className="tree-icon">{isOpen ? '📂' : '📁'}</span>
+                        <span>{item.name}</span>
+                    </div>
+                    {isOpen && item.children && (
+                        <ul className="tree-children">
+                            <TreeNodes items={item.children} selected={selected} onSelect={onSelect} depth={depth + 1} />
+                        </ul>
+                    )}
+                </li>
+            )
+        }
+        return (
+            <li key={key}>
+                <div
+                    className={`tree-item ${selected === item.path ? 'active' : ''}`}
+                    role="button"
+                    tabIndex={0}
+                    onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), onSelect(item.path))}
+                    onClick={() => onSelect(item.path)}
+                >
+                    <span className="tree-icon">📄</span>
+                    <span>{item.name}</span>
+                </div>
+            </li>
+        )
+    })
+}
+
+
+// ====================================================================
+// 辅助:数字格式化
+// ====================================================================
+
+function formatNumber(n) {
+    if (n >= 10000) return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }).format(n / 10000) + ' 万'
+    return new Intl.NumberFormat('zh-CN').format(n)
+}
+
+function formatJSON(str) {
+    try {
+        return JSON.stringify(JSON.parse(str), null, 2)
+    } catch {
+        return str
+    }
+}
+
+
+// ====================================================================
+// 辅助:Canvas 力导图绘制
+// ====================================================================
+
+function drawGraph(canvas, entities, relationships) {
+    const ctx = canvas.getContext('2d')
+    const dpr = window.devicePixelRatio || 1
+    const rect = canvas.parentElement.getBoundingClientRect()
+    canvas.width = rect.width * dpr
+    canvas.height = rect.height * dpr
+    canvas.style.width = rect.width + 'px'
+    canvas.style.height = rect.height + 'px'
+    ctx.scale(dpr, dpr)
+
+    const W = rect.width, H = rect.height
+
+    // 构建节点集合(仅出现在关系中的实体)
+    const relatedIds = new Set()
+    relationships.forEach(r => { relatedIds.add(r.from_entity); relatedIds.add(r.to_entity) })
+
+    const entityMap = {}
+    entities.forEach(e => { entityMap[e.id] = e })
+
+    const nodeIds = [...relatedIds].slice(0, 80)
+    const nodes = nodeIds.map((id, i) => {
+        const angle = (2 * Math.PI * i) / nodeIds.length
+        const r = Math.min(W, H) * 0.35
+        return {
+            id,
+            label: entityMap[id]?.canonical_name || id,
+            type: entityMap[id]?.type || '未知',
+            x: W / 2 + r * Math.cos(angle) + (Math.random() - 0.5) * 40,
+            y: H / 2 + r * Math.sin(angle) + (Math.random() - 0.5) * 40,
+            vx: 0, vy: 0,
+        }
+    })
+
+    const nodeMap = {}
+    nodes.forEach(n => { nodeMap[n.id] = n })
+
+    const edges = relationships
+        .filter(r => nodeMap[r.from_entity] && nodeMap[r.to_entity])
+        .map(r => ({ source: nodeMap[r.from_entity], target: nodeMap[r.to_entity], type: r.type }))
+
+    // 简易力模拟(50 轮)
+    for (let iter = 0; iter < 50; iter++) {
+        // 排斥力
+        for (let i = 0; i < nodes.length; i++) {
+            for (let j = i + 1; j < nodes.length; j++) {
+                let dx = nodes[j].x - nodes[i].x
+                let dy = nodes[j].y - nodes[i].y
+                let d = Math.sqrt(dx * dx + dy * dy) || 1
+                let force = 5000 / (d * d)
+                let fx = (dx / d) * force
+                let fy = (dy / d) * force
+                nodes[i].vx -= fx; nodes[i].vy -= fy
+                nodes[j].vx += fx; nodes[j].vy += fy
+            }
+        }
+        // 吸引力
+        edges.forEach(e => {
+            let dx = e.target.x - e.source.x
+            let dy = e.target.y - e.source.y
+            let d = Math.sqrt(dx * dx + dy * dy) || 1
+            let force = (d - 120) * 0.01
+            let fx = (dx / d) * force
+            let fy = (dy / d) * force
+            e.source.vx += fx; e.source.vy += fy
+            e.target.vx -= fx; e.target.vy -= fy
+        })
+        // 向心力
+        nodes.forEach(n => {
+            n.vx += (W / 2 - n.x) * 0.001
+            n.vy += (H / 2 - n.y) * 0.001
+            n.x += n.vx * 0.5
+            n.y += n.vy * 0.5
+            n.vx *= 0.8
+            n.vy *= 0.8
+            n.x = Math.max(40, Math.min(W - 40, n.x))
+            n.y = Math.max(40, Math.min(H - 40, n.y))
+        })
+    }
+
+    // 绘制
+    ctx.clearRect(0, 0, W, H)
+
+    // 边(带渐变发光)
+    edges.forEach(e => {
+        const grad = ctx.createLinearGradient(e.source.x, e.source.y, e.target.x, e.target.y)
+        grad.addColorStop(0, 'rgba(139, 92, 246, 0.3)')
+        grad.addColorStop(1, 'rgba(79, 143, 247, 0.15)')
+        ctx.beginPath()
+        ctx.moveTo(e.source.x, e.source.y)
+        ctx.lineTo(e.target.x, e.target.y)
+        ctx.strokeStyle = grad
+        ctx.lineWidth = 1.2
+        ctx.stroke()
+    })
+
+    // 节点(带发光晕)
+    const typeColors = {
+        '角色': '#4f8ff7', '地点': '#34d399', '物品': '#f59e0b',
+        '势力': '#8b5cf6', '招式': '#ef4444',
+    }
+    nodes.forEach(n => {
+        const color = typeColors[n.type] || '#5c6078'
+
+        // 外发光
+        ctx.beginPath()
+        ctx.arc(n.x, n.y, 16, 0, Math.PI * 2)
+        const glow = ctx.createRadialGradient(n.x, n.y, 4, n.x, n.y, 16)
+        glow.addColorStop(0, color + '40')
+        glow.addColorStop(1, 'transparent')
+        ctx.fillStyle = glow
+        ctx.fill()
+
+        // 实心节点
+        ctx.beginPath()
+        ctx.arc(n.x, n.y, 7, 0, Math.PI * 2)
+        ctx.fillStyle = color
+        ctx.fill()
+        ctx.strokeStyle = 'rgba(255,255,255,0.2)'
+        ctx.lineWidth = 1.5
+        ctx.stroke()
+
+        // 标签(带阴影)
+        ctx.fillStyle = '#eaf0ff'
+        ctx.font = '500 11px Inter, Noto Sans SC, sans-serif'
+        ctx.textAlign = 'center'
+        ctx.shadowColor = 'rgba(0,0,0,0.6)'
+        ctx.shadowBlur = 4
+        ctx.fillText(n.label, n.x, n.y - 14)
+        ctx.shadowBlur = 0
+    })
+}

+ 33 - 0
webnovel-writer/dashboard/frontend/src/api.js

@@ -0,0 +1,33 @@
+/**
+ * API 请求工具函数
+ */
+
+const BASE = '';  // 开发时由 vite proxy 代理到 FastAPI
+
+export async function fetchJSON(path, params = {}) {
+    const url = new URL(path, window.location.origin);
+    Object.entries(params).forEach(([k, v]) => {
+        if (v !== undefined && v !== null) url.searchParams.set(k, v);
+    });
+    const res = await fetch(url.toString());
+    if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
+    return res.json();
+}
+
+/**
+ * 订阅 SSE 实时事件流
+ * @param {function} onMessage  收到 data 时回调
+ * @returns {function} 取消订阅函数
+ */
+export function subscribeSSE(onMessage) {
+    const es = new EventSource(`${BASE}/api/events`);
+    es.onmessage = (e) => {
+        try {
+            onMessage(JSON.parse(e.data));
+        } catch { /* ignore parse errors */ }
+    };
+    es.onerror = () => {
+        // 自动重连由 EventSource 处理
+    };
+    return () => es.close();
+}

+ 754 - 0
webnovel-writer/dashboard/frontend/src/index.css

@@ -0,0 +1,754 @@
+/* ============================================
+   Webnovel Dashboard - 全局样式 v2
+   设计风格:暗黑赛博 + 玻璃拟态 + 微动画
+   ============================================ */
+
+:root {
+  color-scheme: dark;
+  /* 色彩体系 */
+  --bg-primary: #080a12;
+  --bg-secondary: #0d1020;
+  --bg-card: rgba(18, 22, 40, 0.75);
+  --bg-card-hover: rgba(30, 36, 64, 0.85);
+  --bg-card-solid: #12162a;
+  --bg-input: rgba(15, 18, 35, 0.8);
+  --bg-glass: rgba(20, 24, 48, 0.55);
+
+  --text-primary: #eaf0ff;
+  --text-secondary: #8b92b0;
+  --text-muted: #505672;
+
+  --accent-blue: #4f8ff7;
+  --accent-purple: #8b5cf6;
+  --accent-green: #34d399;
+  --accent-amber: #f59e0b;
+  --accent-red: #ef4444;
+  --accent-cyan: #22d3ee;
+  --accent-pink: #ec4899;
+
+  --gradient-primary: linear-gradient(135deg, #4f8ff7 0%, #8b5cf6 100%);
+  --gradient-warm: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%);
+  --gradient-cool: linear-gradient(135deg, #22d3ee 0%, #34d399 100%);
+  --gradient-pink: linear-gradient(135deg, #ec4899 0%, #8b5cf6 100%);
+  --gradient-bg: radial-gradient(ellipse at 20% 50%, rgba(79, 143, 247, 0.08) 0%, transparent 50%),
+    radial-gradient(ellipse at 80% 20%, rgba(139, 92, 246, 0.06) 0%, transparent 50%),
+    radial-gradient(ellipse at 50% 80%, rgba(34, 211, 238, 0.04) 0%, transparent 50%);
+
+  --border-color: rgba(255, 255, 255, 0.06);
+  --border-glow: rgba(79, 143, 247, 0.2);
+
+  --shadow-card: 0 4px 30px rgba(0, 0, 0, 0.4);
+  --shadow-glow-blue: 0 0 30px rgba(79, 143, 247, 0.12);
+  --shadow-glow-purple: 0 0 30px rgba(139, 92, 246, 0.12);
+
+  /* 玻璃效果 */
+  --glass-blur: blur(16px);
+  --glass-border: 1px solid rgba(255, 255, 255, 0.08);
+
+  /* 圆角 */
+  --radius-sm: 8px;
+  --radius-md: 12px;
+  --radius-lg: 16px;
+  --radius-xl: 24px;
+
+  /* 字体 */
+  --font-sans: 'Inter', 'Noto Sans SC', -apple-system, BlinkMacSystemFont, sans-serif;
+  --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
+
+  /* 过渡 */
+  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+}
+
+*:focus-visible {
+  outline: 2px solid var(--accent-blue);
+  outline-offset: 2px;
+}
+
+html,
+body,
+#root {
+  height: 100%;
+}
+
+body {
+  font-family: var(--font-sans);
+  background: var(--bg-primary);
+  background-image: var(--gradient-bg);
+  color: var(--text-primary);
+  line-height: 1.6;
+  -webkit-font-smoothing: antialiased;
+}
+
+/* ===== 布局 ===== */
+
+.app-layout {
+  display: flex;
+  height: 100vh;
+  overflow: hidden;
+}
+
+/* ===== 侧边栏 ===== */
+
+.sidebar {
+  width: 240px;
+  min-width: 240px;
+  background: var(--bg-secondary);
+  border-right: var(--glass-border);
+  display: flex;
+  flex-direction: column;
+  overflow-y: auto;
+  position: relative;
+}
+
+.sidebar::after {
+  content: '';
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  width: 1px;
+  background: linear-gradient(to bottom, transparent, rgba(79, 143, 247, 0.15), transparent);
+  pointer-events: none;
+}
+
+.sidebar-header {
+  padding: 24px 20px 20px;
+  border-bottom: var(--glass-border);
+}
+
+.sidebar-header h1 {
+  font-size: 17px;
+  font-weight: 700;
+  background: var(--gradient-primary);
+  -webkit-background-clip: text;
+  -webkit-text-fill-color: transparent;
+  background-clip: text;
+  letter-spacing: -0.02em;
+}
+
+.sidebar-header .subtitle {
+  font-size: 12px;
+  color: var(--text-muted);
+  margin-top: 6px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.sidebar-nav {
+  flex: 1;
+  padding: 14px 10px;
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.nav-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 10px 14px;
+  border-radius: var(--radius-md);
+  cursor: pointer;
+  color: var(--text-secondary);
+  font-size: 13.5px;
+  font-weight: 500;
+  transition: color 0.25s var(--ease-out), border-color 0.25s var(--ease-out), background 0.25s var(--ease-out), box-shadow 0.25s var(--ease-out);
+  border: 1px solid transparent;
+  background: none;
+  width: 100%;
+  text-align: left;
+  position: relative;
+  overflow: hidden;
+  outline: none;
+}
+
+.nav-item::before {
+  content: '';
+  position: absolute;
+  inset: 0;
+  border-radius: inherit;
+  opacity: 0;
+  transition: opacity 0.25s;
+  background: linear-gradient(135deg, rgba(79, 143, 247, 0.06), rgba(139, 92, 246, 0.04));
+}
+
+.nav-item:hover {
+  color: var(--text-primary);
+  border-color: var(--border-color);
+}
+
+.nav-item:hover::before {
+  opacity: 1;
+}
+
+.nav-item.active {
+  color: var(--accent-blue);
+  background: rgba(79, 143, 247, 0.08);
+  border-color: rgba(79, 143, 247, 0.15);
+  box-shadow: var(--shadow-glow-blue);
+}
+
+.nav-item .icon {
+  font-size: 17px;
+  width: 22px;
+  text-align: center;
+}
+
+/* ===== 主内容区 ===== */
+
+.main-content {
+  flex: 1;
+  overflow-y: auto;
+  padding: 32px 36px;
+  animation: fadeIn 0.4s var(--ease-out);
+}
+
+@keyframes fadeIn {
+  from {
+    opacity: 0;
+    transform: translateY(8px);
+  }
+
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+/* ===== 卡片(玻璃拟态) ===== */
+
+.card {
+  background: var(--bg-card);
+  backdrop-filter: var(--glass-blur);
+  -webkit-backdrop-filter: var(--glass-blur);
+  border: var(--glass-border);
+  border-radius: var(--radius-lg);
+  padding: 24px;
+  box-shadow: var(--shadow-card);
+  transition: border-color 0.35s var(--ease-out), box-shadow 0.35s var(--ease-out), transform 0.35s var(--ease-out);
+  position: relative;
+  overflow: hidden;
+}
+
+.card::before {
+  content: '';
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  height: 1px;
+  background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.06), transparent);
+}
+
+.card:hover {
+  border-color: var(--border-glow);
+  box-shadow: var(--shadow-card), var(--shadow-glow-blue);
+  transform: translateY(-1px);
+}
+
+.card-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 18px;
+}
+
+.card-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: var(--text-primary);
+  letter-spacing: -0.01em;
+}
+
+.card-badge {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 12px;
+  border-radius: 100px;
+  font-size: 11.5px;
+  font-weight: 600;
+  letter-spacing: 0.02em;
+  border: 1px solid transparent;
+}
+
+.badge-blue {
+  background: rgba(79, 143, 247, 0.12);
+  color: var(--accent-blue);
+  border-color: rgba(79, 143, 247, 0.2);
+}
+
+.badge-green {
+  background: rgba(52, 211, 153, 0.12);
+  color: var(--accent-green);
+  border-color: rgba(52, 211, 153, 0.2);
+}
+
+.badge-amber {
+  background: rgba(245, 158, 11, 0.12);
+  color: var(--accent-amber);
+  border-color: rgba(245, 158, 11, 0.2);
+}
+
+.badge-red {
+  background: rgba(239, 68, 68, 0.12);
+  color: var(--accent-red);
+  border-color: rgba(239, 68, 68, 0.2);
+}
+
+.badge-purple {
+  background: rgba(139, 92, 246, 0.12);
+  color: var(--accent-purple);
+  border-color: rgba(139, 92, 246, 0.2);
+}
+
+.badge-cyan {
+  background: rgba(34, 211, 238, 0.12);
+  color: var(--accent-cyan);
+  border-color: rgba(34, 211, 238, 0.2);
+}
+
+/* ===== 数据大屏网格 ===== */
+
+.dashboard-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+  gap: 18px;
+  margin-bottom: 22px;
+}
+
+.stat-card {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.stat-card .stat-label {
+  font-size: 12px;
+  color: var(--text-muted);
+  font-weight: 600;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+}
+
+.stat-card .stat-value {
+  font-size: 30px;
+  font-weight: 700;
+  letter-spacing: -0.03em;
+  background: var(--gradient-primary);
+  -webkit-background-clip: text;
+  -webkit-text-fill-color: transparent;
+  background-clip: text;
+}
+
+.stat-card .stat-value.plain {
+  background: none;
+  -webkit-text-fill-color: var(--text-primary);
+}
+
+.stat-card .stat-sub {
+  font-size: 12px;
+  color: var(--text-muted);
+}
+
+/* ===== 进度条 ===== */
+
+.progress-track {
+  height: 6px;
+  border-radius: 100px;
+  background: var(--bg-input);
+  margin-top: 10px;
+  overflow: hidden;
+}
+
+.progress-fill {
+  height: 100%;
+  border-radius: 100px;
+  background: var(--gradient-primary);
+  transition: width 0.8s var(--ease-out);
+  position: relative;
+}
+
+.progress-fill::after {
+  content: '';
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  width: 40px;
+  background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2));
+  border-radius: inherit;
+}
+
+/* ===== 表格 ===== */
+
+.data-table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 13px;
+  font-variant-numeric: tabular-nums;
+}
+
+.data-table th {
+  text-align: left;
+  padding: 10px 14px;
+  color: var(--text-muted);
+  font-weight: 600;
+  font-size: 11px;
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+  border-bottom: 1px solid var(--border-color);
+  position: sticky;
+  top: 0;
+  background: var(--bg-card-solid);
+  z-index: 1;
+}
+
+.data-table td {
+  padding: 10px 14px;
+  border-bottom: 1px solid rgba(255, 255, 255, 0.03);
+  color: var(--text-secondary);
+  transition: background 0.15s, color 0.15s;
+}
+
+.data-table tr {
+  transition: background 0.15s;
+}
+
+.data-table tbody tr:hover td {
+  background: rgba(79, 143, 247, 0.04);
+  color: var(--text-primary);
+}
+
+/* ===== Strand 指示条 ===== */
+
+.strand-bar {
+  display: flex;
+  height: 10px;
+  border-radius: 100px;
+  overflow: hidden;
+  background: var(--bg-input);
+  box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
+}
+
+.strand-bar .segment {
+  height: 100%;
+  transition: width 0.6s var(--ease-out);
+  position: relative;
+}
+
+.strand-quest {
+  background: linear-gradient(90deg, #3b82f6, #60a5fa);
+  box-shadow: 0 0 8px rgba(59, 130, 246, 0.4);
+}
+
+.strand-fire {
+  background: linear-gradient(90deg, #ef4444, #f97316);
+  box-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
+}
+
+.strand-constellation {
+  background: linear-gradient(90deg, #8b5cf6, #a78bfa);
+  box-shadow: 0 0 8px rgba(139, 92, 246, 0.4);
+}
+
+/* ===== 文件浏览器 ===== */
+
+.file-tree {
+  list-style: none;
+  font-size: 13.5px;
+}
+
+.file-tree li {
+  padding: 0;
+}
+
+.tree-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 7px 10px;
+  border-radius: var(--radius-sm);
+  cursor: pointer;
+  color: var(--text-secondary);
+  transition: background 0.2s var(--ease-out), color 0.2s var(--ease-out), border-color 0.2s var(--ease-out);
+  border: 1px solid transparent;
+  outline: none;
+}
+
+.tree-item:hover {
+  background: rgba(79, 143, 247, 0.06);
+  color: var(--text-primary);
+  border-color: var(--border-color);
+}
+
+.tree-item.active {
+  background: rgba(79, 143, 247, 0.1);
+  color: var(--accent-blue);
+  border-color: rgba(79, 143, 247, 0.2);
+}
+
+.tree-icon {
+  width: 18px;
+  text-align: center;
+  flex-shrink: 0;
+}
+
+.tree-children {
+  padding-left: 18px;
+  list-style: none;
+  border-left: 1px solid rgba(255, 255, 255, 0.04);
+  margin-left: 9px;
+}
+
+/* ===== 文件内容预览 ===== */
+
+.file-preview {
+  background: var(--bg-glass);
+  backdrop-filter: var(--glass-blur);
+  border: var(--glass-border);
+  border-radius: var(--radius-md);
+  padding: 24px 28px;
+  font-size: 13.5px;
+  line-height: 1.85;
+  white-space: pre-wrap;
+  word-wrap: break-word;
+  max-height: 72vh;
+  overflow-y: auto;
+  color: var(--text-primary);
+  font-family: var(--font-sans);
+}
+
+/* ===== 关系图谱容器 ===== */
+
+.graph-container {
+  width: 100%;
+  min-height: 500px;
+  background: var(--bg-glass);
+  backdrop-filter: var(--glass-blur);
+  border: var(--glass-border);
+  border-radius: var(--radius-lg);
+  position: relative;
+  overflow: hidden;
+  box-shadow: var(--shadow-card);
+}
+
+.graph-container canvas,
+.graph-container svg {
+  width: 100%;
+  height: 100%;
+}
+
+/* ===== 实时状态指示灯 ===== */
+
+.live-indicator {
+  padding: 14px 20px;
+  border-top: var(--glass-border);
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 11.5px;
+  color: var(--text-muted);
+}
+
+.live-dot {
+  width: 7px;
+  height: 7px;
+  border-radius: 50%;
+  background: var(--accent-green);
+  display: inline-block;
+  box-shadow: 0 0 8px rgba(52, 211, 153, 0.5);
+  animation: livePulse 2.5s ease-in-out infinite;
+}
+
+.live-dot.disconnected {
+  background: var(--accent-red);
+  box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
+  animation: none;
+}
+
+@keyframes livePulse {
+
+  0%,
+  100% {
+    opacity: 1;
+    box-shadow: 0 0 8px rgba(52, 211, 153, 0.5);
+  }
+
+  50% {
+    opacity: 0.5;
+    box-shadow: 0 0 16px rgba(52, 211, 153, 0.2);
+  }
+}
+
+/* ===== 页面标题 ===== */
+
+.page-header {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  margin-bottom: 26px;
+}
+
+.page-header h2 {
+  font-size: 22px;
+  font-weight: 700;
+  letter-spacing: -0.02em;
+}
+
+/* ===== 加载与空状态 ===== */
+
+.loading {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 80px 0;
+  color: var(--text-muted);
+  font-size: 14px;
+  gap: 10px;
+}
+
+.loading::before {
+  content: '';
+  width: 18px;
+  height: 18px;
+  border: 2px solid var(--border-color);
+  border-top-color: var(--accent-blue);
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+.empty-state {
+  text-align: center;
+  padding: 80px 20px;
+  color: var(--text-muted);
+}
+
+.empty-state .empty-icon {
+  font-size: 56px;
+  margin-bottom: 16px;
+  filter: grayscale(0.3);
+}
+
+.empty-state p {
+  font-size: 14px;
+}
+
+/* ===== 筛选按钮组 ===== */
+
+.filter-group {
+  display: flex;
+  gap: 6px;
+  margin-bottom: 18px;
+  flex-wrap: wrap;
+}
+
+.filter-btn {
+  padding: 6px 14px;
+  border-radius: 100px;
+  border: 1px solid var(--border-color);
+  background: transparent;
+  color: var(--text-secondary);
+  font-size: 12.5px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: border-color 0.2s var(--ease-out), color 0.2s var(--ease-out), background 0.2s var(--ease-out);
+  font-family: var(--font-sans);
+  outline: none;
+}
+
+.filter-btn:hover {
+  border-color: var(--border-glow);
+  color: var(--text-primary);
+  background: rgba(79, 143, 247, 0.06);
+}
+
+.filter-btn.active {
+  background: rgba(79, 143, 247, 0.12);
+  border-color: rgba(79, 143, 247, 0.3);
+  color: var(--accent-blue);
+}
+
+/* ===== 滚动条美化 ===== */
+
+::-webkit-scrollbar {
+  width: 5px;
+  height: 5px;
+}
+
+::-webkit-scrollbar-track {
+  background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+  background: rgba(255, 255, 255, 0.08);
+  border-radius: 100px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+  background: rgba(255, 255, 255, 0.15);
+}
+
+/* ===== 响应式 ===== */
+
+@media (max-width: 768px) {
+  .sidebar {
+    width: 56px;
+    min-width: 56px;
+  }
+
+  .sidebar-header h1 {
+    display: none;
+  }
+
+  .sidebar-header .subtitle {
+    display: none;
+  }
+
+  .nav-item span:not(.icon) {
+    display: none;
+  }
+
+  .main-content {
+    padding: 16px;
+  }
+
+  .dashboard-grid {
+    grid-template-columns: 1fr;
+  }
+}
+
+/* ===== 辅助类 ===== */
+
+.truncate {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+/* ===== 减弱动态效果 ===== */
+
+@media (prefers-reduced-motion: reduce) {
+
+  *,
+  *::before,
+  *::after {
+    animation-duration: 0.01ms !important;
+    animation-iteration-count: 1 !important;
+    transition-duration: 0.01ms !important;
+    scroll-behavior: auto !important;
+  }
+}

+ 10 - 0
webnovel-writer/dashboard/frontend/src/main.jsx

@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App.jsx'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+    <React.StrictMode>
+        <App />
+    </React.StrictMode>,
+)

+ 15 - 0
webnovel-writer/dashboard/frontend/vite.config.js

@@ -0,0 +1,15 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+  plugins: [react()],
+  server: {
+    proxy: {
+      '/api': 'http://127.0.0.1:8765',
+    },
+  },
+  build: {
+    outDir: 'dist',
+    emptyOutDir: true,
+  },
+})

+ 28 - 0
webnovel-writer/dashboard/path_guard.py

@@ -0,0 +1,28 @@
+"""
+路径防穿越工具 (Path Traversal Guard)
+
+所有文件读取 API 在访问磁盘前 **必须** 经过此模块校验。
+"""
+
+from pathlib import Path
+from fastapi import HTTPException
+
+
+def safe_resolve(project_root: Path, relative: str) -> Path:
+    """将相对路径解析为绝对路径,并确保其位于 project_root 内部。
+
+    Raises:
+        HTTPException 403 如果解析后的路径逃逸出 project_root。
+    """
+    try:
+        resolved = (project_root / relative).resolve()
+    except (OSError, ValueError):
+        raise HTTPException(status_code=403, detail="非法路径")
+
+    # 严格要求目标路径是 project_root 的"子路径或自身"
+    try:
+        resolved.relative_to(project_root.resolve())
+    except ValueError:
+        raise HTTPException(status_code=403, detail="路径越界:禁止访问 PROJECT_ROOT 之外的文件")
+
+    return resolved

+ 3 - 0
webnovel-writer/dashboard/requirements.txt

@@ -0,0 +1,3 @@
+fastapi>=0.115.0
+uvicorn[standard]>=0.32.0
+watchdog>=5.0.0

+ 71 - 0
webnovel-writer/dashboard/server.py

@@ -0,0 +1,71 @@
+"""
+Dashboard 启动脚本
+
+用法:
+    python -m dashboard.server --project-root /path/to/novel-project
+    python -m dashboard.server                   # 自动从 .claude 指针读取
+"""
+
+import argparse
+import os
+import sys
+import webbrowser
+from pathlib import Path
+
+
+def _resolve_project_root(cli_root: str | None) -> Path:
+    """按优先级解析 PROJECT_ROOT:CLI > 环境变量 > .claude 指针 > CWD。"""
+    if cli_root:
+        return Path(cli_root).resolve()
+
+    env = os.environ.get("WEBNOVEL_PROJECT_ROOT")
+    if env:
+        return Path(env).resolve()
+
+    # 尝试从 .claude 指针读取
+    cwd = Path.cwd()
+    pointer = cwd / ".claude" / ".webnovel-current-project"
+    if pointer.is_file():
+        target = pointer.read_text(encoding="utf-8").strip()
+        if target:
+            p = Path(target)
+            if p.is_dir() and (p / ".webnovel" / "state.json").is_file():
+                return p.resolve()
+
+    # 最终兜底:当前目录
+    if (cwd / ".webnovel" / "state.json").is_file():
+        return cwd.resolve()
+
+    print("ERROR: 无法定位 PROJECT_ROOT(需要包含 .webnovel/state.json 的目录)", file=sys.stderr)
+    sys.exit(1)
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Webnovel Dashboard Server")
+    parser.add_argument("--project-root", type=str, default=None, help="小说项目根目录")
+    parser.add_argument("--host", default="127.0.0.1", help="监听地址")
+    parser.add_argument("--port", type=int, default=8765, help="监听端口")
+    parser.add_argument("--no-browser", action="store_true", help="不自动打开浏览器")
+    args = parser.parse_args()
+
+    project_root = _resolve_project_root(args.project_root)
+    print(f"项目路径: {project_root}")
+
+    # 延迟导入,以便先处理路径
+    import uvicorn
+    from .app import create_app
+
+    app = create_app(project_root)
+
+    url = f"http://{args.host}:{args.port}"
+    print(f"Dashboard 启动: {url}")
+    print(f"API 文档: {url}/docs")
+
+    if not args.no_browser:
+        webbrowser.open(url)
+
+    uvicorn.run(app, host=args.host, port=args.port, log_level="info")
+
+
+if __name__ == "__main__":
+    main()

+ 94 - 0
webnovel-writer/dashboard/watcher.py

@@ -0,0 +1,94 @@
+"""
+Watchdog 文件变更监听器 + SSE 推送
+
+监控 PROJECT_ROOT/.webnovel/ 目录下 state.json / index.db 等文件的写事件,
+通过 SSE 通知所有已连接的前端客户端刷新数据。
+"""
+
+import asyncio
+import json
+import time
+from pathlib import Path
+from typing import AsyncGenerator
+
+from watchdog.observers import Observer
+from watchdog.events import FileSystemEventHandler, FileModifiedEvent, FileCreatedEvent
+
+
+class _WebnovelFileHandler(FileSystemEventHandler):
+    """仅关注 .webnovel/ 目录下关键文件的修改/创建事件。"""
+
+    WATCH_NAMES = {"state.json", "index.db", "workflow_state.json"}
+
+    def __init__(self, notify_callback):
+        super().__init__()
+        self._notify = notify_callback
+
+    def on_modified(self, event):
+        if event.is_directory:
+            return
+        if Path(event.src_path).name in self.WATCH_NAMES:
+            self._notify(event.src_path, "modified")
+
+    def on_created(self, event):
+        if event.is_directory:
+            return
+        if Path(event.src_path).name in self.WATCH_NAMES:
+            self._notify(event.src_path, "created")
+
+
+class FileWatcher:
+    """管理 watchdog Observer 和 SSE 客户端订阅。"""
+
+    def __init__(self):
+        self._observer: Observer | None = None
+        self._subscribers: list[asyncio.Queue] = []
+        self._loop: asyncio.AbstractEventLoop | None = None
+
+    # --- 订阅管理 ---
+
+    def subscribe(self) -> asyncio.Queue:
+        q: asyncio.Queue = asyncio.Queue(maxsize=64)
+        self._subscribers.append(q)
+        return q
+
+    def unsubscribe(self, q: asyncio.Queue):
+        try:
+            self._subscribers.remove(q)
+        except ValueError:
+            pass
+
+    # --- 推送 ---
+
+    def _on_change(self, path: str, kind: str):
+        """在 watchdog 线程中调用,向主事件循环投递通知。"""
+        msg = json.dumps({"file": Path(path).name, "kind": kind, "ts": time.time()})
+        if self._loop and not self._loop.is_closed():
+            self._loop.call_soon_threadsafe(self._dispatch, msg)
+
+    def _dispatch(self, msg: str):
+        dead: list[asyncio.Queue] = []
+        for q in self._subscribers:
+            try:
+                q.put_nowait(msg)
+            except asyncio.QueueFull:
+                dead.append(q)
+        for dq in dead:
+            self.unsubscribe(dq)
+
+    # --- 生命周期 ---
+
+    def start(self, watch_dir: Path, loop: asyncio.AbstractEventLoop):
+        """启动 watchdog observer,监听 watch_dir。"""
+        self._loop = loop
+        handler = _WebnovelFileHandler(self._on_change)
+        self._observer = Observer()
+        self._observer.schedule(handler, str(watch_dir), recursive=False)
+        self._observer.daemon = True
+        self._observer.start()
+
+    def stop(self):
+        if self._observer:
+            self._observer.stop()
+            self._observer.join(timeout=3)
+            self._observer = None

+ 80 - 0
webnovel-writer/skills/webnovel-dashboard/SKILL.md

@@ -0,0 +1,80 @@
+---
+name: webnovel-dashboard
+description: 启动可视化小说管理面板(只读 Web Dashboard),实时查看项目状态、实体图谱与章节内容。
+allowed-tools: Bash Read
+---
+
+# Webnovel Dashboard
+
+## 目标
+
+在本地启动一个 **只读** Web 面板,用于可视化查看当前小说项目的:
+- 创作进度与 Strand 节奏分布
+- 设定词典(角色/地点/势力等实体)
+- 关系图谱
+- 章节与大纲内容浏览
+- 追读力分析数据
+
+面板通过 `watchdog` 监听 `.webnovel/` 目录变更并实时刷新,不对项目做任何修改。
+
+## 执行步骤
+
+### Step 0:环境确认
+
+```bash
+export WORKSPACE_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
+
+if [ -z "${CLAUDE_PLUGIN_ROOT}" ] || [ ! -d "${CLAUDE_PLUGIN_ROOT}/dashboard" ]; then
+  echo "ERROR: 未找到 dashboard 模块: ${CLAUDE_PLUGIN_ROOT}/dashboard" >&2
+  exit 1
+fi
+export DASHBOARD_DIR="${CLAUDE_PLUGIN_ROOT}/dashboard"
+```
+
+### Step 1:安装依赖(首次)
+
+```bash
+python -m pip install -r "${DASHBOARD_DIR}/requirements.txt" --quiet
+```
+
+### Step 2:解析项目根目录并准备 Python 模块路径
+
+```bash
+export SCRIPTS_DIR="${CLAUDE_PLUGIN_ROOT}/scripts"
+export PROJECT_ROOT="$(python "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" where)"
+echo "项目路径: ${PROJECT_ROOT}"
+
+# 确保 `python -m dashboard.server` 可在任意工作目录下找到插件模块
+if [ -n "${PYTHONPATH:-}" ]; then
+  export PYTHONPATH="${CLAUDE_PLUGIN_ROOT}:${PYTHONPATH}"
+else
+  export PYTHONPATH="${CLAUDE_PLUGIN_ROOT}"
+fi
+
+# 前端 dist 已随插件发布;若缺失说明安装包异常
+if [ ! -f "${DASHBOARD_DIR}/frontend/dist/index.html" ]; then
+  echo "ERROR: 缺少前端构建产物 ${DASHBOARD_DIR}/frontend/dist/index.html" >&2
+  echo "请重新安装插件或联系维护者修复发布包。" >&2
+  exit 1
+fi
+```
+
+### Step 3:启动 Dashboard
+
+```bash
+python -m dashboard.server --project-root "${PROJECT_ROOT}"
+```
+
+启动后会自动打开浏览器访问 `http://127.0.0.1:8765`。
+
+如不需要自动打开浏览器,使用:
+
+```bash
+python -m dashboard.server --project-root "${PROJECT_ROOT}" --no-browser
+```
+
+## 注意事项
+
+- Dashboard 为纯只读面板,所有 API 仅 GET,不提供任何修改接口。
+- 文件读取严格限制在 `PROJECT_ROOT` 范围内,防止路径穿越。
+- 如需自定义端口,添加 `--port 9000` 参数。

Some files were not shown because too many files changed in this diff