@aiyiran/myclaw 1.1.44 → 1.1.45

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.
@@ -2310,6 +2310,8 @@
2310
2310
  resetBtn(copyLocalBtn);
2311
2311
  if (res.ok) {
2312
2312
  setFooterStatus('✓ 已复制到 ' + res.folder_rel_path, '#10b981');
2313
+ } else if (res.code === 'not_ready') {
2314
+ setFooterStatus('⟳ 模板文件下载中,完成后即可使用', '#f59e0b');
2313
2315
  } else {
2314
2316
  setFooterStatus('✗ ' + (res.error || '复制失败'), '#ff4444');
2315
2317
  }
@@ -2341,6 +2343,8 @@
2341
2343
  window.location.href = 'http://127.0.0.1:18789/chat?session=' + encodeURIComponent(sessionParam);
2342
2344
  }, 4000);
2343
2345
  }
2346
+ } else if (res.code === 'not_ready') {
2347
+ setFooterStatus('⟳ 模板文件下载中,完成后即可使用', '#f59e0b');
2344
2348
  } else {
2345
2349
  setFooterStatus('✗ ' + (res.error || '创建失败'), '#ff4444');
2346
2350
  }
@@ -2367,13 +2371,14 @@
2367
2371
  leftPane.appendChild(loadingEl);
2368
2372
 
2369
2373
  function flattenTemplates(indexData) {
2370
- // 兼容新格式 {index_updated_at, templates: {...}} 和旧格式 {A: {...}}
2374
+ // 新格式:{index_updated_at, templates: {id: record}},直接平铺
2371
2375
  var raw = indexData.templates || indexData;
2372
- var list = [];
2373
- Object.keys(raw).sort().forEach(function (series) {
2374
- Object.keys(raw[series]).sort(function (a, b) { return parseInt(a) - parseInt(b); }).forEach(function (num) {
2375
- list.push(raw[series][num]);
2376
- });
2376
+ var list = Object.values(raw);
2377
+ // 按系列+编号排序
2378
+ list.sort(function (a, b) {
2379
+ var sa = (a['系列'] || '') + ('000' + (a['编号'] || '0')).slice(-4);
2380
+ var sb = (b['系列'] || '') + ('000' + (b['编号'] || '0')).slice(-4);
2381
+ return sa < sb ? -1 : sa > sb ? 1 : 0;
2377
2382
  });
2378
2383
  return list;
2379
2384
  }
@@ -2423,7 +2428,7 @@
2423
2428
  rightHeader.appendChild(infoSpan);
2424
2429
  rightHeader.appendChild(openNewBtn);
2425
2430
  rightHeader.appendChild(useBtn);
2426
- previewIframe.src = TEMPLATE_CDN_BASE + '/' + encodeURIComponent(tpl['文件夹名']) + '/__student-view__.html?t=' + Date.now();
2431
+ previewIframe.src = TEMPLATE_CDN_BASE + '/' + encodeURIComponent(tpl['文件夹名']) + '/' + (tpl['version'] || 'v1') + '/__student-view__.html?t=' + Date.now();
2427
2432
  }
2428
2433
 
2429
2434
  row.onclick = setActive;
@@ -2437,7 +2442,7 @@
2437
2442
  badge.style.cssText = 'font-size:10px;font-weight:bold;background:#4a4a7a;color:#cdd6f4;padding:1px 7px;border-radius:8px;flex-shrink:0;';
2438
2443
  var nameEl = document.createElement('span');
2439
2444
  nameEl.textContent = tpl['名称'];
2440
- nameEl.style.cssText = 'font-size:12px;color:#cdd6f4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
2445
+ nameEl.style.cssText = 'font-size:12px;color:#cdd6f4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;';
2441
2446
  topRow.appendChild(badge);
2442
2447
  topRow.appendChild(nameEl);
2443
2448
 
@@ -2461,6 +2466,7 @@
2461
2466
  });
2462
2467
  }
2463
2468
 
2469
+ // ── 就绪状态追踪 ──────────────────────────────────────────────────────
2464
2470
  // ── 并行:加载本地 index + 检查 CDN 更新 ──────────────────────────────
2465
2471
  var _pollTimer = null; // 提到外层,doSync 守卫用
2466
2472
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiyiran/myclaw",
3
- "version": "1.1.44",
3
+ "version": "1.1.45",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -505,28 +505,40 @@ TEMPLATE_CDN_INDEX_URL = 'https://cdn.yiranlaoshi.com/myclaw/live/yiran/skills/y
505
505
 
506
506
 
507
507
  def _do_sync_templates(cdn_index, local_index, pending):
