@aiyiran/myclaw 1.1.24 → 1.1.26

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.
Files changed (83) hide show
  1. package/.claude/settings.local.json +25 -1
  2. package/assets/myclaw-artifacts.js +1070 -126
  3. package/assets/myclaw-inject.js +913 -121
  4. package/delete_agents.js +268 -0
  5. package/index.js +361 -20
  6. package/package.json +1 -1
  7. package/patches/patch-manifest.json +10 -0
  8. package/server/sync_workspace.py +444 -14
  9. package/skills/yiran-course-template-pipeline/README.md +127 -0
  10. package/skills/yiran-course-template-pipeline/SKILL.md +65 -0
  11. package/skills/yiran-course-template-pipeline/assets/a100-teacher.example.html +66 -0
  12. package/skills/yiran-course-template-pipeline/assets/student-template.html +64 -0
  13. package/skills/yiran-course-template-pipeline/assets/teacher-portrait-demo.html +105 -0
  14. package/skills/yiran-course-template-pipeline/assets/teacher-task-view.html +110 -0
  15. package/skills/yiran-course-template-pipeline/prompts//351/230/266/346/256/2651-demo/347/224/237/346/210/220.md +92 -0
  16. package/skills/yiran-course-template-pipeline/prompts//351/230/266/346/256/2652-student/347/224/237/346/210/220.md +115 -0
  17. package/skills/yiran-course-template-pipeline/prompts//351/230/266/346/256/2653-teacher/347/224/237/346/210/220.md +131 -0
  18. package/skills/yiran-course-template-pipeline/prompts//351/230/266/346/256/2654-/346/211/223/345/214/205/350/220/275/347/233/230.md +77 -0
  19. package/skills/yiran-course-template-pipeline/references/student-example.json +38 -0
  20. package/skills/yiran-course-template-pipeline/references/student-fields.md +195 -0
  21. package/skills/yiran-course-template-pipeline/references/student-scaffold.json +34 -0
  22. package/skills/yiran-course-template-pipeline/references/teacher-fields.md +265 -0
  23. package/skills/yiran-course-template-pipeline/references/teacher-scaffold.json +25 -0
  24. package/skills/yiran-course-template-pipeline/scripts/build_template_views.py +125 -0
  25. package/skills/yiran-course-template-pipeline/scripts/move_template_task.py +59 -0
  26. package/skills/yiran-course-template-pipeline/scripts/render_student_page.py +52 -0
  27. package/skills/yiran-course-template-pipeline/scripts/render_teacher_view.py +108 -0
  28. package/skills/yiran-playground-template-use/SKILL.md +105 -0
  29. package/skills/yiran-playground-template-use/prompts/remix-handoff.txt +11 -0
  30. package/skills/yiran-playground-template-use/scripts/build_template_index.py +103 -0
  31. package/skills/yiran-playground-template-use/scripts/deploy_template.py +34 -0
  32. package/skills/yiran-playground-template-use/scripts/deploy_to_workspace.py +211 -0
  33. package/skills/yiran-playground-template-use/scripts/prepare_playgrounds.py +39 -0
  34. package/skills/yiran-playground-template-use/scripts/query_template.py +171 -0
  35. package/skills/yiran-playground-template-use/scripts/run_playgrounds_flow.py +44 -0
  36. package/skills/yiran-playground-template-use/scripts/start_tui_handoff.py +77 -0
  37. package/skills/yiran-playground-template-use/search-agent-prompt.md +39 -0
  38. package/skills/yiran-playground-template-use/template-index.json +136 -0
  39. package/skills/yiran-playground-template-use/template-index.md +38 -0
  40. package/skills/yiran-playground-template-use/templates/a100_/347/273/231/344/276/235/347/204/266/350/200/201/345/270/210/350/256/276/350/256/241/344/270/200/344/270/252AI/347/224/273/345/203/217/__demo__.html +140 -0
  41. package/skills/yiran-playground-template-use/templates/a100_/347/273/231/344/276/235/347/204/266/350/200/201/345/270/210/350/256/276/350/256/241/344/270/200/344/270/252AI/347/224/273/345/203/217/__student-view__.html +64 -0
  42. package/skills/yiran-playground-template-use/templates/a100_/347/273/231/344/276/235/347/204/266/350/200/201/345/270/210/350/256/276/350/256/241/344/270/200/344/270/252AI/347/224/273/345/203/217/__student__.json +38 -0
  43. package/skills/yiran-playground-template-use/templates/a100_/347/273/231/344/276/235/347/204/266/350/200/201/345/270/210/350/256/276/350/256/241/344/270/200/344/270/252AI/347/224/273/345/203/217/__teacher-view__.html +52 -0
  44. package/skills/yiran-playground-template-use/templates/a100_/347/273/231/344/276/235/347/204/266/350/200/201/345/270/210/350/256/276/350/256/241/344/270/200/344/270/252AI/347/224/273/345/203/217/__teacher__.json +36 -0
  45. package/skills/yiran-playground-template-use/templates/a100_/347/273/231/344/276/235/347/204/266/350/200/201/345/270/210/350/256/276/350/256/241/344/270/200/344/270/252AI/347/224/273/345/203/217/index.html +61 -0
  46. package/skills/yiran-playground-template-use/templates/a101_/345/201/2323/345/274/240/345/220/214/344/270/273/351/242/230/345/233/276/347/211/207/__demo__.html +131 -0
  47. package/skills/yiran-playground-template-use/templates/a101_/345/201/2323/345/274/240/345/220/214/344/270/273/351/242/230/345/233/276/347/211/207/__student-view__.html +64 -0
  48. package/skills/yiran-playground-template-use/templates/a101_/345/201/2323/345/274/240/345/220/214/344/270/273/351/242/230/345/233/276/347/211/207/__student__.json +34 -0
  49. package/skills/yiran-playground-template-use/templates/a101_/345/201/2323/345/274/240/345/220/214/344/270/273/351/242/230/345/233/276/347/211/207/__teacher-view__.html +52 -0
  50. package/skills/yiran-playground-template-use/templates/a101_/345/201/2323/345/274/240/345/220/214/344/270/273/351/242/230/345/233/276/347/211/207/__teacher__.json +34 -0
  51. package/skills/yiran-playground-template-use/templates/a103_/345/201/232/344/270/200/344/270/252/344/273/213/347/273/215/351/241/265/351/235/242/__demo__.html +77 -0
  52. package/skills/yiran-playground-template-use/templates/a103_/345/201/232/344/270/200/344/270/252/344/273/213/347/273/215/351/241/265/351/235/242/__student-view__.html +64 -0
  53. package/skills/yiran-playground-template-use/templates/a103_/345/201/232/344/270/200/344/270/252/344/273/213/347/273/215/351/241/265/351/235/242/__student__.json +38 -0
  54. package/skills/yiran-playground-template-use/templates/a103_/345/201/232/344/270/200/344/270/252/344/273/213/347/273/215/351/241/265/351/235/242/__teacher-view__.html +52 -0
  55. package/skills/yiran-playground-template-use/templates/a103_/345/201/232/344/270/200/344/270/252/344/273/213/347/273/215/351/241/265/351/235/242/__teacher__.json +34 -0
  56. package/skills/yiran-playground-template-use/templates/b100_/345/201/232/344/270/200/344/270/252/346/214/211/351/222/256/351/241/265/351/235/242/__demo__.html +162 -0
  57. package/skills/yiran-playground-template-use/templates/b100_/345/201/232/344/270/200/344/270/252/346/214/211/351/222/256/351/241/265/351/235/242/__student-view__.html +64 -0
  58. package/skills/yiran-playground-template-use/templates/b100_/345/201/232/344/270/200/344/270/252/346/214/211/351/222/256/351/241/265/351/235/242/__student__.json +34 -0
  59. package/skills/yiran-playground-template-use/templates/b100_/345/201/232/344/270/200/344/270/252/346/214/211/351/222/256/351/241/265/351/235/242/__teacher-view__.html +52 -0
  60. package/skills/yiran-playground-template-use/templates/b100_/345/201/232/344/270/200/344/270/252/346/214/211/351/222/256/351/241/265/351/235/242/__teacher__.json +34 -0
  61. package/skills/yiran-playground-template-use/templates/c100_/347/273/231/345/260/217/347/273/204/344/275/234/345/223/201/345/201/232/344/270/200/346/254/241/345/260/217/344/277/256/346/224/271/__demo__.html +180 -0
  62. package/skills/yiran-playground-template-use/templates/c100_/347/273/231/345/260/217/347/273/204/344/275/234/345/223/201/345/201/232/344/270/200/346/254/241/345/260/217/344/277/256/346/224/271/__student-view__.html +64 -0
  63. package/skills/yiran-playground-template-use/templates/c100_/347/273/231/345/260/217/347/273/204/344/275/234/345/223/201/345/201/232/344/270/200/346/254/241/345/260/217/344/277/256/346/224/271/__student__.json +38 -0
  64. package/skills/yiran-playground-template-use/templates/c100_/347/273/231/345/260/217/347/273/204/344/275/234/345/223/201/345/201/232/344/270/200/346/254/241/345/260/217/344/277/256/346/224/271/__teacher-view__.html +52 -0
  65. package/skills/yiran-playground-template-use/templates/c100_/347/273/231/345/260/217/347/273/204/344/275/234/345/223/201/345/201/232/344/270/200/346/254/241/345/260/217/344/277/256/346/224/271/__teacher__.json +41 -0
  66. package/skills/yiran-playground-template-use/templates/c100_/347/273/231/345/260/217/347/273/204/344/275/234/345/223/201/345/201/232/344/270/200/346/254/241/345/260/217/344/277/256/346/224/271/demo.html +180 -0
  67. package/skills/yiran-playground-template-use/templates/c100_/347/273/231/345/260/217/347/273/204/344/275/234/345/223/201/345/201/232/344/270/200/346/254/241/345/260/217/344/277/256/346/224/271/index.html +121 -0
  68. package/skills/yiran-playground-template-use/templates/c100_/347/273/231/345/260/217/347/273/204/344/275/234/345/223/201/345/201/232/344/270/200/346/254/241/345/260/217/344/277/256/346/224/271//345/260/217/347/273/204/345/220/211/347/245/245/347/211/251_26.png +0 -0
  69. package/skills/yiran-playground-template-use/templates/c100_/347/273/231/345/260/217/347/273/204/344/275/234/345/223/201/345/201/232/344/270/200/346/254/241/345/260/217/344/277/256/346/224/271//345/260/217/347/273/204/345/233/276/345/275/242/346/240/207/345/277/227_83.png +0 -0
  70. package/skills/yiran-playground-template-use/templates/c100_/347/273/231/345/260/217/347/273/204/344/275/234/345/223/201/345/201/232/344/270/200/346/254/241/345/260/217/344/277/256/346/224/271//347/217/255/347/272/247/345/260/217/347/273/204/345/276/275/347/253/240_47.png +0 -0
  71. package/skills/yiran-skill-media/SKILL.md +6 -15
  72. package/skills/yiran-skill-media/scripts/generate.py +47 -18
  73. package/skills/yiran-skill-media/scripts/generation_log.json +1 -56
  74. package/skills/yiran-skill-media/scripts/providers/__pycache__/__init__.cpython-311.pyc +0 -0
  75. package/skills/yiran-skill-media/scripts/providers/__pycache__/__init__.cpython-37.pyc +0 -0
  76. package/skills/yiran-skill-media/scripts/providers/__pycache__/jimeng_image.cpython-37.pyc +0 -0
  77. package/skills/yiran-skill-media/scripts/providers/__pycache__/jimeng_video.cpython-311.pyc +0 -0
  78. package/skills/yiran-skill-media/scripts/providers/__pycache__/jimeng_video.cpython-37.pyc +0 -0
  79. package/skills/yiran-skill-media/scripts/providers/__pycache__/minimax_image.cpython-37.pyc +0 -0
  80. package/skills/yiran-skill-media/scripts/providers/__pycache__/minimax_music.cpython-37.pyc +0 -0
  81. package/skills/yiran-skill-media/scripts/providers/__pycache__/minimax_video.cpython-311.pyc +0 -0
  82. package/skills/yiran-skill-media/scripts/providers/__pycache__/minimax_video.cpython-37.pyc +0 -0
  83. package/skills/yiran-skill-media/scripts/providers/__pycache__/vapi_image.cpython-37.pyc +0 -0
