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