508
- """后台线程:按 folder 全量下载有变化的模板文件
509
- pending: list of (folder_name, is_new),已在调用方确定好
508
+ """后台线程:按 folder+version 全量下载有变化的模板文件
509
+ pending: list of (folder_name, version_str, is_new)
510
+
511
+ 流程:
512
+ 1. 删除旧版本目录(保证本地只有一个版本)
513
+ 2. 下载到临时目录 .tmp/
514
+ 3. 下载完成后 rename .tmp/ → v{N}/(版本目录存在即代表就绪)
510
515
  """
516
+ import shutil
511
517
  try:
512
518
  added = []
513
519
  updated = []
514
520
 
515
521
  _sync_log('开始同步,共 {} 个 folder'.format(len(pending)))
516
522
 
517
- for folder_name, is_new in pending:
518
- _sync_log('{} {} → 下载中...'.format('新增' if is_new else '更新', folder_name))
519
- cdn_prefix = TEMPLATE_CDN_PREFIX_BASE + '/{}/'.format(folder_name)
520
- folder_dir = os.path.join(TEMPLATE_LOCAL_DIR, folder_name)
521
- os.makedirs(folder_dir, exist_ok=True)
522
-
523
- # 下载开始前先删掉标记,防止更新窗口期内被误判为就绪
524
- done_marker = os.path.join(folder_dir, '.sync-done')
525
- try:
526
- os.remove(done_marker)
527
- except OSError:
528
- pass
523
+ for folder_name, version_str, is_new in pending:
524
+ _sync_log('{} {}/{} → 下载中...'.format('新增' if is_new else '更新', folder_name, version_str))
529
525
 
526
+ cdn_prefix = '{}/{}/{}/'.format(TEMPLATE_CDN_PREFIX_BASE, folder_name, version_str)
527
+ folder_dir = os.path.join(TEMPLATE_LOCAL_DIR, folder_name)
528
+ version_dir = os.path.join(folder_dir, version_str)
529
+ tmp_dir = os.path.join(folder_dir, '.tmp')
530
+
531
+ # 1. 删除旧版本目录和残留临时目录
532
+ if os.path.isdir(folder_dir):
533
+ for entry in os.listdir(folder_dir):
534
+ if re.match(r'^v\d+$', entry) and entry != version_str:
535
+ shutil.rmtree(os.path.join(folder_dir, entry), ignore_errors=True)
536
+ _sync_log(' 删除旧版本: {}'.format(entry))
537
+ if os.path.isdir(tmp_dir):
538
+ shutil.rmtree(tmp_dir, ignore_errors=True)
539
+
540
+ # 2. 下载到 .tmp/
541
+ os.makedirs(tmp_dir, exist_ok=True)
530
542
  cdn_files = _list_files_from_qiniu(cdn_prefix)
531
543
  _sync_log(' 列举到 {} 个文件'.format(len(cdn_files)))
532
544
  for item in cdn_files:
@@ -534,22 +546,18 @@ def _do_sync_templates(cdn_index, local_index, pending):
534
546
  filename = key[len(cdn_prefix):]
535
547
  if not filename:
536
548
  continue
537
- ok = _download_from_cdn(key, os.path.join(folder_dir, filename))
549
+ ok = _download_from_cdn(key, os.path.join(tmp_dir, filename))
538
550
  _sync_log(' {} {}'.format('✓' if ok else '✗', filename))
539
551
 
540
- # 全部下完后写标记
541
- try:
542
- with open(done_marker, 'w') as f:
543
- f.write(cdn_ua or '')
544
- except Exception:
545
- pass
552
+ # 3. rename .tmp/ → v{N}/ (目录存在即代表该版本就绪)
553
+ os.rename(tmp_dir, version_dir)
554
+ _sync_log(' → 已就绪: {}/{}'.format(folder_name, version_str))
546
555
 
547
556
  if is_new:
548
557
  added.append(folder_name)
549
558
  else:
550
559
  updated.append(folder_name)
551
560
 
552
- # 每完成一个 folder 就更新进度
553
561
  with _sync_lock:
554
562
  _sync_state['completed'].append(folder_name)
555
563
 
@@ -1057,18 +1065,29 @@ class MyclawAPIHandler(BaseHTTPRequestHandler):
1057
1065
  )
1058
1066
 
1059
1067
  def _resolve_template_path(self, folder):
1060
- """校验 folder 并返回模板绝对路径;非法时直接响应并返回 None"""
1068
+ """校验 folder 并返回模板最新版本绝对路径;非法或未就绪时直接响应并返回 None"""
1061
1069
  if not folder or '..' in folder or '/' in folder or '\\' in folder:
1062
1070
  self._send_json({'error': 'invalid folder'}, 400)
1063
1071
  return None
1064
- path = os.path.join(
1072
+ folder_dir = os.path.join(
1065
1073
  get_openclaw_path(), 'skills',
1066
1074
  'yiran-playground-template-use', 'templates', folder
1067
1075
  )
1068
- if not os.path.isfile(os.path.join(path, '.sync-done')):
1076
+ # 找最新 v{N} 子目录(目录存在即代表就绪)
1077
+ max_v = 0
1078
+ version_path = None
1079
+ if os.path.isdir(folder_dir):
1080
+ for entry in os.listdir(folder_dir):
1081
+ m = re.match(r'^v(\d+)$', entry)
1082
+ if m:
1083
+ v = int(m.group(1))
1084
+ if v > max_v:
1085
+ max_v = v
1086
+ version_path = os.path.join(folder_dir, entry)
1087
+ if not version_path:
1069
1088
  self._send_json({'error': '模板尚未下载完成,请稍后重试', 'code': 'not_ready'}, 409)
1070
1089
  return None
1071
- return path
1090
+ return version_path
1072
1091
 
1073
1092
  def _run_skill_script(self, cmd, timeout=60):
1074
1093
  """运行 skill 脚本,解析 stdout JSON 并返回 (data, err)"""
@@ -1229,21 +1248,21 @@ class MyclawAPIHandler(BaseHTTPRequestHandler):
1229
1248
  self._send_json({'changed': False})
1230
1249
  return
1231
1250
 
1232
- # 计算需要下载的 folder 列表
1251
+ # 计算需要下载的 folder 列表(新格式:index.templates 为 {id: record} 平铺字典)
1252
+ # 就绪标准:{folder}/{version}/ 目录存在即代表完整就绪
1233
1253
  cdn_templates = cdn_index.get('templates', {})
1234
1254
  local_templates = local_index.get('templates', {})
1235
- pending = [] # list of (folder_name, is_new)
1236
- for series, nums in cdn_templates.items():
1237
- for num, record in nums.items():
1238
- folder_name = record.get('文件夹名', '')
1239
- if not folder_name:
1240
- continue
1241
- cdn_ua = record.get('updated_at', '')
1242
- local_ua = local_templates.get(series, {}).get(num, {}).get('updated_at', '')
1243
- is_new = series not in local_templates or num not in local_templates.get(series, {})
1244
- is_changed = not is_new and cdn_ua != local_ua
1245
- if is_new or is_changed:
1246
- pending.append((folder_name, is_new))
1255
+ pending = [] # list of (folder_name, version_str, is_new)
1256
+ for tid, record in cdn_templates.items():
1257
+ folder_name = record.get('文件夹名', '')
1258
+ version_str = record.get('version', 'v1')
1259
+ if not folder_name:
1260
+ continue
1261
+ version_dir = os.path.join(TEMPLATE_LOCAL_DIR, folder_name, version_str)
1262
+ if os.path.isdir(version_dir):
1263
+ continue # 本地已有该版本,无需下载
1264
+ is_new = tid not in local_templates
1265
+ pending.append((folder_name, version_str, is_new))
1247
1266
 
1248
1267
  if not pending:
1249
1268
  # index_updated_at 变了但所有 folder 都没变,直接更新 index 即可
@@ -1306,10 +1325,22 @@ class MyclawAPIHandler(BaseHTTPRequestHandler):
1306
1325
  total = _sync_state['total']
1307
1326
  last_result = _sync_state['last_result']
1308
1327
 
1328
+ # 扫描磁盘,返回实际已就绪的 folder 列表(v{N}/ 目录存在即就绪)
1329
+ ready = []
1330
+ if os.path.isdir(TEMPLATE_LOCAL_DIR):
1331
+ for folder in os.listdir(TEMPLATE_LOCAL_DIR):
1332
+ folder_path = os.path.join(TEMPLATE_LOCAL_DIR, folder)
1333
+ if not os.path.isdir(folder_path):
1334
+ continue
1335
+ for version_entry in os.listdir(folder_path):
1336
+ if re.match(r'^v\d+$', version_entry) and os.path.isdir(os.path.join(folder_path, version_entry)):
1337
+ ready.append(folder)
1338
+ break
1339
+
1309
1340
  if running:
1310
- self._send_json({'running': True, 'completed': completed, 'total': total})
1341
+ self._send_json({'running': True, 'completed': completed, 'total': total, 'ready': ready})
1311
1342
  else:
1312
- self._send_json({'running': False, 'result': last_result})
1343
+ self._send_json({'running': False, 'result': last_result, 'ready': ready})
1313
1344
 
1314
1345
  def log_message(self, format, *args):
1315
1346
  # 静默日志,避免轮询刷屏
@@ -1,4 +1,12 @@
1
1
  #!/usr/bin/env python3
2
+ """
3
+ build_template_index.py
4
+
5
+ 扫描 templates/ 目录,为每个模板读取最新版本子目录(v{N}/)中的
6
+ __teacher__.json 和 __student__.json,生成 template-index.json 和 template-index.md。
7
+
8
+ index 以 _id 为主键(flat 结构),每条记录包含版本号。
9
+ """
2
10
  import json
3
11
  import re
4
12
  from datetime import datetime, timezone
@@ -11,7 +19,7 @@ OUTPUT_JSON = ROOT / 'template-index.json'
11
19
  OUTPUT_MD = ROOT / 'template-index.md'
12
20
 
13
21
 
14
- def read_json(path: Path):
22
+ def read_json(path: Path) -> dict:
15
23
  try:
16
24
  return json.loads(path.read_text(encoding='utf-8'))
17
25
  except Exception:
@@ -25,15 +33,30 @@ def parse_folder_name(name: str):
25
33
  return None, None, None
26
34
 
27
35
 
28
- def get_entry_file(folder: Path) -> Optional[Path]:
36
+ def get_latest_version_dir(folder: Path):
37
+ """返回最新版本子目录 Path 和版本号 int,没有则返回 (None, 0)"""
38
+ max_v = 0
39
+ result = None
40
+ for d in folder.iterdir():
41
+ if d.is_dir():
42
+ m = re.match(r'^v(\d+)$', d.name)
43
+ if m:
44
+ v = int(m.group(1))
45
+ if v > max_v:
46
+ max_v = v
47
+ result = d
48
+ return result, max_v
49
+
50
+
51
+ def get_entry_file(version_dir: Path) -> Optional[Path]:
29
52
  for name in ['index.html', '__demo__.html', '__student-view__.html']:
30
- if (folder / name).exists():
31
- return folder / name
53
+ if (version_dir / name).exists():
54
+ return version_dir / name
32
55
  return None
33
56
 
34
57
 
35
58
  def main():
36
- index = {}
59
+ templates = {} # { _id: record }
37
60
  items_for_md = []
38
61
 
39
62
  for folder in sorted(TEMPLATES_DIR.iterdir() if TEMPLATES_DIR.exists() else []):
@@ -44,8 +67,16 @@ def main():
44
67
  if not series:
45
68
  continue
46
69
 
47
- teacher = read_json(folder / '__teacher__.json')
48
- student = read_json(folder / '__student__.json')
70
+ version_dir, version_num = get_latest_version_dir(folder)
71
+ if not version_dir:
72
+ continue
73
+
74
+ teacher = read_json(version_dir / '__teacher__.json')
75
+ student = read_json(version_dir / '__student__.json')
76
+
77
+ tid = teacher.get('_id', '').strip()
78
+ if not tid:
79
+ continue
49
80
 
50
81
  name = teacher.get('任务名称') or student.get('任务标题', '')
51
82
  一句话 = student.get('一句话说明', '')
@@ -62,20 +93,25 @@ def main():
62
93
  类型标签,
63
94
  ])))
64
95
 
65
- entry = get_entry_file(folder)
96
+ entry = get_entry_file(version_dir)
66
97
 
67
- # 文件夹最新 mtime 作为变更标记
68
- folder_mtime = max((f.stat().st_mtime for f in folder.iterdir() if f.is_file()), default=0)
98
+ all_files = list(version_dir.rglob('*'))
99
+ folder_mtime = max(
100
+ (f.stat().st_mtime for f in all_files if f.is_file()),
101
+ default=0
102
+ )
69
103
  updated_at = datetime.fromtimestamp(folder_mtime, tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
70
104
 
105
+ version_str = f'v{version_num}'
106
+
71
107
  record = {
108
+ 'id': tid,
72
109
  '系列': series,
73
110
  '编号': number,
74
111
  '名称': name,
75
112
  '文件夹名': folder.name,
76
- '路径': str(folder.relative_to(ROOT)),
77
- 'student_json': str((folder / '__student__.json').relative_to(ROOT)) if (folder / '__student__.json').exists() else '',
78
- 'teacher_json': str((folder / '__teacher__.json').relative_to(ROOT)) if (folder / '__teacher__.json').exists() else '',
113
+ 'version': version_str,
114
+ '路径': str(version_dir.relative_to(ROOT)),
79
115
  '入口文件': str(entry.relative_to(ROOT)) if entry else '',
80
116
  '一句话说明': 一句话,
81
117
  '主能力标签': 能力标签,
@@ -84,28 +120,29 @@ def main():
84
120
  'updated_at': updated_at,
85
121
  }
86
122
 
87
- if series not in index:
88
- index[series] = {}
89
- index[series][number] = record
123
+ templates[tid] = record
90
124
  items_for_md.append(record)
91
125
 
92
126
  index_updated_at = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
93
- output = {'index_updated_at': index_updated_at, 'templates': index}
127
+ output = {'index_updated_at': index_updated_at, 'templates': templates}
94
128
  OUTPUT_JSON.write_text(json.dumps(output, ensure_ascii=False, indent=2), encoding='utf-8')
95
129
 
130
+ items_for_md.sort(key=lambda x: (x['系列'], int(x['编号'])))
96
131
  lines = ['# 模板索引', '', '每次 templates 内模板有变化后,都应重新运行本脚本。', '']
97
132
  for item in items_for_md:
98
133
  lines.append(f"## {item['系列']}{item['编号']} {item['名称']}")
134
+ lines.append(f"- ID:{item['id']}")
135
+ lines.append(f"- 版本:{item['version']}")
99
136
  lines.append(f"- 路径:{item['路径']}")
100
137
  lines.append(f"- 入口文件:{item['入口文件']}")
101
138
  lines.append(f"- 主能力标签:{item['主能力标签']}")
102
139
  lines.append(f"- 任务类型标签:{item['任务类型标签']}")
103
140
  if item['关键词']:
104
- lines.append(f"- 关键词:{', '.join(item['关键词'])}")
141
+ lines.append(f"- 关键词:{', '.join(str(k) for k in item['关键词'])}")
105
142
  lines.append('')
106
143
 
107
144
  OUTPUT_MD.write_text('\n'.join(lines), encoding='utf-8')
108
- print(f'已生成: {OUTPUT_JSON}')
145
+ print(f'已生成: {OUTPUT_JSON}(共 {len(templates)} 条)')
109
146
  print(f'已生成: {OUTPUT_MD}')
110
147
 
111
148
 
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ identify_template.py
4
+
5
+ 给模板源目录颁发或校准身份信息,直接修改源目录的 __teacher__.json。
6
+ 不做任何文件移动,可单独运行。
7
+
8
+ 写入字段:
9
+ _id : 8位十六进制,首次颁发,后续不变
10
+ _version : 首次=1,更新=已有版本+1
11
+ 任务类别 : 校准后的系列(A/B/C)
12
+ 任务编号 : 校准后的3位编号(100起)
13
+
14
+ 用法:
15
+ python3 identify_template.py <模板目录>
16
+ """
17
+ import json
18
+ import re
19
+ import secrets
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ ROOT = Path(__file__).resolve().parent.parent
24
+ TEMPLATES_DIR = ROOT / 'templates'
25
+
26
+
27
+ def load_json(path: Path) -> dict:
28
+ try:
29
+ return json.loads(path.read_text(encoding='utf-8'))
30
+ except Exception:
31
+ return {}
32
+
33
+
34
+ def save_json(path: Path, data: dict):
35
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
36
+
37
+
38
+ def gen_id() -> str:
39
+ return secrets.token_hex(4) # 8位十六进制
40
+
41
+
42
+ def normalize_category(raw) -> str:
43
+ cat = str(raw).upper().strip()
44
+ if cat not in {'A', 'B', 'C'}:
45
+ raise ValueError(f'任务类别只能是 A、B、C,当前: {cat}')
46
+ return cat
47
+
48
+
49
+ def get_latest_version_num(folder: Path) -> int:
50
+ """返回 folder 下最大的版本号,没有则返回 0"""
51
+ max_v = 0
52
+ for d in folder.iterdir():
53
+ if d.is_dir():
54
+ m = re.match(r'^v(\d+)$', d.name)
55
+ if m:
56
+ max_v = max(max_v, int(m.group(1)))
57
+ return max_v
58
+
59
+
60
+ def scan_templates(templates_dir: Path):
61
+ """
62
+ 扫描 templates/ 下所有已入库模板。
63
+ 返回:
64
+ id_map : { _id -> {'folder': Path, 'version': int} }
65
+ number_map : { category -> { number(int) -> _id } }
66
+ """
67
+ id_map = {}
68
+ number_map = {}
69
+ pattern = re.compile(r'^([a-zA-Z])(\d{3})_')
70
+
71
+ if not templates_dir.exists():
72
+ return id_map, number_map
73
+
74
+ for folder in templates_dir.iterdir():
75
+ if not folder.is_dir():
76
+ continue
77
+ m = pattern.match(folder.name)
78
+ if not m:
79
+ continue
80
+
81
+ version_num = get_latest_version_num(folder)
82
+ if version_num == 0:
83
+ continue
84
+
85
+ version_dir = folder / f'v{version_num}'
86
+ teacher = load_json(version_dir / '__teacher__.json')
87
+ tid = teacher.get('_id')
88
+ if not tid:
89
+ continue
90
+
91
+ cat = m.group(1).upper()
92
+ num = int(m.group(2))
93
+
94
+ id_map[tid] = {'folder': folder, 'version': version_num}
95
+ number_map.setdefault(cat, {})[num] = tid
96
+
97
+ return id_map, number_map
98
+
99
+
100
+ def calibrate_number(category: str, desired: int, number_map: dict, own_id: str) -> int:
101
+ """
102
+ 校准编号:
103
+ - 宣称的编号不存在,或已属于自己 → 合法,直接用
104
+ - 已被别人占用 → 找最小可用编号(>=100)
105
+ """
106
+ used = number_map.get(category, {})
107
+ owner = used.get(desired)
108
+ if owner is None or owner == own_id:
109
+ return desired
110
+ # 冲突,找最小可用
111
+ for num in range(100, 1000):
112
+ if num not in used or used[num] == own_id:
113
+ return num
114
+ raise RuntimeError(f'{category} 类编号已用尽')
115
+
116
+
117
+ def main():
118
+ if len(sys.argv) < 2:
119
+ print('用法: python3 identify_template.py <模板目录>')
120
+ sys.exit(1)
121
+
122
+ source_dir = Path(sys.argv[1]).expanduser().resolve()
123
+ teacher_path = source_dir / '__teacher__.json'
124
+
125
+ if not teacher_path.exists():
126
+ print(f'❌ 找不到 __teacher__.json: {teacher_path}')
127
+ sys.exit(1)
128
+
129
+ teacher = load_json(teacher_path)
130
+ id_map, number_map = scan_templates(TEMPLATES_DIR)
131
+
132
+ # ── 1. _id 与版本号 ────────────────────────────────────────────────────
133
+ tid = teacher.get('_id', '').strip()
134
+ in_templates = tid and tid in id_map
135
+
136
+ if not tid:
137
+ tid = gen_id()
138
+ new_version = 1
139
+ print(f'🆔 颁发新 ID: {tid} 版本: v1')
140
+ elif not in_templates:
141
+ new_version = 1
142
+ print(f'🆔 已有 ID {tid},首次入库 版本: v1')
143
+ else:
144
+ old_version = id_map[tid]['version']
145
+ new_version = old_version + 1
146
+ print(f'🔄 更新 ID {tid} 版本: v{old_version} → v{new_version}')
147
+
148
+ # ── 2. 校准 任务类别 + 任务编号 ────────────────────────────────────────
149
+ try:
150
+ category = normalize_category(teacher.get('任务类别', ''))
151
+ except ValueError as e:
152
+ print(f'❌ {e}')
153
+ sys.exit(1)
154
+
155
+ try:
156
+ desired = int(str(teacher.get('任务编号', '100')))
157
+ except ValueError:
158
+ desired = 100
159
+
160
+ calibrated = calibrate_number(category, desired, number_map, tid)
161
+ if calibrated != desired:
162
+ print(f'⚠️ 编号冲突:{category}{desired:03d} 已被占用,校准为 {category}{calibrated:03d}')
163
+
164
+ # ── 3. 写回 __teacher__.json ───────────────────────────────────────────
165
+ teacher['_id'] = tid
166
+ teacher['_version'] = new_version
167
+ teacher['任务类别'] = category
168
+ teacher['任务编号'] = f'{calibrated:03d}'
169
+ save_json(teacher_path, teacher)
170
+
171
+ print(f'✅ 已写回: {teacher_path}')
172
+ print(f' _id={tid}, _version=v{new_version}, 分类={category}{calibrated:03d}')
173
+
174
+
175
+ if __name__ == '__main__':
176
+ main()
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ migrate_templates.py
4
+
5
+ 将旧版平铺结构迁移到版本化结构:
6
+ templates/{folder}/*.html → templates/{folder}/v1/*.html
7
+
8
+ 同时为没有 _id 的模板颁发 ID,写入 __teacher__.json。
9
+
10
+ 用法:
11
+ python3 scripts/migrate_templates.py [--dry-run]
12
+ """
13
+ import json
14
+ import re
15
+ import secrets
16
+ import shutil
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ ROOT = Path(__file__).resolve().parent.parent
21
+ TEMPLATES_DIR = ROOT / 'templates'
22
+ INDEX_SCRIPT = ROOT / 'scripts' / 'build_template_index.py'
23
+
24
+
25
+ def load_json(path: Path) -> dict:
26
+ try:
27
+ return json.loads(path.read_text(encoding='utf-8'))
28
+ except Exception:
29
+ return {}
30
+
31
+
32
+ def save_json(path: Path, data: dict):
33
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
34
+
35
+
36
+ def gen_id() -> str:
37
+ return secrets.token_hex(4)
38
+
39
+
40
+ def has_version_subdir(folder: Path) -> bool:
41
+ for d in folder.iterdir():
42
+ if d.is_dir() and re.match(r'^v\d+$', d.name):
43
+ return True
44
+ return False
45
+
46
+
47
+ def main():
48
+ dry_run = '--dry-run' in sys.argv
49
+ if dry_run:
50
+ print('🔍 DRY RUN 模式,不实际修改文件\n')
51
+
52
+ if not TEMPLATES_DIR.exists():
53
+ print('❌ templates/ 目录不存在')
54
+ sys.exit(1)
55
+
56
+ folders = sorted(d for d in TEMPLATES_DIR.iterdir() if d.is_dir())
57
+ migrated = 0
58
+
59
+ for folder in folders:
60
+ if has_version_subdir(folder):
61
+ print(f'✓ 跳过(已版本化): {folder.name}')
62
+ continue
63
+
64
+ # 列出所有直接子文件(排除目录)
65
+ files = [f for f in folder.iterdir() if f.is_file()]
66
+ if not files:
67
+ print(f'⚠️ 空目录,跳过: {folder.name}')
68
+ continue
69
+
70
+ teacher_path = folder / '__teacher__.json'
71
+ teacher = load_json(teacher_path)
72
+
73
+ # 补充 _id
74
+ if not teacher.get('_id'):
75
+ teacher['_id'] = gen_id()
76
+ print(f'🆔 颁发 ID {teacher["_id"]}: {folder.name}')
77
+
78
+ # 补充 _version
79
+ if not teacher.get('_version'):
80
+ teacher['_version'] = 1
81
+
82
+ if not dry_run:
83
+ # 先写回 __teacher__.json
84
+ save_json(teacher_path, teacher)
85
+
86
+ # 创建 v1/ 并移动所有文件
87
+ v1_dir = folder / 'v1'
88
+ v1_dir.mkdir(exist_ok=True)
89
+ for f in files:
90
+ shutil.move(str(f), str(v1_dir / f.name))
91
+
92
+ print(f'📦 迁移: {folder.name} → {folder.name}/v1/')
93
+ migrated += 1
94
+
95
+ print(f'\n{"[DRY RUN] " if dry_run else ""}共迁移 {migrated} 个模板')
96
+
97
+ if not dry_run and migrated > 0:
98
+ import subprocess
99
+ subprocess.run(['python3', str(INDEX_SCRIPT)], check=True)
100
+ print('✅ 索引已重建')
101
+
102
+
103
+ if __name__ == '__main__':
104
+ main()
@@ -1,4 +1,19 @@
1
1
  #!/usr/bin/env python3
2
+ """
3
+ move_template_task.py
4
+
5
+ 将模板源目录纳入 pipeline(复制,不删除原目录)。
6
+
7
+ 流程:
8
+ 1. 校验必需文件
9
+ 2. 调用 identify_template.py(颁发/校准身份,写回源目录)
10
+ 3. 读取更新后的 __teacher__.json,拼出目标路径
11
+ 4. 复制到 templates/{folder_name}/v{N}/
12
+ 5. 重建 template-index.json
13
+
14
+ 用法:
15
+ python3 move_template_task.py <模板目录> [目标根目录]
16
+ """
2
17
  import json
3
18
  import re
4
19
  import shutil
@@ -8,86 +23,71 @@ from pathlib import Path
8
23
 
9
24
  ROOT = Path(__file__).resolve().parent.parent
10
25
  DEFAULT_TARGET = ROOT / 'templates'
26
+ IDENTIFY_SCRIPT = ROOT / 'scripts' / 'identify_template.py'
11
27
  INDEX_SCRIPT = ROOT / 'scripts' / 'build_template_index.py'
12
- REQUIRED_FILES = ['__demo__.html', '__student__.json', '__teacher__.json', '__student-view__.html', '__teacher-view__.html']
13
-
14
-
15
- def load_json(path):
16
- with path.open('r', encoding='utf-8') as f:
17
- return json.load(f)
18
-
19
-
20
- def save_json(path, data):
21
- path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
22
-
23
-
24
- def normalize_category(teacher):
25
- category = str(teacher['任务类别']).upper()
26
- if category not in {'A', 'B', 'C'}:
27
- raise ValueError('任务类别只能是 A、B、C')
28
- return category
29
-
28
+ REQUIRED_FILES = [
29
+ '__demo__.html', '__student__.json', '__teacher__.json',
30
+ '__student-view__.html', '__teacher-view__.html',
31
+ ]
30
32
 
31
- def sanitize_name(name):
32
- return str(name).replace('/', '_').replace(' ', '_')
33
33
 
34
+ def load_json(path: Path) -> dict:
35
+ try:
36
+ return json.loads(path.read_text(encoding='utf-8'))
37
+ except Exception:
38
+ return {}
34
39
 
35
- def find_next_available_number(category, target_root):
36
- used = set()
37
- pattern = re.compile(r'^([a-zA-Z])(\d{3})_')
38
40
 
39
- for folder in target_root.iterdir() if target_root.exists() else []:
40
- if not folder.is_dir():
41
- continue
42
- m = pattern.match(folder.name)
43
- if not m:
44
- continue
45
- if m.group(1).upper() != category:
46
- continue
47
- used.add(int(m.group(2)))
48
-
49
- for num in range(100, 1000):
50
- if num not in used:
51
- return f'{num:03d}'
52
- raise RuntimeError(f'{category} 类编号已用尽')
53
-
54
-
55
- def task_folder_name(teacher, target_root):
56
- category = normalize_category(teacher)
57
- number = find_next_available_number(category, target_root)
58
- teacher['任务编号'] = number
59
- name = sanitize_name(teacher['任务名称'])
60
- return f'{category.lower()}{number}_{name}'
41
+ def sanitize_name(name: str) -> str:
42
+ return re.sub(r'[/\\ ]', '_', str(name).strip())
61
43
 
62
44
 
63
45
  def main():
64
46
  if len(sys.argv) < 2:
65
- print('用法: python3 move_template_task.py <已有文件夹> [目标目录]')
47
+ print('用法: python3 move_template_task.py <模板目录> [目标根目录]')
66
48
  sys.exit(1)
67
49
 
68
50
  source_dir = Path(sys.argv[1]).expanduser().resolve()
69
51
  target_root = Path(sys.argv[2]).expanduser().resolve() if len(sys.argv) > 2 else DEFAULT_TARGET
70
52
 
53
+ if not source_dir.is_dir():
54
+ print(f'❌ 目录不存在: {source_dir}')
55
+ sys.exit(1)
56
+
57
+ # 1. 校验必需文件
71
58
  for filename in REQUIRED_FILES:
72
59
  if not (source_dir / filename).exists():
73
- raise FileNotFoundError(f'缺少必需文件: {filename}')
60
+ print(f'缺少必需文件: {filename}')
61
+ sys.exit(1)
74
62
 
75
- teacher_path = source_dir / '__teacher__.json'
76
- teacher = load_json(teacher_path)
77
- target_root.mkdir(parents=True, exist_ok=True)
78
- folder_name = task_folder_name(teacher, target_root)
79
- save_json(teacher_path, teacher)
63
+ # 2. 颁发/校准身份(写回源目录的 __teacher__.json
64
+ print('\n── 身份认证 ────────────────────────────────')
65
+ subprocess.run([sys.executable, str(IDENTIFY_SCRIPT), str(source_dir)], check=True)
66
+
67
+ # 3. 读取更新后的身份信息
68
+ teacher = load_json(source_dir / '__teacher__.json')
69
+ category = teacher.get('任务类别', '').upper()
70
+ number = teacher.get('任务编号', '100')
71
+ name = sanitize_name(teacher.get('任务名称', 'untitled'))
72
+ version = teacher.get('_version', 1)
73
+
74
+ folder_name = f'{category.lower()}{number}_{name}'
75
+ version_str = f'v{version}'
76
+ target_dir = target_root / folder_name / version_str
80
77
 
81
- target_dir = target_root / folder_name
82
78
  if target_dir.exists():
83
- raise FileExistsError(f'目标目录已存在: {target_dir}')
79
+ print(f' 目标目录已存在(重复操作?): {target_dir}')
80
+ sys.exit(1)
84
81
 
85
- shutil.move(str(source_dir), str(target_dir))
86
- print(f'已移动并改名到: {target_dir}')
87
- print(f'已自动分配编号: {teacher["任务编号"]}')
82
+ # 4. 复制到 templates/{folder}/v{N}/
83
+ print(f'\n── 复制文件 ────────────────────────────────')
84
+ target_root.mkdir(parents=True, exist_ok=True)
85
+ shutil.copytree(str(source_dir), str(target_dir))
86
+ print(f'✅ 已复制到: {target_dir}')
88
87
 
89
- subprocess.run(['python3', str(INDEX_SCRIPT)], check=True)
90
- print('已自动重建 templates 索引')
88
+ # 5. 重建 index
89
+ print(f'\n── 重建索引 ────────────────────────────────')
90
+ subprocess.run([sys.executable, str(INDEX_SCRIPT)], check=True)
91
91
 
92
92
 
93
93
  if __name__ == '__main__':
@@ -4,85 +4,99 @@ publish_template.py
4
4
 
5
5
  发布模板文件夹到七牛 CDN,完成后重建并上传 template-index.json。
6
6
 
7
- 流程:
8
- 1. 把文件夹内所有文件无脑上传到七牛(强制覆盖)
9
- 2. 重建 template-index.json 并上传到 CDN
7
+ CDN 路径:{CDN_PREFIX}/{folder_name}/{version}/{filename}
8
+ 版本号从 templates/{folder}/v{N}/ 子目录读取(以本地最新版本为准)。
10
9
 
11
10
  用法:
12
11
  python3 scripts/publish_template.py <模板文件夹路径>
13
12
  python3 scripts/publish_template.py --all
14
-
15
- 例:
16
- python3 scripts/publish_template.py templates/a103_做一个介绍页面
17
- python3 scripts/publish_template.py --all
18
13
  """
19
-
20
14
  import argparse
15
+ import re
21
16
  import subprocess
22
17
  import sys
23
18
  from pathlib import Path
24
19
 
25
20
  from qiniu import Auth, put_file_v2, CdnManager
26
21
 
27
- # ── 七牛配置(与 server 保持一致)──────────────────────────────────────────
28
22
  QINIU_KEY = "T3tgxM7EMx1j4VESw4m4PIfFXoOvBo-wQEOQewXX"
29
23
  QINIU_SECRET = "PVZvlKVOjX2RqlV2ILMg-QwpNMssOlpVbaEzypz0"
30
24
  QINIU_BUCKET = "yiran1"
31
25
  CDN_DOMAIN = "https://cdn.yiranlaoshi.com"
32
- CDN_PREFIX = "myclaw/live/yiran/skills/yiran-playground-template-use/templates"
26
+ CDN_INDEX_KEY = "myclaw/live/yiran/skills/yiran-playground-template-use/template-index.json"
27
+ CDN_PREFIX = "myclaw/live/yiran/skills/yiran-playground-template-use/templates"
33
28
 
34
29
  q = Auth(QINIU_KEY, QINIU_SECRET)
35
30
  cdn_manager = CdnManager(q)
36
31
 
37
32
 
38
- def upload_file(local_path, folder_name):
39
- """上传单个文件到七牛(强制覆盖),返回 CDN URL。"""
40
- key = "{}/{}/{}".format(CDN_PREFIX, folder_name, local_path.name)
41
- token = q.upload_token(QINIU_BUCKET, key, 3600, policy={'insertOnly': 0})
42
- ret, info = put_file_v2(token, key, str(local_path))
33
+ def get_latest_version_dir(folder: Path):
34
+ """返回最新 v{N} 子目录和版本号,没有则返回 (None, 0)"""
35
+ max_v = 0
36
+ result = None
37
+ for d in folder.iterdir():
38
+ if d.is_dir():
39
+ m = re.match(r'^v(\d+)$', d.name)
40
+ if m:
41
+ v = int(m.group(1))
42
+ if v > max_v:
43
+ max_v = v
44
+ result = d
45
+ return result, max_v
46
+
47
+
48
+ def upload_file(local_path: Path, cdn_key: str) -> str:
49
+ token = q.upload_token(QINIU_BUCKET, cdn_key, 3600, policy={'insertOnly': 0})
50
+ ret, info = put_file_v2(token, cdn_key, str(local_path))
43
51
  if info.status_code != 200:
44
- raise RuntimeError("上传失败 {}: {}".format(local_path.name, info))
45
- cdn_url = "{}/{}".format(CDN_DOMAIN, key)
52
+ raise RuntimeError('上传失败 {}: {}'.format(local_path.name, info))
53
+ cdn_url = '{}/{}'.format(CDN_DOMAIN, cdn_key)
46
54
  cdn_manager.refresh_urls([cdn_url])
47
55
  return cdn_url
48
56
 
49
57
 
50
- def publish(folder_path):
58
+ def publish(folder_path: Path):
51
59
  folder_name = folder_path.name
52
- files = [f for f in folder_path.iterdir() if f.is_file()]
60
+ version_dir, version_num = get_latest_version_dir(folder_path)
61
+
62
+ if not version_dir:
63
+ print('⚠️ {} 没有版本子目录,跳过'.format(folder_name))
64
+ return
65
+
66
+ version_str = 'v{}'.format(version_num)
67
+ files = [f for f in version_dir.rglob('*') if f.is_file() and f.name != '.sync-done']
53
68
 
54
69
  if not files:
55
- print("⚠️ 文件夹为空:{}".format(folder_path))
70
+ print('⚠️ 文件夹为空: {}/{}'.format(folder_name, version_str))
56
71
  return
57
72
 
58
- print("📂 发布文件夹:{}".format(folder_name))
73
+ print('📂 发布: {}/{}'.format(folder_name, version_str))
59
74
  for f in sorted(files):
60
- cdn_url = upload_file(f, folder_name)
61
- print(" ⬆️ {} {}".format(f.name, cdn_url))
62
-
63
- print("\n✅ 发布完成:{}".format(folder_name))
75
+ rel = f.relative_to(version_dir)
76
+ cdn_key = '{}/{}/{}/{}'.format(CDN_PREFIX, folder_name, version_str, rel.as_posix())
77
+ cdn_url = upload_file(f, cdn_key)
78
+ print(' ⬆️ {} {}'.format(rel, cdn_url))
64
79
 
80
+ print('✅ 完成: {}/{}\n'.format(folder_name, version_str))
65
81
 
66
82
 
67
83
  def publish_index(pipeline_root: Path):
68
- """重建 template-index.json 并上传到 CDN。"""
69
84
  index_script = pipeline_root / 'scripts' / 'build_template_index.py'
70
- print("\n🔄 重建 template-index.json ...")
85
+ print('🔄 重建 template-index.json ...')
71
86
  subprocess.run(['python3', str(index_script)], check=True)
72
87
 
73
88
  index_file = pipeline_root / 'template-index.json'
74
89
  if not index_file.is_file():
75
- print("⚠️ template-index.json 未生成,跳过上传")
90
+ print('⚠️ template-index.json 未生成,跳过上传')
76
91
  return
77
92
 
78
- key = f"{CDN_PREFIX.rsplit('/templates', 1)[0]}/template-index.json"
79
- token = q.upload_token(QINIU_BUCKET, key, 3600, policy={'insertOnly': 0})
80
- ret, info = put_file_v2(token, key, str(index_file))
93
+ token = q.upload_token(QINIU_BUCKET, CDN_INDEX_KEY, 3600, policy={'insertOnly': 0})
94
+ ret, info = put_file_v2(token, CDN_INDEX_KEY, str(index_file))
81
95
  if info.status_code != 200:
82
- raise RuntimeError(f"index 上传失败: {info}")
83
- cdn_url = f"{CDN_DOMAIN}/{key}"
96
+ raise RuntimeError('index 上传失败: {}'.format(info))
97
+ cdn_url = '{}/{}'.format(CDN_DOMAIN, CDN_INDEX_KEY)
84
98
  cdn_manager.refresh_urls([cdn_url])
85
- print(f" ⬆️ 已上传索引 → {cdn_url}")
99
+ print(' ⬆️ 已上传索引 → {}'.format(cdn_url))
86
100
 
87
101
 
88
102
  def main():
@@ -98,15 +112,15 @@ def main():
98
112
  templates_dir = pipeline_root / 'templates'
99
113
  folders = sorted(d for d in templates_dir.iterdir() if d.is_dir())
100
114
  if not folders:
101
- print("⚠️ templates/ 下没有找到任何模板文件夹")
115
+ print('⚠️ templates/ 下没有找到任何模板文件夹')
102
116
  sys.exit(0)
103
- print(f"📦 共 {len(folders)} 个模板,开始全量发布...\n")
117
+ print('📦 共 {} 个模板,开始全量发布...\n'.format(len(folders)))
104
118
  for folder in folders:
105
119
  publish(folder)
106
120
  else:
107
121
  folder = Path(args.folder).expanduser().resolve()
108
122
  if not folder.is_dir():
109
- print(f"❌ 路径不存在或不是文件夹: {folder}")
123
+ print('❌ 路径不存在或不是文件夹: {}'.format(folder))
110
124
  sys.exit(1)
111
125
  publish(folder)
112
126