@aiyiran/myclaw 1.1.161 → 1.1.164
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.
- package/CLAUDE.md +9 -3
- package/assets/myclaw-inject.js +12 -8
- package/package.json +1 -1
- package/server/debug_record.py +392 -869
package/server/debug_record.py
CHANGED
|
@@ -1,27 +1,30 @@
|
|
|
1
1
|
"""
|
|
2
|
-
|
|
2
|
+
debug_record.py
|
|
3
3
|
|
|
4
4
|
用途:
|
|
5
|
-
把前端收集到的 currentDebugRecord
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
把前端收集到的 currentDebugRecord 落盘成两个文件,供学生/老师/AI 查看。
|
|
6
|
+
|
|
7
|
+
【架构:数据与渲染分离】
|
|
8
|
+
- debug-data.js —— 每次覆盖写。内容是 `window.__DEBUG_DATA__ = {规范化后的记录}`。
|
|
9
|
+
这是**唯一数据源**:既喂前端渲染,也给 AI 直接读。
|
|
10
|
+
- debug-record.html —— 恒定的“壳”。包含 CSS + 前端渲染器 JS,运行时动态加载
|
|
11
|
+
debug-data.js 并渲染。内容不随数据变化,因此同步循环不会
|
|
12
|
+
反复把它当成“更新”重传。
|
|
13
|
+
|
|
14
|
+
为什么这么拆:
|
|
15
|
+
1. HTML 壳恒定 → 哈希不变 → 不再被同步误判为更新(根除循环上传问题)。
|
|
16
|
+
2. AI 读的是纯数据 debug-data.js,信噪比高,不掺样式。
|
|
17
|
+
3. 渲染逻辑在前端,改样式不用动 Python、不用重启同步进程。
|
|
18
|
+
|
|
19
|
+
服务端职责(本文件):
|
|
20
|
+
- 接收 / 校验 / normalize 记录
|
|
21
|
+
- 写出 debug-data.js 和 debug-record.html
|
|
22
|
+
渲染职责(前端,在 HTML 壳的内联 JS 里):
|
|
23
|
+
- 读 window.__DEBUG_DATA__,渲染小结 / 线索 / 运行回放 / 原始数据
|
|
12
24
|
|
|
13
25
|
重要架构原则:
|
|
14
26
|
- 前端只传 claw / workspace / entryFile 这类逻辑身份。
|
|
15
|
-
- 服务端根据 claw / workspace
|
|
16
|
-
- 不允许前端直接传文件系统路径。
|
|
17
|
-
- debug-record.html 是给人看的。
|
|
18
|
-
- debug-record.json / debug-data 是给 AI 和后续程序读的。
|
|
19
|
-
|
|
20
|
-
后续扩展点:
|
|
21
|
-
- 想改页面视觉:主要改 render_style()
|
|
22
|
-
- 想改学生看到的文案:主要改 format_replay_event()
|
|
23
|
-
- 想扩展错误解释:主要改 simplify_error_message()
|
|
24
|
-
- 想新增事件类型:扩展 EventType 和 format_replay_event()
|
|
27
|
+
- 服务端根据 claw / workspace 计算真正的保存目录,不允许前端传文件系统路径。
|
|
25
28
|
"""
|
|
26
29
|
|
|
27
30
|
from __future__ import annotations
|
|
@@ -30,21 +33,20 @@ import json
|
|
|
30
33
|
import re
|
|
31
34
|
import time
|
|
32
35
|
import uuid
|
|
33
|
-
from dataclasses import dataclass
|
|
34
|
-
from html import escape as html_escape
|
|
35
36
|
from pathlib import Path
|
|
36
37
|
from typing import Any, Dict, List, Optional
|
|
37
|
-
from urllib.parse import urlparse, unquote
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
# =============================================================================
|
|
41
41
|
# 1. 事件类型定义
|
|
42
42
|
# =============================================================================
|
|
43
43
|
# 这里的事件类型必须和 debug-probe.js 中发出的 type 保持一致。
|
|
44
|
-
#
|
|
44
|
+
# 注意:渲染逻辑已搬到前端 JS(见 render_shell_html),这里的常量主要供
|
|
45
|
+
# normalize / create_summary 使用。前端 JS 里有一份对应的字符串常量。
|
|
45
46
|
|
|
46
47
|
|
|
47
48
|
class EventType:
|
|
49
|
+
PROBE_READY = "probe-ready"
|
|
48
50
|
PAGE_ENTER = "page-enter"
|
|
49
51
|
CLICK = "click"
|
|
50
52
|
KEYDOWN = "keydown"
|
|
@@ -62,97 +64,39 @@ ERROR_EVENT_TYPES = {
|
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
|
|
65
|
-
DEFAULT_TITLE = "刚才发生了什么?"
|
|
66
|
-
|
|
67
|
-
|
|
68
67
|
# =============================================================================
|
|
69
|
-
# 2.
|
|
68
|
+
# 2. 保存层:写 debug-data.js + 恒定的 debug-record.html 壳
|
|
70
69
|
# =============================================================================
|
|
71
70
|
|
|
72
71
|
|
|
73
|
-
def
|
|
74
|
-
"""
|
|
75
|
-
把调试记录渲染成完整 HTML。
|
|
76
|
-
|
|
77
|
-
参数:
|
|
78
|
-
input_record:
|
|
79
|
-
前端提交的 currentDebugRecord。
|
|
80
|
-
通常包含:
|
|
81
|
-
- claw
|
|
82
|
-
- workspace
|
|
83
|
-
- entryFile
|
|
84
|
-
- entryUrl
|
|
85
|
-
- traceId
|
|
86
|
-
- startedAt
|
|
87
|
-
- endedAt
|
|
88
|
-
- events
|
|
89
|
-
|
|
90
|
-
返回:
|
|
91
|
-
完整 HTML 字符串。
|
|
92
|
-
|
|
93
|
-
注意:
|
|
94
|
-
这里会 normalize record,避免字段缺失导致页面崩溃。
|
|
72
|
+
def save_debug_record(record: Dict[str, Any], debug_dir: Path) -> Dict[str, str]:
|
|
95
73
|
"""
|
|
96
|
-
|
|
97
|
-
summary = create_summary(record)
|
|
98
|
-
clues = extract_clues(record["events"])
|
|
99
|
-
|
|
100
|
-
replay_items_html = render_replay_items(record["events"])
|
|
101
|
-
clues_html = render_clues(clues)
|
|
102
|
-
raw_data_html = render_raw_data_card(record)
|
|
103
|
-
|
|
104
|
-
debug_json = safe_json_for_script(record)
|
|
105
|
-
|
|
106
|
-
return f"""<!DOCTYPE html>
|
|
107
|
-
<html lang="zh-CN">
|
|
108
|
-
<head>
|
|
109
|
-
<meta charset="UTF-8" />
|
|
110
|
-
<title>{escape_html(DEFAULT_TITLE)}</title>
|
|
111
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
112
|
-
{render_style()}
|
|
113
|
-
</head>
|
|
114
|
-
<body>
|
|
115
|
-
<main class="page">
|
|
116
|
-
{render_hero()}
|
|
117
|
-
{render_summary(summary)}
|
|
118
|
-
{render_clues_card(clues_html)}
|
|
119
|
-
{render_replay_card(replay_items_html)}
|
|
120
|
-
{raw_data_html}
|
|
121
|
-
|
|
122
|
-
<script id="debug-data" type="application/json">
|
|
123
|
-
{debug_json}
|
|
124
|
-
</script>
|
|
125
|
-
</main>
|
|
126
|
-
</body>
|
|
127
|
-
</html>"""
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
# =============================================================================
|
|
131
|
-
# 3. 保存层:写入 debug-record.html
|
|
132
|
-
# =============================================================================
|
|
74
|
+
落盘调试记录。
|
|
133
75
|
|
|
134
|
-
|
|
135
|
-
|
|
76
|
+
写两个文件:
|
|
77
|
+
- debug-data.js 每次覆盖,内容 = window.__DEBUG_DATA__ = {normalized record}
|
|
78
|
+
- debug-record.html 恒定壳(内容不变),负责加载并渲染上面的数据
|
|
79
|
+
"""
|
|
136
80
|
debug_dir.mkdir(parents=True, exist_ok=True)
|
|
137
81
|
|
|
138
82
|
normalized_record = normalize_record(record)
|
|
83
|
+
|
|
84
|
+
# 数据文件:每次覆盖
|
|
85
|
+
data_js = "window.__DEBUG_DATA__ = " + safe_json_for_js(normalized_record) + ";\n"
|
|
86
|
+
data_path = debug_dir / "debug-data.js"
|
|
87
|
+
write_text_atomic(data_path, data_js)
|
|
88
|
+
|
|
89
|
+
# HTML 壳:内容恒定(不含数据)。每次都写没关系——内容一致,
|
|
90
|
+
# 基于内容哈希的同步不会把它当成变更重传。
|
|
139
91
|
html_path = debug_dir / "debug-record.html"
|
|
140
|
-
write_text_atomic(html_path,
|
|
92
|
+
write_text_atomic(html_path, render_shell_html())
|
|
141
93
|
|
|
142
|
-
return {"html_path": str(html_path)}
|
|
94
|
+
return {"html_path": str(html_path), "data_path": str(data_path)}
|
|
143
95
|
|
|
144
96
|
|
|
145
97
|
def write_text_atomic(file_path: Path, content: str) -> None:
|
|
146
98
|
"""
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
为什么不用直接 write_text?
|
|
150
|
-
因为 debug-record.html 可能被前端同时访问。
|
|
151
|
-
原子写可以避免出现“写到一半被读取”的半截文件。
|
|
152
|
-
|
|
153
|
-
实现方式:
|
|
154
|
-
1. 先写临时文件
|
|
155
|
-
2. 再 rename 成目标文件
|
|
99
|
+
原子写文件:先写临时文件,再 rename,避免“写到一半被读取”的半截文件。
|
|
156
100
|
"""
|
|
157
101
|
temp_path = file_path.with_name(
|
|
158
102
|
f"{file_path.name}.tmp.{int(time.time() * 1000)}.{uuid.uuid4().hex[:8]}"
|
|
@@ -162,7 +106,7 @@ def write_text_atomic(file_path: Path, content: str) -> None:
|
|
|
162
106
|
|
|
163
107
|
|
|
164
108
|
# =============================================================================
|
|
165
|
-
#
|
|
109
|
+
# 3. 路径层:根据 claw / workspace 计算正确目录
|
|
166
110
|
# =============================================================================
|
|
167
111
|
|
|
168
112
|
|
|
@@ -181,15 +125,11 @@ def resolve_workspace_debug_dir(
|
|
|
181
125
|
"""
|
|
182
126
|
根据逻辑身份计算 debug 目录。
|
|
183
127
|
|
|
184
|
-
|
|
185
|
-
- 前端只应该传 workspace(你们的真实结构是 ~/.openclaw/{workspace}/,没有 claw 目录层)。
|
|
128
|
+
- 前端只传 workspace(真实结构 ~/.openclaw/{workspace}/,没有 claw 目录层)。
|
|
186
129
|
- claw 只用于身份校验,不参与路径计算。
|
|
187
130
|
- 服务端自己计算真实文件路径,不允许前端传文件系统路径。
|
|
188
131
|
|
|
189
|
-
|
|
190
|
-
~/.openclaw/{workspace}/debug/
|
|
191
|
-
真实目录(服务器):
|
|
192
|
-
/root/.openclaw/{workspace}/debug/
|
|
132
|
+
真实目录:{openclaw_root}/{workspace}/debug/
|
|
193
133
|
|
|
194
134
|
安全规则:
|
|
195
135
|
- workspace 只能包含字母、数字、下划线、短横线。
|
|
@@ -249,7 +189,6 @@ def handle_save_request(body: Dict[str, Any], openclaw_root: str, server_claw: s
|
|
|
249
189
|
"ok": True,
|
|
250
190
|
"traceId": record["traceId"],
|
|
251
191
|
"debugRecordPath": paths["html_path"],
|
|
252
|
-
|
|
253
192
|
"debugRecordUrl": debug_record_url,
|
|
254
193
|
}
|
|
255
194
|
|
|
@@ -257,20 +196,7 @@ def handle_save_request(body: Dict[str, Any], openclaw_root: str, server_claw: s
|
|
|
257
196
|
def assert_safe_id(value: str, field_name: str) -> None:
|
|
258
197
|
"""
|
|
259
198
|
校验 claw / workspace 这类逻辑 ID。
|
|
260
|
-
|
|
261
|
-
允许:
|
|
262
|
-
- a-z
|
|
263
|
-
- A-Z
|
|
264
|
-
- 0-9
|
|
265
|
-
- _
|
|
266
|
-
- -
|
|
267
|
-
|
|
268
|
-
不允许:
|
|
269
|
-
- /
|
|
270
|
-
- ..
|
|
271
|
-
- 空格
|
|
272
|
-
- 中文路径
|
|
273
|
-
- 其他特殊字符
|
|
199
|
+
允许 a-z A-Z 0-9 _ -;不允许 / .. 空格 中文 等。
|
|
274
200
|
"""
|
|
275
201
|
if not isinstance(value, str):
|
|
276
202
|
raise ValueError(f"invalid {field_name}: must be string")
|
|
@@ -280,19 +206,16 @@ def assert_safe_id(value: str, field_name: str) -> None:
|
|
|
280
206
|
|
|
281
207
|
|
|
282
208
|
# =============================================================================
|
|
283
|
-
#
|
|
209
|
+
# 4. record / event 规范化(数据清洗留在服务端,输出进 debug-data.js)
|
|
284
210
|
# =============================================================================
|
|
285
211
|
|
|
286
212
|
|
|
287
213
|
def normalize_record(record: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
288
214
|
"""
|
|
289
215
|
规范化调试记录。
|
|
290
|
-
|
|
291
|
-
目的:
|
|
292
216
|
- 服务端不要完全信任前端字段。
|
|
293
217
|
- 字段缺失时提供默认值。
|
|
294
|
-
- events 始终是 list
|
|
295
|
-
- payload 保持 JSON 可序列化。
|
|
218
|
+
- events 始终是 list;payload 保持 JSON 可序列化。
|
|
296
219
|
"""
|
|
297
220
|
if not isinstance(record, dict):
|
|
298
221
|
record = {}
|
|
@@ -325,10 +248,7 @@ def normalize_record(record: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
|
325
248
|
|
|
326
249
|
def normalize_event(event: Any, index: int) -> Dict[str, Any]:
|
|
327
250
|
"""
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
这里不会丢弃未知事件类型。
|
|
331
|
-
未知事件也保留,方便后续调试和扩展。
|
|
251
|
+
规范化单条事件。未知事件类型也保留,方便后续调试和扩展。
|
|
332
252
|
"""
|
|
333
253
|
if not isinstance(event, dict):
|
|
334
254
|
event = {}
|
|
@@ -353,11 +273,7 @@ def normalize_event(event: Any, index: int) -> Dict[str, Any]:
|
|
|
353
273
|
def normalize_entry_file(value: Any) -> str:
|
|
354
274
|
"""
|
|
355
275
|
入口文件只做展示用途,不用它计算文件系统路径。
|
|
356
|
-
|
|
357
|
-
为了安全和稳定:
|
|
358
|
-
- 缺失时返回 index.html
|
|
359
|
-
- 出现 .. 时返回 index.html
|
|
360
|
-
- 不是 .html 时返回 index.html
|
|
276
|
+
缺失 / 含 .. / 非 .html → 返回 index.html
|
|
361
277
|
"""
|
|
362
278
|
entry_file = to_safe_string(value).lstrip("/")
|
|
363
279
|
|
|
@@ -377,16 +293,9 @@ def create_trace_id() -> str:
|
|
|
377
293
|
return f"trace_{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}"
|
|
378
294
|
|
|
379
295
|
|
|
380
|
-
# =============================================================================
|
|
381
|
-
# 6. 概览与线索提取
|
|
382
|
-
# =============================================================================
|
|
383
|
-
|
|
384
|
-
|
|
385
296
|
def create_summary(record: Dict[str, Any]) -> Dict[str, Any]:
|
|
386
297
|
"""
|
|
387
|
-
重新计算概览,不完全依赖前端传来的 summary
|
|
388
|
-
|
|
389
|
-
这块数据主要用于页面顶部的“试玩小结”。
|
|
298
|
+
重新计算概览,不完全依赖前端传来的 summary。用于页面顶部“试玩小结”。
|
|
390
299
|
"""
|
|
391
300
|
events = record.get("events") or []
|
|
392
301
|
|
|
@@ -428,431 +337,82 @@ def create_summary(record: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
428
337
|
}
|
|
429
338
|
|
|
430
339
|
|
|
431
|
-
def extract_clues(events: List[Dict[str, Any]]) -> List[Dict[str, str]]:
|
|
432
|
-
"""
|
|
433
|
-
提取“可能有用的线索”。
|
|
434
|
-
|
|
435
|
-
这不是自动诊断,不要过度智能化。
|
|
436
|
-
只提取明显线索:
|
|
437
|
-
- runtime-error
|
|
438
|
-
- unhandledrejection
|
|
439
|
-
- resource-error
|
|
440
|
-
- console.error / console.warn
|
|
441
|
-
- console 中出现 null / undefined
|
|
442
|
-
"""
|
|
443
|
-
clues: List[Dict[str, str]] = []
|
|
444
|
-
|
|
445
|
-
for event in events:
|
|
446
|
-
event_type = event.get("type")
|
|
447
|
-
payload = event.get("payload") or {}
|
|
448
|
-
|
|
449
|
-
if event_type == EventType.RUNTIME_ERROR:
|
|
450
|
-
clues.append({
|
|
451
|
-
"level": "error",
|
|
452
|
-
"icon": "⚠️",
|
|
453
|
-
"text": f"程序出错:{simplify_error_message(payload.get('message'))}",
|
|
454
|
-
})
|
|
455
|
-
continue
|
|
456
|
-
|
|
457
|
-
if event_type == EventType.UNHANDLED_REJECTION:
|
|
458
|
-
clues.append({
|
|
459
|
-
"level": "error",
|
|
460
|
-
"icon": "⚠️",
|
|
461
|
-
"text": f"程序有一个异步错误:{simplify_error_message(payload.get('message'))}",
|
|
462
|
-
})
|
|
463
|
-
continue
|
|
464
|
-
|
|
465
|
-
if event_type == EventType.RESOURCE_ERROR:
|
|
466
|
-
url = to_safe_string(payload.get("url"))
|
|
467
|
-
clues.append({
|
|
468
|
-
"level": "warning",
|
|
469
|
-
"icon": "🖼️",
|
|
470
|
-
"text": f"资源没加载出来:{get_file_name(url) or url or '未知资源'}",
|
|
471
|
-
})
|
|
472
|
-
continue
|
|
473
|
-
|
|
474
|
-
if event_type == EventType.CONSOLE:
|
|
475
|
-
level = to_safe_string(payload.get("level")) or "log"
|
|
476
|
-
message = to_safe_string(payload.get("message"))
|
|
477
|
-
|
|
478
|
-
if level == "error":
|
|
479
|
-
clues.append({
|
|
480
|
-
"level": "error",
|
|
481
|
-
"icon": "💬",
|
|
482
|
-
"text": f"程序提示:{message}",
|
|
483
|
-
})
|
|
484
|
-
continue
|
|
485
|
-
|
|
486
|
-
if level == "warn":
|
|
487
|
-
clues.append({
|
|
488
|
-
"level": "warning",
|
|
489
|
-
"icon": "💬",
|
|
490
|
-
"text": f"程序提醒:{message}",
|
|
491
|
-
})
|
|
492
|
-
continue
|
|
493
|
-
|
|
494
|
-
if "null" in message or "undefined" in message:
|
|
495
|
-
clues.append({
|
|
496
|
-
"level": "info",
|
|
497
|
-
"icon": "🔎",
|
|
498
|
-
"text": f"程序里出现了空值线索:{message}",
|
|
499
|
-
})
|
|
500
|
-
|
|
501
|
-
return dedupe_clues(clues)[:6]
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
def dedupe_clues(clues: List[Dict[str, str]]) -> List[Dict[str, str]]:
|
|
505
|
-
seen = set()
|
|
506
|
-
result = []
|
|
507
|
-
|
|
508
|
-
for clue in clues:
|
|
509
|
-
key = f"{clue.get('icon')}-{clue.get('text')}"
|
|
510
|
-
if key in seen:
|
|
511
|
-
continue
|
|
512
|
-
|
|
513
|
-
seen.add(key)
|
|
514
|
-
result.append(clue)
|
|
515
|
-
|
|
516
|
-
return result
|
|
517
|
-
|
|
518
|
-
|
|
519
340
|
# =============================================================================
|
|
520
|
-
#
|
|
341
|
+
# 5. 工具函数
|
|
521
342
|
# =============================================================================
|
|
522
343
|
|
|
523
344
|
|
|
524
|
-
def
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
event_type = event.get("type")
|
|
534
|
-
payload = event.get("payload") or {}
|
|
535
|
-
|
|
536
|
-
if event_type == EventType.PAGE_ENTER:
|
|
537
|
-
title = (
|
|
538
|
-
event.get("pageTitle")
|
|
539
|
-
or get_file_name(event.get("pageUrl"))
|
|
540
|
-
or "一个页面"
|
|
541
|
-
)
|
|
542
|
-
return {
|
|
543
|
-
"icon": "📄",
|
|
544
|
-
"title": f"来到了【{title}】",
|
|
545
|
-
"detail": get_file_name(event.get("pageUrl")),
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
if event_type == EventType.CLICK:
|
|
549
|
-
text = (
|
|
550
|
-
payload.get("text")
|
|
551
|
-
or payload.get("selector")
|
|
552
|
-
or payload.get("tag")
|
|
553
|
-
or "某个地方"
|
|
554
|
-
)
|
|
555
|
-
return {
|
|
556
|
-
"icon": "👆",
|
|
557
|
-
"title": f"点击了【{to_safe_string(text)}】",
|
|
558
|
-
"detail": "",
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
if event_type == EventType.KEYDOWN:
|
|
562
|
-
key = payload.get("code") or payload.get("key") or "某个键"
|
|
563
|
-
return {
|
|
564
|
-
"icon": "⌨️",
|
|
565
|
-
"title": f"按下了【{to_safe_string(key)}】",
|
|
566
|
-
"detail": "",
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
if event_type == EventType.CONSOLE:
|
|
570
|
-
level = to_safe_string(payload.get("level")) or "log"
|
|
571
|
-
message = to_safe_string(payload.get("message"))
|
|
572
|
-
|
|
573
|
-
return {
|
|
574
|
-
"icon": "⚠️" if level == "error" else "💬",
|
|
575
|
-
"title": f"程序说:{message}",
|
|
576
|
-
"detail": "" if level == "log" else f"console.{level}",
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
if event_type == EventType.RUNTIME_ERROR:
|
|
580
|
-
message = to_safe_string(payload.get("message"))
|
|
581
|
-
return {
|
|
582
|
-
"icon": "⚠️",
|
|
583
|
-
"title": f"程序出错了:{simplify_error_message(message)}",
|
|
584
|
-
"detail": message,
|
|
585
|
-
}
|
|
345
|
+
def to_safe_string(value: Any) -> str:
|
|
346
|
+
if value is None:
|
|
347
|
+
return ""
|
|
348
|
+
if isinstance(value, str):
|
|
349
|
+
return value
|
|
350
|
+
try:
|
|
351
|
+
return str(value)
|
|
352
|
+
except Exception:
|
|
353
|
+
return ""
|
|
586
354
|
|
|
587
|
-
if event_type == EventType.UNHANDLED_REJECTION:
|
|
588
|
-
message = to_safe_string(payload.get("message"))
|
|
589
|
-
return {
|
|
590
|
-
"icon": "⚠️",
|
|
591
|
-
"title": f"程序有一个异步错误:{simplify_error_message(message)}",
|
|
592
|
-
"detail": message,
|
|
593
|
-
}
|
|
594
355
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
return
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
"detail": url,
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
return {
|
|
604
|
-
"icon": "•",
|
|
605
|
-
"title": to_safe_string(event_type) or "未知事件",
|
|
606
|
-
"detail": "",
|
|
607
|
-
}
|
|
356
|
+
def to_int(value: Any, default: int) -> int:
|
|
357
|
+
try:
|
|
358
|
+
return int(value)
|
|
359
|
+
except Exception:
|
|
360
|
+
return default
|
|
608
361
|
|
|
609
362
|
|
|
610
|
-
def
|
|
363
|
+
def to_jsonable(value: Any) -> Any:
|
|
611
364
|
"""
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
原则:
|
|
615
|
-
- 不做完整翻译。
|
|
616
|
-
- 能稳定解释的才解释。
|
|
617
|
-
- 解释不了就返回原始错误。
|
|
365
|
+
确保对象可被 json.dumps 序列化。probe 发来的 payload 理论上已是 JSON,
|
|
366
|
+
这里做一层保护,避免后端因奇怪字段崩掉。
|
|
618
367
|
"""
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
not_defined_match = re.search(
|
|
625
|
-
r"(?:ReferenceError:\s*)?(.+?)\s+is not defined",
|
|
626
|
-
raw,
|
|
627
|
-
)
|
|
628
|
-
if not_defined_match:
|
|
629
|
-
name = not_defined_match.group(1).strip()
|
|
630
|
-
if name:
|
|
631
|
-
return f"找不到 {name}"
|
|
632
|
-
|
|
633
|
-
read_null_match = re.search(
|
|
634
|
-
r"Cannot read properties of null \(reading ['\"](.+?)['\"]\)",
|
|
635
|
-
raw,
|
|
636
|
-
)
|
|
637
|
-
if read_null_match:
|
|
638
|
-
return f"程序想从一个空东西里读取 {read_null_match.group(1)}"
|
|
639
|
-
|
|
640
|
-
read_undefined_match = re.search(
|
|
641
|
-
r"Cannot read properties of undefined \(reading ['\"](.+?)['\"]\)",
|
|
642
|
-
raw,
|
|
643
|
-
)
|
|
644
|
-
if read_undefined_match:
|
|
645
|
-
return f"程序想从一个还没准备好的东西里读取 {read_undefined_match.group(1)}"
|
|
646
|
-
|
|
647
|
-
if "Cannot read properties of null" in raw:
|
|
648
|
-
return "程序想使用一个空东西"
|
|
649
|
-
|
|
650
|
-
if "Cannot read properties of undefined" in raw:
|
|
651
|
-
return "程序想使用一个还没有准备好的东西"
|
|
652
|
-
|
|
653
|
-
if "Failed to fetch" in raw:
|
|
654
|
-
return "网络请求失败,可能是资源地址或接口有问题"
|
|
655
|
-
|
|
656
|
-
if "Unexpected token" in raw:
|
|
657
|
-
return "代码里可能有一个符号写错了"
|
|
658
|
-
|
|
659
|
-
if "Unexpected end of input" in raw:
|
|
660
|
-
return "代码可能少了一个结尾符号"
|
|
661
|
-
|
|
662
|
-
return raw
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
# =============================================================================
|
|
666
|
-
# 8. HTML 渲染子函数
|
|
667
|
-
# =============================================================================
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
def render_hero() -> str:
|
|
671
|
-
return """
|
|
672
|
-
<section class="hero">
|
|
673
|
-
<div class="eyebrow">给 AI 看的运行回放</div>
|
|
674
|
-
<h1>刚才发生了什么?</h1>
|
|
675
|
-
<p class="intro">
|
|
676
|
-
这是你刚刚试玩作品时留下的记录。它记下了你进入了哪些页面、点击了哪些按钮、按了哪些键,以及程序有没有留下有用的线索。
|
|
677
|
-
如果作品哪里不对,可以把这份记录交给 AI 看。
|
|
678
|
-
</p>
|
|
679
|
-
</section>
|
|
680
|
-
"""
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
def render_summary(summary: Dict[str, Any]) -> str:
|
|
684
|
-
return f"""
|
|
685
|
-
<section class="card">
|
|
686
|
-
<h2>试玩小结</h2>
|
|
687
|
-
<div class="summary-grid">
|
|
688
|
-
{render_summary_item(summary.get("pageCount", 0), "进入页面")}
|
|
689
|
-
{render_summary_item(summary.get("clicks", 0), "点击")}
|
|
690
|
-
{render_summary_item(summary.get("keydowns", 0), "按键")}
|
|
691
|
-
{render_summary_item(summary.get("errorCount", 0), "可能的问题")}
|
|
692
|
-
</div>
|
|
693
|
-
</section>
|
|
694
|
-
"""
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
def render_summary_item(number: Any, label: str) -> str:
|
|
698
|
-
return f"""
|
|
699
|
-
<div class="summary-item">
|
|
700
|
-
<div class="summary-number">{escape_html(number)}</div>
|
|
701
|
-
<div class="summary-label">{escape_html(label)}</div>
|
|
702
|
-
</div>
|
|
703
|
-
"""
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
def render_clues_card(clues_html: str) -> str:
|
|
707
|
-
return f"""
|
|
708
|
-
<section class="card">
|
|
709
|
-
<h2>可能有用的线索</h2>
|
|
710
|
-
<div class="clue-list">
|
|
711
|
-
{clues_html}
|
|
712
|
-
</div>
|
|
713
|
-
</section>
|
|
714
|
-
"""
|
|
715
|
-
|
|
368
|
+
try:
|
|
369
|
+
json.dumps(value, ensure_ascii=False)
|
|
370
|
+
return value
|
|
371
|
+
except Exception:
|
|
372
|
+
return to_safe_string(value)
|
|
716
373
|
|
|
717
|
-
def render_clues(clues: List[Dict[str, str]]) -> str:
|
|
718
|
-
if not clues:
|
|
719
|
-
return """
|
|
720
|
-
<div class="empty-clue">
|
|
721
|
-
这次没有发现明显的红字错误。作品如果不符合预期,可以继续告诉 AI:
|
|
722
|
-
我做了什么、我希望发生什么、实际发生什么。
|
|
723
|
-
</div>
|
|
724
|
-
"""
|
|
725
374
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
375
|
+
def safe_json_for_js(value: Any) -> str:
|
|
376
|
+
"""
|
|
377
|
+
把数据序列化成可安全放进 .js 文件的 JSON 字面量。
|
|
378
|
+
JSON 是 JS 的子集,唯一要处理的是 U+2028 / U+2029:
|
|
379
|
+
它们在 JSON 里合法,但在 JS 源码里是非法的行终止符。
|
|
380
|
+
"""
|
|
381
|
+
return (
|
|
382
|
+
json.dumps(value, ensure_ascii=False, indent=2)
|
|
383
|
+
.replace("
", "\\u2028")
|
|
384
|
+
.replace("
", "\\u2029")
|
|
734
385
|
)
|
|
735
386
|
|
|
736
387
|
|
|
737
|
-
def render_replay_card(replay_items_html: str) -> str:
|
|
738
|
-
fallback = '<li class="empty-replay">这次没有记录到运行事件。</li>'
|
|
739
|
-
|
|
740
|
-
return f"""
|
|
741
|
-
<section class="card">
|
|
742
|
-
<h2>运行回放</h2>
|
|
743
|
-
<ol class="replay-list">
|
|
744
|
-
{replay_items_html or fallback}
|
|
745
|
-
</ol>
|
|
746
|
-
</section>
|
|
747
|
-
"""
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
def render_replay_items(events: List[Dict[str, Any]]) -> str:
|
|
751
|
-
html_parts = []
|
|
752
|
-
|
|
753
|
-
for event in events:
|
|
754
|
-
view = format_replay_event(event)
|
|
755
|
-
class_name = get_event_class_name(event)
|
|
756
|
-
|
|
757
|
-
detail_html = ""
|
|
758
|
-
if view.get("detail"):
|
|
759
|
-
detail_html = f' · {escape_html(view.get("detail"))}'
|
|
760
|
-
|
|
761
|
-
html_parts.append(
|
|
762
|
-
f"""
|
|
763
|
-
<li class="replay-item {escape_html(class_name)}">
|
|
764
|
-
<div class="event-icon">{escape_html(view.get("icon", "•"))}</div>
|
|
765
|
-
<div class="event-body">
|
|
766
|
-
<div class="event-main">{escape_html(view.get("title", ""))}</div>
|
|
767
|
-
<div class="event-sub">
|
|
768
|
-
{escape_html(format_time(event.get("time")))}{detail_html}
|
|
769
|
-
</div>
|
|
770
|
-
</div>
|
|
771
|
-
</li>
|
|
772
|
-
"""
|
|
773
|
-
)
|
|
774
|
-
|
|
775
|
-
return "".join(html_parts)
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
def render_raw_data_card(record: Dict[str, Any]) -> str:
|
|
779
|
-
pretty_json = json.dumps(record, ensure_ascii=False, indent=2)
|
|
780
|
-
|
|
781
|
-
return f"""
|
|
782
|
-
<section class="card">
|
|
783
|
-
<details>
|
|
784
|
-
<summary>展开给 AI 看的原始数据</summary>
|
|
785
|
-
<div class="tech-meta">
|
|
786
|
-
<div>作品空间:{escape_html(record.get("workspace") or "未知")}</div>
|
|
787
|
-
<div>入口文件:{escape_html(record.get("entryFile") or "index.html")}</div>
|
|
788
|
-
<div>开始时间:{escape_html(record.get("startedAt") or "")}</div>
|
|
789
|
-
<div>结束时间:{escape_html(record.get("endedAt") or "")}</div>
|
|
790
|
-
<div>记录编号:{escape_html(record.get("traceId") or "")}</div>
|
|
791
|
-
</div>
|
|
792
|
-
<pre>{escape_html(pretty_json)}</pre>
|
|
793
|
-
</details>
|
|
794
|
-
</section>
|
|
795
|
-
"""
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
def get_event_class_name(event: Dict[str, Any]) -> str:
|
|
799
|
-
event_type = event.get("type")
|
|
800
|
-
payload = event.get("payload") or {}
|
|
801
|
-
|
|
802
|
-
if event_type == EventType.PAGE_ENTER:
|
|
803
|
-
return "page-enter"
|
|
804
|
-
|
|
805
|
-
if event_type == EventType.CLICK:
|
|
806
|
-
return "click"
|
|
807
|
-
|
|
808
|
-
if event_type == EventType.KEYDOWN:
|
|
809
|
-
return "keydown"
|
|
810
|
-
|
|
811
|
-
if event_type == EventType.CONSOLE:
|
|
812
|
-
if payload.get("level") == "error":
|
|
813
|
-
return "console console-error"
|
|
814
|
-
return "console"
|
|
815
|
-
|
|
816
|
-
if event_type == EventType.RUNTIME_ERROR:
|
|
817
|
-
return "runtime-error"
|
|
818
|
-
|
|
819
|
-
if event_type == EventType.UNHANDLED_REJECTION:
|
|
820
|
-
return "unhandledrejection"
|
|
821
|
-
|
|
822
|
-
if event_type == EventType.RESOURCE_ERROR:
|
|
823
|
-
return "resource-error"
|
|
824
|
-
|
|
825
|
-
return "unknown"
|
|
826
|
-
|
|
827
|
-
|
|
828
388
|
# =============================================================================
|
|
829
|
-
#
|
|
389
|
+
# 6. 恒定的 HTML 壳(含前端渲染器)
|
|
830
390
|
# =============================================================================
|
|
831
|
-
#
|
|
832
|
-
#
|
|
833
|
-
#
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
391
|
+
# 这个函数返回的内容**不随数据变化**,是纯静态的“壳”:
|
|
392
|
+
# - <style> 页面样式
|
|
393
|
+
# - 前端渲染器 JS(事件转写 / 错误解释 / 线索提取 / DOM 渲染)
|
|
394
|
+
# - 运行时动态加载 debug-data.js?t=<时间戳> 绕开 CDN 缓存
|
|
395
|
+
# 渲染逻辑是从原 Python 函数(format_replay_event / simplify_error_message /
|
|
396
|
+
# extract_clues / render_*)逐一移植过来的 JS 版本。
|
|
397
|
+
#
|
|
398
|
+
# 维护提示:改样式改 <style>;改文案改 formatReplayEvent;改错误解释改
|
|
399
|
+
# simplifyErrorMessage;新增事件类型在 formatReplayEvent / getEventClassName 补。
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def render_shell_html() -> str:
|
|
403
|
+
return r"""<!DOCTYPE html>
|
|
404
|
+
<html lang="zh-CN">
|
|
405
|
+
<head>
|
|
406
|
+
<meta charset="UTF-8" />
|
|
407
|
+
<title>刚才发生了什么?</title>
|
|
408
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
838
409
|
<style>
|
|
839
410
|
:root {
|
|
840
|
-
--bg: #f7f4ef;
|
|
841
|
-
--
|
|
842
|
-
--
|
|
843
|
-
--muted: #777;
|
|
844
|
-
--line: #eee4d8;
|
|
845
|
-
--soft-blue: #eef6ff;
|
|
846
|
-
--soft-yellow: #fff7df;
|
|
847
|
-
--soft-red: #fff1f1;
|
|
848
|
-
--soft-green: #edf9f1;
|
|
849
|
-
--soft-purple: #f4efff;
|
|
411
|
+
--bg: #f7f4ef; --card: #ffffff; --text: #262626; --muted: #777;
|
|
412
|
+
--line: #eee4d8; --soft-blue: #eef6ff; --soft-yellow: #fff7df;
|
|
413
|
+
--soft-red: #fff1f1; --soft-green: #edf9f1; --soft-purple: #f4efff;
|
|
850
414
|
}
|
|
851
|
-
|
|
852
|
-
* {
|
|
853
|
-
box-sizing: border-box;
|
|
854
|
-
}
|
|
855
|
-
|
|
415
|
+
* { box-sizing: border-box; }
|
|
856
416
|
body {
|
|
857
417
|
margin: 0;
|
|
858
418
|
background:
|
|
@@ -860,352 +420,315 @@ def render_style() -> str:
|
|
|
860
420
|
radial-gradient(circle at top right, #eaf4ff, transparent 26rem),
|
|
861
421
|
var(--bg);
|
|
862
422
|
color: var(--text);
|
|
863
|
-
font-family:
|
|
864
|
-
-
|
|
865
|
-
BlinkMacSystemFont,
|
|
866
|
-
"Segoe UI",
|
|
867
|
-
"PingFang SC",
|
|
868
|
-
"Microsoft YaHei",
|
|
869
|
-
sans-serif;
|
|
423
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
424
|
+
"PingFang SC", "Microsoft YaHei", sans-serif;
|
|
870
425
|
line-height: 1.6;
|
|
871
426
|
}
|
|
872
|
-
|
|
873
|
-
.page {
|
|
874
|
-
max-width: 880px;
|
|
875
|
-
margin: 0 auto;
|
|
876
|
-
padding: 28px 18px 56px;
|
|
877
|
-
}
|
|
878
|
-
|
|
427
|
+
.page { max-width: 880px; margin: 0 auto; padding: 28px 18px 56px; }
|
|
879
428
|
.hero {
|
|
880
429
|
background: rgba(255, 255, 255, 0.88);
|
|
881
430
|
border: 1px solid rgba(255, 255, 255, 0.7);
|
|
882
|
-
border-radius: 24px;
|
|
883
|
-
|
|
884
|
-
box-shadow: 0 10px 32px rgba(80, 58, 30, 0.08);
|
|
885
|
-
margin-bottom: 18px;
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
.eyebrow {
|
|
889
|
-
color: #8b6f3e;
|
|
890
|
-
font-weight: 700;
|
|
891
|
-
font-size: 14px;
|
|
892
|
-
margin-bottom: 8px;
|
|
431
|
+
border-radius: 24px; padding: 28px;
|
|
432
|
+
box-shadow: 0 10px 32px rgba(80, 58, 30, 0.08); margin-bottom: 18px;
|
|
893
433
|
}
|
|
894
|
-
|
|
895
|
-
h1 {
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
letter-spacing: -0.03em;
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
h2 {
|
|
902
|
-
margin: 0 0 14px;
|
|
903
|
-
font-size: 21px;
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
.intro {
|
|
907
|
-
margin: 0;
|
|
908
|
-
color: #555;
|
|
909
|
-
font-size: 16px;
|
|
910
|
-
max-width: 680px;
|
|
911
|
-
}
|
|
912
|
-
|
|
434
|
+
.eyebrow { color: #8b6f3e; font-weight: 700; font-size: 14px; margin-bottom: 8px; }
|
|
435
|
+
h1 { margin: 0 0 10px; font-size: 34px; letter-spacing: -0.03em; }
|
|
436
|
+
h2 { margin: 0 0 14px; font-size: 21px; }
|
|
437
|
+
.intro { margin: 0; color: #555; font-size: 16px; max-width: 680px; }
|
|
913
438
|
.card {
|
|
914
|
-
background: var(--card);
|
|
915
|
-
|
|
916
|
-
padding: 22px;
|
|
917
|
-
margin-bottom: 16px;
|
|
918
|
-
box-shadow: 0 4px 18px rgba(60, 40, 20, 0.055);
|
|
439
|
+
background: var(--card); border-radius: 20px; padding: 22px;
|
|
440
|
+
margin-bottom: 16px; box-shadow: 0 4px 18px rgba(60, 40, 20, 0.055);
|
|
919
441
|
border: 1px solid rgba(0, 0, 0, 0.035);
|
|
920
442
|
}
|
|
921
|
-
|
|
922
|
-
.summary-grid {
|
|
923
|
-
display: grid;
|
|
924
|
-
grid-template-columns: repeat(4, 1fr);
|
|
925
|
-
gap: 10px;
|
|
926
|
-
}
|
|
927
|
-
|
|
443
|
+
.summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
|
|
928
444
|
.summary-item {
|
|
929
|
-
border-radius: 16px;
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
.
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
margin-bottom: 6px;
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
.summary-label {
|
|
944
|
-
color: #746b60;
|
|
945
|
-
font-size: 13px;
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
.clue-list {
|
|
949
|
-
display: grid;
|
|
950
|
-
gap: 10px;
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
.clue {
|
|
954
|
-
display: flex;
|
|
955
|
-
gap: 10px;
|
|
956
|
-
align-items: flex-start;
|
|
957
|
-
border-radius: 14px;
|
|
958
|
-
padding: 12px 14px;
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
.clue-error {
|
|
962
|
-
background: var(--soft-red);
|
|
963
|
-
border: 1px solid #ffd3d3;
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
.clue-warning {
|
|
967
|
-
background: var(--soft-yellow);
|
|
968
|
-
border: 1px solid #f3e4aa;
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
.clue-info {
|
|
972
|
-
background: var(--soft-blue);
|
|
973
|
-
border: 1px solid #d8eaff;
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
.clue-icon {
|
|
977
|
-
font-size: 20px;
|
|
978
|
-
line-height: 1.4;
|
|
979
|
-
}
|
|
980
|
-
|
|
445
|
+
border-radius: 16px; padding: 14px; background: #faf7f2;
|
|
446
|
+
border: 1px solid #f1e8db; text-align: center;
|
|
447
|
+
}
|
|
448
|
+
.summary-number { font-size: 30px; line-height: 1; font-weight: 800; margin-bottom: 6px; }
|
|
449
|
+
.summary-label { color: #746b60; font-size: 13px; }
|
|
450
|
+
.clue-list { display: grid; gap: 10px; }
|
|
451
|
+
.clue { display: flex; gap: 10px; align-items: flex-start; border-radius: 14px; padding: 12px 14px; }
|
|
452
|
+
.clue-error { background: var(--soft-red); border: 1px solid #ffd3d3; }
|
|
453
|
+
.clue-warning { background: var(--soft-yellow); border: 1px solid #f3e4aa; }
|
|
454
|
+
.clue-info { background: var(--soft-blue); border: 1px solid #d8eaff; }
|
|
455
|
+
.clue-icon { font-size: 20px; line-height: 1.4; }
|
|
981
456
|
.empty-clue {
|
|
982
|
-
background: var(--soft-green);
|
|
983
|
-
border:
|
|
984
|
-
border-radius: 14px;
|
|
985
|
-
padding: 14px;
|
|
986
|
-
color: #375d42;
|
|
457
|
+
background: var(--soft-green); border: 1px solid #cdebd5;
|
|
458
|
+
border-radius: 14px; padding: 14px; color: #375d42;
|
|
987
459
|
}
|
|
988
|
-
|
|
989
|
-
.replay-list {
|
|
990
|
-
list-style: none;
|
|
991
|
-
padding: 0;
|
|
992
|
-
margin: 0;
|
|
993
|
-
display: grid;
|
|
994
|
-
gap: 10px;
|
|
995
|
-
}
|
|
996
|
-
|
|
460
|
+
.replay-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; }
|
|
997
461
|
.replay-item {
|
|
998
|
-
display: flex;
|
|
999
|
-
|
|
1000
|
-
padding: 13px 14px;
|
|
1001
|
-
border-radius: 16px;
|
|
1002
|
-
background: #fbfbfb;
|
|
1003
|
-
border: 1px solid #eeeeee;
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
.replay-item.click {
|
|
1007
|
-
background: var(--soft-blue);
|
|
1008
|
-
border-color: #d8eaff;
|
|
462
|
+
display: flex; gap: 12px; padding: 13px 14px; border-radius: 16px;
|
|
463
|
+
background: #fbfbfb; border: 1px solid #eeeeee;
|
|
1009
464
|
}
|
|
1010
|
-
|
|
1011
|
-
.replay-item.keydown {
|
|
1012
|
-
|
|
1013
|
-
border-color: #e2d7ff;
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
.replay-item.console {
|
|
1017
|
-
background: #f9f9f9;
|
|
1018
|
-
border-color: #ececec;
|
|
1019
|
-
}
|
|
1020
|
-
|
|
465
|
+
.replay-item.click { background: var(--soft-blue); border-color: #d8eaff; }
|
|
466
|
+
.replay-item.keydown { background: var(--soft-purple); border-color: #e2d7ff; }
|
|
467
|
+
.replay-item.console { background: #f9f9f9; border-color: #ececec; }
|
|
1021
468
|
.replay-item.console-error,
|
|
1022
469
|
.replay-item.runtime-error,
|
|
1023
470
|
.replay-item.unhandledrejection,
|
|
1024
|
-
.replay-item.resource-error {
|
|
1025
|
-
background: var(--soft-red);
|
|
1026
|
-
border-color: #ffd3d3;
|
|
1027
|
-
}
|
|
1028
|
-
|
|
471
|
+
.replay-item.resource-error { background: var(--soft-red); border-color: #ffd3d3; }
|
|
1029
472
|
.event-icon {
|
|
1030
|
-
width: 30px;
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
.event-main {
|
|
1039
|
-
font-weight: 650;
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
.event-sub {
|
|
1043
|
-
margin-top: 2px;
|
|
1044
|
-
color: var(--muted);
|
|
1045
|
-
font-size: 13px;
|
|
1046
|
-
word-break: break-all;
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
.empty-replay {
|
|
1050
|
-
color: #777;
|
|
1051
|
-
background: #fafafa;
|
|
1052
|
-
border-radius: 14px;
|
|
1053
|
-
padding: 14px;
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
details {
|
|
1057
|
-
color: #555;
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
details summary {
|
|
1061
|
-
cursor: pointer;
|
|
1062
|
-
font-weight: 700;
|
|
1063
|
-
color: #444;
|
|
1064
|
-
margin-bottom: 10px;
|
|
1065
|
-
}
|
|
1066
|
-
|
|
473
|
+
width: 30px; height: 30px; display: grid; place-items: center;
|
|
474
|
+
font-size: 20px; flex: 0 0 auto;
|
|
475
|
+
}
|
|
476
|
+
.event-main { font-weight: 650; }
|
|
477
|
+
.event-sub { margin-top: 2px; color: var(--muted); font-size: 13px; word-break: break-all; }
|
|
478
|
+
.empty-replay { color: #777; background: #fafafa; border-radius: 14px; padding: 14px; }
|
|
479
|
+
details { color: #555; }
|
|
480
|
+
details summary { cursor: pointer; font-weight: 700; color: #444; margin-bottom: 10px; }
|
|
1067
481
|
pre {
|
|
1068
|
-
background: #141821;
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
padding: 16px;
|
|
1072
|
-
overflow: auto;
|
|
1073
|
-
white-space: pre-wrap;
|
|
1074
|
-
word-break: break-word;
|
|
1075
|
-
max-height: 460px;
|
|
1076
|
-
font-size: 12px;
|
|
1077
|
-
line-height: 1.5;
|
|
482
|
+
background: #141821; color: #e5e7eb; border-radius: 14px; padding: 16px;
|
|
483
|
+
overflow: auto; white-space: pre-wrap; word-break: break-word;
|
|
484
|
+
max-height: 460px; font-size: 12px; line-height: 1.5;
|
|
1078
485
|
}
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
color: #777;
|
|
1085
|
-
margin-bottom: 12px;
|
|
486
|
+
.tech-meta { display: grid; gap: 4px; font-size: 13px; color: #777; margin-bottom: 12px; }
|
|
487
|
+
@media (max-width: 720px) {
|
|
488
|
+
.summary-grid { grid-template-columns: repeat(2, 1fr); }
|
|
489
|
+
h1 { font-size: 29px; }
|
|
490
|
+
.hero, .card { padding: 20px; border-radius: 18px; }
|
|
1086
491
|
}
|
|
492
|
+
</style>
|
|
493
|
+
</head>
|
|
494
|
+
<body>
|
|
495
|
+
<main class="page">
|
|
496
|
+
<section class="hero">
|
|
497
|
+
<div class="eyebrow">给 AI 看的运行回放</div>
|
|
498
|
+
<h1>刚才发生了什么?</h1>
|
|
499
|
+
<p class="intro">
|
|
500
|
+
这是你刚刚试玩作品时留下的记录。它记下了你进入了哪些页面、点击了哪些按钮、按了哪些键,以及程序有没有留下有用的线索。
|
|
501
|
+
如果作品哪里不对,可以把这份记录交给 AI 看。
|
|
502
|
+
</p>
|
|
503
|
+
</section>
|
|
504
|
+
<div id="dynamic">正在加载调试数据…</div>
|
|
505
|
+
</main>
|
|
1087
506
|
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
507
|
+
<script>
|
|
508
|
+
(function () {
|
|
509
|
+
var EventType = {
|
|
510
|
+
PROBE_READY: "probe-ready", PAGE_ENTER: "page-enter", CLICK: "click",
|
|
511
|
+
KEYDOWN: "keydown", CONSOLE: "console", RUNTIME_ERROR: "runtime-error",
|
|
512
|
+
UNHANDLED_REJECTION: "unhandledrejection", RESOURCE_ERROR: "resource-error"
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
function toSafeString(v) {
|
|
516
|
+
if (v === null || v === undefined) return "";
|
|
517
|
+
if (typeof v === "string") return v;
|
|
518
|
+
try { return String(v); } catch (e) { return ""; }
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function escapeHtml(v) {
|
|
522
|
+
return toSafeString(v)
|
|
523
|
+
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
524
|
+
.replace(/"/g, """).replace(/'/g, "'");
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function formatTime(iso) {
|
|
528
|
+
var t = toSafeString(iso);
|
|
529
|
+
if (t.length >= 19 && t.indexOf("T") >= 0) return t.substring(11, 19);
|
|
530
|
+
return t;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function getFileName(url) {
|
|
534
|
+
var t = toSafeString(url);
|
|
535
|
+
if (!t) return "";
|
|
536
|
+
try {
|
|
537
|
+
var path = t.split("?")[0].split("#")[0];
|
|
538
|
+
var name = path.substring(path.lastIndexOf("/") + 1);
|
|
539
|
+
return decodeURIComponent(name);
|
|
540
|
+
} catch (e) { return ""; }
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function simplifyErrorMessage(message) {
|
|
544
|
+
var raw = toSafeString(message).trim();
|
|
545
|
+
if (!raw) return "程序遇到了一个错误";
|
|
546
|
+
var m;
|
|
547
|
+
m = raw.match(/(?:ReferenceError:\s*)?(.+?)\s+is not defined/);
|
|
548
|
+
if (m && m[1].trim()) return "找不到 " + m[1].trim();
|
|
549
|
+
m = raw.match(/Cannot read properties of null \(reading ['"](.+?)['"]\)/);
|
|
550
|
+
if (m) return "程序想从一个空东西里读取 " + m[1];
|
|
551
|
+
m = raw.match(/Cannot read properties of undefined \(reading ['"](.+?)['"]\)/);
|
|
552
|
+
if (m) return "程序想从一个还没准备好的东西里读取 " + m[1];
|
|
553
|
+
if (raw.indexOf("Cannot read properties of null") >= 0) return "程序想使用一个空东西";
|
|
554
|
+
if (raw.indexOf("Cannot read properties of undefined") >= 0) return "程序想使用一个还没有准备好的东西";
|
|
555
|
+
if (raw.indexOf("Failed to fetch") >= 0) return "网络请求失败,可能是资源地址或接口有问题";
|
|
556
|
+
if (raw.indexOf("Unexpected token") >= 0) return "代码里可能有一个符号写错了";
|
|
557
|
+
if (raw.indexOf("Unexpected end of input") >= 0) return "代码可能少了一个结尾符号";
|
|
558
|
+
return raw;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function formatReplayEvent(event) {
|
|
562
|
+
var type = event.type;
|
|
563
|
+
var p = event.payload || {};
|
|
564
|
+
if (type === EventType.PROBE_READY) {
|
|
565
|
+
var ver = toSafeString(p.probeVersion) || toSafeString(event.probeVersion);
|
|
566
|
+
return { icon: "🔌", title: ver ? ("调试探针已就位(版本 " + ver + ")") : "调试探针已就位", detail: "" };
|
|
1091
567
|
}
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
568
|
+
if (type === EventType.PAGE_ENTER) {
|
|
569
|
+
var title = event.pageTitle || getFileName(event.pageUrl) || "一个页面";
|
|
570
|
+
return { icon: "📄", title: "来到了【" + title + "】", detail: getFileName(event.pageUrl) };
|
|
1095
571
|
}
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
572
|
+
if (type === EventType.CLICK) {
|
|
573
|
+
var text = p.text || p.selector || p.tag || "某个地方";
|
|
574
|
+
return { icon: "👆", title: "点击了【" + toSafeString(text) + "】", detail: "" };
|
|
575
|
+
}
|
|
576
|
+
if (type === EventType.KEYDOWN) {
|
|
577
|
+
var key = p.code || p.key || "某个键";
|
|
578
|
+
return { icon: "⌨️", title: "按下了【" + toSafeString(key) + "】", detail: "" };
|
|
579
|
+
}
|
|
580
|
+
if (type === EventType.CONSOLE) {
|
|
581
|
+
var level = toSafeString(p.level) || "log";
|
|
582
|
+
var message = toSafeString(p.message);
|
|
583
|
+
return { icon: level === "error" ? "⚠️" : "💬", title: "程序说:" + message, detail: level === "log" ? "" : ("console." + level) };
|
|
584
|
+
}
|
|
585
|
+
if (type === EventType.RUNTIME_ERROR) {
|
|
586
|
+
var em = toSafeString(p.message);
|
|
587
|
+
return { icon: "⚠️", title: "程序出错了:" + simplifyErrorMessage(em), detail: em };
|
|
588
|
+
}
|
|
589
|
+
if (type === EventType.UNHANDLED_REJECTION) {
|
|
590
|
+
var am = toSafeString(p.message);
|
|
591
|
+
return { icon: "⚠️", title: "程序有一个异步错误:" + simplifyErrorMessage(am), detail: am };
|
|
592
|
+
}
|
|
593
|
+
if (type === EventType.RESOURCE_ERROR) {
|
|
594
|
+
var url = toSafeString(p.url);
|
|
595
|
+
return { icon: "🖼️", title: "有一个资源没有加载出来:" + (getFileName(url) || p.tag || "未知资源"), detail: url };
|
|
596
|
+
}
|
|
597
|
+
return { icon: "•", title: toSafeString(type) || "未知事件", detail: "" };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function getEventClassName(event) {
|
|
601
|
+
var type = event.type;
|
|
602
|
+
var p = event.payload || {};
|
|
603
|
+
if (type === EventType.PAGE_ENTER) return "page-enter";
|
|
604
|
+
if (type === EventType.CLICK) return "click";
|
|
605
|
+
if (type === EventType.KEYDOWN) return "keydown";
|
|
606
|
+
if (type === EventType.CONSOLE) return p.level === "error" ? "console console-error" : "console";
|
|
607
|
+
if (type === EventType.RUNTIME_ERROR) return "runtime-error";
|
|
608
|
+
if (type === EventType.UNHANDLED_REJECTION) return "unhandledrejection";
|
|
609
|
+
if (type === EventType.RESOURCE_ERROR) return "resource-error";
|
|
610
|
+
return "unknown";
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function extractClues(events) {
|
|
614
|
+
var clues = [];
|
|
615
|
+
for (var i = 0; i < events.length; i++) {
|
|
616
|
+
var event = events[i];
|
|
617
|
+
var type = event.type;
|
|
618
|
+
var p = event.payload || {};
|
|
619
|
+
if (type === EventType.RUNTIME_ERROR) {
|
|
620
|
+
clues.push({ level: "error", icon: "⚠️", text: "程序出错:" + simplifyErrorMessage(p.message) });
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
if (type === EventType.UNHANDLED_REJECTION) {
|
|
624
|
+
clues.push({ level: "error", icon: "⚠️", text: "程序有一个异步错误:" + simplifyErrorMessage(p.message) });
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
if (type === EventType.RESOURCE_ERROR) {
|
|
628
|
+
var url = toSafeString(p.url);
|
|
629
|
+
clues.push({ level: "warning", icon: "🖼️", text: "资源没加载出来:" + (getFileName(url) || url || "未知资源") });
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
if (type === EventType.CONSOLE) {
|
|
633
|
+
var level = toSafeString(p.level) || "log";
|
|
634
|
+
var message = toSafeString(p.message);
|
|
635
|
+
if (level === "error") { clues.push({ level: "error", icon: "💬", text: "程序提示:" + message }); continue; }
|
|
636
|
+
if (level === "warn") { clues.push({ level: "warning", icon: "💬", text: "程序提醒:" + message }); continue; }
|
|
637
|
+
if (message.indexOf("null") >= 0 || message.indexOf("undefined") >= 0) {
|
|
638
|
+
clues.push({ level: "info", icon: "🔎", text: "程序里出现了空值线索:" + message });
|
|
639
|
+
}
|
|
640
|
+
}
|
|
1101
641
|
}
|
|
642
|
+
// 去重 + 取前 6 条
|
|
643
|
+
var seen = {}, result = [];
|
|
644
|
+
for (var j = 0; j < clues.length; j++) {
|
|
645
|
+
var k = clues[j].icon + "-" + clues[j].text;
|
|
646
|
+
if (seen[k]) continue;
|
|
647
|
+
seen[k] = true;
|
|
648
|
+
result.push(clues[j]);
|
|
649
|
+
}
|
|
650
|
+
return result.slice(0, 6);
|
|
1102
651
|
}
|
|
1103
|
-
</style>
|
|
1104
|
-
"""
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
# =============================================================================
|
|
1108
|
-
# 10. 工具函数
|
|
1109
|
-
# =============================================================================
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
def format_time(iso_time: Any) -> str:
|
|
1113
|
-
"""
|
|
1114
|
-
把 ISO 时间变成页面里显示的 HH:MM:SS。
|
|
1115
|
-
|
|
1116
|
-
注意:
|
|
1117
|
-
这里只做展示,不做严肃时区换算。
|
|
1118
|
-
"""
|
|
1119
|
-
text = to_safe_string(iso_time)
|
|
1120
|
-
|
|
1121
|
-
if len(text) >= 19 and "T" in text:
|
|
1122
|
-
return text[11:19]
|
|
1123
|
-
|
|
1124
|
-
return text
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
def get_file_name(url: Any) -> str:
|
|
1128
|
-
"""
|
|
1129
|
-
从 URL 或路径中取最后一个文件名。
|
|
1130
|
-
|
|
1131
|
-
示例:
|
|
1132
|
-
https://cdn.xxx.com/workspace/player.png?t=123 -> player.png
|
|
1133
|
-
/workspace/game.html -> game.html
|
|
1134
|
-
"""
|
|
1135
|
-
text = to_safe_string(url)
|
|
1136
|
-
|
|
1137
|
-
if not text:
|
|
1138
|
-
return ""
|
|
1139
|
-
|
|
1140
|
-
try:
|
|
1141
|
-
parsed = urlparse(text)
|
|
1142
|
-
path = parsed.path or text.split("?")[0].split("#")[0]
|
|
1143
|
-
name = Path(path).name
|
|
1144
|
-
return unquote(name)
|
|
1145
|
-
except Exception:
|
|
1146
|
-
return ""
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
def to_safe_string(value: Any) -> str:
|
|
1150
|
-
"""
|
|
1151
|
-
尽量把任意值转成字符串。
|
|
1152
|
-
"""
|
|
1153
|
-
if value is None:
|
|
1154
|
-
return ""
|
|
1155
|
-
|
|
1156
|
-
if isinstance(value, str):
|
|
1157
|
-
return value
|
|
1158
|
-
|
|
1159
|
-
try:
|
|
1160
|
-
return str(value)
|
|
1161
|
-
except Exception:
|
|
1162
|
-
return ""
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
def to_int(value: Any, default: int) -> int:
|
|
1166
|
-
try:
|
|
1167
|
-
return int(value)
|
|
1168
|
-
except Exception:
|
|
1169
|
-
return default
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
def to_jsonable(value: Any) -> Any:
|
|
1173
|
-
"""
|
|
1174
|
-
确保对象可以被 json.dumps 序列化。
|
|
1175
|
-
|
|
1176
|
-
debug-probe 发来的 payload 理论上已经是 JSON。
|
|
1177
|
-
但这里做一层保护,避免后端因为奇怪字段崩掉。
|
|
1178
|
-
"""
|
|
1179
|
-
try:
|
|
1180
|
-
json.dumps(value, ensure_ascii=False)
|
|
1181
|
-
return value
|
|
1182
|
-
except Exception:
|
|
1183
|
-
return to_safe_string(value)
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
def escape_html(value: Any) -> str:
|
|
1187
|
-
"""
|
|
1188
|
-
HTML 转义,防止 XSS。
|
|
1189
|
-
|
|
1190
|
-
所有进入 HTML 正文的动态内容,都应该走这个函数。
|
|
1191
|
-
"""
|
|
1192
|
-
return html_escape(to_safe_string(value), quote=True)
|
|
1193
652
|
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
653
|
+
function renderSummary(summary) {
|
|
654
|
+
summary = summary || {};
|
|
655
|
+
function item(n, label) {
|
|
656
|
+
return '<div class="summary-item"><div class="summary-number">' +
|
|
657
|
+
escapeHtml(n || 0) + '</div><div class="summary-label">' +
|
|
658
|
+
escapeHtml(label) + '</div></div>';
|
|
659
|
+
}
|
|
660
|
+
return '<section class="card"><h2>试玩小结</h2><div class="summary-grid">' +
|
|
661
|
+
item(summary.pageCount, "进入页面") +
|
|
662
|
+
item(summary.clicks, "点击") +
|
|
663
|
+
item(summary.keydowns, "按键") +
|
|
664
|
+
item(summary.errorCount, "可能的问题") +
|
|
665
|
+
'</div></section>';
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function renderClues(clues) {
|
|
669
|
+
if (!clues.length) {
|
|
670
|
+
return '<section class="card"><h2>可能有用的线索</h2><div class="clue-list">' +
|
|
671
|
+
'<div class="empty-clue">这次没有发现明显的红字错误。作品如果不符合预期,可以继续告诉 AI:' +
|
|
672
|
+
'我做了什么、我希望发生什么、实际发生什么。</div></div></section>';
|
|
673
|
+
}
|
|
674
|
+
var inner = clues.map(function (c) {
|
|
675
|
+
return '<div class="clue clue-' + escapeHtml(c.level || "info") + '">' +
|
|
676
|
+
'<span class="clue-icon">' + escapeHtml(c.icon || "•") + '</span>' +
|
|
677
|
+
'<span>' + escapeHtml(c.text || "") + '</span></div>';
|
|
678
|
+
}).join("");
|
|
679
|
+
return '<section class="card"><h2>可能有用的线索</h2><div class="clue-list">' + inner + '</div></section>';
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function renderReplay(events) {
|
|
683
|
+
var fallback = '<li class="empty-replay">这次没有记录到运行事件。</li>';
|
|
684
|
+
var items = events.map(function (event) {
|
|
685
|
+
var view = formatReplayEvent(event);
|
|
686
|
+
var cls = getEventClassName(event);
|
|
687
|
+
var detail = view.detail ? (" · " + escapeHtml(view.detail)) : "";
|
|
688
|
+
return '<li class="replay-item ' + escapeHtml(cls) + '">' +
|
|
689
|
+
'<div class="event-icon">' + escapeHtml(view.icon || "•") + '</div>' +
|
|
690
|
+
'<div class="event-body"><div class="event-main">' + escapeHtml(view.title || "") + '</div>' +
|
|
691
|
+
'<div class="event-sub">' + escapeHtml(formatTime(event.time)) + detail + '</div></div></li>';
|
|
692
|
+
}).join("");
|
|
693
|
+
return '<section class="card"><h2>运行回放</h2><ol class="replay-list">' +
|
|
694
|
+
(items || fallback) + '</ol></section>';
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function renderRawData(record) {
|
|
698
|
+
var pretty = JSON.stringify(record, null, 2);
|
|
699
|
+
return '<section class="card"><details><summary>展开给 AI 看的原始数据</summary>' +
|
|
700
|
+
'<div class="tech-meta">' +
|
|
701
|
+
'<div>作品空间:' + escapeHtml(record.workspace || "未知") + '</div>' +
|
|
702
|
+
'<div>入口文件:' + escapeHtml(record.entryFile || "index.html") + '</div>' +
|
|
703
|
+
'<div>开始时间:' + escapeHtml(record.startedAt || "") + '</div>' +
|
|
704
|
+
'<div>结束时间:' + escapeHtml(record.endedAt || "") + '</div>' +
|
|
705
|
+
'<div>记录编号:' + escapeHtml(record.traceId || "") + '</div></div>' +
|
|
706
|
+
'<pre>' + escapeHtml(pretty) + '</pre></details></section>';
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function render(record) {
|
|
710
|
+
record = record || {};
|
|
711
|
+
var events = record.events || [];
|
|
712
|
+
var html =
|
|
713
|
+
renderSummary(record.summary) +
|
|
714
|
+
renderClues(extractClues(events)) +
|
|
715
|
+
renderReplay(events) +
|
|
716
|
+
renderRawData(record);
|
|
717
|
+
document.getElementById("dynamic").innerHTML = html;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// 动态加载数据,带时间戳绕开 CDN 缓存(壳恒定,数据每次新)
|
|
721
|
+
var s = document.createElement("script");
|
|
722
|
+
s.src = "debug-data.js?t=" + Date.now();
|
|
723
|
+
s.onload = function () {
|
|
724
|
+
try { render(window.__DEBUG_DATA__); }
|
|
725
|
+
catch (e) { document.getElementById("dynamic").textContent = "渲染失败:" + (e && e.message); }
|
|
726
|
+
};
|
|
727
|
+
s.onerror = function () {
|
|
728
|
+
document.getElementById("dynamic").textContent = "调试数据加载失败(debug-data.js)";
|
|
729
|
+
};
|
|
730
|
+
document.head.appendChild(s);
|
|
731
|
+
})();
|
|
732
|
+
</script>
|
|
733
|
+
</body>
|
|
734
|
+
</html>"""
|