@aiyiran/myclaw 1.1.20 → 1.1.21

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,16 +1,25 @@
1
1
  import os
2
2
  import sys
3
+ import re
4
+ import random
3
5
  import argparse
4
6
  import platform
7
+ import subprocess
8
+ import urllib.request
5
9
  from datetime import datetime, timezone, timedelta
6
10
  import json
7
11
  import time
8
12
  import threading
13
+ import socketserver
9
14
  from http.server import HTTPServer, BaseHTTPRequestHandler
10
- from urllib.parse import urlparse, parse_qs
15
+ from urllib.parse import urlparse, parse_qs, unquote
11
16
  from watchdog.observers import Observer
12
17
  from watchdog.events import FileSystemEventHandler
13
- from qiniu import Auth, put_file_v2, CdnManager
18
+ from qiniu import Auth, put_file_v2, CdnManager, BucketManager
19
+
20
+ # 确保同目录的共享模块可导入
21
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
22
+ from artifacts_schema import ARTIFACTS_FILENAME, create_empty_artifacts, now_iso as _now_iso
14
23
 
15
24
  # 跨平台获取 openclaw 目录
16
25
  def get_openclaw_path():
@@ -33,19 +42,37 @@ secret_key = QINIU_TOKEN
33
42
  bucket_name = QINIU_BUCKET
34
43
  q = Auth(access_key, secret_key)
35
44
  cdn_manager = CdnManager(q)
45
+ bucket_manager = BucketManager(q)
46
+
47
+ # 是否将 __MY_ARTIFACTS__.json 同步到 OSS
48
+ # True = 上传到七牛(旧行为)
49
+ # False = 不上传,且主动从七牛删除已有的(新行为)
50
+ SYNC_ARTIFACTS_JSON_TO_OSS = False
51
+
52
+
53
+ # 忽略的目录名(路径中只要包含这些目录段就跳过)
54
+ IGNORE_DIR_PATTERNS = {'.git', 'node_modules', '__pycache__', '.myclaw'}
55
+
56
+ # 忽略的文件名
57
+ IGNORE_FILE_PATTERNS = {'.myclaw-sync.pid', '.DS_Store', '.gitignore', '.gitkeep'}
58
+
59
+
60
+ def _path_has_ignored_dir(filepath):
61
+ """检查路径中是否包含需要忽略的目录段"""
62
+ parts = os.path.normpath(filepath).split(os.sep)
63
+ return any(part in IGNORE_DIR_PATTERNS for part in parts)
36
64
 
37
65
 
38
66
  class MyHandler(FileSystemEventHandler):
39
- # 需要忽略的文件模式
40
- IGNORE_PATTERNS = {'.myclaw-sync.pid', '.DS_Store'}
41
67
 
42
68
  def _is_file_event(self, event):
43
69
  return not getattr(event, "is_directory", False)
44
70
 
45
71
  def _should_ignore(self, filepath):
46
- """忽略 PID 文件、隐藏文件等非业务文件"""
47
72
  basename = os.path.basename(filepath)
48
- if basename in self.IGNORE_PATTERNS:
73
+ if basename in IGNORE_FILE_PATTERNS:
74
+ return True
75
+ if _path_has_ignored_dir(filepath):
49
76
  return True
50
77
  # 忽略 .openclaw 根目录下的隐藏文件(非 workspace 目录内的)
51
78
  if basename.startswith('.') and 'workspace' not in filepath:
@@ -86,9 +113,7 @@ class MyHandler(FileSystemEventHandler):
86
113
 
87
114
 
88
115
  def now_iso():
89
- # 生成 +08:00 时间格式
90
- tz = timezone(timedelta(hours=8))
91
- return datetime.now(tz).isoformat()
116
+ return _now_iso()
92
117
 
93
118
 
94
119
  def gen_id():
@@ -101,37 +126,27 @@ def get_type(file_path):
101
126
 
102
127
  def init_config(workspace_id, file_path, method="add"):
103
128
  base_path = get_openclaw_path()
