Files
Toolbox/dev_test_scripts/runners/run_new_travel_skill.js
T

384 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
* run_new_travel_skill.js —— 「游山玩水」新技能脚手架
*
* 用途:一条命令产出新 skill 的最小骨架(前后端各一套空壳),之后自己填。
*
* 用法:
* node dev_test_scripts/runners/run_new_travel_skill.js <skill_id> "技能中文名"
* 例:node dev_test_scripts/runners/run_new_travel_skill.js japan_7d "日本七天游"
*
* 产出(两棵树下各建同名目录):
* src/server/thought_lab/labs/travel_citytour/skills/<id>/
* ├── config.json 聚合页元信息 + 本技能 AI 参数(含注释)
* ├── index.js 导出 bindRoutes(router, ctx) 的空壳(含注释与示例路由)
* └── data/ 本技能私有数据目录(.gitignore 已排除,不进库)
* public/tools/thought_lab/labs/travel_citytour/skills/<id>/
* ├── 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 <skill_id> "技能中文名"')
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/<skill_id>] 前缀)
// 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 = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${name} · 游山玩水</title>
<style>
/* 本页样式自己维护,建议沿用 tc- 前缀,避免与其他 skill 的样式互相污染 */
:root { --tc-bg:#f6f7f9; --tc-card:#fff; --tc-line:#e5e7eb; --tc-text:#1f2328; --tc-sub:#6b7280; --tc-accent:#2f6fed; }
* { box-sizing:border-box; }
body { margin:0; background:var(--tc-bg); color:var(--tc-text); font:14px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif; }
.tc-page { max-width:1080px; margin:0 auto; padding:20px 16px 72px; }
.tc-top { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; margin-bottom:6px; }
.tc-top h1 { font-size:20px; margin:0; }
.tc-sub { font-size:12px; color:var(--tc-sub); }
.tc-back { font-size:12px; color:var(--tc-accent); text-decoration:none; }
.tc-meta { font-size:12px; color:var(--tc-sub); background:#fff; border:1px solid var(--tc-line);
border-radius:8px; padding:8px 12px; margin:12px 0 16px; }
.tc-block { background:var(--tc-card); border:1px solid var(--tc-line); border-radius:10px; padding:16px; margin-bottom:16px; }
.tc-block-title { font-size:15px; margin:0 0 4px; }
.tc-block-hint { font-size:12px; color:var(--tc-sub); }
.tc-note { border-radius:8px; padding:10px 12px; font-size:13px; border:1px solid; margin-bottom:12px; }
.tc-note-info { background:#f2f7ff; border-color:#cfe0ff; }
.tc-note-danger { background:#fdece8; border-color:#f3c0b6; }
.tc-note b { display:block; margin-bottom:2px; }
.tc-note ol { margin:6px 0 0; padding-left:20px; }
.tc-note ol li { margin-bottom:3px; }
.tc-btn { display:inline-block; text-decoration:none; font-size:13px; border:none; cursor:pointer;
background:var(--tc-accent); color:#fff; border-radius:8px; padding:9px 18px; font-family:inherit; }
.tc-btn-ghost { background:#fff; color:var(--tc-accent); border:1px solid #cfdcff; }
.tc-pre { background:#f7f8fa; border:1px solid var(--tc-line); border-radius:8px; padding:10px;
font:12px/1.5 Consolas,Monaco,monospace; white-space:pre-wrap; word-break:break-all;
max-height:280px; overflow:auto; margin:0; }
</style>
<!-- 若需要报告渲染,请把渲染器与报告样式复制到本目录后在此引入(本 skill 私有,不引用别家 skill) -->
</head>
<body>
<div class="tc-page">
<div class="tc-top">
<h1>${name}</h1>
<span class="tc-sub">游山玩水 · 单 skill 页</span>
<a class="tc-back" href="../../index.html">← 返回技能列表</a>
</div>
<div class="tc-meta" id="tc_meta">正在读取技能状态…</div>
<!-- 鉴权失败引导:契约要求每个 skill 页面都要有,否则用户只会看到白屏 401。
诊断接口归 lab 层所有(/auth/diag),本 skill 不重复实现。 -->
<div class="tc-note tc-note-danger" id="tc_auth_note" style="display:none">
<b>缺少导航通行凭证(nav_gate),接口被拒绝</b>
本工具的<b>页面</b>允许同源导航进入,但<b>接口</b>只认 nav_gate cookie,所以会出现「页面能打开、接口全是 401」。按下面做一次即可恢复:
<ol>
<li>从<b>首页九宫格</b>点「游山玩水」卡片进来(必须经过首页,它才会种 cookie)</li>
<li>全程用<b>同一个地址</b>:<code>localhost</code> 与 <code>127.0.0.1</code> 的 cookie 不通用</li>
<li>nav_gate 是<b>会话级 cookie</b>,关闭浏览器后失效,需要重新从首页进一次</li>
</ol>
<div style="margin-top:10px">
<a class="tc-btn" href="/">重新从首页进入</a>
<button class="tc-btn tc-btn-ghost" id="tc_auth_retry">我已重新进入,重试</button>
</div>
<div id="tc_auth_diag"></div>
</div>
<div class="tc-block">
<h2 class="tc-block-title">1 · 填写行程参数</h2>
<div class="tc-block-hint" id="tc_hint">TODO: 本技能的问答表单(按本技能的 qa 定义自己渲染)</div>
<!-- TODO: 表单 -->
</div>
<div id="tc_result"></div>
<div class="tc-note tc-note-info">
<b>打不开 / 提示未授权?</b>
本技能受导航鉴权保护,必须<b>从首页九宫格进入思想实验室、再点本卡片</b>;直接粘贴链接会被拒绝(无同源来源)。
</div>
</div>
<script>
(function () {
'use strict'
// 本 skill 的接口前缀(由 lab 层挂载,鉴权已由 lab 层包好)
var API = '/api/thought_lab/${LAB_ID}/skills/${rawId}'
var LAB_API = '/api/thought_lab/${LAB_ID}'
var $ = function (id) { return document.getElementById(id) }
function esc(s) {
return String(s === undefined || s === null ? '' : s)
.replace(/[&<>"']/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] })
}
function isUnauthorized(err) { return /unauthorized|401/i.test(String((err && err.message) || '')) }
// 鉴权失败引导 + 拉取 lab 级公开诊断(这段是契约要求的必备片段,照抄即可)
function showAuthNote() {
$('tc_auth_note').style.display = ''
fetch(LAB_API + '/auth/diag?t=' + Date.now(), { cache: 'no-store' })
.then(function (r) { return r.json() })
.then(function (d) {
$('tc_auth_diag').innerHTML =
'<div style="font-size:12px;font-weight:600;margin:12px 0 6px;color:#374151">当前请求诊断(公开接口,不含任何 cookie 值)</div>' +
'<pre class="tc-pre">' + esc(JSON.stringify(d, null, 2)) + '</pre>'
})
.catch(function () {})
}
function requestJson(url, options) {
return fetch(url + '?t=' + Date.now(), Object.assign({ cache: 'no-store' }, options || {}))
.then(function (r) {
return r.text().then(function (t) {
var j = null
try { j = JSON.parse(t) } catch (e) {}
if (!r.ok) {
var err = new Error((j && (j.error || j.hint)) || ('HTTP ' + r.status))
err.detail = j || t.slice(0, 400)
throw err
}
if (!j) throw new Error('返回不是合法 JSON')
return j
})
})
}
// 读本技能元信息(ui_mode / 模型 / 本技能自己的 qa、report_spec…)
requestJson(API + '/meta').then(function (r) {
$('tc_meta').innerHTML =
'ui_mode:<b>' + esc(r.ui_mode) + '</b>' +
' · 技能:<b>' + esc(r.skill && r.skill.name) + '</b>' +
' · 生成模型:<b>' + esc(r.ai_model) + '</b>' +
' · AI 通道:<b>' + (r.ai_ready ? '就绪' : '未配置密钥') + '</b>'
$('tc_hint').textContent = '本技能骨架已就绪,接下来按本技能需要自己实现表单与渲染。'
}).catch(function (e) {
if (isUnauthorized(e)) showAuthNote()
$('tc_meta').textContent = '技能元信息加载失败:' + (e && e.message || '')
})
$('tc_auth_retry').addEventListener('click', function () { location.reload() })
})()
</script>
</body>
</html>
`
/* ---------------- 前端 icon.svg 占位 ---------------- */
const feIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"
stroke="#2f6fed" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" role="img"
aria-label="${name}">
<title>${name}</title>
<circle cx="12" cy="12" r="9" />
<path d="M12 7.5v9M7.5 12h9" />
</svg>
`
/* ---------------- 落盘 ---------------- */
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 层」,方便后人理解')