codex-skill-analytics 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ import webbrowser
6
+ from datetime import UTC, datetime
7
+ from http import HTTPStatus
8
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
+ from pathlib import Path
10
+ from urllib.parse import parse_qs, urlparse
11
+
12
+ from .database import AnalyticsDB
13
+ from .graph import build_graph_data
14
+ from .sync import sync_history
15
+ from .web_templates import RELATIONS_PAGE, VOLUME_PAGE
16
+
17
+
18
+ def build_volume_data(db: AnalyticsDB, days: int) -> dict[str, object]:
19
+ return {
20
+ "meta": {"days": days, "generated_at": datetime.now(UTC).isoformat()},
21
+ "totals": dict(db.volume_totals(days)),
22
+ "scopes": [dict(row) for row in db.volume_by_scope(days)],
23
+ "summary": [dict(row) for row in db.summary(days)],
24
+ "trend": [dict(row) for row in db.trend(days)],
25
+ "events": [dict(row) for row in db.events(days, 80)],
26
+ }
27
+
28
+
29
+ def create_server(
30
+ db_path: Path,
31
+ codex_home: Path,
32
+ host: str = "127.0.0.1",
33
+ port: int = 8765,
34
+ ) -> ThreadingHTTPServer:
35
+ db_path = db_path.expanduser().resolve()
36
+ codex_home = codex_home.expanduser().resolve()
37
+ sync_lock = threading.Lock()
38
+
39
+ class DashboardHandler(BaseHTTPRequestHandler):
40
+ server_version = "CodexSkillAnalytics/0.1"
41
+
42
+ def _send_bytes(
43
+ self, body: bytes, content_type: str, status: HTTPStatus = HTTPStatus.OK
44
+ ) -> None:
45
+ self.send_response(status)
46
+ self.send_header("Content-Type", content_type)
47
+ self.send_header("Content-Length", str(len(body)))
48
+ self.send_header("Cache-Control", "no-store")
49
+ self.send_header("X-Content-Type-Options", "nosniff")
50
+ self.send_header("Referrer-Policy", "no-referrer")
51
+ self.send_header(
52
+ "Content-Security-Policy",
53
+ "default-src 'self'; style-src 'unsafe-inline'; "
54
+ "script-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; "
55
+ "object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
56
+ )
57
+ self.end_headers()
58
+ self.wfile.write(body)
59
+
60
+ def _send_json(self, value: object, status: HTTPStatus = HTTPStatus.OK) -> None:
61
+ body = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode()
62
+ self._send_bytes(body, "application/json; charset=utf-8", status)
63
+
64
+ def do_GET(self) -> None:
65
+ parsed = urlparse(self.path)
66
+ if parsed.path == "/":
67
+ self._send_bytes(VOLUME_PAGE.encode(), "text/html; charset=utf-8")
68
+ return
69
+ if parsed.path == "/relations":
70
+ self._send_bytes(RELATIONS_PAGE.encode(), "text/html; charset=utf-8")
71
+ return
72
+ if parsed.path == "/healthz":
73
+ self._send_json({"status": "ok"})
74
+ return
75
+ if parsed.path == "/api/volume":
76
+ try:
77
+ days = int(parse_qs(parsed.query).get("days", ["30"])[0])
78
+ except ValueError:
79
+ self._send_json({"error": "days 必须是整数"}, HTTPStatus.BAD_REQUEST)
80
+ return
81
+ days = min(36500, max(1, days))
82
+ with AnalyticsDB(db_path) as db:
83
+ self._send_json(build_volume_data(db, days))
84
+ return
85
+ if parsed.path == "/api/graph":
86
+ query = parse_qs(parsed.query)
87
+ try:
88
+ days = min(36500, max(1, int(query.get("days", ["30"])[0])))
89
+ min_weight = max(1, int(query.get("min_weight", ["1"])[0]))
90
+ limit = min(500, max(1, int(query.get("limit", ["100"])[0])))
91
+ except ValueError:
92
+ self._send_json(
93
+ {"error": "days、min_weight 和 limit 必须是整数"},
94
+ HTTPStatus.BAD_REQUEST,
95
+ )
96
+ return
97
+ relation_type = query.get("type", ["sequence"])[0]
98
+ if relation_type not in {"same-turn", "sequence", "next-in-thread"}:
99
+ self._send_json({"error": "未知关系类型"}, HTTPStatus.BAD_REQUEST)
100
+ return
101
+ with AnalyticsDB(db_path) as db:
102
+ self._send_json(
103
+ build_graph_data(
104
+ db,
105
+ days=days,
106
+ relation_type=relation_type,
107
+ min_weight=min_weight,
108
+ limit=limit,
109
+ )
110
+ )
111
+ return
112
+ self._send_json({"error": "not found"}, HTTPStatus.NOT_FOUND)
113
+
114
+ def do_POST(self) -> None:
115
+ if urlparse(self.path).path != "/api/sync":
116
+ self._send_json({"error": "not found"}, HTTPStatus.NOT_FOUND)
117
+ return
118
+ if self.headers.get("X-Codex-Skill-Analytics") != "1":
119
+ self._send_json({"error": "missing local request header"}, HTTPStatus.FORBIDDEN)
120
+ return
121
+ with sync_lock, AnalyticsDB(db_path) as db:
122
+ stats = sync_history(codex_home, db)
123
+ self._send_json({"status": "ok", "sync": stats.__dict__})
124
+
125
+ def log_message(self, format: str, *args: object) -> None:
126
+ del format, args
127
+
128
+ return ThreadingHTTPServer((host, port), DashboardHandler)
129
+
130
+
131
+ def serve_dashboard(
132
+ db_path: Path,
133
+ codex_home: Path,
134
+ host: str,
135
+ port: int,
136
+ open_browser: bool,
137
+ ) -> None:
138
+ server = create_server(db_path, codex_home, host, port)
139
+ actual_host, actual_port = server.server_address[:2]
140
+ browser_host = "127.0.0.1" if actual_host in {"0.0.0.0", "::"} else actual_host
141
+ url = f"http://{browser_host}:{actual_port}/"
142
+ print(f"Codex Skill 调用量 Web: {url}")
143
+ print("按 Ctrl-C 停止。")
144
+ if open_browser:
145
+ threading.Timer(0.35, webbrowser.open, args=(url,)).start()
146
+ try:
147
+ server.serve_forever()
148
+ except KeyboardInterrupt:
149
+ pass
150
+ finally:
151
+ server.server_close()
@@ -0,0 +1,61 @@
1
+ SHARED_CSS = r'''
2
+ :root{--canvas:#eef2f3;--surface:#fff;--surface-2:#f6f8f9;--ink:#17242a;--muted:#66777f;--line:#d4dde0;--line-strong:#adbdc3;--rail:#152228;--rail-2:#203139;--signal:#007b94;--signal-2:#005c70;--amber:#bd6728;--danger:#a63f3f;--green:#17766d;--violet:#7057a4;--repo:#3f659b;--focus:#00a2c4;--shadow:0 10px 28px rgba(23,36,42,.09)}
3
+ *{box-sizing:border-box}html,body{height:100%;margin:0;background:var(--canvas);color:var(--ink)}body{font-family:"Avenir Next","PingFang SC",sans-serif;font-size:13px;text-rendering:optimizeLegibility}button,input,select{font:inherit}button,a,input,select{outline:none}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{box-shadow:0 0 0 3px rgba(0,162,196,.25)}::selection{background:#bce8f0;color:#0f2c34}*{scrollbar-color:#9fb0b6 transparent;scrollbar-width:thin}
4
+ .app{height:100%;display:grid;grid-template-columns:228px minmax(0,1fr);overflow:hidden}.rail{background:var(--rail);color:#eaf1f3;display:flex;flex-direction:column;padding:24px 16px 18px;border-right:1px solid #0c171c}.brand{display:flex;align-items:center;gap:12px;padding:0 8px 22px;border-bottom:1px solid #34454d}.brand-mark{width:34px;height:34px;display:grid;place-items:center;border:1px solid #64808a;color:#8bd5e4;font:700 12px ui-monospace,SFMono-Regular,monospace;letter-spacing:.08em}.brand-copy strong{display:block;font:600 15px "DIN Alternate","Avenir Next Condensed",sans-serif;letter-spacing:.02em}.brand-copy span{display:block;color:#91a4ac;font-size:10px;margin-top:3px}.rail-nav{display:grid;gap:4px;margin-top:22px}.rail-nav a{display:grid;grid-template-columns:28px 1fr auto;align-items:center;min-height:42px;padding:0 10px;color:#aebdc3;text-decoration:none;border:1px solid transparent;border-radius:6px}.rail-nav a:hover{background:#1c2c33;color:#fff}.rail-nav a.active{background:var(--rail-2);color:#fff;border-color:#3b535d}.nav-index{font:600 9px ui-monospace,SFMono-Regular,monospace;color:#66818c}.rail-nav a.active .nav-index{color:#69c4d6}.nav-count{font:600 10px ui-monospace,SFMono-Regular,monospace;color:#7f949c}.rail-foot{margin-top:auto;padding:16px 9px 2px;border-top:1px solid #34454d;color:#91a4ac}.source-state{display:flex;align-items:center;gap:8px;color:#c6d3d7;font-size:11px;margin-bottom:7px}.state-dot{width:7px;height:7px;border-radius:50%;background:#49b7a5;box-shadow:0 0 0 3px rgba(73,183,165,.13)}.rail-foot code{display:block;font:9px/1.5 ui-monospace,SFMono-Regular,monospace;color:#72878f;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
5
+ .workspace{min-width:0;min-height:0;display:grid;grid-template-rows:72px 1fr}.commandbar{display:flex;align-items:center;justify-content:space-between;gap:22px;padding:0 24px;background:var(--surface);border-bottom:1px solid var(--line);z-index:4}.page-title{min-width:180px}.page-title h1{font:600 21px/1.1 "DIN Alternate","Avenir Next Condensed",sans-serif;letter-spacing:.01em;margin:0}.page-title p{margin:5px 0 0;color:var(--muted);font-size:10px}.tools{display:flex;align-items:flex-end;gap:10px;min-width:0}.field{display:grid;gap:4px}.field span{font:600 8px ui-monospace,SFMono-Regular,monospace;letter-spacing:.09em;text-transform:uppercase;color:var(--muted)}select,input[type=search],input[type=number]{height:34px;border:1px solid var(--line-strong);border-radius:5px;background:#fff;color:var(--ink);padding:0 9px}input[type=search]{width:205px}.action{height:34px;border:1px solid var(--signal-2);border-radius:5px;background:var(--signal-2);color:white;padding:0 13px;font-weight:600;font-size:11px;cursor:pointer}.action:hover{background:var(--signal);border-color:var(--signal)}.action:disabled{opacity:.55;cursor:wait}.main{min-height:0;overflow:auto;padding:18px 20px 30px}.metrics-strip{display:grid;grid-template-columns:repeat(5,minmax(120px,1fr));background:var(--surface);border:1px solid var(--line);margin-bottom:14px}.metric{padding:12px 15px;border-right:1px solid var(--line)}.metric:last-child{border-right:0}.metric-label{display:block;color:var(--muted);font-size:10px;margin-bottom:5px}.metric-value{font:650 23px/1 ui-monospace,SFMono-Regular,monospace;letter-spacing:-.03em;font-variant-numeric:tabular-nums}.metric-sub{display:block;color:#8a989d;font-size:9px;margin-top:5px}.panel{background:var(--surface);border:1px solid var(--line)}.panel-head{min-height:54px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:0 16px;border-bottom:1px solid var(--line)}.panel-title h2{font:600 14px/1.2 "DIN Alternate","Avenir Next Condensed",sans-serif;letter-spacing:.015em;margin:0}.panel-title p{font-size:9px;color:var(--muted);margin:4px 0 0}.panel-meta{font:10px ui-monospace,SFMono-Regular,monospace;color:var(--muted);font-variant-numeric:tabular-nums}.quiet-button{border:1px solid var(--line-strong);border-radius:4px;background:white;color:var(--signal-2);padding:5px 8px;font-size:9px;font-weight:600;cursor:pointer}.quiet-button:hover{background:#edf7f9}.status-line{min-height:14px;color:var(--muted);font:9px ui-monospace,SFMono-Regular,monospace;text-align:right}
6
+ .data-table{width:100%;border-collapse:collapse;font-size:11px}.data-table th{height:34px;text-align:left;padding:0 12px;background:var(--surface-2);border-bottom:1px solid var(--line);color:var(--muted);font:600 8px ui-monospace,SFMono-Regular,monospace;letter-spacing:.08em;text-transform:uppercase;position:sticky;top:0}.data-table td{height:37px;padding:0 12px;border-bottom:1px solid #e8edef;white-space:nowrap}.data-table tbody tr:hover{background:#f2f7f8}.scope-badge{display:inline-flex;align-items:center;gap:5px;color:var(--muted);font-size:9px}.scope-badge::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--scope-color,#7b898e)}.empty{display:grid;place-items:center;min-height:180px;color:var(--muted);font-size:11px;text-align:center;padding:24px}
7
+ @media(max-width:980px){.app{grid-template-columns:72px minmax(0,1fr)}.brand{padding-inline:2px;justify-content:center}.brand-copy,.rail-nav a span:not(.nav-index),.nav-count,.rail-foot code{display:none}.rail-nav a{grid-template-columns:1fr;justify-items:center}.tools{overflow-x:auto;padding-block:8px}.metrics-strip{grid-template-columns:repeat(3,1fr)}.metric:nth-child(3){border-right:0}.metric:nth-child(n+4){border-top:1px solid var(--line)}}
8
+ @media(max-width:680px){html,body{height:auto;min-height:100%;overflow-x:clip;overflow-y:visible}.app{height:auto;min-height:100%;display:block}.rail{height:auto;padding:10px 12px;display:flex;align-items:center;justify-content:space-between}.brand{border:0;padding:0}.brand-copy{display:none}.rail-nav{display:flex;margin:0;justify-content:flex-end;gap:4px}.rail-nav a{display:flex;gap:5px;min-height:36px;padding:0 10px;font-size:10px}.rail-nav a span:nth-child(2){display:block!important}.rail-nav .nav-index,.rail-nav .nav-count{display:none!important}.rail-foot{display:none}.workspace{display:block}.commandbar{display:grid;grid-template-columns:minmax(0,1fr);height:auto;padding:14px;gap:12px;justify-content:stretch}.page-title{min-width:0;width:100%}.tools{width:100%;max-width:100%;display:grid;grid-template-columns:minmax(0,1fr);align-items:end;overflow:visible}.field{min-width:0}.field select,.field input{width:100%;min-width:0;max-width:100%}.action{width:100%}.main{padding:12px;overflow:visible}.metrics-strip{grid-template-columns:repeat(2,1fr)}.metric{border-right:1px solid var(--line)!important;border-top:1px solid var(--line)}.metric:first-child,.metric:nth-child(2){border-top:0}.metric:nth-child(even){border-right:0!important}input[type=search]{width:100%}}
9
+ @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}
10
+ '''
11
+
12
+ VOLUME_DIRECTION = '''<!--
13
+ THESIS: 工程运行审计台拥有高密度、可下钻的本地证据视图,拒绝营销 Hero 与玩具式卡片堆叠。
14
+ OWN-WORLD: 深色固定工作区导航、冷白分析画布、细边界、青色信号与等宽数据共同构成可识别语言。
15
+ STORY: 用户先看调用规模,再比较全部 Skill 趋势,最后点击单个 Skill 核查证据。
16
+ FIRST VIEWPORT: 导航与筛选在上;紧凑指标条后立即出现主折线图和完整 Skill 图例;同步是唯一主动作。
17
+ FORM: Operate 工程运行审计台,assigned direction 5,seed 7d2ceb83。
18
+ FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance
19
+ -->'''
20
+
21
+ RELATIONS_DIRECTION = '''<!--
22
+ THESIS: 工程运行审计台把观察到的 Skill 关系变成可追溯网络,拒绝把共现误称为源码依赖。
23
+ OWN-WORLD: 深色固定工作区导航、冷白图谱画布、细边界、作用域色与等宽权重共同构成可识别语言。
24
+ STORY: 用户先筛选关系证据,再阅读网络结构,最后点击节点或排名核查流入流出。
25
+ FIRST VIEWPORT: 导航与四项筛选在上;指标条之后以关系网络为主;移动端保留可平移全尺寸画布。
26
+ FORM: Operate 工程运行审计台,assigned direction 5,seed 7d2ceb83。
27
+ FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance
28
+ -->'''
29
+
30
+
31
+ VOLUME_PAGE = r'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>调用数量 · Codex Skill Analytics</title><style>__SHARED_CSS__
32
+ .analysis-grid{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:14px;align-items:stretch}.chart-panel{min-width:0}.chart-wrap{height:430px;padding:12px 12px 6px 4px;position:relative}.chart-wrap svg{display:block;width:100%;height:100%;overflow:visible}.grid-line{stroke:#dfe6e8;stroke-width:1}.axis-label{font:9px ui-monospace,SFMono-Regular,monospace;fill:#75868d}.series-line{fill:none;stroke-width:1.45;stroke-linejoin:round;stroke-linecap:round;opacity:.28}.series-line.focused{opacity:1;stroke-width:3}.series-line.dimmed{opacity:.045}.series-hit{fill:none;stroke:transparent;stroke-width:12;cursor:pointer}.series-point{fill:#fff;stroke-width:2}.focus-summary{display:flex;align-items:center;gap:9px}.focus-dot{width:9px;height:9px;border-radius:50%;background:var(--focus-color,var(--signal))}.series-panel{min-width:0;display:grid;grid-template-rows:auto 1fr}.series-list{overflow:auto;max-height:430px}.series-row{width:100%;display:grid;grid-template-columns:16px minmax(0,1fr) 46px 46px;gap:8px;align-items:center;min-height:36px;padding:0 11px;border:0;border-bottom:1px solid #e8edef;background:white;color:inherit;text-align:left;cursor:pointer}.series-row:hover{background:#f0f6f7}.series-row.selected{background:#e1f0f3;box-shadow:inset 3px 0 0 var(--signal)}.series-swatch{width:12px;height:2px;background:var(--series-color)}.series-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10px}.series-count,.series-peak{text-align:right;font:600 9px ui-monospace,SFMono-Regular,monospace;color:var(--muted)}.series-head{display:grid;grid-template-columns:16px minmax(0,1fr) 46px 46px;gap:8px;padding:8px 11px;background:var(--surface-2);border-bottom:1px solid var(--line);color:var(--muted);font:600 8px ui-monospace,SFMono-Regular,monospace;text-transform:uppercase}.lower-grid{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(260px,.5fr);gap:14px;margin-top:14px}.events-wrap{max-height:330px;overflow:auto}.scope-list{padding:8px 15px 16px}.scope-row{display:grid;grid-template-columns:62px 1fr 42px;gap:10px;align-items:center;min-height:38px}.scope-name{font-size:10px}.scope-track{height:6px;background:#e5ebed}.scope-fill{display:block;height:100%}.scope-value{text-align:right;font:600 9px ui-monospace,SFMono-Regular,monospace}.scope-legend{display:grid;grid-template-columns:repeat(2,1fr);gap:7px 12px;padding:12px 15px;border-top:1px solid var(--line);font-size:9px;color:var(--muted)}
33
+ @media(max-width:1100px){.analysis-grid{grid-template-columns:1fr}.series-list{max-height:260px}.lower-grid{grid-template-columns:1fr}}@media(max-width:680px){.chart-wrap{height:340px}.series-row{grid-template-columns:14px minmax(0,1fr) 42px}.series-peak,.series-head span:last-child{display:none}.series-head{grid-template-columns:14px minmax(0,1fr) 42px}}
34
+ </style></head><body><div class="app"><aside class="rail"><div class="brand"><div class="brand-mark">CSA</div><div class="brand-copy"><strong>Skill Analytics</strong><span>Local evidence console</span></div></div><nav class="rail-nav"><a class="active" href="/"><span class="nav-index">01</span><span>调用数量</span><span class="nav-count" id="navSkills">—</span></a><a href="/relations"><span class="nav-index">02</span><span>调用关系</span><span class="nav-count">GRAPH</span></a></nav><div class="rail-foot"><div class="source-state"><i class="state-dot"></i>旁路数据库在线</div><code>~/.local/share/codex-skill-analytics</code></div></aside><section class="workspace"><header class="commandbar"><div class="page-title"><h1>调用数量</h1><p>比较全部 Skill 的调用强度与时间变化</p></div><div class="tools"><label class="field"><span>时间范围</span><select id="days"><option value="7">最近 7 天</option><option value="30" selected>最近 30 天</option><option value="90">最近 90 天</option><option value="365">最近 1 年</option><option value="3650">全部历史</option></select></label><label class="field"><span>作用域</span><select id="scope"><option value="all">全部</option></select></label><label class="field"><span>查找 Skill</span><input id="search" type="search" placeholder="名称或作用域"></label><button class="action" id="sync">同步历史</button></div></header><main class="main"><section class="metrics-strip"><div class="metric"><span class="metric-label">Invocation</span><strong class="metric-value" id="invocations">—</strong><span class="metric-sub">同轮同 Skill 去重</span></div><div class="metric"><span class="metric-label">Access 证据</span><strong class="metric-value" id="accesses">—</strong><span class="metric-sub">文档读取与脚本执行</span></div><div class="metric"><span class="metric-label">活跃 Skill</span><strong class="metric-value" id="skills">—</strong><span class="metric-sub">当前筛选范围</span></div><div class="metric"><span class="metric-label">涉及线程</span><strong class="metric-value" id="threads">—</strong><span class="metric-sub">去重 thread_id</span></div><div class="metric"><span class="metric-label">涉及轮次</span><strong class="metric-value" id="turns">—</strong><span class="metric-sub">去重 turn_id</span></div></section><section class="analysis-grid"><article class="panel chart-panel"><div class="panel-head"><div class="panel-title"><h2>全部 Skill 时间序列</h2><p>每条线代表一个 Skill;点击曲线或右侧列表聚焦</p></div><div class="focus-summary"><i class="focus-dot" id="focusDot"></i><span class="panel-meta" id="trendMeta">载入中</span><button class="quiet-button" id="clearFocus" hidden>返回全部</button></div></div><div class="chart-wrap"><svg id="trend" viewBox="0 0 980 400" role="img" aria-label="所有 Skill 的每日调用折线图"><g id="trendPlot"></g></svg></div></article><aside class="panel series-panel"><div><div class="panel-head"><div class="panel-title"><h2>Skill 图例</h2><p>完整列表,可点击下钻</p></div><span class="panel-meta" id="seriesCount">—</span></div><div class="series-head"><span></span><span>Skill</span><span>总量</span><span>峰值</span></div></div><div class="series-list" id="seriesList"></div></aside></section><section class="lower-grid"><article class="panel"><div class="panel-head"><div class="panel-title"><h2>最近调用事件</h2><p>只显示时间、Skill、作用域与确定性证据</p></div><span class="panel-meta" id="eventCount">—</span></div><div class="events-wrap"><table class="data-table"><thead><tr><th>时间</th><th>Skill</th><th>作用域</th><th>证据</th><th>线程</th></tr></thead><tbody id="events"></tbody></table></div></article><aside class="panel"><div class="panel-head"><div class="panel-title"><h2>作用域构成</h2><p>Invocation 占比</p></div></div><div class="scope-list" id="scopeBars"></div><div class="scope-legend"><span>system · 系统</span><span>user · 共享</span><span>plugin · 插件</span><span>repo · 项目</span></div></aside></section><div class="status-line" id="status"></div></main></section></div>
35
+ <script>
36
+ (()=>{'use strict';const scopeColors={system:'#7057a4',user:'#17766d',plugin:'#bd6728',repo:'#3f659b',unknown:'#77868c'};let data=null,selected=null;const $=id=>document.getElementById(id),fmt=n=>n==null?'—':new Intl.NumberFormat('zh-CN').format(n),esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])),keyOf=r=>`${r.scope}:${r.name??r.skill}`,hash=s=>{let h=2166136261;for(const c of s){h^=c.charCodeAt(0);h=Math.imul(h,16777619)}return h>>>0},skillColor=k=>`hsl(${hash(k)%360} 58% 40%)`;
37
+ async function load(){const days=$('days').value;$('status').textContent='正在读取 SQLite…';try{const r=await fetch(`/api/volume?days=${days}`,{cache:'no-store'});if(!r.ok)throw new Error(`HTTP ${r.status}`);data=await r.json();populateScopes();render();$('status').textContent=`数据时间 ${new Date(data.meta.generated_at).toLocaleString('zh-CN')}`}catch(e){$('status').textContent=`加载失败:${e.message}。确认本地 Web 仍在运行。`}}
38
+ function populateScopes(){const current=$('scope').value;$('scope').innerHTML=['<option value="all">全部</option>',...data.scopes.map(s=>`<option value="${esc(s.scope)}">${esc(s.scope)}</option>`)].join('');$('scope').value=[...$('scope').options].some(o=>o.value===current)?current:'all'}function filteredSummary(){const scope=$('scope').value,q=$('search').value.trim().toLowerCase();return data.summary.filter(r=>(scope==='all'||r.scope===scope)&&(!q||keyOf(r).toLowerCase().includes(q)))}function totalsFor(rows){const scope=$('scope').value;if(!$('search').value.trim()){if(scope==='all')return data.totals;return data.scopes.find(s=>s.scope===scope)||{}}return{invocations:rows.reduce((n,r)=>n+r.invocations,0),accesses:rows.reduce((n,r)=>n+r.accesses,0),skills:rows.length,threads:null,turns:null}}
39
+ function render(){const rows=filteredSummary(),keys=new Set(rows.map(keyOf));if(selected&&!keys.has(selected))selected=null;const totals=totalsFor(rows);for(const k of ['invocations','accesses','skills','threads','turns'])$(k).textContent=fmt(totals[k]);$('navSkills').textContent=rows.length;renderTrend(rows);renderSeries(rows);renderScopes();renderEvents()}
40
+ function makeSeries(rows){const allowed=new Map(rows.map(r=>[keyOf(r),r])),dates=[...new Set(data.trend.filter(r=>allowed.has(keyOf(r))).map(r=>r.day))].sort(),series=rows.map(r=>({key:keyOf(r),name:r.name,scope:r.scope,total:r.invocations,values:new Map(),color:skillColor(keyOf(r))}));const map=new Map(series.map(s=>[s.key,s]));data.trend.forEach(r=>{const s=map.get(keyOf(r));if(s)s.values.set(r.day,r.invocations)});series.forEach(s=>{s.points=dates.map(d=>s.values.get(d)||0);s.peak=Math.max(0,...s.points);s.active=s.points.filter(Boolean).length});return{dates,series}}
41
+ function renderTrend(rows){const {dates,series}=makeSeries(rows),plot=$('trendPlot'),W=980,H=400,p={l:42,r:18,t:18,b:31};if(!dates.length||!series.length){plot.innerHTML='<text x="490" y="200" text-anchor="middle" class="axis-label">当前筛选条件没有调用数据</text>';$('trendMeta').textContent='0 条曲线';return}const chosen=series.find(s=>s.key===selected),max=Math.max(1,...(chosen?[chosen.peak]:series.map(s=>s.peak))),x=i=>p.l+(dates.length===1?(W-p.l-p.r)/2:i*(W-p.l-p.r)/(dates.length-1)),y=v=>Math.max(p.t,Math.min(H-p.b,H-p.b-v*(H-p.t-p.b)/max));let html='';for(let i=0;i<=4;i++){const yy=p.t+i*(H-p.t-p.b)/4,val=Math.round(max*(4-i)/4);html+=`<line class="grid-line" x1="${p.l}" y1="${yy}" x2="${W-p.r}" y2="${yy}"/><text class="axis-label" x="${p.l-8}" y="${yy+3}" text-anchor="end">${val}</text>`}const ordered=[...series].sort((a,b)=>(a.key===selected)-(b.key===selected));ordered.forEach(s=>{const points=s.points.map((v,i)=>`${x(i)},${y(v)}`).join(' '),klass=selected?(s.key===selected?'series-line focused':'series-line dimmed'):'series-line';html+=`<polyline class="${klass}" data-key="${esc(s.key)}" points="${points}" style="stroke:${s.color}"><title>${esc(s.name)} · ${s.total} 次</title></polyline><polyline class="series-hit" data-key="${esc(s.key)}" points="${points}"><title>查看 ${esc(s.name)}</title></polyline>`;if(s.key===selected)s.points.forEach((v,i)=>html+=`<circle class="series-point" cx="${x(i)}" cy="${y(v)}" r="3" style="stroke:${s.color}"><title>${dates[i]} · ${v} 次</title></circle>`)});html+=`<text class="axis-label" x="${p.l}" y="${H-8}">${dates[0]}</text><text class="axis-label" x="${W-p.r}" y="${H-8}" text-anchor="end">${dates.at(-1)}</text>`;plot.innerHTML=html;plot.querySelectorAll('.series-hit').forEach(el=>el.addEventListener('click',()=>focus(el.dataset.key)));if(chosen){$('trendMeta').textContent=`${chosen.name} · ${chosen.total} 次 · 峰值 ${chosen.peak} · ${chosen.active} 活跃日`;$('focusDot').style.setProperty('--focus-color',chosen.color);$('clearFocus').hidden=false}else{$('trendMeta').textContent=`${series.length} 条曲线 · 单日峰值 ${max}`;$('focusDot').style.setProperty('--focus-color','#007b94');$('clearFocus').hidden=true}}
42
+ function renderSeries(rows){const {series}=makeSeries(rows),max=Math.max(1,...series.map(s=>s.total));$('seriesCount').textContent=`${series.length} SKILLS`;$('seriesList').innerHTML=series.map(s=>`<button class="series-row${s.key===selected?' selected':''}" data-key="${esc(s.key)}" title="查看 ${esc(s.key)} 的折线图"><i class="series-swatch" style="--series-color:${s.color}"></i><span class="series-name">${esc(s.name)}</span><span class="series-count">${s.total}</span><span class="series-peak">${s.peak}</span></button>`).join('')||'<div class="empty">没有匹配的 Skill</div>';$('seriesList').querySelectorAll('.series-row').forEach(el=>el.addEventListener('click',()=>focus(el.dataset.key)))}function focus(key){selected=selected===key?null:key;renderTrend(filteredSummary());renderSeries(filteredSummary())}
43
+ function renderScopes(){const max=Math.max(1,...data.scopes.map(s=>s.invocations));$('scopeBars').innerHTML=data.scopes.map(s=>`<div class="scope-row"><span class="scope-name">${esc(s.scope)}</span><span class="scope-track"><i class="scope-fill" style="width:${s.invocations/max*100}%;background:${scopeColors[s.scope]||scopeColors.unknown}"></i></span><span class="scope-value">${s.invocations}</span></div>`).join('')}function renderEvents(){const scope=$('scope').value,q=$('search').value.trim().toLowerCase(),rows=data.events.filter(e=>(scope==='all'||e.scope===scope)&&(!q||keyOf(e).toLowerCase().includes(q)));$('eventCount').textContent=`${rows.length} EVENTS`;$('events').innerHTML=rows.slice(0,50).map(e=>`<tr><td>${esc(new Date(e.invoked_at).toLocaleString('zh-CN'))}</td><td>${esc(e.skill)}</td><td><span class="scope-badge" style="--scope-color:${scopeColors[e.scope]||scopeColors.unknown}">${esc(e.scope)}</span></td><td>${esc(e.evidence)}</td><td>${esc(e.thread_id.slice(0,8))}…</td></tr>`).join('')||'<tr><td colspan="5"><div class="empty">没有匹配的调用事件</div></td></tr>'}
44
+ $('days').addEventListener('change',()=>{selected=null;load()});$('scope').addEventListener('change',()=>{selected=null;render()});$('search').addEventListener('input',()=>{selected=null;render()});$('clearFocus').addEventListener('click',()=>focus(selected));$('sync').addEventListener('click',async()=>{const b=$('sync');b.disabled=true;b.textContent='同步中';$('status').textContent='正在增量读取 Codex 历史…';try{const r=await fetch('/api/sync',{method:'POST',headers:{'X-Codex-Skill-Analytics':'1','Content-Type':'application/json'},body:'{}'});if(!r.ok)throw new Error(`HTTP ${r.status}`);const v=await r.json();$('status').textContent=`同步完成,新增 ${v.sync.invocations_added} 次调用`;await load()}catch(e){$('status').textContent=`同步失败:${e.message}`}finally{b.disabled=false;b.textContent='同步历史'}});load()})();
45
+ </script></body></html>'''.replace("__SHARED_CSS__", SHARED_CSS).replace("<body>", f"<body>{VOLUME_DIRECTION}", 1).replace('id="status"', 'id="status" role="status" aria-live="polite"')
46
+
47
+
48
+ RELATIONS_PAGE = r'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>调用关系 · Codex Skill Analytics</title><style>__SHARED_CSS__
49
+ .relation-metrics{grid-template-columns:repeat(4,1fr)}.relation-layout{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:14px;min-height:650px}.graph-panel{min-width:0;display:grid;grid-template-rows:auto 1fr}.graph-stage{min-height:590px;position:relative;overflow:hidden;background:#fbfcfc}.graph-stage svg{display:block;width:100%;height:100%;touch-action:none;cursor:grab}.edge{stroke:#8ba0a7;stroke-opacity:.45}.edge.active{stroke:var(--amber);stroke-opacity:1}.edge.dim{opacity:.045}.edge-label{fill:#61747b;font:600 9px ui-monospace,SFMono-Regular,monospace;paint-order:stroke;stroke:#fbfcfc;stroke-width:4px;pointer-events:none}.node{cursor:pointer}.node circle{stroke:#fff;stroke-width:3;filter:drop-shadow(0 3px 4px rgba(23,36,42,.16))}.node:hover circle,.node.selected circle,.node:focus-visible circle{stroke:var(--ink);stroke-width:4}.node:focus-visible{outline:none}.node.dim{opacity:.1}.node text{font:600 10px "Avenir Next","PingFang SC",sans-serif;fill:var(--ink);paint-order:stroke;stroke:#fbfcfc;stroke-width:4px;pointer-events:none}.node .tag{font:600 7px ui-monospace,SFMono-Regular,monospace;fill:#718188;letter-spacing:.08em}.graph-badge{position:absolute;left:12px;bottom:10px;padding:5px 7px;border:1px solid var(--line);background:#ffffffeb;font:9px ui-monospace,SFMono-Regular,monospace;color:var(--muted)}.inspectors{display:grid;grid-template-rows:auto minmax(0,1fr);gap:14px}.node-detail{padding:16px;min-height:210px}.node-detail h3{font:600 16px "DIN Alternate","Avenir Next Condensed",sans-serif;margin:0 0 4px;overflow-wrap:anywhere}.node-detail>p{font-size:9px;color:var(--muted);margin:0 0 16px}.detail-row{display:grid;grid-template-columns:1fr auto;padding:8px 0;border-top:1px solid #e3e9eb;font-size:10px}.detail-row b{font-family:ui-monospace,SFMono-Regular,monospace}.relation-list{overflow:auto;max-height:420px}.relation-row{width:100%;display:grid;grid-template-columns:minmax(0,1fr) 32px;gap:8px;padding:10px 12px;border:0;border-bottom:1px solid #e8edef;background:#fff;text-align:left;color:inherit;cursor:pointer}.relation-row:hover{background:#f0f6f7}.relation-pair{font-size:9px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.relation-weight{text-align:right;font:700 10px ui-monospace,SFMono-Regular,monospace}.arrow{color:var(--signal);margin:0 4px}.legend{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:7px 13px;align-items:center}.legend span{display:flex;align-items:center;gap:5px;font-size:8px;color:var(--muted)}.legend i{width:6px;height:6px;border-radius:50%;background:var(--legend-color)}
50
+ @media(max-width:1050px){.relation-layout{grid-template-columns:1fr}.inspectors{grid-template-columns:1fr 1fr;grid-template-rows:auto}.relation-list{max-height:280px}}@media(max-width:680px){.relation-metrics{grid-template-columns:repeat(2,1fr)}.graph-stage{height:540px;min-height:540px;overflow:auto}.graph-stage svg{width:960px;height:590px;max-width:none;touch-action:pan-x pan-y}.inspectors{grid-template-columns:1fr}.node-detail{min-height:112px;padding:14px}.node-detail>p{margin-bottom:6px}}
51
+ </style></head><body><div class="app"><aside class="rail"><div class="brand"><div class="brand-mark">CSA</div><div class="brand-copy"><strong>Skill Analytics</strong><span>Local evidence console</span></div></div><nav class="rail-nav"><a href="/"><span class="nav-index">01</span><span>调用数量</span><span class="nav-count">SERIES</span></a><a class="active" href="/relations"><span class="nav-index">02</span><span>调用关系</span><span class="nav-count" id="navEdges">—</span></a></nav><div class="rail-foot"><div class="source-state"><i class="state-dot"></i>旁路数据库在线</div><code>observed relationships only</code></div></aside><section class="workspace"><header class="commandbar"><div class="page-title"><h1>调用关系</h1><p>观察 Skill 的共现与调用衔接,不冒充源码依赖</p></div><div class="tools"><label class="field"><span>时间范围</span><select id="days"><option value="7">最近 7 天</option><option value="30" selected>最近 30 天</option><option value="90">最近 90 天</option><option value="365">最近 1 年</option><option value="3650">全部历史</option></select></label><label class="field"><span>关系类型</span><select id="type"><option value="sequence">同轮调用顺序</option><option value="same-turn">同轮共同调用</option><option value="next-in-thread">线程内相邻调用</option></select></label><label class="field"><span>最小权重</span><input id="weight" type="number" min="1" max="999" value="2"></label><label class="field"><span>查找 Skill</span><input id="search" type="search" placeholder="名称或作用域"></label></div></header><main class="main"><section class="metrics-strip relation-metrics"><div class="metric"><span class="metric-label">图谱节点</span><strong class="metric-value" id="nodeCount">—</strong><span class="metric-sub">当前关系涉及 Skill</span></div><div class="metric"><span class="metric-label">关系边</span><strong class="metric-value" id="edgeCount">—</strong><span class="metric-sub">通过权重筛选</span></div><div class="metric"><span class="metric-label">累计权重</span><strong class="metric-value" id="totalWeight">—</strong><span class="metric-sub">观察关系出现次数</span></div><div class="metric"><span class="metric-label">最高权重</span><strong class="metric-value" id="maxWeight">—</strong><span class="metric-sub">最频繁关系</span></div></section><section class="relation-layout"><article class="panel graph-panel"><div class="panel-head"><div class="panel-title"><h2>Skill 关系网络</h2><p>拖动节点,滚轮缩放;点击节点或右侧关系下钻</p></div><div class="legend" id="legend"></div></div><div class="graph-stage"><svg id="graph" viewBox="0 0 1060 650" role="img" aria-label="Skill 调用关系网络"><defs><marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0L10 5L0 10z" fill="#8ba0a7"/></marker></defs><g id="viewport"><g id="edges"></g><g id="edgeLabels"></g><g id="nodes"></g></g></svg><div class="graph-badge" id="graphBadge">载入中</div><div class="empty" id="graphEmpty" hidden>当前条件下没有关系。降低权重或扩大时间范围。</div></div></article><aside class="inspectors"><section class="panel"><div class="panel-head"><div class="panel-title"><h2>节点检查器</h2><p>调用量与关系流量</p></div></div><div class="node-detail" id="detail"><h3>尚未选择 Skill</h3><p>点击图中节点查看确定性统计。</p></div></section><section class="panel"><div class="panel-head"><div class="panel-title"><h2>关系排名</h2><p>按观察权重降序</p></div><span class="panel-meta" id="relationCount">—</span></div><div class="relation-list" id="relationList"></div></section></aside></section><div class="status-line" id="status"></div></main></section></div>
52
+ <script>
53
+ (()=>{'use strict';const colors={system:'#7057a4',user:'#17766d',plugin:'#bd6728',repo:'#3f659b',unknown:'#77868c'},$=id=>document.getElementById(id),esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));let nodes=[],edges=[],byId=new Map(),generation=0,transform={x:0,y:0,k:1};Object.entries(colors).forEach(([scope,color])=>$('legend').insertAdjacentHTML('beforeend',`<span><i style="--legend-color:${color}"></i>${scope}</span>`));
54
+ async function load(){const token=++generation,q=new URLSearchParams({days:$('days').value,type:$('type').value,min_weight:$('weight').value||'1',limit:'200'});$('status').textContent='正在计算关系…';try{const r=await fetch('/api/graph?'+q,{cache:'no-store'});if(!r.ok)throw new Error(`HTTP ${r.status}`);const data=await r.json();if(token!==generation)return;draw(data);$('status').textContent=`数据时间 ${new Date(data.meta.generated_at).toLocaleString('zh-CN')}`}catch(e){$('status').textContent=`加载失败:${e.message}`}}
55
+ function draw(data){$('edges').replaceChildren();$('edgeLabels').replaceChildren();$('nodes').replaceChildren();const directed=data.meta.directed,hash=s=>{let h=2166136261;for(const c of s){h^=c.charCodeAt(0);h=Math.imul(h,16777619)}return h>>>0};nodes=data.nodes.map((n,i)=>({...n,x:100+(hash(n.id)%850),y:60+((hash(n.id+'y')+i*79)%535),vx:0,vy:0,r:10+Math.sqrt(Math.max(1,n.invocations))*2.2}));byId=new Map(nodes.map(n=>[n.id,n]));edges=data.edges.map((e,i)=>({...e,index:i,a:byId.get(e.source),b:byId.get(e.target)})).filter(e=>e.a&&e.b);const total=edges.reduce((n,e)=>n+e.weight,0),max=Math.max(0,...edges.map(e=>e.weight));$('nodeCount').textContent=nodes.length;$('edgeCount').textContent=edges.length;$('totalWeight').textContent=total;$('maxWeight').textContent=max;$('navEdges').textContent=edges.length;$('graphBadge').textContent=`${nodes.length} nodes · ${edges.length} edges`;$('relationCount').textContent=`${edges.length} EDGES`;$('graphEmpty').hidden=edges.length>0;edges.forEach(e=>{const line=document.createElementNS('http://www.w3.org/2000/svg','line');line.classList.add('edge');line.style.strokeWidth=String(1+Math.sqrt(e.weight)*1.05);if(directed)line.setAttribute('marker-end','url(#arrow)');$('edges').append(line);e.el=line;const label=document.createElementNS('http://www.w3.org/2000/svg','text');label.classList.add('edge-label');label.textContent=e.weight;$('edgeLabels').append(label);e.label=label});nodes.forEach(n=>{const g=document.createElementNS('http://www.w3.org/2000/svg','g');g.classList.add('node');g.setAttribute('tabindex','0');g.setAttribute('role','button');g.setAttribute('aria-label',`${n.name},${n.scope},调用 ${n.invocations} 次`);const c=document.createElementNS('http://www.w3.org/2000/svg','circle');c.setAttribute('r',n.r);c.setAttribute('fill',colors[n.scope]||colors.unknown);const t=document.createElementNS('http://www.w3.org/2000/svg','text');t.setAttribute('text-anchor','middle');t.setAttribute('y',n.r+15);t.textContent=n.name.split(':').at(-1);const tag=document.createElementNS('http://www.w3.org/2000/svg','text');tag.classList.add('tag');tag.setAttribute('text-anchor','middle');tag.setAttribute('y',n.r+27);tag.textContent=n.scope;g.append(c,t,tag);$('nodes').append(g);n.el=g;g.addEventListener('click',()=>selectNode(n));g.addEventListener('keydown',ev=>{if(ev.key==='Enter'||ev.key===' '){ev.preventDefault();selectNode(n)}});bindDrag(g,n)});settle();renderRelations(data.meta.directed);filter()}
56
+ function settle(){for(let tick=0;tick<340;tick++){nodes.forEach(n=>{n.vx+=(530-n.x)*.0007;n.vy+=(315-n.y)*.0007});for(let i=0;i<nodes.length;i++)for(let j=i+1;j<nodes.length;j++){const a=nodes[i],b=nodes[j],dx=b.x-a.x,dy=b.y-a.y,d=Math.max(1,Math.hypot(dx,dy)),min=a.r+b.r+65,f=6400/(d*d)+(d<min?(min-d)*.019:0);a.vx-=dx/d*f;a.vy-=dy/d*f;b.vx+=dx/d*f;b.vy+=dy/d*f}edges.forEach(e=>{const dx=e.b.x-e.a.x,dy=e.b.y-e.a.y,d=Math.hypot(dx,dy)||1,f=(d-(125+e.a.r+e.b.r))*.0015*(1+Math.log1p(e.weight));e.a.vx+=dx/d*f;e.a.vy+=dy/d*f;e.b.vx-=dx/d*f;e.b.vy-=dy/d*f});nodes.forEach(n=>{n.vx*=.85;n.vy*=.85;n.x=Math.max(45,Math.min(1015,n.x+n.vx));n.y=Math.max(45,Math.min(585,n.y+n.vy))})}position()}
57
+ function position(){edges.forEach(e=>{const dx=e.b.x-e.a.x,dy=e.b.y-e.a.y,d=Math.hypot(dx,dy)||1,ux=dx/d,uy=dy/d;e.el.setAttribute('x1',e.a.x+ux*(e.a.r+3));e.el.setAttribute('y1',e.a.y+uy*(e.a.r+3));e.el.setAttribute('x2',e.b.x-ux*(e.b.r+8));e.el.setAttribute('y2',e.b.y-uy*(e.b.r+8));e.label.setAttribute('x',(e.a.x+e.b.x)/2+uy*8);e.label.setAttribute('y',(e.a.y+e.b.y)/2-ux*8)});nodes.forEach(n=>n.el.setAttribute('transform',`translate(${n.x},${n.y})`))}
58
+ function selectNode(n){nodes.forEach(x=>x.el.classList.toggle('selected',x===n));edges.forEach(e=>e.el.classList.toggle('active',e.a===n||e.b===n));const incoming=edges.filter(e=>e.b===n).reduce((v,e)=>v+e.weight,0),outgoing=edges.filter(e=>e.a===n).reduce((v,e)=>v+e.weight,0);$('detail').innerHTML=`<h3>${esc(n.name)}</h3><p>${esc(n.scope)} · ${esc(n.id)}</p><div class="detail-row"><span>Invocation</span><b>${n.invocations}</b></div><div class="detail-row"><span>Access 证据</span><b>${n.accesses}</b></div><div class="detail-row"><span>涉及线程</span><b>${n.threads}</b></div><div class="detail-row"><span>流入 / 流出</span><b>${incoming} / ${outgoing}</b></div>`}
59
+ function renderRelations(directed){$('relationList').innerHTML=edges.map(e=>`<button class="relation-row" data-index="${e.index}"><span class="relation-pair">${esc(e.a.name)} <i class="arrow">${directed?'→':'↔'}</i> ${esc(e.b.name)}</span><span class="relation-weight">${e.weight}</span></button>`).join('')||'<div class="empty">当前条件下没有关系</div>';$('relationList').querySelectorAll('.relation-row').forEach(el=>el.addEventListener('click',()=>{const e=edges[Number(el.dataset.index)];selectNode(e.a);e.el.classList.add('active')}))}function filter(){const q=$('search').value.trim().toLowerCase();nodes.forEach(n=>n.el.classList.toggle('dim',!!q&&!n.id.toLowerCase().includes(q)));edges.forEach(e=>e.el.classList.toggle('dim',!!q&&!e.source.toLowerCase().includes(q)&&!e.target.toLowerCase().includes(q)))}function bindDrag(el,n){let active=false;el.addEventListener('pointerdown',ev=>{active=true;el.setPointerCapture(ev.pointerId);ev.stopPropagation()});el.addEventListener('pointermove',ev=>{if(!active)return;const p=$('graph').createSVGPoint();p.x=ev.clientX;p.y=ev.clientY;const q=p.matrixTransform($('viewport').getScreenCTM().inverse());n.x=q.x;n.y=q.y;position()});el.addEventListener('pointerup',()=>active=false)}
60
+ for(const id of ['days','type'])$(id).addEventListener('change',load);$('weight').addEventListener('change',load);$('search').addEventListener('input',filter);const svg=$('graph'),viewport=$('viewport');function tx(){viewport.setAttribute('transform',`translate(${transform.x} ${transform.y}) scale(${transform.k})`)}svg.addEventListener('wheel',ev=>{ev.preventDefault();transform.k=Math.max(.35,Math.min(2.8,transform.k*(ev.deltaY>0?.9:1.1)));tx()},{passive:false});let pan=false,last={x:0,y:0};svg.addEventListener('pointerdown',ev=>{if(ev.target===svg){pan=true;last={x:ev.clientX,y:ev.clientY}}});svg.addEventListener('pointermove',ev=>{if(!pan)return;transform.x+=(ev.clientX-last.x)/transform.k;transform.y+=(ev.clientY-last.y)/transform.k;last={x:ev.clientX,y:ev.clientY};tx()});svg.addEventListener('pointerup',()=>pan=false);load()})();
61
+ </script></body></html>'''.replace("__SHARED_CSS__", SHARED_CSS).replace("<body>", f"<body>{RELATIONS_DIRECTION}", 1).replace('id="status"', 'id="status" role="status" aria-live="polite"').replace('id="detail"', 'id="detail" aria-live="polite"').replace('拖动节点,滚轮缩放;点击节点或右侧关系下钻', '桌面可拖动缩放;移动端横向滑动查看全尺寸图谱')