104
- file = f"{base_path}/{workspace_id}/.myclaw/__MY_ARTIFACTS__.json"
129
+ file = f"{base_path}/{workspace_id}/.myclaw/{ARTIFACTS_FILENAME}"
105
130
 
106
131
  # 确保目录存在
107
132
  os.makedirs(os.path.dirname(file), exist_ok=True)
108
133
 
109
134
  now = now_iso()
110
135
 
111
- # 如果文件不存在,先初始化(包含根级创建/更改时间)
136
+ # 如果文件不存在,先初始化
112
137
  if not os.path.exists(file):
113
- data = {
114
- "workspace_id": workspace_id,
115
- "base_url": BASE_URL,
116
- "assets": [],
117
- "created_at": now,
118
- "updated_at": now
119
- }
138
+ data = create_empty_artifacts(workspace_id, BASE_URL)
120
139
  else:
121
140
  with open(file, "r", encoding="utf-8") as f:
122
141
  try:
123
142
  data = json.load(f)
124
143
  except:
125
- data = {
126
- "workspace_id": workspace_id,
127
- "assets": [],
128
- "created_at": now,
129
- "updated_at": now
130
- }
131
- # 确保根级时间字段存在;保留已有 created_at,更新 updated_at
144
+ data = create_empty_artifacts(workspace_id, BASE_URL)
145
+ # 确保根级字段存在且最新
132
146
  if "created_at" not in data:
133
147
  data["created_at"] = now
134
148
  data["updated_at"] = now
149
+ data["base_url"] = BASE_URL # 始终同步为当前 CDN 前缀
135
150
 
136
151
  if method == "delete":
137
152
  # 删除逻辑:将匹配 path 的项过滤掉,并更新根级更新时间
@@ -213,7 +228,7 @@ def file_gen(path, method):
213
228
  cdn_ret, cdn_info = cdn_manager.refresh_urls([cdn_url])
214
229
 
215
230
  # 如果是 __MY_ARTIFACTS__.json 配置文件,只上传不写入(防止循环触发)
216
- if "__MY_ARTIFACTS__.json" in path:
231
+ if os.path.basename(path) == ARTIFACTS_FILENAME:
217
232
  print(f"🚀 已上传配置文件: {relative_path}")
218
233
  print(f"风 CDN刷新: {cdn_url} - {cdn_info.status_code if info else 'unknown'}")
219
234
  else:
@@ -242,9 +257,14 @@ def sync_all(workspace_id):
242
257
  success_count = 0
243
258
 
244
259
  for root, dirs, files in os.walk(workspace_path):
260
+ # 剪枝:跳过忽略目录,避免递归进入
261
+ dirs[:] = [d for d in dirs if d not in IGNORE_DIR_PATTERNS]
262
+
245
263
  for filename in files:
246
264
  # 排除配置文件自身(防止循环)
247
- if filename == "__MY_ARTIFACTS__.json":
265
+ if filename == ARTIFACTS_FILENAME:
266
+ continue
267
+ if filename in IGNORE_FILE_PATTERNS:
248
268
  continue
249
269
 
250
270
  local_path = os.path.join(root, filename)
@@ -273,23 +293,238 @@ def sync_all(workspace_id):
273
293
  print("-" * 50)
274
294
  print(f"[完成] 共 {file_count} 个文件,成功 {success_count} 个")
275
295
 
