@aiyiran/myclaw 1.1.161 → 1.1.164

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