feat: initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Noma Dashboard - 可视化小说管理面板
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Allow running as `python -m web_dashboard`."""
|
||||
from .server import main
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,717 @@
|
||||
"""
|
||||
Noma Dashboard - FastAPI 主应用
|
||||
|
||||
支持多项目遍历:
|
||||
- 系统级索引 (.noma/projects.json)
|
||||
- 项目切换
|
||||
- 跨项目统计
|
||||
|
||||
仅提供 GET 接口(严格只读);所有文件读取经过 path_guard 防穿越校验。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sqlite3
|
||||
import os
|
||||
from contextlib import asynccontextmanager, closing
|
||||
from datetime import datetime
|
||||
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
|
||||
_system_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 _get_system_root() -> Path:
|
||||
"""获取系统根目录(.noma 所在目录)"""
|
||||
global _system_root
|
||||
if _system_root is not None:
|
||||
return _system_root
|
||||
|
||||
project_root = _get_project_root()
|
||||
|
||||
# 方案1: NOMA_SYSTEM_ROOT 环境变量
|
||||
env_path = os.environ.get("NOMA_SYSTEM_ROOT")
|
||||
if env_path:
|
||||
_system_root = Path(env_path).resolve()
|
||||
return _system_root
|
||||
|
||||
# 方案2: 与 project_root 同级的 .noma
|
||||
sibling = project_root.parent / ".noma"
|
||||
if sibling.exists():
|
||||
_system_root = sibling.parent
|
||||
return _system_root
|
||||
|
||||
# 方案3: project_root/.noma 作为系统根(兼容)
|
||||
_system_root = project_root
|
||||
return _system_root
|
||||
|
||||
|
||||
def _noma_dir() -> Path:
|
||||
return _get_project_root() / ".noma"
|
||||
|
||||
|
||||
def _system_noma_dir() -> Path:
|
||||
"""系统级 .noma 目录"""
|
||||
return _get_system_root() / ".noma"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 应用工厂
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_app(project_root: str | Path | None = None) -> FastAPI:
|
||||
global _project_root
|
||||
|
||||
if project_root:
|
||||
_project_root = Path(project_root).resolve()
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(_: FastAPI):
|
||||
noma = _noma_dir()
|
||||
if noma.is_dir():
|
||||
_watcher.start(noma, asyncio.get_running_loop())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_watcher.stop()
|
||||
|
||||
app = FastAPI(title="Noma Dashboard", version="0.1.0", lifespan=_lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["GET"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ===========================================================
|
||||
# API:项目元信息
|
||||
# ===========================================================
|
||||
|
||||
@app.get("/api/project/info")
|
||||
def project_info():
|
||||
"""返回 state.json 完整内容(只读)。"""
|
||||
state_path = _noma_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:多项目管理
|
||||
# ===========================================================
|
||||
|
||||
@app.get("/api/projects/list")
|
||||
def list_projects():
|
||||
"""列出系统中的所有小说项目"""
|
||||
system_noma = _system_noma_dir()
|
||||
projects_file = system_noma / "projects.json"
|
||||
|
||||
projects = []
|
||||
if projects_file.exists():
|
||||
try:
|
||||
data = json.loads(projects_file.read_text(encoding="utf-8"))
|
||||
projects = data.get("projects", [])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 如果没有索引,尝试自动发现
|
||||
if not projects:
|
||||
projects = _discover_projects()
|
||||
|
||||
# 为每个项目添加实时统计
|
||||
for p in projects:
|
||||
p["path"] = str(p.get("path", ""))
|
||||
project_path = Path(p["path"])
|
||||
if project_path.exists():
|
||||
noma_dir = project_path / ".noma"
|
||||
state_file = noma_dir / "state.json"
|
||||
if state_file.exists():
|
||||
try:
|
||||
state = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
p["title"] = state.get("project_info", {}).get("title", p.get("title", "未知"))
|
||||
p["genre"] = state.get("project_info", {}).get("genre", "unknown")
|
||||
p["progress"] = state.get("progress", {})
|
||||
except Exception:
|
||||
pass
|
||||
# 统计章节数
|
||||
chapters_dir = project_path / "正文"
|
||||
if chapters_dir.exists():
|
||||
p["chapter_count"] = len(list(chapters_dir.glob("*.md")))
|
||||
else:
|
||||
p["chapter_count"] = 0
|
||||
else:
|
||||
p["status"] = "not_found"
|
||||
|
||||
return {
|
||||
"projects": projects,
|
||||
"current_project": str(_get_project_root()),
|
||||
"system_root": str(_get_system_root())
|
||||
}
|
||||
|
||||
@app.get("/api/projects/discover")
|
||||
def discover_projects():
|
||||
"""自动发现 novels/ 目录下的所有项目"""
|
||||
discovered = _discover_projects()
|
||||
return {"projects": discovered, "count": len(discovered)}
|
||||
|
||||
@app.post("/api/projects/register")
|
||||
def register_project(path: str):
|
||||
"""注册一个新项目到系统索引"""
|
||||
project_path = Path(path).resolve()
|
||||
|
||||
if not project_path.exists():
|
||||
raise HTTPException(404, "项目路径不存在")
|
||||
|
||||
noma_dir = project_path / ".noma"
|
||||
if not noma_dir.exists():
|
||||
raise HTTPException(400, "不是有效的 Noma 项目(缺少 .noma 目录)")
|
||||
|
||||
state_file = noma_dir / "state.json"
|
||||
if not state_file.exists():
|
||||
raise HTTPException(400, "不是有效的 Noma 项目(缺少 state.json)")
|
||||
|
||||
try:
|
||||
state = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
title = state.get("project_info", {}).get("title", project_path.name)
|
||||
genre = state.get("project_info", {}).get("genre", "unknown")
|
||||
except Exception:
|
||||
title = project_path.name
|
||||
genre = "unknown"
|
||||
|
||||
# 读取现有索引
|
||||
system_noma = _system_noma_dir()
|
||||
system_noma.mkdir(parents=True, exist_ok=True)
|
||||
projects_file = system_noma / "projects.json"
|
||||
|
||||
projects = []
|
||||
if projects_file.exists():
|
||||
try:
|
||||
data = json.loads(projects_file.read_text(encoding="utf-8"))
|
||||
projects = data.get("projects", [])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 检查是否已存在
|
||||
path_str = str(project_path)
|
||||
for i, p in enumerate(projects):
|
||||
if p.get("path") == path_str:
|
||||
# 更新
|
||||
projects[i] = {
|
||||
"path": path_str,
|
||||
"title": title,
|
||||
"genre": genre,
|
||||
"registered_at": datetime.now().isoformat()
|
||||
}
|
||||
break
|
||||
else:
|
||||
# 添加
|
||||
projects.append({
|
||||
"path": path_str,
|
||||
"title": title,
|
||||
"genre": genre,
|
||||
"registered_at": datetime.now().isoformat()
|
||||
})
|
||||
|
||||
# 写入索引
|
||||
index_data = {
|
||||
"projects": projects,
|
||||
"last_updated": datetime.now().isoformat()
|
||||
}
|
||||
projects_file.write_text(
|
||||
json.dumps(index_data, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
return {"success": True, "title": title, "path": path_str}
|
||||
|
||||
@app.delete("/api/projects/unregister")
|
||||
def unregister_project(path: str):
|
||||
"""从系统索引移除项目"""
|
||||
project_path = Path(path).resolve()
|
||||
path_str = str(project_path)
|
||||
|
||||
system_noma = _system_noma_dir()
|
||||
projects_file = system_noma / "projects.json"
|
||||
|
||||
if not projects_file.exists():
|
||||
return {"success": False, "message": "索引文件不存在"}
|
||||
|
||||
try:
|
||||
data = json.loads(projects_file.read_text(encoding="utf-8"))
|
||||
projects = data.get("projects", [])
|
||||
|
||||
original_count = len(projects)
|
||||
projects = [p for p in projects if p.get("path") != path_str]
|
||||
|
||||
if len(projects) == original_count:
|
||||
return {"success": False, "message": "项目不在索引中"}
|
||||
|
||||
data["projects"] = projects
|
||||
data["last_updated"] = datetime.now().isoformat()
|
||||
|
||||
projects_file.write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
return {"success": True, "message": "项目已移除"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _discover_projects():
|
||||
"""从 novels/ 目录发现项目"""
|
||||
projects = []
|
||||
|
||||
# 检查可能的 novels 目录
|
||||
system_root = _get_system_root()
|
||||
candidates = [
|
||||
system_root / "novels",
|
||||
system_root.parent / "novels",
|
||||
system_root,
|
||||
]
|
||||
|
||||
for novels_dir in candidates:
|
||||
if not novels_dir.exists() or not novels_dir.is_dir():
|
||||
continue
|
||||
|
||||
for item in novels_dir.iterdir():
|
||||
if not item.is_dir():
|
||||
continue
|
||||
|
||||
noma_dir = item / ".noma"
|
||||
if not noma_dir.exists():
|
||||
continue
|
||||
|
||||
state_file = noma_dir / "state.json"
|
||||
if not state_file.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
state = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
projects.append({
|
||||
"path": str(item.resolve()),
|
||||
"title": state.get("project_info", {}).get("title", item.name),
|
||||
"genre": state.get("project_info", {}).get("genre", "unknown"),
|
||||
"registered_at": None
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return projects
|
||||
|
||||
@app.get("/api/projects/summary")
|
||||
def projects_summary():
|
||||
"""获取跨项目汇总统计"""
|
||||
projects_data = list_projects()
|
||||
projects = projects_data.get("projects", [])
|
||||
|
||||
total_words = 0
|
||||
total_chapters = 0
|
||||
genres = {}
|
||||
|
||||
for p in projects:
|
||||
progress = p.get("progress", {})
|
||||
words = progress.get("total_words", 0) or 0
|
||||
chapters = p.get("chapter_count", 0) or 0
|
||||
total_words += words
|
||||
total_chapters += chapters
|
||||
|
||||
genre = p.get("genre", "unknown")
|
||||
if genre not in genres:
|
||||
genres[genre] = {"count": 0, "chapters": 0, "words": 0}
|
||||
genres[genre]["count"] += 1
|
||||
genres[genre]["chapters"] += chapters
|
||||
genres[genre]["words"] += words
|
||||
|
||||
return {
|
||||
"total_projects": len(projects),
|
||||
"total_words": total_words,
|
||||
"total_chapters": total_chapters,
|
||||
"genres": genres,
|
||||
"current_project": projects_data.get("current_project")
|
||||
}
|
||||
|
||||
# ===========================================================
|
||||
# API:实体数据库(index.db 只读查询)
|
||||
# ===========================================================
|
||||
|
||||
def _get_db() -> sqlite3.Connection:
|
||||
db_path = _noma_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
|
||||
|
||||
def _fetchall_safe(conn: sqlite3.Connection, query: str, params: tuple = ()) -> list[dict]:
|
||||
"""执行只读查询;若目标表不存在(旧库),返回空列表。"""
|
||||
try:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
except sqlite3.OperationalError as exc:
|
||||
if "no such table" in str(exc).lower():
|
||||
return []
|
||||
raise HTTPException(status_code=500, detail=f"数据库查询失败: {exc}") from exc
|
||||
|
||||
@app.get("/api/entities")
|
||||
def list_entities(
|
||||
entity_type: Optional[str] = Query(None, alias="type"),
|
||||
include_archived: bool = False,
|
||||
):
|
||||
"""列出所有实体(可按类型过滤)。"""
|
||||
with closing(_get_db()) as conn:
|
||||
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]
|
||||
|
||||
@app.get("/api/entities/{entity_id}")
|
||||
def get_entity(entity_id: str):
|
||||
with closing(_get_db()) as conn:
|
||||
row = conn.execute("SELECT * FROM entities WHERE id = ?", (entity_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "实体不存在")
|
||||
return dict(row)
|
||||
|
||||
@app.get("/api/relationships")
|
||||
def list_relationships(entity: Optional[str] = None, limit: int = 200):
|
||||
with closing(_get_db()) as conn:
|
||||
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]
|
||||
|
||||
@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,
|
||||
):
|
||||
with closing(_get_db()) as conn:
|
||||
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]
|
||||
|
||||
@app.get("/api/chapters")
|
||||
def list_chapters():
|
||||
with closing(_get_db()) as conn:
|
||||
rows = conn.execute("SELECT * FROM chapters ORDER BY chapter ASC").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@app.get("/api/scenes")
|
||||
def list_scenes(chapter: Optional[int] = None, limit: int = 500):
|
||||
with closing(_get_db()) as conn:
|
||||
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]
|
||||
|
||||
@app.get("/api/reading-power")
|
||||
def list_reading_power(limit: int = 50):
|
||||
with closing(_get_db()) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM chapter_reading_power ORDER BY chapter DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@app.get("/api/review-metrics")
|
||||
def list_review_metrics(limit: int = 20):
|
||||
with closing(_get_db()) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM review_metrics ORDER BY end_chapter DESC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@app.get("/api/state-changes")
|
||||
def list_state_changes(entity: Optional[str] = None, limit: int = 100):
|
||||
with closing(_get_db()) as conn:
|
||||
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]
|
||||
|
||||
@app.get("/api/aliases")
|
||||
def list_aliases(entity: Optional[str] = None):
|
||||
with closing(_get_db()) as conn:
|
||||
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]
|
||||
|
||||
# ===========================================================
|
||||
# API:扩展表(v5.3+ / v5.4+)
|
||||
# ===========================================================
|
||||
|
||||
@app.get("/api/overrides")
|
||||
def list_overrides(status: Optional[str] = None, limit: int = 100):
|
||||
with closing(_get_db()) as conn:
|
||||
if status:
|
||||
return _fetchall_safe(
|
||||
conn,
|
||||
"SELECT * FROM override_contracts WHERE status = ? ORDER BY chapter DESC LIMIT ?",
|
||||
(status, limit),
|
||||
)
|
||||
return _fetchall_safe(
|
||||
conn,
|
||||
"SELECT * FROM override_contracts ORDER BY chapter DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
@app.get("/api/debts")
|
||||
def list_debts(status: Optional[str] = None, limit: int = 100):
|
||||
with closing(_get_db()) as conn:
|
||||
if status:
|
||||
return _fetchall_safe(
|
||||
conn,
|
||||
"SELECT * FROM chase_debt WHERE status = ? ORDER BY updated_at DESC LIMIT ?",
|
||||
(status, limit),
|
||||
)
|
||||
return _fetchall_safe(
|
||||
conn,
|
||||
"SELECT * FROM chase_debt ORDER BY updated_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
@app.get("/api/debt-events")
|
||||
def list_debt_events(debt_id: Optional[int] = None, limit: int = 200):
|
||||
with closing(_get_db()) as conn:
|
||||
if debt_id is not None:
|
||||
return _fetchall_safe(
|
||||
conn,
|
||||
"SELECT * FROM debt_events WHERE debt_id = ? ORDER BY chapter DESC, id DESC LIMIT ?",
|
||||
(debt_id, limit),
|
||||
)
|
||||
return _fetchall_safe(
|
||||
conn,
|
||||
"SELECT * FROM debt_events ORDER BY chapter DESC, id DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
@app.get("/api/invalid-facts")
|
||||
def list_invalid_facts(status: Optional[str] = None, limit: int = 100):
|
||||
with closing(_get_db()) as conn:
|
||||
if status:
|
||||
return _fetchall_safe(
|
||||
conn,
|
||||
"SELECT * FROM invalid_facts WHERE status = ? ORDER BY marked_at DESC LIMIT ?",
|
||||
(status, limit),
|
||||
)
|
||||
return _fetchall_safe(
|
||||
conn,
|
||||
"SELECT * FROM invalid_facts ORDER BY marked_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
@app.get("/api/rag-queries")
|
||||
def list_rag_queries(query_type: Optional[str] = None, limit: int = 100):
|
||||
with closing(_get_db()) as conn:
|
||||
if query_type:
|
||||
return _fetchall_safe(
|
||||
conn,
|
||||
"SELECT * FROM rag_query_log WHERE query_type = ? ORDER BY created_at DESC LIMIT ?",
|
||||
(query_type, limit),
|
||||
)
|
||||
return _fetchall_safe(
|
||||
conn,
|
||||
"SELECT * FROM rag_query_log ORDER BY created_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
@app.get("/api/tool-stats")
|
||||
def list_tool_stats(tool_name: Optional[str] = None, limit: int = 200):
|
||||
with closing(_get_db()) as conn:
|
||||
if tool_name:
|
||||
return _fetchall_safe(
|
||||
conn,
|
||||
"SELECT * FROM tool_call_stats WHERE tool_name = ? ORDER BY created_at DESC LIMIT ?",
|
||||
(tool_name, limit),
|
||||
)
|
||||
return _fetchall_safe(
|
||||
conn,
|
||||
"SELECT * FROM tool_call_stats ORDER BY created_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
@app.get("/api/checklist-scores")
|
||||
def list_checklist_scores(limit: int = 100):
|
||||
with closing(_get_db()) as conn:
|
||||
return _fetchall_safe(
|
||||
conn,
|
||||
"SELECT * FROM writing_checklist_scores ORDER BY chapter DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
# ===========================================================
|
||||
# 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 端点,推送 .noma/ 下的文件变更。"""
|
||||
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>Noma Dashboard API is running</h2>"
|
||||
"<p>前端尚未构建。请先在 <code>web_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
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "noma-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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,889 @@
|
||||
import { useState, useEffect, useCallback } 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(() => {
|
||||
const unsub = subscribeSSE(
|
||||
() => {
|
||||
setRefreshKey(k => k + 1)
|
||||
},
|
||||
{
|
||||
onOpen: () => setConnected(true),
|
||||
onError: () => setConnected(false),
|
||||
},
|
||||
)
|
||||
return () => { unsub(); setConnected(false) }
|
||||
}, [])
|
||||
|
||||
const title = projectInfo?.project_info?.title || '未加载'
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h1>PIXEL WRITER HUB</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: '追读力' },
|
||||
]
|
||||
|
||||
const FULL_DATA_GROUPS = [
|
||||
{ key: 'entities', title: '实体', columns: ['id', 'canonical_name', 'type', 'tier', 'first_appearance', 'last_appearance'], domain: 'core' },
|
||||
{ key: 'chapters', title: '章节', columns: ['chapter', 'title', 'word_count', 'location', 'characters'], domain: 'core' },
|
||||
{ key: 'scenes', title: '场景', columns: ['chapter', 'scene_index', 'location', 'time', 'summary'], domain: 'core' },
|
||||
{ key: 'aliases', title: '别名', columns: ['alias', 'entity_id', 'entity_type'], domain: 'core' },
|
||||
{ key: 'stateChanges', title: '状态变化', columns: ['entity_id', 'field', 'old_value', 'new_value', 'chapter'], domain: 'core' },
|
||||
{ key: 'relationships', title: '关系', columns: ['from_entity', 'to_entity', 'type', 'chapter', 'description'], domain: 'network' },
|
||||
{ key: 'relationshipEvents', title: '关系事件', columns: ['from_entity', 'to_entity', 'type', 'chapter', 'event_type', 'description'], domain: 'network' },
|
||||
{ key: 'readingPower', title: '追读力', columns: ['chapter', 'hook_type', 'hook_strength', 'is_transition', 'override_count', 'debt_balance'], domain: 'network' },
|
||||
{ key: 'overrides', title: 'Override 合约', columns: ['chapter', 'constraint_type', 'constraint_id', 'due_chapter', 'status'], domain: 'network' },
|
||||
{ key: 'debts', title: '追读债务', columns: ['id', 'debt_type', 'current_amount', 'interest_rate', 'due_chapter', 'status'], domain: 'network' },
|
||||
{ key: 'debtEvents', title: '债务事件', columns: ['debt_id', 'event_type', 'amount', 'chapter', 'note'], domain: 'network' },
|
||||
{ key: 'reviewMetrics', title: '审查指标', columns: ['start_chapter', 'end_chapter', 'overall_score', 'severity_counts', 'created_at'], domain: 'quality' },
|
||||
{ key: 'invalidFacts', title: '无效事实', columns: ['source_type', 'source_id', 'reason', 'status', 'chapter_discovered'], domain: 'quality' },
|
||||
{ key: 'checklistScores', title: '写作清单评分', columns: ['chapter', 'template', 'score', 'completion_rate', 'completed_items', 'total_items'], domain: 'quality' },
|
||||
{ key: 'ragQueries', title: 'RAG 查询日志', columns: ['query_type', 'query', 'results_count', 'latency_ms', 'chapter', 'created_at'], domain: 'ops' },
|
||||
{ key: 'toolStats', title: '工具调用统计', columns: ['tool_name', 'success', 'retry_count', 'error_code', 'chapter', 'created_at'], domain: 'ops' },
|
||||
]
|
||||
|
||||
const FULL_DATA_DOMAINS = [
|
||||
{ id: 'overview', label: '总览' },
|
||||
{ id: 'core', label: '基础档案' },
|
||||
{ id: 'network', label: '关系与剧情' },
|
||||
{ id: 'quality', label: '质量审查' },
|
||||
{ id: 'ops', label: 'RAG 与工具' },
|
||||
]
|
||||
|
||||
|
||||
// ====================================================================
|
||||
// 页面 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 dashboard-section-card">
|
||||
<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">
|
||||
<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 className="strand-legend">
|
||||
<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 dashboard-section-card">
|
||||
<div className="card-header">
|
||||
<span className="card-title">⚠️ 待回收伏笔 (Top 20)</span>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<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>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<MergedDataView />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ====================================================================
|
||||
// 页面 2:设定词典
|
||||
// ====================================================================
|
||||
|
||||
function EntitiesPage() {
|
||||
const [entities, setEntities] = useState([])
|
||||
const [typeFilter, setTypeFilter] = useState('')
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [changes, setChanges] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchJSON('/api/entities').then(setEntities).catch(() => { })
|
||||
}, [])
|
||||
|
||||
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()
|
||||
const filteredEntities = typeFilter ? entities.filter(e => e.type === typeFilter) : entities
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h2>👤 设定词典</h2>
|
||||
<span className="card-badge badge-green">{filteredEntities.length} / {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 className="split-layout">
|
||||
<div className="split-main">
|
||||
<div className="card">
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>名称</th><th>类型</th><th>层级</th><th>首现</th><th>末现</th></tr></thead>
|
||||
<tbody>
|
||||
{filteredEntities.map(e => (
|
||||
<tr
|
||||
key={e.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`entity-row ${selected?.id === e.id ? 'selected' : ''}`}
|
||||
onKeyDown={evt => (evt.key === 'Enter' || evt.key === ' ') && (evt.preventDefault(), setSelected(e))}
|
||||
onClick={() => setSelected(e)}
|
||||
>
|
||||
<td className={e.is_protagonist ? 'entity-name protagonist' : 'entity-name'}>
|
||||
{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>
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className="split-side">
|
||||
<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 className="entity-detail">
|
||||
<p><strong>类型:</strong>{selected.type}</p>
|
||||
<p><strong>ID:</strong><code>{selected.id}</code></p>
|
||||
{selected.desc && <p className="entity-desc">{selected.desc}</p>}
|
||||
{selected.current_json && (
|
||||
<div className="entity-current-block">
|
||||
<strong>当前状态:</strong>
|
||||
<pre className="entity-json">
|
||||
{formatJSON(selected.current_json)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{changes.length > 0 ? (
|
||||
<div className="entity-history">
|
||||
<div className="card-title">状态变化历史</div>
|
||||
<div className="table-wrap">
|
||||
<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>{c.old_value} → {c.new_value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</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 graph-shell">
|
||||
<ForceGraph3D
|
||||
graphData={graphData}
|
||||
nodeLabel="name"
|
||||
nodeColor="color"
|
||||
nodeRelSize={6}
|
||||
linkColor={() => 'rgba(127, 90, 240, 0.35)'}
|
||||
linkWidth={1}
|
||||
linkDirectionalParticles={2}
|
||||
linkDirectionalParticleWidth={1.5}
|
||||
linkDirectionalParticleSpeed={d => 0.005 + Math.random() * 0.005}
|
||||
backgroundColor="#fffaf0"
|
||||
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">
|
||||
<div className="table-wrap">
|
||||
<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 className="chapter-no">第 {c.chapter} 章</td>
|
||||
<td>{c.title || '—'}</td>
|
||||
<td>{formatNumber(c.word_count || 0)}</td>
|
||||
<td>{c.location || '—'}</td>
|
||||
<td className="truncate chapter-characters">{c.characters || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{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])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPath) return
|
||||
const first = findFirstFilePath(tree)
|
||||
if (first) setSelectedPath(first)
|
||||
}, [tree, selectedPath])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h2>📁 文档浏览</h2>
|
||||
</div>
|
||||
<div className="file-layout">
|
||||
<div className="file-tree-pane">
|
||||
{Object.entries(tree).map(([folder, items]) => (
|
||||
<div key={folder} className="folder-block">
|
||||
<div className="folder-title">📂 {folder}</div>
|
||||
<ul className="file-tree">
|
||||
<TreeNodes items={items} selected={selectedPath} onSelect={setSelectedPath} />
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="file-content-pane">
|
||||
{selectedPath ? (
|
||||
<div>
|
||||
<div className="selected-path">{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">
|
||||
<div className="table-wrap">
|
||||
<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 className="chapter-no">第 {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 className={r.debt_balance > 0 ? 'debt-positive' : 'debt-normal'}>{(r.debt_balance || 0).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{data.length === 0 ? <div className="empty-state"><div className="empty-icon">🔥</div><p>暂无追读力数据</p></div> : null}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function findFirstFilePath(tree) {
|
||||
const roots = Object.values(tree || {})
|
||||
for (const items of roots) {
|
||||
const p = walkFirstFile(items)
|
||||
if (p) return p
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function walkFirstFile(items) {
|
||||
if (!Array.isArray(items)) return null
|
||||
for (const item of items) {
|
||||
if (item?.type === 'file' && item?.path) return item.path
|
||||
if (item?.type === 'dir' && Array.isArray(item.children)) {
|
||||
const p = walkFirstFile(item.children)
|
||||
if (p) return p
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
// ====================================================================
|
||||
// 数据总览内嵌:全量数据视图
|
||||
// ====================================================================
|
||||
|
||||
function MergedDataView() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [payload, setPayload] = useState({})
|
||||
const [domain, setDomain] = useState('overview')
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
|
||||
async function loadAll() {
|
||||
setLoading(true)
|
||||
const requests = [
|
||||
['entities', fetchJSON('/api/entities')],
|
||||
['chapters', fetchJSON('/api/chapters')],
|
||||
['scenes', fetchJSON('/api/scenes', { limit: 200 })],
|
||||
['relationships', fetchJSON('/api/relationships', { limit: 300 })],
|
||||
['relationshipEvents', fetchJSON('/api/relationship-events', { limit: 200 })],
|
||||
['readingPower', fetchJSON('/api/reading-power', { limit: 100 })],
|
||||
['reviewMetrics', fetchJSON('/api/review-metrics', { limit: 50 })],
|
||||
['stateChanges', fetchJSON('/api/state-changes', { limit: 120 })],
|
||||
['aliases', fetchJSON('/api/aliases')],
|
||||
['overrides', fetchJSON('/api/overrides', { limit: 120 })],
|
||||
['debts', fetchJSON('/api/debts', { limit: 120 })],
|
||||
['debtEvents', fetchJSON('/api/debt-events', { limit: 150 })],
|
||||
['invalidFacts', fetchJSON('/api/invalid-facts', { limit: 120 })],
|
||||
['ragQueries', fetchJSON('/api/rag-queries', { limit: 150 })],
|
||||
['toolStats', fetchJSON('/api/tool-stats', { limit: 200 })],
|
||||
['checklistScores', fetchJSON('/api/checklist-scores', { limit: 120 })],
|
||||
]
|
||||
|
||||
const entries = await Promise.all(
|
||||
requests.map(async ([key, p]) => {
|
||||
try {
|
||||
const val = await p
|
||||
return [key, val]
|
||||
} catch {
|
||||
return [key, []]
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (!disposed) {
|
||||
setPayload(Object.fromEntries(entries))
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadAll()
|
||||
return () => { disposed = true }
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="loading">加载全量数据中…</div>
|
||||
|
||||
const groups = domain === 'overview'
|
||||
? FULL_DATA_GROUPS
|
||||
: FULL_DATA_GROUPS.filter(g => g.domain === domain)
|
||||
const totalRows = FULL_DATA_GROUPS.reduce((sum, g) => sum + (payload[g.key] || []).length, 0)
|
||||
const nonEmptyGroups = FULL_DATA_GROUPS.filter(g => (payload[g.key] || []).length > 0).length
|
||||
const maxChapter = FULL_DATA_GROUPS.reduce((max, g) => {
|
||||
const rows = payload[g.key] || []
|
||||
rows.slice(0, 120).forEach(r => {
|
||||
const c = extractChapter(r)
|
||||
if (c > max) max = c
|
||||
})
|
||||
return max
|
||||
}, 0)
|
||||
const domainStats = FULL_DATA_DOMAINS.filter(d => d.id !== 'overview').map(d => {
|
||||
const ds = FULL_DATA_GROUPS.filter(g => g.domain === d.id)
|
||||
const rowCount = ds.reduce((sum, g) => sum + (payload[g.key] || []).length, 0)
|
||||
const filled = ds.filter(g => (payload[g.key] || []).length > 0).length
|
||||
return { ...d, rowCount, filled, total: ds.length }
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header section-page-header">
|
||||
<h2>🧪 全量数据视图</h2>
|
||||
<span className="card-badge badge-cyan">{FULL_DATA_GROUPS.length} 类数据源</span>
|
||||
</div>
|
||||
|
||||
<div className="demo-summary-grid">
|
||||
<div className="card stat-card">
|
||||
<span className="stat-label">总记录数</span>
|
||||
<span className="stat-value">{formatNumber(totalRows)}</span>
|
||||
<span className="stat-sub">当前返回的全部数据行</span>
|
||||
</div>
|
||||
<div className="card stat-card">
|
||||
<span className="stat-label">已覆盖数据源</span>
|
||||
<span className="stat-value plain">{nonEmptyGroups}/{FULL_DATA_GROUPS.length}</span>
|
||||
<span className="stat-sub">有数据的表 / 总表数</span>
|
||||
</div>
|
||||
<div className="card stat-card">
|
||||
<span className="stat-label">最新章节触达</span>
|
||||
<span className="stat-value plain">{maxChapter > 0 ? `第 ${maxChapter} 章` : '—'}</span>
|
||||
<span className="stat-sub">按可识别 chapter 字段估算</span>
|
||||
</div>
|
||||
<div className="card stat-card">
|
||||
<span className="stat-label">当前视图</span>
|
||||
<span className="stat-value plain">{FULL_DATA_DOMAINS.find(d => d.id === domain)?.label}</span>
|
||||
<span className="stat-sub">{groups.length} 个数据分组</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="demo-domain-tabs">
|
||||
{FULL_DATA_DOMAINS.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`demo-domain-tab ${domain === item.id ? 'active' : ''}`}
|
||||
onClick={() => setDomain(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{domain === 'overview' ? (
|
||||
<div className="demo-domain-grid">
|
||||
{domainStats.map(ds => (
|
||||
<div className="card" key={ds.id}>
|
||||
<div className="card-header">
|
||||
<span className="card-title">{ds.label}</span>
|
||||
<span className="card-badge badge-purple">{ds.filled}/{ds.total}</span>
|
||||
</div>
|
||||
<div className="domain-stat-number">{formatNumber(ds.rowCount)}</div>
|
||||
<div className="stat-sub">该数据域总记录数</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{groups.map(g => {
|
||||
const count = (payload[g.key] || []).length
|
||||
return (
|
||||
<div className="card demo-group-card" key={g.key}>
|
||||
<div className="card-header">
|
||||
<span className="card-title">{g.title}</span>
|
||||
<span className={`card-badge ${count > 0 ? 'badge-blue' : 'badge-amber'}`}>{count} 条</span>
|
||||
</div>
|
||||
<MiniTable
|
||||
rows={payload[g.key] || []}
|
||||
columns={g.columns}
|
||||
pageSize={12}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MiniTable({ rows, columns, pageSize = 12 }) {
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [rows, columns, pageSize])
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
return <div className="empty-state compact"><p>暂无数据</p></div>
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize))
|
||||
const safePage = Math.min(page, totalPages)
|
||||
const start = (safePage - 1) * pageSize
|
||||
const list = rows.slice(start, start + pageSize)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>{columns.map(c => <th key={c}>{c}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((row, i) => (
|
||||
<tr key={i}>
|
||||
{columns.map(c => (
|
||||
<td key={c} className="truncate" style={{ maxWidth: 240 }}>
|
||||
{formatCell(row?.[c])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="table-pagination">
|
||||
<button
|
||||
className="page-btn"
|
||||
type="button"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={safePage <= 1}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="page-info">
|
||||
第 {safePage} / {totalPages} 页 · 共 {rows.length} 条
|
||||
</span>
|
||||
<button
|
||||
className="page-btn"
|
||||
type="button"
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={safePage >= totalPages}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function extractChapter(row) {
|
||||
if (!row || typeof row !== 'object') return 0
|
||||
const candidates = [
|
||||
row.chapter,
|
||||
row.start_chapter,
|
||||
row.end_chapter,
|
||||
row.chapter_discovered,
|
||||
row.first_appearance,
|
||||
row.last_appearance,
|
||||
]
|
||||
for (const c of candidates) {
|
||||
const n = Number(c)
|
||||
if (Number.isFinite(n) && n > 0) return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
// ====================================================================
|
||||
// 子组件:文件树递归
|
||||
// ====================================================================
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function formatCell(v) {
|
||||
if (v === null || v === undefined) return '—'
|
||||
if (typeof v === 'boolean') return v ? 'true' : 'false'
|
||||
if (typeof v === 'object') {
|
||||
try {
|
||||
return JSON.stringify(v)
|
||||
} catch {
|
||||
return String(v)
|
||||
}
|
||||
}
|
||||
const s = String(v)
|
||||
return s.length > 180 ? `${s.slice(0, 180)}...` : s
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Handoff_Diff_View.jsx - 状态交接人工审查 Commit 界面
|
||||
*
|
||||
* 特性:
|
||||
* - 展示 AI 生成的下一章 handoff.json Diff
|
||||
* - 支持人工微调
|
||||
* - Commit 后进入实际写作
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { fetchJSON } from './api.js'
|
||||
|
||||
export default function HandoffDiffView({ projectRoot, onCommit, onCancel }) {
|
||||
const [currentHandoff, setCurrentHandoff] = useState(null)
|
||||
const [proposedHandoff, setProposedHandoff] = useState(null)
|
||||
const [diff, setDiff] = useState(null)
|
||||
const [editedFrame, setEditedFrame] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [commitMessage, setCommitMessage] = useState('')
|
||||
|
||||
// 加载当前 Handoff Frame
|
||||
const loadHandoff = useCallback(async () => {
|
||||
try {
|
||||
// 实际应从 API 获取
|
||||
const current = await fetchJSON('/api/project/info')
|
||||
const handoff = current.handoff_frame || generateMockHandoff()
|
||||
|
||||
setCurrentHandoff(handoff)
|
||||
setProposedHandoff(handoff)
|
||||
setEditedFrame(handoff)
|
||||
setDiff(computeDiff(handoff, handoff))
|
||||
} catch (error) {
|
||||
console.error('Failed to load handoff:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadHandoff()
|
||||
}, [loadHandoff])
|
||||
|
||||
// 生成下一章的 Handoff 预测
|
||||
const generateNextHandoff = useCallback((current) => {
|
||||
return {
|
||||
...current,
|
||||
tick: current.tick + 1,
|
||||
chapter: (current.metadata?.chapter_number || 0) + 1,
|
||||
metadata: {
|
||||
...current.metadata,
|
||||
previous_chapter_summary: `第${current.metadata?.chapter_number || 1}章内容摘要`,
|
||||
next_chapter_hint: '根据当前状态自动生成'
|
||||
},
|
||||
world_state: {
|
||||
...current.world_state,
|
||||
tension_level: Math.max(0, (current.world_state?.tension_level || 50) - 20)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 计算 Diff
|
||||
const computeDiff = (current, proposed) => {
|
||||
if (!current || !proposed) return null
|
||||
|
||||
const changes = []
|
||||
const currentStr = JSON.stringify(current)
|
||||
const proposedStr = JSON.stringify(proposed)
|
||||
|
||||
if (currentStr !== proposedStr) {
|
||||
// 逐层比较
|
||||
const currentJson = current
|
||||
const proposedJson = proposed
|
||||
|
||||
// Active Characters
|
||||
if (JSON.stringify(currentJson.active_characters) !== JSON.stringify(proposedJson.active_characters)) {
|
||||
changes.push({
|
||||
section: 'active_characters',
|
||||
type: 'modified',
|
||||
before: currentJson.active_characters,
|
||||
after: proposedJson.active_characters
|
||||
})
|
||||
}
|
||||
|
||||
// World State
|
||||
if (JSON.stringify(currentJson.world_state) !== JSON.stringify(proposedJson.world_state)) {
|
||||
changes.push({
|
||||
section: 'world_state',
|
||||
type: 'modified',
|
||||
before: currentJson.world_state,
|
||||
after: proposedJson.world_state
|
||||
})
|
||||
}
|
||||
|
||||
// Metadata
|
||||
if (JSON.stringify(currentJson.metadata) !== JSON.stringify(proposedJson.metadata)) {
|
||||
changes.push({
|
||||
section: 'metadata',
|
||||
type: 'modified',
|
||||
before: currentJson.metadata,
|
||||
after: proposedJson.metadata
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
// 处理字段编辑
|
||||
const handleFieldEdit = (section, field, value) => {
|
||||
const updated = {
|
||||
...editedFrame,
|
||||
[section]: {
|
||||
...editedFrame[section],
|
||||
[field]: value
|
||||
}
|
||||
}
|
||||
setEditedFrame(updated)
|
||||
setDiff(computeDiff(currentHandoff, updated))
|
||||
}
|
||||
|
||||
// 处理 Commit
|
||||
const handleCommit = () => {
|
||||
if (!commitMessage.trim()) {
|
||||
alert('请输入 commit 消息')
|
||||
return
|
||||
}
|
||||
|
||||
const commitData = {
|
||||
handoff_frame: editedFrame,
|
||||
commit_message: commitMessage,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
|
||||
onCommit?.(commitData)
|
||||
}
|
||||
|
||||
// 生成模拟 Handoff
|
||||
const generateMockHandoff = () => ({
|
||||
tick: 1,
|
||||
timestamp: Date.now(),
|
||||
active_characters: [
|
||||
{ id: 'protagonist', name: '主角', status: 'active', position: { x: 0, y: 0, z: 0 } }
|
||||
],
|
||||
suspended_characters: [],
|
||||
imminent_actions: [],
|
||||
world_state: {
|
||||
location: '天云宗',
|
||||
time_of_day: '早晨',
|
||||
weather: '晴',
|
||||
tension_level: 50,
|
||||
hook_pressure: 30
|
||||
},
|
||||
metadata: {
|
||||
chapter_number: 1,
|
||||
scene_number: 1,
|
||||
previous_chapter_summary: '开篇设定',
|
||||
next_chapter_hint: '引入冲突'
|
||||
}
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">加载 Handoff 数据中...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="handoff-diff-view">
|
||||
<div className="diff-header">
|
||||
<h2>📋 Handoff 状态交接审查</h2>
|
||||
<p className="diff-subtitle">审查 AI 生成的下一章状态交接帧,确认后 Commit</p>
|
||||
</div>
|
||||
|
||||
<div className="diff-layout">
|
||||
{/* 左侧: 当前状态 */}
|
||||
<div className="diff-panel current">
|
||||
<h3>当前帧 (Tick {currentHandoff?.tick})</h3>
|
||||
<div className="diff-content">
|
||||
<HandoffPanel
|
||||
title="活跃角色"
|
||||
data={currentHandoff?.active_characters || []}
|
||||
type="characters"
|
||||
/>
|
||||
<HandoffPanel
|
||||
title="世界状态"
|
||||
data={currentHandoff?.world_state || {}}
|
||||
type="world_state"
|
||||
/>
|
||||
<HandoffPanel
|
||||
title="元数据"
|
||||
data={currentHandoff?.metadata || {}}
|
||||
type="metadata"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间: Diff */}
|
||||
<div className="diff-panel changes">
|
||||
<h3>变更内容</h3>
|
||||
<div className="diff-changes">
|
||||
{diff && diff.length > 0 ? (
|
||||
diff.map((change, idx) => (
|
||||
<div key={idx} className={`diff-item diff-${change.type}`}>
|
||||
<div className="diff-section">{change.section}</div>
|
||||
<div className="diff-values">
|
||||
<div className="diff-before">
|
||||
<span className="label">-</span>
|
||||
<pre>{JSON.stringify(change.before, null, 2)}</pre>
|
||||
</div>
|
||||
<div className="diff-after">
|
||||
<span className="label">+</span>
|
||||
<pre>{JSON.stringify(change.after, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="no-changes">无变更</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧: 提议状态 */}
|
||||
<div className="diff-panel proposed">
|
||||
<h3>提议帧 (Tick {(editedFrame?.tick || currentHandoff?.tick) + 1})</h3>
|
||||
<div className="diff-content">
|
||||
<HandoffEditablePanel
|
||||
title="活跃角色"
|
||||
data={editedFrame?.active_characters || []}
|
||||
type="characters"
|
||||
onEdit={(field, value) => handleFieldEdit('active_characters', field, value)}
|
||||
/>
|
||||
<HandoffEditablePanel
|
||||
title="世界状态"
|
||||
data={editedFrame?.world_state || {}}
|
||||
type="world_state"
|
||||
onEdit={(field, value) => handleFieldEdit('world_state', field, value)}
|
||||
/>
|
||||
<HandoffEditablePanel
|
||||
title="元数据"
|
||||
data={editedFrame?.metadata || {}}
|
||||
type="metadata"
|
||||
onEdit={(field, value) => handleFieldEdit('metadata', field, value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commit 控制区 */}
|
||||
<div className="commit-controls">
|
||||
<div className="commit-message">
|
||||
<label>Commit 消息:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
placeholder="描述本次交接的变更..."
|
||||
/>
|
||||
</div>
|
||||
<div className="commit-actions">
|
||||
<button className="btn-cancel" onClick={onCancel}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn-commit" onClick={handleCommit}>
|
||||
✓ Commit & 继续写作
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HandoffPanel({ title, data, type }) {
|
||||
return (
|
||||
<div className="handoff-section">
|
||||
<h4>{title}</h4>
|
||||
<pre className="handoff-data">
|
||||
{type === 'characters'
|
||||
? data.map(c => `${c.name} (${c.status})`).join(', ')
|
||||
: JSON.stringify(data, null, 2)
|
||||
}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HandoffEditablePanel({ title, data, type, onEdit }) {
|
||||
return (
|
||||
<div className="handoff-section editable">
|
||||
<h4>{title}</h4>
|
||||
{type === 'characters' ? (
|
||||
<div className="character-list">
|
||||
{data.map((char, idx) => (
|
||||
<div key={char.id || idx} className="character-item">
|
||||
<input
|
||||
type="text"
|
||||
value={char.name}
|
||||
onChange={(e) => onEdit(`${idx}.name`, e.target.value)}
|
||||
/>
|
||||
<select
|
||||
value={char.status}
|
||||
onChange={(e) => onEdit(`${idx}.status`, e.target.value)}
|
||||
>
|
||||
<option value="active">活跃</option>
|
||||
<option value="dormant">休眠</option>
|
||||
<option value="suspended">悬置</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="field-editors">
|
||||
{Object.entries(data).map(([field, value]) => (
|
||||
<div key={field} className="field-editor">
|
||||
<label>{field}:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={typeof value === 'object' ? JSON.stringify(value) : value}
|
||||
onChange={(e) => onEdit(field, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Meta_Narrative_Panel.jsx - 元叙事开关与黑天鹅注入控制台
|
||||
*
|
||||
* 特性:
|
||||
* - 第四面墙 Toggle: 开启后关闭物理连贯性校验
|
||||
* - 黑天鹅注入: 一键注入等价对冲债务或平账大纲
|
||||
* - Hook 池监控: 显示当前压强状态
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { fetchJSON } from './api.js'
|
||||
|
||||
export default function MetaNarrativePanel({ projectRoot, onToggle, onInject }) {
|
||||
const [fourthWallToggle, setFourthWallToggle] = useState(false)
|
||||
const [hookPressure, setHookPressure] = useState(0)
|
||||
const [debtBalance, setDebtBalance] = useState(0)
|
||||
const [blackSwanOptions, setBlackSwanOptions] = useState([])
|
||||
const [selectedSwan, setSelectedSwan] = useState(null)
|
||||
const [swanParams, setSwanParams] = useState({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// 加载 Hook 池状态
|
||||
const loadHookStatus = useCallback(async () => {
|
||||
try {
|
||||
const info = await fetchJSON('/api/project/info')
|
||||
const hooks = info.hooks_pool?.hooks || []
|
||||
const unresolvedCount = hooks.filter(h => !h.resolved).length
|
||||
const pressure = hooks.reduce((sum, h) => sum + (h.tension_weight || 0), 0)
|
||||
|
||||
setHookPressure(pressure)
|
||||
setDebtBalance(info.debt_balance || 0)
|
||||
|
||||
// 生成黑天鹅选项
|
||||
generateBlackSwanOptions(pressure, unresolvedCount)
|
||||
} catch (error) {
|
||||
console.error('Failed to load hook status:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadHookStatus()
|
||||
// 定期刷新
|
||||
const interval = setInterval(loadHookStatus, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [loadHookStatus])
|
||||
|
||||
// 生成黑天鹅注入选项
|
||||
const generateBlackSwanOptions = (pressure, unresolvedCount) => {
|
||||
const options = []
|
||||
|
||||
if (pressure > 80) {
|
||||
options.push({
|
||||
id: 'swan_release',
|
||||
label: '🕊️ 高压释放',
|
||||
description: '引入突发转折,大量释放压强',
|
||||
pressureImpact: -40,
|
||||
type: 'release'
|
||||
})
|
||||
}
|
||||
|
||||
if (unresolvedCount > 5) {
|
||||
options.push({
|
||||
id: 'swan_closure',
|
||||
label: '🔗 批量收束',
|
||||
description: '同时回收多个伏笔,认知闭合',
|
||||
pressureImpact: -20,
|
||||
type: 'closure'
|
||||
})
|
||||
}
|
||||
|
||||
if (debtBalance > 0) {
|
||||
options.push({
|
||||
id: 'swan_hedge',
|
||||
label: '⚖️ 对冲平账',
|
||||
description: '注入等价对冲债务,平衡账本',
|
||||
pressureImpact: 0,
|
||||
type: 'hedge'
|
||||
})
|
||||
}
|
||||
|
||||
options.push({
|
||||
id: 'swan_new_hook',
|
||||
label: '🎣 新增悬念',
|
||||
description: '引入全新悬念,增加压强',
|
||||
pressureImpact: 15,
|
||||
type: 'buildup'
|
||||
})
|
||||
|
||||
if (fourthWallToggle) {
|
||||
options.push({
|
||||
id: 'swan_meta',
|
||||
label: '🌀 元叙事突破',
|
||||
description: '打破第四面墙,引入反规则事件',
|
||||
pressureImpact: 30,
|
||||
type: 'meta',
|
||||
requiresFourthWall: true
|
||||
})
|
||||
}
|
||||
|
||||
setBlackSwanOptions(options)
|
||||
}
|
||||
|
||||
// 切换第四面墙
|
||||
const handleFourthWallToggle = () => {
|
||||
const newState = !fourthWallToggle
|
||||
setFourthWallToggle(newState)
|
||||
onToggle?.(newState)
|
||||
}
|
||||
|
||||
// 注入黑天鹅
|
||||
const handleInjectSwan = () => {
|
||||
if (!selectedSwan) {
|
||||
alert('请选择一个黑天鹅事件')
|
||||
return
|
||||
}
|
||||
|
||||
const swanConfig = {
|
||||
...selectedSwan,
|
||||
params: swanParams,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
onInject?.(swanConfig)
|
||||
}
|
||||
|
||||
// 获取压力颜色
|
||||
const getPressureColor = (pressure) => {
|
||||
if (pressure < 30) return 'var(--accent-green)'
|
||||
if (pressure < 60) return 'var(--accent-amber)'
|
||||
if (pressure < 80) return 'var(--accent-orange)'
|
||||
return 'var(--accent-red)'
|
||||
}
|
||||
|
||||
// 获取压力状态
|
||||
const getPressureStatus = (pressure) => {
|
||||
if (pressure < 30) return '低压 - 可积累'
|
||||
if (pressure < 60) return '中压 - 正常'
|
||||
if (pressure < 80) return '高压 - 警告'
|
||||
return '危险 - 急需释放'
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">加载中...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="meta-narrative-panel">
|
||||
<div className="panel-header">
|
||||
<h2>🌀 元叙事控制台</h2>
|
||||
<p className="panel-subtitle">第四面墙切换与黑天鹅事件注入</p>
|
||||
</div>
|
||||
|
||||
{/* 第四面墙 Toggle */}
|
||||
<div className="control-section fourth-wall">
|
||||
<div className="section-header">
|
||||
<h3>第四面墙 Toggle</h3>
|
||||
<label className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fourthWallToggle}
|
||||
onChange={handleFourthWallToggle}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="toggle-description">
|
||||
{fourthWallToggle ? (
|
||||
<p className="status-active">
|
||||
🟢 <strong>元叙事模式已激活</strong><br />
|
||||
<span>物理连贯性校验已关闭,允许"反规则"写法</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="status-inactive">
|
||||
⚪ <strong>正常叙事模式</strong><br />
|
||||
<span>物理连贯性校验正常执行</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="toggle-effects">
|
||||
<h4>开启后的效果:</h4>
|
||||
<ul>
|
||||
<li>✓ 关闭战力/境界一致性校验</li>
|
||||
<li>✓ 关闭时间线连续性检查</li>
|
||||
<li>✓ 允许梦境、意识流等反逻辑场景</li>
|
||||
<li>✓ 允许主角打破第四面墙</li>
|
||||
<li>⚠️ 可能产生世界观不一致风险</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hook 池监控 */}
|
||||
<div className="control-section hook-monitor">
|
||||
<div className="section-header">
|
||||
<h3>📊 Hook 池监控</h3>
|
||||
</div>
|
||||
<div className="pressure-display">
|
||||
<div className="pressure-gauge">
|
||||
<div
|
||||
className="pressure-fill"
|
||||
style={{
|
||||
width: `${Math.min(100, hookPressure)}%`,
|
||||
backgroundColor: getPressureColor(hookPressure)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="pressure-value">
|
||||
<span className="pressure-number" style={{ color: getPressureColor(hookPressure) }}>
|
||||
{hookPressure}
|
||||
</span>
|
||||
<span className="pressure-max">/ 100</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pressure-status" style={{ color: getPressureColor(hookPressure) }}>
|
||||
{getPressureStatus(hookPressure)}
|
||||
</div>
|
||||
<div className="debt-balance">
|
||||
<span>债务余额:</span>
|
||||
<span className={debtBalance > 0 ? 'debt-positive' : 'debt-normal'}>
|
||||
{debtBalance.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hookPressure > 80 && (
|
||||
<div className="pressure-warning">
|
||||
<span className="warning-icon">⚠️</span>
|
||||
<span>压强过高,建议立即注入黑天鹅事件释放</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 黑天鹅注入 */}
|
||||
<div className="control-section black-swan">
|
||||
<div className="section-header">
|
||||
<h3>🦢 黑天鹅事件注入</h3>
|
||||
</div>
|
||||
<p className="section-desc">
|
||||
点击下方选项注入突发事件,对冲债务或释放压强
|
||||
</p>
|
||||
|
||||
<div className="swan-options">
|
||||
{blackSwanOptions.map((option) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className={`swan-option ${selectedSwan?.id === option.id ? 'selected' : ''} ${option.requiresFourthWall && !fourthWallToggle ? 'disabled' : ''}`}
|
||||
onClick={() => {
|
||||
if (!option.requiresFourthWall || fourthWallToggle) {
|
||||
setSelectedSwan(option)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="swan-label">{option.label}</div>
|
||||
<div className="swan-desc">{option.description}</div>
|
||||
<div className="swan-impact">
|
||||
压强变化:{' '}
|
||||
<span className={option.pressureImpact < 0 ? 'negative' : option.pressureImpact > 0 ? 'positive' : ''}>
|
||||
{option.pressureImpact > 0 ? '+' : ''}{option.pressureImpact}
|
||||
</span>
|
||||
</div>
|
||||
{option.requiresFourthWall && !fourthWallToggle && (
|
||||
<div className="swan-requires">需要第四面墙开启</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedSwan && (
|
||||
<div className="swan-params">
|
||||
<h4>事件参数</h4>
|
||||
<div className="param-inputs">
|
||||
<div className="param-field">
|
||||
<label>事件名称:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={swanParams.name || ''}
|
||||
onChange={(e) => setSwanParams({ ...swanParams, name: e.target.value })}
|
||||
placeholder="输入事件名称"
|
||||
/>
|
||||
</div>
|
||||
<div className="param-field">
|
||||
<label>影响章节数:</label>
|
||||
<input
|
||||
type="number"
|
||||
value={swanParams.chapters || 1}
|
||||
onChange={(e) => setSwanParams({ ...swanParams, chapters: parseInt(e.target.value) })}
|
||||
min={1}
|
||||
max={10}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn-inject"
|
||||
onClick={handleInjectSwan}
|
||||
disabled={!selectedSwan}
|
||||
>
|
||||
🦢 注入黑天鹅事件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Retcon_Arbitrator.jsx - 设定修改冲突分析与补丁确认台
|
||||
*
|
||||
* 特性:
|
||||
* - 接收作者的设定修改需求
|
||||
* - 分析与现有账本/Hook池的冲突
|
||||
* - 生成兼容性补丁方案
|
||||
* - 人类确认后应用热补丁
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { fetchJSON } from './api.js'
|
||||
|
||||
export default function RetconArbitrator({ projectRoot, onApply, onCancel }) {
|
||||
const [authorIntent, setAuthorIntent] = useState('')
|
||||
const [analysisResult, setAnalysisResult] = useState(null)
|
||||
const [patchProposal, setPatchProposal] = useState(null)
|
||||
const [selectedOperations, setSelectedOperations] = useState({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// 分析 Retcon 兼容性
|
||||
const analyzeRetcon = useCallback(async () => {
|
||||
if (!authorIntent.trim()) {
|
||||
alert('请输入设定修改需求')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
// 模拟 API 调用
|
||||
const result = await mockAnalyzeRetcon(authorIntent)
|
||||
setAnalysisResult(result)
|
||||
|
||||
if (result.conflicts.length > 0) {
|
||||
setPatchProposal(result.patch)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Analysis failed:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [authorIntent])
|
||||
|
||||
// 应用补丁
|
||||
const applyPatch = useCallback(() => {
|
||||
const selectedOps = Object.entries(selectedOperations)
|
||||
.filter(([_, selected]) => selected)
|
||||
.map(([opId, _]) => patchProposal.operations.find(op => op.id === opId))
|
||||
|
||||
const patch = {
|
||||
...patchProposal,
|
||||
operations: selectedOps
|
||||
}
|
||||
|
||||
onApply?.(patch)
|
||||
}, [selectedOperations, patchProposal, onApply])
|
||||
|
||||
// 切换操作选择
|
||||
const toggleOperation = (opId) => {
|
||||
setSelectedOperations(prev => ({
|
||||
...prev,
|
||||
[opId]: !prev[opId]
|
||||
}))
|
||||
}
|
||||
|
||||
// 模拟分析结果
|
||||
const mockAnalyzeRetcon = (intent) => {
|
||||
// 简单模拟:当输入包含特定关键词时触发冲突
|
||||
const conflicts = []
|
||||
const patch = {
|
||||
id: `retcon_${Date.now()}`,
|
||||
generated_at: Date.now(),
|
||||
operations: [],
|
||||
compatibility_issues: []
|
||||
}
|
||||
|
||||
if (intent.includes('克苏鲁') || intent.includes('邪神')) {
|
||||
conflicts.push({
|
||||
element_id: 'asset_haoqi_sword',
|
||||
element_name: '浩然正气剑',
|
||||
element_type: 'asset',
|
||||
original_value: '正道至宝',
|
||||
proposed_change: '与克苏鲁设定冲突',
|
||||
compatibility_score: 25,
|
||||
resolution: '将浩然正气剑转化为上古邪神骨殖的伪装'
|
||||
})
|
||||
|
||||
patch.operations.push({
|
||||
id: 'op_1',
|
||||
type: 'relabel',
|
||||
target_type: 'asset',
|
||||
target_id: 'asset_haoqi_sword',
|
||||
old_value: { name: '浩然正气剑', type: 'skill', value: 80 },
|
||||
new_value: { name: '邪神骨殖剑', type: 'skill', value: 80, tags: ['corrupted', 'dangerous'] },
|
||||
description: '将正道之物转化为邪神相关设定'
|
||||
})
|
||||
|
||||
patch.operations.push({
|
||||
id: 'op_2',
|
||||
type: 'create',
|
||||
target_type: 'liability',
|
||||
target_id: 'liab_haoqi_curse',
|
||||
old_value: null,
|
||||
new_value: {
|
||||
id: 'liab_haoqi_curse',
|
||||
type: 'unresolved_conflict',
|
||||
name: '浩然正气反噬',
|
||||
description: '正气剑被邪神侵蚀后的反噬',
|
||||
severity: 60
|
||||
},
|
||||
description: '创建反噬负债'
|
||||
})
|
||||
|
||||
patch.compatibility_issues.push('资产"浩然正气剑"被标记为不兼容,转化为对冲负债')
|
||||
}
|
||||
|
||||
if (intent.includes('穿越') || intent.includes('异世界')) {
|
||||
conflicts.push({
|
||||
element_id: 'world_rule_cultivation',
|
||||
element_name: '修仙世界观',
|
||||
element_type: 'world_rule',
|
||||
original_value: '传统修仙',
|
||||
proposed_change: '引入穿越元素',
|
||||
compatibility_score: 60,
|
||||
resolution: '将穿越者设定为域外天魔'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
author_intent: intent,
|
||||
conflicts,
|
||||
patch,
|
||||
compatibility_score: conflicts.length > 0
|
||||
? Math.min(...conflicts.map(c => c.compatibility_score))
|
||||
: 100
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="retcon-arbitrator">
|
||||
<div className="arbi-header">
|
||||
<h2>🔧 Retcon 仲裁台</h2>
|
||||
<p className="arbi-subtitle">
|
||||
输入设定修改需求,系统分析冲突并生成补丁方案
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 输入区 */}
|
||||
<div className="arbi-input-section">
|
||||
<label>设定修改需求:</label>
|
||||
<textarea
|
||||
value={authorIntent}
|
||||
onChange={(e) => setAuthorIntent(e.target.value)}
|
||||
placeholder="描述你想要修改的设定,例如: - 将修仙世界观转为克苏鲁修仙 - 主角穿越到异世界 - 移除某个配角的反派设定"
|
||||
rows={5}
|
||||
/>
|
||||
<button
|
||||
className="btn-analyze"
|
||||
onClick={analyzeRetcon}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '分析中...' : '⚡ 分析冲突'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 分析结果 */}
|
||||
{analysisResult && (
|
||||
<div className="arbi-analysis">
|
||||
<div className="analysis-summary">
|
||||
<div className={`compatibility-badge ${analysisResult.compatibility_score >= 70 ? 'high' : analysisResult.compatibility_score >= 40 ? 'medium' : 'low'}`}>
|
||||
兼容性: {analysisResult.compatibility_score}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 冲突列表 */}
|
||||
{analysisResult.conflicts.length > 0 && (
|
||||
<div className="conflicts-section">
|
||||
<h3>⚠️ 发现 {analysisResult.conflicts.length} 个冲突</h3>
|
||||
<div className="conflicts-list">
|
||||
{analysisResult.conflicts.map((conflict, idx) => (
|
||||
<div key={idx} className="conflict-card">
|
||||
<div className="conflict-header">
|
||||
<span className="element-name">{conflict.element_name}</span>
|
||||
<span className={`compatibility-score ${conflict.compatibility_score >= 70 ? 'high' : conflict.compatibility_score >= 40 ? 'medium' : 'low'}`}>
|
||||
{conflict.compatibility_score}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="conflict-body">
|
||||
<p><strong>原值:</strong> {conflict.original_value}</p>
|
||||
<p><strong>变更:</strong> {conflict.proposed_change}</p>
|
||||
<p><strong>建议:</strong> {conflict.resolution}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysisResult.conflicts.length === 0 && (
|
||||
<div className="no-conflicts">
|
||||
<p>✓ 未发现冲突,可以直接应用变更</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 补丁方案 */}
|
||||
{patchProposal && patchProposal.operations.length > 0 && (
|
||||
<div className="patch-section">
|
||||
<h3>📦 补丁方案</h3>
|
||||
<div className="operations-list">
|
||||
{patchProposal.operations.map((op) => (
|
||||
<div
|
||||
key={op.id}
|
||||
className={`operation-card ${selectedOperations[op.id] ? 'selected' : ''}`}
|
||||
onClick={() => toggleOperation(op.id)}
|
||||
>
|
||||
<div className="op-header">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!selectedOperations[op.id]}
|
||||
onChange={() => toggleOperation(op.id)}
|
||||
/>
|
||||
<span className="op-type">{op.type}</span>
|
||||
<span className="op-target">{op.target_type}: {op.target_id}</span>
|
||||
</div>
|
||||
<div className="op-body">
|
||||
<p className="op-description">{op.description}</p>
|
||||
<div className="op-values">
|
||||
<div className="op-old">
|
||||
<span>旧值:</span>
|
||||
<pre>{JSON.stringify(op.old_value, null, 2)}</pre>
|
||||
</div>
|
||||
<div className="op-new">
|
||||
<span>新值:</span>
|
||||
<pre>{JSON.stringify(op.new_value, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 控制区 */}
|
||||
<div className="arbi-controls">
|
||||
<button className="btn-cancel" onClick={onCancel}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="btn-apply"
|
||||
onClick={applyPatch}
|
||||
disabled={!patchProposal || Object.values(selectedOperations).every(v => !v)}
|
||||
>
|
||||
✓ 应用选定补丁
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 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 时回调
|
||||
* @param {{onOpen?: function, onError?: function}} handlers 连接状态回调
|
||||
* @returns {function} 取消订阅函数
|
||||
*/
|
||||
export function subscribeSSE(onMessage, handlers = {}) {
|
||||
const { onOpen, onError } = handlers
|
||||
const es = new EventSource(`${BASE}/api/events`);
|
||||
es.onopen = () => {
|
||||
if (onOpen) onOpen()
|
||||
};
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
onMessage(JSON.parse(e.data));
|
||||
} catch { /* ignore parse errors */ }
|
||||
};
|
||||
es.onerror = (e) => {
|
||||
// EventSource 会自动重连,这里只更新连接状态
|
||||
if (onError) onError(e)
|
||||
};
|
||||
return () => es.close();
|
||||
}
|
||||
@@ -0,0 +1,743 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Noto+Sans+SC:wght@400;500;700&display=swap');
|
||||
|
||||
:root {
|
||||
--bg-main: #fff7e8;
|
||||
--bg-panel: #fffdf6;
|
||||
--bg-card: #fffaf0;
|
||||
--bg-card-2: #fff3d5;
|
||||
--text-main: #2a220f;
|
||||
--text-sub: #5d5035;
|
||||
--text-mute: #8f7f5c;
|
||||
--accent-blue: #26a8ff;
|
||||
--accent-purple: #7f5af0;
|
||||
--accent-green: #2ec27e;
|
||||
--accent-amber: #f5a524;
|
||||
--accent-red: #d7263d;
|
||||
--accent-cyan: #00b8d4;
|
||||
--border-main: #2a220f;
|
||||
--border-soft: #8f7f5c;
|
||||
--shadow-main: 6px 6px 0 #2a220f;
|
||||
--shadow-soft: 3px 3px 0 #8f7f5c;
|
||||
--font-display: 'Press Start 2P', monospace;
|
||||
--font-body: 'Noto Sans SC', 'Microsoft YaHei', 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: 3px dashed var(--accent-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
color: var(--text-main);
|
||||
background-color: var(--bg-main);
|
||||
background-image:
|
||||
linear-gradient(90deg, rgba(42, 34, 15, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(rgba(42, 34, 15, 0.05) 1px, transparent 1px);
|
||||
background-size: 14px 14px;
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: 3px solid var(--border-main);
|
||||
background: linear-gradient(180deg, #ffe8b8 0%, #ffe19f 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px;
|
||||
border-bottom: 3px solid var(--border-main);
|
||||
}
|
||||
|
||||
.sidebar-header h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.sidebar-header .subtitle {
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-sub);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
width: 100%;
|
||||
border: 2px solid var(--border-main);
|
||||
background: #fff9e8;
|
||||
color: var(--text-main);
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-soft);
|
||||
transition: transform 0.08s ease;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
transform: translate(-1px, -1px);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #dff3ff;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.nav-item .icon {
|
||||
width: 22px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.live-indicator {
|
||||
border-top: 3px solid var(--border-main);
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--accent-green);
|
||||
border: 2px solid var(--border-main);
|
||||
}
|
||||
|
||||
.live-dot.disconnected {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.section-page-header {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 3px solid var(--border-main);
|
||||
box-shadow: var(--shadow-main);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.card-badge {
|
||||
border: 2px solid var(--border-main);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 3px 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.badge-blue { background: #dff3ff; color: #055d8b; }
|
||||
.badge-green { background: #dcfce7; color: #0f5132; }
|
||||
.badge-amber { background: #fff1cd; color: #8a5b00; }
|
||||
.badge-red { background: #ffe0e5; color: #8f1d30; }
|
||||
.badge-purple { background: #ece3ff; color: #4a2ea8; }
|
||||
.badge-cyan { background: #dcfafe; color: #155e75; }
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.stat-card .stat-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
.stat-card .stat-value {
|
||||
font-size: 28px;
|
||||
line-height: 1.15;
|
||||
margin: 6px 0 2px;
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.stat-card .stat-value.plain {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-sub);
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
margin-top: 8px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--border-main);
|
||||
background: #f8e3b8;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #26a8ff, #7f5af0);
|
||||
}
|
||||
|
||||
.dashboard-section-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.strand-bar {
|
||||
height: 12px;
|
||||
border: 2px solid var(--border-main);
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.strand-bar .segment { height: 100%; }
|
||||
.strand-quest { background: #26a8ff; }
|
||||
.strand-fire { background: #ff5c8a; }
|
||||
.strand-constellation { background: #7f5af0; }
|
||||
|
||||
.strand-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-sub);
|
||||
}
|
||||
|
||||
.demo-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.demo-domain-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.demo-domain-tab {
|
||||
border: 2px solid var(--border-main);
|
||||
background: #fff8e6;
|
||||
color: var(--text-main);
|
||||
padding: 6px 10px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.demo-domain-tab.active {
|
||||
background: #dff3ff;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.demo-domain-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.domain-stat-number {
|
||||
font-size: 30px;
|
||||
color: var(--accent-purple);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.demo-group-card {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 2px solid var(--border-soft);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
min-width: 580px;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
font-family: var(--font-body);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 2px solid var(--border-soft);
|
||||
background: var(--bg-card-2);
|
||||
white-space: nowrap;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #d8ccb2;
|
||||
color: var(--text-main);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover td {
|
||||
background: #fff4d8;
|
||||
}
|
||||
|
||||
.table-foot-note {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.table-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
border: 2px solid var(--border-main);
|
||||
background: #fff8e6;
|
||||
color: var(--text-main);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.page-btn:hover:not(:disabled) {
|
||||
background: #e6f7ff;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-sub);
|
||||
}
|
||||
|
||||
.split-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.split-main,
|
||||
.split-side {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entity-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.entity-row.selected td {
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
.entity-name {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.entity-name.protagonist {
|
||||
color: #b86a00;
|
||||
}
|
||||
|
||||
.entity-detail {
|
||||
font-size: 14px;
|
||||
color: var(--text-sub);
|
||||
line-height: 1.7;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.entity-detail code {
|
||||
border: 1px solid var(--border-soft);
|
||||
padding: 1px 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.entity-desc {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.entity-current-block {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.entity-json {
|
||||
margin-top: 6px;
|
||||
border: 2px solid var(--border-soft);
|
||||
background: #fff;
|
||||
padding: 8px;
|
||||
max-height: 190px;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
|
||||
}
|
||||
|
||||
.entity-history {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.graph-shell {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
height: calc(100vh - 120px);
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.chapter-no {
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chapter-characters {
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.file-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
height: calc(100vh - 130px);
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
.file-tree-pane {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border: 3px solid var(--border-main);
|
||||
box-shadow: var(--shadow-soft);
|
||||
background: #fffcf5;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.file-content-pane {
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
border: 3px solid var(--border-main);
|
||||
box-shadow: var(--shadow-soft);
|
||||
background: #fffcf5;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-content-pane > div {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.folder-block {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.folder-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.selected-path {
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-mute);
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.file-tree {
|
||||
list-style: none;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.tree-item {
|
||||
border: 2px solid transparent;
|
||||
padding: 6px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tree-item:hover {
|
||||
background: #fff4d8;
|
||||
border-color: #e0c98d;
|
||||
}
|
||||
|
||||
.tree-item.active {
|
||||
background: #e6f7ff;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.tree-icon {
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tree-children {
|
||||
list-style: none;
|
||||
margin-left: 12px;
|
||||
padding-left: 8px;
|
||||
border-left: 2px dashed #d8ccb2;
|
||||
}
|
||||
|
||||
.file-preview {
|
||||
border: 2px solid var(--border-soft);
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.75;
|
||||
word-break: break-word;
|
||||
font-size: 14px;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.debt-positive { color: var(--accent-red); font-weight: 700; }
|
||||
.debt-normal { color: var(--text-sub); }
|
||||
|
||||
.loading {
|
||||
border: 3px solid var(--border-main);
|
||||
background: #fff9e8;
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow-main);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px 14px;
|
||||
color: var(--text-sub);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.file-content-pane .empty-state {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-state.compact {
|
||||
padding: 20px 10px;
|
||||
}
|
||||
|
||||
.empty-state .empty-icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
border: 2px solid var(--border-main);
|
||||
background: #fff8e6;
|
||||
color: var(--text-main);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: #e6f7ff;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.split-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.graph-shell {
|
||||
height: calc(100vh - 128px);
|
||||
min-height: 460px;
|
||||
}
|
||||
|
||||
.file-layout {
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
min-height: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.app-layout {
|
||||
grid-template-columns: 84px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar-header h1,
|
||||
.sidebar-header .subtitle,
|
||||
.nav-item span:not(.icon),
|
||||
.live-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right-width: 2px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
justify-content: center;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.graph-shell {
|
||||
height: calc(100vh - 108px);
|
||||
min-height: 380px;
|
||||
}
|
||||
|
||||
.file-layout {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.file-tree-pane {
|
||||
height: 260px;
|
||||
border: 2px solid var(--border-soft);
|
||||
padding: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.file-content-pane {
|
||||
height: calc(100vh - 430px);
|
||||
min-height: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page-header h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.dashboard-grid,
|
||||
.demo-summary-grid,
|
||||
.demo-domain-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.demo-domain-tabs {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.demo-domain-tab {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.graph-shell {
|
||||
height: 58vh;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.file-content-pane {
|
||||
height: 58vh;
|
||||
min-height: 280px;
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
@@ -0,0 +1,12 @@
|
||||
# Noma Web Dashboard Dependencies
|
||||
|
||||
# Web 框架
|
||||
fastapi>=0.104.0
|
||||
uvicorn[standard]>=0.24.0
|
||||
python-multipart>=0.0.6
|
||||
|
||||
# 前端构建
|
||||
watchdog>=3.0.0
|
||||
|
||||
# 数据处理
|
||||
pydantic>=2.0.0
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Dashboard 启动脚本
|
||||
|
||||
用法:
|
||||
python -m web_dashboard.server --project-root /path/to/novel-project
|
||||
python -m web_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("NOMA_PROJECT_ROOT")
|
||||
if env:
|
||||
return Path(env).resolve()
|
||||
|
||||
# 尝试从 .claude 指针读取
|
||||
cwd = Path.cwd()
|
||||
pointer = cwd / ".claude" / ".noma-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 / ".noma" / "state.json").is_file():
|
||||
return p.resolve()
|
||||
|
||||
# 最终兜底:当前目录
|
||||
if (cwd / ".noma" / "state.json").is_file():
|
||||
return cwd.resolve()
|
||||
|
||||
print("ERROR: 无法定位 PROJECT_ROOT(需要包含 .noma/state.json 的目录)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Noma 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()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Watchdog 文件变更监听器 + SSE 推送
|
||||
|
||||
监控 PROJECT_ROOT/.noma/ 目录下 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 _NomaFileHandler(FileSystemEventHandler):
|
||||
"""仅关注 .noma/ 目录下关键文件的修改/创建事件。"""
|
||||
|
||||
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 = _NomaFileHandler(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
|
||||
Reference in New Issue
Block a user