276
- # 最后上传配置文件
277
- config_path = f"{workspace_path}/.myclaw/__MY_ARTIFACTS__.json"
278
- if os.path.exists(config_path):
279
- print(f"[配置] 上传配置文件...")
296
+ # 反向查对:JSON 中有记录但磁盘上不存在的,剔除
297
+ config_file = f"{workspace_path}/.myclaw/{ARTIFACTS_FILENAME}"
298
+ if os.path.exists(config_file):
299
+ with open(config_file, "r", encoding="utf-8") as f:
300
+ try:
301
+ data = json.load(f)
302
+ except:
303
+ data = None
304
+
305
+ if data and data.get("assets"):
306
+ before = len(data["assets"])
307
+ data["assets"] = [
308
+ asset for asset in data["assets"]
309
+ if os.path.exists(os.path.join(workspace_path, asset.get("path", "")))
310
+ ]
311
+ removed = before - len(data["assets"])
312
+ if removed > 0:
313
+ data["updated_at"] = now_iso()
314
+ with open(config_file, "w", encoding="utf-8") as f:
315
+ json.dump(data, f, ensure_ascii=False, indent=2)
316
+ print(f"[清理] 剔除 {removed} 条已不存在的记录")
317
+
318
+ # 最后处理配置文件的 OSS 同步
319
+ key = f"{claw}/{workspace_id}/.myclaw/{ARTIFACTS_FILENAME}"
320
+ if SYNC_ARTIFACTS_JSON_TO_OSS:
321
+ config_path = f"{workspace_path}/.myclaw/{ARTIFACTS_FILENAME}"
322
+ if os.path.exists(config_path):
323
+ print(f"[配置] 上传配置文件...")
324
+ try:
325
+ token = q.upload_token(bucket_name, key, 3600)
326
+ ret, info = put_file_v2(token, key, config_path)
327
+ cdn_url = f"https://cdn.yiranlaoshi.com/{key}"
328
+ cdn_manager.refresh_urls([cdn_url])
329
+ print(f" ✅ __MY_ARTIFACTS__.json")
330
+ except Exception as e:
331
+ print(f" ❌ __MY_ARTIFACTS__.json: {e}")
332
+ else:
333
+ print(f"[配置] 从 OSS 删除配置文件...")
280
334
  try:
281
- key = f"{claw}/{workspace_id}/.myclaw/__MY_ARTIFACTS__.json"
282
- token = q.upload_token(bucket_name, key, 3600)
283
- ret, info = put_file_v2(token, key, config_path)
284
- cdn_url = f"https://cdn.yiranlaoshi.com/{key}"
285
- cdn_manager.refresh_urls([cdn_url])
286
- print(f" __MY_ARTIFACTS__.json")
335
+ ret, info = bucket_manager.delete(bucket_name, key)
336
+ if info.status_code == 200 or info.status_code == 612:
337
+ # 612 = 文件不存在,视为成功
338
+ print(f" ✅ 已从 OSS 删除 __MY_ARTIFACTS__.json")
339
+ else:
340
+ print(f" 删除失败: {info.status_code}")
287
341
  except Exception as e:
288
- print(f" ❌ __MY_ARTIFACTS__.json: {e}")
342
+ print(f" ❌ 删除失败: {e}")
289
343
 
290
344
  return True
291
345
 
292
346
 
