@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.
@@ -1,27 +1,30 @@
1
1
  """
2
- debug_record_renderer.py
2
+ debug_record.py
3
3
 
4
4
  用途:
5
- 把前端收集到的 currentDebugRecord 渲染成一个学生愿意打开的 HTML 文件。
6
-
7
- 核心定位:
8
- 1. 学生看到的是“运行回放”,不是专业调试报告。
9
- 2. 老师能看到可能有用的线索。
10
- 3. AI 可以从 <script id="debug-data" type="application/json"> 中读取完整结构化数据。
11
- 4. 不做完整翻译,只做“事件转写”和少量常见 JS 错误解释。
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
- # 后续如果 probe 新增事件类型,优先在这里补充,而不是散落在代码各处。
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. 对外主函数:渲染 HTML
68
+ # 2. 保存层:写 debug-data.js + 恒定的 debug-record.html 壳
71
69
  # =============================================================================
72
70
 
73
71
 
74
- def render_debug_record_html(input_record: Dict[str, Any]) -> str:
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
- record = normalize_record(input_record)
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
- def save_debug_record(record: Dict[str, Any], debug_dir: Path) -> Dict[str, str]:
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, render_debug_record_html(normalized_record))
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
- # 4. 路径层:根据 claw / workspace 计算正确目录
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
- 真实目录(本机 Mac):
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
- # 5. record / event 规范化
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
- # 7. 事件转写:结构化事件 -> 学生能读懂的回放句子
341
+ # 5. 工具函数
522
342
  # =============================================================================
523
343
 
524
344
 
525
- def format_replay_event(event: Dict[str, Any]) -> Dict[str, str]:
526
- """
527
- 把事件转成页面上的一条“运行回放”。
528
-
529
- 注意:
530
- - 这不是完整翻译。
531
- - 变量名、文件名、console 内容尽量保留原文。
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
- return {
616
- "icon": "•",
617
- "title": to_safe_string(event_type) or "未知事件",
618
- "detail": "",
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 simplify_error_message(message: Any) -> str:
363
+ def to_jsonable(value: Any) -> Any:
623
364
  """
624
- 少量常见 JS 错误解释。
625
-
626
- 原则:
627
- - 不做完整翻译。
628
- - 能稳定解释的才解释。
629
- - 解释不了就返回原始错误。
365
+ 确保对象可被 json.dumps 序列化。probe 发来的 payload 理论上已是 JSON,
366
+ 这里做一层保护,避免后端因奇怪字段崩掉。
630
367
  """
631
- raw = to_safe_string(message).strip()
632
-
633
- if not raw:
634
- return "程序遇到了一个错误"
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
- read_undefined_match = re.search(
653
- r"Cannot read properties of undefined \(reading ['\"](.+?)['\"]\)",
654
- raw,
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
- # 9. CSS
842
- # =============================================================================
843
- # CSS 放在这里有一个好处:
844
- # debug-record.html 是一个完整的独立文件。
845
- # 即使学生作品的 CSS 出问题,这个运行回放页面也不会受影响。
846
-
847
-
848
- def render_style() -> str:
849
- return """
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
- --card: #ffffff;
854
- --text: #262626;
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
- -apple-system,
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
- padding: 28px;
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
- border-radius: 20px;
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
- padding: 14px;
943
- background: #faf7f2;
944
- border: 1px solid #f1e8db;
945
- text-align: center;
946
- }
947
-
948
- .summary-number {
949
- font-size: 30px;
950
- line-height: 1;
951
- font-weight: 800;
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: 1px solid #cdebd5;
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
- gap: 12px;
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
- height: 30px;
1044
- display: grid;
1045
- place-items: center;
1046
- font-size: 20px;
1047
- flex: 0 0 auto;
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
- color: #e5e7eb;
1082
- border-radius: 14px;
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
- .tech-meta {
1093
- display: grid;
1094
- gap: 4px;
1095
- font-size: 13px;
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
- @media (max-width: 720px) {
1101
- .summary-grid {
1102
- grid-template-columns: repeat(2, 1fr);
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
524
+ .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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
- h1 {
1106
- font-size: 29px;
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
- .hero,
1110
- .card {
1111
- padding: 20px;
1112
- border-radius: 18px;
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
- text = to_safe_string(iso_time)
1132
-
1133
- if len(text) >= 19 and "T" in text:
1134
- return text[11:19]
1135
-
1136
- return text
1137
-
1138
-
1139
- def get_file_name(url: Any) -> str:
1140
- """
1141
- 从 URL 或路径中取最后一个文件名。
1142
-
1143
- 示例:
1144
- https://cdn.xxx.com/workspace/player.png?t=123 -> player.png
1145
- /workspace/game.html -> game.html
1146
- """
1147
- text = to_safe_string(url)
1148
-
1149
- if not text:
1150
- return ""
1151
-
1152
- try:
1153
- parsed = urlparse(text)
1154
- path = parsed.path or text.split("?")[0].split("#")[0]
1155
- name = Path(path).name
1156
- return unquote(name)
1157
- except Exception:
1158
- return ""
1159
-
1160
-
1161
- def to_safe_string(value: Any) -> str:
1162
- """
1163
- 尽量把任意值转成字符串。
1164
- """
1165
- if value is None:
1166
- return ""
1167
-
1168
- if isinstance(value, str):
1169
- return value
1170
-
1171
- try:
1172
- return str(value)
1173
- except Exception:
1174
- return ""
1175
-
1176
-
1177
- def to_int(value: Any, default: int) -> int:
1178
- try:
1179
- return int(value)
1180
- except Exception:
1181
- return default
1182
-
1183
-
1184
- def to_jsonable(value: Any) -> Any:
1185
- """
1186
- 确保对象可以被 json.dumps 序列化。
1187
-
1188
- debug-probe 发来的 payload 理论上已经是 JSON。
1189
- 但这里做一层保护,避免后端因为奇怪字段崩掉。
1190
- """
1191
- try:
1192
- json.dumps(value, ensure_ascii=False)
1193
- return value
1194
- except Exception:
1195
- return to_safe_string(value)
1196
-
1197
-
1198
- def escape_html(value: Any) -> str:
1199
- """
1200
- HTML 转义,防止 XSS。
1201
-
1202
- 所有进入 HTML 正文的动态内容,都应该走这个函数。
1203
- """
1204
- return html_escape(to_safe_string(value), quote=True)
1205
-
1206
-
1207
- def safe_json_for_script(value: Any) -> str:
1208
- """
1209
- 把 JSON 安全地放进:
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>"""