@@ -51,10 +51,136 @@ SYNC_ARTIFACTS_JSON_TO_OSS = False
51
51
 
52
52
 
53
53
  # 忽略的目录名(路径中只要包含这些目录段就跳过)
54
- IGNORE_DIR_PATTERNS = {'.git', 'node_modules', '__pycache__', '.myclaw'}
54
+ IGNORE_DIR_PATTERNS = {'.git', 'node_modules', '__pycache__', '.myclaw', '__history__', 'history'}
55
55
 
56
56
  # 忽略的文件名
57
- IGNORE_FILE_PATTERNS = {'.myclaw-sync.pid', '.DS_Store', '.gitignore', '.gitkeep'}
57
+ IGNORE_FILE_PATTERNS = {'.myclaw-sync.pid', '.DS_Store', '.gitignore', '.gitkeep', 'history.json'}
58
+
59
+ # 历史版本目录名和索引文件名
60
+ HISTORY_DIR = "__history__"
61
+ HISTORY_FILENAME = "history.json"
62
+
63
+ # 回滚压制表:{ abs_path: 写入时间戳 }
64
+ # 回滚时往 workspace 写文件会触发 on_modified,用此表跳过快照,避免产生多余版本
65
+ _rollback_suppressed: dict = {}
66
+ _ROLLBACK_SUPPRESS_TTL = 3.0 # 秒
67
+
68
+
69
+ def _is_rollback_suppressed(abs_path: str) -> bool:
70
+ ts = _rollback_suppressed.get(abs_path)
71
+ if ts and time.time() - ts < _ROLLBACK_SUPPRESS_TTL:
72
+ return True
73
+ _rollback_suppressed.pop(abs_path, None)
74
+ return False
75
+
76
+
77
+ def _load_history_json(history_json_path: str, workspace_id: str) -> dict:
78
+ if os.path.exists(history_json_path):
79
+ try:
80
+ with open(history_json_path, "r", encoding="utf-8") as f:
81
+ return json.load(f)
82
+ except Exception:
83
+ pass
84
+ return _empty_history(workspace_id)
85
+
86
+
87
+ def save_history_version(workspace_id: str, relative_path: str, file_path: str):
88
+ """把当前文件内容作为新快照写入 __history__/vN/,并更新 history.json。"""
89
+ base_path = get_openclaw_path()
90
+ history_base = os.path.join(base_path, workspace_id, HISTORY_DIR)
91
+ history_json_path = os.path.join(history_base, HISTORY_FILENAME)
92
+
93
+ os.makedirs(history_base, exist_ok=True)
94
+
95
+ history_data = _load_history_json(history_json_path, workspace_id)
96
+
97
+ next_ver = history_data.get("next_version", 1)
98
+ ver_dir_name = f"v{next_ver}"
99
+
100
+ # 复制当前文件内容到 __history__/vN/<relative_path>
101
+ rel_parts = relative_path.replace("\\", "/").split("/")
102
+ dest_path = os.path.join(history_base, ver_dir_name, *rel_parts)
103
+ os.makedirs(os.path.dirname(dest_path), exist_ok=True)
104
+ try:
105
+ with open(file_path, "rb") as src, open(dest_path, "wb") as dst:
106
+ dst.write(src.read())
107
+ except Exception as e:
108
+ print(f"[history] 快照写入失败: {e}")
109
+ return
110
+
111
+ # 更新 next_version、records、current_versions
112
+ history_data["next_version"] = next_ver + 1
113
+ history_data["records"].append({
114
+ "version": next_ver,
115
+ "version_dir": ver_dir_name,
116
+ "path": relative_path,
117
+ "snapshot_at": now_iso()
118
+ })
119
+ if "current_versions" not in history_data:
120
+ history_data["current_versions"] = {}
121
+ history_data["current_versions"][relative_path] = next_ver
122
+
123
+ with open(history_json_path, "w", encoding="utf-8") as f:
124
+ json.dump(history_data, f, ensure_ascii=False, indent=2)
125
+
126
+ print(f"[history] 已保存快照 {ver_dir_name}: {relative_path}")
127
+
128
+
129
+ def rollback_to_version(workspace_id: str, relative_path: str, version_dir: str):
130
+ """
131
+ 将指定历史版本的文件复制回 workspace,更新 current_versions。
132
+ 返回错误信息字符串,成功返回 None。
133
+ """
134
+ base_path = get_openclaw_path()
135
+ history_base = os.path.join(base_path, workspace_id, HISTORY_DIR)
136
+ history_json_path = os.path.join(history_base, HISTORY_FILENAME)
137
+
138
+ # 安全检查
139
+ if ".." in version_dir or ".." in relative_path:
140
+ return "invalid path"
141
+
142
+ rel_parts = relative_path.replace("\\", "/").split("/")
143
+ src_path = os.path.join(history_base, version_dir, *rel_parts)
144
+ if not os.path.exists(src_path):
145
+ return f"历史文件不存在: {version_dir}/{relative_path}"
146
+
147
+ workspace_path = os.path.join(base_path, workspace_id)
148
+ dest_path = os.path.join(workspace_path, *rel_parts)
149
+
150
+ # 压制本次写盘触发的 on_modified 快照
151
+ _rollback_suppressed[dest_path] = time.time()
152
+
153
+ try:
154
+ os.makedirs(os.path.dirname(dest_path), exist_ok=True)
155
+ with open(src_path, "rb") as src, open(dest_path, "wb") as dst:
156
+ dst.write(src.read())
157
+ except Exception as e:
158
+ _rollback_suppressed.pop(dest_path, None)
159
+ return f"文件写入失败: {e}"
160
+
161
+ # 更新 current_versions(从 version_dir 解析版本号)
162
+ history_data = _load_history_json(history_json_path, workspace_id)
163
+ if "current_versions" not in history_data:
164
+ history_data["current_versions"] = {}
165
+ try:
166
+ ver_num = int(version_dir.lstrip("v"))
167
+ except ValueError:
168
+ ver_num = -1
169
+ history_data["current_versions"][relative_path] = ver_num
170
+ with open(history_json_path, "w", encoding="utf-8") as f:
171
+ json.dump(history_data, f, ensure_ascii=False, indent=2)
172
+
173
+ print(f"[history] 已回滚 {relative_path} → {version_dir}")
174
+ return None
175
+
176
+
177
+ def _empty_history(workspace_id: str) -> dict:
178
+ return {
179
+ "workspace_id": workspace_id,
180
+ "next_version": 1,
181
+ "current_versions": {},
182
+ "records": []
183
+ }
58
184
 
