@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.
- package/.claude/settings.local.json +17 -1
- package/assets/myclaw-artifacts.js +142 -46
- package/index.js +36 -4
- package/package.json +1 -1
- package/server/artifacts_schema.py +32 -0
- package/server/fork.py +615 -0
- package/server/install-linux-guard.sh +88 -0
- package/server/myclaw-guard.sh +121 -0
- package/server/myclaw-sync.service +18 -0
- package/server/sync_workspace.py +398 -83
- package/skills/yiran-skill-media/scripts/registry.py +10 -14
package/server/sync_workspace.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
|
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 ==
|
|
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
|
-
|
|
278
|
-
if os.path.exists(
|
|
279
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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" ❌
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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',
|
|
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.
|
|
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.
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
|
700
|
+
"""在后台线程启动 HTTP API 服务(多线程,避免 resync 阻塞轮询)"""
|
|
701
|
+
class ThreadedHTTPServer(socketserver.ThreadingMixIn, HTTPServer):
|
|
393
702
|
allow_reuse_address = True
|
|
703
|
+
daemon_threads = True
|
|
394
704
|
|
|
395
|
-
server =
|
|
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
|
-
|
|
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,
|
|
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"] =
|
|
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
|
|