347
+ # ═══════════════════════════════════════════════════════════════
348
+ # Fork:从 CDN 克隆他人 workspace 到本地
349
+ # ═══════════════════════════════════════════════════════════════
350
+
351
+ # CDN 根域名(不含 scheme)
352
+ CDN_HOST = "cdn.yiranlaoshi.com"
353
+
354
+ # Fork URL 固定前缀:https://cdn.yiranlaoshi.com/myclaw/show/{claw}/{workspace}/{version}/...
355
+ FORK_CDN_PREFIX = "myclaw/show"
356
+
357
+ # 异步 job 状态表 { job_id -> {"status": "running"|"done"|"failed", ...} }
358
+ _fork_jobs = {}
359
+
360
+
361
+ def _list_files_from_qiniu(prefix):
362
+ """按前缀列出七牛 OSS 上的所有文件"""
363
+ marker = None
364
+ files = []
365
+ while True:
366
+ ret, eof, info = bucket_manager.list(
367
+ bucket_name, prefix=prefix, marker=marker, limit=1000)
368
+ if ret is not None:
369
+ files.extend(ret.get('items', []))
370
+ marker = ret.get('marker')
371
+ if eof:
372
+ break
373
+ else:
374
+ break
375
+ return files
376
+
377
+
378
+ def _download_from_cdn(key, local_path):
379
+ """从公共 CDN 下载单个文件"""
380
+ from urllib.parse import quote
381
+ # key 可能含中文,需要对路径部分做 percent-encode(保留 / 分隔符)
382
+ encoded_key = quote(key, safe='/')
383
+ url = f"https://cdn.yiranlaoshi.com/{encoded_key}"
384
+ os.makedirs(os.path.dirname(local_path), exist_ok=True)
385
+ try:
386
+ import ssl
387
+ ctx = ssl.create_default_context()
388
+ ctx.check_hostname = False
389
+ ctx.verify_mode = ssl.CERT_NONE
390
+ with urllib.request.urlopen(url, context=ctx) as resp, open(local_path, 'wb') as f:
391
+ f.write(resp.read())
392
+ return True
393
+ except Exception as e:
394
+ print(f" ❌ 下载失败 {key}: {e}")
395
+ return False
396
+
397
+
398
+ def _do_fork(job_id, src_clawname, src_workspace, src_version, entry_rel_path=None):
399
+ """在后台线程中执行 fork"""
400
+ try:
401
+ rand_id = str(random.randint(100, 999))
402
+
403
+ # create_agent.js 会自动加 "workspace-" 前缀,所以这里只传去掉前缀的名字
404
+ # 例:src_workspace="workspace-fangshunhe" → agent_name="fangshunhe-v1-fork-475"
405
+ # mc new 创建目录 → workspace-fangshunhe-v1-fork-475 ✓
406
+ base_name = src_workspace
407
+ if base_name.startswith("workspace-"):
408
+ base_name = base_name[len("workspace-"):]
409
+ agent_name = f"{base_name}-v{src_version}-fork-{rand_id}"
410
+ new_workspace = f"workspace-{agent_name}"
411
+
412
+ print(f"[Fork] 开始: {src_workspace} v{src_version} → {new_workspace}")
413
+ if entry_rel_path:
414
+ print(f"[Fork] 入口文件: {entry_rel_path}")
415
+
416
+ # mc new 创建 workspace(不带首条消息,等文件下载完再发)
417
+ mc_result = subprocess.run(
418
+ ["mc", "new", agent_name],
419
+ capture_output=True, text=True
420
+ )
421
+ if mc_result.returncode != 0:
422
+ _fork_jobs[job_id] = {
423
+ "status": "failed",
424
+ "error": f"mc new 失败: {mc_result.stderr.strip()}"
425
+ }
426
+ return
427
+
428
+ base_path = get_openclaw_path()
429
+ # create_agent.js: workspaceDir = openclawDir + "/workspace-" + agentId
430
+ target_dir = os.path.join(base_path, new_workspace)
431
+
432
+ # 列出源文件
433
+ prefix = f"{FORK_CDN_PREFIX}/{src_clawname}/{src_workspace}/{src_version}/"
434
+ print(f"[Fork] 列出文件: {prefix}")
435
+ files = _list_files_from_qiniu(prefix)
436
+
437
+ if not files:
438
+ _fork_jobs[job_id] = {"status": "failed", "error": "CDN 上未找到任何文件"}
439
+ return
440
+
441
+ print(f"[Fork] 找到 {len(files)} 个文件,开始下载...")
442
+ success = 0
443
+ for item in files:
444
+ key = item['key']
445
+ # 去掉前缀,得到相对路径(CDN key 是 URL 编码过的,decode 成实际文件名)
446
+ rel_path = unquote(key[len(prefix):])
447
+ if not rel_path:
448
+ continue
449
+ local_path = os.path.join(target_dir, rel_path)
450
+ if _download_from_cdn(key, local_path):
451
+ success += 1
452
+ print(f" ✓ {rel_path}")
453
+
454
+ print(f"[Fork] 完成: 成功 {success}/{len(files)} 个文件")
455
+
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}")
465
+
466
+ # 文件到位后再发分析消息,确保 AI 能读到 fork 的内容
467
+ fork_message = (
468
+ "你好!请你仔细阅读我 workspace 下的所有文件,"
469
+ "简单介绍一下这个项目有哪些资源、大概是做什么用的,"
470
+ "几句话说清楚就好,不用太长。"
471
+ )
472
+ try:
473
+ subprocess.Popen(
474
+ ["openclaw", "agent", "--agent", agent_name, "--message", fork_message, "--json"],
475
+ stdout=subprocess.DEVNULL,
476
+ stderr=subprocess.DEVNULL
477
+ )
478
+ print(f"[Fork] 已发送分析消息 → {agent_name}")
479
+ except Exception as e:
480
+ print(f"[Fork] 发送消息失败(非致命): {e}")
481
+
482
+ _fork_jobs[job_id] = {
483
+ "status": "done",
484
+ "workspace": new_workspace,
485
+ "files": success,
486
+ "total": len(files)
487
+ }
488
+
489
+ except Exception as e:
490
+ print(f"[Fork] 异常: {e}")
491
+ _fork_jobs[job_id] = {"status": "failed", "error": str(e)}
492
+
493
+
494
+ def start_fork(remote_url):
495
+ """
496
+ 解析 remote_url,生成 job_id,在后台线程执行 fork。
497
+ URL 格式: https://cdn.yiranlaoshi.com/myclaw/show/{clawname}/{workspace}/{version}/...
498
+ """
499
+ parsed = urlparse(remote_url)
500
+ # path: /myclaw/show/{clawname}/{workspace}/{version}/...
501
+ parts = parsed.path.strip('/').split('/')
502
+ # parts: ['myclaw', 'show', clawname, workspace, version, ...]
503
+ if len(parts) < 5 or parts[0] != 'myclaw' or parts[1] != 'show':
504
+ return None, "URL 格式不正确,期望 .../myclaw/show/{claw}/{workspace}/{version}/..."
505
+
506
+ src_clawname = parts[2]
507
+ src_workspace = parts[3]
508
+ src_version = parts[4]
509
+ # parts[5:] 是入口文件相对路径(URL 中 version 之后的部分)
510
+ entry_rel_path = '/'.join(parts[5:]) if len(parts) > 5 else None
511
+
512
+ if not src_workspace.startswith('workspace'):
513
+ return None, f"无法识别 workspace: {src_workspace}"
514
+
515
+ job_id = f"fork-{int(time.time() * 1000)}"
516
+ _fork_jobs[job_id] = {"status": "running"}
517
+
518
+ t = threading.Thread(
519
+ target=_do_fork,
520
+ args=(job_id, src_clawname, src_workspace, src_version, entry_rel_path),
521
+ daemon=True
522
+ )
523
+ t.start()
524
+
525
+ return job_id, None
526
+
527
+
293
528
  # ═══════════════════════════════════════════════════════════════
