@aiyiran/myclaw 1.1.139 → 1.1.140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md ADDED
@@ -0,0 +1,164 @@
1
+ # MyClaw 架构说明
2
+
3
+ > 本文件由 Claude Code 每次会话自动加载,也是给人类协作者的架构索引。
4
+ > 项目变复杂后,**新增跨文件、跨前后端的系统时,请在这里补一节**,让后来的 AI 和人都能快速建立全局认识。
5
+
6
+ ---
7
+
8
+ ## 项目是什么
9
+
10
+ MyClaw 是 OpenClaw 教学环境的一站式工具包,做两件事:
11
+
12
+ 1. **CLI**(`index.js`):给老师/管理员用,`myclaw patch`、`myclaw update`、`myclaw new` 等命令。
13
+ 2. **运行时注入**:把一批 JS 文件注入进 OpenClaw 的 control-ui 页面,扩展其功能(作品列表、调试记录、TTS、语音输入…)。
14
+
15
+ 两件事共用同一个仓库,发布为 npm 包(`@aiyiran/myclaw`)。
16
+
17
+ ---
18
+
19
+ ## 目录结构速览
20
+
21
+ ```
22
+ myclaw/
23
+ ├── assets/ # 浏览器运行时注入脚本(被 patch.js 拷进 control-ui)
24
+ │ ├── debug-probe.js # 注入到学生 HTML iframe 的采集探针
25
+ │ ├── debug-collector.js # 父页面调试记录收集器
26
+ │ ├── myclaw-artifacts.js # 作品列表 + 预览弹框
27
+ │ ├── myclaw-inject.js # 核心注入(token、workspace 等)
28
+ │ ├── myclaw-iteration.js # 迭代面板
29
+ │ └── myclaw-tts.js # TTS 注入
30
+ ├── patches/
31
+ │ └── patch.js # 把 assets/ 里的脚本拷进 control-ui 并注入 index.html
32
+ ├── server/
33
+ │ ├── sync_workspace.py # 核心服务端:同步循环 + HTTP API(18800 端口)
34
+ │ ├── debug_record.py # 调试记录的校验、路径计算、渲染、写盘
35
+ │ └── artifacts_schema.py # __MY_ARTIFACTS__.json 的 schema 和工具函数
36
+ ├── injects/ # 注入到 OpenClaw 内部行为的脚本(非 control-ui 页面)
37
+ ├── index.js # CLI 入口
38
+ └── gateway.js # Gateway 生命周期管理
39
+ ```
40
+
41
+ ---
42
+
43
+ ## 两个环境(本地 vs 远程)
44
+
45
+ 很多逻辑都在这里分叉,改代码前先确认你在哪个环境:
46
+
47
+ | | 本地(Mac 开发) | 远程(服务器) |
48
+ |---|---|---|
49
+ | control-ui 地址 | `http://127.0.0.1:18789` | `https://{claw}.yiranlaoshi.com` |
50
+ | API 服务端口 | `18800` | `8080`(经 nginx `/sync` 反代) |
51
+ | `.openclaw` 根目录 | `~/.openclaw/` | `/root/.openclaw/` |
52
+ | clawName 来源 | `/api/config` 接口动态获取 | hostname 解析 |
53
+ | 学生 HTML 预览 | CDN(`cdn.yiranlaoshi.com`) | CDN(同) |
54
+
55
+ `detectEnvironment()` 和 `MYCLAW_API_BASE` 封装了这个分叉,前端代码通过它们取路径,**不要在各处硬写 URL**。
56
+
57
+ ---
58
+
59
+ ## 注入脚本的生命周期
60
+
61
+ ```
62
+ assets/*.js 源码
63
+ ↓ myclaw patch(即 patches/patch.js)
64
+ control-ui 目录(.js 文件拷贝进去 + index.html 注入 <script> 标签)
65
+ ↓ 浏览器加载 control-ui
66
+ 父页面运行时
67
+ ```
68
+
69
+ **script 加载顺序**(有依赖关系,顺序不能乱):
70
+ ```
71
+ voice-output.js → voice-input.js → myclaw-tts.js
72
+ → debug-collector.js ← 必须在 artifacts 之前(artifacts 调它暴露的全局函数)
73
+ → myclaw-artifacts.js → myclaw-iteration.js → myclaw-inject.js
74
+ ```
75
+
76
+ ⚠️ **改了 `assets/` 的文件,必须重新跑 `myclaw patch` 才生效。**
77
+
78
+ ---
79
+
80
+ ## sync_workspace.py 是什么
81
+
82
+ 它不只是同步脚本,是一个完整的 Python HTTP 服务(`MyclawAPIHandler`),同时做两件事:
83
+
84
+ - **同步循环**:定期扫 `.openclaw/workspace*/`,把变化的文件上传七牛 CDN。
85
+ - **HTTP API**:监听 18800(服务器上 8080),提供 `/api/artifacts`、`/api/file`、`/api/debug-record/save` 等接口。
86
+
87
+ ⚠️ **改了 `server/` 下的 Python 文件,必须重启同步进程才生效**(不会热重载)。
88
+
89
+ ---
90
+
91
+ ## 调试记录系统(Debug Record)
92
+
93
+ 让老师/学生在预览 HTML 作品时,自动采集运行现场(点击、报错、console…),关闭预览时落盘成一份调试报告,供 AI 修 bug 或展示给学生。
94
+
95
+ ### 数据流(一条事件的一生)
96
+
97
+ ```
98
+ 学生 HTML 作品(CDN,跨域 iframe)
99
+ │ 内含注入的 probe
100
+ ├─ probe 采集 console/click/keydown/错误/资源失败
101
+ │ │ postMessage(source=OPENCLAW_DEBUG_PROBE)
102
+ ▼ ▼
103
+ 父页面(control-ui,OpenClaw 控制台)
104
+ ├─ artifacts 预览弹框:创建并拥有 iframe
105
+ ├─ collector:挂靠该 iframe,收事件、攒成 currentDebugRecord
106
+ │ │ 关闭预览时 POST {claw, workspace, entryFile, events...}
107
+ ▼ ▼
108
+ 服务端(sync_workspace.py,本机 18800 / 远程经 nginx /sync)
109
+ ├─ 按 workspace 自行算目录,写 {workspace}/debug/debug-record.{json,html}
110
+
111
+ 同步循环 → 上传 CDN → 可展示给学生
112
+ ```
113
+
114
+ ### 三个角色(务必分清职责)
115
+
116
+ | 角色 | 文件 | 职责 | 不负责 |
117
+ |------|------|------|--------|
118
+ | **probe**(采集侧) | `assets/debug-probe.js` | 在 iframe 内采集现场,`postMessage` 上报 | traceId、workspace、上传、判因 |
119
+ | **collector**(接收侧) | `assets/debug-collector.js` | 父页面收事件、攒记录、关闭时提交 | probe 注入、AI 修复、HTML 模板 |
120
+ | **server**(落库侧) | `server/debug_record.py` | 算目录、校验、渲染 HTML、原子写盘 | 采集、CDN 上传(交给同步循环) |
121
+
122
+ ### 关键设计约束(改之前先读)
123
+
124
+ 1. **跨域是前提**。学生 HTML 从 CDN 加载,父页面**读不到也注入不进** iframe。所以 probe 必须预先在 HTML 里,靠 `postMessage` 通信。别试图用父页面 DOM 操作 iframe 内部。
125
+
126
+ 2. **probe 由服务端在上传时注入**,不是前端注入。见 `sync_workspace.py` 的 `_put_file_to_qiniu` / `inject_probe_into_html`:HTML 上传到七牛前注入,**不改磁盘原文件**(改了会触发同步循环 + 污染学生源码)。
127
+
128
+ 3. **probe 版本号单一来源**:`assets/debug-probe.js` 里的 `PROBE_VERSION` 常量。服务端**运行时正则解析**它,不在 Python 里硬写。注入用 `<script data-openclaw-probe="版本号">` 做幂等:已是当前版本跳过,旧版本替换,缺失则注入。
129
+ - ⚠️ **probe 是惰性加载并缓存的**。改了 `debug-probe.js` 必须**重启同步进程**才生效(符合 publish 即重启的流程,但本地手调要记得)。
130
+
131
+ 4. **只有一个 iframe**。collector 当初设计成自建 iframe,已改为"挂靠 artifacts 已建好的 iframe"(`startDebugRecord(iframeEl, entryUrl, context)`)。两个模块共用一个 iframe,artifacts 管生命周期,collector 只录制。
132
+
133
+ 5. **前端不传文件路径**。前端只传逻辑字段(claw / workspace / entryFile),**服务端自己算目录**。`claw` 不是路径层级,只用于跟本机 `CLAW_NAME` 校验防串写;真实路径是 `{openclaw_root}/{workspace}/debug/`。
134
+
135
+ 6. **落盘只留最新一份**:固定文件名 `debug-record.{json,html}`,每次覆盖。
136
+
137
+ ### 接口
138
+
139
+ `POST /api/debug-record/save`(`sync_workspace.py` 的 `do_POST`)
140
+
141
+ ```jsonc
142
+ // 请求
143
+ { "claw","workspace","entryFile","entryUrl","traceId",
144
+ "startedAt","endedAt","summary":{}, "events":[] }
145
+ // 响应
146
+ { "ok":true, "traceId", "debugRecordPath", "debugRecordJsonPath", "debugRecordUrl" }
147
+ ```
148
+
149
+ ### 涉及文件一览
150
+
151
+ | 文件 | 角色 |
152
+ |------|------|
153
+ | `assets/debug-probe.js` | probe 源码(含版本号,单一来源) |
154
+ | `assets/debug-collector.js` | 父页面收集器 |
155
+ | `assets/myclaw-artifacts.js` | 预览弹框,HTML 分支挂载 collector |
156
+ | `server/sync_workspace.py` | probe 注入(上传时)+ 保存接口路由 |
157
+ | `server/debug_record.py` | 保存逻辑 + HTML 渲染 |
158
+ | `patches/patch.js` | 把 collector 注入 control-ui 页面 |
159
+ | `server/参考/*.ref` | AI 给的 Node 版参考实现(已移植为 Python,仅留存) |
160
+
161
+ ### 已知待办
162
+
163
+ - 整链路端到端实测(单测已过,未在真实 gateway + 同步进程跑通)。
164
+ - `{workspace}/debug/debug-record.html` 会进 `__MY_ARTIFACTS__.json` 作品列表,是否需要从列表过滤、改走专门入口,待定。
@@ -0,0 +1,384 @@
1
+ /**
2
+ * OpenClaw Parent Debug Collector V1
3
+ *
4
+ * 职责:
5
+ * 1. 点击查看网页时创建 currentDebugRecord
6
+ * 2. 接收 iframe 内 probe 发来的 DEBUG_EVENT
7
+ * 3. 关闭 iframe 时提交服务端
8
+ *
9
+ * 不负责:
10
+ * 1. probe 注入
11
+ * 2. AI 修复
12
+ * 3. 服务端生成 HTML 的具体模板
13
+ */
14
+
15
+ let currentDebugRecord = null;
16
+ let previewIframe = null;
17
+ let latestDebugRecordResult = null;
18
+
19
+ // 当前预览所属的作品空间上下文(由调用方在打开预览时传入)。
20
+ // collector 只把它原样转交服务端,服务端据此自行计算落盘目录。
21
+ let currentWorkspaceContext = null;
22
+
23
+ const MAX_EVENTS = 300;
24
+
25
+ /**
26
+ * 创建本地 traceId。
27
+ * 这个 traceId 不传给 iframe,只用于整理记录。
28
+ */
29
+ function createTraceId() {
30
+ const time = new Date()
31
+ .toISOString()
32
+ .replace(/[-:.TZ]/g, "")
33
+ .slice(0, 14);
34
+
35
+ const random = Math.random().toString(36).slice(2, 8);
36
+
37
+ return `trace_${time}_${random}`;
38
+ }
39
+
40
+ /**
41
+ * 打开网页预览时调用,挂靠到一个已存在的 iframe 上开始录制。
42
+ * iframe 的创建、显示、关闭都由调用方(artifacts 预览弹框)负责,
43
+ * collector 只负责记录,不拥有 iframe。
44
+ *
45
+ * @param {HTMLIFrameElement} iframeEl 预览用的 iframe(src 已设置)
46
+ * @param {string} entryUrl iframe 实际加载的 URL(通常已含缓存戳)
47
+ * @param {Object} context 作品空间上下文 { claw, workspace, entryFile, apiBase }
48
+ */
49
+ function startDebugRecord(iframeEl, entryUrl, context) {
50
+ closeExistingPreviewWithoutSaving();
51
+
52
+ currentWorkspaceContext = context || null;
53
+
54
+ currentDebugRecord = {
55
+ traceId: createTraceId(),
56
+ entryUrl: entryUrl || (iframeEl && iframeEl.src) || "",
57
+ startedAt: new Date().toISOString(),
58
+ endedAt: null,
59
+ events: []
60
+ };
61
+
62
+ previewIframe = iframeEl || null;
63
+
64
+ setPreviewState("recording");
65
+
66
+ console.log("[debug] 调试记录开始:", currentDebugRecord.traceId);
67
+ }
68
+
69
+ /**
70
+ * 接收 probe 发来的 postMessage。
71
+ */
72
+ function handleProbeMessage(messageEvent) {
73
+ const data = messageEvent.data;
74
+
75
+ if (!data || data.source !== "OPENCLAW_DEBUG_PROBE") {
76
+ return;
77
+ }
78
+
79
+ if (data.type !== "DEBUG_EVENT") {
80
+ return;
81
+ }
82
+
83
+ if (!currentDebugRecord) {
84
+ return;
85
+ }
86
+
87
+ // 第一版只允许一个 iframe,尽量过滤掉非当前 iframe 的消息。
88
+ if (
89
+ previewIframe &&
90
+ previewIframe.contentWindow &&
91
+ messageEvent.source !== previewIframe.contentWindow
92
+ ) {
93
+ return;
94
+ }
95
+
96
+ const probeEvent = data.event;
97
+
98
+ if (!probeEvent || !probeEvent.type) {
99
+ return;
100
+ }
101
+
102
+ appendDebugEvent(probeEvent);
103
+ }
104
+
105
+ /**
106
+ * 把事件追加进当前调试记录。
107
+ */
108
+ function appendDebugEvent(probeEvent) {
109
+ const event = {
110
+ seq: currentDebugRecord.events.length + 1,
111
+ receivedAt: new Date().toISOString(),
112
+ ...probeEvent
113
+ };
114
+
115
+ currentDebugRecord.events.push(event);
116
+
117
+ trimDebugEvents(currentDebugRecord.events);
118
+
119
+ renderDebugRecordPreview(currentDebugRecord);
120
+ }
121
+
122
+ /**
123
+ * 控制日志长度,避免无限膨胀。
124
+ */
125
+ function trimDebugEvents(events) {
126
+ if (events.length <= MAX_EVENTS) {
127
+ return;
128
+ }
129
+
130
+ const importantTypes = new Set([
131
+ "runtime-error",
132
+ "unhandledrejection",
133
+ "resource-error"
134
+ ]);
135
+
136
+ const importantEvents = events.filter((event) =>
137
+ importantTypes.has(event.type)
138
+ );
139
+
140
+ const latestEvents = events.slice(-MAX_EVENTS);
141
+
142
+ const merged = [...importantEvents, ...latestEvents];
143
+
144
+ const seen = new Set();
145
+ const deduped = [];
146
+
147
+ for (const event of merged) {
148
+ const key = event.seq || `${event.type}-${event.time}-${event.pageUrl}`;
149
+ if (seen.has(key)) continue;
150
+ seen.add(key);
151
+ deduped.push(event);
152
+ }
153
+
154
+ events.length = 0;
155
+ events.push(...deduped.slice(-MAX_EVENTS));
156
+ }
157
+
158
+ /**
159
+ * 点击【关闭网页】时调用。
160
+ */
161
+ async function closePreviewAndSaveDebugRecord() {
162
+ if (!currentDebugRecord) {
163
+ closeIframeOnly();
164
+ return null;
165
+ }
166
+
167
+ currentDebugRecord.endedAt = new Date().toISOString();
168
+
169
+ const recordToSave = {
170
+ ...currentDebugRecord,
171
+ summary: createLocalDebugSummary(currentDebugRecord)
172
+ };
173
+
174
+ closeIframeOnly();
175
+
176
+ setPreviewState("saving-debug-record");
177
+
178
+ try {
179
+ const result = await saveDebugRecordToServer(recordToSave);
180
+
181
+ latestDebugRecordResult = result;
182
+
183
+ setPreviewState("debug-record-ready");
184
+
185
+ console.log("[debug] debug record saved:", result);
186
+
187
+ return result;
188
+ } catch (error) {
189
+ setPreviewState("debug-record-save-failed");
190
+
191
+ console.error("[debug] failed to save debug record:", error);
192
+
193
+ throw error;
194
+ } finally {
195
+ currentDebugRecord = null;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * 只关闭 iframe,不保存。
201
+ * 用于打开新预览前清理旧 iframe。
202
+ */
203
+ function closeExistingPreviewWithoutSaving() {
204
+ if (previewIframe) {
205
+ closeIframeOnly();
206
+ }
207
+
208
+ currentDebugRecord = null;
209
+ }
210
+
211
+ /**
212
+ * 解除对 iframe 的引用。
213
+ * iframe 的 DOM 移除由调用方(预览弹框)负责,collector 不拥有 iframe。
214
+ */
215
+ function closeIframeOnly() {
216
+ previewIframe = null;
217
+ }
218
+
219
+ /**
220
+ * 提交给服务端。
221
+ * 合入作品空间逻辑字段(claw / workspace / entryFile),服务端据此自行算目录。
222
+ * 不传文件路径——路径由服务端决定,更安全。
223
+ */
224
+ async function saveDebugRecordToServer(record) {
225
+ const context = currentWorkspaceContext || {};
226
+
227
+ const payload = {
228
+ claw: context.claw,
229
+ workspace: context.workspace,
230
+ entryFile: context.entryFile,
231
+ ...record
232
+ };
233
+
234
+ // API 基址由调用方传入(远程走 /sync 反代,本地走 18800 端口);
235
+ // 缺省回落到同源相对路径。
236
+ const base = context.apiBase || "";
237
+ const response = await fetch(base + "/api/debug-record/save", {
238
+ method: "POST",
239
+ headers: {
240
+ "Content-Type": "application/json"
241
+ },
242
+ body: JSON.stringify(payload)
243
+ });
244
+
245
+ if (!response.ok) {
246
+ throw new Error(`保存调试记录失败:${response.status}`);
247
+ }
248
+
249
+ return await response.json();
250
+ }
251
+
252
+ /**
253
+ * 生成一个本地摘要,方便服务端直接展示。
254
+ */
255
+ function createLocalDebugSummary(record) {
256
+ const events = record.events || [];
257
+
258
+ const errorCount = events.filter((event) =>
259
+ ["runtime-error", "unhandledrejection", "resource-error"].includes(event.type)
260
+ ).length;
261
+
262
+ const consoleErrorCount = events.filter(
263
+ (event) =>
264
+ event.type === "console" &&
265
+ event.payload &&
266
+ event.payload.level === "error"
267
+ ).length;
268
+
269
+ const pages = Array.from(
270
+ new Set(
271
+ events
272
+ .map((event) => event.pagePath || event.pageUrl)
273
+ .filter(Boolean)
274
+ )
275
+ );
276
+
277
+ const clicks = events.filter((event) => event.type === "click").length;
278
+ const keydowns = events.filter((event) => event.type === "keydown").length;
279
+
280
+ return {
281
+ eventCount: events.length,
282
+ errorCount,
283
+ consoleErrorCount,
284
+ pages,
285
+ clicks,
286
+ keydowns
287
+ };
288
+ }
289
+
290
+ /**
291
+ * 简单渲染当前记录,可选。
292
+ * V1 可以只在调试面板里显示最近几条。
293
+ */
294
+ function renderDebugRecordPreview(record) {
295
+ const panel = document.getElementById("debugRecordPreview");
296
+ if (!panel) return;
297
+
298
+ const latestEvents = record.events.slice(-10);
299
+
300
+ panel.innerHTML = latestEvents
301
+ .map((event) => {
302
+ return `<div class="debug-event">
303
+ <span>${escapeHtml(formatEventForStudent(event))}</span>
304
+ </div>`;
305
+ })
306
+ .join("");
307
+ }
308
+
309
+ /**
310
+ * 把事件翻译成学生能看懂的话。
311
+ */
312
+ function formatEventForStudent(event) {
313
+ const time = event.time ? event.time.slice(11, 19) : "";
314
+
315
+ if (event.type === "page-enter") {
316
+ return `${time} 进入页面:${event.pageTitle || event.pagePath || event.pageUrl}`;
317
+ }
318
+
319
+ if (event.type === "click") {
320
+ const text = event.payload && event.payload.text;
321
+ const selector = event.payload && event.payload.selector;
322
+ return `${time} 点击:${text || selector || "某个元素"}`;
323
+ }
324
+
325
+ if (event.type === "keydown") {
326
+ const code = event.payload && event.payload.code;
327
+ return `${time} 按键:${code || "未知按键"}`;
328
+ }
329
+
330
+ if (event.type === "console") {
331
+ const level = event.payload && event.payload.level;
332
+ const message = event.payload && event.payload.message;
333
+ return `${time} 程序输出(${level}):${message || ""}`;
334
+ }
335
+
336
+ if (event.type === "runtime-error") {
337
+ const message = event.payload && event.payload.message;
338
+ return `${time} 程序报错:${message || "未知错误"}`;
339
+ }
340
+
341
+ if (event.type === "unhandledrejection") {
342
+ const message = event.payload && event.payload.message;
343
+ return `${time} 异步错误:${message || "未知错误"}`;
344
+ }
345
+
346
+ if (event.type === "resource-error") {
347
+ const url = event.payload && event.payload.url;
348
+ return `${time} 资源加载失败:${url || "未知资源"}`;
349
+ }
350
+
351
+ return `${time} ${event.type}`;
352
+ }
353
+
354
+ function escapeHtml(text) {
355
+ return String(text)
356
+ .replaceAll("&", "&amp;")
357
+ .replaceAll("<", "&lt;")
358
+ .replaceAll(">", "&gt;")
359
+ .replaceAll('"', "&quot;")
360
+ .replaceAll("'", "&#039;");
361
+ }
362
+
363
+ /**
364
+ * UI 状态钩子。
365
+ */
366
+ function setPreviewState(state) {
367
+ document.body.dataset.previewState = state;
368
+
369
+ const submitBugButton = document.getElementById("submitBugButton");
370
+
371
+ if (submitBugButton) {
372
+ // iframe 关闭并成功生成调试记录后,才允许提交 bug。
373
+ submitBugButton.disabled = state !== "debug-record-ready";
374
+ }
375
+ }
376
+
377
+ /**
378
+ * 启动监听。
379
+ */
380
+ window.addEventListener("message", handleProbeMessage);
381
+
382
+ // 暴露给预览弹框(artifacts)调用:开始录制 / 关闭并保存。
383
+ window.startDebugRecord = startDebugRecord;
384
+ window.closePreviewAndSaveDebugRecord = closePreviewAndSaveDebugRecord;