59
185
 
60
186
  def _path_has_ignored_dir(filepath):
@@ -217,7 +343,12 @@ def file_gen(path, method):
217
343
  init_config(space_id, relative_path, method="delete")
218
344
  print(f"🗑️ 已删除配置记录: {relative_path}")
219
345
  else:
220
- # 添加或修改时进行上传
346
+ # ── 历史版本:把当前内容作为快照存档(仅覆盖行为,非配置文件)──
347
+ # 回滚写盘触发的 on_modified 跳过,避免产生多余版本
348
+ if os.path.basename(path) != ARTIFACTS_FILENAME and not _is_rollback_suppressed(path):
349
+ save_history_version(space_id, relative_path, path)
350
+
351
+ # ── 上传到 OSS ───────────────────────────────────────────
221
352
  try:
222
353
  token = q.upload_token(bucket_name, key, 3600)
223
354
  # 直接使用 put_file_v2 上传文件路径,避免读取到空内容时导致 SDK 报错缺少 data 参数
@@ -440,28 +571,28 @@ def _do_fork(job_id, src_clawname, src_workspace, src_version, entry_rel_path=No
440
571
 
441
572
  print(f"[Fork] 找到 {len(files)} 个文件,开始下载...")
442
573
  success = 0
574
+ downloaded_rels = []
443
575
  for item in files:
444
576
  key = item['key']
445
- # 去掉前缀,得到相对路径(CDN key 是 URL 编码过的,decode 成实际文件名)
446
577
  rel_path = unquote(key[len(prefix):])
447
578
  if not rel_path:
448
579
  continue
449
580
  local_path = os.path.join(target_dir, rel_path)
450
581
  if _download_from_cdn(key, local_path):
451
582
  success += 1
583
+ downloaded_rels.append(rel_path)
452
584
  print(f" ✓ {rel_path}")
453
585
 
454
586
  print(f"[Fork] 完成: 成功 {success}/{len(files)} 个文件")
455
587
 
456
- # 更新入口文件 mtime,使其在列表中排最前
457
- # entry_rel_path 可能是 URL 编码(含中文),需 decode 为本地文件名
458
- if entry_rel_path:
459
- entry_local = os.path.join(target_dir, unquote(entry_rel_path))
460
- if os.path.exists(entry_local):
461
- os.utime(entry_local, None)
462
- print(f"[Fork] 已更新入口文件 mtime: {entry_rel_path}")
463
- else:
464
- print(f"[Fork] 警告: 入口文件不存在,跳过 mtime 更新: {entry_rel_path}")
588
+ # 写入 JSON 记录,入口文件最后写,确保其 updated_at 最新排第一
589
+ entry_decoded = unquote(entry_rel_path) if entry_rel_path else None
590
+ for rel in downloaded_rels:
591
+ if rel != entry_decoded:
592
+ init_config(new_workspace, rel, method="add")
593
+ if entry_decoded and entry_decoded in downloaded_rels:
594
+ init_config(new_workspace, entry_decoded, method="add")
595
+ print(f"[Fork] 入口文件已置顶: {entry_decoded}")
465
596
 
466
597
  # 文件到位后再发分析消息,确保 AI 能读到 fork 的内容
467
598
  fork_message = (
@@ -529,7 +660,7 @@ def start_fork(remote_url):
529
660
  # 内嵌 HTTP API 服务
530
661
  # ═══════════════════════════════════════════════════════════════
531
662
 
532
- API_PORT = int(os.environ.get("MYCLAW_API_PORT", "8080"))
663
+ API_PORT = int(os.environ.get("MYCLAW_API_PORT", "18800"))
533
664
 
534
665
 
535
666
  class MyclawAPIHandler(BaseHTTPRequestHandler):
@@ -588,6 +719,14 @@ class MyclawAPIHandler(BaseHTTPRequestHandler):
588
719
  return self._handle_resync(params)
589
720
  elif path == '/api/fork':
590
721
  return self._handle_fork()
722
+ elif path == '/api/history/rollback':
723
+ return self._handle_rollback()
724
+ elif path == '/api/agents/trash':
725
+ return self._handle_agents_trash()
726
+ elif path == '/api/template/copy':
727
+ return self._handle_template_copy()
728
+ elif path == '/api/template/deploy':
729
+ return self._handle_template_deploy()
591
730
  else:
592
731
  self._send_json({"error": "not found"}, 404)
593
732
 
@@ -598,11 +737,67 @@ class MyclawAPIHandler(BaseHTTPRequestHandler):
598
737
  return self._handle_config()
599
738
  elif path == '/api/artifacts':
600
739
  return self._handle_artifacts(params)
740
+ elif path == '/api/history':
741
+ return self._handle_history(params)
742
+ elif path == '/api/history/file':
743
+ return self._handle_history_file(params)
601
744
  elif path == '/api/fork/status':
602
745
  return self._handle_fork_status(params)
746
+ elif path == '/api/agents':
747
+ return self._handle_agents_list()
748
+ elif path == '/api/file':
749
+ return self._handle_file(params)
603
750
  else:
604
751
  self._send_json({"error": "not found"}, 404)
605
752
 
753
+ def _handle_agents_list(self):
754
+ """GET /api/agents → 复用 mc list-agents --json"""
755
+ try:
756
+ ret = subprocess.run(
757
+ ['mc', 'list-agents', '--json'],
758
+ capture_output=True, timeout=15, text=True
759
+ )
760
+ if ret.returncode == 0:
761
+ self._send_json_raw(ret.stdout.strip().encode('utf-8'))
762
+ else:
763
+ self._send_json({'error': ret.stderr.strip() or 'mc list-agents 执行失败'}, 500)
764
+ except FileNotFoundError:
765
+ self._send_json({'error': 'mc 命令未找到,请确认 MyClaw 已安装'}, 500)
766
+ except subprocess.TimeoutExpired:
767
+ self._send_json({'error': 'mc list-agents 超时'}, 500)
768
+ except Exception as e:
769
+ self._send_json({'error': str(e)}, 500)
770
+
771
+ def _handle_agents_trash(self):
772
+ """POST /api/agents/trash Body: {"paths": [...]} → 复用 mc trash-paths"""
773
+ try:
774
+ length = int(self.headers.get('Content-Length', 0))
775
+ body = json.loads(self.rfile.read(length).decode('utf-8')) if length else {}
776
+ except Exception:
777
+ self._send_json({'error': '请求体解析失败'}, 400)
778
+ return
779
+
780
+ paths = [p for p in body.get('paths', []) if p and isinstance(p, str)]
781
+ if not paths:
782
+ self._send_json({'error': '缺少有效的 paths 参数'}, 400)
783
+ return
784
+
785
+ try:
786
+ ret = subprocess.run(
787
+ ['mc', 'trash-paths', '--json'] + paths,
788
+ capture_output=True, timeout=30, text=True
789
+ )
790
+ if ret.returncode in (0, 1): # 1 = 部分失败,仍返回 JSON
791
+ self._send_json_raw(ret.stdout.strip().encode('utf-8'))
792
+ else:
793
+ self._send_json({'error': ret.stderr.strip() or 'mc trash-paths 执行失败'}, 500)
794
+ except FileNotFoundError:
795
+ self._send_json({'error': 'mc 命令未找到,请确认 MyClaw 已安装'}, 500)
796
+ except subprocess.TimeoutExpired:
797
+ self._send_json({'error': 'mc trash-paths 超时'}, 500)
798
+ except Exception as e:
799
+ self._send_json({'error': str(e)}, 500)
800
+
606
801
  def _handle_config(self):
607
802
  """GET /api/config → 返回 claw 名称和 CDN base_url"""
608
803
  self._send_json({"claw": claw, "base_url": BASE_URL})
@@ -628,6 +823,95 @@ class MyclawAPIHandler(BaseHTTPRequestHandler):
628
823
  except Exception as e:
629
824
  self._send_json({"error": str(e)}, 500)
630
825
 
826
+ def _handle_history(self, params):
827
+ """GET /api/history?workspace=xxx → 返回 __history__/history.json"""
828
+ workspace = params.get('workspace', [''])[0]
829
+ if not workspace:
830
+ self._send_json({"error": "missing workspace param"}, 400)
831
+ return
832
+
833
+ base_path = get_openclaw_path()
834
+ json_path = os.path.join(base_path, workspace, HISTORY_DIR, HISTORY_FILENAME)
835
+
836
+ if not os.path.exists(json_path):
837
+ self._send_json({"workspace_id": workspace, "next_version": 1, "records": []})
838
+ return
839
+
840
+ try:
841
+ with open(json_path, 'r', encoding='utf-8') as f:
842
+ content = f.read()
843
+ self._send_json_raw(content.encode('utf-8'))
844
+ except Exception as e:
845
+ self._send_json({"error": str(e)}, 500)
846
+
847
+ def _handle_history_file(self, params):
848
+ """GET /api/history/file?workspace=xxx&version=v1&path=xxx → 返回历史版本文件内容"""
849
+ workspace = params.get('workspace', [''])[0]
850
+ version = params.get('version', [''])[0]
851
+ rel_path = params.get('path', [''])[0]
852
+
853
+ if not workspace or not version or not rel_path:
854
+ self._send_json({"error": "missing params"}, 400)
855
+ return
856
+
857
+ # 安全检查:防止路径穿越
858
+ if '..' in version or '..' in rel_path:
859
+ self._send_json({"error": "invalid path"}, 400)
860
+ return
861
+
862
+ base_path = get_openclaw_path()
863
+ rel_parts = rel_path.replace('\\', '/').split('/')
864
+ file_path = os.path.join(base_path, workspace, HISTORY_DIR, version, *rel_parts)
865
+
866
+ if not os.path.exists(file_path):
867
+ self._send_json({"error": "file not found"}, 404)
868
+ return
869
+
870
+ try:
871
+ with open(file_path, 'rb') as f:
872
+ content = f.read()
873
+ # 根据扩展名设置 Content-Type
874
+ ext = rel_path.rsplit('.', 1)[-1].lower() if '.' in rel_path else ''
875
+ ct_map = {
876
+ 'html': 'text/html; charset=utf-8',
877
+ 'htm': 'text/html; charset=utf-8',
878
+ 'css': 'text/css; charset=utf-8',
879
+ 'js': 'application/javascript; charset=utf-8',
880
+ 'json': 'application/json; charset=utf-8',
881
+ }
882
+ content_type = ct_map.get(ext, 'text/plain; charset=utf-8')
883
+ self.send_response(200)
884
+ self._cors_headers()
885
+ self.send_header('Content-Type', content_type)
886
+ self.send_header('Content-Length', str(len(content)))
887
+ self.end_headers()
888
+ self.wfile.write(content)
889
+ except Exception as e:
890
+ self._send_json({"error": str(e)}, 500)
891
+
892
+ def _handle_rollback(self):
893
+ """POST /api/history/rollback Body: {"workspace":"...","version":"v2","path":"..."}"""
894
+ try:
895
+ length = int(self.headers.get('Content-Length', 0))
896
+ body = json.loads(self.rfile.read(length).decode('utf-8')) if length else {}
897
+ except Exception:
898
+ self._send_json({"error": "请求体解析失败"}, 400)
899
+ return
900
+
901
+ workspace = body.get('workspace', '')
902
+ version = body.get('version', '')
903
+ rel_path = body.get('path', '')
904
+
905
+ if not workspace or not version or not rel_path:
906
+ self._send_json({"error": "缺少参数"}, 400)
907
+ return
908
+
909
+ err = rollback_to_version(workspace, rel_path, version)
910
+ if err:
911
+ self._send_json({"ok": False, "error": err}, 400)
912
+ else:
913
+ self._send_json({"ok": True})
914
+
631
915
  def _handle_resync(self, params):
632
916
  """POST /api/resync?workspace=xxx → 触发全量同步"""
633
917
  workspace = params.get('workspace', [''])[0]
@@ -671,6 +955,152 @@ class MyclawAPIHandler(BaseHTTPRequestHandler):
671
955
  return
672
956
  self._send_json(_fork_jobs[job_id])
673
957
 
958
+ def _skill_scripts_root(self):
959
+ """返回 yiran-playground-template-use/scripts 的绝对路径"""
960
+ return os.path.join(
961
+ get_openclaw_path(), 'workspace-ai-demo', 'skills',
962
+ 'yiran-playground-template-use', 'scripts'
963
+ )
964
+
965
+ def _resolve_template_path(self, folder):
966
+ """校验 folder 并返回模板绝对路径;非法时直接响应并返回 None"""
967
+ if not folder or '..' in folder or '/' in folder or '\\' in folder:
968
+ self._send_json({'error': 'invalid folder'}, 400)
969
+ return None
970
+ path = os.path.join(
971
+ get_openclaw_path(), 'workspace-ai-demo', 'skills',
972
+ 'yiran-playground-template-use', 'templates', folder
973
+ )
974
+ if not os.path.isdir(path):
975
+ self._send_json({'error': f'模板目录不存在: {folder}'}, 404)
976
+ return None
977
+ return path
978
+
979
+ def _run_skill_script(self, cmd, timeout=60):
980
+ """运行 skill 脚本,解析 stdout JSON 并返回 (data, err)"""
981
+ try:
982
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
983
+ output = (result.stdout or '').strip()
984
+ if output:
985
+ try:
986
+ return json.loads(output), None
987
+ except json.JSONDecodeError:
988
+ pass
989
+ return None, result.stderr.strip() or output or '未知错误'
990
+ except subprocess.TimeoutExpired:
991
+ return None, f'执行超时({timeout}s)'
992
+ except Exception as e:
993
+ return None, str(e)
994
+
995
+ def _handle_template_copy(self):
996
+ """POST /api/template/copy Body: {"folder": "a100_...", "workspace": "workspace-xxx"}
997
+ → deploy_to_workspace.py <template_path> <workspace_abs_path> (不加 --with-tui)"""
998
+ try:
999
+ length = int(self.headers.get('Content-Length', 0))
1000
+ body = json.loads(self.rfile.read(length).decode('utf-8')) if length else {}
1001
+ except Exception:
1002
+ self._send_json({'error': '请求体解析失败'}, 400)
1003
+ return
1004
+
1005
+ folder = body.get('folder', '')
1006
+ workspace = body.get('workspace', '') # e.g. "workspace-xxx"
1007
+ if not workspace:
1008
+ self._send_json({'error': '缺少 workspace 参数'}, 400)
1009
+ return
1010
+
1011
+ template_path = self._resolve_template_path(folder)
1012
+ if template_path is None:
1013
+ return
1014
+
1015
+ workspace_abs = os.path.join(get_openclaw_path(), workspace)
1016
+ script = os.path.join(self._skill_scripts_root(), 'deploy_to_workspace.py')
1017
+
1018
+ data, err = self._run_skill_script(
1019
+ [sys.executable, script, template_path, workspace_abs],
1020
+ timeout=30
1021
+ )
1022
+ if err:
1023
+ self._send_json({'ok': False, 'error': err}, 500)
1024
+ else:
1025
+ self._send_json(data)
1026
+
1027
+ def _handle_template_deploy(self):
1028
+ """POST /api/template/deploy Body: {"folder": "a100_..."}
1029
+ → run_playgrounds_flow.py <template_path> (prepare + deploy + --with-tui)"""
1030
+ try:
1031
+ length = int(self.headers.get('Content-Length', 0))
1032
+ body = json.loads(self.rfile.read(length).decode('utf-8')) if length else {}
1033
+ except Exception:
1034
+ self._send_json({'error': '请求体解析失败'}, 400)
1035
+ return
1036
+
1037
+ folder = body.get('folder', '')
1038
+ template_path = self._resolve_template_path(folder)
1039
+ if template_path is None:
1040
+ return
1041
+
1042
+ script = os.path.join(self._skill_scripts_root(), 'run_playgrounds_flow.py')
1043
+
1044
+ data, err = self._run_skill_script(
1045
+ [sys.executable, script, template_path],
1046
+ timeout=60
1047
+ )
1048
+ if err:
1049
+ self._send_json({'ok': False, 'error': err}, 500)
1050
+ else:
1051
+ self._send_json(data)
1052
+
1053
+ def _handle_file(self, params):
1054
+ """GET /api/file?path=relative/path → 通用文件服务,路径相对于 .openclaw 根目录
1055
+ 安全:os.path.realpath 解析后必须以 openclaw 根为前缀,防止路径穿越"""
1056
+ rel_path = params.get('path', [''])[0]
1057
+ if not rel_path:
1058
+ self._send_json({"error": "missing path param"}, 400)
1059
+ return
1060
+
1061
+ base_path = os.path.realpath(get_openclaw_path())
1062
+ abs_path = os.path.realpath(os.path.join(base_path, rel_path))
1063
+
1064
+ # 路径必须在 .openclaw 目录内
1065
+ if not abs_path.startswith(base_path + os.sep):
1066
+ self._send_json({"error": "forbidden"}, 403)
1067
+ return
1068
+
1069
+ if not os.path.isfile(abs_path):
1070
+ self._send_json({"error": "file not found"}, 404)
1071
+ return
1072
+
1073
+ try:
1074
+ with open(abs_path, 'rb') as f:
1075
+ content = f.read()
1076
+
1077
+ ext = abs_path.rsplit('.', 1)[-1].lower() if '.' in abs_path else ''
1078
+ ct_map = {
1079
+ 'html': 'text/html; charset=utf-8',
1080
+ 'htm': 'text/html; charset=utf-8',
1081
+ 'css': 'text/css; charset=utf-8',
1082
+ 'js': 'application/javascript; charset=utf-8',
1083
+ 'json': 'application/json; charset=utf-8',
1084
+ 'txt': 'text/plain; charset=utf-8',
1085
+ 'md': 'text/plain; charset=utf-8',
1086
+ 'png': 'image/png',
1087
+ 'jpg': 'image/jpeg',
1088
+ 'jpeg': 'image/jpeg',
1089
+ 'gif': 'image/gif',
1090
+ 'svg': 'image/svg+xml',
1091
+ 'webp': 'image/webp',
1092
+ }
1093
+ content_type = ct_map.get(ext, 'application/octet-stream')
1094
+
1095
+ self.send_response(200)
1096
+ self._cors_headers()
1097
+ self.send_header('Content-Type', content_type)
1098
+ self.send_header('Content-Length', str(len(content)))
1099
+ self.end_headers()
1100
+ self.wfile.write(content)
1101
+ except Exception as e:
1102
+ self._send_json({"error": str(e)}, 500)
1103
+
674
1104
  def log_message(self, format, *args):
675
1105
  # 静默日志,避免轮询刷屏
676
1106
  pass
@@ -0,0 +1,127 @@
1
+ # 课程模板流水线 Skill
2
+
3
+ 这个 skill 面向中文课堂的小专题课程设计流程。
4
+
5
+ 它的目标不是只生成某一个页面,而是把一个“题目灵感 / 设计角度”逐步变成一整套可用的课程任务文件。
6
+
7
+ ---
8
+
9
+ # 当前工作流
10
+
11
+ ## 阶段一:灵感 -> demo 页面
12
+ 最开始输入的是:
13
+ - 题目灵感
14
+ - 设计角度
15
+ - 课堂上的切入想法
16
+
17
+ 这一阶段最重要的事情是:
18
+ **先做出 __demo__.html(效果演示页)**
19
+
20
+ 阶段一到阶段三的产物,统一放在:
21
+ **当前工作目录下新建的 `任务名称/` 文件夹里。**
22
+
23
+ 也就是说:
24
+ - 不先放到 templates
25
+ - 不先放到 skill 目录根部
26
+ - 而是在当前工作目录里,先建立一个任务临时文件夹,再往里写 `__demo__.html`、`__student__.json`、`__teacher__.json`
27
+
28
+ ---
29
+
30
+ ## 阶段二:生成 __student__.json
31
+ 这一阶段不要求一定已经有 __teacher__.json。
32
+ 这一阶段甚至不要求一定已经有完整作品。
33
+
34
+ 这一阶段的核心目标是:
35
+ **先把学生任务的步骤和评价结构 JSON 确立下来。**
36
+
37
+ 对应输出:
38
+ `__student__.json`
39
+
40
+ ---
41
+
42
+ ## 阶段三:生成 __teacher__.json
43
+ 在 __student__.json 已经明确后,再整理教师侧信息。
44
+
45
+ 这一阶段的核心目标是:
46
+ **明确题目设计目的、训练重点、适合学生类型,以及老师上课时如何判断和引导。**
47
+
48
+ 对应输出:
49
+ `__teacher__.json`
50
+
51
+ ---
52
+
53
+ ## 阶段四:JSON -> HTML,并按目录打包
54
+ 第4阶段现在拆成两个脚本:
55
+
56
+ ### 1)build_template_views.py
57
+ 校验通过后,在原文件夹中直接生成:
58
+ - `__student-view__.html`
59
+ - `__teacher-view__.html`
60
+ - `index.html`
61
+
62
+ 其中 `index.html` 采用固定脚本规则:
63
+ - 只展示两块内容
64
+ - 左边是“评价目标”
65
+ - 右边是“步骤”
66
+ - 不显示任务标题
67
+ - 不显示一句话说明
68
+ - 不再依赖临时提示词拼装
69
+ - 统一由脚本直接根据 `__student__.json` 渲染
70
+
71
+ ### 2)move_template_task.py
72
+ 在页面已经生成好的前提下,把整个文件夹移动到:
73
+ `yiran-playground-template-use` skill 的 `templates/`
74
+
75
+ 同时按规则改名,例如:
76
+ `a100_给依然老师设计一个AI画像`
77
+
78
+ 并且:
79
+ **move 成功后会自动触发 `yiran-playground-template-use` 的索引重建。**
80
+
81
+ ---
82
+
83
+ # 当前目录结构
84
+
85
+ - `SKILL.md`:技能总说明
86
+ - `prompts/阶段1-demo生成.md`:如何把灵感先做成 demo 页面
87
+ - `prompts/阶段2-student生成.md`:如何生成 __student__.json
88
+ - `prompts/阶段3-teacher生成.md`:如何生成 __teacher__.json
89
+ - `prompts/阶段4-打包落盘.md`:如何组织文件并调用第4阶段脚本
90
+ - `scripts/render_teacher_view.py`:把 __teacher__.json 渲染成教师查看页
91
+ - `scripts/render_student_page.py`:把 __student__.json 渲染成学生页
92
+ - `scripts/build_template_views.py`:在原目录生成 __student-view__.html 和 __teacher-view__.html
93
+ - `scripts/move_template_task.py`:移动整个任务文件夹到 templates 并改名,同时自动重建索引
94
+ - `references/teacher-fields.md`:教师侧字段设计与标签规则说明
95
+ - `references/student-fields.md`:学生侧字段说明
96
+ - `references/student-example.json`:学生侧 JSON 示例
97
+ - `references/student-scaffold.json`:学生侧 JSON 脚手架
98
+ - `references/teacher-scaffold.json`:教师侧 JSON 脚手架
99
+ - `assets/student-template.html`:独立学生页模板
100
+ - 模板库和搜索索引统一维护在 `yiran-playground-template-use` skill 中
101
+
102
+ ---
103
+
104
+ # 模板索引
105
+
106
+ 模板库和索引统一维护在 `yiran-playground-template-use` skill 中。
107
+
108
+ `move_template_task.py` 成功执行后会自动触发索引重建。如需手动重建,在 `yiran-playground-template-use` skill 中运行:
109
+
110
+ ```bash
111
+ python3 scripts/build_template_index.py
112
+ ```
113
+
114
+ ---
115
+
116
+ # 核心原则
117
+
118
+ 1. 先有灵感,再做 demo,再定 __student__.json,再定 __teacher__.json
119
+ 2. 阶段一到三的产物,统一放在当前工作目录下新建的任务文件夹里
120
+ 3. __student__.json 先解决学生任务结构
121
+ 4. __teacher__.json 再解决题目设计目的与教师判断
122
+ 5. 第4阶段拆成 build 和 move 两个独立脚本
123
+ 6. templates 作为 skill 的资产目录统一维护
124
+ 7. 模板有变化后,要重新构建索引
125
+ 8. demo 中的媒体资源优先使用 CDN 链接,不使用本地相对路径;当前固定前缀为 `https://cdn.yiranlaoshi.com/yiran/workspace-ai-demo`,后面拼接资源相对路径
126
+ 9. 模板负责布局,提示词负责填内容,脚本负责稳定输出
127
+ 10. 所有内容优先服务中文课堂和小学生阅读场景