294
529
  # 内嵌 HTTP API 服务
295
530
  # ═══════════════════════════════════════════════════════════════
@@ -299,18 +534,52 @@ API_PORT = int(os.environ.get("MYCLAW_API_PORT", "18800"))
299
534
 
300
535
  class MyclawAPIHandler(BaseHTTPRequestHandler):
301
536
  """轻量 HTTP API,供前端获取作品列表和配置"""
537
+ # 使用默认 HTTP/1.0 — 天然 connection close,无 keep-alive 阻塞问题
302
538
 
303
539
  def _cors_headers(self):
304
540
  self.send_header('Access-Control-Allow-Origin', '*')
305
- self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS')
541
+ self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
306
542
  self.send_header('Access-Control-Allow-Headers', 'Content-Type')
543
+ self.send_header('Access-Control-Allow-Private-Network', 'true')
307
544
  self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
308
545
 
546
+ def _send_json(self, data, status=200):
547
+ """统一 JSON 响应:自动设 Content-Type / Content-Length / CORS"""
548
+ body = json.dumps(data, ensure_ascii=False).encode('utf-8')
549
+ self.send_response(status)
550
+ self._cors_headers()
551
+ self.send_header('Content-Type', 'application/json; charset=utf-8')
552
+ self.send_header('Content-Length', str(len(body)))
553
+ self.end_headers()
554
+ self.wfile.write(body)
555
+
556
+ def _send_json_raw(self, raw_bytes, status=200):
557
+ """发送已编码的 JSON 字节(跳过二次序列化)"""
558
+ self.send_response(status)
559
+ self._cors_headers()
560
+ self.send_header('Content-Type', 'application/json; charset=utf-8')
561
+ self.send_header('Content-Length', str(len(raw_bytes)))
562
+ self.end_headers()
563
+ self.wfile.write(raw_bytes)
564
+
309
565
  def do_OPTIONS(self):
