/* ============================================================ * run_new_travel_skill.js —— 「游山玩水」新技能脚手架 * * 用途:一条命令产出新 skill 的最小骨架(前后端各一套空壳),之后自己填。 * * 用法: * node dev_test_scripts/runners/run_new_travel_skill.js "技能中文名" * 例:node dev_test_scripts/runners/run_new_travel_skill.js japan_7d "日本七天游" * * 产出(两棵树下各建同名目录): * src/server/thought_lab/labs/travel_citytour/skills// * ├── config.json 聚合页元信息 + 本技能 AI 参数(含注释) * ├── index.js 导出 bindRoutes(router, ctx) 的空壳(含注释与示例路由) * └── data/ 本技能私有数据目录(.gitignore 已排除,不进库) * public/tools/thought_lab/labs/travel_citytour/skills// * ├── index.html 空页面:含「返回列表」+ 鉴权失败引导面板(可复制片段) * ├── icon.svg 占位图标 * └── assets/ 本技能私有静态资源目录 * * 刻意不产出(按约定「从 0 开始」): * 渲染器 / 报告样式 / 导出器 / 问答表单 —— 这些由各 skill 自己写或自己复制改。 * * 位置遵循项目规则:所有脚本统一放 dev_test_scripts/。 * ============================================================ */ const fs = require('fs') const path = require('path') const ROOT = path.join(__dirname, '..', '..') const LAB_ID = 'travel_citytour' const BE_SKILLS = path.join(ROOT, 'src', 'server', 'thought_lab', 'labs', LAB_ID, 'skills') const FE_SKILLS = path.join(ROOT, 'public', 'tools', 'thought_lab', 'labs', LAB_ID, 'skills') const die = (msg) => { console.error('✗ ' + msg); process.exit(1) } const rawId = String(process.argv[2] || '').trim() const name = String(process.argv[3] || '').trim() if (!rawId) die('用法:node dev_test_scripts/runners/run_new_travel_skill.js "技能中文名"') if (/[^A-Za-z0-9_]/.test(rawId)) die('skill_id 只允许字母 / 数字 / 下划线(会作为目录名与 API 路径段):' + rawId) if (!name) die('缺少技能中文名(聚合页卡片标题)') if (!/^[a-z][a-z0-9_]*$/.test(rawId)) die('skill_id 建议小写下划线风格,例如 japan_7d') const beDir = path.join(BE_SKILLS, rawId) const feDir = path.join(FE_SKILLS, rawId) if (fs.existsSync(beDir) || fs.existsSync(feDir)) { die('同名 skill 已存在,拒绝覆盖:\n ' + beDir + '\n ' + feDir) } /* ---------------- 后端 config.json ---------------- */ const beConfig = { _comment: '技能:' + name + ' —— 本文件是【聚合页与 lab 扫描层唯一读取的文件】,必须保持极简。lab 层只看 id/name/desc/icon/enabled/sort_order,不读任何业务内容。', id_comment: '技能唯一标识,必须与目录名完全一致(不一致时 lab 会标记 broken 并在聚合页置灰)', id: rawId, name_comment: '聚合页卡片标题', name: name, desc_comment: '聚合页卡片一句话简介(tooltip)', desc: '(待填)这套行程打法适用什么场景', icon_comment: '聚合页卡片图标(emoji)。若本目录存在 icon.svg,聚合页优先用 icon.svg', icon: '🧭', sort_order_comment: '聚合页排序,数值越小越靠前', sort_order: 50, enabled_comment: '是否启用(false 时 lab 扫描层直接跳过本 skill)', enabled: false, ui_mode_comment: '弹窗交互模式:debug=交互详情弹窗(3s 自动关闭);normal=成功降级 Toast(2s),失败仍弹窗', ui_mode: 'debug', ai_comment: '本技能生成链路的 AI 参数。密钥不在这里,由 lab 层 ctx.callLLM 从环境变量读取(红线:skill 不接触密钥)', ai: { model_comment: '纯文本生成默认 qwen-plus-latest(项目统一规则:自动跟随官方最新稳定快照)', model: 'qwen-plus-latest', temperature_comment: '采样温度(0-1)。结构化输出建议 0.2-0.4', temperature: 0.3, max_tokens_comment: '单次最大输出 token。按本技能的行程长度自行评估(天数越多、节点越多,需要越大)', max_tokens: 20000, timeout_ms_comment: '单次生成超时(毫秒)', timeout_ms: 600000 } } /* ---------------- 后端 index.js ---------------- */ const beIndex = `// ============================================================ // skills/${rawId}/index.js —— 本技能的后端路由(技能私有、自包含) // // 契约(lab 扫描层唯一要求): // module.exports = { bindRoutes(router, ctx) } // - router:lab 层已经 .use(labAuth) 之后再交给我 → 本文件【不写任何鉴权代码】 // - ctx :lab 层提供的红线能力(见文末清单),本文件只管业务 // // 挂载点:/api/thought_lab/${LAB_ID}/skills/${rawId}/* // 本技能与同 lab 其他技能零耦合:删除本目录即完全卸载。 // ============================================================ const fs = require('fs') const path = require('path') const SKILL_DIR = __dirname // TODO: 本技能需要的技能包文件清单(读法自定,格式自定;不一定要沿用别家 skill) const SKILL_FILES = { // skill: 'skill.json', // rules: 'rules.md' } const loadBundle = () => { const bundle = { id: path.basename(SKILL_DIR), dir: SKILL_DIR, rules: '' } Object.keys(SKILL_FILES).forEach((key) => { const file = path.join(SKILL_DIR, SKILL_FILES[key]) if (!fs.existsSync(file)) { bundle[key] = key === 'rules' ? '' : {}; return } const txt = fs.readFileSync(file, 'utf-8') bundle[key] = key === 'rules' ? txt : JSON.parse(txt) }) return bundle } const bindRoutes = (router, ctx) => { // 本技能元信息:页面双模式依据 + 本技能自己的配置 router.get('/meta', (req, res) => { const cfg = ctx.readConfig() return res.json({ ok: true, ui_mode: String(cfg.ui_mode || 'debug'), ai_model: String((cfg.ai && cfg.ai.model) || 'qwen-plus-latest'), ai_ready: ctx.aiReady(), skill: { id: '${rawId}', name: '${name}', version: '0.0.1' }, // TODO: 按本技能需要补充 qa 定义 / 渲染描述 / 目的地清单等 qa: {}, report_spec: {}, destinations: [] }) }) // 生成(鉴权已由 lab 层完成;ctx.genGuard 负责按 skill 单飞 + 全局并发上限 + 按 IP 限流) router.post('/generate', ctx.jsonParser, ctx.genGuard, async (req, res) => { const body = ctx.sanitizeObject(req.body || {}) const answers = (body.answers && typeof body.answers === 'object' && !Array.isArray(body.answers)) ? body.answers : {} try { ctx.log('generate start', { answers }) const bundle = loadBundle() const cfg = ctx.readConfig() const aiConfig = cfg.ai || {} // TODO: 这里写本技能的 prompt 与输出契约。 // ctx.callLLM({ messages, model, temperature, maxTokens, timeoutMs }) // → { content, finishReason, usage } // ctx.extractJson(content) → 解析出的对象(容忍代码块与前后废话) const messages = [ { role: 'system', content: 'TODO: 本技能的 system prompt' }, { role: 'user', content: JSON.stringify(answers) } ] const r = await ctx.callLLM({ messages, model: String(aiConfig.model || 'qwen-plus-latest'), temperature: Number(aiConfig.temperature ?? 0.3), maxTokens: Number(aiConfig.max_tokens || 8000), timeoutMs: Number(aiConfig.timeout_ms || 600000) }) const payload = ctx.extractJson(r.content) payload.meta = payload.meta || {} payload.meta.skill_id = '${rawId}' payload.meta.generated_at = ctx.nowBeijing() // 红线:北京时间唯一实现 ctx.log('generate done', { finish_reason: r.finishReason }) return res.json({ ok: true, payload }) } catch (e) { const msg = String((e && e.message) || e) ctx.log('generate failed', { error: msg }) return res.status(msg === 'dashscope_key_missing' || msg === 'dashscope_host_missing' ? 500 : 502) .json({ ok: false, error: msg }) } }) } // ctx 可用能力(红线统一实现,不要自己另写一套): // ctx.log(event, data) 日志(自动带 [travel_citytour/] 前缀) // ctx.nowBeijing() 北京时间字符串(唯一实现) // ctx.escapeHtml(s) HTML 转义(唯一实现) // ctx.callLLM(opts) 调模型(唯一持有密钥的地方) // ctx.extractJson(text) 稳健取 JSON // ctx.sanitizeObject(obj) 原型污染防护 // ctx.jsonParser 请求体解析中间件 // ctx.genGuard 生成并发/限流中间件 // ctx.readLimit 只读限流中间件 // ctx.readConfig() 读本技能自己的 config.json // ctx.dataDir 本技能私有 data/ 绝对路径 // ctx.skillId / ctx.prefix skill_id 与 API 前缀 module.exports = { bindRoutes, loadBundle } ` /* ---------------- 前端 index.html ---------------- */ const feHtml = ` ${name} · 游山玩水

${name}

游山玩水 · 单 skill 页 ← 返回技能列表
正在读取技能状态…

1 · 填写行程参数

TODO: 本技能的问答表单(按本技能的 qa 定义自己渲染)
打不开 / 提示未授权? 本技能受导航鉴权保护,必须从首页九宫格进入思想实验室、再点本卡片;直接粘贴链接会被拒绝(无同源来源)。
` /* ---------------- 前端 icon.svg 占位 ---------------- */ const feIcon = ` ${name} ` /* ---------------- 落盘 ---------------- */ try { fs.mkdirSync(path.join(beDir, 'data'), { recursive: true }) fs.mkdirSync(path.join(feDir, 'assets'), { recursive: true }) fs.writeFileSync(path.join(beDir, 'config.json'), JSON.stringify(beConfig, null, 2) + '\n', 'utf-8') fs.writeFileSync(path.join(beDir, 'index.js'), beIndex, 'utf-8') fs.writeFileSync(path.join(feDir, 'index.html'), feHtml, 'utf-8') fs.writeFileSync(path.join(feDir, 'icon.svg'), feIcon, 'utf-8') // data/ 与 assets/ 是空目录:放一个说明文件,避免「空目录进不了 git」导致目录消失 fs.writeFileSync(path.join(beDir, 'data', '.gitkeep'), '', 'utf-8') fs.writeFileSync(path.join(feDir, 'assets', '.gitkeep'), '', 'utf-8') } catch (e) { die('写入失败:' + String(e.message || e)) } console.log('✓ 新技能骨架已生成:' + rawId + '(' + name + ')') console.log('') console.log('后端:' + path.relative(ROOT, beDir)) console.log(' config.json ← 聚合页元信息 + 本技能 AI 参数(enabled 默认 false)') console.log(' index.js ← 导出 bindRoutes(router, ctx),业务路由空壳') console.log(' data/ ← 本技能私有数据(.gitignore 已排除,不进库)') console.log('前端:' + path.relative(ROOT, feDir)) console.log(' index.html ← 空页面(含返回列表 + 鉴权失败引导片段)') console.log(' icon.svg ← 占位图标') console.log(' assets/ ← 本技能私有静态资源') console.log('') console.log('接下来(按需自己写,脚手架刻意不产出):') console.log(' 1. 写本技能的技能包文件(格式自定),在 index.js 里读进来') console.log(' 2. 写本技能的 prompt 与输出契约(可参考兄弟 skill,但请【复制后再改】而不是引用它)') console.log(' 3. 写本技能的问答表单与结果渲染;要渲染器/导出器就从兄弟 skill 复制一份改') console.log(' 4. 自测:node dev_test_scripts/unit/test_travel_citytour_isolation.js') console.log(' ↑ 含隔离校验:会断言本 skill 没有跨目录引用(引用兄弟 skill 或 lab 业务文件都会失败)') console.log(' 5. 自写本技能的契约测试:照 test_travel_citytour_rail_skill.js 再写一份(每个 skill 一份)') console.log(' 6. 自检:GET /api/thought_lab/' + LAB_ID + '/skills/_health') console.log(' 7. 一切就绪后,把 config.json 的 enabled 改成 true(默认 false 是为了避免半成品进聚合页)') console.log('') console.log('★ 隔离规则(本 lab 的宪法,务必遵守):') console.log(' · 本 skill 只能引用自己目录内的文件;唯一允许向上引用的是 lab 级 assets/vendor/(第三方库)') console.log(' · 禁止引用兄弟 skill 的任何文件(哪怕只是一行代码);要复用就复制过来再改') console.log(' · 鉴权/凭据/北京时间/XSS 转义 不许自己实现,一律用 lab 层注入的 ctx.*') console.log(' · 数据落在本目录的 data/(已被 .gitignore 排除,不会进库)') console.log(' · 每个源文件头部请写清「为什么这个文件在 skill 目录而不是 lab 层」,方便后人理解')