@aiyiran/myclaw 1.1.44 → 1.1.46

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.
@@ -19,7 +19,7 @@
19
19
  * }
20
20
  *
21
21
  * URL 拼接规则:
22
- * {origin}/cmd/api/preview?path={wsPrefix}/{asset.path}
22
+ * {MYCLAW_API_BASE}/api/artifacts?workspace={wsPrefix}
23
23
  * main → workspace, 其他 → workspace-{name}
24
24
  * ============================================================================
25
25
  */
@@ -384,18 +384,6 @@
384
384
  });
385
385
  }
386
386
 
387
- function fetchArtifactsFromServerAPI(wsPrefix) {
388
- var url = window.location.origin + '/cmd/api/preview?path=' + encodeURIComponent(wsPrefix + '/.myclaw/__MY_ARTIFACTS__.json');
389
- console.log('[myclaw-artifacts] [2/2] 尝试 ServerAPI:', url);
390
- return fetch(url).then(function (res) {
391
- if (!res.ok) throw new Error('HTTP ' + res.status);
392
- return res.json();
393
- }).then(function (data) {
394
- console.log('[myclaw-artifacts] ✅ ServerAPI 成功');
395
- return data;
396
- });
397
- }
398
-
399
387
  function autoPreloadImages(data) {
400
388
  if (!data || !data.assets) return;
401
389
  data.assets.forEach(function (asset) {
@@ -418,12 +406,7 @@
418
406
  function fetchArtifacts(contentEl) {
419
407
  var wsPrefix = getWorkspaceId();
420
408
 
421
- // LocalAPI → ServerAPI(两层降级,不走 CDN)
422
409
  fetchArtifactsFromLocalAPI(wsPrefix)
423
- .catch(function (err) {
424
- console.warn('[myclaw-artifacts] ❌ LocalAPI 失败:', err.message, '→ 降级 ServerAPI');
425
- return fetchArtifactsFromServerAPI(wsPrefix);
426
- })
427
410
  .then(function (data) {
428
411
  cachedData = data;
429
412
  autoPreloadImages(data);
@@ -2310,6 +2293,8 @@
2310
2293
  resetBtn(copyLocalBtn);
2311
2294
  if (res.ok) {
2312
2295
  setFooterStatus('✓ 已复制到 ' + res.folder_rel_path, '#10b981');
2296
+ } else if (res.code === 'not_ready') {
2297
+ setFooterStatus('⟳ 模板文件下载中,完成后即可使用', '#f59e0b');
2313
2298
  } else {
2314
2299
  setFooterStatus('✗ ' + (res.error || '复制失败'), '#ff4444');
2315
2300
  }
@@ -2341,6 +2326,8 @@
2341
2326
  window.location.href = 'http://127.0.0.1:18789/chat?session=' + encodeURIComponent(sessionParam);
2342
2327
  }, 4000);
2343
2328
  }
2329
+ } else if (res.code === 'not_ready') {
2330
+ setFooterStatus('⟳ 模板文件下载中,完成后即可使用', '#f59e0b');
2344
2331
  } else {
2345
2332
  setFooterStatus('✗ ' + (res.error || '创建失败'), '#ff4444');
2346
2333
  }
@@ -2367,13 +2354,14 @@
2367
2354
  leftPane.appendChild(loadingEl);
2368
2355
 
2369
2356
  function flattenTemplates(indexData) {
2370
- // 兼容新格式 {index_updated_at, templates: {...}} 和旧格式 {A: {...}}
2357
+ // 新格式:{index_updated_at, templates: {id: record}},直接平铺
2371
2358
  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
- });
2359
+ var list = Object.values(raw);
2360
+ // 按系列+编号排序
2361
+ list.sort(function (a, b) {
2362
+ var sa = (a['系列'] || '') + ('000' + (a['编号'] || '0')).slice(-4);
2363
+ var sb = (b['系列'] || '') + ('000' + (b['编号'] || '0')).slice(-4);
2364
+ return sa < sb ? -1 : sa > sb ? 1 : 0;
2377
2365
  });
2378
2366
  return list;
2379
2367
  }
@@ -2423,7 +2411,7 @@
2423
2411
  rightHeader.appendChild(infoSpan);
2424
2412
  rightHeader.appendChild(openNewBtn);
2425
2413
  rightHeader.appendChild(useBtn);
2426
- previewIframe.src = TEMPLATE_CDN_BASE + '/' + encodeURIComponent(tpl['文件夹名']) + '/__student-view__.html?t=' + Date.now();
2414
+ previewIframe.src = TEMPLATE_CDN_BASE + '/' + encodeURIComponent(tpl['文件夹名']) + '/' + (tpl['version'] || 'v1') + '/__student-view__.html?t=' + Date.now();
2427
2415
  }
2428
2416
 
2429
2417
  row.onclick = setActive;
@@ -2437,7 +2425,7 @@
2437
2425
  badge.style.cssText = 'font-size:10px;font-weight:bold;background:#4a4a7a;color:#cdd6f4;padding:1px 7px;border-radius:8px;flex-shrink:0;';
2438
2426
  var nameEl = document.createElement('span');
2439
2427
  nameEl.textContent = tpl['名称'];
2440
- nameEl.style.cssText = 'font-size:12px;color:#cdd6f4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
2428
+ nameEl.style.cssText = 'font-size:12px;color:#cdd6f4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;';
2441
2429
  topRow.appendChild(badge);
2442
2430
  topRow.appendChild(nameEl);
2443
2431
 
@@ -2461,6 +2449,7 @@
2461
2449
  });
2462
2450
  }
2463
2451
 
2452
+ // ── 就绪状态追踪 ──────────────────────────────────────────────────────
2464
2453
  // ── 并行:加载本地 index + 检查 CDN 更新 ──────────────────────────────
2465
2454
  var _pollTimer = null; // 提到外层,doSync 守卫用
2466
2455
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiyiran/myclaw",
3
- "version": "1.1.44",
3
+ "version": "1.1.46",
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()