310
566
  self.send_response(200)
311
567
  self._cors_headers()
568
+ self.send_header('Content-Length', '0')
312
569
  self.end_headers()
313
570
 
571
+ def do_POST(self):
572
+ parsed = urlparse(self.path)
573
+ path = parsed.path.rstrip('/')
574
+ params = parse_qs(parsed.query)
575
+
576
+ if path == '/api/resync':
577
+ return self._handle_resync(params)
578
+ elif path == '/api/fork':
579
+ return self._handle_fork()
580
+ else:
581
+ self._send_json({"error": "not found"}, 404)
582
+
314
583
  def do_GET(self):
315
584
  parsed = urlparse(self.path)
316
585
  path = parsed.path.rstrip('/')
@@ -320,79 +589,120 @@ class MyclawAPIHandler(BaseHTTPRequestHandler):
320
589
  return self._handle_config()
321
590
  elif path == '/api/artifacts':
322
591
  return self._handle_artifacts(params)
592
+ elif path == '/api/fork/status':
593
+ return self._handle_fork_status(params)
323
594
  else:
324
- self.send_response(404)
325
- self._cors_headers()
326
- self.send_header('Content-Type', 'application/json; charset=utf-8')
327
- self.end_headers()
328
- self.wfile.write(json.dumps({"error": "not found"}).encode('utf-8'))
595
+ self._send_json({"error": "not found"}, 404)
329
596
 
330
597
  def _handle_config(self):
331
598
  """GET /api/config → 返回 claw 名称和 CDN base_url"""
332
- data = {
333
- "claw": claw,
334
- "base_url": BASE_URL,
335
- }
336
- self.send_response(200)
337
- self._cors_headers()
338
- self.send_header('Content-Type', 'application/json; charset=utf-8')
339
- self.end_headers()
340
- self.wfile.write(json.dumps(data, ensure_ascii=False).encode('utf-8'))
599
+ self._send_json({"claw": claw, "base_url": BASE_URL})
341
600
 
342
601
  def _handle_artifacts(self, params):
343
602
  """GET /api/artifacts?workspace=xxx → 直接从磁盘读取 __MY_ARTIFACTS__.json"""
344
603
  workspace = params.get('workspace', [''])[0]
345
604
  if not workspace:
346
- self.send_response(400)
347
- self._cors_headers()
348
- self.send_header('Content-Type', 'application/json; charset=utf-8')
349
- self.end_headers()
350
- self.wfile.write(json.dumps({"error": "missing workspace param"}).encode('utf-8'))
605
+ self._send_json({"error": "missing workspace param"}, 400)
351
606
  return
352
607
 
353
608
  base_path = get_openclaw_path()
354
- json_path = os.path.join(base_path, workspace, '.myclaw', '__MY_ARTIFACTS__.json')
609
+ json_path = os.path.join(base_path, workspace, '.myclaw', ARTIFACTS_FILENAME)
355
610
 
356
611
  if not os.path.exists(json_path):
357
- # 文件不存在,返回空结构
358
- data = {
359
- "workspace_id": workspace,
360
- "base_url": BASE_URL,
361
- "assets": [],
362
- }
363
- self.send_response(200)
364
- self._cors_headers()
365
- self.send_header('Content-Type', 'application/json; charset=utf-8')
366
- self.end_headers()
367
- self.wfile.write(json.dumps(data, ensure_ascii=False).encode('utf-8'))
612
+ self._send_json(create_empty_artifacts(workspace, BASE_URL))
368
613
  return
369
614
 
370
615
  try:
371
616
  with open(json_path, 'r', encoding='utf-8') as f:
372
617
  content = f.read()
