feat: initial commit

This commit is contained in:
2026-06-23 20:29:02 +08:00
commit ea8f6066c4
217 changed files with 60754 additions and 0 deletions
@@ -0,0 +1 @@
# Noma Dashboard - 可视化小说管理面板
@@ -0,0 +1,4 @@
"""Allow running as `python -m web_dashboard`."""
from .server import main
main()
+717
View File
@@ -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>
</>
)
}
// ====================================================================
// 页面 33D 宇宙关系图谱
// ====================================================================
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="描述你想要修改的设定,例如:&#10;- 将修仙世界观转为克苏鲁修仙&#10;- 主角穿越到异世界&#10;- 移除某个配角的反派设定"
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
+71
View File
@@ -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_ROOTCLI > 环境变量 > .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()
+94
View File
@@ -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