@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.
- package/assets/myclaw-artifacts.js +15 -26
- package/package.json +1 -1
- package/server/sync_workspace.py +72 -41
- package/skills/yiran-course-template-pipeline/scripts/build_template_index.py +56 -19
- package/skills/yiran-course-template-pipeline/scripts/identify_template.py +176 -0
- package/skills/yiran-course-template-pipeline/scripts/migrate_templates.py +104 -0
- package/skills/yiran-course-template-pipeline/scripts/move_template_task.py +59 -59
- package/skills/yiran-course-template-pipeline/scripts/publish_template.py +51 -37
- package/skills/yiran-course-template-pipeline/template-index.json +185 -191
- package/skills/yiran-course-template-pipeline/template-index.md +28 -14
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* }
|
|
20
20
|
*
|
|
21
21
|
* URL 拼接规则:
|
|
22
|
-
* {
|
|
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
|
-
//
|
|
2357
|
+
// 新格式:{index_updated_at, templates: {id: record}},直接平铺
|
|
2371
2358
|
var raw = indexData.templates || indexData;
|
|
2372
|
-
var list =
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
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
package/server/sync_workspace.py
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
542
|
-
|
|
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
|
|
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
|
-
|
|
1072
|
+
folder_dir = os.path.join(
|
|
1065
1073
|
get_openclaw_path(), 'skills',
|
|
1066
1074
|
'yiran-playground-template-use', 'templates', folder
|
|
1067
1075
|
)
|
|
1068
|
-
|
|
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
|
|
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
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
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
|
|
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 (
|
|
31
|
-
return
|
|
53
|
+
if (version_dir / name).exists():
|
|
54
|
+
return version_dir / name
|
|
32
55
|
return None
|
|
33
56
|
|
|
34
57
|
|
|
35
58
|
def main():
|
|
36
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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(
|
|
96
|
+
entry = get_entry_file(version_dir)
|
|
66
97
|
|
|
67
|
-
|
|
68
|
-
folder_mtime = max(
|
|
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
|
-
'
|
|
77
|
-
'
|
|
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
|
-
|
|
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':
|
|
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()
|