373
- self.send_response(200)
374
- self._cors_headers()
375
- self.send_header('Content-Type', 'application/json; charset=utf-8')
376
- self.end_headers()
377
- self.wfile.write(content.encode('utf-8'))
618
+ self._send_json_raw(content.encode('utf-8'))
378
619
  except Exception as e:
379
- self.send_response(500)
380
- self._cors_headers()
381
- self.send_header('Content-Type', 'application/json; charset=utf-8')
382
- self.end_headers()
383
- self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
620
+ self._send_json({"error": str(e)}, 500)
621
+
622
+ def _handle_resync(self, params):
623
+ """POST /api/resync?workspace=xxx → 触发全量同步"""
624
+ workspace = params.get('workspace', [''])[0]
625
+ if not workspace:
626
+ self._send_json({"error": "missing workspace param"}, 400)
627
+ return
628
+
629
+ print(f"[API] 收到 resync 请求: {workspace}")
630
+ try:
631
+ result = sync_all(workspace)
632
+ self._send_json({"ok": result, "workspace": workspace})
633
+ except Exception as e:
634
+ self._send_json({"ok": False, "error": str(e)}, 500)
635
+
636
+ def _handle_fork(self):
637
+ """POST /api/fork Body: {"url": "https://cdn.yiranlaoshi.com/myclaw/show/..."}"""
638
+ try:
639
+ length = int(self.headers.get('Content-Length', 0))
640
+ body = json.loads(self.rfile.read(length).decode('utf-8')) if length else {}
641
+ remote_url = body.get('url', '')
642
+ except Exception:
643
+ self._send_json({"error": "请求体解析失败"}, 400)
644
+ return
645
+
646
+ if not remote_url:
647
+ self._send_json({"error": "缺少 url 参数"}, 400)
648
+ return
649
+
650
+ job_id, err = start_fork(remote_url)
651
+ if err:
652
+ self._send_json({"error": err}, 400)
653
+ return
654
+
655
+ self._send_json({"job_id": job_id})
656
+
657
+ def _handle_fork_status(self, params):
658
+ """GET /api/fork/status?job_id=xxx"""
659
+ job_id = params.get('job_id', [''])[0]
660
+ if not job_id or job_id not in _fork_jobs:
661
+ self._send_json({"error": "job 不存在"}, 404)
662
+ return
663
+ self._send_json(_fork_jobs[job_id])
384
664
 
385
665
  def log_message(self, format, *args):
386
666
  # 静默日志,避免轮询刷屏
387
667
  pass
388
668
 
389
669
 
670
+ def cleanup_ignored_assets(workspace_id):
671
+ """启动时清理 __MY_ARTIFACTS__.json 中属于忽略目录的旧记录"""
672
+ base_path = get_openclaw_path()
673
+ file = f"{base_path}/{workspace_id}/.myclaw/{ARTIFACTS_FILENAME}"
674
+ if not os.path.exists(file):
675
+ return
676
+ try:
677
+ with open(file, "r", encoding="utf-8") as f:
678
+ data = json.load(f)
679
+ except Exception:
680
+ return
681
+
682
+ before = len(data.get("assets", []))
683
+ data["assets"] = [
684
+ asset for asset in data.get("assets", [])
685
+ if not _path_has_ignored_dir(asset.get("path", ""))
686
+ and os.path.basename(asset.get("path", "")) not in IGNORE_FILE_PATTERNS
687
+ ]
688
+ after = len(data["assets"])
689
+
690
+ if before != after:
691
+ data["updated_at"] = now_iso()
692
+ with open(file, "w", encoding="utf-8") as f:
693
+ json.dump(data, f, ensure_ascii=False, indent=2)
694
+ print(f"[cleanup] 清理忽略文件记录: {before - after} 条({workspace_id})")
695
+ else:
696
+ print(f"[cleanup] 无需清理({workspace_id})")
697
+
698
+
390
699
  def start_api_server():
391
- """在后台线程启动 HTTP API 服务"""
392
- class ReusableHTTPServer(HTTPServer):
700
+ """在后台线程启动 HTTP API 服务(多线程,避免 resync 阻塞轮询)"""
701
+ class ThreadedHTTPServer(socketserver.ThreadingMixIn, HTTPServer):
393
702
  allow_reuse_address = True
703
+ daemon_threads = True
394
704
 
395
- server = ReusableHTTPServer(('0.0.0.0', API_PORT), MyclawAPIHandler)
705
+ server = ThreadedHTTPServer(('0.0.0.0', API_PORT), MyclawAPIHandler)
396
706
  thread = threading.Thread(target=server.serve_forever, daemon=True)
397
707
  thread.start()
398
708
  print(f"[myclaw-api] ✅ HTTP API 服务已启动: http://127.0.0.1:{API_PORT}")
@@ -411,6 +721,11 @@ if __name__ == "__main__":
411
721
  base_path = get_openclaw_path()
412
722
  path = base_path
413
723
 
724
+ # 启动时清理所有 workspace 中的忽略文件记录
725
+ for entry in os.scandir(base_path):
726
+ if entry.is_dir() and "workspace" in entry.name:
727
+ cleanup_ignored_assets(entry.name)
728
+
414
729
  # 如果指定了 --agent,先全量同步
415
730
  if args.agent:
416
731
  if not sync_all(args.agent):
@@ -16,7 +16,10 @@ MiniMax 资源注册脚本
16
16
  import os
17
17
  import json
18
18
  import sys
19
- from datetime import datetime, timezone, timedelta
19
+
20
+ # 添加 server 目录到 path 以导入共享模块
21
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', 'server'))
22
+ from artifacts_schema import ARTIFACTS_FILENAME, create_empty_artifacts, now_iso
20
23
 
21
24
  WORKSPACE = os.environ.get("WORKSPACE_NAME", "main")
22
25
  AUTO_REGISTRY = os.environ.get("AUTO_REGISTRY", "false").lower() == "true"
@@ -30,7 +33,10 @@ else:
30
33
  WORKSPACE_ROOT = os.path.join(OPENCLAW_HOME, f"workspace-{WORKSPACE}")
31
34
 
32
35
  MYCLAW_DIR = os.path.join(WORKSPACE_ROOT, ".myclaw")
33
- REGISTRY_FILE = os.path.join(MYCLAW_DIR, "__MY_ARTIFACTS__.json")
36
+ REGISTRY_FILE = os.path.join(MYCLAW_DIR, ARTIFACTS_FILENAME)
37
+
38
+ # registry 的 base_url(远程服务端 API)
39
+ REGISTRY_BASE_URL = "https://claw.kekouen.cn/cmd/api/preview?path="
34
40
 
35
41
 
36
42
  def load_registry():
@@ -38,23 +44,13 @@ def load_registry():
38
44
  if os.path.exists(REGISTRY_FILE):
39
45
  with open(REGISTRY_FILE, "r", encoding="utf-8") as f:
40
46
  return json.load(f)
41
- return {
42
- "workspace_id": WORKSPACE,
43
- "title": f"我的资源库",
44
- "release_version": 1,
45
- "created_at": datetime.now(timezone(timedelta(hours=8))).isoformat(),
46
- "updated_at": datetime.now(timezone(timedelta(hours=8))).isoformat(),
47
- "version": 1,
48
- "base_url": "https://claw.kekouen.cn/cmd/api/preview?path=",
49
- "preview_path": "index.html",
50
- "assets": []
51
- }
47
+ return create_empty_artifacts(WORKSPACE, REGISTRY_BASE_URL)
52
48
 
53
49
 
54
50
  def save_registry(data):
55
51
  """保存 registry"""
56
52
  os.makedirs(MYCLAW_DIR, exist_ok=True)
57
- data["updated_at"] = datetime.now(timezone(timedelta(hours=8))).isoformat()
53
+ data["updated_at"] = now_iso()
58
54
  with open(REGISTRY_FILE, "w", encoding="utf-8") as f:
59
55
  json.dump(data, f, ensure_ascii=False, indent=2)
60
56