feat: 新增高铁游与车宿徒步游技能及相关基础设施

This commit is contained in:
yangxiangyuan
2026-09-30 13:47:51 +08:00
parent bbc7ca6523
commit ac475b6659
66 changed files with 14572 additions and 0 deletions
+2
View File
@@ -52,6 +52,8 @@ umersoft.com_OV_SSL/
# ===== 调试/临时文件 =====
*.md
# 例外:游山玩水 lab 的技能包规则文件是业务资产,必须入库
!**/thought_lab/labs/travel_citytour/skills/**/*.md
*.txt
cookies.txt
_fix_*.js
@@ -0,0 +1,233 @@
/* ============================================================
* debug_dashscope_image_models.js —— 探测百炼(DashScope)可用的文生图模型
*
* 目的:项目此前只用了 chat/completions 与 TTS,没有文生图调用。
* 本脚本枚举候选模型与候选端点,找出「本账号实际能用」的组合,
* 并把生成的图落盘到临时目录做 MD5 比对,确认不同 prompt 出的是不同的图。
*
* 运行:node dev_test_scripts/debug/debug_dashscope_image_models.js
* 说明:只读探测,不写入仓库任何文件(产物落在系统临时目录)。
* ============================================================ */
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
// 载入本机私有凭据(与项目其他模块同一份文件,不进仓库)
const envPath = path.join(process.env.USERPROFILE || 'C:\\Users\\Administrator', 'Toolbox_local_creds.env.local')
if (fs.existsSync(envPath)) {
const text = fs.readFileSync(envPath, 'utf8')
for (const line of text.split(/\r?\n/)) {
if (!line || /^\s*#/.test(line)) continue
const i = line.indexOf('=')
if (i < 0) continue
const k = line.slice(0, i).trim()
const v = line.slice(i + 1).trim().replace(/^["']|["']$/g, '')
if (k && !(k in process.env)) process.env[k] = v
}
}
const KEY = process.env.DASHSCOPE_API_KEY || ''
const MaaS = (process.env.DASHSCOPE_API_HOST_FOR_OPENAI || '').replace(/\/+$/, '')
const PUBLIC_BASE = 'https://dashscope.aliyuncs.com'
const OUT = path.join(process.env.TEMP || 'C:\\Windows\\Temp', 'tc_img_probe')
fs.mkdirSync(OUT, { recursive: true })
const promptA = 'coastal embankment above tidal flat, wind turbines, autumn afternoon'
const promptB = 'old Chinese town stone lane with grey brick houses and red lanterns'
console.log('[env] KEY 已加载:', !!KEY, KEY ? '(len=' + KEY.length + ')' : '')
console.log('[env] MaaS host:', MaaS ? MaaS.replace(/\/compatible-mode.*$/, '/…') : '(空)')
console.log('[out]', OUT)
const get = async (url, headers) => {
const r = await fetch(url, { headers, signal: AbortSignal.timeout(25000) })
const t = await r.text()
return { status: r.status, body: t }
}
/* ---------- 1. 先看这个 MaaS 端点声明了哪些模型 ---------- */
async function listModels() {
console.log('\n======== 1. 列出 MaaS 端点的模型 ========')
if (!MaaS) { console.log('无 MaaS host,跳过'); return [] }
try {
const r = await get(MaaS + '/models', { Authorization: 'Bearer ' + KEY })
console.log('status=', r.status)
if (r.status !== 200) { console.log(r.body.slice(0, 400)); return [] }
let j = null
try { j = JSON.parse(r.body) } catch { console.log(r.body.slice(0, 400)); return [] }
const ids = (j.data || []).map((m) => m.id)
console.log('模型数 =', ids.length)
const likely = ids.filter((id) => /image|t2i|wanx|wan2|flux|sd|diffus/i.test(id))
console.log('疑似文生图相关:', likely.length ? likely.join(', ') : '(无)')
console.log('全部模型(前 60):\n ' + ids.slice(0, 60).join('\n '))
return ids
} catch (e) { console.log('ERR', e.message) ; return [] }
}
/* ---------- 2. 探 text2image/image-synthesis 端点 ---------- */
const WANX_MODELS = [
'wanx2.1-t2i-turbo',
'wanx2.1-t2i-plus',
'wanx2.0-t2i-turbo',
'wanx-v1',
'wan2.2-t2i-flash',
'wan2.2-t2i-plus',
'wan2.5-t2i-preview',
'wan2.7-t2i-plus'
]
async function tryImageSynthesis(model) {
const url = PUBLIC_BASE + '/api/v1/services/aigc/text2image/image-synthesis'
try {
const r = await fetch(url, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + KEY,
'Content-Type': 'application/json',
'X-DashScope-Async': 'enable'
},
body: JSON.stringify({
model,
input: { prompt: promptA },
parameters: { size: '1024*576', n: 1 }
}),
signal: AbortSignal.timeout(25000)
})
const t = await r.text()
let j = null
try { j = JSON.parse(t) } catch {}
if (r.status === 200 && j && j.output && j.output.task_id) {
console.log(' ✓ ' + model + ' task=' + j.output.task_id)
return j.output.task_id
}
const msg = (j && (j.message || (j.output && j.output.task_status))) || t.slice(0, 160)
console.log(' ✗ ' + model + ' HTTP ' + r.status + ' ' + String(msg).slice(0, 160))
return null
} catch (e) {
console.log(' ✗ ' + model + ' ERR ' + e.message)
return null
}
}
async function poll(taskId, maxTry) {
const url = PUBLIC_BASE + '/api/v1/tasks/' + taskId
for (let i = 0; i < (maxTry || 20); i++) {
await new Promise((r) => setTimeout(r, 3000))
const r = await get(url, { Authorization: 'Bearer ' + KEY })
let j = null
try { j = JSON.parse(r.body) } catch {}
const st = j && j.output && j.output.task_status
if (st === 'SUCCEEDED') {
const results = (j.output.results || []).map((x) => x.url).filter(Boolean)
return { status: st, urls: results }
}
if (st === 'FAILED' || st === 'UNKNOWN') {
return { status: st, msg: j.message || (j.output && j.output.message) }
}
}
return { status: 'TIMEOUT' }
}
/* ---------- 3. 探多模态生成端点(qwen-image 走 messages) ---------- */
const QWEN_IMAGE_MODELS = ['qwen-image', 'qwen-image-plus', 'qwen-image-max']
async function tryMultimodal(model) {
const url = PUBLIC_BASE + '/api/v1/services/aigc/multimodal-generation/generation'
try {
const r = await fetch(url, {
method: 'POST',
headers: { Authorization: 'Bearer ' + KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
input: { messages: [{ role: 'user', content: [{ text: promptA }] }] },
parameters: { size: '1024*576', n: 1 }
}),
signal: AbortSignal.timeout(40000)
})
const t = await r.text()
let j = null
try { j = JSON.parse(t) } catch {}
const content = j && j.output && j.output.choices && j.output.choices[0] &&
j.output.choices[0].message && j.output.choices[0].message.content
const urls = Array.isArray(content) ? content.map((c) => c.image).filter(Boolean) : []
if (r.status === 200 && urls.length) { console.log(' ✓ ' + model + ' 直接返回图片'); return { status: 'SUCCEEDED', urls } }
if (r.status === 200 && j && j.output && j.output.task_id) { console.log(' ✓ ' + model + ' task=' + j.output.task_id); return { status: 'TASK', task: j.output.task_id } }
const msg = (j && j.message) || t.slice(0, 160)
console.log(' ✗ ' + model + ' HTTP ' + r.status + ' ' + String(msg).slice(0, 160))
return null
} catch (e) { console.log(' ✗ ' + model + ' ERR ' + e.message); return null }
}
/* ---------- 4. 下载两张图,比对 MD5 ---------- */
async function download(url, name) {
const r = await fetch(url, { signal: AbortSignal.timeout(60000) })
if (!r.ok) throw new Error('HTTP ' + r.status)
const buf = Buffer.from(await r.arrayBuffer())
const file = path.join(OUT, name)
fs.writeFileSync(file, buf)
return { file, size: buf.length, md5: crypto.createHash('md5').update(buf).digest('hex') }
}
async function main() {
const available = await listModels()
console.log('\n======== 2. text2image/image-synthesis 候选模型 ========')
const hit = []
for (const m of WANX_MODELS) {
const t = await tryImageSynthesis(m)
if (t) hit.push({ model: m, task: t, kind: 'synthesis' })
}
console.log('\n======== 3. multimodal-generation 候选模型 ========')
for (const m of QWEN_IMAGE_MODELS) {
if (available.length && available.indexOf(m) < 0) { console.log(' - ' + m + ' 不在 MaaS 模型列表,仍试一次'); }
const r = await tryMultimodal(m)
if (r && r.status === 'SUCCEEDED') hit.push({ model: m, urls: r.urls, kind: 'multimodal' })
else if (r && r.status === 'TASK') hit.push({ model: m, task: r.task, kind: 'multimodal' })
}
if (!hit.length) { console.log('\n结论:没有可用的文生图模型组合。'); return }
const first = hit[0]
console.log('\n======== 4. 用可用模型 ' + first.model + ' 跑两张不同 prompt,比对 MD5 ========')
let urls = first.urls
if (!urls) {
const p = await poll(first.task, 30)
console.log('poll:', p.status, p.msg || '')
urls = p.urls || []
}
if (!urls.length) { console.log('未拿到图片 URL,无法比对'); return }
console.log('URL 数 =', urls.length, urls[0].slice(0, 100))
const a1 = await download(urls[0], 'probe_A.jpg')
console.log('A:', a1.size, a1.md5)
// 换 prompt 再生成一张
let taskB = null
if (first.kind === 'synthesis') taskB = await tryImageSynthesisWith(first.model, promptB)
if (taskB) {
const p = await poll(taskB, 30)
if (p.urls && p.urls.length) {
const b1 = await download(p.urls[0], 'probe_B.jpg')
console.log('B:', b1.size, b1.md5)
console.log('\n两张图 MD5 是否相同:', a1.md5 === b1.md5 ? '❌ 相同(模型没区分 prompt)' : '✓ 不同(模型正常按 prompt 出图)')
} else console.log('B 图未生成:', p.status)
}
}
async function tryImageSynthesisWith(model, prompt) {
const url = PUBLIC_BASE + '/api/v1/services/aigc/text2image/image-synthesis'
try {
const r = await fetch(url, {
method: 'POST',
headers: { Authorization: 'Bearer ' + KEY, 'Content-Type': 'application/json', 'X-DashScope-Async': 'enable' },
body: JSON.stringify({ model, input: { prompt }, parameters: { size: '1024*576', n: 1 } }),
signal: AbortSignal.timeout(25000)
})
const j = JSON.parse(await r.text())
return j && j.output && j.output.task_id ? j.output.task_id : null
} catch { return null }
}
main().catch((e) => console.error('FATAL', e && e.message))
@@ -0,0 +1,88 @@
/* ============================================================
* debug_probe_max_tokens.js —— 探测 chat 模型可接受的 max_tokens 上限
*
* 背景:「游山玩水」整份行程 JSON 约 1.2-1.6 万 output token,
* 用 qwen-plus-latest + max_tokens=8000 会截断(finish_reason=length)。
* 本脚本用极短的 prompt 试不同 max_tokens,找出模型实际能接受的上限,
* 顺便列出可用 chat 模型。
*
* 运行:node dev_test_scripts/debug/debug_probe_max_tokens.js
* 说明:只读探测,用极短 prompt(输出 1-2 个 token),几乎不产生费用。
* ============================================================ */
const fs = require('fs')
const path = require('path')
const envPath = path.join(process.env.USERPROFILE || 'C:\\Users\\Administrator', 'Toolbox_local_creds.env.local')
if (fs.existsSync(envPath)) {
for (const line of fs.readFileSync(envPath, 'utf8').split(/\r?\n/)) {
if (!line || /^\s*#/.test(line)) continue
const i = line.indexOf('=')
if (i < 0) continue
const k = line.slice(0, i).trim()
const v = line.slice(i + 1).trim().replace(/^["']|["']$/g, '')
if (k && !(k in process.env)) process.env[k] = v
}
}
const HOST = (process.env.DASHSCOPE_API_HOST_FOR_OPENAI || '').replace(/\/+$/, '')
const KEY = process.env.DASHSCOPE_API_KEY || ''
const URL = HOST ? HOST + '/chat/completions' : ''
const MODELS = [
'qwen-plus-latest',
'qwen-max-latest',
'qwen3-max',
'qwen3.5-plus',
'qwen3.5-plus-latest',
'qwen-turbo-latest'
]
const BUDGETS = [8192, 16384, 32000]
const tryOne = async (model, maxTokens) => {
try {
const r = await fetch(URL, {
method: 'POST',
headers: { Authorization: 'Bearer ' + KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ model, messages: [{ role: 'user', content: '只回复两个字:好的' }], max_tokens: maxTokens }),
signal: AbortSignal.timeout(60000)
})
const t = await r.text()
let j = null
try { j = JSON.parse(t) } catch {}
if (r.status === 200 && j && j.choices) {
const u = j.usage || {}
return { ok: true, note: 'completion=' + (u.completion_tokens === undefined ? '?' : u.completion_tokens) }
}
return { ok: false, note: 'HTTP ' + r.status + ' ' + String((j && j.message) || t).slice(0, 130) }
} catch (e) {
return { ok: false, note: 'ERR ' + e.message }
}
}
const main = async () => {
console.log('host:', HOST ? '已配置' : '缺失', ' key:', KEY ? '已加载' : '缺失')
console.log('\n===== 可用 chat 模型(MaaS 声明) =====')
try {
const r = await fetch(HOST + '/models', { headers: { Authorization: 'Bearer ' + KEY } })
const j = JSON.parse(await r.text())
const ids = (j.data || []).map((m) => m.id)
const chat = ids.filter((id) => /qwen|deepseek|glm|kimi|llama|gpt/i.test(id) && !/image|vl|audio|asr|tts|voice|video|t2i|t2v|embed/i.test(id))
console.log('chat 类候选共 ' + chat.length + ' 个:\n ' + chat.slice(0, 50).join('\n '))
} catch (e) {
console.log('列模型失败:' + e.message)
}
console.log('\n===== max_tokens 上限探测 =====')
for (const m of MODELS) {
const line = []
for (const b of BUDGETS) {
const r = await tryOne(m, b)
line.push(b + ':' + (r.ok ? '✓' : '✗'))
if (!r.ok) { line.push('(' + r.note.slice(0, 90) + ')'); break }
}
console.log(' ' + m.padEnd(20) + ' ' + line.join(' '))
}
}
main().catch((e) => console.error('FATAL', e && e.message))
@@ -0,0 +1,112 @@
/* ============================================================
* debug_rail_replay_wuyuan.js —— 用真实失败案例回放,验证修复有效
*
* 背景:2026-09-30 生成「婺源」时返回 payload_invalid,唯一 error 是
* 「第一天缺「夜逛」环节(night_stroll)」——但 AI 其实排了 19:00 的夜逛。
* 根因是校验器写死了 day.label 含「第一天」,而 AI 标的是「周六/周日」。
*
* 本脚本把那次失败响应的 payload 原样取出来,走一遍「修复 → 校验」,
* 用来证明修复后不再误报(同时也顺带检查其余 warning 是否已消除)。
*
* 用法:
* node dev_test_scripts/debug/debug_rail_replay_wuyuan.js 只回放校验,不写任何文件
* node dev_test_scripts/debug/debug_rail_replay_wuyuan.js --save 额外把这份结果写回
* 技能私有 data/last_result.json,
* 这样用户可在页面上点「载入最近一次生成」
* 直接看到并导出这份婺源行程(不必重烧 token)
*
* 为什么需要 --save:服务端只在「校验 0 errors」时才落盘,那次因误判被丢弃,
* 用户 4 分钟的生成白费了。这个开关是给「已经拿到完整 payload、只想补落盘」的补救场景用的。
* ============================================================ */
const fs = require('fs')
const path = require('path')
const os = require('os')
const SRC = path.join(
os.homedir(), 'AppData', 'Roaming', 'Trae CN', 'User', 'workspaceStorage',
'540d38674b58f7802a478abf6956abc7', 'long-text', '6ab1e0ff40ac82e5fecf1de9',
'munlta58-u92r', '当前状态__失败__....txt'
)
const SKILL = path.join(process.cwd(), 'src', 'server', 'thought_lab', 'labs', 'travel_citytour', 'skills', 'sh_2d_rail_hike')
const skillIndex = require(path.join(SKILL, 'index.js'))
const { validatePayload } = require(path.join(SKILL, 'validate.js'))
const { repairPayload } = require(path.join(SKILL, 'repair.js'))
if (!fs.existsSync(SRC)) {
console.log('找不到失败响应文件(可能已被清理):' + SRC)
process.exit(0)
}
const raw = fs.readFileSync(SRC, 'utf-8')
// 响应体从「返回结果」后的第一个 '{' 开始,到「错误信息」之前结束
const startMark = raw.indexOf('返回结果')
const start = raw.indexOf('{', startMark)
const endMark = raw.indexOf('错误信息', start)
const text = raw.slice(start, endMark > 0 ? endMark : undefined).trim()
let envelope = null
try {
envelope = JSON.parse(text)
} catch (e) {
// 兜底:按大括号配平截取
let depth = 0, endIdx = -1
for (let i = start; i < raw.length; i++) {
if (raw[i] === '{') depth++
else if (raw[i] === '}') { depth--; if (depth === 0) { endIdx = i; break } }
}
envelope = JSON.parse(raw.slice(start, endIdx + 1))
}
const payload = envelope.payload
const bundle = skillIndex.loadBundle()
console.log('===== 婺源失败案例回放 =====')
console.log('原文 error:' + JSON.stringify(envelope.check && envelope.check.errors))
console.log('day.label 实际写法:' + ((payload.timeline && payload.timeline.days) || []).map((d) => JSON.stringify(d.label)).join(' / '))
console.log('AI 是否排了夜逛行:' + ((payload.timeline && payload.timeline.days) || [])
.reduce((a, d) => a.concat(d.rows || []), [])
.some((r) => r.anchor_id === 'night_stroll'))
console.log('')
const repaired = repairPayload(JSON.parse(JSON.stringify(payload)), bundle)
const chk = validatePayload(repaired.payload, bundle)
console.log('— 修复说明 —')
repaired.fixes.forEach((f) => console.log(' · ' + f))
console.log('')
console.log('— 修复后校验结果 —')
console.log('errors : ' + chk.errors.length + (chk.errors.length ? '\n ✗ ' + chk.errors.join('\n ✗ ') : ''))
console.log('warnings: ' + chk.warnings.length + (chk.warnings.length ? '\n ! ' + chk.warnings.join('\n ! ') : ''))
console.log('')
console.log(chk.errors.length === 0
? '✓ 回放通过:这份真实失败的 payload 修复后 0 errors,不再被误判'
: '✗ 仍有 error,需继续排查')
// --save:把修复后的结果补落盘,供页面「载入最近一次生成」使用
if (process.argv.indexOf('--save') >= 0) {
if (chk.errors.length) {
console.log('\n⚠ 仍有 error,拒绝落盘(避免把不合格结果写进 last_result)')
process.exit(1)
}
const outEnvelope = {
ok: true,
payload: repaired.payload,
report_spec: bundle.report_spec || {},
destination_override: null,
check: chk,
meta: Object.assign({}, envelope.meta, {
repairs: repaired.fixes,
recovered_from: 'payload_invalid 误判(校验器已修复)'
})
}
const dataDir = path.join(SKILL, 'data')
fs.mkdirSync(dataDir, { recursive: true })
const out = path.join(dataDir, 'last_result.json')
fs.writeFileSync(out, JSON.stringify(outEnvelope), 'utf-8')
console.log('\n✓ 已补落盘:' + out)
console.log(' 下一步:打开本技能页面 → 点「载入最近一次生成」即可查看/导出这份婺源行程')
}
process.exit(chk.errors.length === 0 ? 0 : 1)
@@ -0,0 +1,105 @@
/* ============================================================
* test_travel_citytour_generate_integration.js
* 「游山玩水」生成链路集成测试:不依赖服务重启,直接调用 lab 内的 ai.js
*
* 验证目标:
* 1. prompt 组装 + 百炼调用是否通
* 2. qwen-plus-latest 能否产出符合 output_schema 的整份行程(会不会被截断)
* 3. 结构校验(validate.js)出来多少 errors / warnings
* 4. 端到端耗时(决定前端要不要长 loading 提示)
*
* 运行:node dev_test_scripts/integration/test_travel_citytour_generate_integration.js
* 产物:payload 落在系统临时目录(不进仓库),仅用于人工查看
* ============================================================ */
const fs = require('fs')
const path = require('path')
// 载入本机私有凭据(与服务端同一份文件)
const envPath = path.join(process.env.USERPROFILE || 'C:\\Users\\Administrator', 'Toolbox_local_creds.env.local')
if (fs.existsSync(envPath)) {
for (const line of fs.readFileSync(envPath, 'utf8').split(/\r?\n/)) {
if (!line || /^\s*#/.test(line)) continue
const i = line.indexOf('=')
if (i < 0) continue
const k = line.slice(0, i).trim()
const v = line.slice(i + 1).trim().replace(/^["']|["']$/g, '')
if (k && !(k in process.env)) process.env[k] = v
}
}
const LAB = path.join(process.cwd(), 'src', 'server', 'thought_lab', 'labs', 'travel_citytour')
const SKILL_ID = process.argv[2] || 'sh_2d_car_hike'
const DEST = process.argv[3] || '南通如东'
const MODEL_OVERRIDE = process.argv[4] || ''
const MAXTOK_OVERRIDE = Number(process.argv[5]) || 0
// 技能全栈自包含:引擎三件套与技能包都在 skills/<skill_id>/ 内
const SKILL_DIR = path.join(LAB, 'skills', SKILL_ID)
const skillIndex = require(path.join(SKILL_DIR, 'index.js'))
const { validatePayload } = require(path.join(SKILL_DIR, 'validate.js'))
const { repairPayload } = require(path.join(SKILL_DIR, 'repair.js'))
const ai = require(path.join(SKILL_DIR, 'ai.js'))
// 红线能力(callLLM / 北京时间 / JSON 提取)由 lab 层提供,测试直接复用同一实现,避免漂移
const { makeCtx } = require(path.join(LAB, 'index.js'))
const ctx = makeCtx(SKILL_ID)
const main = async () => {
const cfg = JSON.parse(fs.readFileSync(path.join(SKILL_DIR, 'config.json'), 'utf8'))
const aiConfig = Object.assign({}, cfg.ai || {})
if (MODEL_OVERRIDE) aiConfig.model = MODEL_OVERRIDE
if (MAXTOK_OVERRIDE) aiConfig.max_tokens = MAXTOK_OVERRIDE
const bundle = skillIndex.loadBundle()
const dest = skillIndex.findDestination(bundle, DEST)
const answers = {
destination: DEST,
departure_origin: bundle.skill.base_origin || '',
departure_date: '2026-09-19',
departure_time: '06:40',
vehicle_type: 'fuel',
headcount: 1
}
console.log('===== 生成链路集成测试 =====')
console.log('skill :', bundle.skill.id, 'v' + bundle.skill.version, '(' + (bundle.skill.anchors || []).length + ' 槽位)')
console.log('skill_dir :', SKILL_DIR)
console.log('destination:', DEST, dest ? '(技能包内有覆盖数据)' : '(首次生成,无覆盖数据)')
console.log('model :', aiConfig.model, ' temp=', aiConfig.temperature, ' max_tokens=', aiConfig.max_tokens)
console.log('ai_ready :', ctx.aiReady() ? '已配置(密钥由 lab 层持有)' : '缺失!')
console.log('system 长度:', ai.buildMessages(bundle, answers, dest)[0].content.length, '字符')
console.log('开始生成…\n')
const t0 = Date.now()
let result = null
try {
result = await ai.generate({ bundle, answers, dest, aiConfig, ctx })
} catch (e) {
console.log('❌ 生成失败:' + String(e.message || e))
if (e.raw) console.log('原始输出前 1200 字:\n' + String(e.raw).slice(0, 1200))
process.exit(1)
}
// 与服务端一致:先无损修复,再校验
const repaired = repairPayload(result.payload, bundle)
const check = validatePayload(repaired.payload, bundle)
const outDir = path.join(process.env.TEMP || 'C:\\Windows\\Temp', 'travel_citytour_out')
fs.mkdirSync(outDir, { recursive: true })
const outFile = path.join(outDir, 'payload_' + SKILL_ID + '.json')
fs.writeFileSync(outFile, JSON.stringify(repaired.payload, null, 2), 'utf8')
console.log('✅ 生成成功')
console.log('耗时 :', result.elapsed_ms + ' ms(含 ' + result.attempts + ' 次尝试)')
console.log('总墙钟耗时 :', (Date.now() - t0) + ' ms')
console.log('finish_reason:', result.finish_reason || '(空)')
console.log('usage :', JSON.stringify(result.usage || {}))
console.log('\n结构规模 :', JSON.stringify(check.stats))
console.log('无损修复 :', repaired.fixes.length, repaired.fixes.length ? '\n - ' + repaired.fixes.slice(0, 12).join('\n - ') : '')
console.log('errors :', check.errors.length, check.errors.length ? '\n - ' + check.errors.slice(0, 12).join('\n - ') : '')
console.log('warnings :', check.warnings.length, check.warnings.length ? '\n - ' + check.warnings.slice(0, 12).join('\n - ') : '')
console.log('\npayload 落盘:', outFile, '(' + (fs.statSync(outFile).size / 1024).toFixed(1) + ' KB)')
console.log('summary 标题:', (repaired.payload.summary || {}).title || '(无)')
console.log('\n' + (check.errors.length === 0 ? '✓ 结构校验通过,可直接渲染' : '✗ 结构校验未通过,需按上面 errors 调整规则或 prompt'))
process.exit(check.errors.length === 0 ? 0 : 2)
}
main().catch((e) => { console.error('FATAL', e && e.message); process.exit(1) })
@@ -0,0 +1,384 @@
/* ============================================================
* 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 层」,方便后人理解')
@@ -0,0 +1,202 @@
/* ============================================================
* tool_gen_travel_citytour_photos.js —— 用百炼生成「游山玩水」样例的 AI 示意图
*
* 背景:Trae 自带的 text_to_image 接口(coresg)对任意 prompt 只返回同一张固定图,
* 无法逐节点出图;改用百炼(DashScope)文生图。
* 实测可用两条通道:
* A) multimodal-generation + qwen-image 系列 —— 直接返回图片 URL,无需轮询
* B) text2image/image-synthesis + wanx/wan2.x —— 异步任务,需轮询
* 脚本按优先级依次尝试,谁通用谁,避免单点故障。
*
* 产物:public/tools/thought_lab/labs/travel_citytour/assets/photos/*.jpg
* 运行:node dev_test_scripts/tools/tool_gen_travel_citytour_photos.js
* 说明:密钥来自本机私有凭据文件,不写入仓库;本脚本只读密钥、只写图片。
* ============================================================ */
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const envPath = path.join(process.env.USERPROFILE || 'C:\\Users\\Administrator', 'Toolbox_local_creds.env.local')
if (fs.existsSync(envPath)) {
for (const line of fs.readFileSync(envPath, 'utf8').split(/\r?\n/)) {
if (!line || /^\s*#/.test(line)) continue
const i = line.indexOf('=')
if (i < 0) continue
const k = line.slice(0, i).trim()
const v = line.slice(i + 1).trim().replace(/^["']|["']$/g, '')
if (k && !(k in process.env)) process.env[k] = v
}
}
const KEY = process.env.DASHSCOPE_API_KEY || ''
const BASE = 'https://dashscope.aliyuncs.com'
const OUT_DIR = path.join(
process.cwd(), 'public', 'tools', 'thought_lab', 'labs', 'travel_citytour', 'assets', 'photos'
)
fs.mkdirSync(OUT_DIR, { recursive: true })
// 优先级:先试直接返回的 qwen-image 系列,再退到异步的 wanx 系列
const MODEL_CHAIN = [
{ id: 'qwen-image-3.0-pro', kind: 'multimodal' },
{ id: 'qwen-image-3.0', kind: 'multimodal' },
{ id: 'qwen-image-max', kind: 'multimodal' },
{ id: 'qwen-image-plus', kind: 'multimodal' },
{ id: 'wan2.2-t2i-plus', kind: 'synthesis', size: '1280*720' },
{ id: 'wanx2.1-t2i-plus', kind: 'synthesis', size: '1280*720' }
]
// 尺寸候选:页面卡片里只显示 150px 高,用不到 1664×928,
// 按「够用且省体积」的顺序试;qwen-image 对尺寸有白名单,故逐个降级尝试。
const MULTIMODAL_SIZES = [
process.env.TC_IMG_SIZE || '',
'1024*576',
'1280*720',
'1328*1328'
].filter(Boolean)
const STYLE = 'realistic travel photograph, natural daylight, no text, no watermark, no logo'
const JOBS = [
{ file: 'rudong_cover.jpg', prompt: 'coastal highway along the Yellow Sea tidal flat in Jiangsu China, wind turbines in the distance, autumn afternoon, wide landscape' },
{ file: 'rudong_hike1_embankment.jpg', prompt: 'long concrete sea embankment levee path above a mudflat, fishing nets and small boats below, overcast coastal sky, empty path' },
{ file: 'rudong_bengcha.jpg', prompt: 'old Chinese town street with grey brick houses, stone slab lane, red lanterns, quiet morning light, low-rise traditional buildings' },
{ file: 'rudong_lunch_local.jpg', prompt: 'small local seafood restaurant table in a Jiangsu coastal town, clam soup, steamed fish, plain ceramic bowls, simple interior' },
{ file: 'rudong_bathhouse.jpg', prompt: 'outdoor hot spring pool in a resort garden at dusk, steam rising from the water, wooden deck, greenery around' },
{ file: 'rudong_carsleep_park.jpg', prompt: 'quiet parking lot beside a city park in a small Chinese town at night, warm street lamps, one car parked alone, trees' },
{ file: 'rudong_hike2_forest.jpg', prompt: 'coastal shelterbelt forest dirt trail, tall trees on both sides, dappled sunlight through leaves, green tunnel path' }
]
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
async function genMultimodal(model, prompt, size) {
const r = await fetch(BASE + '/api/v1/services/aigc/multimodal-generation/generation', {
method: 'POST',
headers: { Authorization: 'Bearer ' + KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
input: { messages: [{ role: 'user', content: [{ text: prompt + ', ' + STYLE }] }] },
parameters: { size, n: 1, prompt_extend: true }
}),
signal: AbortSignal.timeout(120000)
})
const t = await r.text()
let j = null
try { j = JSON.parse(t) } catch {}
if (r.status !== 200) throw new Error('HTTP ' + r.status + ' ' + (j && j.message ? j.message : t.slice(0, 140)))
const content = j && j.output && j.output.choices && j.output.choices[0] &&
j.output.choices[0].message && j.output.choices[0].message.content
const url = Array.isArray(content) ? (content.find((c) => c.image) || {}).image : null
if (!url) throw new Error('未返回图片:' + t.slice(0, 200))
return url
}
async function genSynthesis(model, prompt, size) {
const r = await fetch(BASE + '/api/v1/services/aigc/text2image/image-synthesis', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + KEY,
'Content-Type': 'application/json',
'X-DashScope-Async': 'enable'
},
body: JSON.stringify({
model,
input: { prompt: prompt + ', ' + STYLE },
parameters: { size, n: 1 }
}),
signal: AbortSignal.timeout(60000)
})
const t = await r.text()
let j = null
try { j = JSON.parse(t) } catch {}
if (r.status !== 200 || !j || !j.output || !j.output.task_id) {
throw new Error('HTTP ' + r.status + ' ' + (j && j.message ? j.message : t.slice(0, 140)))
}
const taskId = j.output.task_id
for (let i = 0; i < 40; i++) {
await sleep(3000)
const pr = await fetch(BASE + '/api/v1/tasks/' + taskId, { headers: { Authorization: 'Bearer ' + KEY } })
const pj = JSON.parse(await pr.text())
const st = pj && pj.output && pj.output.task_status
if (st === 'SUCCEEDED') {
const url = ((pj.output.results || [])[0] || {}).url
if (!url) throw new Error('任务成功但无图片 URL')
return url
}
if (st === 'FAILED') throw new Error('任务失败:' + (pj.output.message || pj.message || ''))
}
throw new Error('任务超时')
}
async function download(url, file) {
const r = await fetch(url, { signal: AbortSignal.timeout(120000) })
if (!r.ok) throw new Error('下载失败 HTTP ' + r.status)
const buf = Buffer.from(await r.arrayBuffer())
if (buf.length < 10000) throw new Error('文件过小,疑似异常:' + buf.length)
const full = path.join(OUT_DIR, file)
fs.writeFileSync(full, buf)
return { size: buf.length, md5: crypto.createHash('md5').update(buf).digest('hex') }
}
async function main() {
if (!KEY) { console.log('缺少 DASHSCOPE_API_KEY'); process.exit(1) }
// 先做一次连通性预检,确定这次用哪条通道、哪个尺寸
let chosen = null
for (const m of MODEL_CHAIN) {
if (chosen) break
if (m.kind === 'multimodal') {
for (const size of MULTIMODAL_SIZES) {
process.stdout.write('[preflight] ' + m.id + ' size=' + size + ' … ')
try {
await genMultimodal(m.id, 'a simple red apple on a wooden table', size)
console.log('可用(直接返回)')
chosen = { id: m.id, kind: 'multimodal', size }
break
} catch (e) {
console.log('不可用:' + e.message)
}
}
} else {
process.stdout.write('[preflight] ' + m.id + ' size=' + m.size + ' … ')
try {
// 异步模型预检只验证能否建任务,避免多花钱
const r = await fetch(BASE + '/api/v1/services/aigc/text2image/image-synthesis', {
method: 'POST',
headers: { Authorization: 'Bearer ' + KEY, 'Content-Type': 'application/json', 'X-DashScope-Async': 'enable' },
body: JSON.stringify({ model: m.id, input: { prompt: 'test' }, parameters: { size: m.size, n: 1 } })
})
const j = JSON.parse(await r.text())
if (!(j && j.output && j.output.task_id)) throw new Error(j && j.message ? j.message : 'HTTP ' + r.status)
console.log('可用(异步任务)')
chosen = m
} catch (e) {
console.log('不可用:' + e.message)
}
}
}
if (!chosen) { console.log('没有可用模型/尺寸组合'); process.exit(1) }
console.log('[chosen] ' + chosen.id + ' size=' + chosen.size + ' → 输出目录 ' + OUT_DIR)
const results = []
for (const job of JOBS) {
process.stdout.write('[gen] ' + job.file + ' … ')
try {
const url = chosen.kind === 'multimodal'
? await genMultimodal(chosen.id, job.prompt, chosen.size)
: await genSynthesis(chosen.id, job.prompt, chosen.size)
const d = await download(url, job.file)
results.push({ file: job.file, ...d })
console.log('OK ' + d.size + ' bytes ' + d.md5.slice(0, 12))
} catch (e) {
console.log('FAIL ' + e.message)
}
}
console.log('\n===== 汇总 =====')
results.forEach((r) => console.log(r.file + ' ' + r.size + ' ' + r.md5))
const uniq = new Set(results.map((r) => r.md5))
console.log('生成 ' + results.length + '/' + JOBS.length + ' 张,去重后 ' + uniq.size + ' 张')
console.log(uniq.size === results.length ? '✓ 每张都不同,可逐节点使用' : '❌ 存在重复图,不可用')
}
main().catch((e) => { console.error('FATAL', e && e.message); process.exit(1) })
@@ -0,0 +1,179 @@
/* ============================================================
* test_travel_citytour_export.js —— 游山玩水 · 单文件 HTML 导出器单元测试
*
* 测什么:tc_export.js 的 build() 是纯字符串拼装(不碰 DOM),
* 所以可以在 Node 里直接跑,把导出的 HTML 当成字符串做结构断言。
*
* 覆盖:
* 1. report_spec 为空时必须抛错(否则又会出现「静默留白」)
* 2. 12 个区块、hero、目录、卡片数、表格 pick_id、清单、地图 SVG、页脚 都要在
* 3. 不得出现 undefined / [object Object] / <script(导出文档是静态的)
* 4. 标签配平(section / table / ol 等)
* 5. 有配图的样例(如东)不能崩,未内嵌时降级为占位块
* 6. tc_export.css 必须覆盖导出文档用到的关键类名
*
* 运行: node dev_test_scripts/unit/test_travel_citytour_export.js
* ============================================================ */
const fs = require('fs')
const path = require('path')
const ROOT = path.join(__dirname, '..', '..')
// 技能全栈隔离:导出器与其样式、样例、照片、生成结果都在 skills/<skill_id>/ 内
const SKILL_ID = 'sh_2d_car_hike'
const SKILL_PUBLIC = path.join(ROOT, 'public', 'tools', 'thought_lab', 'labs', 'travel_citytour', 'skills', SKILL_ID)
const SKILL_SERVER = path.join(ROOT, 'src', 'server', 'thought_lab', 'labs', 'travel_citytour', 'skills', SKILL_ID)
const readJson = (p) => JSON.parse(fs.readFileSync(p, 'utf-8'))
const exporter = require(path.join(SKILL_PUBLIC, 'tc_export.js'))
let pass = 0
const fails = []
function ok(name, cond, extra) {
if (cond) { pass++; console.log(' ✔ ' + name) }
else { fails.push(name + (extra ? ' → ' + extra : '')); console.log(' ✘ ' + name + (extra ? ' → ' + extra : '')) }
}
function count(hay, needle) {
let n = 0, i = 0
while (true) {
const j = hay.indexOf(needle, i)
if (j < 0) break
n++; i = j + needle.length
}
return n
}
const CSS = fs.readFileSync(path.join(SKILL_PUBLIC, 'tc_export.css'), 'utf-8')
/* ---------- 1. 空 spec 必须抛错 ---------- */
console.log('\n[1] 空 report_spec')
let threw = false
try { exporter.build({ summary: { title: 'x' } }, {}, { css: CSS }) } catch (e) { threw = true }
ok('sections 缺失时主动抛错(不静默留白)', threw)
/* ---------- 2. 真实结果:结构完整性 ---------- */
console.log('\n[2] 真实生成结果(last_result.json)')
const env = readJson(path.join(SKILL_SERVER, 'data', 'last_result.json'))
const spec = readJson(path.join(SKILL_SERVER, 'report_spec.json'))
const payload = env.payload
const html = exporter.build(payload, spec, {
css: CSS,
meta: env.meta,
exportedAt: '2026-09-29 15:30:00'
})
ok('产出完整 HTML 文档', html.startsWith('<!DOCTYPE html>') && html.trim().endsWith('</html>'))
ok('lang=zh-CN', html.indexOf('<html lang="zh-CN">') > 0)
ok('标题取自 payload.summary.title', html.indexOf('<title>' + payload.summary.title + ' · 行程报告</title>') > 0)
ok('样式已内联(<style> 含 .hero)', html.indexOf('<style>') > 0 && html.indexOf('.hero{') > 0)
const specSections = spec.sections
ok('区块数 = report_spec.sections 数(' + specSections.length + ')', count(html, '<section class="section"') === specSections.length - 1,
'实际 ' + count(html, '<section class="section"'))
let allIds = true
const missIds = []
specSections.forEach((s) => {
const id = s.id
if (html.indexOf('id="' + id + '"') < 0) { allIds = false; missIds.push(id) }
})
ok('每个区块 id 都落地', allIds, missIds.join(','))
ok('overview 渲染成 hero 头图', html.indexOf('<header class="hero" id="sec_overview">') > 0)
ok('hero 里有标题/一句话/标签/统计', html.indexOf('<h1>' + payload.summary.title + '</h1>') > 0 &&
html.indexOf('class="lede"') > 0 && html.indexOf('class="hero-tags"') > 0 && html.indexOf('class="hero-stats"') > 0)
ok('hero meta 徽标(模型/耗时/校验)', html.indexOf('模型 ' + env.meta.model) > 0 && html.indexOf('耗时 ') > 0 &&
html.indexOf('结构校验通过') > 0)
ok('目录锚点数 = 有标题的区块数', count(html, 'class="index"') === 1 &&
count(html, '<div class="index"') === 0 && count(html, '<a href="#sec_') === specSections.filter((s) => s.title).length,
'实际 ' + count(html, '<a href="#sec_') + ' / 期望 ' + specSections.filter((s) => s.title).length)
ok('卡片数 = payload.cards 数(' + payload.cards.length + ')', count(html, '<article class="card"') === payload.cards.length,
'实际 ' + count(html, '<article class="card"'))
const pickIds = specSections.filter((s) => s.pick_id).map((s) => s.pick_id)
const missTables = pickIds.filter((id) => html.indexOf('缺少表格:' + id) >= 0)
ok('4 张按 pick_id 引用的表格全部命中', missTables.length === 0, missTables.join(','))
ok('待核实清单条数 = payload.checklist 数(' + payload.checklist.length + ')',
count(html, '<li class="check-item">') === payload.checklist.length,
'实际 ' + count(html, '<li class="check-item">'))
ok('地图内联 SVG(不依赖 Leaflet)', html.indexOf('<svg class="map-svg"') > 0 && html.indexOf('leaflet') < 0)
ok('地图点位数 = payload.map.markers 数(' + payload.map.markers.length + ')',
count(html, '<circle ') === payload.map.markers.length, '实际 ' + count(html, '<circle '))
ok('地图线条数 > 0(路线已连线)', count(html, '<line ') > 0)
ok('地图带图例与免责说明', html.indexOf('class="legend"') > 0 &&
html.indexOf(payload.map.disclaimer || spec.map_route_disclaimer_default) > 0)
ok('地图点位清单(.points)条数与点位数一致', count(html, '<div class="point">') === payload.map.markers.length)
ok('费用合计行渲染', html.indexOf('<tfoot>') > 0 && html.indexOf('合计') > 0)
ok('页脚含导出时间与免责', html.indexOf('2026-09-29 15:30:00') > 0 && html.indexOf('出行前请自行核实') > 0)
/* ---------- 3. 静态 & 干净 ---------- */
console.log('\n[3] 导出文档是静态的、无脏数据')
ok('不含 <script>', html.indexOf('<script') < 0)
ok('不含 <link rel="stylesheet">(样式必须内联)', html.indexOf('<link') < 0)
ok('不含 undefined', html.indexOf('undefined') < 0)
ok('不含 [object Object]', html.indexOf('[object Object]') < 0)
ok('不含 null 字面量渲染', html.indexOf('>null<') < 0)
const pairs = [
['<section class="section"', '</section>'],
['<article class="card"', '</article>'],
['<table class="tbl">', '</table>'],
['<div class="map-wrap">', '</div>']
]
pairs.forEach(([open, close]) => {
ok('标签配平:' + open.replace(/[<>=]/g, '').slice(0, 24), count(html, open) <= count(html, close))
})
/* ---------- 4. 有配图的样例(如东):不能崩,且降级为占位块 ---------- */
console.log('\n[4] 带配图的离线样例(如东)')
const sample = readJson(path.join(SKILL_PUBLIC, 'sample', 'rudong_2026-09-19.json'))
const sampleSpec = readJson(path.join(SKILL_PUBLIC, 'sample', 'report_spec.json'))
const sampleHtml = exporter.build(sample, sampleSpec, { css: CSS, meta: sample.meta })
ok('样例可正常导出', sampleHtml.startsWith('<!DOCTYPE html>') && sampleHtml.length > 5000)
const sampleImgs = exporter.collectImageUrls(sample)
ok('识别出样例配图(' + sampleImgs.length + ' 张)', sampleImgs.length > 0, '实际 ' + sampleImgs.length)
ok('未内嵌时降级为占位块(不出现断图 <img>)',
sampleHtml.indexOf('配图未内嵌') > 0 && sampleHtml.indexOf('<img src="../assets') < 0)
ok('图片署名/免责说明仍在', sampleHtml.indexOf('AI 示意图,非实景') > 0)
const withEmbedded = exporter.build(sample, sampleSpec, {
css: CSS, images: { '../assets/photos/rudong_cover.jpg': 'data:image/jpeg;base64,AAAA' }
})
ok('内嵌成功后使用 data URI', withEmbedded.indexOf('src="data:image/jpeg;base64,AAAA"') > 0)
/* ---------- 5. 文件名 ---------- */
console.log('\n[5] 导出文件名')
ok('文件名 = 标题 + 行程报告.html', exporter.defaultFilename(payload) === payload.summary.title + '-行程报告.html',
exporter.defaultFilename(payload))
ok('非法字符被清掉', exporter.defaultFilename({ summary: { title: 'a/b:c*?"<>|d' } }) === 'a b c d-行程报告.html',
exporter.defaultFilename({ summary: { title: 'a/b:c*?"<>|d' } }))
ok('无标题时兜底', exporter.defaultFilename({}) === '行程报告-行程报告.html', exporter.defaultFilename({}))
/* ---------- 6. 样式覆盖 ---------- */
console.log('\n[6] tc_export.css 覆盖导出用到的关键类名')
const needClasses = [
'.hero', '.kicker', '.hero-stats', '.index', '.section', '.sec-title', '.note-warn', '.note-info',
'.note-danger', '.tablebox', 'table.tbl', '.row-hard', '.row-hl', '.col-time', '.col-kind', '.kind-act',
'.kind-hard', '.badge-high', '.badge-medium', '.badge-low', '.badge-info', '.day', '.group', '.card-grid',
'.card-title', '.card-sub', '.card-badges', '.field', '.field-label', '.field-value', '.verify', '.bullets',
'.figcap', '.img-missing', '.map-wrap', '.map-svg', '.legend', '.swatch', '.map-note', '.points', '.point',
'.checklist', '.check-item', '.doc-foot'
]
const missCss = needClasses.filter((c) => CSS.indexOf(c) < 0)
ok('关键类名全部有样式', missCss.length === 0, missCss.join(','))
/* ---------- 汇总 ---------- */
console.log('\n============================')
console.log('通过 ' + pass + ' 项,失败 ' + fails.length + ' 项')
if (fails.length) {
console.log('\n失败明细:')
fails.forEach((f) => console.log(' - ' + f))
process.exit(1)
}
console.log('导出的 HTML 体积约 ' + Math.round(html.length / 1024) + ' KB(含内联样式)')
@@ -0,0 +1,426 @@
/* ============================================================
* test_travel_citytour_isolation.js —— 「游山玩水」skill 全栈隔离校验
*
* 为什么需要本脚本:
* 本 lab 的架构约定是「skill 全栈隔离」——每个 skill 在 skills/<skill_id>/ 下自带一整套
* 后端(config/index/ai/validate/repair + 技能包)与一整套前端(页面/渲染器/样式/导出器/
* ⬆图/照片),彼此零耦合。这个约定光靠注释和口头说明必然会被后续开发侵蚀(有人图省事
* 直接 require 兄弟 skill 或引用 lab 层业务文件),所以这里把它变成「失败即挡」的硬校验。
*
* 自动覆盖未来新增的 skill:
* 本脚本不写死任何 skill_id,而是通过 lab 的 registry 枚举当前所有 skill,
* 逐个断言。所以以后每加一个 skill,这套校验自动生效,无需改本文件。
*
* 校验项(对每个 skill):
* A. 后端:skill 目录内所有 .js 不得 require 出本目录(禁止 '../')
* B. 前端:skill 目录内所有 .html/.js/.css 的相对引用,解析后必须落在
* 「本 skill 目录内」或「lab 级 assets/vendor/」;其余一律违规
* (即:禁止引用兄弟 skill、禁止引用 lab 层业务文件/配置/照片)
* C. 前端:不得通过绝对路径引用 lab 业务文件(只允许 /api/... 接口前缀)
* D. 契约:config.json / index.js(导出 bindRoutes)/ index.html 三者齐全且可加载
* E. 运行时:后端能 require、bindRoutes 能注册出预期路由、prompt 能组装、校验器不抛异常
*
* 运行:node dev_test_scripts/unit/test_travel_citytour_isolation.js
* 退出码:0 = 全部通过;1 = 存在违规
* ============================================================ */
const fs = require('fs')
const path = require('path')
const ROOT = process.cwd()
const LAB_DIR = path.join(ROOT, 'src', 'server', 'thought_lab', 'labs', 'travel_citytour')
const LAB_PUBLIC = path.join(ROOT, 'public', 'tools', 'thought_lab', 'labs', 'travel_citytour')
const BE_SKILLS = path.join(LAB_DIR, 'skills')
const FE_SKILLS = path.join(LAB_PUBLIC, 'skills')
// 唯一允许 skill 向上引用的位置:lab 级第三方库(无业务语义)
const ALLOWED_UP_REF_PREFIX = path.join(LAB_PUBLIC, 'assets', 'vendor')
const bad = []
const info = []
const registry = require(path.join(LAB_DIR, 'index.js')).constructor ? require(path.join(LAB_DIR, 'registry.js')) : require(path.join(LAB_DIR, 'registry.js'))
const { makeCtx } = require(path.join(LAB_DIR, 'index.js'))
const rel = (p) => path.relative(ROOT, p)
/* 递归收集目录下的文件(跳过 data / node_modules / .gitkeep) */
const walkFiles = (dir, exts, out, skipDirs) => {
out = out || []
skipDirs = skipDirs || ['data', 'node_modules', '.git']
if (!fs.existsSync(dir)) return out
fs.readdirSync(dir, { withFileTypes: true }).forEach((e) => {
if (e.isDirectory()) {
if (skipDirs.indexOf(e.name) >= 0) return
walkFiles(path.join(dir, e.name), exts, out, skipDirs)
return
}
if (exts.indexOf(path.extname(e.name)) >= 0) out.push(path.join(dir, e.name))
})
return out
}
/* ---------- A. 后端:禁止 require 出本 skill 目录 ---------- */
const checkBackendIsolation = (skillId) => {
const dir = path.join(BE_SKILLS, skillId)
walkFiles(dir, ['.js']).forEach((file) => {
const txt = fs.readFileSync(file, 'utf-8')
// 去掉注释行,避免把「隔离说明」里提到的 ../ 误判成真实引用
const code = txt.split(/\r?\n/).filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n')
const m = code.match(/require\(\s*['"]\.\.?\/(\.\.\/)*[^'"]+['"]\s*\)/g) || []
m.forEach((hit) => {
if (/require\(\s*['"]\.\//.test(hit)) return // './xxx' 同目录,允许
bad.push(`[后端隔离] ${rel(file)} 出现向上越级 require:${hit}(skill 后端只能 require 本目录文件或 npm 包)`)
})
})
}
/* ---------- B/C. 前端:相对引用与绝对引用必须落在允许范围 ---------- */
const REF_RE = /(?:src|href)\s*=\s*["']([^"']+)["']|fetch\(\s*['"]([^'"]+)['"]/g
// 两条【契约要求】的导航例外(不是业务耦合,必须放行):
// 1. 站点首页 '/' —— 401 引导面板里的「重新从首页进入」按钮,指到工具箱根
// 2. lab 聚合页 <lab>/index.html —— 每个 skill 页面都要求有「← 返回技能列表」
const LAB_AGG_PAGE = path.join(LAB_PUBLIC, 'index.html')
const checkFrontendIsolation = (skillId) => {
const skillRoot = path.join(FE_SKILLS, skillId)
walkFiles(skillRoot, ['.html', '.js', '.css'], null, ['node_modules', '.git']).forEach((file) => {
const txt = fs.readFileSync(file, 'utf-8')
REF_RE.lastIndex = 0
let hit
while ((hit = REF_RE.exec(txt)) !== null) {
const ref = String(hit[1] || hit[2] || '').trim()
if (!ref) continue
if (/^(https?:)?\/\//.test(ref) || ref.indexOf('data:') === 0) continue // 外链/内联,放过
if (ref === '/') continue // 例外 1:站点首页导航
if (ref.charAt(0) === '/') {
// 绝对路径:只允许接口前缀,不允许指向 lab 业务文件
if (ref.indexOf('/api/') !== 0) {
bad.push(`[前端隔离] ${rel(file)} 出现绝对路径引用:${ref}(只允许 /api/... 接口前缀)`)
}
continue
}
if (ref.charAt(0) === '#') continue
// 相对路径:解析后必须落在「本 skill 目录内」或「lab 级 assets/vendor/」
const resolved = path.resolve(path.dirname(file), ref.split('?')[0].split('#')[0])
if (resolved === LAB_AGG_PAGE) continue // 例外 2:返回聚合页
const insideSkill = resolved === skillRoot || resolved.indexOf(skillRoot + path.sep) === 0
const insideVendor = resolved === ALLOWED_UP_REF_PREFIX || resolved.indexOf(ALLOWED_UP_REF_PREFIX + path.sep) === 0
if (!insideSkill && !insideVendor) {
// 同 lab 但落在其他位置(兄弟 skill / lab 业务文件 / lab 配置)→ 违规
if (resolved.indexOf(LAB_PUBLIC + path.sep) === 0 || resolved.indexOf(LAB_DIR + path.sep) === 0) {
bad.push(`[前端隔离] ${rel(file)} 引用越界:${ref} → ${rel(resolved)}`
+ `(只允许本 skill 目录内,或 lab 级 ${rel(ALLOWED_UP_REF_PREFIX)}/)`)
}
// 完全跳出 lab 目录的(指向其他 tool / lab)也算违规
else if (resolved.indexOf(path.join(ROOT, 'public', 'tools') + path.sep) === 0
|| resolved.indexOf(path.join(ROOT, 'src') + path.sep) === 0) {
bad.push(`[前端隔离] ${rel(file)} 引用越界:${ref} → ${rel(resolved)}(禁止跨 tool / lab 引用)`)
}
}
}
})
}
/* ---------- 与兄弟 skill 的交叉引用(代码层面,注释不算) ---------- */
const checkSiblingRefs = (skillId, allIds) => {
const siblings = allIds.filter((x) => x !== skillId)
if (!siblings.length) return
const scan = (dir, exts) => walkFiles(dir, exts).forEach((file) => {
const code = fs.readFileSync(file, 'utf-8')
.split(/\r?\n/).filter((l) => !/^\s*(\/\/|\*|\/\*|<!--)/.test(l)).join('\n')
siblings.forEach((sib) => {
// 只查「像引用」的用法(路径/字符串字面量),不看注释
const re = new RegExp('[\'"`/]' + sib + '[/\'"`]', 'g')
if (re.test(code)) {
bad.push(`[兄弟隔离] ${rel(file)} 代码中引用了兄弟 skill「${sib}」(skill 之间不得互相引用)`)
}
})
})
scan(path.join(BE_SKILLS, skillId), ['.js', '.json'])
scan(path.join(FE_SKILLS, skillId), ['.js', '.html', '.json'])
}
/* ---------- D/E. 契约与运行时 ---------- */
const checkContractAndRuntime = (skillId) => {
const beDir = path.join(BE_SKILLS, skillId)
const feDir = path.join(FE_SKILLS, skillId)
const need = [
[path.join(beDir, 'config.json'), '后端 config.json'],
[path.join(beDir, 'index.js'), '后端 index.js'],
[path.join(feDir, 'index.html'), '前端 index.html']
]
need.forEach(([p, label]) => {
if (!fs.existsSync(p)) bad.push(`[契约] ${skillId} 缺 ${label}`)
})
if (bad.some((b) => b.indexOf('[契约] ' + skillId) === 0)) return
const cfg = JSON.parse(fs.readFileSync(path.join(beDir, 'config.json'), 'utf-8'))
if (String(cfg.id || '') !== skillId) bad.push(`[契约] ${skillId} 的 config.json id 与目录名不一致:${cfg.id}`)
try {
const mod = require(path.join(beDir, 'index.js'))
if (typeof mod.bindRoutes !== 'function') {
bad.push(`[契约] ${skillId} 的 index.js 未导出 bindRoutes`)
return
}
// 后端不得自行实现鉴权(红线:鉴权必须由 lab 层通过 Router 注入)
const code = fs.readFileSync(path.join(beDir, 'index.js'), 'utf-8')
.split(/\r?\n/).filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n')
;['nav_gate', 'parseCookie', 'verifyJwt', 'guidValue', 'DASHSCOPE_API_KEY'].forEach((k) => {
if (code.indexOf(k) >= 0) {
bad.push(`[红线] ${skillId}/index.js 出现「${k}」——鉴权/凭据必须由 lab 层 ctx 提供,skill 不得自行实现`)
}
})
const routes = []
const fakeRouter = { get: (p) => routes.push('GET ' + p), post: (p) => routes.push('POST ' + p), use: () => {} }
mod.bindRoutes(fakeRouter, makeCtx(skillId))
if (!routes.length) bad.push(`[契约] ${skillId} 的 bindRoutes 未注册任何路由`)
info.push(`${skillId}:后端路由 ${routes.join(' / ')}`)
} catch (e) {
bad.push(`[契约] ${skillId} 后端加载失败:${String(e.message || e)}`)
}
// 深度检查(可选):ai.js / validate.js 不是 lab 强制契约,只是两个已有 skill 的
// 约定俗成的拆分方式。新 skill 完全可以不拆(把逻辑都写在 index.js 里)。
// 所以这里只在「文件存在」时验证其可用性;不存在则跳过,避免把约定当强制而误报。
const hasAi = fs.existsSync(path.join(beDir, 'ai.js'))
const hasValidate = fs.existsSync(path.join(beDir, 'validate.js'))
if (!hasAi && !hasValidate) {
info.push(`${skillId}:未拆分 ai.js/validate.js(允许;lab 强制契约只有 config.json + index.js + index.html)`)
return
}
try {
const mod = require(path.join(beDir, 'index.js'))
if (hasAi) {
const aiMod = require(path.join(beDir, 'ai.js'))
if (typeof mod.loadBundle === 'function' && typeof aiMod.buildMessages === 'function') {
const b = mod.loadBundle()
const msgs = aiMod.buildMessages(b, { destination: '测试地' }, null)
if (!msgs || msgs.length !== 2) bad.push(`[运行时] ${skillId} buildMessages 未产出 system+user 两条消息`)
info.push(`${skillId}:prompt ${msgs[0].content.length} 字符`)
}
}
if (hasValidate) {
const { validatePayload } = require(path.join(beDir, 'validate.js'))
if (typeof mod.loadBundle === 'function' && typeof validatePayload === 'function') {
const chk = validatePayload({}, mod.loadBundle())
if (!chk.errors.length) bad.push(`[运行时] ${skillId} 校验器对空 payload 未报错(校验形同虚设)`)
info.push(`${skillId}:空 payload 报 ${chk.errors.length} 个 error`)
}
}
if (fs.existsSync(path.join(beDir, 'repair.js'))) require(path.join(beDir, 'repair.js'))
} catch (e) {
bad.push(`[运行时] ${skillId} 引擎加载失败:${String(e.message || e)}`)
}
}
/* ---------- F. 离线样例(若存在)必须通过本 skill 自己的校验器 ---------- */
const checkSamples = (skillId) => {
const beDir = path.join(BE_SKILLS, skillId)
const sampleDir = path.join(FE_SKILLS, skillId, 'sample')
if (!fs.existsSync(sampleDir)) return
const files = fs.readdirSync(sampleDir).filter((f) => f.endsWith('.json') && f !== 'report_spec.json')
if (!files.length) return
// 样例校验依赖本 skill 的 validate.js(可选拆分);没拆就跳过,不视为错误
if (!fs.existsSync(path.join(beDir, 'validate.js'))) {
info.push(`${skillId}:有样例但未拆分 validate.js,跳过样例校验`)
return
}
try {
const mod = require(path.join(beDir, 'index.js'))
const { validatePayload } = require(path.join(beDir, 'validate.js'))
const b = mod.loadBundle()
files.forEach((f) => {
const payload = JSON.parse(fs.readFileSync(path.join(sampleDir, f), 'utf-8'))
const chk = validatePayload(payload, b)
if (chk.errors.length) {
bad.push(`[样例] ${skillId}/sample/${f} 未通过本 skill 校验器:${chk.errors.slice(0, 4).join(' / ')}`)
} else {
info.push(`${skillId}:样例 ${f} 校验通过(${chk.warnings.length} warnings,` +
`卡片 ${chk.stats.cards} / 点位 ${chk.stats.markers}` +
(chk.stats.addressCovered === undefined ? '' : ` / 地址覆盖 ${chk.stats.addressCovered}`) + ')')
}
})
} catch (e) {
bad.push(`[样例] ${skillId} 样例校验失败:${String(e.message || e)}`)
}
}
/* ---------- G. destinations.json 的 sample_ref 必须相对【本 skill 前端目录】可解析 ----------
这是隔离后修正过的口径:以前 sample_ref 指向 lab 级共享目录,会让 A skill 的样例被 B skill 引用。 */
const checkSampleRefs = (skillId) => {
const beDest = path.join(BE_SKILLS, skillId, 'destinations.json')
if (!fs.existsSync(beDest)) return
const feSkillDir = path.join(FE_SKILLS, skillId)
try {
const dest = JSON.parse(fs.readFileSync(beDest, 'utf-8'))
;(dest.destinations || []).forEach((d) => {
;((d && d.runs) || []).forEach((r) => {
if (!r || !r.sample_ref) return
const resolved = path.join(feSkillDir, r.sample_ref)
if (!fs.existsSync(resolved)) {
bad.push(`[样例] ${skillId} destinations[${d.key}].runs.sample_ref 不存在(应相对本 skill 前端目录):${r.sample_ref}`)
} else if (resolved.indexOf(feSkillDir + path.sep) !== 0) {
bad.push(`[样例] ${skillId} destinations[${d.key}].sample_ref 越界到本 skill 目录之外:${r.sample_ref}`)
}
})
})
} catch (e) {
bad.push(`[样例] ${skillId} destinations.json 解析失败:${String(e.message || e)}`)
}
}
/* ---------- H. 生成结果「有结构问题也必须落盘 + 200 回传」 ----------
*
* 背景(真实事故,本 skill 踩过):过去只要结构校验出现哪怕 1 个 error,服务端就整份丢弃、
* 不落盘也不回传 payload,用户什么都拿不到。而那个 error 很可能只是校验器的误判 ——
* 一次 4 分钟 + 上万 token 的生成就这样白烧了。
*
* 这条检查直接调用 skill 注册出来的 POST /generate(把 ctx.callLLM 换成桩,返回一份必然
* 不合契约的空 payload),断言三件事:
* 1) 结果仍被写入 skill 私有 data/last_result.json
* 2) HTTPstatus 为 200(若为 422,前端会走失败分支,payload 反而渲染不出来)
* 3) envelope.ok === false(如实告知有结构问题,不假装成功)
*
* 自动覆盖所有「拆了 ai.js」的 skill;未拆分的(如脚手架骨架)跳过,不算违规。
* ---------- */
const os = require('os')
const checkPersistOnError = (skillId) => {
const beDir = path.join(BE_SKILLS, skillId)
if (!fs.existsSync(path.join(beDir, 'ai.js'))) {
info.push(`[落盘] ${skillId}:未拆分 ai.js,跳过本项检查`)
return null
}
let mod = null
let bundle = null
let answers = {}
try {
mod = require(path.join(beDir, 'index.js'))
bundle = mod.loadBundle()
// 按本 skill 自己的 qa.json 组装必填答案(不写死任何字段名)
const qa = bundle.qa || {}
if ((qa.destination_input || {}).required !== false) answers.destination = '测试地'
;(qa.questions || []).forEach((q) => {
if (!q || q.required === false || !q.key) return
answers[q.key] = (q.default !== undefined && q.default !== null && q.default !== '')
? q.default
: (q.type === 'number' ? 1 : '测试值')
})
} catch (e) {
bad.push(`[落盘] ${skillId} 准备失败:${String((e && e.message) || e)}`)
return null
}
let tmpDir = ''
try {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tclab-last-'))
} catch (e) {
bad.push(`[落盘] ${skillId} 无法创建临时目录:${String((e && e.message) || e)}`)
return null
}
const ctx = makeCtx(skillId)
ctx.dataDir = tmpDir
// 桩掉模型调用:返回空对象 → 必然触发一堆结构 error
ctx.callLLM = async () => ({ content: JSON.stringify({}), finishReason: 'stop', usage: {} })
const handlers = {}
const fakeRouter = {
get: (p, ...mw) => { handlers['GET ' + p] = mw[mw.length - 1] },
post: (p, ...mw) => { handlers['POST ' + p] = mw[mw.length - 1] },
use: () => {}
}
let gen = null
try {
mod.bindRoutes(fakeRouter, ctx)
gen = handlers['POST /generate']
} catch (e) {
bad.push(`[落盘] ${skillId} 注册路由失败:${String((e && e.message) || e)}`)
return null
}
if (typeof gen !== 'function') {
bad.push(`[落盘] ${skillId} 未注册 POST /generate`)
return null
}
const res = {
statusCode: 200,
body: null,
status(c) { this.statusCode = c; return this },
json(b) { this.body = b; return this },
set() { return this },
on() { return this },
writableEnded: true
}
return Promise.resolve()
.then(() => gen({ body: { answers }, headers: {} }, res))
.then(() => {
const saved = path.join(tmpDir, 'last_result.json')
if (!fs.existsSync(saved)) {
bad.push(`[落盘] ${skillId} 结果有结构问题时未落盘 —— 用户会失去整份生成结果(该行为必须避免)`)
return
}
let env = null
try {
env = JSON.parse(fs.readFileSync(saved, 'utf-8'))
} catch (e) {
bad.push(`[落盘] ${skillId} 落盘文件不是合法 JSON`)
return
}
const nErr = ((env.check || {}).errors || []).length
if (nErr === 0) {
bad.push(`[落盘] ${skillId} 用例失效:预期产生结构 error,实际为 0`)
return
}
if (res.statusCode !== 200) {
bad.push(`[落盘] ${skillId} 有 error 时返回 ${res.statusCode}(应为 200;422 会让前端渲染不出 payload)`)
}
if (!res.body || res.body.ok !== false) {
bad.push(`[落盘] ${skillId} 有 error 时 envelope.ok 应为 false(如实告知结构问题)`)
}
info.push(`[落盘] ${skillId}:${nErr} 处结构问题时仍落盘 + 200 回传 + ok=false(用户可查看/导出)`)
})
.catch((e) => {
bad.push(`[落盘] ${skillId} 「有 error 也落盘」测试异常:${String((e && e.message) || e)}`)
})
.then(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }) } catch {}
})
}
/* ---------- 主流程 ---------- */
const health = registry.health()
if (!health.ok) bad.push('[registry] 自检未通过:' + JSON.stringify(health.problems_detail))
const ids = health.items.map((i) => i.id)
if (!ids.length) bad.push('[registry] 未扫描到任何 skill')
ids.forEach((id) => {
checkBackendIsolation(id)
checkFrontendIsolation(id)
checkContractAndRuntime(id)
checkSamples(id)
checkSampleRefs(id)
})
if (ids.length > 1) ids.forEach((id) => checkSiblingRefs(id, ids))
const report = () => {
console.log('===== 游山玩水 · skill 全栈隔离校验 =====')
info.push(`扫描到 ${ids.length} 个 skill:${ids.join(', ')}`)
info.push('隔离口径:skill 内联引用只允许「本 skill 目录内」或「lab 级 assets/vendor/」;后端只允许 require 本目录或 npm 包')
info.forEach((l) => console.log('· ' + l))
if (bad.length) {
console.log('\n发现 ' + bad.length + ' 个违规:')
bad.forEach((b) => console.log(' ✗ ' + b))
process.exit(1)
}
console.log('\n✓ 隔离校验全部通过(' + ids.length + ' 个 skill)')
}
Promise.all(ids.map((id) => checkPersistOnError(id)).filter(Boolean)).then(report)
@@ -0,0 +1,313 @@
/* ============================================================
* test_travel_citytour_rail_skill.js —— 「高铁游」技能契约自检(本技能专属)
*
* 为什么单独一份:
* 本 lab 是「skill 全栈隔离」架构,每个 skill 有自己的骨架、词表、硬约束。
* 自驾 skill 的契约测试(test_travel_citytour_skill.js)查的是「停车方案、车内过夜」,
* 本技能查的是「不许有车次号、每处可导航、21:00 抵沪、共享单车/网约车」——
* 两者断言完全不同,所以各写一份。要新增 skill,请照本文件再写一份自己的契约测试。
*
* 校验项(本技能专属):
* 1. 技能包自洽:refs 指向的文件都存在;骨架槽位数与卡片数一致
* 2. 【核心】任何地方都不得出现高铁车次号(如 G1234)—— 模型无法查 12306,写出来必然是编的
* 3. 【核心】每个地图点位都有可导航 address;每张卡片建议带「导航」字段
* 4. 【核心】返程硬约束:报告里必须有「21:00 抵沪」的 warn/danger 提示;train_back 是 hard 时间点
* 5. 词表:activity 只能取本技能枚举(含 bike/taxi/metro/train/checkin/rest);
* marker.kind / route.kind 同理
* 6. 5 张必需表格齐全,且车次表里不得出现「车次号」列
* 7. 离线样例必须过本技能校验器,且 0 errors
* 8. 两条出发模式(周六出发 / 周五晚夜车)在 prompt 里的提示文案都要正确
*
* 运行:node dev_test_scripts/unit/test_travel_citytour_rail_skill.js
* ============================================================ */
const fs = require('fs')
const path = require('path')
const ROOT = process.cwd()
const LAB_DIR = path.join(ROOT, 'src', 'server', 'thought_lab', 'labs', 'travel_citytour')
const SKILL_ID = 'sh_2d_rail_hike'
const BE = path.join(LAB_DIR, 'skills', SKILL_ID)
const FE = path.join(ROOT, 'public', 'tools', 'thought_lab', 'labs', 'travel_citytour', 'skills', SKILL_ID)
const readJson = (p) => JSON.parse(fs.readFileSync(p, 'utf-8'))
const bad = []
const info = []
const skillIndex = require(path.join(BE, 'index.js'))
const ai = require(path.join(BE, 'ai.js'))
const { validatePayload, ACTIVITIES, MARKER_KINDS, ROUTE_KINDS } = require(path.join(BE, 'validate.js'))
const { repairPayload } = require(path.join(BE, 'repair.js'))
const bundle = skillIndex.loadBundle()
const skill = bundle.skill
const schema = bundle.schema
const spec = bundle.report_spec
/* ---------- 1. 技能包自洽 ---------- */
Object.keys(skill.refs || {}).forEach((k) => {
const f = path.join(BE, skill.refs[k])
if (!fs.existsSync(f)) bad.push('refs.' + k + ' 指向的文件不存在:' + skill.refs[k])
})
if (skill.id !== SKILL_ID) bad.push('skill.json id 与目录名不一致:' + skill.id)
if (skill.schema_version !== schema.version) bad.push('skill.schema_version 与 output_schema.version 不一致')
info.push(`技能包:${skill.id} v${skill.version}(契约 v${schema.version})· ${skill.anchors.length} 个骨架槽位`)
const anchorIds = skill.anchors.map((a) => a.id)
const hardIds = skill.anchors.filter((a) => a.time_anchor && a.time_anchor.type === 'hard').map((a) => a.id)
// 本技能专属:返程必须是硬时间点(不可漂移)
if (hardIds.indexOf('train_back') < 0) bad.push('骨架里 train_back 必须是 hard 时间点(返程死线不可漂移)')
if (hardIds.indexOf('lodging_night') < 0) bad.push('骨架里 lodging_night 必须是 hard 时间点(夜间入住不可漂移)')
info.push(`硬时间点槽位 ${hardIds.length} 个:${hardIds.join(', ')}`)
/* ---------- 2. 词表一致性(skill.json / output_schema / rules 要口径一致) ---------- */
const schemaActivities = schema.definitions.timeline.properties.days.items.properties.rows.items.properties.activity.enum
const missAct = schemaActivities.filter((a) => ACTIVITIES.indexOf(a) < 0)
if (missAct.length) bad.push('output_schema 的 activity 枚举与 validate.js 词表不一致:' + missAct.join(','))
const extraAct = ACTIVITIES.filter((a) => schemaActivities.indexOf(a) < 0)
if (extraAct.length) bad.push('validate.js 的 activity 词表未写进 output_schema:' + extraAct.join(','))
const newActs = ['stroll', 'spot', 'bike', 'taxi', 'metro', 'train', 'checkin', 'rest']
newActs.forEach((a) => {
if (ACTIVITIES.indexOf(a) < 0) bad.push('本技能必需的活动类型缺失:' + a)
if (String(bundle.rules || '').indexOf(a) < 0) bad.push('rules.md 未说明活动类型:' + a)
})
const schemaMarkerKinds = schema.definitions.marker.properties.kind.enum
const missMk = schemaMarkerKinds.filter((k) => MARKER_KINDS.indexOf(k) < 0)
if (missMk.length) bad.push('output_schema 的 marker.kind 与 validate.js 词表不一致:' + missMk.join(','))
const schemaRouteKinds = schema.definitions.route.properties.kind.enum
const missRk = schemaRouteKinds.filter((k) => ROUTE_KINDS.indexOf(k) < 0)
if (missRk.length) bad.push('output_schema 的 route.kind 与 validate.js 词表不一致:' + missRk.join(','))
// 自驾skill的 parking 不应出现在本技能(本技能无自驾)
if (schemaMarkerKinds.indexOf('parking') >= 0 || MARKER_KINDS.indexOf('parking') >= 0) {
bad.push('本技能不涉及自驾,不应出现 marker.kind=parking')
}
info.push(`词表:activity ${ACTIVITIES.length} 种(含本技能新增 ${newActs.join('/')})· marker.kind ${MARKER_KINDS.length} 种 · route.kind ${ROUTE_KINDS.join('/')}`)
/* ---------- 3. 渲染器/导出器的词表要与校验器一致(防漂移) ---------- */
const renderTxt = fs.readFileSync(path.join(FE, 'tc_render.js'), 'utf-8')
const exportTxt = fs.readFileSync(path.join(FE, 'tc_export.js'), 'utf-8')
newActs.forEach((a) => {
if (renderTxt.indexOf(a + ':') < 0) bad.push('tc_render.js 的 activity 中文名表缺:' + a)
if (exportTxt.indexOf(a + ':') < 0) bad.push('tc_export.js 的 activity 中文名表缺:' + a)
})
;['train_station', 'lodging', 'bike', 'taxi_point', 'night_market'].forEach((k) => {
if (renderTxt.indexOf(k + ':') < 0) bad.push('tc_render.js 的点位配色表缺:' + k)
if (exportTxt.indexOf(k + ':') < 0) bad.push('tc_export.js 的点位配色表缺:' + k)
})
// 车次表不得出现车次号列(本技能核心禁令)
if (String(bundle.rules || '').indexOf('禁止编造高铁车次号') < 0) bad.push('rules.md 缺少「禁止编造高铁车次号」硬规则')
// v1.1.0:两条新限制必须写进 rules.md
if (String(bundle.rules || '').indexOf('本地人爱去') < 0) bad.push('rules.md 缺少「餐馆双筛选:本地人爱去 + 评分好」规则')
if (String(bundle.rules || '').indexOf('19:00-21:00') < 0) bad.push('rules.md 缺少「周六晚 19:00-21:00 夜逛不得留白」规则')
info.push('渲染器/导出器词表与校验器一致;rules.md 已写明车次号禁令 + 餐馆双筛选 + 夜逛留白禁令')
/* ---------- 4. 必需表格 ---------- */
const need = ['train_out_options', 'train_back_options', 'city_transport', 'lodging_compare', 'food_summary']
need.forEach((id) => {
if ((schema.required_table_ids || []).indexOf(id) < 0) bad.push('output_schema.required_table_ids 缺:' + id)
const used = (spec.sections || []).some((s) => s.pick_id === id)
if (!used) bad.push('report_spec 未引用表格:' + id)
})
info.push(`必需表格 ${need.length} 张,report_spec 全部引用`)
/* ---------- 5. prompt 两条模式提示 ---------- */
const base = { destination: '苏州', departure_window: 'sat_morning' }
const msgSat = ai.buildMessages(bundle, base, null)
const sysSat = msgSat[0].content
if (sysSat.indexOf('模式 A') < 0) bad.push('周六出发时 prompt 未提示「模式 A」')
if (sysSat.indexOf('住宿 1 晚') < 0) bad.push('周六出发时 prompt 未说明「目的地住宿 1 晚」')
const sysFri = ai.buildMessages(bundle, { destination: '北京', departure_window: 'fri_night' }, null)[0].content
if (sysFri.indexOf('模式 B') < 0) bad.push('周五晚出发时 prompt 未提示「模式 B」')
if (sysFri.indexOf('车上过一夜') < 0) bad.push('周五晚出发时 prompt 未说明「车上过一夜」')
;['绝对禁止编造高铁车次号', '21:00', '每个地点都必须可导航', '禁停', '共享单车', '网约车',
'本地人爱去', '评分好', '夜逛', '19:00-21:00', '必须安排在 17:30-18:45'].forEach((k) => {
if (sysSat.indexOf(k) < 0) bad.push('system prompt 缺少关键约束:' + k)
})
// v1.1.0:骨架里必须有「周六下午白天景点」与「夜逛」两个槽位
;['spot_d1', 'night_stroll'].forEach((a) => {
if (anchorIds.indexOf(a) < 0) bad.push('骨架缺 v1.1.0 新增槽位:' + a)
})
// 白天景点必须是白天开放(不得出现「夜间开放」)
const spotD1 = skill.anchors.find((a) => a.id === 'spot_d1') || {}
if (JSON.stringify(spotD1).indexOf('白天开放') < 0) bad.push('spot_d1 未强调「白天开放」')
const strollAnchor = skill.anchors.find((a) => a.id === 'night_stroll') || {}
if (JSON.stringify(strollAnchor).indexOf('自由活动') < 0) bad.push('night_stroll 未强调「自由活动」性质')
if (JSON.stringify(skill.anchors).indexOf('19:00-21:00') < 0) bad.push('骨架未写明夜逛时段 19:00-21:00')
info.push(`prompt 组装:周六模式 ${sysSat.length} 字符 / 周五晚模式 ${sysFri.length} 字符,两条模式提示与 v1.1.0 新约束均正确`)
/* ---------- 6. 离线样例:必须过校验器且 0 errors ---------- */
const sampleDir = path.join(FE, 'sample')
const sampleFiles = fs.readdirSync(sampleDir).filter((f) => f.endsWith('.json') && f !== 'report_spec.json')
if (!sampleFiles.length) bad.push('sample/ 下没有任何样例 payload')
sampleFiles.forEach((f) => {
const p = readJson(path.join(sampleDir, f))
const chk = validatePayload(p, bundle)
if (chk.errors.length) bad.push(`样例 ${f} 未通过校验器:${chk.errors.slice(0, 5).join(' / ')}`)
/* 6a.【核心】样例里不得出现车次号 */
const raw = JSON.stringify(p)
const hits = raw.match(/[GDCTZK]\d{3,4}/g)
if (hits) bad.push(`样例 ${f} 出现疑似编造的车次号:${Array.from(new Set(hits)).join(',')}`)
/* 6b.【核心】点位地址全覆盖 */
const markers = (p.map && p.map.markers) || []
const noAddr = markers.filter((m) => !m || !String(m.address || '').trim())
if (noAddr.length) bad.push(`样例 ${f} 有 ${noAddr.length} 个点位缺 address:${noAddr.map((m) => m && m.id).join(',')}`)
const shortAddr = markers.filter((m) => m && String(m.address || '').trim().length < 6)
if (shortAddr.length) bad.push(`样例 ${f} 有 ${shortAddr.length} 个点位地址过短(无法定位)`)
/* 6c. 卡片建议带「导航」字段 */
const cards = p.cards || []
const noNav = cards.filter((c) => !(c.fields || []).some((f) => String(f.label || '').indexOf('导航') >= 0))
if (noNav.length) bad.push(`样例 ${f} 有 ${noNav.length} 张卡片未给「导航」字段:${noNav.map((c) => c.anchor_id).join(',')}`)
/* 6d.【核心】返程死线提示必须存在 */
const notes = p.notes || []
const hasDeadline = notes.some((n) => n && (n.level === 'warn' || n.level === 'danger')
&& String(n.text || '').indexOf('21:00') >= 0)
if (!hasDeadline) bad.push(`样例 ${f} 缺少「21:00 抵沪」的 warn/danger 提示`)
/* 6e. 骨架槽位全覆盖 */
const cardAnchors = cards.map((c) => c.anchor_id)
const missing = anchorIds.filter((a) => cardAnchors.indexOf(a) < 0)
if (missing.length) bad.push(`样例 ${f} 卡片缺槽位:${missing.join(',')}`)
/* 6f. 车次表里不得出现车次号列 */
;(p.tables || []).forEach((t) => {
if (['train_out_options', 'train_back_options'].indexOf(t.id) < 0) return
;(t.columns || []).forEach((c) => {
const lab = String(c.label || c.key || '')
if (lab.indexOf('车次') >= 0 && lab.indexOf('时段') < 0) {
bad.push(`样例 ${f} 的车次表出现车次号列:${t.id}.${c.key}`)
}
})
})
/* 6g.【核心 v1.1.0】周六晚必须有夜逛,且落在 19:00 之后 */
const d1Rows = ((p.timeline && p.timeline.days) || [])
.filter((d) => d && String(d.label || '').indexOf('第一天') >= 0)
.reduce((acc, d) => acc.concat(d.rows || []), [])
const strollRows = d1Rows.filter((r) => r && r.anchor_id === 'night_stroll')
if (!strollRows.length) bad.push(`样例 ${f} 第一天缺夜逛行(night_stroll)`)
else {
const late = strollRows.some((r) => {
const m = String(r.time || '').match(/(\d{1,2}):(\d{2})/)
return m && (Number(m[1]) * 60 + Number(m[2])) >= 18 * 60 + 30
})
if (!late) bad.push(`样例 ${f} 夜逛时间早于 18:30:${strollRows.map((r) => r.time).join(' / ')}`)
if (!strollRows.some((r) => r.activity === 'stroll')) {
bad.push(`样例 ${f} 夜逛行的 activity 应为 stroll:${strollRows.map((r) => String(r.activity || '未填')).join(' / ')}`)
}
}
// 晚饭须在 19:00 前
const dinnerRows = d1Rows.filter((r) => r && r.anchor_id === 'dinner_d1')
if (dinnerRows.length && !dinnerRows.some((r) => {
const m = String(r.time || '').match(/(\d{1,2}):(\d{2})/)
return m && (Number(m[1]) * 60 + Number(m[2])) <= 19 * 60
})) {
bad.push(`样例 ${f} 晚饭时间晚于 19:00,会挤压夜逛:${dinnerRows.map((r) => r.time).join(' / ')}`)
}
/* 6h.【核心 v1.1.0】午饭与晚饭卡片必须给出「评分」与「本地」依据 */
;['lunch_d1', 'dinner_d1'].forEach((aid) => {
const card = cards.find((c) => c && c.anchor_id === aid)
if (!card) return
const txt = JSON.stringify(card)
if (txt.indexOf('评分') < 0) bad.push(`样例 ${f} 的 ${aid} 卡片未给出餐馆评分`)
if (txt.indexOf('本地') < 0) bad.push(`样例 ${f} 的 ${aid} 卡片未给出「本地人爱去」依据`)
})
info.push(`样例 ${f}:0 errors / ${chk.warnings.length} warnings,卡片 ${cards.length},` +
`点位 ${markers.length}(地址覆盖 ${chk.stats.addressCovered}),无车次号,含夜逛与餐馆评分`)
})
/* ---------- 6i.【回归】校验器不得依赖 day.label 的文案 ----------
背景(真实事故):v1.1.0 的校验器写成「找 label 含『第一天』的那一天」来判断第一天,
而 AI 把两天标成了「周六 / 周日」,find 返回 undefined,于是明明排了夜逛却被误判为
「缺夜逛」→ 整份 4 分钟生成被丢弃(payload_invalid)。这里把「改 label 不改变判定结果」
固化成回归断言,防止再犯。 */
try {
const base = JSON.parse(fs.readFileSync(path.join(sampleDir, sampleFiles[0]), 'utf-8'))
const variants = [
['周六/周日', ['周六', '周日']],
['第一天/第二天', ['第一天', '第二天']],
['Day1/Day2', ['Day1', 'Day2']],
['空 label', ['', '']]
]
variants.forEach(([name, labels]) => {
const p = JSON.parse(JSON.stringify(base))
;((p.timeline || {}).days || []).forEach((d, i) => { d.label = labels[i] !== undefined ? labels[i] : d.label })
const chk = validatePayload(p, bundle)
if (chk.errors.length) {
bad.push(`[回归] day.label 改成「${name}」后校验出现 error(说明校验器又依赖了 label 文案):${chk.errors.join(' / ')}`)
}
})
info.push('回归:day.label 换成 周六/周日、第一天/第二天、Day1/Day2、空值 四种写法,校验结果均一致(不依赖 label 文案)')
} catch (e) {
bad.push('[回归] day.label 无关性测试失败:' + String(e.message || e))
}
/* ---------- 6j.【回归】清单 confidence 越界(high)能被修复器降级 ---------- */
try {
const dirty = JSON.parse(JSON.stringify(readJson(path.join(sampleDir, sampleFiles[0]))))
dirty.checklist[0].confidence = 'high'
const before = validatePayload(dirty, bundle).warnings.filter((w) => w.indexOf('confidence 应为 medium/low') >= 0)
if (!before.length) bad.push('[回归] 清单项标 high 时校验器未报警')
const fixed = repairPayload(dirty, bundle)
if (fixed.payload.checklist[0].confidence !== 'medium') {
bad.push('[回归] 修复器未把清单项的 high 降级为 medium(实际:' + fixed.payload.checklist[0].confidence + ')')
} else {
const after = validatePayload(fixed.payload, bundle).warnings.filter((w) => w.indexOf('confidence 应为 medium/low') >= 0)
if (after.length) bad.push('[回归] 修复后清单仍报 confidence 越界')
info.push('回归:清单项 confidence=high 会被校验器报警、被修复器降级为 medium(' + fixed.fixes.length + ' 条修复)')
}
} catch (e) {
bad.push('[回归] 清单 confidence 修复测试失败:' + String(e.message || e))
}
/* ---------- 7. 修复器:本技能专属的「返程死线」补齐 ---------- */
try {
const bare = { notes: [] }
const r = repairPayload(bare, bundle)
const added = (r.payload.notes || []).some((n) => n && n.level === 'danger' && String(n.text).indexOf('21:00') >= 0)
if (!added) bad.push('repair.js 未能在缺失时补齐「21:00 抵沪」的 danger 提示')
else info.push('修复器:缺失时能自动补齐返程死线提示(' + r.fixes.length + ' 条修复)')
} catch (e) {
bad.push('修复器执行异常:' + String(e.message || e))
}
/* ---------- 8. 导出器:用样例跑一遍,产物必须干净且含导航信息 ---------- */
try {
const exporter = require(path.join(FE, 'tc_export.js'))
const sample = readJson(path.join(sampleDir, sampleFiles[0]))
const sampleSpec = readJson(path.join(sampleDir, 'report_spec.json'))
const css = fs.readFileSync(path.join(FE, 'tc_export.css'), 'utf-8')
const html = exporter.build(sample, sampleSpec, { css, meta: sample.meta, exportedAt: '2026-09-30 11:00:00' })
const cnt = (s, n) => s.split(n).length - 1
const checks = [
['产出完整文档', html.startsWith('<!DOCTYPE html>') && html.trim().endsWith('</html>')],
['无 <script>(导出件是静态的)', html.indexOf('<script') < 0],
['无 undefined / [object Object]', html.indexOf('undefined') < 0 && html.indexOf('[object Object]') < 0],
['无编造车次号', !/[GDCTZK]\d{3,4}/.test(html)],
['含高德搜索链接', html.indexOf('uri.amap.com') > 0],
['含百度搜索链接', html.indexOf('map.baidu.com') > 0],
['点位地址全部摊开', cnt(html, 'class="point-addr"') === sample.map.markers.length],
['返程死线提示在', html.indexOf('21:00 前必须抵达上海') > 0],
['样式已内联', html.indexOf('<style>') > 0 && html.indexOf('.hero{') > 0],
['区块数 = report_spec.sections 数 - 1', cnt(html, '<section class="section"') === sampleSpec.sections.length - 1]
]
const failed = checks.filter(([, ok]) => !ok).map(([n]) => n)
if (failed.length) bad.push('导出器验证未通过:' + failed.join(' / '))
else info.push(`导出器:产物 ${Math.round(html.length / 1024)} KB,${checks.length} 项断言全通过(含导航链接与无车次号)`)
} catch (e) {
bad.push('导出器验证异常:' + String(e.message || e))
}
/* ---------- 汇总 ---------- */
console.log('===== 高铁游 · 技能契约自检 =====')
info.forEach((l) => console.log('· ' + l))
if (bad.length) {
console.log('\n发现 ' + bad.length + ' 个问题:')
bad.forEach((b) => console.log(' ✗ ' + b))
process.exit(1)
}
console.log('\n✓ ALL CHECKS PASS')
@@ -0,0 +1,347 @@
/* ============================================================
* test_travel_citytour_skill.js —— 游山玩水 lab · 技能包与样例一致性校验
*
* 用途:检查技能包(skill.json / output_schema.json / report_spec.json /
* destinations.json)与离线样例 payload 是否自洽,防止 skill 升级后
* 样例、契约、卡片槽位三者互相脱节。
*
* 运行:node dev_test_scripts/unit/test_travel_citytour_skill.js
* 退出码:0 = 全部通过;1 = 存在不一致
*
* 说明:本脚本只读取文件,不修改任何数据;位置遵循项目规则
* 「测试脚本统一放 dev_test_scripts/」。
* ============================================================ */
const fs = require('fs')
const path = require('path')
const ROOT = process.cwd()
const LAB_DIR = path.join(ROOT, 'src', 'server', 'thought_lab', 'labs', 'travel_citytour')
// 技能全栈隔离:技能包、引擎三件套、样例、渲染器都归 skills/<skill_id>/ 私有
const SKILL_ID = 'sh_2d_car_hike'
const SKILL_DIR = path.join(LAB_DIR, 'skills', SKILL_ID)
const LAB_PUBLIC = path.join(ROOT, 'public', 'tools', 'thought_lab', 'labs', 'travel_citytour')
const SKILL_PUBLIC = path.join(LAB_PUBLIC, 'skills', SKILL_ID)
const SAMPLE_DIR = path.join(SKILL_PUBLIC, 'sample')
const bad = []
const info = []
const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf-8'))
let skill, schema, spec, dest, payload, sampleSpec
try {
skill = readJson(path.join(SKILL_DIR, 'skill.json'))
schema = readJson(path.join(SKILL_DIR, 'output_schema.json'))
spec = readJson(path.join(SKILL_DIR, 'report_spec.json'))
dest = readJson(path.join(SKILL_DIR, 'destinations.json'))
payload = readJson(path.join(SAMPLE_DIR, 'rudong_2026-09-19.json'))
sampleSpec = readJson(path.join(SAMPLE_DIR, 'report_spec.json'))
} catch (e) {
console.log('JSON 解析失败:' + e.message)
process.exit(1)
}
/* 1. 技能包引用文件齐全 */
Object.keys(skill.refs || {}).forEach((k) => {
const f = path.join(SKILL_DIR, skill.refs[k])
if (!fs.existsSync(f)) bad.push('refs.' + k + ' 指向的文件不存在:' + skill.refs[k])
})
/* 2. payload 根字段齐全 */
;(schema.root.required || []).forEach((k) => {
if (!(k in payload)) bad.push('payload 缺根字段:' + k)
})
/* 2.1 版本一致性:样例记录的版本必须等于技能包当前版本(防止改了骨架忘了同步样例) */
if (payload.meta.skill_version !== skill.version) {
bad.push('样例 meta.skill_version(' + payload.meta.skill_version + ')≠ 技能包 version(' + skill.version + ')')
}
if (payload.meta.skill_id !== skill.id) {
bad.push('样例 meta.skill_id(' + payload.meta.skill_id + ')≠ 技能包 id(' + skill.id + ')')
}
if (payload.meta.schema_version !== schema.version) {
bad.push('样例 meta.schema_version(' + payload.meta.schema_version + ')≠ 契约 version(' + schema.version + ')')
}
dest.destinations.forEach((d) => {
;(d.runs || []).forEach((r) => {
if (r.skill_version && r.skill_version !== skill.version) {
bad.push('destinations[' + d.key + '] runs.skill_version 与技能包 version 不一致')
}
})
})
/* 2.2 skill 声明的默认出发点必须体现在样例问答里 */
if (skill.base_origin && (!payload.meta.qa || !payload.meta.qa.departure_origin)) {
bad.push('技能包声明了 base_origin,但样例 meta.qa 缺 departure_origin')
}
/* 3. 卡片必须覆盖全部骨架槽位(一个不少、一个不多) */
const anchorIds = skill.anchors.map((a) => a.id)
const cardAnchorIds = payload.cards.map((c) => c.anchor_id)
anchorIds.forEach((a) => {
if (cardAnchorIds.indexOf(a) < 0) bad.push('卡片缺槽位:' + a)
})
cardAnchorIds.forEach((c) => {
if (anchorIds.indexOf(c) < 0) bad.push('卡片引用了未定义的槽位:' + c)
})
/* 4. 时序表只能引用合法槽位;硬时间点必须落到对应槽位 */
payload.timeline.days.forEach((d) => {
;(d.rows || []).forEach((r) => {
if (anchorIds.indexOf(r.anchor_id) < 0) bad.push('时序引用未知槽位:' + r.anchor_id)
})
})
const hardAnchors = skill.anchors.filter((a) => a.time_anchor && a.time_anchor.type === 'hard').map((a) => a.id)
hardAnchors.forEach((a) => {
const hit = payload.timeline.days.some((d) => (d.rows || []).some((r) => r.anchor_id === a && r.kind === 'hard'))
if (!hit) bad.push('硬时间点槽位未在时序表中以 kind=hard 出现:' + a)
})
/* 5. 契约要求的表格必须存在 */
;(schema.required_table_ids || []).forEach((id) => {
if (!payload.tables.some((t) => t.id === id)) bad.push('缺表格:' + id)
})
/* 6. report_spec 的 pick_id 必须能在 payload.tables 里命中 */
spec.sections.concat(sampleSpec.sections).forEach((s) => {
if (!s.pick_id) return
if (!payload.tables.some((t) => t.id === s.pick_id)) bad.push('report_spec pick_id 未命中:' + s.pick_id)
})
/* 7. 地图:路线引用的点位必须存在,且每条路线至少 2 个点 */
const markerIds = payload.map.markers.map((m) => m.id)
payload.map.routes.forEach((r) => {
;(r.marker_ids || []).forEach((id) => {
if (markerIds.indexOf(id) < 0) bad.push('路线 ' + r.id + ' 引用未知点位:' + id)
})
if ((r.marker_ids || []).length < 2) bad.push('路线点位少于 2 个:' + r.id)
if (!r.note) bad.push('路线缺示意图免责说明:' + r.id)
})
/* 8. destinations 覆盖的槽位必须存在于骨架 */
dest.destinations.forEach((d) => {
Object.keys(d.anchors_override || {}).forEach((k) => {
if (anchorIds.indexOf(k) < 0) bad.push('destinations[' + d.key + '] 覆盖了未知槽位:' + k)
})
;(d.runs || []).forEach((r) => {
// sample_ref 相对【本 skill 自己的前端目录】解析(技能隔离后不再指向 lab 级共享目录)
if (r.sample_ref && !fs.existsSync(path.join(SKILL_PUBLIC, r.sample_ref))) {
bad.push('destinations[' + d.key + '] sample_ref 不存在:' + r.sample_ref)
}
})
})
/* 9. 置信度:非 high 必须带 verify;主观项不得标 high */
const unverified = skill.unverified_fields || []
const subjectiveLabels = ['免费', '本地人', '卫生', '档次', '禁停', '过夜']
let confTotal = 0
let confNonHigh = 0
;(function walk(node, trail) {
if (Array.isArray(node)) return node.forEach((v, i) => walk(v, trail + '[' + i + ']'))
if (node && typeof node === 'object') {
if ('confidence' in node) {
confTotal++
const c = node.confidence
if (['high', 'medium', 'low'].indexOf(c) < 0) bad.push('置信度取值非法:' + c + ' @ ' + trail)
if (c !== 'high') {
confNonHigh++
if (!node.verify) bad.push('非 high 字段缺 verify:' + String(node.text).slice(0, 24) + ' @ ' + trail)
}
return
}
Object.keys(node).forEach((k) => walk(node[k], trail + '.' + k))
}
})(payload, '')
/* 10. 待核实清单必须覆盖每一个含非 high 字段的槽位(同槽位可归并成一条,但不许漏槽位) */
const hasNonHighFact = (node) => {
if (Array.isArray(node)) return node.some(hasNonHighFact)
if (node && typeof node === 'object') {
if ('confidence' in node) return node.confidence !== 'high'
return Object.keys(node).some((k) => hasNonHighFact(node[k]))
}
return false
}
const checklistAnchors = payload.checklist.map((c) => c.anchor_id).filter(Boolean)
payload.cards.forEach((c) => {
if (hasNonHighFact(c) && checklistAnchors.indexOf(c.anchor_id) < 0) {
bad.push('槽位含非 high 字段,但待核实清单未收录:' + c.anchor_id)
}
})
payload.checklist.forEach((c) => {
if (c.anchor_id && anchorIds.indexOf(c.anchor_id) < 0) bad.push('清单引用了未知槽位:' + c.anchor_id)
})
/* 11. 图片字段若存在,必须带免责说明 */
;(function walkImg(node, trail) {
if (Array.isArray(node)) return node.forEach((v, i) => walkImg(v, trail + '[' + i + ']'))
if (node && typeof node === 'object') {
if (node.kind === 'ai_illustration' && !node.disclaimer) bad.push('AI 示意图缺免责说明 @ ' + trail)
Object.keys(node).forEach((k) => walkImg(node[k], trail + '.' + k))
}
})(payload, '')
/* 12. 低耦合自检:技能包与页面不得引用其他 lab / tool 模块 */
const OWN_DIRS = [
path.join(ROOT, 'src', 'server', 'thought_lab', 'labs', 'travel_citytour'),
LAB_PUBLIC
]
const CROSS_REF_PATTERNS = [/require\s*\(\s*['"]\.\.\/\.\.\/\.\./, /from\s+['"]\.\.\/\.\.\/\.\./, /tools\/(?!thought_lab)[a-z_]+/]
const scanDir = (dir) => {
fs.readdirSync(dir, { withFileTypes: true }).forEach((e) => {
const full = path.join(dir, e.name)
if (e.isDirectory()) {
// vendor(第三方库)与 data(运行时产物)不参与跨模块引用扫描
if (e.name === 'vendor' || e.name === 'data') return
return scanDir(full)
}
if (!/\.(js|html|json)$/.test(e.name)) return
const txt = fs.readFileSync(full, 'utf-8')
CROSS_REF_PATTERNS.forEach((re) => {
if (re.test(txt)) bad.push('疑似跨模块引用:' + path.relative(ROOT, full) + ' 命中 ' + re)
})
})
}
OWN_DIRS.forEach(scanDir)
/* 13. lab 后端路由注册自检(skill 全栈隔离后的新形态)
* lab 只做:诊断(公开) + 聚合列表 + 自检 + 逐个 skill 挂载
* skill 的 /meta /last /generate 由 lab 包好鉴权后挂到 skills/<id>/ 前缀下 */
let makeCtx = null
try {
const labIndex = require(path.join(LAB_DIR, 'index.js'))
if (typeof labIndex.bindPublicRoutes !== 'function') {
bad.push('lab index.js 未导出 bindPublicRoutes(thought_lab 自动挂载会跳过本 lab)')
} else if (typeof labIndex.bindRoutes === 'function') {
bad.push('lab 同时导出了 bindRoutes,会与 bindPublicRoutes 重复注册同一批路径')
} else {
const routes = []
const rec = (m) => (p) => routes.push([m, p])
labIndex.bindPublicRoutes({ get: rec('GET'), post: rec('POST'), put: rec('PUT'), delete: rec('DELETE'), use: rec('USE') })
const expect = [
['GET', '/api/thought_lab/travel_citytour/skills'],
['GET', '/api/thought_lab/travel_citytour/skills/_health'],
['GET', '/api/thought_lab/travel_citytour/auth/diag'],
['USE', '/api/thought_lab/travel_citytour/skills/' + SKILL_ID]
]
expect.forEach((pair) => {
if (!routes.some((r) => r[0] === pair[0] && r[1] === pair[1])) {
bad.push('缺少路由:' + pair[0] + ' ' + pair[1])
}
})
// lab 层不得再注册任何业务路由(业务已全部下沉到 skill)
const biz = routes.filter((r) => /\/(meta|last|generate)$/.test(r[1]))
if (biz.length) bad.push('lab 层仍在注册业务路由(应下沉到 skill):' + biz.map((r) => r[1]).join(' / '))
info.push('lab 后端:bindPublicRoutes 无异常,注册 ' + routes.length + ' 条(含公开 auth/diag;业务路由 0 条)')
}
if (typeof labIndex.makeCtx !== 'function') bad.push('lab 未导出 makeCtx(离线链路测试无法复用红线实现)')
else makeCtx = labIndex.makeCtx
// 鉴权口径必须是显式合法值(默认 nav_gate 为最安全口径)
const labCfg = JSON.parse(fs.readFileSync(path.join(LAB_DIR, 'config.json'), 'utf-8'))
const amode = String((labCfg.auth || {}).mode || '')
if (['nav_gate', 'browser'].indexOf(amode) < 0) bad.push('config.json auth.mode 取值非法:' + amode)
else info.push('lab 鉴权口径 auth.mode=' + amode + (amode === 'nav_gate' ? '(只认 nav_gate,不放宽)' : '(nav_gate 或同源 Referer)'))
const rl = labCfg.rate_limit || {}
if (!(Number(rl.generate_per_ip_hour) > 0)) bad.push('rate_limit.generate_per_ip_hour 未配置')
else info.push('lab 限流:全局单飞=' + (rl.generate_single_flight !== false) + ' + 按 skill 单飞,单 IP 每小时 ' + rl.generate_per_ip_hour + ' 次生成,只读 ' + rl.read_per_ip_minute + ' 次/分钟')
} catch (e) {
bad.push('lab 后端加载失败:' + String(e.message || e))
}
/* 13b. skill 侧自检:契约(config.json + index.js + index.html)与路由形状 */
let skillIndex = null
try {
const registry = require(path.join(LAB_DIR, 'registry.js'))
const h = registry.health()
if (!h.ok) bad.push('registry 自检未通过:' + JSON.stringify(h.problems_detail))
else info.push('registry:' + h.total + ' 个 skill 全部可挂载(config/后端入口/前端页面/图标齐全)')
const cfg = registry.readSkillConfig(SKILL_ID)
if (cfg.enabled === false) bad.push('skill config.json enabled=false,聚合页不会显示')
if (String(cfg.id || '') !== SKILL_ID) bad.push('skill config.json 的 id 与目录名不一致:' + cfg.id)
const need = ['name', 'desc']
need.forEach((k) => { if (!String(cfg[k] || '').trim()) bad.push('skill config.json 缺展示字段:' + k) })
skillIndex = require(path.join(SKILL_DIR, 'index.js'))
if (typeof skillIndex.bindRoutes !== 'function') bad.push('skill index.js 未导出 bindRoutes')
else if (makeCtx) {
const sroutes = []
const fakeRouter = {
get: (p) => sroutes.push('GET ' + p),
post: (p) => sroutes.push('POST ' + p),
use: () => {}
}
skillIndex.bindRoutes(fakeRouter, makeCtx(SKILL_ID))
;[['GET', '/meta'], ['GET', '/last'], ['POST', '/generate']].forEach((pair) => {
if (!sroutes.some((r) => r === pair[0] + ' ' + pair[1])) bad.push('skill 缺路由:' + pair[0] + ' ' + pair[1])
})
info.push('skill 后端:bindRoutes 注册 ' + sroutes.length + ' 条(' + sroutes.join(' / ') + ')')
}
} catch (e) {
bad.push('skill 侧加载失败:' + String(e.message || e))
}
/* 14. 生成链路关键口径自检(不联网):prompt 能组装、必填校验按 qa 定义生效 */
try {
const registry = require(path.join(LAB_DIR, 'registry.js'))
const aiMod = require(path.join(SKILL_DIR, 'ai.js'))
const { validatePayload } = require(path.join(SKILL_DIR, 'validate.js'))
const b = skillIndex.loadBundle()
const ctx = makeCtx(SKILL_ID)
const msgs = aiMod.buildMessages(b, { destination: '测试地', departure_date: '2026-09-19' }, null)
if (!msgs || msgs.length !== 2) bad.push('buildMessages 未产出 system+user 两条消息')
else {
if (String(msgs[0].content).indexOf('output_schema') < 0) bad.push('system prompt 未包含输出契约')
if (String(msgs[0].content).indexOf(b.skill.anchors[0].id) < 0) bad.push('system prompt 未包含骨架槽位')
info.push('生成链路:prompt 组装 OK(system ' + msgs[0].content.length + ' 字符 / user ' + msgs[1].content.length + ' 字符)')
}
// 必填校验按 qa.json 定义生效(不写死字段名)
const miss = skillIndex.validateAnswers(b, {})
if (!miss.length) bad.push('validateAnswers 对空答案未报缺失')
else info.push('问答必填校验:空答案下缺失 ' + miss.length + ' 项(' + miss.slice(0, 4).join('、') + '…)')
// 目的地在技能包内的覆盖能被命中
const destName = (b.destinations && b.destinations.destinations && b.destinations.destinations[0] || {}).name
if (destName && !skillIndex.findDestination(b, destName)) bad.push('findDestination 未能命中技能包内目的地:' + destName)
// ctx 红线能力必须齐全(skill 依赖它们,缺一个就会在运行时炸)
;['callLLM', 'extractJson', 'nowBeijing', 'escapeHtml', 'dataDir', 'readConfig', 'genGuard', 'jsonParser', 'log', 'sanitizeObject'].forEach((k) => {
if (ctx[k] === undefined) bad.push('ctx 缺红线能力:' + k)
})
if (ctx.dataDir.indexOf(path.join('skills', SKILL_ID, 'data')) < 0) bad.push('ctx.dataDir 未指向本 skill 私有 data/:' + ctx.dataDir)
// 用离线样例反推校验器:样例应当 0 errors(口径一致性)
const sampleCheck = validatePayload(payload, b)
if (sampleCheck.errors.length) bad.push('离线样例未通过服务端校验器:' + sampleCheck.errors.slice(0, 5).join(' / '))
else info.push('校验口径一致:离线样例在服务端校验器下 0 errors(' + sampleCheck.warnings.length + ' warnings)')
if (sampleCheck.warnings.length) bad.push('离线样例存在校验告警:' + sampleCheck.warnings.slice(0, 3).join(' / '))
// kind(时间点性质)与 activity(活动类型)必须严格分离
const rowList = []
;((payload.timeline && payload.timeline.days) || []).forEach((d) => ((d && d.rows) || []).forEach((r) => rowList.push(r)))
const badKind = rowList.filter((r) => r && ['hard', 'soft'].indexOf(r.kind) < 0)
const badAct = rowList.filter((r) => r && r.activity && ['move', 'eat', 'hike', 'shop', 'sleep', 'walk'].indexOf(r.activity) < 0)
if (badKind.length) bad.push('样例时序行 kind 取值非法(只能 hard/soft):' + badKind.map((r) => r.kind).join(' / '))
if (badAct.length) bad.push('样例时序行 activity 取值非法:' + badAct.map((r) => r.activity).join(' / '))
const hardAnchors = (b.skill.anchors || []).filter((a) => a.time_anchor && a.time_anchor.type === 'hard')
const hardMissing = hardAnchors.filter((a) => !rowList.some((r) => r.anchor_id === a.id && r.kind === 'hard'))
if (hardMissing.length) bad.push('样例中硬时间点槽位未以 kind=hard 出现:' + hardMissing.map((a) => a.id).join(' / '))
info.push('时序行 ' + rowList.length + ' 行:kind 全为 hard/soft,带 activity 的 ' + rowList.filter((r) => r && r.activity).length + ' 行;硬时间点槽位 ' + hardAnchors.length + ' 个全部命中')
} catch (e) {
bad.push('生成链路自检失败:' + String(e.message || e))
}
info.push('卡片 ' + payload.cards.length + ' 张 / 骨架槽位 ' + anchorIds.length + ' 个')
info.push('时序 ' + payload.timeline.days.length + ' 天 / 点位 ' + payload.map.markers.length + ' 个 / 路线 ' + payload.map.routes.length + ' 条 / 表格 ' + payload.tables.length + ' 张')
info.push('置信度字段 ' + confTotal + ' 个,其中非 high ' + confNonHigh + ' 个;待核实清单 ' + payload.checklist.length + ' 条')
info.push('骨架槽位覆盖:' + (anchorIds.length === cardAnchorIds.length ? '完整' : '不完整'))
console.log('===== 游山玩水 · 技能包自检 =====')
info.forEach((l) => console.log('· ' + l))
if (bad.length) {
console.log('\n发现 ' + bad.length + ' 个问题:')
bad.forEach((b) => console.log(' ✗ ' + b))
process.exit(1)
}
console.log('\n✓ ALL CHECKS PASS')
@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,176 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>游山玩水 · 技能列表</title>
<style>
/* 聚合页样式:只服务「技能列表」这一件事,不含任何业务表单/报告样式 */
: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:24px 16px 72px; }
.tc-top { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; margin-bottom:6px; }
.tc-top h1 { font-size:22px; margin:0; }
.tc-sub { font-size:12px; color:var(--tc-sub); }
.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 18px; }
.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-warn { background:#fffaf0; border-color:#f0dcae; }
.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:hover { background:#2560d8; }
.tc-btn-ghost { background:#fff; color:var(--tc-accent); border:1px solid #cfdcff; }
.tc-btn-ghost:hover { background:#f0f4ff; }
.tc-skills { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:12px; }
.tc-skill { display:block; text-decoration:none; color:inherit; border:1px solid var(--tc-line);
border-radius:12px; padding:16px; background:#fff; transition:border-color .15s, box-shadow .15s, transform .15s; }
.tc-skill:hover { border-color:#b9cbff; box-shadow:0 8px 22px rgba(16,24,40,.08); transform:translateY(-1px); }
.tc-skill.tc-off { opacity:.62; cursor:not-allowed; border-style:dashed; }
.tc-skill.tc-off:hover { border-color:var(--tc-line); box-shadow:none; transform:none; }
.tc-skill-head { display:flex; align-items:flex-start; gap:10px; }
.tc-skill-icon { flex:none; width:34px; height:34px; display:flex; align-items:center; justify-content:center;
font-size:20px; line-height:1; background:#f2f6ff; border-radius:9px; }
.tc-skill-icon img { width:22px; height:22px; display:block; }
.tc-skill-name { font-size:15px; font-weight:600; margin:2px 0 0; }
.tc-skill-desc { font-size:12.5px; color:#374151; margin-top:8px; line-height:1.6; }
.tc-skill-foot { display:flex; flex-wrap:wrap; gap:5px; margin-top:10px; }
.tc-tag { font-size:11px; background:#eef2f7; color:#4b5563; border-radius:999px; padding:1px 8px; }
.tc-tag-go { background:#e8f0fe; color:#1d4ed8; }
.tc-tag-bad { background:#fdece8; color:#b42318; }
.tc-empty { color:var(--tc-sub); font-size:13px; padding:14px 0; }
</style>
</head>
<body>
<div class="tc-page">
<div class="tc-top">
<h1>游山玩水</h1>
<span class="tc-sub">思想实验室 · 选择一个技能进入(每个技能是完全独立的一套行程打法)</span>
</div>
<div class="tc-meta" id="tc_meta">正在读取技能列表…</div>
<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-skills" id="tc_skills"><div class="tc-empty">加载中…</div></div>
</div>
<script>
(function () {
'use strict'
var API = '/api/thought_lab/travel_citytour'
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) || '')) }
function showAuthNote() {
$('tc_auth_note').style.display = ''
// lab 级公开诊断接口(注册在鉴权守卫之前),只回传请求方自己已有的信息
fetch(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 style="background:#f7f8fa;border:1px solid #e5e7eb;border-radius:8px;padding:10px;' +
'font:12px/1.5 Consolas,Monaco,monospace;white-space:pre-wrap;word-break:break-all;margin:0;max-height:280px;overflow:auto">' +
esc(JSON.stringify(d, null, 2)) + '</pre>'
})
.catch(function () {})
}
function renderSkills(list) {
var box = $('tc_skills')
var usable = list.filter(function (s) { return !s.broken })
if (!list.length) {
box.innerHTML = '<div class="tc-empty">还没有任何技能。在后端 skills/ 与前端 skills/ 下各建同名目录即可(需含 config.json + index.js + index.html)。</div>'
return
}
box.innerHTML = ''
list.forEach(function (s) {
var broken = !!s.broken
var el = document.createElement(broken ? 'div' : 'a')
el.className = 'tc-skill' + (broken ? ' tc-off' : '')
if (!broken) el.href = s.page
el.title = s.desc || ''
var iconHtml = s.icon_url
? '<img src="' + esc(s.icon_url) + '" alt="" />'
: esc(s.icon || '🧭')
el.innerHTML =
'<div class="tc-skill-head">' +
'<div class="tc-skill-icon">' + iconHtml + '</div>' +
'<h3 class="tc-skill-name">' + esc(s.name) + '</h3>' +
'</div>' +
(s.desc ? '<div class="tc-skill-desc">' + esc(s.desc) + '</div>' : '') +
'<div class="tc-skill-foot">' +
(broken
? '<span class="tc-tag tc-tag-bad">不可用:' + esc(s.broken) + '</span>'
: '<span class="tc-tag tc-tag-go">进入 →</span>') +
'<span class="tc-tag">' + esc(s.id) + '</span>' +
'</div>'
box.appendChild(el)
})
$('tc_meta').innerHTML =
'共 <b>' + list.length + '</b> 个技能' +
(usable.length !== list.length ? '(其中 ' + (list.length - usable.length) + ' 个不可用)' : '') +
' · 每个技能在 skills/&lt;skill_id&gt;/ 下完全独立(前端页面 / 渲染 / 样式 / 导出 / 数据各自一份)'
}
function load() {
return fetch(API + '/skills?t=' + Date.now(), { cache: 'no-store' })
.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))
throw err
}
if (!j) throw new Error('返回不是合法 JSON')
return j
})
})
.then(function (j) { renderSkills(j.skills || []) })
}
$('tc_auth_retry').addEventListener('click', function () { location.reload() })
load().catch(function (e) {
if (isUnauthorized(e)) showAuthNote()
$('tc_meta').textContent = '读取失败:' + (e && e.message || '')
$('tc_skills').innerHTML = '<div class="tc-empty">技能列表加载失败</div>'
})
})()
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 979 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 899 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 777 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 KiB

@@ -0,0 +1,11 @@
<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="上海周边2日车宿徒步游">
<title>上海周边2日车宿徒步游</title>
<!-- 车宿徒步:车厢 + 下方山径 -->
<path d="M3 15.5h18v-4.2l-1.9-4.1a1.6 1.6 0 0 0-1.45-.9H6.35a1.6 1.6 0 0 0-1.45.9L3 11.3v4.2Z" />
<path d="M6.2 16v1.7M17.8 16v1.7" />
<circle cx="6.8" cy="13.2" r="1" fill="#2f6fed" stroke="none" />
<circle cx="17.2" cy="13.2" r="1" fill="#2f6fed" stroke="none" />
<path d="M3.4 11.3h17.2" />
</svg>

After

Width:  |  Height:  |  Size: 652 B

@@ -0,0 +1,743 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>上海周边2日车宿徒步游 · 游山玩水</title>
<style>
/* 本页样式全部以 tc- 前缀隔离,不引用其他 skill / lab / tool 的任何样式 */
: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-back:hover { text-decoration:underline; }
.tc-meta { font-size:12px; color:var(--tc-sub); background:#fff; border:1px solid var(--tc-line);
border-radius:8px; padding:8px 12px; margin-bottom:14px; }
.tc-meta b { color:var(--tc-text); }
.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-warn { background:#fffaf0; border-color:#f0dcae; }
.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-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); margin-bottom:12px; }
/* 表单 */
.tc-form { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:12px; }
.tc-field { display:flex; flex-direction:column; gap:4px; }
.tc-label { font-size:12px; color:#374151; font-weight:500; }
.tc-label .tc-req { color:#b42318; margin-left:2px; }
.tc-input, .tc-select {
font-size:13px; padding:7px 9px; border:1px solid var(--tc-line); border-radius:8px;
background:#fff; color:var(--tc-text); font-family:inherit;
}
.tc-input:focus, .tc-select:focus { outline:none; border-color:var(--tc-accent); box-shadow:0 0 0 3px #e8f0fe; }
.tc-hint { font-size:11px; color:var(--tc-sub); }
.tc-affects { font-size:11px; color:#8a6100; }
.tc-actions { display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-top:14px; }
.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:hover:not(:disabled) { background:#2560d8; }
.tc-btn:disabled { background:#c3cddb; cursor:not-allowed; }
.tc-btn-ghost { background:#fff; color:var(--tc-accent); border:1px solid #cfdcff; }
.tc-btn-ghost:hover { background:#f0f4ff; }
.tc-gen-status { font-size:12px; color:var(--tc-sub); }
.tc-empty { color:var(--tc-sub); font-size:13px; padding:8px 0; }
/* 结果区徽标 */
.tc-skill-tags { display:flex; flex-wrap:wrap; gap:4px; }
.tc-badge { font-size:11px; padding:1px 7px; border-radius:999px; background:#eef2f7; color:#4b5563; }
.tc-badge-info { background:#e8f0fe; color:#1d4ed8; }
.tc-badge-warn { background:#fff5e0; color:#8a6100; }
.tc-badge-danger { background:#fdece8; color:#b42318; }
.tc-badge-good { background:#e8f6ee; color:#1a7a45; }
/* 交互弹窗 */
.tc-modal-mask { position:fixed; inset:0; background:rgba(15,20,30,.45); display:none;
align-items:center; justify-content:center; z-index:9000; padding:16px; }
.tc-modal-mask.tc-show { display:flex; }
.tc-modal { background:#fff; border-radius:12px; max-width:820px; width:100%;
max-height:86vh; display:flex; flex-direction:column; overflow:hidden; }
.tc-modal-head { display:flex; justify-content:space-between; align-items:flex-start; gap:10px;
padding:14px 16px; border-bottom:1px solid var(--tc-line); }
.tc-modal-title { font-size:15px; font-weight:600; margin:0; }
.tc-modal-sub { font-size:12px; color:var(--tc-sub); }
.tc-modal-body { padding:14px 16px; overflow:auto; }
.tc-modal-foot { padding:10px 16px; border-top:1px solid var(--tc-line);
display:flex; justify-content:space-between; align-items:center; gap:10px; }
.tc-kv { display:grid; grid-template-columns:112px 1fr; gap:4px 10px; font-size:13px; margin-bottom:10px; }
.tc-kv-k { color:var(--tc-sub); }
.tc-kv-v { word-break:break-all; }
.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; }
.tc-sec-label { font-size:12px; font-weight:600; margin:12px 0 6px; color:#374151; }
.tc-state { font-size:12px; padding:1px 8px; border-radius:999px; display:inline-block; }
.tc-state-ok { background:#e8f6ee; color:#1a7a45; }
.tc-state-error { background:#fdece8; color:#b42318; }
.tc-state-pending { background:#eef2f7; color:#4b5563; }
/* Toast */
.tc-toasts { position:fixed; top:16px; left:50%; transform:translateX(-50%); z-index:9500;
display:flex; flex-direction:column; gap:8px; align-items:center; pointer-events:none; }
.tc-toast { font-size:13px; color:#fff; border-radius:8px; padding:8px 16px;
box-shadow:0 4px 14px rgba(0,0,0,.18); }
.tc-toast-info { background:#2f6fed; }
.tc-toast-success { background:#1a7a45; }
.tc-toast-error { background:#b42318; }
.tc-result-head { display:flex; justify-content:space-between; align-items:baseline; gap:10px;
flex-wrap:wrap; margin-bottom:10px; }
.tc-result-title { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
@media (max-width:640px){ .tc-kv{ grid-template-columns:88px 1fr; } .tc-block{ padding:13px; } }
</style>
<!-- 报告样式单独一份:类名统一 tcr- 前缀,与上面的表单/页面样式完全隔离 -->
<link rel="stylesheet" href="./tc_report.css" />
</head>
<body>
<div class="tc-page" id="tcr_top">
<div class="tc-top">
<h1>上海周边2日车宿徒步游</h1>
<span class="tc-sub">游山玩水 · 单 skill 页</span>
<a class="tc-back" href="../../index.html">← 返回技能列表</a>
</div>
<div class="tc-meta" id="tc_meta">正在读取技能状态…</div>
<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 class="tc-actions">
<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-note tc-note-warn" id="tc_slow_note" style="display:none">
<b>生成较慢,请勿关闭页面</b>
整份行程需要模型输出 1.2-1.6 万 token,实测约 6-8 分钟。页面会显示已用时间,完成后自动渲染。
</div>
<div class="tc-block">
<h2 class="tc-block-title">1 · 填写行程参数</h2>
<div class="tc-block-hint" id="tc_form_hint">正在读取问答定义…</div>
<div class="tc-form" id="tc_form"></div>
<div class="tc-actions">
<button class="tc-btn" id="tc_gen" disabled>生成行程</button>
<button class="tc-btn tc-btn-ghost" id="tc_gen_last" title="不重新生成,直接载入上一次的结果(一次生成要 4 分钟)">载入最近一次生成</button>
<span class="tc-gen-status" id="tc_gen_status"></span>
</div>
</div>
<div id="tc_result"></div>
<div class="tc-note tc-note-info">
<b>打不开 / 提示未授权?</b>
本技能受导航鉴权保护,必须<b>从首页九宫格进入思想实验室、再点本卡片</b>;直接把链接粘贴到地址栏会被拒绝(无同源来源)。
<br><a href="./sample/index.html">查看离线样例:南通如东</a>
</div>
</div>
<!-- Leaflet 为可选增强(lab 级第三方库,仅限 vendor);加载失败时渲染器自动降级为内置 SVG 点线示意 -->
<link rel="stylesheet" href="../../assets/vendor/leaflet/leaflet.css" onerror="void 0" />
<script src="../../assets/vendor/leaflet/leaflet.js" onerror="void 0"></script>
<script src="./tc_render.js"></script>
<!-- 单文件 HTML 导出器:纯前端拼字符串 + 内联样式/图片,导出不消耗任何 token -->
<script src="./tc_export.js"></script>
<script>
(function () {
'use strict'
// 本 skill 的接口前缀(由 lab 层挂载,鉴权已由 lab 层包好)
var API = '/api/thought_lab/travel_citytour/skills/sh_2d_car_hike'
var state = { uiMode: 'debug', meta: null, generating: false, timer: null, lastResult: null }
var $ = function (id) { return document.getElementById(id) }
/* ---------- 通用 UI 组件(双模式:debug / normal) ---------- */
function nowBeijing() {
try {
return new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })
} catch (e) { return new Date().toLocaleString() }
}
function esc(s) {
return String(s === undefined || s === null ? '' : s)
.replace(/[&<>"']/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] })
}
function toast(text, kind) {
var box = $('tc_toasts')
if (!box) {
box = document.createElement('div')
box.className = 'tc-toasts'
box.id = 'tc_toasts'
document.body.appendChild(box)
}
var el = document.createElement('div')
el.className = 'tc-toast tc-toast-' + (kind || 'info')
el.textContent = text
box.appendChild(el)
setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el) }, 2000)
}
/* 导航通行凭证缺失(页面守卫认 Referer、接口守卫只认 cookie)时给出明确指引 */
function isUnauthorized(err) { return /unauthorized|401/i.test(String((err && err.message) || '')) }
function showAuthNote() {
var el = $('tc_auth_note')
if (el) el.style.display = ''
// 顺手拉一次 lab 级公开诊断接口(注册在鉴权守卫之前),把「到底缺什么」直接摊在页面上
// 注意:诊断接口归 lab 层所有(红线),本 skill 不重复实现
fetch('/api/thought_lab/travel_citytour/auth/diag?t=' + Date.now(), { cache: 'no-store' })
.then(function (r) { return r.json() })
.then(function (d) {
var box = $('tc_auth_diag')
if (!box) return
box.innerHTML = '<div class="tc-sec-label">当前请求诊断(公开接口,不含任何 cookie 值)</div>' +
'<pre class="tc-pre">' + esc(JSON.stringify(d, null, 2)) + '</pre>'
})
.catch(function () {})
}
/* 交互详情弹窗:完整记录触发动作 / 时间 / 接口 / 方法 / 参数 / 返回 / 错误 / 耗时 / 状态 */
var interactionTimer = null
function showInteractionDialog(rec) {
var mask = $('tc_modal')
var okState = rec.state === 'ok'
var canAutoClose = okState
var autoSec = state.uiMode === 'debug' ? 3 : 2
mask.querySelector('.tc-modal-title').textContent = rec.action
mask.querySelector('.tc-modal-head .tc-modal-sub').textContent =
state.uiMode === 'debug' ? '研发调试模式 · 完整交互详情' : '交互详情'
var body = mask.querySelector('.tc-modal-body')
body.innerHTML =
'<div class="tc-kv">' +
'<div class="tc-kv-k">当前状态</div><div class="tc-kv-v"><span class="tc-state tc-state-' +
(rec.state === 'ok' ? 'ok' : (rec.state === 'pending' ? 'pending' : 'error')) + '">' +
esc(rec.state === 'ok' ? '成功' : (rec.state === 'pending' ? '进行中' : '失败')) + '</span></div>' +
'<div class="tc-kv-k">触发动作</div><div class="tc-kv-v">' + esc(rec.action) + '</div>' +
'<div class="tc-kv-k">时间</div><div class="tc-kv-v">' + esc(rec.time) + '(北京时间)</div>' +
'<div class="tc-kv-k">接口地址</div><div class="tc-kv-v">' + esc(rec.method + ' ' + rec.url) + '</div>' +
'<div class="tc-kv-k">耗时</div><div class="tc-kv-v">' + esc(rec.elapsed) + '</div>' +
'<div class="tc-kv-k">结果摘要</div><div class="tc-kv-v">' + esc(rec.summary || '—') + '</div>' +
'</div>' +
'<div class="tc-sec-label">请求参数</div><pre class="tc-pre">' + esc(rec.request || '(无)') + '</pre>' +
'<div class="tc-sec-label">返回结果</div><pre class="tc-pre">' + esc(rec.response || '(无)') + '</pre>' +
(rec.error ? '<div class="tc-sec-label">错误信息</div><pre class="tc-pre">' + esc(rec.error) + '</pre>' : '')
mask.querySelector('.tc-modal-foot .tc-gen-status').textContent = canAutoClose
? ('窗口将在 ' + autoSec + 's 后自动关闭') : '出现异常,窗口不会自动关闭,便于排查'
mask.classList.add('tc-show')
if (interactionTimer) { clearInterval(interactionTimer); interactionTimer = null }
if (canAutoClose) {
var left = autoSec
interactionTimer = setInterval(function () {
left--
var tip = mask.querySelector('.tc-modal-foot .tc-gen-status')
if (tip) tip.textContent = '窗口将在 ' + left + 's 后自动关闭'
if (left <= 0) { closeInteractionDialog(); }
}, 1000)
}
}
function closeInteractionDialog() {
if (interactionTimer) { clearInterval(interactionTimer); interactionTimer = null }
var mask = $('tc_modal')
if (mask) mask.classList.remove('tc-show')
}
/* 统一包装一次后台交互:记录细节 + 按模式决定弹窗还是 Toast */
function runTrackedInteraction(opt) {
var t0 = Date.now()
var rec = {
action: opt.action, method: opt.method || 'GET', url: opt.url,
request: opt.request ? JSON.stringify(opt.request, null, 2) : '', time: nowBeijing()
}
if (opt.showPending && state.uiMode === 'debug') {
rec.state = 'pending'; rec.elapsed = '—'; rec.summary = '请求已发出,等待返回…'
showInteractionDialog(rec)
}
return opt.run().then(function (res) {
rec.state = 'ok'
rec.elapsed = (Date.now() - t0) + ' ms'
rec.summary = opt.summary ? opt.summary(res) : '成功'
rec.response = JSON.stringify(opt.responseOf ? opt.responseOf(res) : res, null, 2)
if (state.uiMode === 'debug') showInteractionDialog(rec)
else toast(rec.summary, 'success')
return res
}).catch(function (err) {
rec.state = 'error'
rec.elapsed = (Date.now() - t0) + ' ms'
rec.summary = '失败'
rec.error = (err && err.message) || String(err)
if (err && err.detail) rec.response = JSON.stringify(err.detail, null, 2)
// 失败在任何模式下都强提示,不降级、不自动关闭
showInteractionDialog(rec)
throw err
})
}
function requestJson(url, options) {
var opt = Object.assign({ cache: 'no-store' }, options || {})
var timeoutMs = opt.timeoutMs || 0
delete opt.timeoutMs
var timer = null
if (timeoutMs && window.AbortController) {
var ctrl = new AbortController()
opt.signal = ctrl.signal
timer = setTimeout(function () { ctrl.abort() }, timeoutMs)
}
var clear = function () { if (timer) { clearTimeout(timer); timer = null } }
return fetch(url + '?t=' + Date.now(), opt)
.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 ? j.error + ':' + j.hint : j.error))) || ('HTTP ' + r.status))
err.detail = j || t.slice(0, 400)
throw err
}
if (!j) throw new Error('返回不是合法 JSON')
return j
})
})
.then(function (j) { clear(); return j }, function (e) {
clear()
if (e && e.name === 'AbortError') {
// 长请求经 8975 反代存在「服务端已返回、客户端收不到」的情况,此前界面会无限停在「生成中」
var err = new Error('等待 ' + Math.round(timeoutMs / 60000) + ' 分钟仍未收到响应。服务端可能已经生成完成,请点「载入最近一次生成」查看')
err.detail = { timeout_ms: timeoutMs, url: url }
throw err
}
throw e
})
}
/* ---------- 启动:读本技能元信息(含 ui_mode / qa / report_spec / destinations) ---------- */
function loadMeta() {
return runTrackedInteraction({
action: '读取技能元信息', method: 'GET', url: API + '/meta',
run: function () { return requestJson(API + '/meta') },
summary: function (r) {
return 'ui_mode=' + r.ui_mode + ',模型=' + r.ai_model +
',' + (r.report_spec && r.report_spec.sections || []).length + ' 个区块'
},
responseOf: function (r) {
return {
ok: r.ok, ui_mode: r.ui_mode, ai_model: r.ai_model, ai_ready: r.ai_ready,
skill: r.skill, sections: (r.report_spec && r.report_spec.sections || []).length,
destinations: (r.destinations || []).map(function (d) { return d.name })
}
}
}).then(function (r) {
state.meta = r
state.uiMode = r.ui_mode || 'debug'
var s = r.skill || {}
$('tc_meta').innerHTML =
'ui_mode:<b>' + esc(state.uiMode) + '</b>' +
' · 技能:<b>' + esc(s.name || '') + '</b>(v' + esc(s.version || '') + ')' +
' · 生成模型:<b>' + esc(r.ai_model) + '</b>' +
' · AI 通道:<b>' + (r.ai_ready ? '就绪' : '未配置密钥') + '</b>'
$('tc_slow_note').style.display = ''
renderForm(r)
return r
})
}
/* ---------- 渲染问答表单(按本技能 qa.json 动态生成,不写死字段) ---------- */
function renderForm(meta) {
var qa = meta.qa || {}
var host = $('tc_form')
host.innerHTML = ''
var s = meta.skill || {}
$('tc_form_hint').textContent = '当前技能:' + (s.name || '') + '(v' + (s.version || '') + ')· '
+ (s.anchors_count || 0) + ' 个骨架槽位 · 带 * 为必填。'
var dest = qa.destination_input || {}
var destSuggestions = (meta.destinations || []).map(function (d) { return d.name }).join(' / ')
addField(host, {
key: 'destination', label: dest.label || '目的地',
type: 'text', required: dest.required !== false,
placeholder: dest.placeholder || '例如:南通如东',
hint: (dest.hint || '') + (destSuggestions ? '(本技能已跑过:' + destSuggestions + ')' : '')
})
;(qa.questions || []).forEach(function (q) {
addField(host, {
key: q.key, label: q.label || q.key, type: q.type || 'text',
required: q.required !== false, default: q.default, options: q.options,
min: q.min, max: q.max, hint: q.hint || '',
affects: q.affects || []
})
})
var btn = $('tc_gen')
btn.disabled = false
btn.textContent = '生成行程'
$('tc_gen_status').textContent = '参数填好后点右侧按钮;生成需 6-8 分钟'
}
function addField(host, f) {
var wrap = document.createElement('div')
wrap.className = 'tc-field'
var lab = document.createElement('label')
lab.className = 'tc-label'
lab.textContent = f.label
if (f.required) {
var st = document.createElement('span'); st.className = 'tc-req'; st.textContent = '*'
lab.appendChild(st)
}
wrap.appendChild(lab)
var input
if (f.type === 'select') {
input = document.createElement('select')
;(f.options || []).forEach(function (o) {
var op = document.createElement('option')
op.value = o.value
op.textContent = o.label || o.value
input.appendChild(op)
})
} else {
input = document.createElement('input')
input.className = 'tc-input'
input.type = (f.type === 'number' ? 'number' : (f.type === 'date' ? 'date' : (f.type === 'time' ? 'time' : 'text')))
if (f.placeholder) input.placeholder = f.placeholder
if (f.min !== undefined && f.min !== null) input.min = f.min
if (f.max !== undefined && f.max !== null) input.max = f.max
}
if (f.type === 'select') input.className = 'tc-select'
input.id = 'qa_' + f.key
if (f.default !== undefined && f.default !== null && f.default !== '') input.value = f.default
wrap.appendChild(input)
if (f.hint) {
var h = document.createElement('div'); h.className = 'tc-hint'; h.textContent = f.hint
wrap.appendChild(h)
}
if (f.affects && f.affects.length) {
var a = document.createElement('div'); a.className = 'tc-affects'
a.textContent = '影响:' + f.affects.join('、')
wrap.appendChild(a)
}
host.appendChild(wrap)
}
function collectAnswers() {
var qa = (state.meta && state.meta.qa) || {}
var out = {}
var keys = ['destination'].concat((qa.questions || []).map(function (q) { return q.key }))
keys.forEach(function (k) {
var el = $('qa_' + k)
if (!el) return
var v = String(el.value || '').trim()
if (v === '') return
var q = (qa.questions || []).filter(function (x) { return x.key === k })[0]
out[k] = (q && q.type === 'number') ? Number(v) : v
})
return out
}
/* ---------- 生成 ---------- */
function startTimer() {
var t0 = Date.now()
state.timer = setInterval(function () {
var s = Math.floor((Date.now() - t0) / 1000)
$('tc_gen_status').textContent = '生成中… 已用 ' + s + ' 秒(整份行程实测约 3-5 分钟,请勿关闭页面)'
}, 1000)
}
function stopTimer() {
if (state.timer) { clearInterval(state.timer); state.timer = null }
}
/* 载入最近一次生成:不重新烧 4 分钟,直接看上一次落盘的结果(本技能私有 data/) */
function onLoadLast() {
var btn = $('tc_gen_last')
var old = btn.textContent
btn.disabled = true
btn.textContent = '载入中…'
runTrackedInteraction({
action: '载入最近一次生成', method: 'GET', url: API + '/last',
run: function () { return requestJson(API + '/last') },
summary: function (r) {
return '已载入:' + ((r.payload && r.payload.summary && r.payload.summary.title) || '未命名行程')
},
responseOf: function (r) {
return {
ok: r.ok, meta: r.meta,
check: { stats: r.check && r.check.stats, errors: r.check && r.check.errors, warnings: (r.check && r.check.warnings || []).slice(0, 20) }
}
}
}).then(function (r) {
if (renderResultSafe(r, '载入成功,但渲染失败(问题在前端渲染器)')) toast('已载入最近一次生成', 'success')
}).catch(function (e) {
if (isUnauthorized(e)) showAuthNote()
$('tc_result').innerHTML = '<div class="tc-note tc-note-warn"><b>载入失败</b>:' + esc((e && e.message) || String(e)) + '</div>'
}).then(function () {
btn.disabled = false
btn.textContent = old
})
}
function onGenerate() {
if (state.generating) return
var answers = collectAnswers()
if (!answers.destination) { toast('请先填写目的地', 'error'); return }
state.generating = true
var btn = $('tc_gen')
btn.disabled = true
btn.textContent = '生成中…'
startTimer()
runTrackedInteraction({
action: '生成行程(AI)',
method: 'POST',
url: API + '/generate',
request: { answers: answers },
showPending: true,
run: function () {
return requestJson(API + '/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers: answers }),
timeoutMs: 8 * 60 * 1000
})
},
summary: function (r) {
var c = r.check || {}
// 有结构问题时如实说明(结果已被保留,可查看/导出),不要一律报「生成成功」
var errs = c.errors || []
return (errs.length ? '已生成但有 ' + errs.length + ' 处结构问题:' : '生成成功:') +
(r.check && r.check.stats ? r.check.stats.cards : '?') + ' 张卡片,' +
(c.warnings ? c.warnings.length : 0) + ' 条提醒,耗时 ' + Math.round((r.meta && r.meta.elapsed_ms || 0) / 1000) + 's'
},
responseOf: function (r) {
return {
ok: r.ok, meta: r.meta, check: { stats: r.check && r.check.stats, errors: r.check && r.check.errors, warnings: (r.check && r.check.warnings || []).slice(0, 20) },
payload_summary: r.payload && r.payload.summary,
destination_override: r.destination_override
}
}
}).then(function (r) {
if (renderResultSafe(r, '生成成功,但渲染失败(数据是好的,问题在前端渲染器)')) {
var nErr = (r.check && r.check.errors) || []
// 有结构问题时不用绿色成功提示,避免「看起来一切正常」的错觉
if (nErr.length) toast('已生成,但有 ' + nErr.length + ' 处结构问题(见下方红色提示)', 'error')
else toast('行程已生成并渲染', 'success')
}
}).catch(function (err) {
if (isUnauthorized(err)) showAuthNote()
$('tc_gen_status').textContent = '生成失败:' + (err && err.message || '')
}).then(function () {
state.generating = false
stopTimer()
btn.disabled = false
btn.textContent = '重新生成'
})
}
// 渲染必须永远有反馈:异常时把错误摊在页面上,绝不留白、绝不静默
function renderResultSafe(r, title) {
try {
renderResult(r)
return true
} catch (e) {
$('tc_result').innerHTML =
'<div class="tc-note tc-note-warn"><b>' + esc(title) + '</b><br>' +
'· 错误:' + esc((e && e.message) || String(e)) + '<br>' +
'· 位置:' + esc(String((e && e.stack) || '').split('\n').slice(1, 4).join(' ← ')) + '</div>'
return false
}
}
function renderResult(r) {
var host = $('tc_result')
var payload = r.payload
var spec = r.report_spec || {}
// 导出按钮吃的是这份「已经生成好」的结果:导出不再调用 AI,不消耗 token
state.lastResult = r
var head = document.createElement('div')
head.className = 'tc-result-head'
var warns = (r.check && r.check.warnings) || []
// 结构问题(errors):结果已被服务端保留(不再整份丢弃),这里必须醒目告知,而不是假装一切正常
var errs = (r.check && r.check.errors) || []
head.innerHTML =
'<div class="tc-result-title">' +
'<h2 class="tc-block-title" style="margin:0">2 · 生成结果</h2>' +
'<button class="tc-btn tc-btn-ghost" id="tc_export" style="padding:5px 12px"' +
' title="把下面这份结果导出成一个独立的 HTML 单文件,离线随时查看(不重新生成、不消耗 token)">导出至 HTML</button>' +
'</div>' +
'<div class="tc-skill-tags">' +
'<span class="tc-badge tc-badge-info">模型 ' + esc((r.meta && r.meta.model) || '') + '</span>' +
'<span class="tc-badge tc-badge-info">耗时 ' + Math.round(((r.meta && r.meta.elapsed_ms) || 0) / 1000) + 's</span>' +
(r.meta && r.meta.is_new_destination ? '<span class="tc-badge tc-badge-warn">首个新目的地</span>' : '<span class="tc-badge tc-badge-good">技能包内已有覆盖</span>') +
(errs.length
? '<span class="tc-badge tc-badge-danger">' + errs.length + ' 处结构问题</span>'
: (warns.length
? '<span class="tc-badge tc-badge-warn">' + warns.length + ' 条校验提醒</span>'
: '<span class="tc-badge tc-badge-good">结构校验通过</span>')) +
'</div>'
host.innerHTML = ''
host.appendChild(head)
var exportBtn = $('tc_export')
if (exportBtn) {
if (window.TCExport) exportBtn.addEventListener('click', onExport)
else { exportBtn.disabled = true; exportBtn.title = '导出器未加载(tc_export.js 未成功执行)' }
}
if (errs.length) {
var e = document.createElement('div')
e.className = 'tc-note tc-note-danger'
e.innerHTML = '<b>本次结果有 ' + errs.length + ' 处结构问题(前 12 条)—— 已保留,未丢弃</b>' +
errs.slice(0, 12).map(function (x) { return '· ' + esc(x) }).join('<br>') +
'<br>下方内容仍可正常查看与导出。若这些报错其实是「校验器误判」,' +
'请把这段文字发给 AI 定位(不必重新生成);若确属内容缺失,再点「重新生成」。'
host.appendChild(e)
}
if (warns.length) {
var w = document.createElement('div')
w.className = 'tc-note tc-note-warn'
w.innerHTML = '<b>结构校验提醒(' + warns.length + ' 条,前 12 条)</b>' +
warns.slice(0, 12).map(function (x) { return '· ' + esc(x) }).join('<br>')
host.appendChild(w)
}
var mount = document.createElement('div')
mount.id = 'tc_report'
host.appendChild(mount)
if (!window.TCRender) {
mount.innerHTML = '<div class="tc-empty">渲染器未加载(tc_render.js 未成功执行)</div>'
return
}
try {
window.TCRender.render(payload, spec, mount)
} catch (e) {
// 渲染中断时保留已渲染部分,并把错误摊在页面上 —— 绝不静默留白
var box = document.createElement('div')
box.className = 'tc-note tc-note-warn'
box.innerHTML = '<b>渲染中断</b>:' + esc((e && e.message) || String(e)) +
'<br>位置:' + esc(String((e && e.stack) || '').split('\n').slice(1, 4).join(' ← ')) +
'<br>以上为已成功渲染的部分。把这行错误发给 AI 即可定位。'
mount.appendChild(box)
}
}
/* 导出至 HTML:把当前这份结果拼成「单文件离线文档」并下载。
全程纯前端(读同目录 tc_export.css + 内嵌配图),不调用任何后端接口、不消耗 token。 */
function onExport() {
var r = state.lastResult
if (!r) { toast('还没有可导出的结果,先生成或载入一次', 'error'); return }
if (!window.TCExport) { toast('导出器未加载(tc_export.js)', 'error'); return }
var btn = $('tc_export')
if (btn && btn.disabled) return
var old = btn ? btn.textContent : ''
if (btn) { btn.disabled = true; btn.textContent = '导出中…' }
var payload = r.payload || {}
var summary = payload.summary || {}
var sections = (r.report_spec && r.report_spec.sections) || []
var cards = Array.isArray(payload.cards) ? payload.cards : []
var markers = (payload.map && payload.map.markers) || []
runTrackedInteraction({
action: '导出至 HTML(单文件·离线可看)', method: 'LOCAL', url: './tc_export.css → blob 下载',
request: {
title: summary.title || '(无标题)',
sections: sections.length,
cards: cards.length,
markers: markers.length,
note: '内联样式 + 内嵌配图 + 内联 SVG 地图;不调用 AI、不消耗 token'
},
showPending: true,
run: function () { return window.TCExport.download(payload, r.report_spec || {}, { meta: r.meta }) },
summary: function (res) {
return '已导出 ' + res.filename + '(' + Math.round(res.bytes / 1024) + ' KB,' + res.sections + ' 个区块' +
(res.images && res.images.total ? ',配图内嵌 ' + res.images.embedded + '/' + res.images.total : '') + ')'
},
responseOf: function (res) { return res }
}).then(function (res) {
toast('已导出:' + res.filename, 'success')
}).catch(function (e) {
toast('导出失败:' + ((e && e.message) || ''), 'error')
}).then(function () {
if (btn) { btn.disabled = false; btn.textContent = old }
})
}
/* ---------- 启动 ---------- */
// 交互弹窗骨架
var mask = document.createElement('div')
mask.className = 'tc-modal-mask'
mask.id = 'tc_modal'
mask.innerHTML =
'<div class="tc-modal">' +
'<div class="tc-modal-head">' +
'<div><h3 class="tc-modal-title">交互详情</h3><div class="tc-modal-sub"></div></div>' +
'<button class="tc-btn tc-btn-ghost" id="tc_modal_close" style="padding:5px 12px">关闭</button>' +
'</div>' +
'<div class="tc-modal-body"></div>' +
'<div class="tc-modal-foot">' +
'<button class="tc-btn tc-btn-ghost" id="tc_modal_copy" style="padding:5px 12px">复制完整交互日志</button>' +
'<span class="tc-gen-status"></span>' +
'</div>' +
'</div>'
document.body.appendChild(mask)
$('tc_modal_close').addEventListener('click', closeInteractionDialog)
mask.addEventListener('click', function (e) { if (e.target === mask) closeInteractionDialog() })
$('tc_modal_copy').addEventListener('click', function () {
var txt = mask.querySelector('.tc-modal-body').innerText
var done = function () { toast('交互日志已复制', 'success') }
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(txt).then(done, done)
else {
var ta = document.createElement('textarea')
ta.value = txt; document.body.appendChild(ta); ta.select()
try { document.execCommand('copy'); done() } catch (e) { toast('复制失败', 'error') }
document.body.removeChild(ta)
}
})
$('tc_gen').addEventListener('click', onGenerate)
$('tc_gen_last').addEventListener('click', onLoadLast)
$('tc_auth_retry').addEventListener('click', function () { location.reload() })
loadMeta().catch(function (e) {
if (isUnauthorized(e)) showAuthNote()
$('tc_form_hint').textContent = '技能元信息加载失败:' + (e && e.message || '')
})
})()
</script>
</body>
</html>
@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>南通如东 · 两日车宿徒步|游山玩水(离线样例)</title>
<style>
/* 全部样式以 tc- 前缀隔离,避免与其他 lab / tool 的样式互相污染 */
:root {
--tc-bg: #f6f7f9;
--tc-card: #ffffff;
--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 64px; }
.tc-top { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
.tc-top h1 { font-size: 20px; margin: 0; }
.tc-top .tc-mode { font-size: 12px; color: var(--tc-sub); }
.tc-meta {
font-size: 12px; color: var(--tc-sub); background: #fff;
border: 1px solid var(--tc-line); border-radius: 8px; padding: 8px 12px; margin-bottom: 14px;
}
.tc-meta b { color: var(--tc-text); font-weight: 600; }
.tc-disclaimer {
background: #fff8e6; border: 1px solid #f0d79a; color: #7a5a12;
border-radius: 8px; padding: 10px 12px; font-size: 13px; margin-bottom: 18px;
}
/* 报告区(tcr- 前缀)的样式已统一收进 ../tc_report.css,本页只保留页面级样式 */
.tc-error { background: #fff5f4; border: 1px solid #f5c9c3; color: #b42318; border-radius: 8px; padding: 14px; font-size: 13px; }
.tc-error code { background: rgba(0,0,0,.05); padding: 1px 5px; border-radius: 4px; }
</style>
<!-- 报告样式单独一份:类名统一 tcr- 前缀,与上面的页面级样式完全隔离 -->
<link rel="stylesheet" href="../tc_report.css" />
</head>
<body>
<div class="tc-page" id="tcr_top">
<div class="tc-top">
<h1>南通如东 · 两日车宿徒步</h1>
<span class="tc-mode">游山玩水 · 技能包「上海周边2日车宿徒步游」· 离线样例</span>
</div>
<div class="tc-meta" id="tc_meta">正在载入样例数据…</div>
<div class="tc-disclaimer">
<b>这是效果验证样例:</b>行程由技能包规则生成,所有主观信息(免费停车、本地人常去、温泉档次与卫生)均标注置信度并列入「待核实清单」。
本样例<b>不编造店名与价格</b>,餐饮与超市只给业态与位置描述。出行前请自行复核关键信息。
</div>
<div id="tc_mount"></div>
</div>
<!-- Leaflet 为可选增强(lab 级第三方库,仅限 vendor);加载失败时渲染器自动降级为内置 SVG 点线示意 -->
<link rel="stylesheet" href="../../../assets/vendor/leaflet/leaflet.css" onerror="void 0" />
<script src="../../../assets/vendor/leaflet/leaflet.js" onerror="void 0"></script>
<!-- 渲染器与报告样式归本 skill 私有:同目录一份,改本 skill 不影响其他 skill -->
<script src="../tc_render.js"></script>
<script>
(function () {
var mount = document.getElementById('tc_mount')
var metaBox = document.getElementById('tc_meta')
function fail(msg, err) {
mount.innerHTML = ''
var box = document.createElement('div')
box.className = 'tc-error'
box.innerHTML = '<b>' + msg + '</b><br/>' + (err ? String(err.message || err) : '')
+ '<br/>请通过首页九宫格进入本工具后再打开本页(静态资源受导航鉴权保护)。'
mount.appendChild(box)
metaBox.textContent = '载入失败'
}
function renderMeta(payload) {
var m = payload.meta || {}
var d = m.destination || {}
var qa = m.qa || {}
var vehicle = qa.vehicle_type === 'ev' ? '电车' : (qa.vehicle_type === 'fuel' ? '油车' : (qa.vehicle_type || '—'))
metaBox.innerHTML =
'skill:<b>' + (m.skill_name || '—') + '</b>(v' + (m.skill_version || '—') + ')' +
' · 目的地:<b>' + (d.name || '—') + '</b>' + (d.admin ? '(' + d.admin + ')' : '') +
' · 出行:<b>' + (qa.departure_date || '—') + ' ' + (qa.departure_time || '') + '</b>' +
' · 车辆:<b>' + vehicle + '</b> · 人数:<b>' + (qa.headcount === undefined ? '—' : qa.headcount) + '</b>' +
' · 生成方式:<b>' + (m.generator || '—') + '</b> · 生成时间:<b>' + (m.generated_at || '—') + '</b>(北京时间)'
}
if (!window.TCRender) { fail('渲染器未加载', null); return }
var files = ['./rudong_2026-09-19.json', './report_spec.json']
Promise.all(files.map(function (u) {
return fetch(u + '?t=' + Date.now(), { cache: 'no-store' }).then(function (r) {
if (!r.ok) throw new Error(u + ' HTTP ' + r.status)
return r.json()
})
})).then(function (res) {
var payload = res[0]
var spec = res[1]
window.TCRender.render(payload, spec, mount)
renderMeta(payload)
}).catch(function (e) {
fail('样例数据载入失败', e)
})
})()
</script>
</body>
</html>
@@ -0,0 +1,24 @@
{
"_comment": "【阶段一离线样例专用】本文件是技能包 report_spec.json 的样例副本,仅为了让 sample/index.html 能独立打开。阶段二由后端从 skills/sh_2d_car_hike/report_spec.json 读取并注入,届时本副本可直接删除。",
"version": "0.1.0",
"block_types": ["overview", "timeline", "cards", "map", "table", "cost", "checklist", "notes"],
"layout": "single_column",
"show_index": true,
"sections": [
{ "id": "sec_overview", "type": "overview", "title": "行程概览", "data": "$.summary" },
{ "id": "sec_notes_top", "type": "notes", "title": "出行前必读", "data": "$.notes", "filter_levels": ["warn", "danger"] },
{ "id": "sec_timeline", "type": "timeline", "title": "两天时序", "data": "$.timeline" },
{ "id": "sec_cards", "type": "cards", "title": "节点详情", "data": "$.cards", "group_by": "day" },
{ "id": "sec_map", "type": "map", "title": "点位与路线", "data": "$.map" },
{ "id": "sec_drive_out", "type": "table", "title": "去程 · 双版本对比", "data": "$.tables", "pick_id": "drive_out_compare" },
{ "id": "sec_drive_back", "type": "table", "title": "返程 · 双版本对比", "data": "$.tables", "pick_id": "drive_back_compare" },
{ "id": "sec_food", "type": "table", "title": "三餐候选一览", "data": "$.tables", "pick_id": "food_summary" },
{ "id": "sec_parking", "type": "table", "title": "各节点停车一览", "data": "$.tables", "pick_id": "parking_summary" },
{ "id": "sec_cost", "type": "cost", "title": "费用合计", "data": "$.cost" },
{ "id": "sec_checklist", "type": "checklist", "title": "待核实清单", "data": "$.checklist" },
{ "id": "sec_notes", "type": "notes", "title": "提示与免责", "data": "$.notes" }
],
"confidence_badge": { "high": "已核实", "medium": "待确认", "low": "需核实" },
"image_disclaimer_default": "AI 示意图,非实景",
"map_route_disclaimer_default": "点位顺序示意,非真实道路/轨迹"
}
@@ -0,0 +1,728 @@
{
"meta": {
"skill_id": "sh_2d_car_hike",
"skill_name": "上海周边2日车宿徒步游",
"skill_version": "0.3.0",
"schema_version": "0.2.0",
"destination": {
"key": "rudong",
"name": "南通如东",
"admin": "江苏省南通市如东县",
"center": [32.31, 121.19]
},
"qa": {
"departure_origin": "上海宝山富锦路地铁站",
"departure_date": "2026-09-19",
"departure_time": "06:40",
"vehicle_type": "fuel",
"headcount": 1
},
"generated_at": "2026-09-17 21:30:00",
"generator": "manual_sample",
"disclaimer": "本行程按技能包规则生成,用于效果验证;所有主观信息均标注置信度,出行前请按『待核实清单』自行复核。"
},
"summary": {
"title": "南通如东 · 两日车宿徒步",
"one_liner": "沿海堤岸走两段平路徒步,落脚栟茶古镇逛三小时,傍晚泡温泉,夜里睡在县城公园,周日再走一段林带后返沪。",
"tags": ["2天1夜", "单人自驾", "车内过夜", "两次1.5h徒步", "历史街区", "温泉"],
"stats": [
{ "label": "全程车程", "value": "约 340 km", "hint": "往返里程,高速版口径;起点为宝山富锦路地铁站" },
{ "label": "徒步合计", "value": "3.0 小时", "hint": "两段各 1.5h,平路无爬升" },
{ "label": "预估花费", "value": "≈ 900 元", "hint": "含过路费/油费/两餐/温泉/补给" },
{ "label": "硬时间点", "value": "17:00 / ≤22:00 / 05:00", "hint": "浴场 / 车宿(不晚于)/ 晨起" },
{ "label": "地形提醒", "value": "平原·无山", "hint": "已触发地形降级,两段徒步爬升均约 0" }
]
},
"timeline": {
"days": [
{
"label": "第一天 · 周六",
"date": "2026-09-19",
"rows": [
{ "time": "06:40", "title": "宝山富锦路地铁站出发(可 06:30 提前)", "anchor_id": "drive_out", "kind": "soft", "activity": "move", "note": "高速版 2.6h / 非高速版 3.5h" },
{ "time": "09:15", "title": "抵达沿海堤岸徒步起点", "anchor_id": "hike_1", "kind": "soft", "activity": "move", "note": "06:40 出发 + 约 2.6h 车程;到达即走" },
{ "time": "09:20-10:50", "title": "第一段徒步 1.5h", "anchor_id": "hike_1", "kind": "soft", "activity": "hike", "note": "平路,约 6.5km,爬升约 0m" },
{ "time": "11:05", "title": "抵达茶古镇外围,先解决停车", "anchor_id": "district_parking", "kind": "soft", "activity": "move", "note": "免费与否需现场确认" },
{ "time": "11:20-12:25", "title": "午饭(本地小馆)", "anchor_id": "lunch_d1", "kind": "soft", "activity": "eat", "note": "三选一,人均约 100" },
{ "time": "12:30-15:30", "title": "古镇打卡动线约 3 小时", "anchor_id": "district_walk", "kind": "soft", "activity": "walk", "note": "5 个点位,步行约 3.2km" },
{ "time": "16:15", "title": "离开古镇前往温泉", "anchor_id": "bathhouse", "kind": "soft", "activity": "move", "note": "车程约 35 分钟,含找车位" },
{ "time": "17:00", "title": "抵达温泉 / 洗浴中心", "anchor_id": "bathhouse", "kind": "hard", "activity": "move", "note": "硬时间点:骨架声明的到达时刻(16:45-17:15)" },
{ "time": "17:05-19:40", "title": "泡汤、淋浴、休息", "anchor_id": "bathhouse", "kind": "soft", "note": "建议不超过 2.5 小时" },
{ "time": "19:40-20:10", "title": "浴场旁超市补给(与浴场一起完成)", "anchor_id": "grocery", "kind": "soft", "activity": "shop", "note": "同商圈,与浴场共用停车点,不挪车" },
{ "time": "20:20", "title": "前往车宿点", "anchor_id": "car_sleep", "kind": "soft", "activity": "move", "note": "往县城方向,车程约 45 分钟" },
{ "time": "21:10", "title": "抵达车宿停车点", "anchor_id": "car_sleep", "kind": "hard", "activity": "sleep", "note": "硬点要求不晚于 22:00,本次提前到位" },
{ "time": "21:10-22:00", "title": "车内整理、吃夜宵、准备就寝", "anchor_id": "car_sleep", "kind": "soft", "activity": "sleep", "note": "提前到就提前休息,不必等到 22:00" }
]
},
{
"label": "第二天 · 周日",
"date": "2026-09-20",
"rows": [
{ "time": "05:00", "title": "起床散步", "anchor_id": "morning_walk", "kind": "hard", "activity": "walk", "note": "硬时间点;日出约 05:35,此时为黎明微光" },
{ "time": "05:05-06:10", "title": "公园环线晨走", "anchor_id": "morning_walk", "kind": "soft", "activity": "walk", "note": "约 3km 环线" },
{ "time": "06:15-07:10", "title": "早饭 + 洗漱", "anchor_id": "breakfast_wash", "kind": "soft", "activity": "eat", "note": "早点铺 + 公园公厕/加油站卫生间" },
{ "time": "07:20", "title": "驱车前往第二徒步点", "anchor_id": "hike_2", "kind": "soft", "activity": "move", "note": "沿海林带方向" },
{ "time": "08:15-09:45", "title": "第二段徒步 1.5h", "anchor_id": "hike_2", "kind": "soft", "activity": "hike", "note": "林道 + 水岸,约 6.5km,爬升约 0m" },
{ "time": "10:00-11:20", "title": "打卡点 1-2 个", "anchor_id": "photo_spots", "kind": "soft", "activity": "walk", "note": "人文点位为主,与昨日古镇点位去重" },
{ "time": "11:30-12:40", "title": "午饭(县城本地家常菜)", "anchor_id": "lunch_d2", "kind": "soft", "activity": "eat", "note": "优先能停车" },
{ "time": "13:00", "title": "返程上海", "anchor_id": "drive_back", "kind": "soft", "activity": "move", "note": "注意周日下午进城方向高峰" }
]
}
]
},
"cards": [
{
"id": "c_drive_out",
"anchor_id": "drive_out",
"day": 1,
"title": "去程 · 双版本自驾",
"subtitle": "宝山富锦路地铁站 → 如东(沿海徒步点)",
"image": {
"url": "../assets/photos/rudong_cover.jpg",
"kind": "ai_illustration",
"caption": "沿海公路与堤岸意象",
"disclaimer": "AI 示意图,非实景"
},
"badges": [
{ "text": "双版本", "tone": "info" },
{ "text": "里程为估算", "tone": "warn" }
],
"fields": [
{ "label": "高速版", "value": { "text": "170 km / 约 2.6 小时 / 过路费约 85 元", "confidence": "medium", "verify": "出发前用导航核对实时里程与路况" } },
{ "label": "非高速版", "value": { "text": "162 km / 约 3.5 小时 / 无过路费", "confidence": "low", "verify": "国道走向与限速以导航为准,国省道易遇施工与拥堵" } },
{ "label": "加油点", "value": "高速版服务区 + 沿途连锁加油站充足;出高速后县城加油站密集" },
{ "label": "拥堵风险", "value": { "text": "周六上午出城方向 07:00-09:00 相对顺畅,过江通道节假日上午易排队", "confidence": "medium", "verify": "出行当天看实时路况" } },
{ "label": "取舍建议", "value": "赶时间走高速;想省钱且不赶时间走非高速,但非高速要多留 1 小时余量" }
],
"bullets": [
"起点:上海宝山富锦路地铁站(示例坐标仅用于地图示意)",
"中途拐点:过江后经南通方向转如东",
"终点:沿海堤岸徒步起点,路边可临停卸装备",
"出发前把两套路线都存进导航,现场按实时路况二选一"
],
"map_marker_ids": ["m_sh_start", "m_way_sutong", "m_way_nantong", "m_hike1_start"]
},
{
"id": "c_hike_1",
"anchor_id": "hike_1",
"day": 1,
"title": "第一段徒步 · 沿海堤岸",
"subtitle": "1.5 小时 · 到达即走",
"image": {
"url": "../assets/photos/rudong_hike1_embankment.jpg",
"kind": "ai_illustration",
"caption": "海堤与滩涂意象",
"disclaimer": "AI 示意图,非实景"
},
"badges": [
{ "text": "已触发地形降级", "tone": "warn" },
{ "text": "平路无爬升", "tone": "warn" }
],
"fields": [
{ "label": "起点 / 终点", "value": { "text": "沿海堤岸起点 → 沿堤折返或走至下一闸口", "confidence": "low", "verify": "堤岸是否允许步行、是否有施工封闭,需现场确认" } },
{ "label": "折算里程", "value": "按 4.3 km/h 常规步速,1.5 小时约 6.5 km" },
{ "label": "累计爬升", "value": "约 0 m(平路徒步,运动量按时长与步数计)" },
{ "label": "路面类型", "value": "堤顶水泥路 / 土路混合,风力较大" },
{ "label": "难度", "value": "低(无技术路段),但日晒与海风体感明显" },
{ "label": "停车", "value": { "text": "堤岸起点附近路侧空地可临停", "confidence": "low", "verify": "是否允许停车、是否属禁停区需现场看标识" } },
{ "label": "补水 / 卫生间", "value": { "text": "堤岸沿线基本无补给,需自带水;卫生间需回镇上解决", "confidence": "medium", "verify": "出发前确认最近公厕位置" } }
],
"bullets": [
"降级说明:如东为平原沿海地区,无山可爬,本段改走堤岸与滩涂边,累计爬升约 0,不得按山地徒步的运动量期待",
"想出汗的做法:把配速提到 5.5-6 km/h 快走,或做 4 组 200 米折返快走",
"海边风力大,注意防晒与帽子固定;鞋建议防滑、不穿白鞋(滩涂泥)"
],
"map_marker_ids": ["m_hike1_start", "m_hike1_end"]
},
{
"id": "c_district_arrive",
"anchor_id": "district_arrive",
"day": 1,
"title": "街区 · 茶古镇一带",
"subtitle": "历史街区",
"badges": [
{ "text": "历史街区", "tone": "good" },
{ "text": "待确认", "tone": "warn" }
],
"fields": [
{ "label": "街区定性", "value": "历史街区(古镇型),非纯商业步行街" },
{ "label": "范围", "value": { "text": "古镇老街一带,可逛核心范围不大,约 2-3 条主街", "confidence": "medium", "verify": "实际开放范围与是否局部封闭,出发前查当地公告" } },
{ "label": "可逛时长", "value": "约 3 小时(含吃饭与休憩),节奏偏慢" },
{ "label": "选择理由", "value": "如东县城以现代街区为主,历史街区以古镇型最符合本条要求" },
{ "label": "备选", "value": "如东县城掘港镇老街一带(更偏闹市街区,若古镇封闭可切换)" }
],
"bullets": [
"古镇型街区的价值在街巷肌理与老建筑,不追求商铺数量",
"若遇整修或大面积围挡,直接切到备选的县城闹市街区,动线按同规则重排"
],
"map_marker_ids": ["m_bengcha_gate", "m_bengcha_center"]
},
{
"id": "c_district_parking",
"anchor_id": "district_parking",
"day": 1,
"title": "街区停车(免费优先)",
"subtitle": "先停车,再吃饭",
"badges": [
{ "text": "需核实", "tone": "danger" },
{ "text": "不得默认免费", "tone": "warn" }
],
"fields": [
{ "label": "首选停车点", "value": { "text": "古镇入口外围路侧泊位 / 镇区公共停车场", "confidence": "low", "verify": "出发前打电话给古镇管理方或看地图实景确认是否免费" } },
{ "label": "免费判定依据", "value": { "text": "暂无可靠依据,需现场看标识或电话确认", "confidence": "low", "verify": "以现场收费公示牌为准" } },
{ "label": "预估费用", "value": "若收费,按小客车 5-10 元/次估(占位口径)" },
{ "label": "步行距离", "value": "预计 5 分钟内进主街;实际以现场为准" },
{ "label": "风险提示", "value": "古镇周末可能有临时交通管制,停车点会临时变化" }
],
"bullets": [
"规则要求:本节点不允许把收费点写成免费点;如无免费方案,必须给收费方案与费用",
"若愿意多走 5-10 分钟,可把车停在镇区外围再步行进古镇核心"
],
"map_marker_ids": ["m_bengcha_park"]
},
{
"id": "c_lunch_d1",
"anchor_id": "lunch_d1",
"day": 1,
"title": "第一天午饭 · 本地小馆",
"subtitle": "人均约 100 · 单人份",
"image": {
"url": "../assets/photos/rudong_lunch_local.jpg",
"kind": "ai_illustration",
"caption": "沿海小镇家常菜意象",
"disclaimer": "AI 示意图,非实景"
},
"badges": [
{ "text": "3 家候选", "tone": "info" },
{ "text": "店名待核实", "tone": "danger" }
],
"fields": [
{ "label": "候选 1(走路可达)", "value": { "text": "古镇外围沿街海鲜小馆 —— 以文蛤、小海鲜、时令河鲜为招牌的本地小馆", "confidence": "low", "verify": "店名与实际人均需现场确认;本样例不编造店名" } },
{ "label": "候选 2(走路可达)", "value": { "text": "镇区非游客主街的家常菜馆 —— 做本地家常菜,中午有本地人吃饭", "confidence": "low", "verify": "同上" } },
{ "label": "候选 3(需挪车)", "value": { "text": "镇区外围临街面馆 / 小炒 —— 停车方便,适合赶时间", "confidence": "low", "verify": "同上" } },
{ "label": "判断依据", "value": "加分项:不在游客主街、有本地招牌菜、评价中本地标签占比高、开店年限长;减分项:门口拉客、中英双语菜单、团餐大巴停靠" },
{ "label": "单人怎么点", "value": "一荤一素 + 一份主食,或点一碗海鲜面/文蛤面;人均 100 通常吃得很饱" },
{ "label": "停车", "value": "候选 1、2 用街区停车点;候选 3 门口可停" }
],
"bullets": [
"本地口味偏清淡鲜甜,偏咸辣可要求少盐",
"海鲜按时令,若遇禁渔期价格会明显上行,点单前先问价",
"不确定店名时合规做法:只给业态 + 位置描述,不给编造名字"
],
"map_marker_ids": ["m_bengcha_center", "m_lunch_d1_alt"]
},
{
"id": "c_district_walk",
"anchor_id": "district_walk",
"day": 1,
"title": "街区打卡动线 · 约 3 小时",
"subtitle": "5 个点位 · 步行约 3.2 km",
"image": {
"url": "../assets/photos/rudong_bengcha.jpg",
"kind": "ai_illustration",
"caption": "古镇街巷意象",
"disclaimer": "AI 示意图,非实景"
},
"badges": [
{ "text": "含点线顺序", "tone": "info" },
{ "text": "逗留节奏偏慢", "tone": "plain" }
],
"fields": [
{ "label": "总时长", "value": "约 3 小时(12:40-15:40),含 1 次坐下休憩" },
{ "label": "步行距离", "value": "约 3.2 km,平路,随时可缩短" },
{ "label": "休憩点", "value": { "text": "主街上的茶馆 / 咖啡位(店名待核实)", "confidence": "low", "verify": "现场找有座位的店即可,不必指定" } }
],
"bullets": [
"① 古镇入口牌坊 12:40-12:55 —— 拍门头与街口全景,建立方位感",
"② 主街老建筑段 12:55-13:40 —— 看沿街立面与老字号门面,慢走细看",
"③ 巷弄纵深段 13:40-14:25 —— 拐进横向支巷,人少、更出片",
"④ 水岸 / 桥头 14:25-15:05 —— 坐 20 分钟休憩,看水面与老码头方向",
"⑤ 出口前土产段 15:05-15:40 —— 顺路买点海产干货或糕点带走",
"地图连线顺序即为 ①→⑤,用于生成点线路线图",
"如某个点位封闭或施工,直接跳过并把时间匀给 ③④"
],
"map_marker_ids": ["m_bengcha_gate", "m_bengcha_center", "m_bengcha_alley", "m_bengcha_water", "m_bengcha_shop"]
},
{
"id": "c_bathhouse",
"anchor_id": "bathhouse",
"day": 1,
"title": "下午浴场 · 温泉替代",
"subtitle": "17:00 抵达(硬时间点)",
"image": {
"url": "../assets/photos/rudong_bathhouse.jpg",
"kind": "ai_illustration",
"caption": "温泉汤池意象",
"disclaimer": "AI 示意图,非实景"
},
"badges": [
{ "text": "已触发降级", "tone": "warn" },
{ "text": "需核实", "tone": "danger" }
],
"fields": [
{ "label": "降级说明", "value": "如东本地无传统大型浴场,本条按规则降级为温泉 / 大型洗浴中心(营业面积大、本地评价量级高)" },
{ "label": "候选", "value": { "text": "沿海温泉 / 度假区方向的大型汤池(候选)", "confidence": "low", "verify": "营业状态、是否需预约、当日是否开放,出发前电话确认" } },
{ "label": "免费停车", "value": { "text": "度假区类通常有免费停车,但需确认", "confidence": "low", "verify": "电话确认是否免费、是否对非住客开放" } },
{ "label": "人均", "value": { "text": "约 150-200 元 / 次(温泉类普遍高于市区洗浴)", "confidence": "low", "verify": "以当日价目为准" } },
{ "label": "卫生依据", "value": { "text": "看本地评价中卫生关键词、开业年限与翻新时间;不空口说‘干净’", "confidence": "low", "verify": "出发前看最新评价" } },
{ "label": "建议时长", "value": "2-2.5 小时(17:10-19:30),长时间泡汤易累,之后还要开车" },
{ "label": "自带物品", "value": "毛巾、拖鞋、洗护、换洗衣物、密封袋装湿衣、少量现金" }
],
"bullets": [
"硬时间点 17:00 是为了给 19:45 的补给和 22:00 的车宿留余量",
"泡完不要立刻长时间开车,建议先吃点东西再走",
"若当日温泉闭馆,备选:市区规模较大的洗浴中心,或直接跳过本条改早到车宿点"
],
"map_marker_ids": ["m_bathhouse"]
},
{
"id": "c_grocery",
"anchor_id": "grocery",
"day": 1,
"title": "大卖场补给",
"subtitle": "19:45-20:25 · 买次日早餐",
"badges": [
{ "text": "店名待核实", "tone": "danger" }
],
"fields": [
{ "label": "首选", "value": { "text": "县城掘港镇大型超市 / 卖场(连锁品牌优先)", "confidence": "low", "verify": "店名与营业时间用地图核实;本样例不编造店名" } },
{ "label": "营业时间", "value": { "text": "大型卖场通常到 21:30-22:00,需确认", "confidence": "low", "verify": "出发前查当日营业时间" } },
{ "label": "停车", "value": { "text": "大卖场自带停车场,通常免费 1-2 小时", "confidence": "medium", "verify": "以现场公示为准" } },
{ "label": "购物清单", "value": "常温牛奶(小包装)、面包 / 包子、鸡蛋或卤蛋、水果、瓶装水 2L、湿巾、垃圾袋" },
{ "label": "牛奶提示", "value": "车内过夜无冷藏,优先常温奶或小包装,避免大瓶冷藏奶;看清保质期与是否需要冷藏" }
],
"bullets": [
"顺手补:纸巾、驱蚊液、车载垃圾袋、备用水",
"不要买需要冷藏的熟食,夜间车内温度不可控"
],
"map_marker_ids": ["m_grocery"]
},
{
"id": "c_car_sleep",
"anchor_id": "car_sleep",
"day": 1,
"title": "车宿过夜停车点",
"subtitle": "不晚于 22:00 抵达(硬时间点)",
"image": {
"url": "../assets/photos/rudong_carsleep_park.jpg",
"kind": "ai_illustration",
"caption": "夜间城市公园停车场意象",
"disclaimer": "AI 示意图,非实景"
},
"badges": [
{ "text": "禁停风险 中", "tone": "warn" },
{ "text": "需核实", "tone": "danger" }
],
"fields": [
{ "label": "首选", "value": { "text": "县城公园类市政停车场(市中心、空气好、有公厕)", "confidence": "low", "verify": "过夜是否被劝离,以现场标识与当地执法口径为准" } },
{ "label": "卫生间", "value": { "text": "公园公厕白天开放;夜间是否开放需现场确认", "confidence": "low", "verify": "到场后先确认最近公厕位置与是否上锁" } },
{ "label": "照明", "value": { "text": "市政公园停车场通常有路灯,但部分区域后半夜会关灯", "confidence": "medium", "verify": "到场后选有常亮路灯的位置停" } },
{ "label": "早锻炼人群", "value": { "text": "5-6 点公园通常已有晨练人群,安全性反而更好", "confidence": "medium", "verify": "现场观察" } },
{ "label": "备选点", "value": "① 高速服务区(最稳,但离市区远)② 24 小时加油站(次稳)③ 收费营地 / 房车营地(花钱买安心)" },
{ "label": "风险等级", "value": { "text": "中 —— 市政公园对过夜停车普遍不明确表态,存在夜间劝离可能", "confidence": "low", "verify": "到场看标识;若被劝离直接切服务区备选" } },
{ "label": "本次到达", "value": "21:10 抵达,早于硬点(不晚于 22:00),属提前到位,不算漂移" }
],
"bullets": [
"车宿安全:靠路灯停、避开主通道与出入口、留 2-3cm 通风缝、不长时间怠速开空调",
"贵重物品不外露,前排不要放包;垃圾自己带走,不留痕迹",
"停车位置避开低洼与树下(秋季落叶堵塞排水、潮气重)"
],
"map_marker_ids": ["m_car_sleep"]
},
{
"id": "c_morning_walk",
"anchor_id": "morning_walk",
"day": 2,
"title": "晨间散步 · 05:00",
"subtitle": "公园环线约 3 km",
"badges": [
{ "text": "硬时间点", "tone": "info" },
{ "text": "天未全亮", "tone": "warn" }
],
"fields": [
{ "label": "日出时间", "value": { "text": "2026-09-19/20 如东日出约 05:35,05:00 起床时天为黎明微光", "confidence": "medium", "verify": "出行前用天气 App 核对当日日出时间" } },
{ "label": "路线", "value": "从车宿点出发绕公园主环线一圈(不走机动车道,不远离停车位)" },
{ "label": "距离 / 用时", "value": "约 3 km / 60-70 分钟(含拉伸)" },
{ "label": "照明", "value": { "text": "公园主路有路灯,支路可能关灯", "confidence": "medium", "verify": "到场确认;建议带头灯或手机照明" } },
{ "label": "公厕", "value": { "text": "公园公厕位置到场确认,作为洗漱点备选", "confidence": "low", "verify": "现场确认" } }
],
"bullets": [
"05:00 天没亮,穿反光条或亮色外套更安全",
"晨走就是当天的‘第一次热身’,为 08:15 的第二段徒步做准备"
],
"map_marker_ids": ["m_car_sleep", "m_morning_park"]
},
{
"id": "c_breakfast_wash",
"anchor_id": "breakfast_wash",
"day": 2,
"title": "早饭 + 洗漱",
"subtitle": "06:15-07:10",
"badges": [
{ "text": "开门时间待核实", "tone": "warn" }
],
"fields": [
{ "label": "方案 A · 店内吃", "value": { "text": "县城本地早点铺(面条 / 汤包 / 烧饼类)", "confidence": "low", "verify": "5-6 点是否开门需现场或电话确认;很多面馆 06:30 后才开" } },
{ "label": "方案 B · 车内吃", "value": "昨晚大卖场买的常温奶 + 面包 + 卤蛋,最稳妥,不依赖店铺开门" },
{ "label": "洗漱点·首选", "value": { "text": "公园公厕(免费,需确认是否上锁)", "confidence": "low", "verify": "现场确认" } },
{ "label": "洗漱点·备选 1", "value": { "text": "24 小时加油站卫生间(多数可借用)", "confidence": "medium", "verify": "看店内标识" } },
{ "label": "洗漱点·备选 2", "value": { "text": "连锁健身房单次体验卡(花钱但热水稳定)", "confidence": "low", "verify": "当地是否有门店需核实" } }
],
"bullets": [
"建议顺序:先晨走 → 洗漱 → 早饭,避免吃完再运动",
"自备:漱口水、湿巾、免洗洗手液、便携毛巾(公厕不一定有热水)"
],
"map_marker_ids": ["m_breakfast"]
},
{
"id": "c_hike_2",
"anchor_id": "hike_2",
"day": 2,
"title": "第二段徒步 · 沿海林带",
"subtitle": "1.5 小时 · 与昨日不重复",
"image": {
"url": "../assets/photos/rudong_hike2_forest.jpg",
"kind": "ai_illustration",
"caption": "沿海林带林道意象",
"disclaimer": "AI 示意图,非实景"
},
"badges": [
{ "text": "已触发地形降级", "tone": "warn" },
{ "text": "免费停车待核实", "tone": "danger" }
],
"fields": [
{ "label": "起点 / 终点", "value": { "text": "沿海林带 / 河道绿廊入口 → 纵深林道折返", "confidence": "low", "verify": "林带开放情况与出入口位置需核实" } },
{ "label": "折算里程", "value": "约 6.5 km(林道平路,1.5 小时口径)" },
{ "label": "累计爬升", "value": "约 0 m(平路 + 林道)" },
{ "label": "路面类型", "value": "林地土路 / 碎石路,树荫好、比昨天晒得少" },
{ "label": "停车", "value": { "text": "林带入口路边空地(尽量免费)", "confidence": "low", "verify": "是否允许停车需现场确认" } },
{ "label": "与第一段的区别", "value": "第一段是开阔海堤(风大日晒),第二段是林荫林道(凉爽安静),景观与体感差异明显" }
],
"bullets": [
"降级说明同昨日:如东无山,‘山水优先’退化为‘水 + 林’,本段爬升约 0",
"林地秋季蚊虫仍多,出发前喷驱蚊液、穿长裤",
"林道岔路多,建议开轨迹记录,避免绕远"
],
"map_marker_ids": ["m_hike2_start", "m_hike2_end"]
},
{
"id": "c_photo_spots",
"anchor_id": "photo_spots",
"day": 2,
"title": "打卡点 · 1-2 个",
"subtitle": "10:00-11:20 · 人文优先",
"badges": [
{ "text": "点位待核实", "tone": "danger" }
],
"fields": [
{ "label": "候选 1(人文)", "value": { "text": "海堤 / 范公堤类人文点 —— 沿海堤防历史线索,看的是‘人与海的关系’", "confidence": "low", "verify": "具体点位、是否有标识与开放说明需核实" } },
{ "label": "候选 2(人文/街区)", "value": { "text": "古镇内人文点位(故居 / 纪念馆类)—— 如开放则优先,未开放则跳过", "confidence": "low", "verify": "开放时间与门票需核实" } },
{ "label": "开放时间 / 门票", "value": { "text": "均为待核实项", "confidence": "low", "verify": "出发前查当地文旅公告" } },
{ "label": "停车", "value": "沿用林带入口或就近路侧泊位" },
{ "label": "去重要求", "value": "已排除昨天古镇动线里的 5 个点位,避免重复" }
],
"bullets": [
"本条不追求名气,追求‘有典故、看得到东西’",
"若两个候选都不可达,宁可把时间留给午饭,不要硬凑景点"
],
"map_marker_ids": ["m_spot1"]
},
{
"id": "c_lunch_d2",
"anchor_id": "lunch_d2",
"day": 2,
"title": "第二天午饭 · 县城家常菜",
"subtitle": "11:30-12:40 · 优先能停车",
"badges": [
{ "text": "店名待核实", "tone": "danger" },
{ "text": "与昨天不同店型", "tone": "info" }
],
"fields": [
{ "label": "候选 1", "value": { "text": "县城本地家常菜老店 —— 做本地菜、中午本地人多、门口可停车", "confidence": "low", "verify": "店名与实际停车条件需现场核实" } },
{ "label": "候选 2", "value": { "text": "县城面馆 / 小炒(备选)—— 出餐快、便于下午长途驾驶", "confidence": "low", "verify": "同上" } },
{ "label": "判断依据", "value": "非游客区、本地客占比高、开店年限长、菜品以本地食材为主" },
{ "label": "停车", "value": { "text": "优先选门口能停的;不能停则用就近社会停车场", "confidence": "medium", "verify": "现场确认" } },
{ "label": "单人怎么点", "value": "一菜一汤 + 米饭;若下午要开 3 小时车,避免过饱与饮酒" }
],
"bullets": [
"与昨天的区别:昨天是古镇海鲜小馆,今天是县城家常菜,避免两天吃同一类型",
"出发前先把返程导航设好,吃完直接走"
],
"map_marker_ids": ["m_lunch2"]
},
{
"id": "c_drive_back",
"anchor_id": "drive_back",
"day": 2,
"title": "返程 · 双版本自驾",
"subtitle": "如东 → 上海宝山富锦路地铁站",
"badges": [
{ "text": "双版本", "tone": "info" },
{ "text": "下午高峰", "tone": "warn" }
],
"fields": [
{ "label": "高速版", "value": { "text": "170 km / 约 2.6 小时 / 过路费约 85 元", "confidence": "medium", "verify": "出发前用导航核对实时路况" } },
{ "label": "非高速版", "value": { "text": "162 km / 约 3.5 小时 / 无过路费", "confidence": "low", "verify": "国省道限速与施工以导航为准" } },
{ "label": "加油点", "value": "县城与高速服务区均可加油;建议午饭后就近加满" },
{ "label": "高峰提示", "value": { "text": "周日下午 15:00-20:00 为进城方向高峰,过江通道易缓行", "confidence": "medium", "verify": "出发前看实时路况" } },
{ "label": "建议", "value": "13:00 出发走高速最稳;若想省钱走非高速,务必 12:30 前出发" }
],
"bullets": [
"返程前检查:胎压、玻璃水、油量/电量、车内垃圾清空",
"疲劳管理:每 1.5-2 小时进一次服务区,下车走 5 分钟"
],
"map_marker_ids": ["m_lunch2", "m_way_nantong", "m_sh_end"]
}
],
"map": {
"center": [31.95, 121.15],
"zoom": 9,
"disclaimer": "点位顺序示意,非真实道路/轨迹;坐标多为概略值",
"markers": [
{ "id": "m_sh_start", "name": "上海宝山富锦路地铁站(出发)", "kind": "start", "lat": 31.376, "lng": 121.414, "coord_precision": "approx", "day": 1, "seq": 1 },
{ "id": "m_way_sutong", "name": "过江通道方向", "kind": "waypoint", "lat": 31.72, "lng": 120.95, "coord_precision": "approx", "day": 1, "seq": 2 },
{ "id": "m_way_nantong", "name": "南通方向拐点", "kind": "waypoint", "lat": 31.98, "lng": 120.89, "coord_precision": "approx", "day": 1, "seq": 3 },
{ "id": "m_hike1_start", "name": "第一段徒步起点(堤岸)", "kind": "hike", "lat": 32.50, "lng": 121.10, "coord_precision": "approx", "day": 1, "seq": 4 },
{ "id": "m_hike1_end", "name": "第一段徒步终点(闸口)", "kind": "hike", "lat": 32.47, "lng": 121.06, "coord_precision": "approx", "day": 1, "seq": 5 },
{ "id": "m_bengcha_park", "name": "古镇停车点", "kind": "parking", "lat": 32.556, "lng": 120.946, "coord_precision": "approx", "day": 1, "seq": 6, "note": "免费与否待核实" },
{ "id": "m_bengcha_gate", "name": "古镇入口牌坊", "kind": "district", "lat": 32.554, "lng": 120.949, "coord_precision": "approx", "day": 1, "seq": 7 },
{ "id": "m_bengcha_center", "name": "古镇主街", "kind": "district", "lat": 32.552, "lng": 120.952, "coord_precision": "approx", "day": 1, "seq": 8 },
{ "id": "m_bengcha_alley", "name": "巷弄纵深", "kind": "district", "lat": 32.550, "lng": 120.955, "coord_precision": "approx", "day": 1, "seq": 9 },
{ "id": "m_bengcha_water", "name": "水岸 / 桥头", "kind": "district", "lat": 32.548, "lng": 120.958, "coord_precision": "approx", "day": 1, "seq": 10 },
{ "id": "m_bengcha_shop", "name": "出口土产段", "kind": "district", "lat": 32.546, "lng": 120.960, "coord_precision": "approx", "day": 1, "seq": 11 },
{ "id": "m_lunch_d1_alt", "name": "午饭候选 3(需挪车)", "kind": "food", "lat": 32.558, "lng": 120.943, "coord_precision": "approx", "day": 1, "seq": 12 },
{ "id": "m_bathhouse", "name": "温泉 / 洗浴中心(候选)", "kind": "bathhouse", "lat": 32.53, "lng": 121.03, "coord_precision": "approx", "day": 1, "seq": 13 },
{ "id": "m_grocery", "name": "浴场旁超市(同商圈)", "kind": "shop", "lat": 32.534, "lng": 121.038, "coord_precision": "approx", "day": 1, "seq": 14, "note": "与浴场共用停车点" },
{ "id": "m_car_sleep", "name": "车宿停车点(候选)", "kind": "sleep", "lat": 32.312, "lng": 121.196, "coord_precision": "approx", "day": 1, "seq": 15 },
{ "id": "m_morning_park", "name": "公园晨走环线", "kind": "walk", "lat": 32.314, "lng": 121.199, "coord_precision": "approx", "day": 2, "seq": 16 },
{ "id": "m_breakfast", "name": "早点铺(候选)", "kind": "breakfast", "lat": 32.309, "lng": 121.193, "coord_precision": "approx", "day": 2, "seq": 17 },
{ "id": "m_hike2_start", "name": "第二段徒步起点(林带)", "kind": "hike", "lat": 32.45, "lng": 121.25, "coord_precision": "approx", "day": 2, "seq": 18 },
{ "id": "m_hike2_end", "name": "第二段徒步纵深处", "kind": "hike", "lat": 32.43, "lng": 121.30, "coord_precision": "approx", "day": 2, "seq": 19 },
{ "id": "m_spot1", "name": "人文打卡点(候选)", "kind": "spot", "lat": 32.47, "lng": 121.15, "coord_precision": "approx", "day": 2, "seq": 20 },
{ "id": "m_lunch2", "name": "第二天午饭(候选)", "kind": "food", "lat": 32.30, "lng": 121.18, "coord_precision": "approx", "day": 2, "seq": 21 },
{ "id": "m_sh_end", "name": "返回上海", "kind": "end", "lat": 31.23, "lng": 121.47, "coord_precision": "approx", "day": 2, "seq": 22 }
],
"routes": [
{
"id": "r_drive_out",
"name": "去程(示意)",
"kind": "drive",
"color": "#2f6fed",
"dashed": false,
"marker_ids": ["m_sh_start", "m_way_sutong", "m_way_nantong", "m_hike1_start"],
"note": "点位顺序示意,非真实道路"
},
{
"id": "r_hike_1",
"name": "第一段徒步(示意)",
"kind": "hike",
"color": "#2e9e5b",
"dashed": true,
"marker_ids": ["m_hike1_start", "m_hike1_end"],
"note": "点位顺序示意,非真实轨迹"
},
{
"id": "r_district_walk",
"name": "古镇动线 ①→⑤",
"kind": "stroll",
"color": "#c2701a",
"dashed": false,
"marker_ids": ["m_bengcha_park", "m_bengcha_gate", "m_bengcha_center", "m_bengcha_alley", "m_bengcha_water", "m_bengcha_shop"],
"note": "按打卡点顺序连线,示意"
},
{
"id": "r_morning_walk",
"name": "晨走环线(示意)",
"kind": "stroll",
"color": "#8a5cf6",
"dashed": true,
"marker_ids": ["m_car_sleep", "m_morning_park", "m_breakfast"],
"note": "公园环线示意,非真实步道"
},
{
"id": "r_hike_2",
"name": "第二段徒步(示意)",
"kind": "hike",
"color": "#1f8a70",
"dashed": true,
"marker_ids": ["m_hike2_start", "m_spot1", "m_hike2_end"],
"note": "点位顺序示意,非真实轨迹"
},
{
"id": "r_drive_back",
"name": "返程(示意)",
"kind": "drive",
"color": "#d14343",
"dashed": false,
"marker_ids": ["m_lunch2", "m_way_nantong", "m_sh_end"],
"note": "点位顺序示意,非真实道路"
}
]
},
"tables": [
{
"id": "drive_out_compare",
"title": "去程 · 高速版 vs 非高速版",
"columns": [
{ "key": "version", "label": "版本" },
{ "key": "distance", "label": "里程", "align": "right" },
{ "key": "hours", "label": "预计时长", "align": "right" },
{ "key": "toll", "label": "过路费", "align": "right" },
{ "key": "supply", "label": "加油 / 补能" },
{ "key": "risk", "label": "风险提示" },
{ "key": "fit", "label": "适合" }
],
"rows": [
{ "_key": "hw", "version": "高速版", "distance": "约 170 km", "hours": "约 2.6 h", "toll": "约 85 元", "supply": "服务区加油充足", "risk": "节假日上午过江通道可能排队", "fit": "赶时间 / 首选" },
{ "_key": "nh", "version": "非高速版", "distance": "约 162 km", "hours": "约 3.5 h", "toll": "0 元", "supply": "沿途加油站密集", "risk": "国省道施工、限速变化、穿镇拥堵", "fit": "省钱 / 不赶时间" }
],
"note": "起点:上海宝山富锦路地铁站。里程与时长均为估算示意,未接实时路况;换出发点需重算。",
"highlight_row_keys": ["hw"]
},
{
"id": "drive_back_compare",
"title": "返程 · 高速版 vs 非高速版",
"columns": [
{ "key": "version", "label": "版本" },
{ "key": "distance", "label": "里程", "align": "right" },
{ "key": "hours", "label": "预计时长", "align": "right" },
{ "key": "toll", "label": "过路费", "align": "right" },
{ "key": "supply", "label": "加油 / 补能" },
{ "key": "risk", "label": "风险提示" },
{ "key": "fit", "label": "适合" }
],
"rows": [
{ "_key": "hw", "version": "高速版", "distance": "约 170 km", "hours": "约 2.6 h", "toll": "约 85 元", "supply": "服务区加油充足", "risk": "周日下午 15:00-20:00 进城方向高峰", "fit": "13:00 出发首选" },
{ "_key": "nh", "version": "非高速版", "distance": "约 162 km", "hours": "约 3.5 h", "toll": "0 元", "supply": "沿途加油站密集", "risk": "穿城镇路段多,夜间视距差", "fit": "12:30 前出发" }
],
"note": "终点:上海宝山富锦路地铁站。估算示意,未接实时路况。",
"highlight_row_keys": ["hw"]
},
{
"id": "food_summary",
"title": "三餐与补给一览",
"columns": [
{ "key": "meal", "label": "餐次" },
{ "key": "time", "label": "时间" },
{ "key": "candidates", "label": "候选" },
{ "key": "area", "label": "位置" },
{ "key": "per", "label": "人均", "align": "right" },
{ "key": "parking", "label": "停车" },
{ "key": "conf", "label": "置信度" }
],
"rows": [
{ "_key": "d1", "meal": "D1 午饭", "time": "11:30-12:35", "candidates": "3 家(海鲜小馆 / 家常菜 / 面馆)", "area": "栟茶古镇一带", "per": "约 100 元", "parking": "用街区停车点", "conf": "需核实" },
{ "_key": "d2", "meal": "D2 午饭", "time": "11:30-12:40", "candidates": "2 家(家常菜老店 / 面馆)", "area": "如东县城", "per": "约 90 元", "parking": "优先门口可停", "conf": "需核实" },
{ "_key": "bf", "meal": "D2 早饭", "time": "06:15-07:10", "candidates": "本地早点铺 / 车内补给", "area": "车宿点附近", "per": "约 15 元", "parking": "路边临停", "conf": "需核实" },
{ "_key": "sp", "meal": "夜间补给", "time": "19:40-20:10", "candidates": "浴场旁超市(与浴场一起完成)", "area": "小洋口 / 浴场商圈", "per": "约 60 元", "parking": "与浴场共用", "conf": "需核实" }
],
"note": "本样例不编造店名,只给业态 + 位置描述,店名与人均需现场核实。"
},
{
"id": "parking_summary",
"title": "各节点停车一览",
"columns": [
{ "key": "node", "label": "节点" },
{ "key": "park", "label": "停车点" },
{ "key": "free", "label": "是否免费" },
{ "key": "fee", "label": "预估费用", "align": "right" },
{ "key": "walk", "label": "步行距离" },
{ "key": "risk", "label": "风险" },
{ "key": "conf", "label": "置信度" }
],
"rows": [
{ "_key": "hike1", "node": "第一段徒步", "park": "堤岸起点路侧空地", "free": "待核实", "fee": "0-10 元", "walk": "0 min", "risk": "可能禁停", "conf": "需核实" },
{ "_key": "d1", "node": "古镇街区", "park": "古镇外围泊位 / 镇区公共停车场", "free": "待核实(不得默认免费)", "fee": "0-10 元/次", "walk": "≤5 min", "risk": "周末或临时管制", "conf": "需核实" },
{ "_key": "bath", "node": "温泉 / 洗浴", "park": "度假区停车场", "free": "待核实", "fee": "0-10 元", "walk": "≤3 min", "risk": "非住客是否可停", "conf": "需核实" },
{ "_key": "shop", "node": "浴场旁超市", "park": "与浴场共用停车点", "free": "随浴场", "fee": "0 元", "walk": "≤5 min", "risk": "低", "conf": "需核实" },
{ "_key": "sleep", "node": "车宿点", "park": "公园类市政停车场", "free": "待核实", "fee": "0 元", "walk": "0 min", "risk": "夜间可能被劝离", "conf": "需核实" },
{ "_key": "hike2", "node": "第二段徒步", "park": "林带入口路侧空地", "free": "待核实", "fee": "0-10 元", "walk": "0 min", "risk": "可能禁停", "conf": "需核实" }
],
"note": "免费与否一律不得默认写成免费,需现场看收费公示牌。"
}
],
"cost": {
"currency": "CNY",
"items": [
{ "label": "过路费(往返高速版)", "amount": 170, "unit": "元", "note": "走非高速版则为 0" },
{ "label": "油费(往返约 340 km)", "amount": 205, "unit": "元", "note": "按 8L/100km、7.5 元/L 估" },
{ "label": "D1 午饭", "amount": 100, "unit": "元", "note": "单人" },
{ "label": "D2 午饭", "amount": 90, "unit": "元", "note": "单人" },
{ "label": "温泉 / 洗浴", "amount": 180, "unit": "元", "note": "温泉类估值偏高" },
{ "label": "超市补给(浴场旁)", "amount": 60, "unit": "元", "note": "含次日早餐" },
{ "label": "D2 早饭", "amount": 15, "unit": "元", "note": "若车内吃可省" },
{ "label": "停车费预埋", "amount": 30, "unit": "元", "note": "若全部免费可省" },
{ "label": "杂项(水、零食、湿巾)", "amount": 50, "unit": "元", "note": "浮动项" }
],
"total": 900,
"note": "全部为估算口径,供预算参考;走非高速版可省约 170 元过路费但多约 1 小时。起点为宝山富锦路地铁站,换出发点需重算。"
},
"checklist": [
{ "anchor_id": "district_parking", "field": "free_parking", "text": "古镇停车是否免费、周末是否交通管制", "confidence": "low", "verify": "出发前致电古镇管理方或看地图实景" },
{ "anchor_id": "bathhouse", "field": "bathhouse_grade", "text": "温泉 / 洗浴中心当日是否营业、是否需预约、是否对非住客开放", "confidence": "low", "verify": "出发前电话确认" },
{ "anchor_id": "bathhouse", "field": "hygiene", "text": "温泉卫生情况与规模", "confidence": "low", "verify": "看最新本地评价中的卫生关键词" },
{ "anchor_id": "district_arrive", "field": "open_status", "text": "古镇是否局部施工或围挡、夜间照明情况", "confidence": "medium", "verify": "查当地文旅公告" },
{ "anchor_id": "district_walk", "field": "rest_point", "text": "动线中段可坐下的茶馆 / 咖啡位是否营业、是否有空位", "confidence": "low", "verify": "现场找有座位的店即可,不必指定具体店" },
{ "anchor_id": "car_sleep", "field": "car_sleep_legality", "text": "公园停车场是否允许过夜停车", "confidence": "low", "verify": "以现场标识与当地执法口径为准;被劝离直接切服务区备选" },
{ "anchor_id": "morning_walk", "field": "sunrise_and_facility", "text": "当日日出时间、公园路灯后半夜是否关灯、公厕夜间是否上锁", "confidence": "low", "verify": "出行前查天气 App 的日出时间;到场确认照明与公厕" },
{ "anchor_id": "hike_1", "field": "access", "text": "海堤 / 滩涂段是否允许步行、是否有施工封闭", "confidence": "low", "verify": "现场确认" },
{ "anchor_id": "hike_2", "field": "access", "text": "林带是否开放、出入口位置", "confidence": "low", "verify": "现场确认" },
{ "anchor_id": "grocery", "field": "store_info", "text": "浴场旁超市的位置、约 20 点是否还在营业、能否与浴场共用停车点", "confidence": "low", "verify": "地图或电话核实;同商圈若无超市,按规则就近找并标注" },
{ "anchor_id": "breakfast_wash", "field": "opening", "text": "早点铺 5-6 点是否开门;公园公厕夜间是否上锁", "confidence": "low", "verify": "现场或电话确认" },
{ "anchor_id": "photo_spots", "field": "open_status", "text": "打卡点开放时间与门票", "confidence": "low", "verify": "查当地文旅公告" },
{ "anchor_id": "lunch_d1", "field": "locals_favorite", "text": "午饭 3 家候选的店名、人均与真实口碑", "confidence": "low", "verify": "到店前看最新本地评价" },
{ "anchor_id": "lunch_d2", "field": "locals_favorite", "text": "第二天午饭店名与停车条件", "confidence": "low", "verify": "现场或电话确认" },
{ "anchor_id": "drive_out", "field": "route_data", "text": "去程里程 / 时长 / 过路费为估算值", "confidence": "medium", "verify": "出发前用导航核对" },
{ "anchor_id": "drive_back", "field": "route_data", "text": "返程里程 / 时长 / 过路费为估算值", "confidence": "medium", "verify": "出发前用导航核对" }
],
"notes": [
{
"level": "danger",
"title": "车宿合规风险:中",
"text": "如东县城公园类停车场能否过夜停车并无明确说法,存在夜间被劝离的可能。若被劝离,请直接切换到备选点:高速服务区(最稳)或 24 小时加油站。"
},
{
"level": "warn",
"title": "两段徒步均为平路,爬升约 0",
"text": "如东是平原沿海地区,无山可爬,已触发行程规则中的地形降级。1.5 小时的口径按步速折算里程执行,运动量靠时长与配速来,不靠爬升。若你需要‘爬坡出汗’的强度,这个目的地不合适,建议换丘陵/山地目的地。"
},
{
"level": "warn",
"title": "06:40 出发是底线",
"text": "若 06:40 之后出发,17:00 抵达温泉与 22:00 抵达车宿点都将被压缩。车程偏长时请提前到 06:00-06:30 出发。"
},
{
"level": "warn",
"title": "牛奶与熟食不要冷藏型",
"text": "车内过夜没有冷藏条件,牛奶优先常温奶或小包装,不要买需要冷藏的熟食与鲜奶大瓶装。"
},
{
"level": "info",
"title": "地图与路线为示意图",
"text": "所有点位顺序连线仅用于理解动线关系,不是真实道路或徒步轨迹;坐标多为概略值。"
},
{
"level": "info",
"title": "本样例不编造店名与价格",
"text": "为如实反映信息可信度,餐饮、超市、温泉等未给出编造的店名,只给业态与位置描述,并全部列入『待核实清单』。"
},
{
"level": "info",
"title": "出发点可改,换了就要重算",
"text": "本行程以「上海宝山富锦路地铁站」为起点。换出发点后,去返程的里程、时长、过路费与油费都需重算,不要沿用本页数字。"
},
{
"level": "info",
"title": "补给已并入浴场,不再单独跑大卖场",
"text": "按你的口径,本 Skill 取消了「找大卖场」这条行程:买牛奶与次日早餐改在浴场同商圈的超市顺手完成,与浴场共用停车点、不挪车。"
}
]
}
@@ -0,0 +1,212 @@
/* ============================================================
* tc_export.css —— 游山玩水 · 导出文档样式(只服务 tc_export.js)
*
* 定位:这是【导出的单文件 HTML】用的样式,不在页面上加载。
* tc_export.js 导出时会把本文件全文读出来,内联进 <style>,
* 这样导出的 .html 双击即可离线查看(不依赖网络、不依赖本工具)。
*
* 与 tc_report.css 的关系:互不相干。
* tc_report.css 服务页面内的 tc_render.js(tcr- 前缀);
* 本文件服务导出文档(page/hero/section/card/table 等文档级类名)。
* 两份样式不共享类名、不互相引用,删掉任一份都不影响另一份。
* ============================================================ */
*,*::before,*::after{box-sizing:border-box}
:root{
--bg:#f4f5f7;
--card:#ffffff;
--line:#e6e8ec;
--line-soft:#f0f1f4;
--text:#1f2328;
--sub:#6b7280;
--accent:#2f6fed;
--radius:14px;
}
html{-webkit-text-size-adjust:100%}
body{
margin:0;
background:var(--bg);
color:var(--text);
font:14px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei","PingFang SC","Hiragino Sans GB",sans-serif;
-webkit-font-smoothing:antialiased;
text-rendering:optimizeLegibility;
}
.page{max-width:1100px;margin:0 auto;padding:28px 18px 80px}
/* ---------- HERO / 行程概览 ---------- */
.hero{
position:relative;overflow:hidden;border-radius:18px;padding:30px 30px 26px;
color:#fff;
background:radial-gradient(1100px 460px at 10% -12%,#3f7bf0 0%,#2456c9 44%,#1b3d94 100%);
box-shadow:0 16px 40px rgba(27,61,148,.24);
margin-bottom:18px;
}
.hero::after{
content:"";position:absolute;right:-90px;top:-110px;width:360px;height:360px;border-radius:50%;
background:radial-gradient(circle,rgba(255,255,255,.18),transparent 62%);
pointer-events:none;
}
.hero-inner{position:relative;z-index:1}
.kicker{display:flex;align-items:center;gap:9px;flex-wrap:wrap;font-size:12px;letter-spacing:.08em;color:rgba(255,255,255,.76);margin-bottom:12px}
.kicker .chip{background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.3);border-radius:999px;padding:2px 10px;letter-spacing:.02em;color:#fff}
.hero h1{margin:0 0 9px;font-size:26px;line-height:1.32;letter-spacing:.01em;font-weight:700}
.lede{margin:0 0 16px;max-width:780px;font-size:14px;line-height:1.75;color:rgba(255,255,255,.87)}
.hero-tags{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:18px}
.hero-tags span{font-size:12px;padding:3px 11px;border-radius:999px;background:rgba(255,255,255,.13);border:1px solid rgba(255,255,255,.24)}
.hero-meta{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:20px}
.hero-meta .mb{font-size:11.5px;padding:3px 10px;border-radius:999px;background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.2);color:rgba(255,255,255,.94)}
.hero-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px}
.hstat{background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.18);border-radius:12px;padding:12px 14px}
.hstat .l{font-size:11.5px;color:rgba(255,255,255,.72)}
.hstat .v{font-size:17px;font-weight:700;margin:4px 0 3px;font-variant-numeric:tabular-nums}
.hstat .v.sm{font-size:14px;line-height:1.5}
.hstat .h{font-size:11px;line-height:1.55;color:rgba(255,255,255,.66)}
/* ---------- 目录 ---------- */
.index{
position:sticky;top:10px;z-index:30;
display:flex;flex-wrap:wrap;gap:6px;padding:9px;margin-bottom:18px;
background:rgba(255,255,255,.9);
-webkit-backdrop-filter:saturate(180%) blur(10px);
backdrop-filter:saturate(180%) blur(10px);
border:1px solid var(--line);border-radius:14px;
box-shadow:0 6px 18px rgba(15,23,42,.06);
}
.index a{font-size:12.5px;text-decoration:none;color:#2f6fed;background:#f0f4ff;padding:4px 10px;border-radius:8px;transition:background .15s,color .15s}
.index a:hover{background:#e2eaff}
/* ---------- 区块 ---------- */
.section{
background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
padding:20px 20px 22px;margin-bottom:16px;box-shadow:0 1px 2px rgba(16,24,40,.03);
}
.sec-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:16px}
.sec-title{display:flex;align-items:center;gap:9px;font-size:16px;font-weight:700;margin:0;letter-spacing:.01em}
.sec-title::before{content:"";width:4px;height:17px;border-radius:3px;background:linear-gradient(180deg,#2f6fed,#5b8df6)}
.sec-top{font-size:12px;color:var(--sub);text-decoration:none;white-space:nowrap;transition:color .15s}
.sec-top:hover{color:var(--accent)}
.empty{color:var(--sub);font-size:13px;padding:8px 0}
/* ---------- 提示块 ---------- */
.note{border-radius:10px;padding:12px 14px;margin-bottom:10px;border:1px solid}
.note:last-child{margin-bottom:0}
.note-title{font-weight:600;font-size:13.5px;margin-bottom:3px}
.note-text{font-size:13px;line-height:1.72;color:#374151}
.note-warn{background:#fffaf0;border-color:#f0dcae}
.note-info{background:#f2f7ff;border-color:#cfe0ff}
.note-danger{background:#fff5f4;border-color:#f5c9c3}
/* ---------- 表格 ---------- */
.tablebox{overflow-x:auto;-webkit-overflow-scrolling:touch}
.table-title{font-size:13px;font-weight:600;margin-bottom:9px;color:#374151}
table.tbl{width:100%;border-collapse:collapse;font-size:13px;background:#fff}
table.tbl th,table.tbl td{border:1px solid var(--line);padding:8px 10px;text-align:left;vertical-align:top}
table.tbl thead th{background:#f7f8fa;font-weight:600;white-space:nowrap;color:#374151}
table.tbl tbody tr:hover td{background:#fafbfc}
table.tbl tbody tr.row-hl td{background:#eef4ff}
table.tbl tbody tr.row-hard td{background:#fff7ed}
table.tbl tfoot td{font-weight:700;background:#f2f6ff;font-size:13.5px}
.col-time{white-space:nowrap;color:#374151;width:104px;font-variant-numeric:tabular-nums}
.col-kind{width:150px}
.row-title{font-weight:500}
.al-right{text-align:right !important;font-variant-numeric:tabular-nums}
.cell-note{color:var(--sub);font-size:12px}
.tbl-note{font-size:12px;color:var(--sub);margin-top:9px}
/* ---------- 类型标签 ---------- */
.kind{display:inline-block;font-size:11px;padding:1px 8px;border-radius:999px;background:#f3f4f6;color:#6b7280;white-space:nowrap}
.kind-act{background:#eef2ff;color:#4f46e5;margin-left:4px}
.kind-hard{background:#fdece8;color:#b42318;font-weight:600}
/* ---------- 置信度徽标 ---------- */
.badge{display:inline-block;font-size:11px;padding:1px 8px;border-radius:999px;background:#eef2f7;color:#4b5563;white-space:nowrap;vertical-align:1px}
.badge-high{background:#e8f6ee;color:#1a7a45}
.badge-medium{background:#fff5e0;color:#8a6100}
.badge-low{background:#fdece8;color:#b42318}
.badge-info{background:#e8f0fe;color:#1d4ed8}
.badge-good{background:#e8f6ee;color:#1a7a45}
.badge-warn{background:#fff5e0;color:#8a6100}
.badge-danger{background:#fdece8;color:#b42318}
/* ---------- 时序 ---------- */
.day{margin-bottom:18px}
.day:last-child{margin-bottom:0}
.day-head{display:flex;align-items:baseline;gap:8px;margin-bottom:7px}
.day-label{font-weight:700;font-size:14px}
.day-date{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}
/* ---------- 卡片 ---------- */
.group{margin-bottom:18px}
.group:last-child{margin-bottom:0}
.group-title{display:flex;align-items:center;gap:9px;font-size:14px;font-weight:700;color:#374151;margin:0 0 11px}
.group-title::after{content:"";flex:1;height:1px;background:var(--line-soft)}
.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:14px}
.card{border:1px solid var(--line);border-radius:12px;padding:14px 15px 15px;background:#fff;transition:box-shadow .18s,border-color .18s}
.card:hover{box-shadow:0 8px 24px rgba(16,24,40,.08);border-color:#d7dce5}
.card-title{font-size:14.5px;font-weight:700;margin:0;line-height:1.45}
.card-sub{font-size:12px;color:var(--sub);margin-top:2px}
.card-badges{display:flex;flex-wrap:wrap;gap:5px;margin-top:7px}
.fields{margin-top:11px}
.field{display:grid;grid-template-columns:98px 1fr;gap:8px;padding:7px 0;border-top:1px dashed #eceef2}
.field:first-child{border-top:0;padding-top:0}
.field-label{color:var(--sub);font-size:12px;line-height:1.6}
.field-value{font-size:13px;line-height:1.66}
.field-hint{font-size:11px;color:var(--sub);margin-top:3px;line-height:1.55}
.fact-text{margin-right:4px}
.verify{display:block;font-size:11px;color:#8a6100;margin-top:3px;line-height:1.55}
.bullets{margin:11px 0 0;padding-left:19px;font-size:13px}
.bullets li{margin-bottom:4px}
.bullets li::marker{color:#9aa3af}
.card figure{margin:9px 0 0}
.card figure img{width:100%;height:auto;display:block;border-radius:9px;border:1px solid var(--line)}
.figcap{font-size:11px;color:var(--sub);margin-top:6px;line-height:1.55}
.img-missing{font-size:12px;color:var(--sub);background:#f7f8fa;border:1px dashed var(--line);border-radius:9px;padding:14px;text-align:center}
/* ---------- 点位与路线 ---------- */
.map-wrap{background:#fafbfc;border:1px solid var(--line);border-radius:12px;padding:16px 14px}
.map-svg{width:100%;height:auto;display:block}
.legend{display:flex;flex-wrap:wrap;gap:14px;margin-top:14px;font-size:12px;color:#374151}
.legend-item{display:inline-flex;align-items:center;gap:6px}
.swatch{width:20px;height:4px;border-radius:2px;display:inline-block}
.map-note{font-size:12px;color:var(--sub);margin-top:10px}
.points{display:grid;grid-template-columns:repeat(auto-fill,minmax(228px,1fr));gap:7px;margin-top:15px;border-top:1px dashed #e9ebef;padding-top:15px}
.point{display:flex;align-items:center;gap:8px;font-size:12.5px;color:#374151}
.point .num{flex:none;width:20px;height:20px;border-radius:50%;color:#fff;font-size:10.5px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;font-variant-numeric:tabular-nums}
/* ---------- 待核实清单 ---------- */
.checklist{margin:0;padding:0;list-style:none;counter-reset:c}
.check-item{counter-increment:c;position:relative;padding:10px 0 10px 36px;border-top:1px dashed #eceef2;font-size:13px;line-height:1.65}
.check-item:first-child{border-top:0;padding-top:4px}
.check-item::before{
content:counter(c);position:absolute;left:0;top:11px;width:22px;height:22px;border-radius:50%;
background:#eef2ff;color:#2f6fed;font-size:11px;font-weight:700;
display:flex;align-items:center;justify-content:center;font-variant-numeric:tabular-nums;
}
.check-item:first-child::before{top:5px}
.check-text{margin-right:8px}
/* ---------- 页脚 ---------- */
.doc-foot{text-align:center;font-size:12px;color:var(--sub);padding:22px 0 0;line-height:1.8}
/* ---------- 响应式 ---------- */
@media (max-width:640px){
.page{padding:16px 12px 60px}
.hero{padding:22px 18px 20px;border-radius:14px}
.hero h1{font-size:21px}
.lede{font-size:13.5px}
.card-grid{grid-template-columns:1fr}
.field{grid-template-columns:84px 1fr}
.sec-title{font-size:15px}
.index{top:0;border-radius:0;margin:0 -12px 16px;border-left:0;border-right:0}
}
/* ---------- 打印 ---------- */
@media print{
body{background:#fff}
.page{max-width:none;padding:0}
.index{display:none}
.section,.card,.hero,.note{box-shadow:none}
.card,.section,.note,.day{break-inside:avoid}
.hero{background:#1b3d94 !important;-webkit-print-color-adjust:exact;print-color-adjust:exact}
table.tbl{font-size:11.5px}
}
@@ -0,0 +1,731 @@
/* ============================================================
* tc_export.js —— 游山玩水 · 单文件 HTML 导出器
*
* 干什么:把后端已经生成好的 payload + 技能包的 report_spec,在【浏览器本地】
* 拼成一份完整的、自包含的 .html 文档并下载。纯前端、零 AI 调用,
* 导出一次不消耗任何 token(就是「我不想为了再看一遍再烧 4 分钟」)。
*
* 怎么做到「单文件、离线可看」:
* 1. 样式内联:把同目录 tc_export.css 全文读出来塞进 <style>,不引用外部 css。
* 2. 图片内嵌:卡片的配图就地 fetch 成 base64 data URI 塞进 <img>,不引用外部图。
* 3. 地图内联:不用 Leaflet(它要联网拉瓦片),改用内置 SVG 画点位顺序示意图。
* → 导出的文件双击即可打开,断网也能看。
*
* 设计原则(与 tc_render.js 一致):
* - 完全独立:不引用工具箱任何其他模块,删除本文件只影响「导出」按钮。
* - 只认 report_spec 的 8 种 block 类型 + 数据路径,不认识任何业务字段。
* - 与 tc_render.js 各写一套(一个是建 DOM 给页面看,一个是拼字符串给文件看),
* 互不依赖:tc_render.js 挂了,导出照样能用。
*
* 依赖:无。tc_export.css 由本文件在导出时读取并内联。
* ============================================================ */
;(function () {
'use strict'
/* ---------------- 常量 ---------------- */
var DEFAULT_BADGE = { high: '已核实', medium: '待确认', low: '需核实' }
var KIND_LABEL = { hard: '硬时间点', soft: '常规' }
var ACTIVITY_LABEL = { move: '移动', eat: '吃饭', hike: '徒步', shop: '采买', sleep: '过夜', walk: '步行' }
var DEFAULT_MAP_NOTE = '点位顺序示意,非真实道路/轨迹'
var MAX_IMAGE_BYTES = 5 * 1024 * 1024
var PIN_COLORS = {
start: '#2f6fed', end: '#d14343', waypoint: '#8a8f98', parking: '#c2701a',
hike: '#2e9e5b', district: '#b8860b', food: '#e0673a', bathhouse: '#1f8a70',
shop: '#5a67d8', sleep: '#2d3748', walk: '#8a5cf6', breakfast: '#c98a00', spot: '#0ea5e9'
}
// 本脚本所在目录:用来定位同目录的 tc_export.css
// 必须在「脚本加载时」取(异步回调里 document.currentScript 已经是 null)
var SELF_DIR = (function () {
try {
if (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) {
return String(document.currentScript.src).replace(/[^/]*$/, '')
}
} catch (e) {}
return ''
})()
/* ---------------- 基础工具 ---------------- */
function esc(s) {
return String(s === undefined || s === null ? '' : s)
.replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
})
}
function isArr(v) { return Array.isArray(v) }
function isPlainObject(v) { return !!v && typeof v === 'object' && !isArr(v) }
function resolvePath(root, path) {
if (!path) return root
var p = String(path).replace(/^\$\.?/, '')
if (!p) return root
var cur = root
var keys = p.split('.')
for (var i = 0; i < keys.length; i++) {
if (cur === null || cur === undefined) return undefined
cur = cur[keys[i]]
}
return cur
}
function pickById(arr, id) {
if (!isArr(arr)) return null
for (var i = 0; i < arr.length; i++) {
if (arr[i] && arr[i].id === id) return arr[i]
}
return null
}
function badgeText(spec, level) {
var map = (spec && spec.confidence_badge) || DEFAULT_BADGE
return map[level] || DEFAULT_BADGE[level] || level
}
function pinColor(kind) { return PIN_COLORS[kind] || '#4b5563' }
function dayName(d) {
return d === 1 ? '第一天' : (d === 2 ? '第二天' : (d ? ('第 ' + d + ' 天') : '其他'))
}
function dayNameForMap(d) {
return d === 1 ? '第一天' : (d === 2 ? '第二天' : (d ? ('第 ' + d + ' 天') : ''))
}
function toneClass(tone) {
var map = { info: 'info', warn: 'warn', danger: 'danger', good: 'good', high: 'high', medium: 'medium', low: 'low' }
return map[tone] || ''
}
function absUrl(u, baseUrl) {
try { return new URL(String(u), baseUrl || undefined).href } catch (e) { return String(u) }
}
function nowBeijing() {
try {
return new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })
} catch (e) { return new Date().toLocaleString() }
}
function utf8Length(s) {
try { return new Blob([s]).size } catch (e) { return String(s).length }
}
/* ---------------- 事实 / 置信度 ---------------- */
// fact 可以是纯字符串,也可以是 { text, confidence, verify }
function factHtml(value, spec) {
if (isPlainObject(value)) {
var out = '<span class="fact-text">' + esc(value.text === undefined ? '' : value.text) + '</span>'
if (value.confidence) {
out += '<span class="badge badge-' + esc(value.confidence) + '">' + esc(badgeText(spec, value.confidence)) + '</span>'
}
if (value.verify) out += '<span class="verify">核实:' + esc(value.verify) + '</span>'
return out
}
return '<span class="fact-text">' + esc(value === null || value === undefined ? '—' : value) + '</span>'
}
/* ---------------- 各 block 渲染(返回 HTML 字符串) ---------------- */
function metaBadges(meta) {
var out = []
if (!meta) return out
var keys = Object.keys(meta)
if (!keys.length) return out
if (meta.model) out.push('模型 ' + meta.model)
if (meta.elapsed_ms) out.push('耗时 ' + Math.round(Number(meta.elapsed_ms) / 1000) + 's')
if (Object.prototype.hasOwnProperty.call(meta, 'is_new_destination')) {
out.push(meta.is_new_destination ? '首个新目的地' : '技能包内已有覆盖')
}
var warns = isArr(meta.warnings) ? meta.warnings : null
if (warns) out.push(warns.length ? (warns.length + ' 条校验提醒') : '结构校验通过')
return out
}
function overviewHtml(data, spec, ctx) {
if (!isPlainObject(data)) return '<div class="hero-inner"><div class="empty">无概览数据</div></div>'
var html = '<div class="hero-inner">'
html += '<div class="kicker">' + esc(ctx.kicker) + '<span class="chip">' + esc(ctx.chip) + '</span></div>'
if (data.title) html += '<h1>' + esc(data.title) + '</h1>'
if (data.one_liner) html += '<p class="lede">' + esc(data.one_liner) + '</p>'
if (isArr(data.tags) && data.tags.length) {
html += '<div class="hero-tags">' + data.tags.map(function (t) {
return '<span>' + esc(t) + '</span>'
}).join('') + '</div>'
}
var mbs = metaBadges(ctx.meta)
if (mbs.length) {
html += '<div class="hero-meta">' + mbs.map(function (t) {
return '<span class="mb">' + esc(t) + '</span>'
}).join('') + '</div>'
}
if (isArr(data.stats) && data.stats.length) {
html += '<div class="hero-stats">' + data.stats.map(function (s) {
var v = s.value === undefined || s.value === null ? '' : String(s.value)
return '<div class="hstat">' +
'<div class="l">' + esc(s.label || '') + '</div>' +
'<div class="v' + (v.length > 9 ? ' sm' : '') + '">' + esc(v) + '</div>' +
(s.hint ? '<div class="h">' + esc(s.hint) + '</div>' : '') +
'</div>'
}).join('') + '</div>'
}
html += '</div>'
return html
}
function notesHtml(data, section) {
var list = isArr(data) ? data.slice() : []
if (section && isArr(section.filter_levels) && section.filter_levels.length) {
list = list.filter(function (n) { return section.filter_levels.indexOf(n.level) >= 0 })
}
if (!list.length) return '<div class="empty">无提示项</div>'
return list.map(function (n) {
return '<div class="note note-' + esc(n.level || 'info') + '">' +
(n.title ? '<div class="note-title">' + esc(n.title) + '</div>' : '') +
(n.text ? '<div class="note-text">' + esc(n.text) + '</div>' : '') +
'</div>'
}).join('')
}
function timelineHtml(data) {
if (!isPlainObject(data) || !isArr(data.days)) return '<div class="empty">无时序数据</div>'
return data.days.map(function (day) {
var html = '<div class="day">'
html += '<div class="day-head"><span class="day-label">' + esc(day.label || '') + '</span>' +
(day.date ? '<span class="day-date">' + esc(day.date) + '</span>' : '') + '</div>'
html += '<div class="tablebox"><table class="tbl"><thead><tr>' +
'<th class="col-time">时间</th><th>安排</th><th class="col-kind">类型</th>' +
'</tr></thead><tbody>'
;(isArr(day.rows) ? day.rows : []).forEach(function (r) {
var hard = r.kind === 'hard'
html += '<tr' + (hard ? ' class="row-hard"' : '') + '>'
html += '<td class="col-time">' + esc(r.time || '') + '</td>'
html += '<td><div class="row-title">' + esc(r.title || '') + '</div>' +
(r.note ? '<div class="cell-note">' + esc(r.note) + '</div>' : '') + '</td>'
html += '<td class="col-kind">' +
'<span class="kind' + (hard ? ' kind-hard' : '') + '">' +
esc(KIND_LABEL[r.kind] || r.kind || '') + '</span>' +
(r.activity ? '<span class="kind kind-act">' + esc(ACTIVITY_LABEL[r.activity] || r.activity) + '</span>' : '') +
'</td>'
html += '</tr>'
})
html += '</tbody></table></div></div>'
return html
}).join('')
}
function cardHtml(card, spec, ctx) {
var id = card.id || card.anchor_id || ''
var html = '<article class="card"' + (id ? ' id="card_' + esc(id) + '"' : '') + '>'
html += '<h4 class="card-title">' + esc(card.title || '') + '</h4>'
if (card.subtitle) html += '<div class="card-sub">' + esc(card.subtitle) + '</div>'
if (isArr(card.badges) && card.badges.length) {
html += '<div class="card-badges">' + card.badges.map(function (b) {
var tone = toneClass(b.tone)
return '<span class="badge' + (tone ? ' badge-' + tone : '') + '">' + esc(b.text || '') + '</span>'
}).join('') + '</div>'
}
if (isPlainObject(card.image) && card.image.url) {
var raw = String(card.image.url)
var embedded = ctx.images && ctx.images[raw]
var src = embedded || absUrl(raw, ctx.baseUrl)
var cap = card.image.caption || ''
var dis = card.image.disclaimer || (spec && spec.image_disclaimer_default) || ''
html += '<figure>'
if (embedded) {
html += '<img src="' + esc(src) + '" alt="' + esc(cap) + '" />'
} else {
html += '<div class="img-missing">配图未内嵌:' + esc(raw) + '</div>'
}
html += '<figcaption class="figcap">' + (cap ? esc(cap) + ' · ' : '') + esc(dis) + '</figcaption>'
html += '</figure>'
}
if (isArr(card.fields) && card.fields.length) {
html += '<div class="fields">' + card.fields.map(function (f) {
return '<div class="field">' +
'<div class="field-label">' + esc(f.label || '') + '</div>' +
'<div class="field-value">' + factHtml(f.value, spec) +
(f.hint ? '<div class="field-hint">' + esc(f.hint) + '</div>' : '') +
'</div></div>'
}).join('') + '</div>'
}
if (isArr(card.bullets) && card.bullets.length) {
html += '<ul class="bullets">' + card.bullets.map(function (b) {
return '<li>' + esc(b) + '</li>'
}).join('') + '</ul>'
}
html += '</article>'
return html
}
function cardsHtml(data, spec, section, ctx) {
var list = isArr(data) ? data : []
if (!list.length) return '<div class="empty">无节点数据</div>'
var dayOf = function (c) { return c.day === undefined || c.day === null ? 0 : Number(c.day) }
if (!(section && section.group_by === 'day')) {
return '<div class="card-grid">' + list.map(function (c) { return cardHtml(c, spec, ctx) }).join('') + '</div>'
}
var days = []
list.forEach(function (c) {
var d = dayOf(c)
if (days.indexOf(d) < 0) days.push(d)
})
days.sort(function (a, b) { return a - b })
return days.map(function (d) {
return '<div class="group"><h3 class="group-title">' + esc(dayName(d)) + '</h3>' +
'<div class="card-grid">' +
list.filter(function (c) { return dayOf(c) === d })
.map(function (c) { return cardHtml(c, spec, ctx) }).join('') +
'</div></div>'
}).join('')
}
function tableHtml(data, section) {
var table = null
if (isArr(data)) {
table = pickById(data, section && section.pick_id)
if (!table && section && section.pick_id) return '<div class="empty">缺少表格:' + esc(section.pick_id) + '</div>'
if (!table) table = data[0]
} else {
table = data
}
if (!isPlainObject(table)) return '<div class="empty">无表格数据</div>'
var columns = isArr(table.columns) ? table.columns : []
var rows = isArr(table.rows) ? table.rows : []
var hl = isArr(table.highlight_row_keys) ? table.highlight_row_keys : []
var html = '<div class="tablebox">'
if (table.title) html += '<div class="table-title">' + esc(table.title) + '</div>'
html += '<table class="tbl"><thead><tr>'
columns.forEach(function (c) {
html += '<th' + (c.align ? ' class="al-' + esc(c.align) + '"' : '') +
(c.width ? ' style="width:' + esc(c.width) + '"' : '') + '>' + esc(c.label || c.key) + '</th>'
})
html += '</tr></thead><tbody>'
rows.forEach(function (r) {
html += '<tr' + (hl.indexOf(r._key) >= 0 ? ' class="row-hl"' : '') + '>'
columns.forEach(function (c) {
var v = r[c.key]
html += '<td' + (c.align ? ' class="al-' + esc(c.align) + '"' : '') + '>' +
esc(v === null || v === undefined ? '—' : v) + '</td>'
})
html += '</tr>'
})
html += '</tbody></table></div>'
if (table.note) html += '<div class="tbl-note">' + esc(table.note) + '</div>'
return html
}
function costHtml(data) {
if (!isPlainObject(data)) return '<div class="empty">无费用数据</div>'
var items = isArr(data.items) ? data.items : []
var html = '<div class="tablebox"><table class="tbl"><thead><tr>' +
'<th>项目</th><th class="al-right">金额(' + esc(data.currency || 'CNY') + ')</th><th>口径</th>' +
'</tr></thead><tbody>'
items.forEach(function (it) {
html += '<tr><td>' + esc(it.label || '') + '</td>' +
'<td class="al-right">' + esc(it.amount === undefined ? '—' : it.amount) + '</td>' +
'<td class="cell-note">' + esc(it.note || it.unit || '') + '</td></tr>'
})
html += '</tbody>'
if (data.total !== undefined && data.total !== null) {
html += '<tfoot><tr><td>合计</td><td class="al-right">' + esc(data.total) + '</td><td></td></tr></tfoot>'
}
html += '</table></div>'
if (data.note) html += '<div class="tbl-note">' + esc(data.note) + '</div>'
return html
}
function checklistHtml(data, spec) {
var list = isArr(data) ? data : []
if (!list.length) return '<div class="empty">无待核实项</div>'
return '<ol class="checklist">' + list.map(function (c) {
return '<li class="check-item">' +
'<span class="check-text">' + esc(c.text || '') + '</span>' +
(c.confidence ? '<span class="badge badge-' + esc(c.confidence) + '">' + esc(badgeText(spec, c.confidence)) + '</span>' : '') +
(c.verify ? '<span class="verify">核实:' + esc(c.verify) + '</span>' : '') +
'</li>'
}).join('') + '</ol>'
}
/* ---------------- 地图 block(内联 SVG,不联网、不用 Leaflet) ---------------- */
function mapSvgHtml(markers, routes) {
var W = 1000
var PAD = 70
var ROW_H = 176
var dayOf = function (m) { return m.day === undefined || m.day === null ? 0 : Number(m.day) }
var days = []
markers.forEach(function (m) {
var d = dayOf(m)
if (days.indexOf(d) < 0) days.push(d)
})
days.sort(function (a, b) { return a - b })
var hasRowLabel = days.length > 1
var ROW_Y0 = hasRowLabel ? 92 : 62
var pos = {}
var labels = ''
var maxY = ROW_Y0
days.forEach(function (d, rowIdx) {
var rowMarks = markers.filter(function (m) { return dayOf(m) === d })
var y = ROW_Y0 + rowIdx * ROW_H
maxY = y
var step = rowMarks.length > 1 ? (W - PAD * 2) / (rowMarks.length - 1) : 0
rowMarks.forEach(function (m, i) {
pos[m.id] = { x: Math.round((PAD + i * step) * 10) / 10, y: y }
})
if (hasRowLabel) {
labels += '<text x="' + (W / 2) + '" y="' + (y - 50) + '" text-anchor="middle" font-size="12" fill="#6b7280">' +
esc(dayNameForMap(d)) + '</text>'
}
})
var lines = ''
routes.forEach(function (r) {
var ids = isArr(r.marker_ids) ? r.marker_ids : []
for (var i = 0; i < ids.length - 1; i++) {
var a = pos[ids[i]]
var b = pos[ids[i + 1]]
if (!a || !b) continue
if (a.y !== b.y) continue // 跨天不连线,避免斜穿两行
if (a.x === b.x && a.y === b.y) continue
lines += '<line x1="' + a.x + '" y1="' + a.y + '" x2="' + b.x + '" y2="' + b.y + '"' +
' stroke="' + esc(r.color || '#cbd5e1') + '"' +
' stroke-width="' + (r.kind === 'drive' ? 3 : 4) + '"' +
(r.dashed ? ' stroke-dasharray="6 6"' : '') +
' stroke-linecap="round"></line>'
}
})
var nodes = ''
markers.forEach(function (m) {
var p = pos[m.id]
if (!p) return
var seq = m.seq === undefined || m.seq === null ? '' : String(m.seq)
nodes += '<g><title>' + esc((seq ? seq + ' ' : '') + (m.name || '')) + '</title>' +
'<circle cx="' + p.x + '" cy="' + p.y + '" r="13" fill="' + esc(pinColor(m.kind)) +
'" stroke="#fff" stroke-width="2.5"></circle>' +
'<text x="' + p.x + '" y="' + p.y + '" text-anchor="middle" dominant-baseline="central"' +
' font-size="11" font-weight="700" fill="#fff">' + esc(seq) + '</text></g>'
})
var H = maxY + 62
return '<svg class="map-svg" viewBox="0 0 ' + W + ' ' + H + '" role="img" aria-label="行程点位顺序示意图">' +
labels +
(lines ? '<g>' + lines + '</g>' : '') +
(nodes ? '<g>' + nodes + '</g>' : '') +
'</svg>'
}
function mapLegendHtml(routes) {
if (!isArr(routes) || !routes.length) return ''
return '<div class="legend">' + routes.map(function (r) {
var color = r.color || '#4b5563'
var style = r.dashed
? 'background:repeating-linear-gradient(90deg,' + color + ' 0 6px,transparent 6px 12px)'
: 'background:' + color
return '<span class="legend-item"><span class="swatch" style="' + esc(style) + '"></span>' +
esc(r.name || r.id || '') + '</span>'
}).join('') + '</div>'
}
function mapHtml(data, spec) {
if (!isPlainObject(data) || !isArr(data.markers) || !data.markers.length) {
return '<div class="empty">无地图数据</div>'
}
var markers = data.markers.slice().sort(function (a, b) {
var x = a.seq === undefined || a.seq === null ? 9999 : Number(a.seq)
var y = b.seq === undefined || b.seq === null ? 9999 : Number(b.seq)
return x - y
})
var html = '<div class="map-wrap">'
html += mapSvgHtml(markers, isArr(data.routes) ? data.routes : [])
html += mapLegendHtml(data.routes)
html += '<div class="map-note">' +
esc(data.disclaimer || (spec && spec.map_route_disclaimer_default) || DEFAULT_MAP_NOTE) + '</div>'
html += '<div class="points">' + markers.map(function (m) {
var seq = m.seq === undefined || m.seq === null ? '' : String(m.seq)
return '<div class="point"><span class="num" style="background:' + esc(pinColor(m.kind)) + '">' +
esc(seq) + '</span><span>' + esc(m.name || '') + '</span></div>'
}).join('') + '</div>'
html += '</div>'
return html
}
/* ---------------- 顶层组装 ---------------- */
var BLOCKS = {
overview: function (data, spec, section, ctx) { return overviewHtml(data, spec, ctx) },
notes: function (data, spec, section) { return notesHtml(data, section) },
timeline: function (data) { return timelineHtml(data) },
cards: function (data, spec, section, ctx) { return cardsHtml(data, spec, section, ctx) },
map: function (data, spec) { return mapHtml(data, spec) },
table: function (data, spec, section) { return tableHtml(data, section) },
cost: function (data) { return costHtml(data) },
checklist: function (data, spec) { return checklistHtml(data, spec) }
}
function sectionHtml(section, payload, spec, ctx) {
var id = section.id || ('sec_' + section.type)
var data = section.data ? resolvePath(payload, section.data) : payload
var fn = BLOCKS[section.type]
var html = '<section class="section" id="' + esc(id) + '">'
if (section.title) {
html += '<div class="sec-head"><h2 class="sec-title">' + esc(section.title) + '</h2>' +
'<a class="sec-top" href="#top">回到顶部</a></div>'
}
html += (typeof fn === 'function')
? fn(data, spec, section, ctx)
: '<div class="empty">未知 block 类型:' + esc(section.type) + '</div>'
html += '</section>'
return html
}
function indexHtml(items) {
return '<nav class="index">' + items.map(function (it) {
return '<a href="#' + esc(it.id) + '">' + esc(it.title) + '</a>'
}).join('') + '</nav>'
}
function footHtml(ctx) {
var meta = ctx.meta || {}
var bits = ['游山玩水 · 思想实验室']
if (meta.model) bits.push('模型 ' + meta.model)
if (meta.elapsed_ms) bits.push('耗时 ' + Math.round(Number(meta.elapsed_ms) / 1000) + 's')
if (metaBadges(meta).length) {
var warns = isArr(meta.warnings) ? meta.warnings : []
bits.push(warns.length ? (warns.length + ' 条校验提醒') : '结构校验通过')
}
if (ctx.exportedAt) bits.push('导出于 ' + ctx.exportedAt)
return '<div class="doc-foot">' + esc(bits.join(' | ')) + '<br>' +
'本报告内容由模型生成,含标注为「待确认 / 需核实」的信息,出行前请自行核实。</div>'
}
/**
* 把 payload + report_spec 拼成一份完整的单文件 HTML(纯字符串,无副作用)
* @param {object} payload 后端返回的行程数据
* @param {object} spec 技能包的 report_spec
* @param {object} opts { css, images, meta, title, kicker, chip, exportedAt, baseUrl }
* @returns {string} 完整 HTML 文本
*/
function build(payload, spec, opts) {
spec = spec || {}
opts = opts || {}
var sections = isArr(spec.sections) ? spec.sections : []
if (!sections.length) {
throw new Error('report_spec 里没有 sections —— 没有渲染描述就导不出文档')
}
var ctx = {
images: opts.images || {},
meta: opts.meta || {},
baseUrl: opts.baseUrl || '',
kicker: opts.kicker || '思想实验室 · 游山玩水',
chip: opts.chip || '行程报告',
exportedAt: opts.exportedAt || ''
}
var summary = isPlainObject(payload && payload.summary) ? payload.summary : null
var title = opts.title || (summary && summary.title) || '行程报告'
var heroHtml = ''
var bodyParts = []
var indexItems = []
sections.forEach(function (section) {
if (!section || !section.type) return
if (section.title) indexItems.push({ id: section.id || ('sec_' + section.type), title: section.title })
if (section.type === 'overview' && !heroHtml) {
var ovData = section.data ? resolvePath(payload, section.data) : payload
heroHtml = '<header class="hero" id="' + esc(section.id || 'sec_overview') + '">' +
overviewHtml(ovData, spec, ctx) + '</header>'
return
}
bodyParts.push(sectionHtml(section, payload, spec, ctx))
})
// 技能包若没给 overview 区块,就用 payload.summary 兜底一个头图,避免文档没有标题
if (!heroHtml && summary) {
heroHtml = '<header class="hero" id="sec_overview">' + overviewHtml(summary, spec, ctx) + '</header>'
}
var html = '<!DOCTYPE html>\n<html lang="zh-CN">\n<head>\n'
html += '<meta charset="utf-8" />\n'
html += '<meta name="viewport" content="width=device-width, initial-scale=1" />\n'
html += '<title>' + esc(title) + ' · 行程报告</title>\n'
html += '<!-- 由 TRAE-Toolbox · 思想实验室「游山玩水」导出' +
(ctx.exportedAt ? '(' + esc(ctx.exportedAt) + ')' : '') + ',单文件、可离线查看 -->\n'
html += '<style>\n' + (opts.css || '') + '\n</style>\n'
html += '</head>\n<body>\n'
html += '<div class="page" id="top">\n'
if (heroHtml) html += heroHtml + '\n'
if (spec.show_index !== false && indexItems.length) html += indexHtml(indexItems) + '\n'
if (bodyParts.length) html += bodyParts.join('\n') + '\n'
html += footHtml(ctx) + '\n'
html += '</div>\n</body>\n</html>\n'
return html
}
/* ---------------- 图片内嵌(转 base64,保证单文件离线可看) ---------------- */
function collectImageUrls(payload) {
var seen = {}
var out = []
;(function walk(node, depth) {
if (depth > 12 || node === null || typeof node !== 'object') return
if (isArr(node)) {
node.forEach(function (x) { walk(x, depth + 1) })
return
}
if (isPlainObject(node.image) && typeof node.image.url === 'string' && node.image.url) {
if (!seen[node.image.url]) { seen[node.image.url] = 1; out.push(node.image.url) }
}
Object.keys(node).forEach(function (k) { walk(node[k], depth + 1) })
})(payload, 0)
return out
}
function toDataUrl(url, baseUrl) {
return fetch(absUrl(url, baseUrl))
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status)
return r.blob()
})
.then(function (blob) {
if (blob.size > MAX_IMAGE_BYTES) {
throw new Error('单张超过 ' + Math.round(MAX_IMAGE_BYTES / 1024 / 1024) + 'MB,已跳过')
}
return new Promise(function (resolve, reject) {
var fr = new FileReader()
fr.onload = function () { resolve({ dataUri: String(fr.result), bytes: blob.size }) }
fr.onerror = function () { reject(new Error('读取图片失败')) }
fr.readAsDataURL(blob)
})
})
}
function embedImages(payload) {
var baseUrl = (typeof location !== 'undefined' && location.href) ? location.href : ''
var urls = collectImageUrls(payload)
var stats = { total: urls.length, embedded: 0, failed: 0, bytes: 0 }
var warnings = []
var map = {}
if (!urls.length || typeof fetch !== 'function' || typeof FileReader === 'undefined') {
return Promise.resolve({ map: map, baseUrl: baseUrl, stats: stats, warnings: warnings })
}
return Promise.all(urls.map(function (u) {
return toDataUrl(u, baseUrl).then(function (d) {
map[u] = d.dataUri
stats.embedded++
stats.bytes += d.bytes
}, function (err) {
stats.failed++
warnings.push('配图未内嵌(' + (err && err.message || '获取失败') + '):' + u)
})
})).then(function () {
return { map: map, baseUrl: baseUrl, stats: stats, warnings: warnings }
})
}
/* ---------------- 样式读取 / 文件落盘 ---------------- */
var cssCache = null
function loadCss(cssUrl) {
if (typeof fetch !== 'function') return Promise.reject(new Error('当前环境不支持导出(无 fetch)'))
if (cssCache) return Promise.resolve(cssCache)
return fetch(cssUrl, { cache: 'force-cache' })
.then(function (r) {
if (!r.ok) throw new Error('读取样式失败:HTTP ' + r.status + '(' + cssUrl + ')')
return r.text()
})
.then(function (txt) {
if (!txt || txt.indexOf('.hero') < 0) throw new Error('样式文件内容异常:' + cssUrl)
cssCache = txt
return cssCache
})
}
function saveBlob(html, filename) {
var blob = new Blob([html], { type: 'text/html;charset=utf-8' })
var url = URL.createObjectURL(blob)
var a = document.createElement('a')
a.href = url
a.download = filename
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(function () { try { URL.revokeObjectURL(url) } catch (e) {} }, 10000)
}
function defaultFilename(payload) {
var s = isPlainObject(payload && payload.summary) ? payload.summary : {}
var name = String(s.title || '行程报告')
.replace(/[\\/:*?"<>|\r\n\t]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!name) name = '行程报告'
if (name.length > 60) name = name.slice(0, 60)
return name + '-行程报告.html'
}
/* ---------------- 对外入口 ---------------- */
/**
* 导出并触发下载(浏览器)
* @returns {Promise<{ok, filename, bytes, images, warnings}>}
*/
function download(payload, spec, opts) {
opts = opts || {}
var cssUrl = opts.cssUrl || (SELF_DIR + 'tc_export.css')
return Promise.all([loadCss(cssUrl), embedImages(payload)])
.then(function (res) {
var html = build(payload, spec, {
css: res[0],
images: res[1].map,
baseUrl: res[1].baseUrl,
meta: opts.meta,
title: opts.title,
kicker: opts.kicker,
chip: opts.chip,
exportedAt: opts.exportedAt || nowBeijing()
})
var filename = opts.filename || defaultFilename(payload)
saveBlob(html, filename)
var warnings = res[1].warnings.slice()
return {
ok: true,
filename: filename,
bytes: utf8Length(html),
images: res[1].stats,
sections: (isArr(spec && spec.sections) ? spec.sections.length : 0),
warnings: warnings
}
})
}
var api = {
build: build,
download: download,
defaultFilename: defaultFilename,
collectImageUrls: collectImageUrls
}
if (typeof window !== 'undefined') window.TCExport = api
if (typeof module !== 'undefined' && module.exports) module.exports = api
})()
@@ -0,0 +1,652 @@
/* ============================================================
* tc_render.js —— 游山玩水 · 通用汇报渲染器(阶段一离线版)
*
* 设计原则(插件式 / 低耦合):
* 1. 本文件完全独立:不 import、不 require、不引用工具箱任何其他模块或公共库。
* 2. 只认 report_spec 里的 8 种 block 类型 + 数据路径,不认识任何业务字段
* (不知道什么叫"浴场""徒步""停车"),换 skill 只换 report_spec,本文件一行不改。
* 3. 地图双通道:有 Leaflet 就用 Leaflet,没有就用内置 SVG 降级渲染,
* 保证 Leaflet 加载失败时页面照样能看点线关系。
* 4. 删除本文件不影响任何其他 lab / tool。
*
* 依赖:无(Leaflet 为可选增强,通过全局 window.L 探测)
* ============================================================ */
(function (global) {
'use strict'
/* ---------------- 可调参数(改这里即可换瓦片源) ---------------- */
const MAP_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
const MAP_TILE_ATTR = '&copy; OpenStreetMap contributors'
const MAP_FALLBACK_DISCLAIMER = '点位顺序示意,非真实道路/轨迹'
const DEFAULT_BADGE = { high: '已核实', medium: '待确认', low: '需核实' }
const FALLBACK_IMAGE_TEXT = '配图未生成'
const mapRegistry = []
/* ---------------- 基础工具 ---------------- */
function h(tag, attrs, children) {
const el = document.createElement(tag)
if (attrs) {
Object.keys(attrs).forEach(function (k) {
const v = attrs[k]
if (v === null || v === undefined || v === false) return
if (k === 'class') el.className = v
else if (k === 'text') el.textContent = v
else if (k === 'html') el.innerHTML = v
else if (k.indexOf('on') === 0 && typeof v === 'function') el.addEventListener(k.slice(2), v)
else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v)
else el.setAttribute(k, v === true ? '' : String(v))
})
}
appendChildren(el, children)
return el
}
function appendChildren(el, children) {
if (children === null || children === undefined) return
if (Array.isArray(children)) {
children.forEach(function (c) { appendChildren(el, c) })
return
}
if (children instanceof Node) { el.appendChild(children); return }
el.appendChild(document.createTextNode(String(children)))
}
function clear(el) { while (el && el.firstChild) el.removeChild(el.firstChild) }
function resolvePath(root, path) {
if (!path) return root
const p = String(path).replace(/^\$\.?/, '')
if (!p) return root
return p.split('.').reduce(function (acc, key) {
if (acc === null || acc === undefined) return undefined
return acc[key]
}, root)
}
function pickById(arr, id) {
if (!Array.isArray(arr)) return null
for (let i = 0; i < arr.length; i++) {
if (arr[i] && arr[i].id === id) return arr[i]
}
return null
}
function isPlainObject(v) { return !!v && typeof v === 'object' && !Array.isArray(v) }
function badgeText(spec, level) {
const map = (spec && spec.confidence_badge) || DEFAULT_BADGE
return map[level] || DEFAULT_BADGE[level] || level
}
/* ---------------- 置信度字段渲染(通用) ---------------- */
// fact 可以是纯字符串,也可以是 { text, confidence, verify }
function renderFact(value, spec) {
if (isPlainObject(value)) {
const box = h('span', { class: 'tcr-fact' })
box.appendChild(h('span', { class: 'tcr-fact-text', text: value.text === undefined ? '' : String(value.text) }))
if (value.confidence) {
box.appendChild(h('span', {
class: 'tcr-badge tcr-badge-' + value.confidence,
text: badgeText(spec, value.confidence),
title: value.verify ? ('核实方式:' + value.verify) : ''
}))
}
if (value.verify) {
box.appendChild(h('span', { class: 'tcr-verify', text: '核实:' + value.verify }))
}
return box
}
return h('span', { class: 'tcr-fact-text', text: value === null || value === undefined ? '—' : String(value) })
}
/* ---------------- 各 block 渲染 ---------------- */
function renderOverview(data, spec) {
if (!isPlainObject(data)) return h('div', { class: 'tcr-empty', text: '无概览数据' })
const box = h('div', { class: 'tcr-overview' })
if (data.title) box.appendChild(h('h2', { class: 'tcr-ov-title', text: data.title }))
if (data.one_liner) box.appendChild(h('p', { class: 'tcr-ov-line', text: data.one_liner }))
if (Array.isArray(data.tags) && data.tags.length) {
const tagRow = h('div', { class: 'tcr-tags' })
data.tags.forEach(function (t) { tagRow.appendChild(h('span', { class: 'tcr-tag', text: t })) })
box.appendChild(tagRow)
}
if (Array.isArray(data.stats) && data.stats.length) {
const grid = h('div', { class: 'tcr-stats' })
data.stats.forEach(function (s) {
const cell = h('div', { class: 'tcr-stat' })
cell.appendChild(h('div', { class: 'tcr-stat-label', text: s.label || '' }))
cell.appendChild(h('div', { class: 'tcr-stat-value', text: s.value === undefined ? '' : String(s.value) }))
if (s.hint) cell.appendChild(h('div', { class: 'tcr-stat-hint', text: s.hint }))
grid.appendChild(cell)
})
box.appendChild(grid)
}
return box
}
function renderTimeline(data, spec) {
if (!isPlainObject(data) || !Array.isArray(data.days)) return h('div', { class: 'tcr-empty', text: '无时序数据' })
const box = h('div', { class: 'tcr-timeline' })
data.days.forEach(function (day) {
const dayBox = h('div', { class: 'tcr-day' })
const head = h('div', { class: 'tcr-day-head' })
head.appendChild(h('span', { class: 'tcr-day-label', text: day.label || '' }))
if (day.date) head.appendChild(h('span', { class: 'tcr-day-date', text: day.date }))
dayBox.appendChild(head)
const table = h('table', { class: 'tcr-table tcr-timeline-table' })
const thead = h('thead', null, h('tr', null, [
h('th', { class: 'tcr-col-time', text: '时间' }),
h('th', { text: '安排' }),
h('th', { class: 'tcr-col-kind', text: '类型' })
]))
table.appendChild(thead)
const tbody = h('tbody')
const rows = Array.isArray(day.rows) ? day.rows : []
rows.forEach(function (r) {
const tr = h('tr', { class: r.kind === 'hard' ? 'tcr-row-hard' : '' })
tr.appendChild(h('td', { class: 'tcr-col-time', text: r.time || '' }))
const td = h('td')
td.appendChild(h('div', { class: 'tcr-row-title', text: r.title || '' }))
if (r.note) td.appendChild(h('div', { class: 'tcr-row-note', text: r.note }))
tr.appendChild(td)
const kindCell = h('td', { class: 'tcr-col-kind' })
kindCell.appendChild(h('span', {
class: 'tcr-kind tcr-kind-' + (r.kind || 'soft'),
text: kindLabel(r.kind)
}))
if (r.activity) {
kindCell.appendChild(h('span', { class: 'tcr-kind tcr-kind-act', text: activityLabel(r.activity) }))
}
tr.appendChild(kindCell)
tbody.appendChild(tr)
})
table.appendChild(tbody)
dayBox.appendChild(table)
box.appendChild(dayBox)
})
return box
}
function kindLabel(kind) {
const map = { hard: '硬时间点', soft: '常规' }
return map[kind] || (kind || '')
}
// 活动类型与 kind(时间点性质)是两个正交字段,分开显示
function activityLabel(activity) {
const map = {
move: '移动', eat: '吃饭', hike: '徒步',
shop: '采买', sleep: '过夜', walk: '步行'
}
return map[activity] || (activity || '')
}
function renderCards(data, spec, section) {
const list = Array.isArray(data) ? data : []
if (!list.length) return h('div', { class: 'tcr-empty', text: '无节点数据' })
const box = h('div', { class: 'tcr-cards' })
const grouped = section && section.group_by === 'day'
if (!grouped) {
list.forEach(function (c) { box.appendChild(renderCard(c, spec)) })
return box
}
const days = []
list.forEach(function (c) {
const d = c.day === undefined ? 0 : c.day
if (days.indexOf(d) < 0) days.push(d)
})
days.sort(function (a, b) { return a - b })
days.forEach(function (d) {
const group = h('div', { class: 'tcr-card-group' })
group.appendChild(h('h3', { class: 'tcr-group-title', text: d === 1 ? '第一天' : (d === 2 ? '第二天' : '其他') }))
const grid = h('div', { class: 'tcr-card-grid' })
list.forEach(function (c) {
const cd = c.day === undefined ? 0 : c.day
if (cd === d) grid.appendChild(renderCard(c, spec))
})
group.appendChild(grid)
box.appendChild(group)
})
return box
}
function renderCard(card, spec) {
const el = h('article', { class: 'tcr-card', id: 'card_' + (card.id || card.anchor_id || '') })
const head = h('div', { class: 'tcr-card-head' })
const titles = h('div', { class: 'tcr-card-titles' })
titles.appendChild(h('h4', { class: 'tcr-card-title', text: card.title || '' }))
if (card.subtitle) titles.appendChild(h('div', { class: 'tcr-card-sub', text: card.subtitle }))
head.appendChild(titles)
if (Array.isArray(card.badges) && card.badges.length) {
const bs = h('div', { class: 'tcr-badges' })
card.badges.forEach(function (b) {
bs.appendChild(h('span', { class: 'tcr-badge tcr-tone-' + (b.tone || 'plain'), text: b.text || '' }))
})
head.appendChild(bs)
}
el.appendChild(head)
if (card.image && card.image.url) {
const fig = h('figure', { class: 'tcr-figure' })
const img = h('img', { class: 'tcr-img', src: card.image.url, alt: card.image.caption || '', loading: 'lazy' })
img.addEventListener('error', function () {
clear(fig)
fig.appendChild(h('div', { class: 'tcr-img-missing', text: FALLBACK_IMAGE_TEXT + '(' + (card.image.caption || '') + ')' }))
fig.appendChild(h('figcaption', { class: 'tcr-figcap', text: card.image.disclaimer || '' }))
})
fig.appendChild(img)
const cap = h('figcaption', { class: 'tcr-figcap' })
if (card.image.caption) cap.appendChild(h('span', { text: card.image.caption + ' · ' }))
cap.appendChild(h('span', { class: 'tcr-figcap-note', text: card.image.disclaimer || '' }))
fig.appendChild(cap)
el.appendChild(fig)
}
if (Array.isArray(card.fields) && card.fields.length) {
const dl = h('div', { class: 'tcr-fields' })
card.fields.forEach(function (f) {
const row = h('div', { class: 'tcr-field' })
row.appendChild(h('div', { class: 'tcr-field-label', text: f.label || '' }))
const val = h('div', { class: 'tcr-field-value' })
val.appendChild(renderFact(f.value, spec))
if (f.hint) val.appendChild(h('div', { class: 'tcr-field-hint', text: f.hint }))
row.appendChild(val)
dl.appendChild(row)
})
el.appendChild(dl)
}
if (Array.isArray(card.bullets) && card.bullets.length) {
const ul = h('ul', { class: 'tcr-bullets' })
card.bullets.forEach(function (b) { ul.appendChild(h('li', { text: b })) })
el.appendChild(ul)
}
return el
}
function renderTable(data, spec, section) {
let table = null
if (Array.isArray(data)) {
table = pickById(data, section.pick_id)
if (!table && section.pick_id) return h('div', { class: 'tcr-empty', text: '缺少表格:' + section.pick_id })
if (!table) table = data[0]
} else {
table = data
}
if (!isPlainObject(table)) return h('div', { class: 'tcr-empty', text: '无表格数据' })
const box = h('div', { class: 'tcr-tablebox' })
if (table.title) box.appendChild(h('div', { class: 'tcr-table-title', text: table.title }))
const columns = Array.isArray(table.columns) ? table.columns : []
const rows = Array.isArray(table.rows) ? table.rows : []
const hl = Array.isArray(table.highlight_row_keys) ? table.highlight_row_keys : []
const el = h('table', { class: 'tcr-table' })
const tr = h('tr')
columns.forEach(function (c) {
tr.appendChild(h('th', {
class: c.align ? ('tcr-al-' + c.align) : '',
style: c.width ? { width: c.width } : null,
text: c.label || c.key
}))
})
el.appendChild(h('thead', null, tr))
const tbody = h('tbody')
rows.forEach(function (r) {
const row = h('tr', { class: hl.indexOf(r._key) >= 0 ? 'tcr-row-hl' : '' })
columns.forEach(function (c) {
const v = r[c.key]
const td = h('td', { class: c.align ? ('tcr-al-' + c.align) : '' })
if (v === null || v === undefined) td.textContent = '—'
else td.textContent = String(v)
row.appendChild(td)
})
tbody.appendChild(row)
})
el.appendChild(tbody)
box.appendChild(h('div', { class: 'tcr-table-scroll' }, el))
if (table.note) box.appendChild(h('div', { class: 'tcr-table-note', text: table.note }))
return box
}
function renderCost(data, spec) {
if (!isPlainObject(data)) return h('div', { class: 'tcr-empty', text: '无费用数据' })
const box = h('div', { class: 'tcr-cost' })
const items = Array.isArray(data.items) ? data.items : []
const el = h('table', { class: 'tcr-table' })
el.appendChild(h('thead', null, h('tr', null, [
h('th', { text: '项目' }),
h('th', { class: 'tcr-al-right', text: '金额(' + (data.currency || 'CNY') + ')' }),
h('th', { text: '口径' })
])))
const tbody = h('tbody')
items.forEach(function (it) {
const row = h('tr')
row.appendChild(h('td', { text: it.label || '' }))
row.appendChild(h('td', { class: 'tcr-al-right', text: it.amount === undefined ? '—' : String(it.amount) }))
row.appendChild(h('td', { class: 'tcr-cell-note', text: it.note || it.unit || '' }))
tbody.appendChild(row)
})
el.appendChild(tbody)
if (data.total !== undefined) {
const tf = h('tfoot')
const tr = h('tr', { class: 'tcr-row-hl' })
tr.appendChild(h('td', { text: '合计' }))
tr.appendChild(h('td', { class: 'tcr-al-right', text: String(data.total) }))
tr.appendChild(h('td', { text: '' }))
tf.appendChild(tr)
el.appendChild(tf)
}
box.appendChild(h('div', { class: 'tcr-table-scroll' }, el))
if (data.note) box.appendChild(h('div', { class: 'tcr-table-note', text: data.note }))
return box
}
function renderChecklist(data, spec) {
const list = Array.isArray(data) ? data : []
if (!list.length) return h('div', { class: 'tcr-empty', text: '无待核实项' })
const ol = h('ol', { class: 'tcr-checklist' })
list.forEach(function (c) {
const li = h('li', { class: 'tcr-check-item' })
li.appendChild(h('span', { class: 'tcr-check-text', text: c.text || '' }))
if (c.confidence) {
li.appendChild(h('span', { class: 'tcr-badge tcr-badge-' + c.confidence, text: badgeText(spec, c.confidence) }))
}
if (c.verify) li.appendChild(h('span', { class: 'tcr-verify', text: '核实:' + c.verify }))
ol.appendChild(li)
})
return ol
}
function renderNotes(data, spec, section) {
let list = Array.isArray(data) ? data : []
if (section && Array.isArray(section.filter_levels) && section.filter_levels.length) {
const allow = section.filter_levels
list = list.filter(function (n) { return allow.indexOf(n.level) >= 0 })
}
if (!list.length) return h('div', { class: 'tcr-empty', text: '无提示项' })
const box = h('div', { class: 'tcr-notes' })
list.forEach(function (n) {
const el = h('div', { class: 'tcr-note tcr-note-' + (n.level || 'info') })
el.appendChild(h('div', { class: 'tcr-note-title', text: n.title || '' }))
if (n.text) el.appendChild(h('div', { class: 'tcr-note-text', text: n.text }))
box.appendChild(el)
})
return box
}
/* ---------------- 地图 block(Leaflet 优先,SVG 降级) ---------------- */
function renderMap(data, spec) {
if (!isPlainObject(data) || !Array.isArray(data.markers)) {
return h('div', { class: 'tcr-empty', text: '无地图数据' })
}
const box = h('div', { class: 'tcr-mapbox' })
const holder = h('div', { class: 'tcr-map-holder' })
box.appendChild(holder)
const legend = renderMapLegend(data)
if (legend) box.appendChild(legend)
const dis = data.disclaimer || (spec && spec.map_route_disclaimer_default) || MAP_FALLBACK_DISCLAIMER
box.appendChild(h('div', { class: 'tcr-map-note', text: dis }))
mapRegistry.push({ el: holder, data: data })
return box
}
function renderMapLegend(data) {
const routes = Array.isArray(data.routes) ? data.routes : []
if (!routes.length) return null
const box = h('div', { class: 'tcr-map-legend' })
routes.forEach(function (r) {
const item = h('span', { class: 'tcr-legend-item' })
const swatch = h('span', { class: 'tcr-legend-swatch' + (r.dashed ? ' tcr-dashed' : '') })
if (r.color) swatch.style.background = r.color
if (r.dashed) {
swatch.style.background = 'repeating-linear-gradient(90deg,' + (r.color || '#666') + ' 0 6px,transparent 6px 12px)'
}
item.appendChild(swatch)
item.appendChild(h('span', { text: r.name || r.id }))
box.appendChild(item)
})
return box
}
function initMaps() {
mapRegistry.forEach(function (reg) {
try {
if (global.L && typeof global.L.map === 'function') drawLeaflet(reg.el, reg.data)
else drawSvgFallback(reg.el, reg.data)
} catch (e) {
try { drawSvgFallback(reg.el, reg.data) } catch (e2) {
clear(reg.el)
reg.el.appendChild(h('div', { class: 'tcr-empty', text: '地图渲染失败' }))
}
}
})
mapRegistry.length = 0
}
function drawLeaflet(el, data) {
const L = global.L
clear(el)
const map = L.map(el, { zoomControl: true, attributionControl: true })
L.tileLayer(MAP_TILE_URL, { maxZoom: 18, attribution: MAP_TILE_ATTR }).addTo(map)
const markers = data.markers || []
const byId = {}
const latlngs = []
markers.forEach(function (m) {
if (typeof m.lat !== 'number' || typeof m.lng !== 'number') return
byId[m.id] = m
latlngs.push([m.lat, m.lng])
const icon = L.divIcon({
className: 'tcr-pin-wrap',
html: '<span class="tcr-pin tcr-pin-' + (m.kind || 'spot') + '">' + (m.seq === undefined ? '' : m.seq) + '</span>',
iconSize: [22, 22],
iconAnchor: [11, 11]
})
const marker = L.marker([m.lat, m.lng], { icon: icon, title: m.name || '' }).addTo(map)
marker.bindPopup('<b>' + escapeHtml(m.name || '') + '</b>' +
(m.note ? '<br/>' + escapeHtml(m.note) : '') +
(m.coord_precision === 'approx' ? '<br/><i>坐标为概略值</i>' : ''))
})
;(data.routes || []).forEach(function (r) {
const pts = (r.marker_ids || []).map(function (id) { return byId[id] }).filter(Boolean)
.map(function (m) { return [m.lat, m.lng] })
if (pts.length < 2) return
const line = L.polyline(pts, {
color: r.color || '#2f6fed',
weight: r.kind === 'drive' ? 3 : 4,
dashArray: r.dashed ? '6 6' : null,
opacity: 0.9
}).addTo(map)
if (r.note) line.bindTooltip(r.name + ' · ' + r.note, { sticky: true })
else line.bindTooltip(r.name || '', { sticky: true })
})
if (latlngs.length) {
try {
map.fitBounds(L.latLngBounds(latlngs).pad(0.15))
} catch (e) {
map.setView(data.center || latlngs[0], data.zoom || 9)
}
} else {
map.setView(data.center || [31.95, 121.15], data.zoom || 9)
}
setTimeout(function () { try { map.invalidateSize() } catch (e) {} }, 60)
}
function drawSvgFallback(el, data) {
clear(el)
const markers = (data.markers || []).filter(function (m) {
return typeof m.lat === 'number' && typeof m.lng === 'number'
})
const W = 860, H = 420, PAD = 34
const svgNS = 'http://www.w3.org/2000/svg'
const svg = document.createElementNS(svgNS, 'svg')
svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H)
svg.setAttribute('class', 'tcr-svg-map')
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet')
svg.appendChild(mkSvg('rect', { x: 0, y: 0, width: W, height: H, class: 'tcr-svg-bg' }))
if (!markers.length) {
el.appendChild(svg)
el.appendChild(h('div', { class: 'tcr-empty', text: '无可绘制点位' }))
return
}
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity
markers.forEach(function (m) {
minLat = Math.min(minLat, m.lat); maxLat = Math.max(maxLat, m.lat)
minLng = Math.min(minLng, m.lng); maxLng = Math.max(maxLng, m.lng)
})
const spanLat = Math.max(maxLat - minLat, 0.01)
const spanLng = Math.max(maxLng - minLng, 0.01)
function px(m) {
const x = PAD + ((m.lng - minLng) / spanLng) * (W - PAD * 2)
const y = H - PAD - ((m.lat - minLat) / spanLat) * (H - PAD * 2)
return { x: x, y: y }
}
const byId = {}
markers.forEach(function (m) { byId[m.id] = m })
;(data.routes || []).forEach(function (r) {
const pts = (r.marker_ids || []).map(function (id) { return byId[id] }).filter(Boolean)
if (pts.length < 2) return
const d = pts.map(function (m, i) {
const p = px(m)
return (i === 0 ? 'M' : 'L') + p.x.toFixed(1) + ' ' + p.y.toFixed(1)
}).join(' ')
svg.appendChild(mkSvg('path', {
d: d,
fill: 'none',
stroke: r.color || '#2f6fed',
'stroke-width': r.kind === 'drive' ? 2.5 : 3.2,
'stroke-dasharray': r.dashed ? '6 6' : 'none',
'stroke-linejoin': 'round',
class: 'tcr-svg-line'
}))
})
markers.forEach(function (m) {
const p = px(m)
svg.appendChild(mkSvg('circle', { cx: p.x, cy: p.y, r: 9, fill: pinColor(m.kind), stroke: '#fff', 'stroke-width': 2 }))
const t = mkSvg('text', {
x: p.x, y: p.y + 3.4, 'text-anchor': 'middle',
class: 'tcr-svg-seq', fill: '#fff'
})
t.textContent = m.seq === undefined ? '' : String(m.seq)
svg.appendChild(t)
const label = mkSvg('text', { x: p.x + 12, y: p.y + 4, class: 'tcr-svg-label' })
label.textContent = m.name || ''
svg.appendChild(label)
})
el.appendChild(svg)
el.appendChild(h('div', { class: 'tcr-map-fallback-hint', text: '未加载到 Leaflet,已用内置 SVG 降级绘制点位顺序' }))
}
function mkSvg(tag, attrs) {
const el = document.createElementNS('http://www.w3.org/2000/svg', tag)
Object.keys(attrs || {}).forEach(function (k) {
if (attrs[k] === null || attrs[k] === undefined) return
el.setAttribute(k, String(attrs[k]))
})
return el
}
function pinColor(kind) {
const map = {
start: '#2f6fed', end: '#d14343', waypoint: '#8a8f98', parking: '#c2701a',
hike: '#2e9e5b', district: '#b8860b', food: '#e0673a', bathhouse: '#1f8a70',
shop: '#5a67d8', sleep: '#2d3748', walk: '#8a5cf6', breakfast: '#c98a00', spot: '#0ea5e9'
}
return map[kind] || '#4b5563'
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
})
}
/* ---------------- 顶层渲染 ---------------- */
const BLOCKS = {
overview: renderOverview,
timeline: renderTimeline,
cards: renderCards,
map: renderMap,
table: renderTable,
cost: renderCost,
checklist: renderChecklist,
notes: renderNotes
}
function renderSection(section, payload, spec) {
const data = section.data ? resolvePath(payload, section.data) : payload
const fn = BLOCKS[section.type]
const wrap = h('section', { class: 'tcr-section', id: section.id || ('sec_' + section.type) })
if (section.title) {
const head = h('div', { class: 'tcr-section-head' })
head.appendChild(h('h3', { class: 'tcr-section-title', text: section.title }))
head.appendChild(h('a', { class: 'tcr-section-top', href: '#tcr_top', text: '回到顶部' }))
wrap.appendChild(head)
}
if (typeof fn !== 'function') {
wrap.appendChild(h('div', { class: 'tcr-empty', text: '未知 block 类型:' + section.type }))
return wrap
}
wrap.appendChild(fn(data, spec, section))
return wrap
}
function renderIndex(spec) {
const sections = (spec.sections || []).filter(function (s) { return s.title })
if (!sections.length) return null
const nav = h('nav', { class: 'tcr-index' })
sections.forEach(function (s) {
nav.appendChild(h('a', { class: 'tcr-index-item', href: '#' + (s.id || ('sec_' + s.type)), text: s.title }))
})
return nav
}
function render(payload, spec, mount) {
if (!mount) throw new Error('tc_render: 缺少挂载节点')
spec = spec || {}
const sections = Array.isArray(spec.sections) ? spec.sections : []
// 空 spec 是「静默留白」的经典成因:不报错、不渲染,页面一片空白、无从排查。
// 宁可主动抛错,让调用方把「渲染描述没传过来」直接摊在页面上。
if (!sections.length) {
throw new Error('report_spec 里没有 sections —— 后端没把渲染描述传过来,或技能包的 report_spec.json 为空')
}
clear(mount)
if (spec.show_index) {
const idx = renderIndex(spec)
if (idx) mount.appendChild(idx)
}
sections.forEach(function (section) {
mount.appendChild(renderSection(section, payload || {}, spec))
})
// 地图必须等节点入 DOM 后再初始化
if (global.requestAnimationFrame) global.requestAnimationFrame(initMaps)
else setTimeout(initMaps, 0)
}
global.TCRender = {
render: render,
resolvePath: resolvePath,
pickById: pickById,
MAP_TILE_URL: MAP_TILE_URL
}
})(window)
@@ -0,0 +1,193 @@
/* ============================================================
* tc_report.css —— 游山玩水 · 报告样式(只服务 tc_render.js)
*
* 为什么单独一份:
* tc_render.js 是「通用汇报渲染器」,它生成的节点类名统一用 tcr- 前缀。
* 页面上还有表单(.tc-field / .tc-badge / .tc-note 等页面级类名),
* 两套类名如果共用 tc- 前缀就会互相污染(例如 .tc-field 表单要 flex 纵向、
* 报告卡片里要 grid 两列),所以报告一律走 tcr- 独立命名空间。
*
* 使用方式:任何页面只要引用本文件 + tc_render.js,就能渲染任意 skill 的报告。
* 依赖:无(下面自带 :root 变量兜底,不引用工具箱任何其他样式文件)
* 删除本文件只会让报告掉样式,不会影响本 lab 之外的任何 tool / lab。
* ============================================================ */
:root {
--tcr-bg: #f6f7f9;
--tcr-card: #ffffff;
--tcr-line: #e5e7eb;
--tcr-text: #1f2328;
--tcr-sub: #6b7280;
--tcr-accent: #2f6fed;
}
/* ---------------- 区块骨架 ---------------- */
.tcr-index {
display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px;
background: #fff; border: 1px solid var(--tcr-line); border-radius: 8px; padding: 8px;
}
.tcr-index-item {
font-size: 12px; color: var(--tcr-accent); text-decoration: none;
padding: 3px 8px; border-radius: 6px; background: #f0f4ff;
}
.tcr-index-item:hover { background: #e2eaff; }
.tcr-section {
background: var(--tcr-card); border: 1px solid var(--tcr-line);
border-radius: 10px; padding: 16px 16px 18px; margin-bottom: 16px;
}
.tcr-section-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; }
.tcr-section-title { font-size: 16px; margin: 0; }
.tcr-section-top { font-size: 12px; color: var(--tcr-sub); text-decoration: none; }
.tcr-empty { color: var(--tcr-sub); font-size: 13px; padding: 8px 0; }
/* ---------------- 概览 ---------------- */
.tcr-ov-title { margin: 0 0 6px; font-size: 18px; }
.tcr-ov-line { margin: 0 0 10px; color: #374151; }
.tcr-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
.tcr-tag { font-size: 12px; background: #eef2f7; color: #374151; border-radius: 999px; padding: 2px 10px; }
.tcr-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; }
.tcr-stat { background: #fafbfc; border: 1px solid var(--tcr-line); border-radius: 8px; padding: 10px; }
.tcr-stat-label { font-size: 12px; color: var(--tcr-sub); }
.tcr-stat-value { font-size: 16px; font-weight: 600; margin: 2px 0; }
.tcr-stat-hint { font-size: 11px; color: var(--tcr-sub); line-height: 1.45; }
/* ---------------- 表格 ---------------- */
.tcr-table-scroll { overflow-x: auto; }
table.tcr-table { width: 100%; border-collapse: collapse; font-size: 13px; background: #fff; }
table.tcr-table th, table.tcr-table td {
border: 1px solid var(--tcr-line); padding: 7px 9px; text-align: left; vertical-align: top;
}
table.tcr-table thead th { background: #f3f5f8; font-weight: 600; white-space: nowrap; }
.tcr-al-right { text-align: right !important; }
.tcr-al-center { text-align: center !important; }
.tcr-row-hl td { background: #eef4ff; }
.tcr-row-hard td { background: #fff7ed; }
.tcr-col-time { white-space: nowrap; color: #374151; width: 108px; }
.tcr-col-kind { width: 138px; }
.tcr-row-title { font-weight: 500; }
.tcr-row-note { font-size: 12px; color: var(--tcr-sub); }
.tcr-cell-note { color: var(--tcr-sub); font-size: 12px; }
.tcr-kind {
font-size: 11px; padding: 1px 7px; border-radius: 999px;
background: #eef2f7; color: #4b5563; white-space: nowrap;
}
.tcr-kind-hard { background: #fdece8; color: #b42318; font-weight: 600; }
.tcr-kind-act { background: #eef2f7; color: #374151; margin-left: 4px; }
.tcr-table-title { font-size: 13px; font-weight: 600; margin-bottom: 8px; }
.tcr-table-note { font-size: 12px; color: var(--tcr-sub); margin-top: 8px; }
/* ---------------- 时序分组 ---------------- */
.tcr-day { margin-bottom: 16px; }
.tcr-day-head { display: flex; gap: 8px; align-items: baseline; margin-bottom: 6px; }
.tcr-day-label { font-weight: 600; }
.tcr-day-date { font-size: 12px; color: var(--tcr-sub); }
/* ---------------- 卡片 ---------------- */
.tcr-card-group { margin-bottom: 14px; }
.tcr-group-title { font-size: 14px; margin: 6px 0 10px; color: #374151; }
.tcr-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; }
.tcr-card { border: 1px solid var(--tcr-line); border-radius: 10px; padding: 12px; background: #fff; }
.tcr-card-head { display: flex; justify-content: space-between; gap: 8px; align-items: flex-start; }
.tcr-card-title { font-size: 15px; margin: 0; }
.tcr-card-sub { font-size: 12px; color: var(--tcr-sub); }
.tcr-badges { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; }
.tcr-badge {
font-size: 11px; padding: 1px 7px; border-radius: 999px;
background: #eef2f7; color: #4b5563; white-space: nowrap; cursor: default;
}
.tcr-badge-high { background: #e8f6ee; color: #1a7a45; }
.tcr-badge-medium { background: #fff5e0; color: #8a6100; }
.tcr-badge-low { background: #fdece8; color: #b42318; }
.tcr-tone-info { background: #e8f0fe; color: #1d4ed8; }
.tcr-tone-good { background: #e8f6ee; color: #1a7a45; }
.tcr-tone-warn { background: #fff5e0; color: #8a6100; }
.tcr-tone-danger { background: #fdece8; color: #b42318; }
.tcr-tone-plain { background: #eef2f7; color: #4b5563; }
/* ---------------- 配图 ---------------- */
.tcr-figure { margin: 10px 0 4px; }
.tcr-img { width: 100%; height: 150px; object-fit: cover; border-radius: 8px; display: block; background: #f0f2f5; }
.tcr-img-missing {
height: 90px; display: flex; align-items: center; justify-content: center;
background: #f3f5f8; border: 1px dashed var(--tcr-line); border-radius: 8px;
color: var(--tcr-sub); font-size: 12px;
}
.tcr-figcap { font-size: 11px; color: var(--tcr-sub); margin-top: 4px; }
.tcr-figcap-note { color: #b42318; }
/* ---------------- 卡片字段 / 事实 ---------------- */
.tcr-fields { margin-top: 10px; }
.tcr-field {
display: grid; grid-template-columns: 96px 1fr; gap: 6px;
padding: 5px 0; border-top: 1px dashed #eef0f3;
}
.tcr-field:first-child { border-top: none; }
.tcr-field-label { color: var(--tcr-sub); font-size: 12px; }
.tcr-field-value { font-size: 13px; }
.tcr-field-hint { font-size: 11px; color: var(--tcr-sub); }
.tcr-verify { display: block; font-size: 11px; color: #8a6100; margin-top: 2px; }
.tcr-bullets { margin: 10px 0 0; padding-left: 18px; font-size: 13px; }
.tcr-bullets li { margin-bottom: 3px; }
/* ---------------- 地图 ---------------- */
.tcr-map-holder { height: 420px; border-radius: 8px; overflow: hidden; background: #eef1f4; border: 1px solid var(--tcr-line); }
.tcr-map-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 8px; font-size: 12px; color: #374151; }
.tcr-legend-item { display: inline-flex; align-items: center; gap: 5px; }
.tcr-legend-swatch { width: 18px; height: 3px; border-radius: 2px; display: inline-block; background: #666; }
.tcr-map-note { font-size: 12px; color: var(--tcr-sub); margin-top: 6px; }
.tcr-map-fallback-hint { font-size: 12px; color: #8a6100; margin-top: 6px; }
.tcr-svg-map { width: 100%; height: auto; display: block; }
.tcr-svg-bg { fill: #f2f5f8; }
.tcr-svg-seq { font-size: 10px; font-weight: 700; }
.tcr-svg-label { font-size: 11px; fill: #374151; }
.tcr-pin-wrap { background: transparent; border: none; }
.tcr-pin {
display: inline-flex; align-items: center; justify-content: center;
width: 22px; height: 22px; border-radius: 50%; color: #fff; font-size: 11px;
font-weight: 700; border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.3);
background: #4b5563;
}
.tcr-pin-start { background: #2f6fed; }
.tcr-pin-end { background: #d14343; }
.tcr-pin-hike { background: #2e9e5b; }
.tcr-pin-district { background: #b8860b; }
.tcr-pin-food { background: #e0673a; }
.tcr-pin-bathhouse { background: #1f8a70; }
.tcr-pin-shop { background: #5a67d8; }
.tcr-pin-sleep { background: #2d3748; }
.tcr-pin-walk { background: #8a5cf6; }
.tcr-pin-breakfast { background: #c98a00; }
.tcr-pin-parking { background: #c2701a; }
.tcr-pin-spot { background: #0ea5e9; }
.tcr-pin-waypoint { background: #8a8f98; }
/* ---------------- 提示 ---------------- */
.tcr-note { border-radius: 8px; padding: 10px 12px; margin-bottom: 8px; border: 1px solid; }
.tcr-note-title { font-weight: 600; margin-bottom: 2px; font-size: 13px; }
.tcr-note-text { font-size: 13px; color: #374151; }
.tcr-note-info { background: #f2f7ff; border-color: #cfe0ff; }
.tcr-note-warn { background: #fffaf0; border-color: #f0dcae; }
.tcr-note-danger { background: #fff5f4; border-color: #f5c9c3; }
/* ---------------- 待核实清单 ---------------- */
.tcr-checklist { margin: 0; padding-left: 20px; font-size: 13px; }
.tcr-check-item { margin-bottom: 7px; }
.tcr-check-text { margin-right: 6px; }
@media (max-width: 640px) {
.tcr-card-grid { grid-template-columns: 1fr; }
.tcr-field { grid-template-columns: 82px 1fr; }
.tcr-map-holder { height: 320px; }
}
@@ -0,0 +1,11 @@
<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="上海周边高铁游周末两天一晚">
<title>上海周边高铁游周末两天一晚</title>
<!-- 高铁:流线车头 + 车窗 + 轨道 -->
<path d="M6.5 4.5h11a2 2 0 0 1 1.95 1.55l1.05 5.2A2 2 0 0 1 18.55 13H5.45a2 2 0 0 1-1.95-2.25l1.05-5.2A2 2 0 0 1 6.5 4.5Z" />
<path d="M5.45 13 4.2 16.6M18.55 13l1.25 3.6" />
<path d="M4 19.5h16" />
<circle cx="9" cy="9" r="1.1" fill="#2f6fed" stroke="none" />
<circle cx="15" cy="9" r="1.1" fill="#2f6fed" stroke="none" />
</svg>

After

Width:  |  Height:  |  Size: 701 B

@@ -0,0 +1,768 @@
<!DOCTYPE html>
<!--
============================================================
sh_2d_rail_hike/index.html —— 高铁游技能页(技能私有)
【隔离用意 · 请后续开发者先读这段】
本页面从 sh_2d_car_hike/index.html 复制而来(用户已确认「复制现有再改」),
复制后它只服务【本 skill】。它自带自己的表单/结果/导出按钮/交互弹窗,
自己的渲染器 tc_render.js、自己的样式 tc_report.css、自己的导出器 tc_export.js。
本 skill 的接口前缀是 /api/thought_lab/travel_citytour/skills/sh_2d_rail_hike
(由 lab 层挂载,鉴权已由 lab 层包好,本页不写任何鉴权逻辑)。
请勿把它与自驾 skill 的页面「抽公共组件」合并;本 lab 的原则是每个 skill 各存一份
可自由改的页面,宁可重复,也不要一处改动牵连全体。要新增 skill,请复制整个
skills/<skill_id>/ 目录后在副本里改。
唯一允许向上引用的只有 lab 级第三方库:../../assets/vendor/leaflet/
(无业务语义的 vendor,不涉及任何 skill 的业务代码)
============================================================
-->
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>上海周边高铁游周末两天一晚 · 游山玩水</title>
<style>
/* 本页样式全部以 tc- 前缀隔离,不引用其他 skill / lab / tool 的任何样式 */
: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-back:hover { text-decoration:underline; }
.tc-meta { font-size:12px; color:var(--tc-sub); background:#fff; border:1px solid var(--tc-line);
border-radius:8px; padding:8px 12px; margin-bottom:14px; }
.tc-meta b { color:var(--tc-text); }
.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-warn { background:#fffaf0; border-color:#f0dcae; }
.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-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); margin-bottom:12px; }
/* 表单 */
.tc-form { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:12px; }
.tc-field { display:flex; flex-direction:column; gap:4px; }
.tc-label { font-size:12px; color:#374151; font-weight:500; }
.tc-label .tc-req { color:#b42318; margin-left:2px; }
.tc-input, .tc-select {
font-size:13px; padding:7px 9px; border:1px solid var(--tc-line); border-radius:8px;
background:#fff; color:var(--tc-text); font-family:inherit;
}
.tc-input:focus, .tc-select:focus { outline:none; border-color:var(--tc-accent); box-shadow:0 0 0 3px #e8f0fe; }
.tc-hint { font-size:11px; color:var(--tc-sub); }
.tc-affects { font-size:11px; color:#8a6100; }
.tc-actions { display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-top:14px; }
.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:hover:not(:disabled) { background:#2560d8; }
.tc-btn:disabled { background:#c3cddb; cursor:not-allowed; }
.tc-btn-ghost { background:#fff; color:var(--tc-accent); border:1px solid #cfdcff; }
.tc-btn-ghost:hover { background:#f0f4ff; }
.tc-gen-status { font-size:12px; color:var(--tc-sub); }
.tc-empty { color:var(--tc-sub); font-size:13px; padding:8px 0; }
/* 结果区徽标 */
.tc-skill-tags { display:flex; flex-wrap:wrap; gap:4px; }
.tc-badge { font-size:11px; padding:1px 7px; border-radius:999px; background:#eef2f7; color:#4b5563; }
.tc-badge-info { background:#e8f0fe; color:#1d4ed8; }
.tc-badge-warn { background:#fff5e0; color:#8a6100; }
.tc-badge-danger { background:#fdece8; color:#b42318; }
.tc-badge-good { background:#e8f6ee; color:#1a7a45; }
/* 交互弹窗 */
.tc-modal-mask { position:fixed; inset:0; background:rgba(15,20,30,.45); display:none;
align-items:center; justify-content:center; z-index:9000; padding:16px; }
.tc-modal-mask.tc-show { display:flex; }
.tc-modal { background:#fff; border-radius:12px; max-width:820px; width:100%;
max-height:86vh; display:flex; flex-direction:column; overflow:hidden; }
.tc-modal-head { display:flex; justify-content:space-between; align-items:flex-start; gap:10px;
padding:14px 16px; border-bottom:1px solid var(--tc-line); }
.tc-modal-title { font-size:15px; font-weight:600; margin:0; }
.tc-modal-sub { font-size:12px; color:var(--tc-sub); }
.tc-modal-body { padding:14px 16px; overflow:auto; }
.tc-modal-foot { padding:10px 16px; border-top:1px solid var(--tc-line);
display:flex; justify-content:space-between; align-items:center; gap:10px; }
.tc-kv { display:grid; grid-template-columns:112px 1fr; gap:4px 10px; font-size:13px; margin-bottom:10px; }
.tc-kv-k { color:var(--tc-sub); }
.tc-kv-v { word-break:break-all; }
.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; }
.tc-sec-label { font-size:12px; font-weight:600; margin:12px 0 6px; color:#374151; }
.tc-state { font-size:12px; padding:1px 8px; border-radius:999px; display:inline-block; }
.tc-state-ok { background:#e8f6ee; color:#1a7a45; }
.tc-state-error { background:#fdece8; color:#b42318; }
.tc-state-pending { background:#eef2f7; color:#4b5563; }
/* Toast */
.tc-toasts { position:fixed; top:16px; left:50%; transform:translateX(-50%); z-index:9500;
display:flex; flex-direction:column; gap:8px; align-items:center; pointer-events:none; }
.tc-toast { font-size:13px; color:#fff; border-radius:8px; padding:8px 16px;
box-shadow:0 4px 14px rgba(0,0,0,.18); }
.tc-toast-info { background:#2f6fed; }
.tc-toast-success { background:#1a7a45; }
.tc-toast-error { background:#b42318; }
.tc-result-head { display:flex; justify-content:space-between; align-items:baseline; gap:10px;
flex-wrap:wrap; margin-bottom:10px; }
.tc-result-title { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
@media (max-width:640px){ .tc-kv{ grid-template-columns:88px 1fr; } .tc-block{ padding:13px; } }
</style>
<!-- 报告样式单独一份:类名统一 tcr- 前缀,与上面的表单/页面样式完全隔离 -->
<link rel="stylesheet" href="./tc_report.css" />
</head>
<body>
<div class="tc-page" id="tcr_top">
<div class="tc-top">
<h1>上海周边高铁游周末两天一晚</h1>
<span class="tc-sub">游山玩水 · 单 skill 页</span>
<a class="tc-back" href="../../index.html">← 返回技能列表</a>
</div>
<div class="tc-meta" id="tc_meta">正在读取技能状态…</div>
<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 class="tc-actions">
<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-note tc-note-warn" id="tc_slow_note" style="display:none">
<b>生成较慢,请勿关闭页面</b>
整份行程(12 个节点 + 5 张表,含市内交通方案)需要模型输出 1.4-1.8 万 token,实测约 6-10 分钟。页面会显示已用时间,完成后自动渲染。
</div>
<div class="tc-note tc-note-info">
<b>两条本技能的固定规则,先说在前面</b>
① <b>不给具体车次号</b>:AI 无法查 12306,写出来的车次号一定是编的,所以报告只给「建议时段 + 车站 + 历时区间 + 参考票价区间」,<b>实际车次请以 12306 为准</b>。<br>
② <b>返程死线 21:00</b>:周日必须下午出发,最晚 21:00 抵达上海(周一要上班,晚了没地铁)。若班次偏晚,报告会提示你改早班次。
</div>
<div class="tc-block">
<h2 class="tc-block-title">1 · 填写行程参数</h2>
<div class="tc-block-hint" id="tc_form_hint">正在读取问答定义…</div>
<div class="tc-form" id="tc_form"></div>
<div class="tc-actions">
<button class="tc-btn" id="tc_gen" disabled>生成行程</button>
<button class="tc-btn tc-btn-ghost" id="tc_gen_last" title="不重新生成,直接载入上一次的结果(一次生成要 6-10 分钟)">载入最近一次生成</button>
<span class="tc-gen-status" id="tc_gen_status"></span>
</div>
</div>
<div id="tc_result"></div>
<div class="tc-note tc-note-info">
<b>打不开 / 提示未授权?</b>
本技能受导航鉴权保护,必须<b>从首页九宫格进入思想实验室、再点本卡片</b>;直接把链接粘贴到地址栏会被拒绝(无同源来源)。
<br><a href="./sample/index.html">查看离线样例:苏州(不烧 token,直接看报告长什么样)</a>
</div>
</div>
<!-- Leaflet 为可选增强(lab 级第三方库,仅限 vendor);加载失败时渲染器自动降级为内置 SVG 点线示意 -->
<link rel="stylesheet" href="../../assets/vendor/leaflet/leaflet.css" onerror="void 0" />
<script src="../../assets/vendor/leaflet/leaflet.js" onerror="void 0"></script>
<script src="./tc_render.js"></script>
<!-- 单文件 HTML 导出器:纯前端拼字符串 + 内联样式/图片,导出不消耗任何 token -->
<script src="./tc_export.js"></script>
<script>
(function () {
'use strict'
// 本 skill 的接口前缀(由 lab 层挂载,鉴权已由 lab 层包好)
var API = '/api/thought_lab/travel_citytour/skills/sh_2d_rail_hike'
var state = { uiMode: 'debug', meta: null, generating: false, timer: null, lastResult: null }
var $ = function (id) { return document.getElementById(id) }
/* ---------- 通用 UI 组件(双模式:debug / normal) ---------- */
function nowBeijing() {
try {
return new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })
} catch (e) { return new Date().toLocaleString() }
}
function esc(s) {
return String(s === undefined || s === null ? '' : s)
.replace(/[&<>"']/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] })
}
function toast(text, kind) {
var box = $('tc_toasts')
if (!box) {
box = document.createElement('div')
box.className = 'tc-toasts'
box.id = 'tc_toasts'
document.body.appendChild(box)
}
var el = document.createElement('div')
el.className = 'tc-toast tc-toast-' + (kind || 'info')
el.textContent = text
box.appendChild(el)
setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el) }, 2000)
}
/* 导航通行凭证缺失(页面守卫认 Referer、接口守卫只认 cookie)时给出明确指引 */
function isUnauthorized(err) { return /unauthorized|401/i.test(String((err && err.message) || '')) }
function showAuthNote() {
var el = $('tc_auth_note')
if (el) el.style.display = ''
// 顺手拉一次 lab 级公开诊断接口(注册在鉴权守卫之前),把「到底缺什么」直接摊在页面上
// 注意:诊断接口归 lab 层所有(红线),本 skill 不重复实现
fetch('/api/thought_lab/travel_citytour/auth/diag?t=' + Date.now(), { cache: 'no-store' })
.then(function (r) { return r.json() })
.then(function (d) {
var box = $('tc_auth_diag')
if (!box) return
box.innerHTML = '<div class="tc-sec-label">当前请求诊断(公开接口,不含任何 cookie 值)</div>' +
'<pre class="tc-pre">' + esc(JSON.stringify(d, null, 2)) + '</pre>'
})
.catch(function () {})
}
/* 交互详情弹窗:完整记录触发动作 / 时间 / 接口 / 方法 / 参数 / 返回 / 错误 / 耗时 / 状态 */
var interactionTimer = null
function showInteractionDialog(rec) {
var mask = $('tc_modal')
var okState = rec.state === 'ok'
var canAutoClose = okState
var autoSec = state.uiMode === 'debug' ? 3 : 2
mask.querySelector('.tc-modal-title').textContent = rec.action
mask.querySelector('.tc-modal-head .tc-modal-sub').textContent =
state.uiMode === 'debug' ? '研发调试模式 · 完整交互详情' : '交互详情'
var body = mask.querySelector('.tc-modal-body')
body.innerHTML =
'<div class="tc-kv">' +
'<div class="tc-kv-k">当前状态</div><div class="tc-kv-v"><span class="tc-state tc-state-' +
(rec.state === 'ok' ? 'ok' : (rec.state === 'pending' ? 'pending' : 'error')) + '">' +
esc(rec.state === 'ok' ? '成功' : (rec.state === 'pending' ? '进行中' : '失败')) + '</span></div>' +
'<div class="tc-kv-k">触发动作</div><div class="tc-kv-v">' + esc(rec.action) + '</div>' +
'<div class="tc-kv-k">时间</div><div class="tc-kv-v">' + esc(rec.time) + '(北京时间)</div>' +
'<div class="tc-kv-k">接口地址</div><div class="tc-kv-v">' + esc(rec.method + ' ' + rec.url) + '</div>' +
'<div class="tc-kv-k">耗时</div><div class="tc-kv-v">' + esc(rec.elapsed) + '</div>' +
'<div class="tc-kv-k">结果摘要</div><div class="tc-kv-v">' + esc(rec.summary || '—') + '</div>' +
'</div>' +
'<div class="tc-sec-label">请求参数</div><pre class="tc-pre">' + esc(rec.request || '(无)') + '</pre>' +
'<div class="tc-sec-label">返回结果</div><pre class="tc-pre">' + esc(rec.response || '(无)') + '</pre>' +
(rec.error ? '<div class="tc-sec-label">错误信息</div><pre class="tc-pre">' + esc(rec.error) + '</pre>' : '')
mask.querySelector('.tc-modal-foot .tc-gen-status').textContent = canAutoClose
? ('窗口将在 ' + autoSec + 's 后自动关闭') : '出现异常,窗口不会自动关闭,便于排查'
mask.classList.add('tc-show')
if (interactionTimer) { clearInterval(interactionTimer); interactionTimer = null }
if (canAutoClose) {
var left = autoSec
interactionTimer = setInterval(function () {
left--
var tip = mask.querySelector('.tc-modal-foot .tc-gen-status')
if (tip) tip.textContent = '窗口将在 ' + left + 's 后自动关闭'
if (left <= 0) { closeInteractionDialog(); }
}, 1000)
}
}
function closeInteractionDialog() {
if (interactionTimer) { clearInterval(interactionTimer); interactionTimer = null }
var mask = $('tc_modal')
if (mask) mask.classList.remove('tc-show')
}
/* 统一包装一次后台交互:记录细节 + 按模式决定弹窗还是 Toast */
function runTrackedInteraction(opt) {
var t0 = Date.now()
var rec = {
action: opt.action, method: opt.method || 'GET', url: opt.url,
request: opt.request ? JSON.stringify(opt.request, null, 2) : '', time: nowBeijing()
}
if (opt.showPending && state.uiMode === 'debug') {
rec.state = 'pending'; rec.elapsed = '—'; rec.summary = '请求已发出,等待返回…'
showInteractionDialog(rec)
}
return opt.run().then(function (res) {
rec.state = 'ok'
rec.elapsed = (Date.now() - t0) + ' ms'
rec.summary = opt.summary ? opt.summary(res) : '成功'
rec.response = JSON.stringify(opt.responseOf ? opt.responseOf(res) : res, null, 2)
if (state.uiMode === 'debug') showInteractionDialog(rec)
else toast(rec.summary, 'success')
return res
}).catch(function (err) {
rec.state = 'error'
rec.elapsed = (Date.now() - t0) + ' ms'
rec.summary = '失败'
rec.error = (err && err.message) || String(err)
if (err && err.detail) rec.response = JSON.stringify(err.detail, null, 2)
// 失败在任何模式下都强提示,不降级、不自动关闭
showInteractionDialog(rec)
throw err
})
}
function requestJson(url, options) {
var opt = Object.assign({ cache: 'no-store' }, options || {})
var timeoutMs = opt.timeoutMs || 0
delete opt.timeoutMs
var timer = null
if (timeoutMs && window.AbortController) {
var ctrl = new AbortController()
opt.signal = ctrl.signal
timer = setTimeout(function () { ctrl.abort() }, timeoutMs)
}
var clear = function () { if (timer) { clearTimeout(timer); timer = null } }
return fetch(url + '?t=' + Date.now(), opt)
.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 ? j.error + ':' + j.hint : j.error))) || ('HTTP ' + r.status))
err.detail = j || t.slice(0, 400)
throw err
}
if (!j) throw new Error('返回不是合法 JSON')
return j
})
})
.then(function (j) { clear(); return j }, function (e) {
clear()
if (e && e.name === 'AbortError') {
// 长请求经 8975 反代存在「服务端已返回、客户端收不到」的情况,此前界面会无限停在「生成中」
var err = new Error('等待 ' + Math.round(timeoutMs / 60000) + ' 分钟仍未收到响应。服务端可能已经生成完成,请点「载入最近一次生成」查看')
err.detail = { timeout_ms: timeoutMs, url: url }
throw err
}
throw e
})
}
/* ---------- 启动:读本技能元信息(含 ui_mode / qa / report_spec / destinations) ---------- */
function loadMeta() {
return runTrackedInteraction({
action: '读取技能元信息', method: 'GET', url: API + '/meta',
run: function () { return requestJson(API + '/meta') },
summary: function (r) {
return 'ui_mode=' + r.ui_mode + ',模型=' + r.ai_model +
',' + (r.report_spec && r.report_spec.sections || []).length + ' 个区块'
},
responseOf: function (r) {
return {
ok: r.ok, ui_mode: r.ui_mode, ai_model: r.ai_model, ai_ready: r.ai_ready,
skill: r.skill, sections: (r.report_spec && r.report_spec.sections || []).length,
destinations: (r.destinations || []).map(function (d) { return d.name })
}
}
}).then(function (r) {
state.meta = r
state.uiMode = r.ui_mode || 'debug'
var s = r.skill || {}
$('tc_meta').innerHTML =
'ui_mode:<b>' + esc(state.uiMode) + '</b>' +
' · 技能:<b>' + esc(s.name || '') + '</b>(v' + esc(s.version || '') + ')' +
' · 生成模型:<b>' + esc(r.ai_model) + '</b>' +
' · AI 通道:<b>' + (r.ai_ready ? '就绪' : '未配置密钥') + '</b>'
$('tc_slow_note').style.display = ''
renderForm(r)
return r
})
}
/* ---------- 渲染问答表单(按本技能 qa.json 动态生成,不写死字段) ---------- */
function renderForm(meta) {
var qa = meta.qa || {}
var host = $('tc_form')
host.innerHTML = ''
var s = meta.skill || {}
$('tc_form_hint').textContent = '当前技能:' + (s.name || '') + '(v' + (s.version || '') + ')· '
+ (s.anchors_count || 0) + ' 个骨架槽位 · 带 * 为必填。'
var dest = qa.destination_input || {}
var destSuggestions = (meta.destinations || []).map(function (d) { return d.name }).join(' / ')
addField(host, {
key: 'destination', label: dest.label || '目的地',
type: 'text', required: dest.required !== false,
placeholder: dest.placeholder || '例如:南通如东',
hint: (dest.hint || '') + (destSuggestions ? '(本技能已跑过:' + destSuggestions + ')' : '')
})
;(qa.questions || []).forEach(function (q) {
addField(host, {
key: q.key, label: q.label || q.key, type: q.type || 'text',
required: q.required !== false, default: q.default, options: q.options,
min: q.min, max: q.max, hint: q.hint || '',
affects: q.affects || []
})
})
var btn = $('tc_gen')
btn.disabled = false
btn.textContent = '生成行程'
$('tc_gen_status').textContent = '参数填好后点右侧按钮;生成需 6-8 分钟'
}
function addField(host, f) {
var wrap = document.createElement('div')
wrap.className = 'tc-field'
var lab = document.createElement('label')
lab.className = 'tc-label'
lab.textContent = f.label
if (f.required) {
var st = document.createElement('span'); st.className = 'tc-req'; st.textContent = '*'
lab.appendChild(st)
}
wrap.appendChild(lab)
var input
if (f.type === 'select') {
input = document.createElement('select')
;(f.options || []).forEach(function (o) {
var op = document.createElement('option')
op.value = o.value
op.textContent = o.label || o.value
input.appendChild(op)
})
} else {
input = document.createElement('input')
input.className = 'tc-input'
input.type = (f.type === 'number' ? 'number' : (f.type === 'date' ? 'date' : (f.type === 'time' ? 'time' : 'text')))
if (f.placeholder) input.placeholder = f.placeholder
if (f.min !== undefined && f.min !== null) input.min = f.min
if (f.max !== undefined && f.max !== null) input.max = f.max
}
if (f.type === 'select') input.className = 'tc-select'
input.id = 'qa_' + f.key
if (f.default !== undefined && f.default !== null && f.default !== '') input.value = f.default
wrap.appendChild(input)
if (f.hint) {
var h = document.createElement('div'); h.className = 'tc-hint'; h.textContent = f.hint
wrap.appendChild(h)
}
if (f.affects && f.affects.length) {
var a = document.createElement('div'); a.className = 'tc-affects'
a.textContent = '影响:' + f.affects.join('、')
wrap.appendChild(a)
}
host.appendChild(wrap)
}
function collectAnswers() {
var qa = (state.meta && state.meta.qa) || {}
var out = {}
var keys = ['destination'].concat((qa.questions || []).map(function (q) { return q.key }))
keys.forEach(function (k) {
var el = $('qa_' + k)
if (!el) return
var v = String(el.value || '').trim()
if (v === '') return
var q = (qa.questions || []).filter(function (x) { return x.key === k })[0]
out[k] = (q && q.type === 'number') ? Number(v) : v
})
return out
}
/* ---------- 生成 ---------- */
function startTimer() {
var t0 = Date.now()
state.timer = setInterval(function () {
var s = Math.floor((Date.now() - t0) / 1000)
$('tc_gen_status').textContent = '生成中… 已用 ' + s + ' 秒(整份行程实测约 3-5 分钟,请勿关闭页面)'
}, 1000)
}
function stopTimer() {
if (state.timer) { clearInterval(state.timer); state.timer = null }
}
/* 载入最近一次生成:不重新烧 4 分钟,直接看上一次落盘的结果(本技能私有 data/) */
function onLoadLast() {
var btn = $('tc_gen_last')
var old = btn.textContent
btn.disabled = true
btn.textContent = '载入中…'
runTrackedInteraction({
action: '载入最近一次生成', method: 'GET', url: API + '/last',
run: function () { return requestJson(API + '/last') },
summary: function (r) {
return '已载入:' + ((r.payload && r.payload.summary && r.payload.summary.title) || '未命名行程')
},
responseOf: function (r) {
return {
ok: r.ok, meta: r.meta,
check: { stats: r.check && r.check.stats, errors: r.check && r.check.errors, warnings: (r.check && r.check.warnings || []).slice(0, 20) }
}
}
}).then(function (r) {
if (renderResultSafe(r, '载入成功,但渲染失败(问题在前端渲染器)')) toast('已载入最近一次生成', 'success')
}).catch(function (e) {
if (isUnauthorized(e)) showAuthNote()
$('tc_result').innerHTML = '<div class="tc-note tc-note-warn"><b>载入失败</b>:' + esc((e && e.message) || String(e)) + '</div>'
}).then(function () {
btn.disabled = false
btn.textContent = old
})
}
function onGenerate() {
if (state.generating) return
var answers = collectAnswers()
if (!answers.destination) { toast('请先填写目的地', 'error'); return }
state.generating = true
var btn = $('tc_gen')
btn.disabled = true
btn.textContent = '生成中…'
startTimer()
runTrackedInteraction({
action: '生成行程(AI)',
method: 'POST',
url: API + '/generate',
request: { answers: answers },
showPending: true,
run: function () {
return requestJson(API + '/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers: answers }),
timeoutMs: 8 * 60 * 1000
})
},
summary: function (r) {
var c = r.check || {}
// 有结构问题时如实说明(结果已被保留,可查看/导出),不要一律报「生成成功」
var errs = c.errors || []
return (errs.length ? '已生成但有 ' + errs.length + ' 处结构问题:' : '生成成功:') +
(r.check && r.check.stats ? r.check.stats.cards : '?') + ' 张卡片,' +
(c.warnings ? c.warnings.length : 0) + ' 条提醒,耗时 ' + Math.round((r.meta && r.meta.elapsed_ms || 0) / 1000) + 's'
},
responseOf: function (r) {
return {
ok: r.ok, meta: r.meta, check: { stats: r.check && r.check.stats, errors: r.check && r.check.errors, warnings: (r.check && r.check.warnings || []).slice(0, 20) },
payload_summary: r.payload && r.payload.summary,
destination_override: r.destination_override
}
}
}).then(function (r) {
if (renderResultSafe(r, '生成成功,但渲染失败(数据是好的,问题在前端渲染器)')) {
var nErr = (r.check && r.check.errors) || []
// 有结构问题时不用绿色成功提示,避免「看起来一切正常」的错觉
if (nErr.length) toast('已生成,但有 ' + nErr.length + ' 处结构问题(见下方红色提示)', 'error')
else toast('行程已生成并渲染', 'success')
}
}).catch(function (err) {
if (isUnauthorized(err)) showAuthNote()
$('tc_gen_status').textContent = '生成失败:' + (err && err.message || '')
}).then(function () {
state.generating = false
stopTimer()
btn.disabled = false
btn.textContent = '重新生成'
})
}
// 渲染必须永远有反馈:异常时把错误摊在页面上,绝不留白、绝不静默
function renderResultSafe(r, title) {
try {
renderResult(r)
return true
} catch (e) {
$('tc_result').innerHTML =
'<div class="tc-note tc-note-warn"><b>' + esc(title) + '</b><br>' +
'· 错误:' + esc((e && e.message) || String(e)) + '<br>' +
'· 位置:' + esc(String((e && e.stack) || '').split('\n').slice(1, 4).join(' ← ')) + '</div>'
return false
}
}
function renderResult(r) {
var host = $('tc_result')
var payload = r.payload
var spec = r.report_spec || {}
// 导出按钮吃的是这份「已经生成好」的结果:导出不再调用 AI,不消耗 token
state.lastResult = r
var head = document.createElement('div')
head.className = 'tc-result-head'
var warns = (r.check && r.check.warnings) || []
// 结构问题(errors):结果已被服务端保留(不再整份丢弃),这里必须醒目告知,而不是假装一切正常
var errs = (r.check && r.check.errors) || []
head.innerHTML =
'<div class="tc-result-title">' +
'<h2 class="tc-block-title" style="margin:0">2 · 生成结果</h2>' +
'<button class="tc-btn tc-btn-ghost" id="tc_export" style="padding:5px 12px"' +
' title="把下面这份结果导出成一个独立的 HTML 单文件,离线随时查看(不重新生成、不消耗 token)">导出至 HTML</button>' +
'</div>' +
'<div class="tc-skill-tags">' +
'<span class="tc-badge tc-badge-info">模型 ' + esc((r.meta && r.meta.model) || '') + '</span>' +
'<span class="tc-badge tc-badge-info">耗时 ' + Math.round(((r.meta && r.meta.elapsed_ms) || 0) / 1000) + 's</span>' +
(r.meta && r.meta.is_new_destination ? '<span class="tc-badge tc-badge-warn">首个新目的地</span>' : '<span class="tc-badge tc-badge-good">技能包内已有覆盖</span>') +
(errs.length
? '<span class="tc-badge tc-badge-danger">' + errs.length + ' 处结构问题</span>'
: (warns.length
? '<span class="tc-badge tc-badge-warn">' + warns.length + ' 条校验提醒</span>'
: '<span class="tc-badge tc-badge-good">结构校验通过</span>')) +
'</div>'
host.innerHTML = ''
host.appendChild(head)
var exportBtn = $('tc_export')
if (exportBtn) {
if (window.TCExport) exportBtn.addEventListener('click', onExport)
else { exportBtn.disabled = true; exportBtn.title = '导出器未加载(tc_export.js 未成功执行)' }
}
if (errs.length) {
var e = document.createElement('div')
e.className = 'tc-note tc-note-danger'
e.innerHTML = '<b>本次结果有 ' + errs.length + ' 处结构问题(前 12 条)—— 已保留,未丢弃</b>' +
errs.slice(0, 12).map(function (x) { return '· ' + esc(x) }).join('<br>') +
'<br>下方内容仍可正常查看与导出。若这些报错其实是「校验器误判」,' +
'请把这段文字发给 AI 定位(不必重新生成);若确属内容缺失,再点「重新生成」。'
host.appendChild(e)
}
if (warns.length) {
var w = document.createElement('div')
w.className = 'tc-note tc-note-warn'
w.innerHTML = '<b>结构校验提醒(' + warns.length + ' 条,前 12 条)</b>' +
warns.slice(0, 12).map(function (x) { return '· ' + esc(x) }).join('<br>')
host.appendChild(w)
}
var mount = document.createElement('div')
mount.id = 'tc_report'
host.appendChild(mount)
if (!window.TCRender) {
mount.innerHTML = '<div class="tc-empty">渲染器未加载(tc_render.js 未成功执行)</div>'
return
}
try {
window.TCRender.render(payload, spec, mount)
} catch (e) {
// 渲染中断时保留已渲染部分,并把错误摊在页面上 —— 绝不静默留白
var box = document.createElement('div')
box.className = 'tc-note tc-note-warn'
box.innerHTML = '<b>渲染中断</b>:' + esc((e && e.message) || String(e)) +
'<br>位置:' + esc(String((e && e.stack) || '').split('\n').slice(1, 4).join(' ← ')) +
'<br>以上为已成功渲染的部分。把这行错误发给 AI 即可定位。'
mount.appendChild(box)
}
}
/* 导出至 HTML:把当前这份结果拼成「单文件离线文档」并下载。
全程纯前端(读同目录 tc_export.css + 内嵌配图),不调用任何后端接口、不消耗 token。 */
function onExport() {
var r = state.lastResult
if (!r) { toast('还没有可导出的结果,先生成或载入一次', 'error'); return }
if (!window.TCExport) { toast('导出器未加载(tc_export.js)', 'error'); return }
var btn = $('tc_export')
if (btn && btn.disabled) return
var old = btn ? btn.textContent : ''
if (btn) { btn.disabled = true; btn.textContent = '导出中…' }
var payload = r.payload || {}
var summary = payload.summary || {}
var sections = (r.report_spec && r.report_spec.sections) || []
var cards = Array.isArray(payload.cards) ? payload.cards : []
var markers = (payload.map && payload.map.markers) || []
runTrackedInteraction({
action: '导出至 HTML(单文件·离线可看)', method: 'LOCAL', url: './tc_export.css → blob 下载',
request: {
title: summary.title || '(无标题)',
sections: sections.length,
cards: cards.length,
markers: markers.length,
note: '内联样式 + 内嵌配图 + 内联 SVG 地图;不调用 AI、不消耗 token'
},
showPending: true,
run: function () { return window.TCExport.download(payload, r.report_spec || {}, { meta: r.meta }) },
summary: function (res) {
return '已导出 ' + res.filename + '(' + Math.round(res.bytes / 1024) + ' KB,' + res.sections + ' 个区块' +
(res.images && res.images.total ? ',配图内嵌 ' + res.images.embedded + '/' + res.images.total : '') + ')'
},
responseOf: function (res) { return res }
}).then(function (res) {
toast('已导出:' + res.filename, 'success')
}).catch(function (e) {
toast('导出失败:' + ((e && e.message) || ''), 'error')
}).then(function () {
if (btn) { btn.disabled = false; btn.textContent = old }
})
}
/* ---------- 启动 ---------- */
// 交互弹窗骨架
var mask = document.createElement('div')
mask.className = 'tc-modal-mask'
mask.id = 'tc_modal'
mask.innerHTML =
'<div class="tc-modal">' +
'<div class="tc-modal-head">' +
'<div><h3 class="tc-modal-title">交互详情</h3><div class="tc-modal-sub"></div></div>' +
'<button class="tc-btn tc-btn-ghost" id="tc_modal_close" style="padding:5px 12px">关闭</button>' +
'</div>' +
'<div class="tc-modal-body"></div>' +
'<div class="tc-modal-foot">' +
'<button class="tc-btn tc-btn-ghost" id="tc_modal_copy" style="padding:5px 12px">复制完整交互日志</button>' +
'<span class="tc-gen-status"></span>' +
'</div>' +
'</div>'
document.body.appendChild(mask)
$('tc_modal_close').addEventListener('click', closeInteractionDialog)
mask.addEventListener('click', function (e) { if (e.target === mask) closeInteractionDialog() })
$('tc_modal_copy').addEventListener('click', function () {
var txt = mask.querySelector('.tc-modal-body').innerText
var done = function () { toast('交互日志已复制', 'success') }
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(txt).then(done, done)
else {
var ta = document.createElement('textarea')
ta.value = txt; document.body.appendChild(ta); ta.select()
try { document.execCommand('copy'); done() } catch (e) { toast('复制失败', 'error') }
document.body.removeChild(ta)
}
})
$('tc_gen').addEventListener('click', onGenerate)
$('tc_gen_last').addEventListener('click', onLoadLast)
$('tc_auth_retry').addEventListener('click', function () { location.reload() })
loadMeta().catch(function (e) {
if (isUnauthorized(e)) showAuthNote()
$('tc_form_hint').textContent = '技能元信息加载失败:' + (e && e.message || '')
})
})()
</script>
</body>
</html>
@@ -0,0 +1,148 @@
<!DOCTYPE html>
<!--
============================================================
sh_2d_rail_hike/sample/index.html —— 本技能的离线样例页(技能私有)
用途:不烧 token、不需要后端,直接打开就能看到本技能的报告长什么样。
做法:读同目录的 sample/suzhou_2026-10-17.json(手工样例)+ report_spec.json,
交给本 skill 的渲染器 tc_render.js 渲染。
【隔离用意】
1. 本页与样例数据都归【本 skill】私有:样例放在 skills/sh_2d_rail_hike/sample/。
这是隔离后修正过的口径 —— 以前样例放在 lab 级共享 sample/ 目录,A skill 的样例
会被 B skill 引用,容易串味。
2. report_spec.json 这里是技能包那份的【副本】,只为让本页能独立打开。
后端生成时用的是 skills/sh_2d_rail_hike/report_spec.json(真源),不是本副本。
改真源后请同步本副本,否则本页展示的区块顺序会和实际生成结果不一致。
3. 唯一向上引用的只有 lab 级第三方库 ../../../assets/vendor/leaflet/(无业务语义的 vendor)。
============================================================
-->
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>苏州古城水岸 · 两日高铁轻旅行|游山玩水(离线样例)</title>
<style>
/* 页面级样式,与报告样式(tcr- 前缀)完全隔离,避免互相污染 */
:root {
--tc-bg: #f6f7f9;
--tc-card: #ffffff;
--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 64px; }
.tc-top { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
.tc-top h1 { font-size: 20px; margin: 0; }
.tc-top .tc-mode { font-size: 12px; color: var(--tc-sub); }
.tc-back { font-size: 12px; color: var(--tc-accent); text-decoration: none; }
.tc-back:hover { text-decoration: underline; }
.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 14px;
}
.tc-meta b { color: var(--tc-text); font-weight: 600; }
.tc-disclaimer {
background: #fff8e6; border: 1px solid #f0d79a; color: #7a5a12;
border-radius: 8px; padding: 10px 12px; font-size: 13px; margin-bottom: 18px;
}
.tc-error { background: #fff5f4; border: 1px solid #f5c9c3; color: #b42318; border-radius: 8px; padding: 14px; font-size: 13px; }
.tc-error code { background: rgba(0,0,0,.05); padding: 1px 5px; border-radius: 4px; }
</style>
<!-- 报告样式与渲染器都归本 skill 私有(同目录一份);改本 skill 不影响其他 skill -->
<link rel="stylesheet" href="../tc_report.css" />
</head>
<body>
<div class="tc-page" id="tcr_top">
<div class="tc-top">
<h1>苏州古城水岸 · 两日高铁轻旅行</h1>
<span class="tc-mode">游山玩水 · 技能「上海周边高铁游周末两天一晚」· 离线样例</span>
<a class="tc-back" href="../index.html">← 返回本技能</a>
</div>
<div class="tc-meta" id="tc_meta">正在载入样例数据…</div>
<div class="tc-disclaimer">
<b>这是效果验证样例:</b>由技能包规则手工编写,用来验证渲染器/导出器能正确处理本技能的新词表
(共享单车 / 网约车 / 车站 / 住宿)与「每处可导航地址」。<b>其中的高铁时段、票价、店铺、酒店、
共享单车禁停区均属示意</b>,真实行程以 AI 生成 + 12306 核实为准。
</div>
<div id="tc_mount"></div>
</div>
<!-- Leaflet 为可选增强(lab 级第三方库,仅限 vendor);加载失败时渲染器自动降级为内置 SVG 点线示意 -->
<link rel="stylesheet" href="../../../assets/vendor/leaflet/leaflet.css" onerror="void 0" />
<script src="../../../assets/vendor/leaflet/leaflet.js" onerror="void 0"></script>
<script src="../tc_render.js"></script>
<script>
(function () {
var mount = document.getElementById('tc_mount')
var metaBox = document.getElementById('tc_meta')
function fail(msg, err) {
mount.innerHTML = ''
var box = document.createElement('div')
box.className = 'tc-error'
box.innerHTML = '<b>' + msg + '</b><br/>' + (err ? String(err.message || err) : '')
+ '<br/>请通过首页九宫格进入本工具后再打开本页(静态资源受导航鉴权保护)。'
mount.appendChild(box)
metaBox.textContent = '载入失败'
}
// 本技能样例的问答摘要(字段名取自本 skill 的 qa.json,与自驾 skill 完全不同)
var LABELS = {
departure_window: { sat_early: '周六早出发', sat_morning: '周六上午出发', fri_night: '周五晚出发(夜车)' },
lodging_mode: { hotel: '酒店/民宿', bathhouse: '高级洗浴(带留宿)', both: '两者都给' },
budget_level: { economy: '经济', comfort: '舒适', quality: '品质' },
luggage: { backpack: '轻装(背包)', suitcase: '带拉杆箱' },
hike_intensity: { easy: '轻松(约1h)', medium: '中等(约1.5h)', strong: '稍强(约2h)' }
}
function label(key, val) {
var m = LABELS[key]
if (m && m[val]) return m[val]
return val === undefined || val === null || val === '' ? '—' : String(val)
}
function renderMeta(payload) {
var m = payload.meta || {}
var d = m.destination || {}
var qa = m.qa || {}
metaBox.innerHTML =
'skill:<b>' + (m.skill_name || '—') + '</b>(v' + (m.skill_version || '—') + ')' +
' · 目的地:<b>' + (d.name || '—') + '</b>' + (d.admin ? '(' + d.admin + ')' : '') +
' · 出发:<b>' + label('departure_window', qa.departure_window) + '</b>' +
'(' + (qa.departure_date || '—') + ' 自 ' + (qa.departure_station || '—') + ')' +
' · 过夜:<b>' + label('lodging_mode', qa.lodging_mode) + '</b>' +
' · 档次:<b>' + label('budget_level', qa.budget_level) + '</b>' +
' · 行李:<b>' + label('luggage', qa.luggage) + '</b>' +
' · 徒步:<b>' + label('hike_intensity', qa.hike_intensity) + '</b>' +
' · 人数:<b>' + (qa.headcount === undefined ? '—' : qa.headcount) + '</b>' +
' · 生成方式:<b>' + (m.generator || '—') + '</b> · 生成时间:<b>' + (m.generated_at || '—') + '</b>(北京时间)'
}
if (!window.TCRender) { fail('渲染器未加载', null); return }
var files = ['./suzhou_2026-10-17.json', './report_spec.json']
Promise.all(files.map(function (u) {
return fetch(u + '?t=' + Date.now(), { cache: 'no-store' }).then(function (r) {
if (!r.ok) throw new Error(u + ' HTTP ' + r.status)
return r.json()
})
})).then(function (res) {
var payload = res[0]
var spec = res[1]
window.TCRender.render(payload, spec, mount)
renderMeta(payload)
}).catch(function (e) {
fail('样例数据载入失败', e)
})
})()
</script>
</body>
</html>
@@ -0,0 +1,52 @@
{
"_comment": "技能包:渲染描述。通用渲染器只认 block 类型 + 数据路径 + pick_id,不认识任何业务字段;换 skill 只换这份文件,前端渲染逻辑不用改结构。",
"_isolation_comment": "【隔离用意】本文件是【本 skill 私有的渲染描述】。本 skill 复用了与 sh_2d_car_hike 相同的 8 种 block 类型(overview/timeline/cards/map/table/cost/checklist/notes),这是刻意为之 —— block 类型是『展示形态』的抽象,够通用;而『车次表』『市内交通表』这类业务差异,靠 table 里的列定义与 pick_id 表达即可,不必新增 block 类型。所以本 skill 的渲染器只需改自己的字典(activity 中文名、marker 颜色),不必改渲染结构。这是隔离后最省力的定制方式。若将来某个 skill 真需要全新展示形态(例如航班时刻轴),那时再为它单独加 block,不影响本文件。",
"version": "1.1.1",
"block_types_comment": "通用渲染器支持的 8 种 block;本 skill 未新增 block 类型",
"block_types": ["overview", "timeline", "cards", "map", "table", "cost", "checklist", "notes"],
"layout_comment": "页面骨架:单列纵向",
"layout": "single_column",
"show_index_comment": "是否在页首显示区块锚点导航",
"show_index": true,
"sections_comment": "区块顺序即页面从上到下的顺序;data 为简单点号路径($.a.b),pick_id 用于从数组里按 id 取一项",
"sections": [
{ "id": "sec_overview", "type": "overview", "title": "行程概览", "data": "$.summary" },
{ "id": "sec_notes_top", "type": "notes", "title": "出行前必读", "data": "$.notes", "filter_levels": ["warn", "danger"] },
{ "id": "sec_timeline", "type": "timeline", "title": "两天时序", "data": "$.timeline" },
{ "id": "sec_cards", "type": "cards", "title": "节点详情(含导航地址)", "data": "$.cards", "group_by": "day" },
{ "id": "sec_map", "type": "map", "title": "点位与路线", "data": "$.map" },
{ "id": "sec_train_out", "type": "table", "title": "去程高铁 · 时段与车站", "data": "$.tables", "pick_id": "train_out_options" },
{ "id": "sec_train_back", "type": "table", "title": "返程高铁 · 时段与车站", "data": "$.tables", "pick_id": "train_back_options" },
{ "id": "sec_city_transport", "type": "table", "title": "市内交通方案 · 共享单车 / 网约车", "data": "$.tables", "pick_id": "city_transport" },
{ "id": "sec_lodging", "type": "table", "title": "住宿候选对比", "data": "$.tables", "pick_id": "lodging_compare" },
{ "id": "sec_food", "type": "table", "title": "三餐候选一览", "data": "$.tables", "pick_id": "food_summary" },
{ "id": "sec_cost", "type": "cost", "title": "费用合计", "data": "$.cost" },
{ "id": "sec_checklist", "type": "checklist", "title": "待核实清单", "data": "$.checklist" },
{ "id": "sec_notes", "type": "notes", "title": "提示与免责", "data": "$.notes" }
],
"confidence_badge_comment": "置信度徽标文案映射(渲染器通用逻辑,不由 skill 硬编)",
"confidence_badge": {
"high": "已核实",
"medium": "待确认",
"low": "需核实"
},
"image_disclaimer_default_comment": "图片一律按示意图处理,不冒充实景",
"image_disclaimer_default": "AI 示意图,非实景",
"map_route_disclaimer_default": "点位顺序示意,非真实道路/轨迹;坐标按概略值处理,导航请以导航地址为准"
}
@@ -0,0 +1,486 @@
{
"_comment": "【离线样例 · 手工编写,仅用于渲染验证与产物预览】本文件是 sh_2d_rail_hike 的一份完整 payload 示例,用来:(1) 验证渲染器/导出器能正确处理本 skill 的新词表(共享单车/网约车/车站/住宿)与 address 字段;(2) 让用户在不想烧 token 时也能先看报告长什么样。注意:本样例中的高铁时段、票价、店铺、酒店、共享单车禁停区等均属【示意】,不代表真实情况,真实行程以 AI 生成 + 12306 核实为准。",
"meta": {
"skill_id": "sh_2d_rail_hike",
"skill_name": "上海周边高铁游周末两天一晚",
"skill_version": "1.1.1",
"schema_version": "1.1.1",
"destination": {
"key": "suzhou",
"name": "苏州",
"admin": "江苏省苏州市",
"center": [31.299, 120.585]
},
"qa": {
"destination": "苏州",
"departure_station": "上海虹桥站",
"departure_window": "sat_morning",
"departure_date": "2026-10-17",
"lodging_mode": "bathhouse",
"budget_level": "comfort",
"headcount": 1,
"luggage": "backpack",
"hike_intensity": "medium"
},
"generated_at": "2026-09-30 10:20:00",
"generator": "manual_sample",
"disclaimer": "本样例内容含标注为「待确认 / 需核实」的信息,出行前请按『待核实清单』自行复核。"
},
"summary": {
"title": "苏州古城水岸 · 两日高铁轻旅行",
"one_liner": "虹桥半小时直达:环古城河步道走一段,下午赶在闭园前看拙政园或苏博,晚饭选本地人爱去的馆子,饭后把 19:00-21:00 交给平江路夜景与观前街夜市;泡个本地大浴场留宿一夜,隔天金鸡湖环湖收尾再返沪。",
"tags": ["高铁半小时", "古城水岸", "白天景点", "夜逛闹市", "本地人饭馆", "洗浴留宿"],
"stats": [
{ "label": "去程车程", "value": "约 25 分钟", "hint": "上海虹桥 → 苏州站" },
{ "label": "徒步合计", "value": "约 3 小时", "hint": "两段各约 1.5 小时,均为平路" },
{ "label": "周六夜夜逛", "value": "19:00-21:00", "hint": "平江路夜景 + 观前街夜市,自由活动" },
{ "label": "返程死线", "value": "21:00 前抵沪", "hint": "周日下午出发,为地铁末班留余量" }
]
},
"timeline": {
"days": [
{
"label": "第一天",
"date": "2026-10-17(周六)",
"rows": [
{ "time": "07:40", "title": "抵达上海虹桥站,安检进站", "anchor_id": "train_out", "kind": "soft", "activity": "train", "note": "大站建议提前 40 分钟到,含安检与检票" },
{ "time": "08:20", "title": "乘高铁赴苏州站(约 25 分钟)", "anchor_id": "train_out", "kind": "soft", "activity": "train", "note": "建议时段 08:00-09:30;实际车次以 12306 为准" },
{ "time": "09:00", "title": "苏州站出站,扫码取共享单车", "anchor_id": "city_mobility", "kind": "soft", "activity": "bike", "note": "站前广场有指定单车停放区;带拉杆箱请改打车" },
{ "time": "09:20", "title": "骑行至酒店寄存行李", "anchor_id": "station_to_lodging", "kind": "soft", "activity": "bike", "note": "约 3km,骑行 15 分钟;14:00 后方可入住,先寄存" },
{ "time": "12:00", "title": "平江路北段午饭(本地人爱去 + 评分 4.4)", "anchor_id": "lunch_d1", "kind": "soft", "activity": "eat", "note": "临河本地苏帮菜小馆,人均约 90 元;非游客主街、本地评价占比高" },
{ "time": "14:00", "title": "环古城河步道徒步(相门—平门段)", "anchor_id": "hike_1", "kind": "soft", "activity": "hike", "note": "平路水岸,累计爬升约 0" },
{ "time": "15:40", "title": "拙政园 / 苏州博物馆(白天开放,务必赶在最后入场前)", "anchor_id": "spot_d1", "kind": "soft", "activity": "walk", "note": "拙政园约 16:30 停止入场、苏博约 16:00 停止入馆;徒步结束后直接前往" },
{ "time": "17:30", "title": "观前街晚饭(本地人爱去 + 评分 4.3,19:00 前吃完)", "anchor_id": "dinner_d1", "kind": "soft", "activity": "eat", "note": "巷内本地家常菜馆,与午饭菜系区分;吃完步行 10 分钟即可开始夜逛" },
{ "time": "19:00", "title": "夜逛:平江路夜景 + 观前街夜市(自由活动)", "anchor_id": "night_stroll", "kind": "soft", "activity": "stroll", "note": "可选去处 ≥3 个,不强制顺序;地铁末班约 22:30,返回可打车" },
{ "time": "21:20", "title": "返回住处 · 入住本地大型浴场(留宿)", "anchor_id": "lodging_night", "kind": "hard", "activity": "checkin", "note": "不晚于 22:30 抵达;需确认是否可过夜" }
]
},
{
"label": "第二天",
"date": "2026-10-18(周日)",
"rows": [
{ "time": "07:20", "title": "住处附近本地早点", "anchor_id": "breakfast_d2", "kind": "soft", "activity": "eat", "note": "面馆 6:30 开门" },
{ "time": "08:40", "title": "打车至金鸡湖,环湖步道徒步", "anchor_id": "hike_2", "kind": "soft", "activity": "hike", "note": "带装备建议打车,约 8km、20 分钟" },
{ "time": "10:40", "title": "虎丘(与昨天景点去重,白天开放)", "anchor_id": "spot_d2", "kind": "soft", "activity": "walk", "note": "距苏州站约 6km,返程前需留足车程" },
{ "time": "13:20", "title": "取行李,打车赴苏州站(返程起点)", "anchor_id": "train_back", "kind": "soft", "activity": "taxi", "note": "返程前一站;留出 40 分钟安检检票缓冲" },
{ "time": "14:30", "title": "乘高铁返沪(约 25 分钟)", "anchor_id": "train_back", "kind": "hard", "activity": "train", "note": "周日必须下午出发,最晚 21:00 抵沪;实际车次以 12306 为准" },
{ "time": "15:10", "title": "抵达上海虹桥站,地铁返家", "anchor_id": "train_back", "kind": "soft", "activity": "metro", "note": "返程终点;虹桥 2/10/17 号线,末班约 22:30 后,距 21:00 死线尚早" }
]
}
]
},
"cards": [
{
"id": "card_train_out", "anchor_id": "train_out", "day": 1,
"title": "去程高铁 · 时段与车站",
"subtitle": "虹桥 → 苏州站,班次极密",
"badges": [{ "text": "建议时段 08:00-09:30", "tone": "info" }],
"fields": [
{ "label": "出发站", "value": "上海虹桥站" },
{ "label": "到达站", "value": "苏州站" },
{ "label": "建议时段", "value": { "text": "08:00-09:30(班次密集,高峰约 10 分钟一班)", "confidence": "low", "verify": "车次与时刻以 12306 实时查询为准" } },
{ "label": "历时", "value": { "text": "23-35 分钟", "confidence": "low", "verify": "以 12306 查询结果为准" } },
{ "label": "二等座参考票价", "value": { "text": "¥40-50", "confidence": "low", "verify": "票价按区间示意,实际以 12306 为准" } },
{ "label": "进站缓冲", "value": "虹桥为大站,建议提前 40 分钟到站" },
{ "label": "导航", "value": "上海市闵行区申贵路1500号 上海虹桥站" }
],
"bullets": [
"本技能不给具体车次号(模型无法查 12306,写出来必然是编的)",
"苏州站出站后即为地铁 2/4 号线,站前广场设共享单车停放区"
]
},
{
"id": "card_city_mobility", "anchor_id": "city_mobility", "day": 1,
"title": "市内交通方案 · 共享单车 + 网约车",
"subtitle": "苏州老城禁停区较多,先看清再骑",
"badges": [{ "text": "预估市内交通约 ¥45", "tone": "info" }],
"fields": [
{ "label": "策略", "value": "3km 内优先共享单车;超过 5km 或带行李时打车;老城核心区一律步行" },
{ "label": "共享单车", "value": { "text": "美团 / 哈啰 / 青桔均可,扫码开锁;需确认当地在运营区", "confidence": "medium", "verify": "打开对应 App 查看是否显示可租车辆" } },
{ "label": "禁停区", "value": { "text": "平江路、观前街、拙政园周边大片区域禁行禁停,须停到外围指定停车点", "confidence": "low", "verify": "以 App 内显示的 P 点为准,出发前查看" } },
{ "label": "网约车上车点", "value": { "text": "苏州站北广场西侧临时停靠点;平江路口禁停,建议在干将东路侧上车", "confidence": "low", "verify": "叫车后以司机约定的上车点为准" } },
{ "label": "预估费用", "value": "共享单车按 1.5 元/15 分钟计;网约车起步约 ¥10,全程约 ¥45" },
{ "label": "行李约束", "value": "本次为轻装背包,可骑车;若改带拉杆箱则全程改打车并先寄存" },
{ "label": "导航", "value": "苏州市姑苏区干将东路(平江路口西侧临时停靠点,网约车常用上车点)" }
]
},
{
"id": "card_station_to_lodging", "anchor_id": "station_to_lodging", "day": 1,
"title": "出站接驳 + 行李寄存",
"subtitle": "先寄存,再开逛",
"fields": [
{ "label": "接驳方式", "value": "苏州站 → 酒店约 3km,共享单车 15 分钟 / 网约车 10 分钟" },
{ "label": "顺序", "value": "多数酒店 14:00 后才可入住,早到必须先免费寄存行李再出行" },
{ "label": "导航", "value": { "text": "苏州市姑苏区临顿路(观前街南段)酒店集群", "confidence": "medium", "verify": "具体酒店以实际预订为准" } }
]
},
{
"id": "card_lunch_d1", "anchor_id": "lunch_d1", "day": 1,
"title": "第一天午饭 · 平江路北段",
"subtitle": "本地人爱去 + 评分好,人均约 ¥90",
"fields": [
{ "label": "推荐业态", "value": { "text": "平江路北段临河的本地苏帮菜小馆(苏式汤面、松鼠鳜鱼小份、时令河鲜)", "confidence": "low", "verify": "不编造店名;到店前用高德/大众点评搜索『平江路 苏帮菜』按评分挑选" } },
{ "label": "为什么是本地人爱去", "value": { "text": "位于平江路北段非游客主街、以本地招牌菜(苏式汤面)为主、本地评价占比高", "confidence": "low", "verify": "查看大众点评评价里本地用户占比与复购提及" } },
{ "label": "评分", "value": { "text": "大众点评约 4.3-4.5,评价量数千条级", "confidence": "low", "verify": "以大众点评/高德实时显示为准(评分会随时间变化)" } },
{ "label": "人均", "value": "约 ¥90(单人点面 + 一菜即可)" },
{ "label": "到达方式", "value": "从寄存点步行约 12 分钟;沿途为步行街区,不可骑入" },
{ "label": "导航", "value": "苏州市姑苏区平江路历史街区北段(白塔东路入口)" }
]
},
{
"id": "card_hike_1", "anchor_id": "hike_1", "day": 1,
"title": "第一段徒步 · 环古城河步道(相门—平门段)",
"subtitle": "平路水岸,约 1.5 小时",
"badges": [{ "text": "平路 · 累计爬升约 0", "tone": "warn" }],
"fields": [
{ "label": "起点", "value": "相门城墙遗址(苏州市姑苏区干将东路相门桥)" },
{ "label": "终点", "value": "平门桥(苏州市姑苏区人民路北端)" },
{ "label": "方式", "value": "单程走,不走回头路;全程沿河,约 4.5km" },
{ "label": "耗时", "value": "约 1.5 小时(按 3km/h 慢走折算,含驻足看景)" },
{ "label": "路面", "value": "塑胶步道 + 石板路,平坦好走,适合运动鞋" },
{ "label": "补水点", "value": "沿途有便利店与驿站,可买水" },
{ "label": "到达起点", "value": "从午饭点步行约 15 分钟;若骑车,须停在相门桥南侧指定停车点" },
{ "label": "导航", "value": "江苏省苏州市姑苏区干将东路 相门桥(相门城墙遗址)" }
],
"bullets": [
"已触发地形降级 terrain_flat:苏州无山,本段为平路徒步,累计爬升约 0,运动量按时长与步数计",
"想出汗可提高配速或走两个来回"
]
},
{
"id": "card_spot_d1", "anchor_id": "spot_d1", "day": 1,
"title": "周六下午 · 拙政园 / 苏州博物馆(白天开放)",
"subtitle": "走完步道直接去,务必赶在最后入场前",
"badges": [{ "text": "白天开放 · 需赶在停止入场前", "tone": "warn" }],
"fields": [
{ "label": "看点", "value": "拙政园为中国四大名园之一,明代造园代表;苏州博物馆由贝聿铭设计,建筑本身就是看点" },
{ "label": "开放时间", "value": { "text": "拙政园旺季约 7:30-17:30;苏博约 9:00-17:00(周一闭馆)", "confidence": "low", "verify": "以官方公众号公布时间为准" } },
{ "label": "最后入场时间", "value": { "text": "拙政园约 16:30 停止入场;苏博约 16:00 停止入馆(闭馆前 30-60 分钟停止入场,这比闭馆时间更关键)", "confidence": "low", "verify": "官方公众号或购票页公示「停止入场时间」" } },
{ "label": "时间校验", "value": "徒步 15:30 结束 → 打车/步行约 10 分钟到东北街 → 约 15:40 抵达,仍有约 50 分钟入场余量" },
{ "label": "门票", "value": { "text": "拙政园旺季约 ¥70-90;苏博免费", "confidence": "low", "verify": "以官方渠道为准" } },
{ "label": "预约", "value": { "text": "苏州博物馆需提前在官方渠道预约,旺季名额紧张", "confidence": "low", "verify": "出发前 3-7 天在官方公众号预约" } },
{ "label": "与次日去重", "value": "第二天 spot_d2 改用虎丘或盘门景区,不与此处重复" },
{ "label": "导航", "value": "江苏省苏州市姑苏区东北街 拙政园/苏州博物馆" }
],
"bullets": ["本槽位核心是「白天开放」:若当天来不及,必须改选更近的景点或缩短徒步,绝不允许安排到门口已停止入场"]
},
{
"id": "card_dinner_d1", "anchor_id": "dinner_d1", "day": 1,
"title": "第一天晚饭 · 观前街(本地人爱去 + 评分好)",
"subtitle": "与午饭换菜系,19:00 前吃完,直接接夜逛",
"fields": [
{ "label": "推荐业态", "value": { "text": "观前街巷内本地家常菜馆(与午饭的苏帮菜区分,可选苏式面/馄饨/砂锅)", "confidence": "low", "verify": "不编造店名;按大众点评本地评价占比挑选" } },
{ "label": "为什么是本地人爱去", "value": { "text": "位于观前街巷内而非主街、以本地家常菜为主、本地评价占比高", "confidence": "low", "verify": "查看大众点评评价里本地用户占比" } },
{ "label": "评分", "value": { "text": "大众点评约 4.3-4.5,评价量千条级", "confidence": "low", "verify": "以大众点评/高德实时显示为准" } },
{ "label": "营业时间", "value": { "text": "多数至 21:00-22:00", "confidence": "medium", "verify": "到店前电话或 App 确认打烊时间" } },
{ "label": "时间约束", "value": "必须 19:00 前吃完,把 19:00-21:00 留给夜逛;不得选上菜慢或长队排队的店" },
{ "label": "到夜逛起点", "value": "出门即观前街夜市,步行 0 分钟;平江路夜景步行约 10 分钟" },
{ "label": "人均", "value": "约 ¥80" },
{ "label": "导航", "value": "江苏省苏州市姑苏区观前街(玄妙观东侧巷内)" }
]
},
{
"id": "card_night_stroll", "anchor_id": "night_stroll", "day": 1,
"title": "周六夜 · 夜逛闹市(自由活动,19:00-21:00)",
"subtitle": "平江路夜景 + 观前街夜市,不强制顺序,自己挑",
"badges": [{ "text": "自由活动 · 约 2 小时", "tone": "info" }],
"fields": [
{ "label": "为什么有这一段", "value": "用户反馈过:原来 18:00 吃完晚饭就直接回住处,19:00-21:00 空着太浪费。所以晚饭提前,这两小时用来夜逛" },
{ "label": "可逛范围", "value": "闹市步行街 + 历史街区夜景(平江路夜里挂灯,观前街为夜间商圈)" },
{ "label": "可选去处 1", "value": "平江路夜间段(临河灯笼夜景,安静适合拍照)— 江苏省苏州市姑苏区平江路历史街区(南入口:干将东路)" },
{ "label": "可选去处 2", "value": "观前街夜间商圈(玄妙观前广场,热闹、小吃多)— 江苏省苏州市姑苏区观前街 玄妙观" },
{ "label": "可选去处 3", "value": "山塘街夜景(七里山塘,需打车约 10 分钟)— 江苏省苏州市姑苏区山塘街(渡僧桥入口)" },
{ "label": "夜间是否开放", "value": { "text": "平江路与观前街为开放街区,夜间常开;沿街店铺多数营业至 21:00-22:00", "confidence": "low", "verify": "夜市与灯光开放时间会随季节调整,出发前确认" } },
{ "label": "夜间交通", "value": { "text": "苏州地铁 2/4 号线末班约 22:30 前后;该时段网约车可正常叫到;共享单车夜间可用但老城禁停区仍生效", "confidence": "medium", "verify": "地铁末班时刻以官方 App 为准" } },
{ "label": "返回住处", "value": "夜逛区域距住处约 1km,步行 12 分钟;打车约 8 分钟 / ¥12" },
{ "label": "安全提示", "value": "夜间人流较多,注意随身财物;沿河路段注意照明较暗处" },
{ "label": "导航", "value": "夜逛主区域:江苏省苏州市姑苏区平江路历史街区(南入口:干将东路);另两处见上方可选去处" }
],
"bullets": [
"自由活动性质:以上 3 处任选,不要求走完;累了随时可回住处",
"本段结束时间控制在 21:00 左右,回住处后仍有充裕时间洗漱休息"
]
},
{
"id": "card_lodging_night", "anchor_id": "lodging_night", "day": 1,
"title": "住宿 · 本地大型浴场(留宿)",
"subtitle": "已按你的选择:高级洗浴带留宿",
"badges": [{ "text": "硬时间点:不晚于 22:30 抵达", "tone": "warn" }],
"fields": [
{ "label": "形态", "value": "高级洗浴(带留宿)——按 lodging_mode=bathhouse 执行" },
{ "label": "规模依据", "value": { "text": "营业面积通常在数千平方米量级,本地评价量级高(本地人复购为主)", "confidence": "low", "verify": "出发前电话确认营业面积、是否可过夜、是否需预约" } },
{ "label": "能否过夜", "value": { "text": "部分浴场仅营业到凌晨,不可留宿;必须电话确认 24 小时营业或有安静休息区", "confidence": "low", "verify": "致电前台确认『能否过夜、是否有独立休息区』" } },
{ "label": "男女分区", "value": { "text": "通常分男女浴区,休息大厅为公共区域", "confidence": "medium", "verify": "到店确认" } },
{ "label": "人均", "value": "约 ¥120-180(含过夜)" },
{ "label": "次日寄存", "value": "次日离店后一般可免费寄存行李,便于继续游玩;需前台确认" },
{ "label": "导航", "value": { "text": "苏州市姑苏区观前街周边大型洗浴中心(以实际选定门店为准)", "confidence": "low", "verify": "选店后请以门店地址导航" } }
],
"bullets": ["已排除社区小浴室:本条要求规模较大、档次中上"]
},
{
"id": "card_breakfast_d2", "anchor_id": "breakfast_d2", "day": 2,
"title": "第二天早饭 · 本地面馆",
"subtitle": "7 点前已营业",
"fields": [
{ "label": "方案", "value": "店内吃:本地苏式面馆(枫镇大肉面/焖肉面时令)" },
{ "label": "开门时间", "value": { "text": "多数面馆 6:00-6:30 开门", "confidence": "medium", "verify": "出发前一晚确认次日开门时间" } },
{ "label": "距离", "value": "从住处步行约 8 分钟" },
{ "label": "导航", "value": "苏州市姑苏区观前街周边(临顿路沿线本地面馆)" }
]
},
{
"id": "card_hike_2", "anchor_id": "hike_2", "day": 2,
"title": "第二段徒步 · 金鸡湖环湖步道",
"subtitle": "与昨天的古城水岸形成对比:现代湖景",
"badges": [{ "text": "平路 · 累计爬升约 0", "tone": "warn" }],
"fields": [
{ "label": "起点", "value": "金鸡湖西岸 · 东方之门前广场(苏州市工业园区星港街)" },
{ "label": "终点", "value": "湖东 · 摩天轮乐园段,或原路折返" },
{ "label": "方式", "value": "环湖步道,可走一段折返;全程约 5km" },
{ "label": "耗时", "value": "约 1.5 小时" },
{ "label": "路面", "value": "木栈道 + 铺装步道,平坦" },
{ "label": "到达起点", "value": "从住处打车约 20 分钟 / ¥25(约 8km,带装备建议打车)" },
{ "label": "导航", "value": "江苏省苏州市工业园区星港街 东方之门(金鸡湖西岸)" }
],
"bullets": ["已触发地形降级 terrain_flat:本段同属平路,运动量按时长与步数计"]
},
{
"id": "card_spot_d2", "anchor_id": "spot_d2", "day": 2,
"title": "返程前穿插 · 虎丘(与昨天景点去重)",
"subtitle": "白天开放,与苏州站顺路",
"fields": [
{ "label": "看点", "value": "虎丘有「吴中第一名胜」之称,云岩寺塔(虎丘塔)为斜塔代表,历史可上溯春秋" },
{ "label": "去重说明", "value": "第一天的 spot_d1 已用拙政园/苏州博物馆,本处改用虎丘,两处不重复" },
{ "label": "开放时间", "value": { "text": "约 7:30-17:30(旺季),闭园前 30-60 分钟停止入场", "confidence": "low", "verify": "以官方公众号公布时间为准" } },
{ "label": "门票", "value": { "text": "旺季约 ¥70-80", "confidence": "low", "verify": "以官方渠道为准" } },
{ "label": "预约", "value": { "text": "通常无需预约,但旺季建议线上购票", "confidence": "low", "verify": "官方公众号购票页确认" } },
{ "label": "衔接返程", "value": "虎丘距苏州站约 6km,打车约 20 分钟;返程前务必留足车程 + 40 分钟安检缓冲" },
{ "label": "导航", "value": "江苏省苏州市姑苏区虎丘山门内8号 虎丘" }
]
},
{
"id": "card_train_back", "anchor_id": "train_back", "day": 2,
"title": "返程高铁 · 周日 14:30 发车,21:00 前抵沪",
"subtitle": "死线约束:周日必须下午返",
"badges": [{ "text": "21:00 前必须抵沪", "tone": "danger" }],
"fields": [
{ "label": "出发站", "value": "苏州站" },
{ "label": "到达站", "value": "上海虹桥站" },
{ "label": "建议时段", "value": { "text": "14:00-17:00 发车(越早越稳)", "confidence": "low", "verify": "车次与时刻以 12306 实时查询为准" } },
{ "label": "历时", "value": { "text": "23-35 分钟", "confidence": "low", "verify": "以 12306 查询结果为准" } },
{ "label": "二等座参考票价", "value": { "text": "¥40-50", "confidence": "low", "verify": "票价按区间示意,实际以 12306 为准" } },
{ "label": "到站缓冲", "value": "从最后一个景点到苏州站约 15 分钟,另留 40 分钟安检检票" },
{ "label": "抵沪后回家", "value": "虹桥站地铁 2/10/17 号线,末班约 22:30 后;本次 15:10 抵沪,时间充裕" },
{ "label": "导航", "value": "江苏省苏州市姑苏区苏站路27号 苏州站" }
],
"bullets": [
"周日下午为返程高峰,票源紧张且车站人多,建议提前购票",
"本技能不给具体车次号;请以 12306 为准并核对发到时刻"
]
}
],
"map": {
"center": [31.299, 120.585],
"zoom": 12,
"markers": [
{ "id": "m_hongqiao", "name": "上海虹桥站", "kind": "start", "lat": 31.1948, "lng": 121.325, "coord_precision": "approx", "day": 1, "seq": 1, "address": "上海市闵行区申贵路1500号 上海虹桥站", "note": "去程出发站,提前 40 分钟到" },
{ "id": "m_suzhou_station", "name": "苏州站", "kind": "train_station", "lat": 31.326, "lng": 120.605, "coord_precision": "approx", "day": 1, "seq": 2, "address": "江苏省苏州市姑苏区苏站路27号 苏州站", "note": "去程到达站 / 返程出发站" },
{ "id": "m_bike_pickup", "name": "共享单车停放区(站前广场)", "kind": "bike", "lat": 31.3245, "lng": 120.607, "coord_precision": "approx", "day": 1, "seq": 3, "address": "江苏省苏州市姑苏区苏站路 苏州站北广场共享单车停放区", "note": "扫码取车;以 App 显示的可租车辆为准" },
{ "id": "m_hotel", "name": "住宿(观前街/临顿路一带)", "kind": "lodging", "lat": 31.3096, "lng": 120.6226, "coord_precision": "approx", "day": 1, "seq": 4, "address": "江苏省苏州市姑苏区临顿路(观前街南段)", "note": "先寄存行李再出行" },
{ "id": "m_lunch", "name": "午饭 · 平江路北段", "kind": "food", "lat": 31.3152, "lng": 120.6289, "coord_precision": "approx", "day": 1, "seq": 5, "address": "江苏省苏州市姑苏区平江路历史街区北段(白塔东路入口)", "note": "步行街区,不可骑入;本地人爱去 + 评分 4.4" },
{ "id": "m_hike1_start", "name": "相门城墙遗址(徒步起点)", "kind": "hike", "lat": 31.3167, "lng": 120.6385, "coord_precision": "approx", "day": 1, "seq": 6, "address": "江苏省苏州市姑苏区干将东路 相门桥", "note": "单车须停南侧指定停车点" },
{ "id": "m_hike1_end", "name": "平门桥(徒步终点)", "kind": "hike", "lat": 31.3238, "lng": 120.6148, "coord_precision": "approx", "day": 1, "seq": 7, "address": "江苏省苏州市姑苏区人民路北端 平门桥", "note": "单程 4.5km,平路" },
{ "id": "m_spot_d1", "name": "拙政园 / 苏州博物馆(白天开放)", "kind": "spot", "lat": 31.3242, "lng": 120.6294, "coord_precision": "approx", "day": 1, "seq": 8, "address": "江苏省苏州市姑苏区东北街 拙政园/苏州博物馆", "note": "务必赶在停止入场前(拙政园约 16:30、苏博约 16:00);苏博需预约" },
{ "id": "m_dinner", "name": "晚饭 · 观前街巷内", "kind": "food", "lat": 31.3109, "lng": 120.6231, "coord_precision": "approx", "day": 1, "seq": 9, "address": "江苏省苏州市姑苏区观前街(玄妙观东侧巷内)", "note": "本地人爱去 + 评分 4.3;须 19:00 前吃完" },
{ "id": "m_pingjiang", "name": "平江路夜景区(夜逛主区域)", "kind": "night_market", "lat": 31.3162, "lng": 120.6275, "coord_precision": "approx", "day": 1, "seq": 10, "address": "江苏省苏州市姑苏区平江路历史街区(南入口:干将东路)", "note": "夜逛自由活动;沿河灯光夜景;禁停共享单车" },
{ "id": "m_guangqian", "name": "观前街夜市 · 玄妙观", "kind": "district", "lat": 31.3105, "lng": 120.6248, "coord_precision": "approx", "day": 1, "seq": 11, "address": "江苏省苏州市姑苏区观前街 玄妙观", "note": "夜逛可选去处 2;夜间商圈,小吃多" },
{ "id": "m_bathhouse", "name": "住宿 · 大型浴场(留宿)", "kind": "bathhouse", "lat": 31.3082, "lng": 120.6301, "coord_precision": "approx", "day": 1, "seq": 12, "address": "江苏省苏州市姑苏区观前街周边大型洗浴中心", "note": "需确认能否过夜;夜逛结束后返回" },
{ "id": "m_breakfast", "name": "早饭 · 临顿路面馆", "kind": "food", "lat": 31.3088, "lng": 120.6262, "coord_precision": "approx", "day": 2, "seq": 13, "address": "江苏省苏州市姑苏区临顿路沿线本地面馆", "note": "6:30 前已开门" },
{ "id": "m_taxi_call", "name": "网约车上车点(干将东路侧)", "kind": "taxi_point", "lat": 31.3121, "lng": 120.6282, "coord_precision": "approx", "day": 2, "seq": 14, "address": "江苏省苏州市姑苏区干将东路(平江路口西侧临时停靠点)", "note": "平江路口禁停,在此上车" },
{ "id": "m_jinji_lake", "name": "金鸡湖西岸 · 东方之门(徒步起点)", "kind": "hike", "lat": 31.3175, "lng": 120.6742, "coord_precision": "approx", "day": 2, "seq": 15, "address": "江苏省苏州市工业园区星港街 东方之门", "note": "湖景步道,平坦" },
{ "id": "m_motianlun", "name": "金鸡湖东岸 · 摩天轮乐园段", "kind": "hike", "lat": 31.3155, "lng": 120.7219, "coord_precision": "approx", "day": 2, "seq": 16, "address": "江苏省苏州市工业园区华池街 金鸡湖摩天轮乐园", "note": "徒步终点,可折返" },
{ "id": "m_huqiu", "name": "虎丘(或用盘门景区替代)", "kind": "spot", "lat": 31.3376, "lng": 120.5756, "coord_precision": "approx", "day": 2, "seq": 17, "address": "江苏省苏州市姑苏区虎丘山门内8号 虎丘", "note": "与第一天 spot_d1(拙政园/苏博)去重;距苏州站约 6km" },
{ "id": "m_back_station", "name": "苏州站(返程)", "kind": "end", "lat": 31.326, "lng": 120.605, "coord_precision": "approx", "day": 2, "seq": 18, "address": "江苏省苏州市姑苏区苏站路27号 苏州站", "note": "留 40 分钟缓冲" },
{ "id": "m_hongqiao_back", "name": "上海虹桥站(抵沪)", "kind": "end", "lat": 31.1948, "lng": 121.325, "coord_precision": "approx", "day": 2, "seq": 19, "address": "上海市闵行区申贵路1500号 上海虹桥站", "note": "地铁 2/10/17 号线返家" }
],
"routes": [
{ "id": "r_train_out", "name": "去程高铁", "kind": "train", "marker_ids": ["m_hongqiao", "m_suzhou_station"], "color": "#1d4ed8", "dashed": true, "note": "点位顺序示意,非真实铁路线;实际车次以 12306 为准" },
{ "id": "r_bike_to_hotel", "name": "共享单车接驳", "kind": "bike", "marker_ids": ["m_suzhou_station", "m_bike_pickup", "m_hotel"], "color": "#0ea5e9", "dashed": false, "note": "点位顺序示意,非真实骑行路径" },
{ "id": "r_hike_1", "name": "环古城河步道", "kind": "hike", "marker_ids": ["m_hike1_start", "m_hike1_end"], "color": "#2e9e5b", "dashed": false, "note": "点位顺序示意,非真实道路/轨迹" },
{ "id": "r_to_spot_d1", "name": "徒步终点 → 白天景点", "kind": "stroll", "marker_ids": ["m_hike1_end", "m_spot_d1"], "color": "#059669", "dashed": false, "note": "点位顺序示意,非真实道路;步行约 10 分钟,须赶在停止入场前" },
{ "id": "r_night_stroll", "name": "夜逛动线(自由活动)", "kind": "stroll", "marker_ids": ["m_dinner", "m_guangqian", "m_pingjiang"], "color": "#7c2d92", "dashed": false, "note": "点位顺序示意,非真实道路;夜逛自由活动,不强制顺序,可随时返回住处" },
{ "id": "r_taxi_jinji", "name": "网约车至金鸡湖", "kind": "taxi", "marker_ids": ["m_taxi_call", "m_jinji_lake"], "color": "#f59e0b", "dashed": true, "note": "点位顺序示意,非真实行驶路径" },
{ "id": "r_hike_2", "name": "金鸡湖环湖步道", "kind": "hike", "marker_ids": ["m_jinji_lake", "m_motianlun"], "color": "#2e9e5b", "dashed": false, "note": "点位顺序示意,非真实道路/轨迹" },
{ "id": "r_train_back", "name": "返程高铁", "kind": "train", "marker_ids": ["m_back_station", "m_hongqiao_back"], "color": "#d14343", "dashed": true, "note": "点位顺序示意,非真实铁路线;最晚 21:00 抵沪" }
]
},
"tables": [
{
"id": "train_out_options",
"title": "去程高铁 · 时段与车站",
"columns": [
{ "key": "window", "label": "建议时段" },
{ "key": "from_station", "label": "出发站" },
{ "key": "to_station", "label": "到达站" },
{ "key": "duration", "label": "历时", "align": "right" },
{ "key": "fare", "label": "二等座参考票价", "align": "right" },
{ "key": "note", "label": "备注" }
],
"rows": [
{ "_key": "out_early", "window": "07:30-08:30", "from_station": "上海虹桥站", "to_station": "苏州站", "duration": "23-35 分钟", "fare": "¥40-50", "note": "最早到,第一天时间最充裕(推荐)" },
{ "_key": "out_mid", "window": "08:30-09:30", "from_station": "上海虹桥站", "to_station": "苏州站", "duration": "23-35 分钟", "fare": "¥40-50", "note": "本次选用;到站后仍可从容吃午饭" }
],
"note": "本表为时段建议,非具体车次。本技能不给车次号(模型无法查询 12306,写出的车次号必然是编造的);车次、时刻与票价请以 12306 实时查询为准。"
},
{
"id": "train_back_options",
"title": "返程高铁 · 时段与车站(死线 21:00 抵沪)",
"columns": [
{ "key": "window", "label": "建议时段" },
{ "key": "from_station", "label": "出发站" },
{ "key": "to_station", "label": "到达站" },
{ "key": "duration", "label": "历时", "align": "right" },
{ "key": "fare", "label": "二等座参考票价", "align": "right" },
{ "key": "note", "label": "备注" }
],
"rows": [
{ "_key": "back_mid", "window": "14:00-15:30", "from_station": "苏州站", "to_station": "上海虹桥站", "duration": "23-35 分钟", "fare": "¥40-50", "note": "本次选用;抵沪后地铁余量充足(推荐)" },
{ "_key": "back_late", "window": "16:00-17:00", "from_station": "苏州站", "to_station": "上海虹桥站", "duration": "23-35 分钟", "fare": "¥40-50", "note": "仍可满足 21:00 前抵沪,但余量变小;周日下午票源紧张" }
],
"highlight_row_keys": ["back_mid"],
"note": "本表为时段建议,非具体车次。周日必须下午出发,最晚 21:00 抵达上海(周一上班,晚了没地铁)。周日下午返程高峰,建议提前购票。"
},
{
"id": "city_transport",
"title": "市内交通方案 · 共享单车 / 网约车",
"columns": [
{ "key": "leg", "label": "路段" },
{ "key": "mode", "label": "方式" },
{ "key": "distance", "label": "距离/时长", "align": "right" },
{ "key": "cost", "label": "预估费用", "align": "right" },
{ "key": "tip", "label": "提示" }
],
"rows": [
{ "_key": "t1", "leg": "苏州站 → 酒店", "mode": "共享单车", "distance": "约 3km / 15 分钟", "cost": "¥1.5-3", "tip": "站前广场有指定停放区;带拉杆箱改打车" },
{ "_key": "t2", "leg": "酒店 → 平江路", "mode": "步行", "distance": "约 1km / 12 分钟", "cost": "¥0", "tip": "步行街禁停单车,直接走过去" },
{ "_key": "t3", "leg": "平江路 → 相门(徒步起点)", "mode": "步行", "distance": "约 1.2km / 15 分钟", "cost": "¥0", "tip": "若骑车须停相门桥南侧指定点" },
{ "_key": "t4", "leg": "观前街 → 浴场", "mode": "步行", "distance": "约 800m / 10 分钟", "cost": "¥0", "tip": "夜间城区路况好,步行即可" },
{ "_key": "t5", "leg": "住处 → 金鸡湖", "mode": "网约车", "distance": "约 8km / 20 分钟", "cost": "约 ¥25", "tip": "超过 5km 建议打车;上车点用干将东路侧" },
{ "_key": "t6", "leg": "金鸡湖 → 拙政园/苏博", "mode": "网约车", "distance": "约 9km / 22 分钟", "cost": "约 ¥28", "tip": "跨区,打车最稳" },
{ "_key": "t7", "leg": "拙政园 → 苏州站(返程)", "mode": "网约车", "distance": "约 4km / 15 分钟", "cost": "约 ¥15", "tip": "留 40 分钟安检检票缓冲,务必准时" }
],
"note": "禁停区范围与网约车上车点属易变信息,出行前请以共享单车 App 内显示的 P 点、以及叫车后司机约定的上车点为准。共享单车按 1.5 元/15 分钟估算。"
},
{
"id": "lodging_compare",
"title": "住宿候选对比",
"columns": [
{ "key": "name", "label": "候选" },
{ "key": "type", "label": "类型" },
{ "key": "price", "label": "价格区间", "align": "right" },
{ "key": "overnight", "label": "能否过夜" },
{ "key": "note", "label": "备注" }
],
"rows": [
{ "_key": "l1", "name": "观前街周边大型洗浴中心", "type": "洗浴留宿(已选)", "price": "¥120-180", "overnight": "需确认", "note": "须电话确认 24 小时营业或有安静休息区;含过夜" },
{ "_key": "l2", "name": "临顿路连锁酒店", "type": "酒店(备选)", "price": "¥380-520", "overnight": "可", "note": "14:00 入住 / 12:00 退房,多数含早,可免费寄存" },
{ "_key": "l3", "name": "平江路民宿", "type": "民宿(备选)", "price": "¥450-700", "overnight": "可", "note": "位置好但多为老宅改造,隔音一般;需确认能否寄存" }
],
"highlight_row_keys": ["l1"],
"note": "已按你的选择优先『高级洗浴留宿』;档次与能否过夜属主观/易变信息,务必出发前电话核实。"
},
{
"id": "food_summary",
"title": "三餐候选一览",
"columns": [
{ "key": "meal", "label": "餐次" },
{ "key": "area", "label": "位置" },
{ "key": "type", "label": "业态" },
{ "key": "per_capita", "label": "人均", "align": "right" },
{ "key": "access", "label": "到达方式" }
],
"rows": [
{ "_key": "f1", "meal": "D1 午餐", "area": "平江路北段", "type": "苏帮菜小馆(临河)", "per_capita": "¥90", "access": "步行 12 分钟" },
{ "_key": "f2", "meal": "D1 晚餐", "area": "观前街", "type": "本地家常菜", "per_capita": "¥80", "access": "步行 15 分钟" },
{ "_key": "f3", "meal": "D2 早餐", "area": "临顿路", "type": "苏式面馆", "per_capita": "¥25", "access": "步行 8 分钟" }
],
"note": "按单人出行设计;不编造店名,到店前用高德/大众点评按当前位置挑选本地评价占比高的店。"
}
],
"cost": {
"currency": "CNY",
"items": [
{ "label": "往返高铁票(二等座,按区间中值 ×2)", "amount": 90, "note": "¥45×2,实际以 12306 为准" },
{ "label": "住宿(洗浴留宿,含过夜)", "amount": 150, "unit": "晚", "note": "单人间基准,档次以门店为准" },
{ "label": "市内交通 · 共享单车", "amount": 5, "note": "按 1.5 元/15 分钟估" },
{ "label": "市内交通 · 网约车", "amount": 70, "note": "金鸡湖往返 + 返程赴站" },
{ "label": "餐饮(2 正餐 + 1 早餐)", "amount": 195, "note": "按人均估算" },
{ "label": "门票(拙政园,如选苏博则免)", "amount": 80, "note": "以官方票价为准" }
],
"total": 590,
"note": "高铁票价按区间中值估算,实际以 12306 为准;住宿与门票以实际预订为准。"
},
"checklist": [
{ "anchor_id": "train_out", "field": "train_schedule", "kind": "must", "confidence": "low", "text": "出发前在 12306 核对去程车次、发车时刻与到达时刻(本报告不给车次号)", "verify": "12306 App 或官网查询 上海虹桥 → 苏州站" },
{ "anchor_id": "train_back", "field": "train_schedule", "kind": "must", "confidence": "low", "text": "核对返程车次,确认最晚 21:00 前能抵达上海虹桥(返程死线)", "verify": "12306 App 查询 苏州站 → 上海虹桥,优先选 17:00 前发车" },
{ "anchor_id": "train_out", "field": "train_fare", "kind": "optional", "confidence": "low", "text": "确认二等座实际票价(报告仅给 ¥40-50 区间)", "verify": "12306 下单页显示价格" },
{ "anchor_id": "lodging_night", "field": "bathhouse_overnight_ok", "kind": "must", "confidence": "low", "text": "电话确认所选浴场是否真能过夜(部分仅营业到凌晨)", "verify": "致电前台:是否 24 小时营业、有无安静休息区、是否需预约" },
{ "anchor_id": "station_to_lodging", "field": "opening_hours", "kind": "optional", "confidence": "low", "text": "确认所选住宿能否提前免费寄存行李(多数 14:00 后才可入住)", "verify": "预订时备注早到;或致电前台确认行李寄存政策" },
{ "anchor_id": "lodging_night", "field": "bathhouse_grade", "kind": "optional", "confidence": "low", "text": "确认浴场规模与卫生(营业面积、开业年限、本地评价量级)", "verify": "大众点评查看门店信息与近期评价" },
{ "anchor_id": "city_mobility", "field": "bike_no_park_zone", "kind": "must", "confidence": "low", "text": "确认平江路/观前街周边的共享单车禁停范围与合法还车点", "verify": "打开美团/哈啰 App 查看 P 点分布" },
{ "anchor_id": "city_mobility", "field": "taxi_pickup_point", "kind": "optional", "confidence": "low", "text": "确认平江路口等禁停路段的网约车实际可上车位置", "verify": "叫车后与司机确认上车点" },
{ "anchor_id": "spot_d1", "field": "opening_hours", "kind": "must", "confidence": "low", "text": "确认拙政园/苏博当日的开放时间与「最后入场时间」(拙政园约 16:30、苏博约 16:00 停止入场,比闭馆时间更关键)", "verify": "官方公众号或购票页公示的停止入场时间" },
{ "anchor_id": "spot_d1", "field": "reservation_required", "kind": "must", "confidence": "low", "text": "苏州博物馆需提前预约(旺季名额紧张);若选拙政园需确认门票", "verify": "苏州博物馆官方公众号预约" },
{ "anchor_id": "spot_d2", "field": "opening_hours", "kind": "optional", "confidence": "low", "text": "确认虎丘当日开放时间与停止入场时间(返程前需留足车程)", "verify": "官方公众号或官网公布时间" },
{ "anchor_id": "lunch_d1", "field": "restaurant_rating", "kind": "must", "confidence": "low", "text": "核对午饭餐馆的评分与评价数(「本地人爱去 + 评分好」两条都要成立)", "verify": "大众点评/高德按「平江路 苏帮菜」筛选,看评分与本地评价占比" },
{ "anchor_id": "dinner_d1", "field": "restaurant_rating", "kind": "must", "confidence": "low", "text": "核对晚饭餐馆的评分、评价数与打烊时间(须 19:00 前能吃完)", "verify": "大众点评/高德筛选,并致电确认营业时段" },
{ "anchor_id": "night_stroll", "field": "night_market_open", "kind": "must", "confidence": "low", "text": "确认平江路/观前街夜间开放情况与沿街店铺打烊时间(会随季节调整)", "verify": "出发前查当地文旅公众号或致电街区管理方" },
{ "anchor_id": "night_stroll", "field": "opening_hours", "kind": "optional", "confidence": "medium", "text": "确认苏州地铁 2/4 号线末班时刻(夜逛结束返程用)", "verify": "苏州地铁官方 App 或站内公示" },
{ "anchor_id": "breakfast_d2", "field": "opening_hours", "kind": "optional", "confidence": "medium", "text": "确认面馆次日开门时间(7 点前是否已营业)", "verify": "前一晚现场确认或致电" }
],
"notes": [
{
"level": "danger",
"title": "返程死线:周日 21:00 前必须抵达上海",
"text": "周日务必下午出发返程,最晚 21:00 抵达上海,为地铁末班留出余量(周一要上班)。本次建议 14:00-16:00 发车;若所选班次偏晚请改早班次。抵沪后如接近末班时间,请直接打车回家。"
},
{
"level": "warn",
"title": "本技能不给高铁车次号",
"text": "AI 无法查询 12306,写出的车次号必然是编造的,会误导你赶错车。因此报告只给「建议时段 + 车站 + 历时区间 + 参考票价区间」。出发前请在 12306 核对车次与发到时刻。"
},
{
"level": "warn",
"title": "降级说明:地形平坦处理",
"text": "苏州为平原水乡、无山。两段徒步均按 terrain_flat 规则执行:环古城河步道与金鸡湖环湖步道均为平路水岸,累计爬升约 0。已明确标注「平路徒步」,未冒充山地徒步效果。想出汗可提高配速或折返走两趟。"
},
{
"level": "warn",
"title": "共享单车禁停区提示",
"text": "平江路、观前街、拙政园一带为共享单车禁行/禁停重点区域。请在干将东路、白塔东路等外围指定停车点还车后再步行进入,否则可能产生调度费。"
},
{
"level": "info",
"title": "周六夜怎么安排:19:00-21:00 是自由活动",
"text": "晚饭安排在 17:30 左右,吃完正好接上夜逛。平江路夜间段(临河灯笼夜景,安静适合拍照)与观前街夜市(热闹、小吃多)相距约 1.5km,可任选或都逛;想走远一点可打车去七里山塘看夜景。不强制顺序,累了随时回住处。逛街时请留意沿街店铺打烊时间(多数 21:00-22:00)。"
},
{
"level": "info",
"title": "两顿饭都按「本地人爱去 + 评分好」筛选",
"text": "午饭(平江路北段苏帮菜)与晚饭(观前街本地家常菜)都要求同时满足两条:本地人爱去(非游客主街、本地招牌菜、本地评价占比高)与评分好(大众点评约 4.3-4.5)。报告未指定具体店名,避免编造;到店前请用大众点评/高德按当前位置筛选,评分与打烊时间以实时显示为准。"
},
{
"level": "info",
"title": "季节提示",
"text": "2026 年 10 月中旬苏州气温约 15-23℃,秋高气爽,适合步行。平江路早晚温差较大,建议带一件薄外套。"
}
]
}
@@ -0,0 +1,220 @@
/* ============================================================
* sh_2d_rail_hike/tc_export.css —— 高铁游 · 导出文档样式(只服务本 skill 的 tc_export.js)
*
* 定位:这是【导出的单文件 HTML】用的样式,不在页面上加载。
* tc_export.js 导出时会把本文件全文读出来,内联进 <style>,
* 这样导出的 .html 双击即可离线查看(不依赖网络、不依赖本工具)。
*
* 【隔离用意 · 请后续开发者先读这段】
* 本文件从 sh_2d_car_hike/tc_export.css 复制而来(用户已确认「复制现有再改」)。
* 复制后只服务【本 skill】的导出文档。相对原版刻意的改动:
* 点位清单由「单行名字」改成「名字 + 导航地址 + 高德/百度链接」(.point-body 等新类)。
* 本 skill 内部另有一份 tc_report.css 服务页面渲染,两者类名不同、互不引用 ——
* 一个管页面、一个管导出文件,各自独立。要新增 skill,请复制本文件后再改。
* ============================================================ */
*,*::before,*::after{box-sizing:border-box}
:root{
--bg:#f4f5f7;
--card:#ffffff;
--line:#e6e8ec;
--line-soft:#f0f1f4;
--text:#1f2328;
--sub:#6b7280;
--accent:#2f6fed;
--radius:14px;
}
html{-webkit-text-size-adjust:100%}
body{
margin:0;
background:var(--bg);
color:var(--text);
font:14px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei","PingFang SC","Hiragino Sans GB",sans-serif;
-webkit-font-smoothing:antialiased;
text-rendering:optimizeLegibility;
}
.page{max-width:1100px;margin:0 auto;padding:28px 18px 80px}
/* ---------- HERO / 行程概览 ---------- */
.hero{
position:relative;overflow:hidden;border-radius:18px;padding:30px 30px 26px;
color:#fff;
background:radial-gradient(1100px 460px at 10% -12%,#3f7bf0 0%,#2456c9 44%,#1b3d94 100%);
box-shadow:0 16px 40px rgba(27,61,148,.24);
margin-bottom:18px;
}
.hero::after{
content:"";position:absolute;right:-90px;top:-110px;width:360px;height:360px;border-radius:50%;
background:radial-gradient(circle,rgba(255,255,255,.18),transparent 62%);
pointer-events:none;
}
.hero-inner{position:relative;z-index:1}
.kicker{display:flex;align-items:center;gap:9px;flex-wrap:wrap;font-size:12px;letter-spacing:.08em;color:rgba(255,255,255,.76);margin-bottom:12px}
.kicker .chip{background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.3);border-radius:999px;padding:2px 10px;letter-spacing:.02em;color:#fff}
.hero h1{margin:0 0 9px;font-size:26px;line-height:1.32;letter-spacing:.01em;font-weight:700}
.lede{margin:0 0 16px;max-width:780px;font-size:14px;line-height:1.75;color:rgba(255,255,255,.87)}
.hero-tags{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:18px}
.hero-tags span{font-size:12px;padding:3px 11px;border-radius:999px;background:rgba(255,255,255,.13);border:1px solid rgba(255,255,255,.24)}
.hero-meta{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:20px}
.hero-meta .mb{font-size:11.5px;padding:3px 10px;border-radius:999px;background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.2);color:rgba(255,255,255,.94)}
.hero-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px}
.hstat{background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.18);border-radius:12px;padding:12px 14px}
.hstat .l{font-size:11.5px;color:rgba(255,255,255,.72)}
.hstat .v{font-size:17px;font-weight:700;margin:4px 0 3px;font-variant-numeric:tabular-nums}
.hstat .v.sm{font-size:14px;line-height:1.5}
.hstat .h{font-size:11px;line-height:1.55;color:rgba(255,255,255,.66)}
/* ---------- 目录 ---------- */
.index{
position:sticky;top:10px;z-index:30;
display:flex;flex-wrap:wrap;gap:6px;padding:9px;margin-bottom:18px;
background:rgba(255,255,255,.9);
-webkit-backdrop-filter:saturate(180%) blur(10px);
backdrop-filter:saturate(180%) blur(10px);
border:1px solid var(--line);border-radius:14px;
box-shadow:0 6px 18px rgba(15,23,42,.06);
}
.index a{font-size:12.5px;text-decoration:none;color:#2f6fed;background:#f0f4ff;padding:4px 10px;border-radius:8px;transition:background .15s,color .15s}
.index a:hover{background:#e2eaff}
/* ---------- 区块 ---------- */
.section{
background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
padding:20px 20px 22px;margin-bottom:16px;box-shadow:0 1px 2px rgba(16,24,40,.03);
}
.sec-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:16px}
.sec-title{display:flex;align-items:center;gap:9px;font-size:16px;font-weight:700;margin:0;letter-spacing:.01em}
.sec-title::before{content:"";width:4px;height:17px;border-radius:3px;background:linear-gradient(180deg,#2f6fed,#5b8df6)}
.sec-top{font-size:12px;color:var(--sub);text-decoration:none;white-space:nowrap;transition:color .15s}
.sec-top:hover{color:var(--accent)}
.empty{color:var(--sub);font-size:13px;padding:8px 0}
/* ---------- 提示块 ---------- */
.note{border-radius:10px;padding:12px 14px;margin-bottom:10px;border:1px solid}
.note:last-child{margin-bottom:0}
.note-title{font-weight:600;font-size:13.5px;margin-bottom:3px}
.note-text{font-size:13px;line-height:1.72;color:#374151}
.note-warn{background:#fffaf0;border-color:#f0dcae}
.note-info{background:#f2f7ff;border-color:#cfe0ff}
.note-danger{background:#fff5f4;border-color:#f5c9c3}
/* ---------- 表格 ---------- */
.tablebox{overflow-x:auto;-webkit-overflow-scrolling:touch}
.table-title{font-size:13px;font-weight:600;margin-bottom:9px;color:#374151}
table.tbl{width:100%;border-collapse:collapse;font-size:13px;background:#fff}
table.tbl th,table.tbl td{border:1px solid var(--line);padding:8px 10px;text-align:left;vertical-align:top}
table.tbl thead th{background:#f7f8fa;font-weight:600;white-space:nowrap;color:#374151}
table.tbl tbody tr:hover td{background:#fafbfc}
table.tbl tbody tr.row-hl td{background:#eef4ff}
table.tbl tbody tr.row-hard td{background:#fff7ed}
table.tbl tfoot td{font-weight:700;background:#f2f6ff;font-size:13.5px}
.col-time{white-space:nowrap;color:#374151;width:104px;font-variant-numeric:tabular-nums}
.col-kind{width:150px}
.row-title{font-weight:500}
.al-right{text-align:right !important;font-variant-numeric:tabular-nums}
.cell-note{color:var(--sub);font-size:12px}
.tbl-note{font-size:12px;color:var(--sub);margin-top:9px}
/* ---------- 类型标签 ---------- */
.kind{display:inline-block;font-size:11px;padding:1px 8px;border-radius:999px;background:#f3f4f6;color:#6b7280;white-space:nowrap}
.kind-act{background:#eef2ff;color:#4f46e5;margin-left:4px}
.kind-hard{background:#fdece8;color:#b42318;font-weight:600}
/* ---------- 置信度徽标 ---------- */
.badge{display:inline-block;font-size:11px;padding:1px 8px;border-radius:999px;background:#eef2f7;color:#4b5563;white-space:nowrap;vertical-align:1px}
.badge-high{background:#e8f6ee;color:#1a7a45}
.badge-medium{background:#fff5e0;color:#8a6100}
.badge-low{background:#fdece8;color:#b42318}
.badge-info{background:#e8f0fe;color:#1d4ed8}
.badge-good{background:#e8f6ee;color:#1a7a45}
.badge-warn{background:#fff5e0;color:#8a6100}
.badge-danger{background:#fdece8;color:#b42318}
/* ---------- 时序 ---------- */
.day{margin-bottom:18px}
.day:last-child{margin-bottom:0}
.day-head{display:flex;align-items:baseline;gap:8px;margin-bottom:7px}
.day-label{font-weight:700;font-size:14px}
.day-date{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}
/* ---------- 卡片 ---------- */
.group{margin-bottom:18px}
.group:last-child{margin-bottom:0}
.group-title{display:flex;align-items:center;gap:9px;font-size:14px;font-weight:700;color:#374151;margin:0 0 11px}
.group-title::after{content:"";flex:1;height:1px;background:var(--line-soft)}
.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:14px}
.card{border:1px solid var(--line);border-radius:12px;padding:14px 15px 15px;background:#fff;transition:box-shadow .18s,border-color .18s}
.card:hover{box-shadow:0 8px 24px rgba(16,24,40,.08);border-color:#d7dce5}
.card-title{font-size:14.5px;font-weight:700;margin:0;line-height:1.45}
.card-sub{font-size:12px;color:var(--sub);margin-top:2px}
.card-badges{display:flex;flex-wrap:wrap;gap:5px;margin-top:7px}
.fields{margin-top:11px}
.field{display:grid;grid-template-columns:98px 1fr;gap:8px;padding:7px 0;border-top:1px dashed #eceef2}
.field:first-child{border-top:0;padding-top:0}
.field-label{color:var(--sub);font-size:12px;line-height:1.6}
.field-value{font-size:13px;line-height:1.66}
.field-hint{font-size:11px;color:var(--sub);margin-top:3px;line-height:1.55}
.fact-text{margin-right:4px}
.verify{display:block;font-size:11px;color:#8a6100;margin-top:3px;line-height:1.55}
.bullets{margin:11px 0 0;padding-left:19px;font-size:13px}
.bullets li{margin-bottom:4px}
.bullets li::marker{color:#9aa3af}
.card figure{margin:9px 0 0}
.card figure img{width:100%;height:auto;display:block;border-radius:9px;border:1px solid var(--line)}
.figcap{font-size:11px;color:var(--sub);margin-top:6px;line-height:1.55}
.img-missing{font-size:12px;color:var(--sub);background:#f7f8fa;border:1px dashed var(--line);border-radius:9px;padding:14px;text-align:center}
/* ---------- 点位与路线 ---------- */
.map-wrap{background:#fafbfc;border:1px solid var(--line);border-radius:12px;padding:16px 14px}
.map-svg{width:100%;height:auto;display:block}
.legend{display:flex;flex-wrap:wrap;gap:14px;margin-top:14px;font-size:12px;color:#374151}
.legend-item{display:inline-flex;align-items:center;gap:6px}
.swatch{width:20px;height:4px;border-radius:2px;display:inline-block}
.map-note{font-size:12px;color:var(--sub);margin-top:10px}
.points{display:grid;grid-template-columns:repeat(auto-fill,minmax(228px,1fr));gap:7px;margin-top:15px;border-top:1px dashed #e9ebef;padding-top:15px}
.point{display:flex;align-items:flex-start;gap:8px;font-size:12.5px;color:#374151}
.point .num{flex:none;width:20px;height:20px;border-radius:50%;color:#fff;font-size:10.5px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;font-variant-numeric:tabular-nums}
/* 【本技能新增】点位清单里的导航信息:名字 + 地址 + 高德/百度链接 */
.point-body{display:flex;flex-direction:column;gap:1px;min-width:0}
.point-name{line-height:1.45}
.point-addr{color:#6b7280;font-size:11.5px;line-height:1.5}
.point-nav{color:#2f6fed;text-decoration:none;font-size:11.5px}
.point-nav:hover{text-decoration:underline}
/* ---------- 待核实清单 ---------- */
.checklist{margin:0;padding:0;list-style:none;counter-reset:c}
.check-item{counter-increment:c;position:relative;padding:10px 0 10px 36px;border-top:1px dashed #eceef2;font-size:13px;line-height:1.65}
.check-item:first-child{border-top:0;padding-top:4px}
.check-item::before{
content:counter(c);position:absolute;left:0;top:11px;width:22px;height:22px;border-radius:50%;
background:#eef2ff;color:#2f6fed;font-size:11px;font-weight:700;
display:flex;align-items:center;justify-content:center;font-variant-numeric:tabular-nums;
}
.check-item:first-child::before{top:5px}
.check-text{margin-right:8px}
/* ---------- 页脚 ---------- */
.doc-foot{text-align:center;font-size:12px;color:var(--sub);padding:22px 0 0;line-height:1.8}
/* ---------- 响应式 ---------- */
@media (max-width:640px){
.page{padding:16px 12px 60px}
.hero{padding:22px 18px 20px;border-radius:14px}
.hero h1{font-size:21px}
.lede{font-size:13.5px}
.card-grid{grid-template-columns:1fr}
.field{grid-template-columns:84px 1fr}
.sec-title{font-size:15px}
.index{top:0;border-radius:0;margin:0 -12px 16px;border-left:0;border-right:0}
}
/* ---------- 打印 ---------- */
@media print{
body{background:#fff}
.page{max-width:none;padding:0}
.index{display:none}
.section,.card,.hero,.note{box-shadow:none}
.card,.section,.note,.day{break-inside:avoid}
.hero{background:#1b3d94 !important;-webkit-print-color-adjust:exact;print-color-adjust:exact}
table.tbl{font-size:11.5px}
}
@@ -0,0 +1,756 @@
/* ============================================================
* sh_2d_rail_hike/tc_export.js —— 高铁游 · 单文件 HTML 导出器(技能私有)
*
* 干什么:把后端已经生成好的 payload + 技能包的 report_spec,在【浏览器本地】
* 拼成一份完整的、自包含的 .html 文档并下载。纯前端、零 AI 调用,
* 导出一次不消耗任何 token。
*
* 怎么做到「单文件、离线可看」:
* 1. 样式内联:把同目录 tc_export.css 全文读出来塞进 <style>,不引用外部 css。
* 2. 图片内嵌:卡片的配图就地 fetch 成 base64 data URI 塞进 <img>,不引用外部图。
* 3. 地图内联:不用 Leaflet(它要联网拉瓦片),改用内置 SVG 画点位顺序示意图。
* → 导出的文件双击即可打开,断网也能看。
*
* 【隔离用意 · 请后续开发者先读这段】
* 本文件从 sh_2d_car_hike/tc_export.js 复制而来(用户已确认「复制现有再改」)。
* 复制后只服务【本 skill】。相对原版刻意的改动:
* (1) ACTIVITY_LABEL / PIN_COLORS 扩充为本技能词表(单车/网约车/地铁/高铁/入住/车站/住宿)
* (2) 点位清单里直接展示「导航地址」,并给高德/百度搜索链接 ——
* 导出的离线文件里也能一键查到怎么走,这是本 skill 用户的核心诉求
* 为什么不与自驾 skill 共用?同上:共用意味着本 skill 想加导航链接就得改别人的文件。
* 要新增 skill,请复制本文件后再改。本文件与 tc_render.js 也各写一套(一个拼字符串给文件看、
* 一个建 DOM 给页面看),互不依赖:tc_render.js 挂了,导出照样能用。
*
* 依赖:无。tc_export.css 由本文件在导出时读取并内联。
* ============================================================ */
;(function () {
'use strict'
/* ---------------- 常量 ---------------- */
var DEFAULT_BADGE = { high: '已核实', medium: '待确认', low: '需核实' }
var KIND_LABEL = { hard: '硬时间点', soft: '常规' }
// 【本技能私有词表】与同目录 tc_render.js 保持一致;比自驾 skill 多
// 逛街夜游/游览景点/共享单车/网约车/地铁/高铁/入住寄存/休息
var ACTIVITY_LABEL = {
walk: '步行', stroll: '逛街夜游', spot: '游览景点', bike: '共享单车', taxi: '网约车',
metro: '地铁', train: '高铁', eat: '吃饭', hike: '徒步', shop: '采买',
checkin: '入住寄存', rest: '休息'
}
var DEFAULT_MAP_NOTE = '点位顺序示意,非真实道路/轨迹'
var MAX_IMAGE_BYTES = 5 * 1024 * 1024
// 【本技能私有词表】点位配色:无 parking(本技能无自驾),
// 新增 train_station 车站 / lodging 住宿 / bike 单车点 / taxi_point 网约车上车点 /
// night_market 夜市夜逛区(v1.1.0)
var PIN_COLORS = {
start: '#2f6fed', end: '#d14343', waypoint: '#8a8f98',
train_station: '#1d4ed8', lodging: '#7c3aed', bathhouse: '#1f8a70',
district: '#b8860b', food: '#e0673a', hike: '#2e9e5b',
bike: '#0ea5e9', taxi_point: '#f59e0b', night_market: '#7c2d92',
spot: '#059669', shop: '#5a67d8', walk: '#8a5cf6'
}
// 本脚本所在目录:用来定位同目录的 tc_export.css
// 必须在「脚本加载时」取(异步回调里 document.currentScript 已经是 null)
var SELF_DIR = (function () {
try {
if (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) {
return String(document.currentScript.src).replace(/[^/]*$/, '')
}
} catch (e) {}
return ''
})()
/* ---------------- 基础工具 ---------------- */
function esc(s) {
return String(s === undefined || s === null ? '' : s)
.replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
})
}
function isArr(v) { return Array.isArray(v) }
function isPlainObject(v) { return !!v && typeof v === 'object' && !isArr(v) }
function resolvePath(root, path) {
if (!path) return root
var p = String(path).replace(/^\$\.?/, '')
if (!p) return root
var cur = root
var keys = p.split('.')
for (var i = 0; i < keys.length; i++) {
if (cur === null || cur === undefined) return undefined
cur = cur[keys[i]]
}
return cur
}
function pickById(arr, id) {
if (!isArr(arr)) return null
for (var i = 0; i < arr.length; i++) {
if (arr[i] && arr[i].id === id) return arr[i]
}
return null
}
function badgeText(spec, level) {
var map = (spec && spec.confidence_badge) || DEFAULT_BADGE
return map[level] || DEFAULT_BADGE[level] || level
}
function pinColor(kind) { return PIN_COLORS[kind] || '#4b5563' }
function dayName(d) {
return d === 1 ? '第一天' : (d === 2 ? '第二天' : (d ? ('第 ' + d + ' 天') : '其他'))
}
function dayNameForMap(d) {
return d === 1 ? '第一天' : (d === 2 ? '第二天' : (d ? ('第 ' + d + ' 天') : ''))
}
function toneClass(tone) {
var map = { info: 'info', warn: 'warn', danger: 'danger', good: 'good', high: 'high', medium: 'medium', low: 'low' }
return map[tone] || ''
}
function absUrl(u, baseUrl) {
try { return new URL(String(u), baseUrl || undefined).href } catch (e) { return String(u) }
}
function nowBeijing() {
try {
return new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })
} catch (e) { return new Date().toLocaleString() }
}
function utf8Length(s) {
try { return new Blob([s]).size } catch (e) { return String(s).length }
}
/* ---------------- 事实 / 置信度 ---------------- */
// fact 可以是纯字符串,也可以是 { text, confidence, verify }
function factHtml(value, spec) {
if (isPlainObject(value)) {
var out = '<span class="fact-text">' + esc(value.text === undefined ? '' : value.text) + '</span>'
if (value.confidence) {
out += '<span class="badge badge-' + esc(value.confidence) + '">' + esc(badgeText(spec, value.confidence)) + '</span>'
}
if (value.verify) out += '<span class="verify">核实:' + esc(value.verify) + '</span>'
return out
}
return '<span class="fact-text">' + esc(value === null || value === undefined ? '—' : value) + '</span>'
}
/* ---------------- 各 block 渲染(返回 HTML 字符串) ---------------- */
function metaBadges(meta) {
var out = []
if (!meta) return out
var keys = Object.keys(meta)
if (!keys.length) return out
if (meta.model) out.push('模型 ' + meta.model)
if (meta.elapsed_ms) out.push('耗时 ' + Math.round(Number(meta.elapsed_ms) / 1000) + 's')
if (Object.prototype.hasOwnProperty.call(meta, 'is_new_destination')) {
out.push(meta.is_new_destination ? '首个新目的地' : '技能包内已有覆盖')
}
var warns = isArr(meta.warnings) ? meta.warnings : null
if (warns) out.push(warns.length ? (warns.length + ' 条校验提醒') : '结构校验通过')
return out
}
function overviewHtml(data, spec, ctx) {
if (!isPlainObject(data)) return '<div class="hero-inner"><div class="empty">无概览数据</div></div>'
var html = '<div class="hero-inner">'
html += '<div class="kicker">' + esc(ctx.kicker) + '<span class="chip">' + esc(ctx.chip) + '</span></div>'
if (data.title) html += '<h1>' + esc(data.title) + '</h1>'
if (data.one_liner) html += '<p class="lede">' + esc(data.one_liner) + '</p>'
if (isArr(data.tags) && data.tags.length) {
html += '<div class="hero-tags">' + data.tags.map(function (t) {
return '<span>' + esc(t) + '</span>'
}).join('') + '</div>'
}
var mbs = metaBadges(ctx.meta)
if (mbs.length) {
html += '<div class="hero-meta">' + mbs.map(function (t) {
return '<span class="mb">' + esc(t) + '</span>'
}).join('') + '</div>'
}
if (isArr(data.stats) && data.stats.length) {
html += '<div class="hero-stats">' + data.stats.map(function (s) {
var v = s.value === undefined || s.value === null ? '' : String(s.value)
return '<div class="hstat">' +
'<div class="l">' + esc(s.label || '') + '</div>' +
'<div class="v' + (v.length > 9 ? ' sm' : '') + '">' + esc(v) + '</div>' +
(s.hint ? '<div class="h">' + esc(s.hint) + '</div>' : '') +
'</div>'
}).join('') + '</div>'
}
html += '</div>'
return html
}
function notesHtml(data, section) {
var list = isArr(data) ? data.slice() : []
if (section && isArr(section.filter_levels) && section.filter_levels.length) {
list = list.filter(function (n) { return section.filter_levels.indexOf(n.level) >= 0 })
}
if (!list.length) return '<div class="empty">无提示项</div>'
return list.map(function (n) {
return '<div class="note note-' + esc(n.level || 'info') + '">' +
(n.title ? '<div class="note-title">' + esc(n.title) + '</div>' : '') +
(n.text ? '<div class="note-text">' + esc(n.text) + '</div>' : '') +
'</div>'
}).join('')
}
function timelineHtml(data) {
if (!isPlainObject(data) || !isArr(data.days)) return '<div class="empty">无时序数据</div>'
return data.days.map(function (day) {
var html = '<div class="day">'
html += '<div class="day-head"><span class="day-label">' + esc(day.label || '') + '</span>' +
(day.date ? '<span class="day-date">' + esc(day.date) + '</span>' : '') + '</div>'
html += '<div class="tablebox"><table class="tbl"><thead><tr>' +
'<th class="col-time">时间</th><th>安排</th><th class="col-kind">类型</th>' +
'</tr></thead><tbody>'
;(isArr(day.rows) ? day.rows : []).forEach(function (r) {
var hard = r.kind === 'hard'
html += '<tr' + (hard ? ' class="row-hard"' : '') + '>'
html += '<td class="col-time">' + esc(r.time || '') + '</td>'
html += '<td><div class="row-title">' + esc(r.title || '') + '</div>' +
(r.note ? '<div class="cell-note">' + esc(r.note) + '</div>' : '') + '</td>'
html += '<td class="col-kind">' +
'<span class="kind' + (hard ? ' kind-hard' : '') + '">' +
esc(KIND_LABEL[r.kind] || r.kind || '') + '</span>' +
(r.activity ? '<span class="kind kind-act">' + esc(ACTIVITY_LABEL[r.activity] || r.activity) + '</span>' : '') +
'</td>'
html += '</tr>'
})
html += '</tbody></table></div></div>'
return html
}).join('')
}
function cardHtml(card, spec, ctx) {
var id = card.id || card.anchor_id || ''
var html = '<article class="card"' + (id ? ' id="card_' + esc(id) + '"' : '') + '>'
html += '<h4 class="card-title">' + esc(card.title || '') + '</h4>'
if (card.subtitle) html += '<div class="card-sub">' + esc(card.subtitle) + '</div>'
if (isArr(card.badges) && card.badges.length) {
html += '<div class="card-badges">' + card.badges.map(function (b) {
var tone = toneClass(b.tone)
return '<span class="badge' + (tone ? ' badge-' + tone : '') + '">' + esc(b.text || '') + '</span>'
}).join('') + '</div>'
}
if (isPlainObject(card.image) && card.image.url) {
var raw = String(card.image.url)
var embedded = ctx.images && ctx.images[raw]
var src = embedded || absUrl(raw, ctx.baseUrl)
var cap = card.image.caption || ''
var dis = card.image.disclaimer || (spec && spec.image_disclaimer_default) || ''
html += '<figure>'
if (embedded) {
html += '<img src="' + esc(src) + '" alt="' + esc(cap) + '" />'
} else {
html += '<div class="img-missing">配图未内嵌:' + esc(raw) + '</div>'
}
html += '<figcaption class="figcap">' + (cap ? esc(cap) + ' · ' : '') + esc(dis) + '</figcaption>'
html += '</figure>'
}
if (isArr(card.fields) && card.fields.length) {
html += '<div class="fields">' + card.fields.map(function (f) {
return '<div class="field">' +
'<div class="field-label">' + esc(f.label || '') + '</div>' +
'<div class="field-value">' + factHtml(f.value, spec) +
(f.hint ? '<div class="field-hint">' + esc(f.hint) + '</div>' : '') +
'</div></div>'
}).join('') + '</div>'
}
if (isArr(card.bullets) && card.bullets.length) {
html += '<ul class="bullets">' + card.bullets.map(function (b) {
return '<li>' + esc(b) + '</li>'
}).join('') + '</ul>'
}
html += '</article>'
return html
}
function cardsHtml(data, spec, section, ctx) {
var list = isArr(data) ? data : []
if (!list.length) return '<div class="empty">无节点数据</div>'
var dayOf = function (c) { return c.day === undefined || c.day === null ? 0 : Number(c.day) }
if (!(section && section.group_by === 'day')) {
return '<div class="card-grid">' + list.map(function (c) { return cardHtml(c, spec, ctx) }).join('') + '</div>'
}
var days = []
list.forEach(function (c) {
var d = dayOf(c)
if (days.indexOf(d) < 0) days.push(d)
})
days.sort(function (a, b) { return a - b })
return days.map(function (d) {
return '<div class="group"><h3 class="group-title">' + esc(dayName(d)) + '</h3>' +
'<div class="card-grid">' +
list.filter(function (c) { return dayOf(c) === d })
.map(function (c) { return cardHtml(c, spec, ctx) }).join('') +
'</div></div>'
}).join('')
}
function tableHtml(data, section) {
var table = null
if (isArr(data)) {
table = pickById(data, section && section.pick_id)
if (!table && section && section.pick_id) return '<div class="empty">缺少表格:' + esc(section.pick_id) + '</div>'
if (!table) table = data[0]
} else {
table = data
}
if (!isPlainObject(table)) return '<div class="empty">无表格数据</div>'
var columns = isArr(table.columns) ? table.columns : []
var rows = isArr(table.rows) ? table.rows : []
var hl = isArr(table.highlight_row_keys) ? table.highlight_row_keys : []
var html = '<div class="tablebox">'
if (table.title) html += '<div class="table-title">' + esc(table.title) + '</div>'
html += '<table class="tbl"><thead><tr>'
columns.forEach(function (c) {
html += '<th' + (c.align ? ' class="al-' + esc(c.align) + '"' : '') +
(c.width ? ' style="width:' + esc(c.width) + '"' : '') + '>' + esc(c.label || c.key) + '</th>'
})
html += '</tr></thead><tbody>'
rows.forEach(function (r) {
html += '<tr' + (hl.indexOf(r._key) >= 0 ? ' class="row-hl"' : '') + '>'
columns.forEach(function (c) {
var v = r[c.key]
html += '<td' + (c.align ? ' class="al-' + esc(c.align) + '"' : '') + '>' +
esc(v === null || v === undefined ? '—' : v) + '</td>'
})
html += '</tr>'
})
html += '</tbody></table></div>'
if (table.note) html += '<div class="tbl-note">' + esc(table.note) + '</div>'
return html
}
function costHtml(data) {
if (!isPlainObject(data)) return '<div class="empty">无费用数据</div>'
var items = isArr(data.items) ? data.items : []
var html = '<div class="tablebox"><table class="tbl"><thead><tr>' +
'<th>项目</th><th class="al-right">金额(' + esc(data.currency || 'CNY') + ')</th><th>口径</th>' +
'</tr></thead><tbody>'
items.forEach(function (it) {
html += '<tr><td>' + esc(it.label || '') + '</td>' +
'<td class="al-right">' + esc(it.amount === undefined ? '—' : it.amount) + '</td>' +
'<td class="cell-note">' + esc(it.note || it.unit || '') + '</td></tr>'
})
html += '</tbody>'
if (data.total !== undefined && data.total !== null) {
html += '<tfoot><tr><td>合计</td><td class="al-right">' + esc(data.total) + '</td><td></td></tr></tfoot>'
}
html += '</table></div>'
if (data.note) html += '<div class="tbl-note">' + esc(data.note) + '</div>'
return html
}
function checklistHtml(data, spec) {
var list = isArr(data) ? data : []
if (!list.length) return '<div class="empty">无待核实项</div>'
return '<ol class="checklist">' + list.map(function (c) {
return '<li class="check-item">' +
'<span class="check-text">' + esc(c.text || '') + '</span>' +
(c.confidence ? '<span class="badge badge-' + esc(c.confidence) + '">' + esc(badgeText(spec, c.confidence)) + '</span>' : '') +
(c.verify ? '<span class="verify">核实:' + esc(c.verify) + '</span>' : '') +
'</li>'
}).join('') + '</ol>'
}
/* ---------------- 地图 block(内联 SVG,不联网、不用 Leaflet) ---------------- */
function mapSvgHtml(markers, routes) {
var W = 1000
var PAD = 70
var ROW_H = 176
var dayOf = function (m) { return m.day === undefined || m.day === null ? 0 : Number(m.day) }
var days = []
markers.forEach(function (m) {
var d = dayOf(m)
if (days.indexOf(d) < 0) days.push(d)
})
days.sort(function (a, b) { return a - b })
var hasRowLabel = days.length > 1
var ROW_Y0 = hasRowLabel ? 92 : 62
var pos = {}
var labels = ''
var maxY = ROW_Y0
days.forEach(function (d, rowIdx) {
var rowMarks = markers.filter(function (m) { return dayOf(m) === d })
var y = ROW_Y0 + rowIdx * ROW_H
maxY = y
var step = rowMarks.length > 1 ? (W - PAD * 2) / (rowMarks.length - 1) : 0
rowMarks.forEach(function (m, i) {
pos[m.id] = { x: Math.round((PAD + i * step) * 10) / 10, y: y }
})
if (hasRowLabel) {
labels += '<text x="' + (W / 2) + '" y="' + (y - 50) + '" text-anchor="middle" font-size="12" fill="#6b7280">' +
esc(dayNameForMap(d)) + '</text>'
}
})
var lines = ''
routes.forEach(function (r) {
var ids = isArr(r.marker_ids) ? r.marker_ids : []
for (var i = 0; i < ids.length - 1; i++) {
var a = pos[ids[i]]
var b = pos[ids[i + 1]]
if (!a || !b) continue
if (a.y !== b.y) continue // 跨天不连线,避免斜穿两行
if (a.x === b.x && a.y === b.y) continue
lines += '<line x1="' + a.x + '" y1="' + a.y + '" x2="' + b.x + '" y2="' + b.y + '"' +
' stroke="' + esc(r.color || '#cbd5e1') + '"' +
' stroke-width="' + (r.kind === 'drive' ? 3 : 4) + '"' +
(r.dashed ? ' stroke-dasharray="6 6"' : '') +
' stroke-linecap="round"></line>'
}
})
var nodes = ''
markers.forEach(function (m) {
var p = pos[m.id]
if (!p) return
var seq = m.seq === undefined || m.seq === null ? '' : String(m.seq)
nodes += '<g><title>' + esc((seq ? seq + ' ' : '') + (m.name || '')) + '</title>' +
'<circle cx="' + p.x + '" cy="' + p.y + '" r="13" fill="' + esc(pinColor(m.kind)) +
'" stroke="#fff" stroke-width="2.5"></circle>' +
'<text x="' + p.x + '" y="' + p.y + '" text-anchor="middle" dominant-baseline="central"' +
' font-size="11" font-weight="700" fill="#fff">' + esc(seq) + '</text></g>'
})
var H = maxY + 62
return '<svg class="map-svg" viewBox="0 0 ' + W + ' ' + H + '" role="img" aria-label="行程点位顺序示意图">' +
labels +
(lines ? '<g>' + lines + '</g>' : '') +
(nodes ? '<g>' + nodes + '</g>' : '') +
'</svg>'
}
function mapLegendHtml(routes) {
if (!isArr(routes) || !routes.length) return ''
return '<div class="legend">' + routes.map(function (r) {
var color = r.color || '#4b5563'
var style = r.dashed
? 'background:repeating-linear-gradient(90deg,' + color + ' 0 6px,transparent 6px 12px)'
: 'background:' + color
return '<span class="legend-item"><span class="swatch" style="' + esc(style) + '"></span>' +
esc(r.name || r.id || '') + '</span>'
}).join('') + '</div>'
}
function mapHtml(data, spec) {
if (!isPlainObject(data) || !isArr(data.markers) || !data.markers.length) {
return '<div class="empty">无地图数据</div>'
}
var markers = data.markers.slice().sort(function (a, b) {
var x = a.seq === undefined || a.seq === null ? 9999 : Number(a.seq)
var y = b.seq === undefined || b.seq === null ? 9999 : Number(b.seq)
return x - y
})
var html = '<div class="map-wrap">'
html += mapSvgHtml(markers, isArr(data.routes) ? data.routes : [])
html += mapLegendHtml(data.routes)
html += '<div class="map-note">' +
esc(data.disclaimer || (spec && spec.map_route_disclaimer_default) || DEFAULT_MAP_NOTE) + '</div>'
// 【本技能专属】点位清单:不只列名字,还要把「导航地址」摊开,并给高德/百度搜索链接。
// 导出的文件是离线单文件,用户在手机上打开时可以直接点链接去搜路线 —— 这正是
// 本 skill 用户的核心诉求(每到一处都有具体地点,方便导航)。
html += '<div class="points">' + markers.map(function (m) {
var seq = m.seq === undefined || m.seq === null ? '' : String(m.seq)
var addr = String(m.address || '').trim()
var q = encodeURIComponent(addr || m.name || '')
var nav = q
? ' <a class="point-nav" target="_blank" rel="noopener" href="https://uri.amap.com/search?keyword=' + esc(q) + '">高德</a>' +
'<a class="point-nav" target="_blank" rel="noopener" href="https://map.baidu.com/search/' + esc(q) + '">百度</a>'
: ''
return '<div class="point"><span class="num" style="background:' + esc(pinColor(m.kind)) + '">' +
esc(seq) + '</span><span class="point-body"><span class="point-name">' + esc(m.name || '') + '</span>' +
(addr ? '<span class="point-addr">' + esc(addr) + '</span>' : '') + nav + '</span></div>'
}).join('') + '</div>'
html += '</div>'
return html
}
/* ---------------- 顶层组装 ---------------- */
var BLOCKS = {
overview: function (data, spec, section, ctx) { return overviewHtml(data, spec, ctx) },
notes: function (data, spec, section) { return notesHtml(data, section) },
timeline: function (data) { return timelineHtml(data) },
cards: function (data, spec, section, ctx) { return cardsHtml(data, spec, section, ctx) },
map: function (data, spec) { return mapHtml(data, spec) },
table: function (data, spec, section) { return tableHtml(data, section) },
cost: function (data) { return costHtml(data) },
checklist: function (data, spec) { return checklistHtml(data, spec) }
}
function sectionHtml(section, payload, spec, ctx) {
var id = section.id || ('sec_' + section.type)
var data = section.data ? resolvePath(payload, section.data) : payload
var fn = BLOCKS[section.type]
var html = '<section class="section" id="' + esc(id) + '">'
if (section.title) {
html += '<div class="sec-head"><h2 class="sec-title">' + esc(section.title) + '</h2>' +
'<a class="sec-top" href="#top">回到顶部</a></div>'
}
html += (typeof fn === 'function')
? fn(data, spec, section, ctx)
: '<div class="empty">未知 block 类型:' + esc(section.type) + '</div>'
html += '</section>'
return html
}
function indexHtml(items) {
return '<nav class="index">' + items.map(function (it) {
return '<a href="#' + esc(it.id) + '">' + esc(it.title) + '</a>'
}).join('') + '</nav>'
}
function footHtml(ctx) {
var meta = ctx.meta || {}
var bits = ['游山玩水 · 思想实验室']
if (meta.model) bits.push('模型 ' + meta.model)
if (meta.elapsed_ms) bits.push('耗时 ' + Math.round(Number(meta.elapsed_ms) / 1000) + 's')
if (metaBadges(meta).length) {
var warns = isArr(meta.warnings) ? meta.warnings : []
bits.push(warns.length ? (warns.length + ' 条校验提醒') : '结构校验通过')
}
if (ctx.exportedAt) bits.push('导出于 ' + ctx.exportedAt)
return '<div class="doc-foot">' + esc(bits.join(' | ')) + '<br>' +
'本报告内容由模型生成,含标注为「待确认 / 需核实」的信息,出行前请自行核实。</div>'
}
/**
* 把 payload + report_spec 拼成一份完整的单文件 HTML(纯字符串,无副作用)
* @param {object} payload 后端返回的行程数据
* @param {object} spec 技能包的 report_spec
* @param {object} opts { css, images, meta, title, kicker, chip, exportedAt, baseUrl }
* @returns {string} 完整 HTML 文本
*/
function build(payload, spec, opts) {
spec = spec || {}
opts = opts || {}
var sections = isArr(spec.sections) ? spec.sections : []
if (!sections.length) {
throw new Error('report_spec 里没有 sections —— 没有渲染描述就导不出文档')
}
var ctx = {
images: opts.images || {},
meta: opts.meta || {},
baseUrl: opts.baseUrl || '',
kicker: opts.kicker || '思想实验室 · 游山玩水',
chip: opts.chip || '行程报告',
exportedAt: opts.exportedAt || ''
}
var summary = isPlainObject(payload && payload.summary) ? payload.summary : null
var title = opts.title || (summary && summary.title) || '行程报告'
var heroHtml = ''
var bodyParts = []
var indexItems = []
sections.forEach(function (section) {
if (!section || !section.type) return
if (section.title) indexItems.push({ id: section.id || ('sec_' + section.type), title: section.title })
if (section.type === 'overview' && !heroHtml) {
var ovData = section.data ? resolvePath(payload, section.data) : payload
heroHtml = '<header class="hero" id="' + esc(section.id || 'sec_overview') + '">' +
overviewHtml(ovData, spec, ctx) + '</header>'
return
}
bodyParts.push(sectionHtml(section, payload, spec, ctx))
})
// 技能包若没给 overview 区块,就用 payload.summary 兜底一个头图,避免文档没有标题
if (!heroHtml && summary) {
heroHtml = '<header class="hero" id="sec_overview">' + overviewHtml(summary, spec, ctx) + '</header>'
}
var html = '<!DOCTYPE html>\n<html lang="zh-CN">\n<head>\n'
html += '<meta charset="utf-8" />\n'
html += '<meta name="viewport" content="width=device-width, initial-scale=1" />\n'
html += '<title>' + esc(title) + ' · 行程报告</title>\n'
html += '<!-- 由 TRAE-Toolbox · 思想实验室「游山玩水」导出' +
(ctx.exportedAt ? '(' + esc(ctx.exportedAt) + ')' : '') + ',单文件、可离线查看 -->\n'
html += '<style>\n' + (opts.css || '') + '\n</style>\n'
html += '</head>\n<body>\n'
html += '<div class="page" id="top">\n'
if (heroHtml) html += heroHtml + '\n'
if (spec.show_index !== false && indexItems.length) html += indexHtml(indexItems) + '\n'
if (bodyParts.length) html += bodyParts.join('\n') + '\n'
html += footHtml(ctx) + '\n'
html += '</div>\n</body>\n</html>\n'
return html
}
/* ---------------- 图片内嵌(转 base64,保证单文件离线可看) ---------------- */
function collectImageUrls(payload) {
var seen = {}
var out = []
;(function walk(node, depth) {
if (depth > 12 || node === null || typeof node !== 'object') return
if (isArr(node)) {
node.forEach(function (x) { walk(x, depth + 1) })
return
}
if (isPlainObject(node.image) && typeof node.image.url === 'string' && node.image.url) {
if (!seen[node.image.url]) { seen[node.image.url] = 1; out.push(node.image.url) }
}
Object.keys(node).forEach(function (k) { walk(node[k], depth + 1) })
})(payload, 0)
return out
}
function toDataUrl(url, baseUrl) {
return fetch(absUrl(url, baseUrl))
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status)
return r.blob()
})
.then(function (blob) {
if (blob.size > MAX_IMAGE_BYTES) {
throw new Error('单张超过 ' + Math.round(MAX_IMAGE_BYTES / 1024 / 1024) + 'MB,已跳过')
}
return new Promise(function (resolve, reject) {
var fr = new FileReader()
fr.onload = function () { resolve({ dataUri: String(fr.result), bytes: blob.size }) }
fr.onerror = function () { reject(new Error('读取图片失败')) }
fr.readAsDataURL(blob)
})
})
}
function embedImages(payload) {
var baseUrl = (typeof location !== 'undefined' && location.href) ? location.href : ''
var urls = collectImageUrls(payload)
var stats = { total: urls.length, embedded: 0, failed: 0, bytes: 0 }
var warnings = []
var map = {}
if (!urls.length || typeof fetch !== 'function' || typeof FileReader === 'undefined') {
return Promise.resolve({ map: map, baseUrl: baseUrl, stats: stats, warnings: warnings })
}
return Promise.all(urls.map(function (u) {
return toDataUrl(u, baseUrl).then(function (d) {
map[u] = d.dataUri
stats.embedded++
stats.bytes += d.bytes
}, function (err) {
stats.failed++
warnings.push('配图未内嵌(' + (err && err.message || '获取失败') + '):' + u)
})
})).then(function () {
return { map: map, baseUrl: baseUrl, stats: stats, warnings: warnings }
})
}
/* ---------------- 样式读取 / 文件落盘 ---------------- */
var cssCache = null
function loadCss(cssUrl) {
if (typeof fetch !== 'function') return Promise.reject(new Error('当前环境不支持导出(无 fetch)'))
if (cssCache) return Promise.resolve(cssCache)
return fetch(cssUrl, { cache: 'force-cache' })
.then(function (r) {
if (!r.ok) throw new Error('读取样式失败:HTTP ' + r.status + '(' + cssUrl + ')')
return r.text()
})
.then(function (txt) {
if (!txt || txt.indexOf('.hero') < 0) throw new Error('样式文件内容异常:' + cssUrl)
cssCache = txt
return cssCache
})
}
function saveBlob(html, filename) {
var blob = new Blob([html], { type: 'text/html;charset=utf-8' })
var url = URL.createObjectURL(blob)
var a = document.createElement('a')
a.href = url
a.download = filename
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(function () { try { URL.revokeObjectURL(url) } catch (e) {} }, 10000)
}
function defaultFilename(payload) {
var s = isPlainObject(payload && payload.summary) ? payload.summary : {}
var name = String(s.title || '行程报告')
.replace(/[\\/:*?"<>|\r\n\t]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!name) name = '行程报告'
if (name.length > 60) name = name.slice(0, 60)
return name + '-行程报告.html'
}
/* ---------------- 对外入口 ---------------- */
/**
* 导出并触发下载(浏览器)
* @returns {Promise<{ok, filename, bytes, images, warnings}>}
*/
function download(payload, spec, opts) {
opts = opts || {}
var cssUrl = opts.cssUrl || (SELF_DIR + 'tc_export.css')
return Promise.all([loadCss(cssUrl), embedImages(payload)])
.then(function (res) {
var html = build(payload, spec, {
css: res[0],
images: res[1].map,
baseUrl: res[1].baseUrl,
meta: opts.meta,
title: opts.title,
kicker: opts.kicker,
chip: opts.chip,
exportedAt: opts.exportedAt || nowBeijing()
})
var filename = opts.filename || defaultFilename(payload)
saveBlob(html, filename)
var warnings = res[1].warnings.slice()
return {
ok: true,
filename: filename,
bytes: utf8Length(html),
images: res[1].stats,
sections: (isArr(spec && spec.sections) ? spec.sections.length : 0),
warnings: warnings
}
})
}
var api = {
build: build,
download: download,
defaultFilename: defaultFilename,
collectImageUrls: collectImageUrls
}
if (typeof window !== 'undefined') window.TCExport = api
if (typeof module !== 'undefined' && module.exports) module.exports = api
})()
@@ -0,0 +1,701 @@
/* ============================================================
* sh_2d_rail_hike/tc_render.js —— 高铁游 · 报告渲染器(技能私有)
*
* 设计原则(插件式 / 低耦合):
* 1. 本文件完全独立:不 import、不 require、不引用工具箱任何其他模块或公共库。
* 2. 只认 report_spec 里的 8 种 block 类型 + 数据路径,不认识任何业务字段。
* 3. 地图双通道:有 Leaflet 就用 Leaflet,没有就用内置 SVG 降级渲染。
*
* 【隔离用意 · 请后续开发者先读这段】
* 本文件从 sh_2d_car_hike/tc_render.js 复制而来(用户已确认「复制现有再改」方案),
* 复制后它只服务【本 skill】。相对原版刻意的三处改动,全部由本 skill 的业务特点决定:
* (1) ACTIVITY_LABEL 扩充:新增 共享单车/网约车/地铁/高铁/入住寄存/休息
* (2) PIN_COLORS 扩充:新增 train_station/lodging/bike/taxi_point 四种点位配色
* (3) 地图弹窗新增「导航地址 + 一键打开高德/百度」——本 skill 用户的核心诉求:
* 每到一处都有具体地点,方便直接导航
* 为什么不去改自驾 skill 那份来共用?因为一旦共用,本 skill 想加导航链接就得动到
* 自驾 skill 的渲染逻辑,风险互相传导。这就是本 lab「skill 全栈隔离」的取舍:
* 宁可各存一份,也不要跨 skill 的共用渲染器。要新增 skill,请复制本文件后再改。
*
* 依赖:无(Leaflet 为可选增强,通过全局 window.L 探测)
* ============================================================ */
(function (global) {
'use strict'
/* ---------------- 可调参数(改这里即可换瓦片源) ---------------- */
const MAP_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
const MAP_TILE_ATTR = '&copy; OpenStreetMap contributors'
const MAP_FALLBACK_DISCLAIMER = '点位顺序示意,非真实道路/轨迹'
const DEFAULT_BADGE = { high: '已核实', medium: '待确认', low: '需核实' }
const FALLBACK_IMAGE_TEXT = '配图未生成'
const mapRegistry = []
/* ---------------- 基础工具 ---------------- */
function h(tag, attrs, children) {
const el = document.createElement(tag)
if (attrs) {
Object.keys(attrs).forEach(function (k) {
const v = attrs[k]
if (v === null || v === undefined || v === false) return
if (k === 'class') el.className = v
else if (k === 'text') el.textContent = v
else if (k === 'html') el.innerHTML = v
else if (k.indexOf('on') === 0 && typeof v === 'function') el.addEventListener(k.slice(2), v)
else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v)
else el.setAttribute(k, v === true ? '' : String(v))
})
}
appendChildren(el, children)
return el
}
function appendChildren(el, children) {
if (children === null || children === undefined) return
if (Array.isArray(children)) {
children.forEach(function (c) { appendChildren(el, c) })
return
}
if (children instanceof Node) { el.appendChild(children); return }
el.appendChild(document.createTextNode(String(children)))
}
function clear(el) { while (el && el.firstChild) el.removeChild(el.firstChild) }
function resolvePath(root, path) {
if (!path) return root
const p = String(path).replace(/^\$\.?/, '')
if (!p) return root
return p.split('.').reduce(function (acc, key) {
if (acc === null || acc === undefined) return undefined
return acc[key]
}, root)
}
function pickById(arr, id) {
if (!Array.isArray(arr)) return null
for (let i = 0; i < arr.length; i++) {
if (arr[i] && arr[i].id === id) return arr[i]
}
return null
}
function isPlainObject(v) { return !!v && typeof v === 'object' && !Array.isArray(v) }
function badgeText(spec, level) {
const map = (spec && spec.confidence_badge) || DEFAULT_BADGE
return map[level] || DEFAULT_BADGE[level] || level
}
/* ---------------- 置信度字段渲染(通用) ---------------- */
// fact 可以是纯字符串,也可以是 { text, confidence, verify }
function renderFact(value, spec) {
if (isPlainObject(value)) {
const box = h('span', { class: 'tcr-fact' })
box.appendChild(h('span', { class: 'tcr-fact-text', text: value.text === undefined ? '' : String(value.text) }))
if (value.confidence) {
box.appendChild(h('span', {
class: 'tcr-badge tcr-badge-' + value.confidence,
text: badgeText(spec, value.confidence),
title: value.verify ? ('核实方式:' + value.verify) : ''
}))
}
if (value.verify) {
box.appendChild(h('span', { class: 'tcr-verify', text: '核实:' + value.verify }))
}
return box
}
return h('span', { class: 'tcr-fact-text', text: value === null || value === undefined ? '—' : String(value) })
}
/* ---------------- 各 block 渲染 ---------------- */
function renderOverview(data, spec) {
if (!isPlainObject(data)) return h('div', { class: 'tcr-empty', text: '无概览数据' })
const box = h('div', { class: 'tcr-overview' })
if (data.title) box.appendChild(h('h2', { class: 'tcr-ov-title', text: data.title }))
if (data.one_liner) box.appendChild(h('p', { class: 'tcr-ov-line', text: data.one_liner }))
if (Array.isArray(data.tags) && data.tags.length) {
const tagRow = h('div', { class: 'tcr-tags' })
data.tags.forEach(function (t) { tagRow.appendChild(h('span', { class: 'tcr-tag', text: t })) })
box.appendChild(tagRow)
}
if (Array.isArray(data.stats) && data.stats.length) {
const grid = h('div', { class: 'tcr-stats' })
data.stats.forEach(function (s) {
const cell = h('div', { class: 'tcr-stat' })
cell.appendChild(h('div', { class: 'tcr-stat-label', text: s.label || '' }))
cell.appendChild(h('div', { class: 'tcr-stat-value', text: s.value === undefined ? '' : String(s.value) }))
if (s.hint) cell.appendChild(h('div', { class: 'tcr-stat-hint', text: s.hint }))
grid.appendChild(cell)
})
box.appendChild(grid)
}
return box
}
function renderTimeline(data, spec) {
if (!isPlainObject(data) || !Array.isArray(data.days)) return h('div', { class: 'tcr-empty', text: '无时序数据' })
const box = h('div', { class: 'tcr-timeline' })
data.days.forEach(function (day) {
const dayBox = h('div', { class: 'tcr-day' })
const head = h('div', { class: 'tcr-day-head' })
head.appendChild(h('span', { class: 'tcr-day-label', text: day.label || '' }))
if (day.date) head.appendChild(h('span', { class: 'tcr-day-date', text: day.date }))
dayBox.appendChild(head)
const table = h('table', { class: 'tcr-table tcr-timeline-table' })
const thead = h('thead', null, h('tr', null, [
h('th', { class: 'tcr-col-time', text: '时间' }),
h('th', { text: '安排' }),
h('th', { class: 'tcr-col-kind', text: '类型' })
]))
table.appendChild(thead)
const tbody = h('tbody')
const rows = Array.isArray(day.rows) ? day.rows : []
rows.forEach(function (r) {
const tr = h('tr', { class: r.kind === 'hard' ? 'tcr-row-hard' : '' })
tr.appendChild(h('td', { class: 'tcr-col-time', text: r.time || '' }))
const td = h('td')
td.appendChild(h('div', { class: 'tcr-row-title', text: r.title || '' }))
if (r.note) td.appendChild(h('div', { class: 'tcr-row-note', text: r.note }))
tr.appendChild(td)
const kindCell = h('td', { class: 'tcr-col-kind' })
kindCell.appendChild(h('span', {
class: 'tcr-kind tcr-kind-' + (r.kind || 'soft'),
text: kindLabel(r.kind)
}))
if (r.activity) {
kindCell.appendChild(h('span', { class: 'tcr-kind tcr-kind-act', text: activityLabel(r.activity) }))
}
tr.appendChild(kindCell)
tbody.appendChild(tr)
})
table.appendChild(tbody)
dayBox.appendChild(table)
box.appendChild(dayBox)
})
return box
}
function kindLabel(kind) {
const map = { hard: '硬时间点', soft: '常规' }
return map[kind] || (kind || '')
}
// 活动类型与 kind(时间点性质)是两个正交字段,分开显示
// 【本技能私有词表】比自驾 skill 多:stroll 逛街/夜逛 / spot 游览景点 / bike 共享单车 /
// taxi 网约车 / metro 地铁 / train 高铁 / checkin 入住寄存 / rest 休息。改这里只影响本 skill。
function activityLabel(activity) {
const map = {
walk: '步行', stroll: '逛街夜游', spot: '游览景点', bike: '共享单车', taxi: '网约车',
metro: '地铁', train: '高铁', eat: '吃饭', hike: '徒步', shop: '采买',
checkin: '入住寄存', rest: '休息'
}
return map[activity] || (activity || '')
}
function renderCards(data, spec, section) {
const list = Array.isArray(data) ? data : []
if (!list.length) return h('div', { class: 'tcr-empty', text: '无节点数据' })
const box = h('div', { class: 'tcr-cards' })
const grouped = section && section.group_by === 'day'
if (!grouped) {
list.forEach(function (c) { box.appendChild(renderCard(c, spec)) })
return box
}
const days = []
list.forEach(function (c) {
const d = c.day === undefined ? 0 : c.day
if (days.indexOf(d) < 0) days.push(d)
})
days.sort(function (a, b) { return a - b })
days.forEach(function (d) {
const group = h('div', { class: 'tcr-card-group' })
group.appendChild(h('h3', { class: 'tcr-group-title', text: d === 1 ? '第一天' : (d === 2 ? '第二天' : '其他') }))
const grid = h('div', { class: 'tcr-card-grid' })
list.forEach(function (c) {
const cd = c.day === undefined ? 0 : c.day
if (cd === d) grid.appendChild(renderCard(c, spec))
})
group.appendChild(grid)
box.appendChild(group)
})
return box
}
function renderCard(card, spec) {
const el = h('article', { class: 'tcr-card', id: 'card_' + (card.id || card.anchor_id || '') })
const head = h('div', { class: 'tcr-card-head' })
const titles = h('div', { class: 'tcr-card-titles' })
titles.appendChild(h('h4', { class: 'tcr-card-title', text: card.title || '' }))
if (card.subtitle) titles.appendChild(h('div', { class: 'tcr-card-sub', text: card.subtitle }))
head.appendChild(titles)
if (Array.isArray(card.badges) && card.badges.length) {
const bs = h('div', { class: 'tcr-badges' })
card.badges.forEach(function (b) {
bs.appendChild(h('span', { class: 'tcr-badge tcr-tone-' + (b.tone || 'plain'), text: b.text || '' }))
})
head.appendChild(bs)
}
el.appendChild(head)
if (card.image && card.image.url) {
const fig = h('figure', { class: 'tcr-figure' })
const img = h('img', { class: 'tcr-img', src: card.image.url, alt: card.image.caption || '', loading: 'lazy' })
img.addEventListener('error', function () {
clear(fig)
fig.appendChild(h('div', { class: 'tcr-img-missing', text: FALLBACK_IMAGE_TEXT + '(' + (card.image.caption || '') + ')' }))
fig.appendChild(h('figcaption', { class: 'tcr-figcap', text: card.image.disclaimer || '' }))
})
fig.appendChild(img)
const cap = h('figcaption', { class: 'tcr-figcap' })
if (card.image.caption) cap.appendChild(h('span', { text: card.image.caption + ' · ' }))
cap.appendChild(h('span', { class: 'tcr-figcap-note', text: card.image.disclaimer || '' }))
fig.appendChild(cap)
el.appendChild(fig)
}
if (Array.isArray(card.fields) && card.fields.length) {
const dl = h('div', { class: 'tcr-fields' })
card.fields.forEach(function (f) {
const row = h('div', { class: 'tcr-field' })
row.appendChild(h('div', { class: 'tcr-field-label', text: f.label || '' }))
const val = h('div', { class: 'tcr-field-value' })
val.appendChild(renderFact(f.value, spec))
if (f.hint) val.appendChild(h('div', { class: 'tcr-field-hint', text: f.hint }))
row.appendChild(val)
dl.appendChild(row)
})
el.appendChild(dl)
}
if (Array.isArray(card.bullets) && card.bullets.length) {
const ul = h('ul', { class: 'tcr-bullets' })
card.bullets.forEach(function (b) { ul.appendChild(h('li', { text: b })) })
el.appendChild(ul)
}
return el
}
function renderTable(data, spec, section) {
let table = null
if (Array.isArray(data)) {
table = pickById(data, section.pick_id)
if (!table && section.pick_id) return h('div', { class: 'tcr-empty', text: '缺少表格:' + section.pick_id })
if (!table) table = data[0]
} else {
table = data
}
if (!isPlainObject(table)) return h('div', { class: 'tcr-empty', text: '无表格数据' })
const box = h('div', { class: 'tcr-tablebox' })
if (table.title) box.appendChild(h('div', { class: 'tcr-table-title', text: table.title }))
const columns = Array.isArray(table.columns) ? table.columns : []
const rows = Array.isArray(table.rows) ? table.rows : []
const hl = Array.isArray(table.highlight_row_keys) ? table.highlight_row_keys : []
const el = h('table', { class: 'tcr-table' })
const tr = h('tr')
columns.forEach(function (c) {
tr.appendChild(h('th', {
class: c.align ? ('tcr-al-' + c.align) : '',
style: c.width ? { width: c.width } : null,
text: c.label || c.key
}))
})
el.appendChild(h('thead', null, tr))
const tbody = h('tbody')
rows.forEach(function (r) {
const row = h('tr', { class: hl.indexOf(r._key) >= 0 ? 'tcr-row-hl' : '' })
columns.forEach(function (c) {
const v = r[c.key]
const td = h('td', { class: c.align ? ('tcr-al-' + c.align) : '' })
if (v === null || v === undefined) td.textContent = '—'
else td.textContent = String(v)
row.appendChild(td)
})
tbody.appendChild(row)
})
el.appendChild(tbody)
box.appendChild(h('div', { class: 'tcr-table-scroll' }, el))
if (table.note) box.appendChild(h('div', { class: 'tcr-table-note', text: table.note }))
return box
}
function renderCost(data, spec) {
if (!isPlainObject(data)) return h('div', { class: 'tcr-empty', text: '无费用数据' })
const box = h('div', { class: 'tcr-cost' })
const items = Array.isArray(data.items) ? data.items : []
const el = h('table', { class: 'tcr-table' })
el.appendChild(h('thead', null, h('tr', null, [
h('th', { text: '项目' }),
h('th', { class: 'tcr-al-right', text: '金额(' + (data.currency || 'CNY') + ')' }),
h('th', { text: '口径' })
])))
const tbody = h('tbody')
items.forEach(function (it) {
const row = h('tr')
row.appendChild(h('td', { text: it.label || '' }))
row.appendChild(h('td', { class: 'tcr-al-right', text: it.amount === undefined ? '—' : String(it.amount) }))
row.appendChild(h('td', { class: 'tcr-cell-note', text: it.note || it.unit || '' }))
tbody.appendChild(row)
})
el.appendChild(tbody)
if (data.total !== undefined) {
const tf = h('tfoot')
const tr = h('tr', { class: 'tcr-row-hl' })
tr.appendChild(h('td', { text: '合计' }))
tr.appendChild(h('td', { class: 'tcr-al-right', text: String(data.total) }))
tr.appendChild(h('td', { text: '' }))
tf.appendChild(tr)
el.appendChild(tf)
}
box.appendChild(h('div', { class: 'tcr-table-scroll' }, el))
if (data.note) box.appendChild(h('div', { class: 'tcr-table-note', text: data.note }))
return box
}
function renderChecklist(data, spec) {
const list = Array.isArray(data) ? data : []
if (!list.length) return h('div', { class: 'tcr-empty', text: '无待核实项' })
const ol = h('ol', { class: 'tcr-checklist' })
list.forEach(function (c) {
const li = h('li', { class: 'tcr-check-item' })
li.appendChild(h('span', { class: 'tcr-check-text', text: c.text || '' }))
if (c.confidence) {
li.appendChild(h('span', { class: 'tcr-badge tcr-badge-' + c.confidence, text: badgeText(spec, c.confidence) }))
}
if (c.verify) li.appendChild(h('span', { class: 'tcr-verify', text: '核实:' + c.verify }))
ol.appendChild(li)
})
return ol
}
function renderNotes(data, spec, section) {
let list = Array.isArray(data) ? data : []
if (section && Array.isArray(section.filter_levels) && section.filter_levels.length) {
const allow = section.filter_levels
list = list.filter(function (n) { return allow.indexOf(n.level) >= 0 })
}
if (!list.length) return h('div', { class: 'tcr-empty', text: '无提示项' })
const box = h('div', { class: 'tcr-notes' })
list.forEach(function (n) {
const el = h('div', { class: 'tcr-note tcr-note-' + (n.level || 'info') })
el.appendChild(h('div', { class: 'tcr-note-title', text: n.title || '' }))
if (n.text) el.appendChild(h('div', { class: 'tcr-note-text', text: n.text }))
box.appendChild(el)
})
return box
}
/* ---------------- 地图 block(Leaflet 优先,SVG 降级) ---------------- */
function renderMap(data, spec) {
if (!isPlainObject(data) || !Array.isArray(data.markers)) {
return h('div', { class: 'tcr-empty', text: '无地图数据' })
}
const box = h('div', { class: 'tcr-mapbox' })
const holder = h('div', { class: 'tcr-map-holder' })
box.appendChild(holder)
const legend = renderMapLegend(data)
if (legend) box.appendChild(legend)
const dis = data.disclaimer || (spec && spec.map_route_disclaimer_default) || MAP_FALLBACK_DISCLAIMER
box.appendChild(h('div', { class: 'tcr-map-note', text: dis }))
mapRegistry.push({ el: holder, data: data })
return box
}
function renderMapLegend(data) {
const routes = Array.isArray(data.routes) ? data.routes : []
if (!routes.length) return null
const box = h('div', { class: 'tcr-map-legend' })
routes.forEach(function (r) {
const item = h('span', { class: 'tcr-legend-item' })
const swatch = h('span', { class: 'tcr-legend-swatch' + (r.dashed ? ' tcr-dashed' : '') })
if (r.color) swatch.style.background = r.color
if (r.dashed) {
swatch.style.background = 'repeating-linear-gradient(90deg,' + (r.color || '#666') + ' 0 6px,transparent 6px 12px)'
}
item.appendChild(swatch)
item.appendChild(h('span', { text: r.name || r.id }))
box.appendChild(item)
})
return box
}
function initMaps() {
mapRegistry.forEach(function (reg) {
try {
if (global.L && typeof global.L.map === 'function') drawLeaflet(reg.el, reg.data)
else drawSvgFallback(reg.el, reg.data)
} catch (e) {
try { drawSvgFallback(reg.el, reg.data) } catch (e2) {
clear(reg.el)
reg.el.appendChild(h('div', { class: 'tcr-empty', text: '地图渲染失败' }))
}
}
})
mapRegistry.length = 0
}
/* 【本技能专属】单个点位的弹窗内容:名称 + 备注 + 导航地址 + 一键打开高德/百度。
用户诉求是「每到一处都有具体地点,方便我导航」,所以地址必须显眼、可复制、可点开。
坐标与地址的关系(重要):AI 给出的经纬度一律按「概略值」处理(契约要求 coord_precision=approx),
真实导航请以 address 为准。所以这里把 address 放在主位,坐标仅作兜底的跳转参数。
高德 uri API 能接受「坐标或地址关键字」,这里优先用地址关键字,避免坐标偏移把用户带错。 */
function markerPopupHtml(m) {
const name = m.name || ''
const addr = String(m.address || '').trim()
const parts = ['<b>' + escapeHtml(name) + '</b>']
if (addr) parts.push('<br/><span class="tcr-pop-addr">' + escapeHtml(addr) + '</span>')
if (m.note) parts.push('<br/><span class="tcr-pop-note">' + escapeHtml(m.note) + '</span>')
if (m.coord_precision === 'approx') parts.push('<br/><i class="tcr-pop-dim">坐标为概略值,导航请用上面的地址</i>')
const links = []
const q = encodeURIComponent(addr || name)
if (q) {
// 高德:关键字搜索(最稳,不受坐标系偏移影响)
links.push('<a class="tcr-pop-nav" target="_blank" rel="noopener" href="https://uri.amap.com/search?keyword=' + q + '">高德搜</a>')
// 百度:同样按关键字搜索
links.push('<a class="tcr-pop-nav" target="_blank" rel="noopener" href="https://map.baidu.com/search/' + q + '">百度搜</a>')
}
if (typeof m.lat === 'number' && typeof m.lng === 'number') {
// 坐标直达(作为参考;因 AI 坐标是概略值,可能落在附近而非精确点上)
links.push('<a class="tcr-pop-nav" target="_blank" rel="noopener" href="https://uri.amap.com/marker?position='
+ m.lng + ',' + m.lat + '&name=' + encodeURIComponent(name) + '">坐标定位</a>')
}
if (links.length) parts.push('<br/><span class="tcr-pop-navs">' + links.join(' · ') + '</span>')
return parts.join('')
}
function drawLeaflet(el, data) {
const L = global.L
clear(el)
const map = L.map(el, { zoomControl: true, attributionControl: true })
L.tileLayer(MAP_TILE_URL, { maxZoom: 18, attribution: MAP_TILE_ATTR }).addTo(map)
const markers = data.markers || []
const byId = {}
const latlngs = []
markers.forEach(function (m) {
if (typeof m.lat !== 'number' || typeof m.lng !== 'number') return
byId[m.id] = m
latlngs.push([m.lat, m.lng])
const icon = L.divIcon({
className: 'tcr-pin-wrap',
html: '<span class="tcr-pin tcr-pin-' + (m.kind || 'spot') + '">' + (m.seq === undefined ? '' : m.seq) + '</span>',
iconSize: [22, 22],
iconAnchor: [11, 11]
})
const marker = L.marker([m.lat, m.lng], { icon: icon, title: m.name || '' }).addTo(map)
marker.bindPopup(markerPopupHtml(m))
})
;(data.routes || []).forEach(function (r) {
const pts = (r.marker_ids || []).map(function (id) { return byId[id] }).filter(Boolean)
.map(function (m) { return [m.lat, m.lng] })
if (pts.length < 2) return
const line = L.polyline(pts, {
color: r.color || '#2f6fed',
weight: r.kind === 'drive' ? 3 : 4,
dashArray: r.dashed ? '6 6' : null,
opacity: 0.9
}).addTo(map)
if (r.note) line.bindTooltip(r.name + ' · ' + r.note, { sticky: true })
else line.bindTooltip(r.name || '', { sticky: true })
})
if (latlngs.length) {
try {
map.fitBounds(L.latLngBounds(latlngs).pad(0.15))
} catch (e) {
map.setView(data.center || latlngs[0], data.zoom || 9)
}
} else {
map.setView(data.center || [31.95, 121.15], data.zoom || 9)
}
setTimeout(function () { try { map.invalidateSize() } catch (e) {} }, 60)
}
function drawSvgFallback(el, data) {
clear(el)
const markers = (data.markers || []).filter(function (m) {
return typeof m.lat === 'number' && typeof m.lng === 'number'
})
const W = 860, H = 420, PAD = 34
const svgNS = 'http://www.w3.org/2000/svg'
const svg = document.createElementNS(svgNS, 'svg')
svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H)
svg.setAttribute('class', 'tcr-svg-map')
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet')
svg.appendChild(mkSvg('rect', { x: 0, y: 0, width: W, height: H, class: 'tcr-svg-bg' }))
if (!markers.length) {
el.appendChild(svg)
el.appendChild(h('div', { class: 'tcr-empty', text: '无可绘制点位' }))
return
}
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity
markers.forEach(function (m) {
minLat = Math.min(minLat, m.lat); maxLat = Math.max(maxLat, m.lat)
minLng = Math.min(minLng, m.lng); maxLng = Math.max(maxLng, m.lng)
})
const spanLat = Math.max(maxLat - minLat, 0.01)
const spanLng = Math.max(maxLng - minLng, 0.01)
function px(m) {
const x = PAD + ((m.lng - minLng) / spanLng) * (W - PAD * 2)
const y = H - PAD - ((m.lat - minLat) / spanLat) * (H - PAD * 2)
return { x: x, y: y }
}
const byId = {}
markers.forEach(function (m) { byId[m.id] = m })
;(data.routes || []).forEach(function (r) {
const pts = (r.marker_ids || []).map(function (id) { return byId[id] }).filter(Boolean)
if (pts.length < 2) return
const d = pts.map(function (m, i) {
const p = px(m)
return (i === 0 ? 'M' : 'L') + p.x.toFixed(1) + ' ' + p.y.toFixed(1)
}).join(' ')
svg.appendChild(mkSvg('path', {
d: d,
fill: 'none',
stroke: r.color || '#2f6fed',
'stroke-width': r.kind === 'drive' ? 2.5 : 3.2,
'stroke-dasharray': r.dashed ? '6 6' : 'none',
'stroke-linejoin': 'round',
class: 'tcr-svg-line'
}))
})
markers.forEach(function (m) {
const p = px(m)
svg.appendChild(mkSvg('circle', { cx: p.x, cy: p.y, r: 9, fill: pinColor(m.kind), stroke: '#fff', 'stroke-width': 2 }))
const t = mkSvg('text', {
x: p.x, y: p.y + 3.4, 'text-anchor': 'middle',
class: 'tcr-svg-seq', fill: '#fff'
})
t.textContent = m.seq === undefined ? '' : String(m.seq)
svg.appendChild(t)
const label = mkSvg('text', { x: p.x + 12, y: p.y + 4, class: 'tcr-svg-label' })
label.textContent = m.name || ''
// 【本技能专属】SVG 降级模式下也用 <title> 挂上导航地址,鼠标悬停即可看到
const tip = mkSvg('title', {})
tip.textContent = (m.name || '') + (m.address ? '|' + m.address : '')
label.appendChild(tip)
svg.appendChild(label)
})
el.appendChild(svg)
el.appendChild(h('div', { class: 'tcr-map-fallback-hint', text: '未加载到 Leaflet,已用内置 SVG 降级绘制点位顺序' }))
}
function mkSvg(tag, attrs) {
const el = document.createElementNS('http://www.w3.org/2000/svg', tag)
Object.keys(attrs || {}).forEach(function (k) {
if (attrs[k] === null || attrs[k] === undefined) return
el.setAttribute(k, String(attrs[k]))
})
return el
}
// 【本技能私有词表】点位配色。比自驾 skill 去掉 parking(无自驾),
// 新增 train_station 车站 / lodging 住宿 / bike 单车点 / taxi_point 网约车上车点 /
// night_market 夜市夜逛区(v1.1.0,用于周六夜夜逛)
function pinColor(kind) {
const map = {
start: '#2f6fed', end: '#d14343', waypoint: '#8a8f98',
train_station: '#1d4ed8', lodging: '#7c3aed', bathhouse: '#1f8a70',
district: '#b8860b', food: '#e0673a', hike: '#2e9e5b',
bike: '#0ea5e9', taxi_point: '#f59e0b', night_market: '#7c2d92',
spot: '#059669', shop: '#5a67d8', walk: '#8a5cf6'
}
return map[kind] || '#4b5563'
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
})
}
/* ---------------- 顶层渲染 ---------------- */
const BLOCKS = {
overview: renderOverview,
timeline: renderTimeline,
cards: renderCards,
map: renderMap,
table: renderTable,
cost: renderCost,
checklist: renderChecklist,
notes: renderNotes
}
function renderSection(section, payload, spec) {
const data = section.data ? resolvePath(payload, section.data) : payload
const fn = BLOCKS[section.type]
const wrap = h('section', { class: 'tcr-section', id: section.id || ('sec_' + section.type) })
if (section.title) {
const head = h('div', { class: 'tcr-section-head' })
head.appendChild(h('h3', { class: 'tcr-section-title', text: section.title }))
head.appendChild(h('a', { class: 'tcr-section-top', href: '#tcr_top', text: '回到顶部' }))
wrap.appendChild(head)
}
if (typeof fn !== 'function') {
wrap.appendChild(h('div', { class: 'tcr-empty', text: '未知 block 类型:' + section.type }))
return wrap
}
wrap.appendChild(fn(data, spec, section))
return wrap
}
function renderIndex(spec) {
const sections = (spec.sections || []).filter(function (s) { return s.title })
if (!sections.length) return null
const nav = h('nav', { class: 'tcr-index' })
sections.forEach(function (s) {
nav.appendChild(h('a', { class: 'tcr-index-item', href: '#' + (s.id || ('sec_' + s.type)), text: s.title }))
})
return nav
}
function render(payload, spec, mount) {
if (!mount) throw new Error('tc_render: 缺少挂载节点')
spec = spec || {}
const sections = Array.isArray(spec.sections) ? spec.sections : []
// 空 spec 是「静默留白」的经典成因:不报错、不渲染,页面一片空白、无从排查。
// 宁可主动抛错,让调用方把「渲染描述没传过来」直接摊在页面上。
if (!sections.length) {
throw new Error('report_spec 里没有 sections —— 后端没把渲染描述传过来,或技能包的 report_spec.json 为空')
}
clear(mount)
if (spec.show_index) {
const idx = renderIndex(spec)
if (idx) mount.appendChild(idx)
}
sections.forEach(function (section) {
mount.appendChild(renderSection(section, payload || {}, spec))
})
// 地图必须等节点入 DOM 后再初始化
if (global.requestAnimationFrame) global.requestAnimationFrame(initMaps)
else setTimeout(initMaps, 0)
}
global.TCRender = {
render: render,
resolvePath: resolvePath,
pickById: pickById,
MAP_TILE_URL: MAP_TILE_URL
}
})(window)
@@ -0,0 +1,210 @@
/* ============================================================
* sh_2d_rail_hike/tc_report.css —— 高铁游 · 报告样式(只服务本 skill 的 tc_render.js)
*
* 【隔离用意 · 请后续开发者先读这段】
* 本文件从 sh_2d_car_hike/tc_report.css 复制而来(用户已确认「复制现有再改」)。
* 复制后它只服务【本 skill】的报告。相对原版刻意的改动:
* (1) 新增本技能的点位配色类(.tcr-pin-train_station / -lodging / -bike / -taxi_point)
* (2) 新增地图弹窗里的导航样式(.tcr-pop-addr / .tcr-pop-nav …)
* 为什么不与自驾 skill 共用一份 CSS?因为本 skill 想改配色或加导航样式时,
* 共用就意味着动到别人的文件、可能弄坏别人的报告。宁可有这份重复,也不要跨 skill 耦合。
* 要新增 skill,请复制本文件后再改。
*
* 使用方式:本 skill 的页面引用本文件 + 同目录 tc_render.js,即可渲染本 skill 的报告。
* 依赖:无(下面自带 :root 变量兜底,不引用工具箱任何其他样式文件)
* ============================================================ */
:root {
--tcr-bg: #f6f7f9;
--tcr-card: #ffffff;
--tcr-line: #e5e7eb;
--tcr-text: #1f2328;
--tcr-sub: #6b7280;
--tcr-accent: #2f6fed;
}
/* ---------------- 区块骨架 ---------------- */
.tcr-index {
display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px;
background: #fff; border: 1px solid var(--tcr-line); border-radius: 8px; padding: 8px;
}
.tcr-index-item {
font-size: 12px; color: var(--tcr-accent); text-decoration: none;
padding: 3px 8px; border-radius: 6px; background: #f0f4ff;
}
.tcr-index-item:hover { background: #e2eaff; }
.tcr-section {
background: var(--tcr-card); border: 1px solid var(--tcr-line);
border-radius: 10px; padding: 16px 16px 18px; margin-bottom: 16px;
}
.tcr-section-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; }
.tcr-section-title { font-size: 16px; margin: 0; }
.tcr-section-top { font-size: 12px; color: var(--tcr-sub); text-decoration: none; }
.tcr-empty { color: var(--tcr-sub); font-size: 13px; padding: 8px 0; }
/* ---------------- 概览 ---------------- */
.tcr-ov-title { margin: 0 0 6px; font-size: 18px; }
.tcr-ov-line { margin: 0 0 10px; color: #374151; }
.tcr-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
.tcr-tag { font-size: 12px; background: #eef2f7; color: #374151; border-radius: 999px; padding: 2px 10px; }
.tcr-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; }
.tcr-stat { background: #fafbfc; border: 1px solid var(--tcr-line); border-radius: 8px; padding: 10px; }
.tcr-stat-label { font-size: 12px; color: var(--tcr-sub); }
.tcr-stat-value { font-size: 16px; font-weight: 600; margin: 2px 0; }
.tcr-stat-hint { font-size: 11px; color: var(--tcr-sub); line-height: 1.45; }
/* ---------------- 表格 ---------------- */
.tcr-table-scroll { overflow-x: auto; }
table.tcr-table { width: 100%; border-collapse: collapse; font-size: 13px; background: #fff; }
table.tcr-table th, table.tcr-table td {
border: 1px solid var(--tcr-line); padding: 7px 9px; text-align: left; vertical-align: top;
}
table.tcr-table thead th { background: #f3f5f8; font-weight: 600; white-space: nowrap; }
.tcr-al-right { text-align: right !important; }
.tcr-al-center { text-align: center !important; }
.tcr-row-hl td { background: #eef4ff; }
.tcr-row-hard td { background: #fff7ed; }
.tcr-col-time { white-space: nowrap; color: #374151; width: 108px; }
.tcr-col-kind { width: 138px; }
.tcr-row-title { font-weight: 500; }
.tcr-row-note { font-size: 12px; color: var(--tcr-sub); }
.tcr-cell-note { color: var(--tcr-sub); font-size: 12px; }
.tcr-kind {
font-size: 11px; padding: 1px 7px; border-radius: 999px;
background: #eef2f7; color: #4b5563; white-space: nowrap;
}
.tcr-kind-hard { background: #fdece8; color: #b42318; font-weight: 600; }
.tcr-kind-act { background: #eef2f7; color: #374151; margin-left: 4px; }
.tcr-table-title { font-size: 13px; font-weight: 600; margin-bottom: 8px; }
.tcr-table-note { font-size: 12px; color: var(--tcr-sub); margin-top: 8px; }
/* ---------------- 时序分组 ---------------- */
.tcr-day { margin-bottom: 16px; }
.tcr-day-head { display: flex; gap: 8px; align-items: baseline; margin-bottom: 6px; }
.tcr-day-label { font-weight: 600; }
.tcr-day-date { font-size: 12px; color: var(--tcr-sub); }
/* ---------------- 卡片 ---------------- */
.tcr-card-group { margin-bottom: 14px; }
.tcr-group-title { font-size: 14px; margin: 6px 0 10px; color: #374151; }
.tcr-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; }
.tcr-card { border: 1px solid var(--tcr-line); border-radius: 10px; padding: 12px; background: #fff; }
.tcr-card-head { display: flex; justify-content: space-between; gap: 8px; align-items: flex-start; }
.tcr-card-title { font-size: 15px; margin: 0; }
.tcr-card-sub { font-size: 12px; color: var(--tcr-sub); }
.tcr-badges { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; }
.tcr-badge {
font-size: 11px; padding: 1px 7px; border-radius: 999px;
background: #eef2f7; color: #4b5563; white-space: nowrap; cursor: default;
}
.tcr-badge-high { background: #e8f6ee; color: #1a7a45; }
.tcr-badge-medium { background: #fff5e0; color: #8a6100; }
.tcr-badge-low { background: #fdece8; color: #b42318; }
.tcr-tone-info { background: #e8f0fe; color: #1d4ed8; }
.tcr-tone-good { background: #e8f6ee; color: #1a7a45; }
.tcr-tone-warn { background: #fff5e0; color: #8a6100; }
.tcr-tone-danger { background: #fdece8; color: #b42318; }
.tcr-tone-plain { background: #eef2f7; color: #4b5563; }
/* ---------------- 配图 ---------------- */
.tcr-figure { margin: 10px 0 4px; }
.tcr-img { width: 100%; height: 150px; object-fit: cover; border-radius: 8px; display: block; background: #f0f2f5; }
.tcr-img-missing {
height: 90px; display: flex; align-items: center; justify-content: center;
background: #f3f5f8; border: 1px dashed var(--tcr-line); border-radius: 8px;
color: var(--tcr-sub); font-size: 12px;
}
.tcr-figcap { font-size: 11px; color: var(--tcr-sub); margin-top: 4px; }
.tcr-figcap-note { color: #b42318; }
/* ---------------- 卡片字段 / 事实 ---------------- */
.tcr-fields { margin-top: 10px; }
.tcr-field {
display: grid; grid-template-columns: 96px 1fr; gap: 6px;
padding: 5px 0; border-top: 1px dashed #eef0f3;
}
.tcr-field:first-child { border-top: none; }
.tcr-field-label { color: var(--tcr-sub); font-size: 12px; }
.tcr-field-value { font-size: 13px; }
.tcr-field-hint { font-size: 11px; color: var(--tcr-sub); }
.tcr-verify { display: block; font-size: 11px; color: #8a6100; margin-top: 2px; }
.tcr-bullets { margin: 10px 0 0; padding-left: 18px; font-size: 13px; }
.tcr-bullets li { margin-bottom: 3px; }
/* ---------------- 地图 ---------------- */
.tcr-map-holder { height: 420px; border-radius: 8px; overflow: hidden; background: #eef1f4; border: 1px solid var(--tcr-line); }
.tcr-map-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 8px; font-size: 12px; color: #374151; }
.tcr-legend-item { display: inline-flex; align-items: center; gap: 5px; }
.tcr-legend-swatch { width: 18px; height: 3px; border-radius: 2px; display: inline-block; background: #666; }
.tcr-map-note { font-size: 12px; color: var(--tcr-sub); margin-top: 6px; }
.tcr-map-fallback-hint { font-size: 12px; color: #8a6100; margin-top: 6px; }
.tcr-svg-map { width: 100%; height: auto; display: block; }
.tcr-svg-bg { fill: #f2f5f8; }
.tcr-svg-seq { font-size: 10px; font-weight: 700; }
.tcr-svg-label { font-size: 11px; fill: #374151; }
.tcr-pin-wrap { background: transparent; border: none; }
.tcr-pin {
display: inline-flex; align-items: center; justify-content: center;
width: 22px; height: 22px; border-radius: 50%; color: #fff; font-size: 11px;
font-weight: 700; border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.3);
background: #4b5563;
}
.tcr-pin-start { background: #2f6fed; }
.tcr-pin-end { background: #d14343; }
.tcr-pin-hike { background: #2e9e5b; }
.tcr-pin-district { background: #b8860b; }
.tcr-pin-food { background: #e0673a; }
.tcr-pin-bathhouse { background: #1f8a70; }
.tcr-pin-shop { background: #5a67d8; }
.tcr-pin-sleep { background: #2d3748; }
.tcr-pin-walk { background: #8a5cf6; }
.tcr-pin-breakfast { background: #c98a00; }
.tcr-pin-parking { background: #c2701a; }
.tcr-pin-spot { background: #0ea5e9; }
.tcr-pin-waypoint { background: #8a8f98; }
/* 【本技能新增配色】车站 / 住宿 / 共享单车站点 / 网约车上车点 / 夜市夜逛区 */
.tcr-pin-train_station { background: #1d4ed8; }
.tcr-pin-lodging { background: #7c3aed; }
.tcr-pin-bike { background: #0ea5e9; }
.tcr-pin-taxi_point { background: #f59e0b; }
.tcr-pin-night_market { background: #7c2d92; }
/* ---------------- 地图弹窗里的导航信息(本技能专属) ---------------- */
.tcr-pop-addr { color: #1f2328; font-size: 12px; }
.tcr-pop-note { color: #6b7280; font-size: 12px; }
.tcr-pop-dim { color: #8a6100; font-size: 11px; }
.tcr-pop-navs { display: inline-block; margin-top: 4px; font-size: 12px; }
.tcr-pop-nav { color: #2f6fed; text-decoration: none; }
.tcr-pop-nav:hover { text-decoration: underline; }
/* ---------------- 提示 ---------------- */
.tcr-note { border-radius: 8px; padding: 10px 12px; margin-bottom: 8px; border: 1px solid; }
.tcr-note-title { font-weight: 600; margin-bottom: 2px; font-size: 13px; }
.tcr-note-text { font-size: 13px; color: #374151; }
.tcr-note-info { background: #f2f7ff; border-color: #cfe0ff; }
.tcr-note-warn { background: #fffaf0; border-color: #f0dcae; }
.tcr-note-danger { background: #fff5f4; border-color: #f5c9c3; }
/* ---------------- 待核实清单 ---------------- */
.tcr-checklist { margin: 0; padding-left: 20px; font-size: 13px; }
.tcr-check-item { margin-bottom: 7px; }
.tcr-check-text { margin-right: 6px; }
@media (max-width: 640px) {
.tcr-card-grid { grid-template-columns: 1fr; }
.tcr-field { grid-template-columns: 82px 1fr; }
.tcr-map-holder { height: 320px; }
}
+17
View File
@@ -111,8 +111,25 @@ const allowedOrigins = [
'http://127.0.0.1:8081'
]
const isAllowedCorsOrigin = (origin) => !origin || allowedOrigins.includes(origin)
// 同源请求不属于 CORS 管辖范围:浏览器对非 GET/HEAD 的同源请求同样会带 Origin 头,
// 但既不做预检、也不按 CORS 响应头过滤,因此必须直接放行。
// 否则 http://localhost:8976 / http://127.0.0.1:8976 / 局域网 IP 等「自己人」会被误伤成 403,
// 表现为「页面能打开、GET 正常,所有 POST 全挂」。
const isSameOriginRequest = (req, origin) => {
if (!origin) return false
try {
const host = String(req.headers.host || '').trim()
if (!host) return false
return new URL(origin).host === host
} catch (e) {
return false
}
}
app.use((req, res, next) => {
const origin = String(req.headers.origin || '').trim()
if (isSameOriginRequest(req, origin)) return next()
const isAllowed = isAllowedCorsOrigin(origin)
if (origin && isAllowed) {
@@ -0,0 +1,32 @@
{
"_comment": "思想实验室 · 游山玩水(travel_citytour):多 skill 行程生成的聚合 lab。每个 skill 是 skills/<skill_id>/ 下一个完全独立的全栈单元(后端 index.js + config.json,前端自己的页面/渲染/样式/导出/照片),彼此零耦合;本文件只保留【lab 级策略】(导航卡片信息 + 鉴权口径 + 限流),skill 的业务参数在各 skill 自己的 config.json 里。",
"id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
"id": "travel_citytour",
"name_comment": "首页九宫格卡片显示名称",
"name": "游山玩水",
"icon_comment": "首页卡片图标(emoji)",
"icon": "🚗",
"desc_comment": "一句话简介,作为卡片 tooltip 展示",
"desc": "多 skill × 多目的地 · 每个 skill 全栈独立(行程生成与定稿快照)",
"sort_order_comment": "卡片排序,数值越小越靠前(现网已用到 97,本 lab 固定排最后,避免插队影响其他 lab 视觉顺序)",
"sort_order": 100,
"enabled_comment": "是否启用(false 时 /api/thought_lab/labs 不返回该 lab)",
"enabled": true,
"auth_comment": "本 lab 接口的鉴权口径(红线,lab 级统一实现,skill 不可绕过、不可自写)。背景:lab 页面允许同源 Referer 进入,而全局接口守卫只认 nav_gate cookie,两者不一致会导致『页面能开、接口全 401』;同类 lab(bot_chess)的做法是不走全局守卫、自己鉴权",
"auth": {
"mode_comment": "nav_gate=只认 nav_gate cookie(与全局守卫等效,不放宽任何东西);browser=nav_gate 或同源 Referer(照 bot_chess 写法,拿不到 nav_gate 的环境也能用)",
"mode": "browser"
},
"rate_limit_comment": "调用限制(红线,lab 级统一实现)。本 lab 的生成会真实消耗 AI 额度,公网环境下必须防刷",
"rate_limit": {
"generate_single_flight_comment": "全局是否只允许一个生成任务(建议 true:一次要 3-4 分钟,并发会白烧额度)。在此之上还有一层『按 skill 单飞』,即 A 技能生成不影响 B 技能",
"generate_single_flight": true,
"generate_per_ip_hour_comment": "单个 IP 每小时、每个 skill 最多生成次数",
"generate_per_ip_hour": 3,
"read_per_ip_minute_comment": "只读接口(meta / last / skills)单 IP 每分钟上限",
"read_per_ip_minute": 120
}
}
@@ -0,0 +1,350 @@
// ============================================================
// travel_citytour/index.js —— 「游山玩水」lab 主入口(聚合层 + 红线层)
//
// 架构(skill 全栈隔离):
// - 每个 skill 是 skills/<skill_id>/ 下一个完全独立的全栈单元
// (后端 index.js + config.json,前端 index.html + 自己的渲染/样式/导出/照片)
// - 本文件只做四件事:扫描、鉴权、挂载、提供红线能力
// - 本文件【不读任何业务文件、不认任何业务字段、不 require skill 的 prompt/校验/渲染】
//
// 为什么鉴权/限流/密钥/时间不放进 skill(红线):
// 下沉到 N 个 skill 就必然有人漏做 → 等于开后门。所以本层把鉴权包进 Router
// 再交给 skill,skill 拿不到绕过鉴权的机会;密钥同理,只经 ctx.callLLM 出口。
//
// 挂载方式:由 thought_lab/index.js 自动扫描 labs/<id>/index.js 并绑定
// —— 只导出 bindPublicRoutes(在导航 API 守卫之前注册,自行鉴权)。
//
// 说明:AI 生成是「一次请求一次响应」,不是常驻后台任务,因此留在 8976;
// 将来若要批量预生成 / 定时重算,应迁到 8977 自动化实例。
// ============================================================
const fs = require('fs')
const path = require('path')
const express = require('express')
const axios = require('axios')
const registry = require('./registry')
const B = '/api/thought_lab/travel_citytour'
const CONFIG_PATH = path.join(__dirname, 'config.json')
const FLAGS_PATH = path.join(process.cwd(), 'config', 'flags.json')
const BODY_LIMIT = '1mb'
const LOG_TAG = '[travel_citytour]'
const readConfig = () => {
try {
if (fs.existsSync(CONFIG_PATH)) return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'))
} catch {}
return {}
}
/* ============================================================
* 红线一:鉴权(统一实现,skill 不可绕过)
* ============================================================ */
const parseCookie = (raw) => {
const out = {}
try {
String(raw || '').split(';').forEach((item) => {
const idx = item.indexOf('=')
if (idx < 1) return
out[item.slice(0, idx).trim()] = item.slice(idx + 1).trim()
})
} catch {}
return out
}
const isNavAuthEnabled = () => {
try {
const f = JSON.parse(fs.readFileSync(FLAGS_PATH, 'utf-8'))
return ((f && f.navAuth) || {}).enable_auth !== false
} catch { return true }
}
const hasNavGate = (req) => parseCookie(req.headers.cookie || '').nav_gate === '1'
const sameOrigin = (req) => {
try {
const referer = String(req.headers.referer || '')
const host = String(req.headers.host || '')
return !!referer && !!host && new URL(referer).host === host
} catch { return false }
}
const authMode = () => String((readConfig().auth || {}).mode || 'nav_gate')
// 本 lab 支持两种口径,由 config.json 的 auth.mode 决定:
// nav_gate:只认 nav_gate cookie(与全局守卫等效,不放宽任何东西)
// browser :nav_gate 或同源 Referer(照 bot_chess,拿不到 nav_gate 的环境也能用)
const labAuth = (req, res, next) => {
if (!isNavAuthEnabled()) return next()
if (hasNavGate(req)) return next()
if (authMode() === 'browser' && sameOrigin(req)) return next()
return res.status(401).json({
ok: false,
error: 'unauthorized',
hint: authMode() === 'browser'
? '既没有 nav_gate,也不是同源来源;请从工具箱内导航进入'
: '本 lab 当前口径为 nav_gate(只认同源导航凭证)。若该环境拿不到 nav_gate,可在本 lab 的 config.json 把 auth.mode 改为 browser'
})
}
/* ============================================================
* 红线二:限流与生成并发(统一实现)
*
* readLimit :只读接口按 IP 每分钟计数(挂在每个 skill Router 上)
* genGuard :按 skill 单飞(A 生成不阻塞 B)+ 全局只允许一个生成(防并发烧额度)
* + 按 IP 按小时计数
* ============================================================ */
const RL = { hits: {} }
const GEN = { global: false, bySkill: {} }
const clientIp = (req) => {
const xf = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim()
return xf || String((req.socket && req.socket.remoteAddress) || '')
}
const recentHits = (key, windowMs) => {
const now = Date.now()
const arr = (RL.hits[key] || []).filter((t) => now - t < windowMs)
RL.hits[key] = arr
return arr
}
const readLimit = (req, res, next) => {
const max = Number((readConfig().rate_limit || {}).read_per_ip_minute || 120)
const arr = recentHits('r:' + clientIp(req), 60 * 1000)
if (arr.length >= max) return res.status(429).json({ ok: false, error: 'rate_limited', hint: '只读接口调用过于频繁' })
arr.push(Date.now())
next()
}
const makeGenGuard = (skillId) => (req, res, next) => {
const rl = readConfig().rate_limit || {}
if (rl.generate_single_flight !== false && GEN.global) {
return res.status(429).json({ ok: false, error: 'busy', hint: '已有一次生成正在进行,请等它结束(一次约 3-4 分钟)' })
}
if (GEN.bySkill[skillId]) {
return res.status(429).json({ ok: false, error: 'busy', hint: '本技能已有一次生成正在进行,请等它结束' })
}
const maxHour = Number(rl.generate_per_ip_hour || 3)
const arr = recentHits('g:' + skillId + ':' + clientIp(req), 60 * 60 * 1000)
if (arr.length >= maxHour) {
return res.status(429).json({ ok: false, error: 'rate_limited', hint: `单个 IP 每小时最多生成 ${maxHour} 次(可在本 lab 的 config.json 调整)` })
}
arr.push(Date.now())
GEN.global = true
GEN.bySkill[skillId] = true
let released = false
const release = () => {
if (released) return
released = true
GEN.global = false
delete GEN.bySkill[skillId]
}
res.on('finish', release)
res.on('close', release)
next()
}
/* ============================================================
* 红线三:凭据(skill 永不接触密钥)
* 红线四:北京时间(唯一实现)
* 红线五:HTML 转义(唯一实现)
* 其余:日志前缀、数据目录、配置读取
* ============================================================ */
const CHAT_URL = process.env.DASHSCOPE_API_HOST_FOR_OPENAI
? `${process.env.DASHSCOPE_API_HOST_FOR_OPENAI.replace(/\/+$/, '')}/chat/completions`
: ''
const BJ_FMT = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai', hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
})
const nowBeijing = () => {
const parts = BJ_FMT.formatToParts(new Date())
const g = (t) => (parts.find((p) => p.type === t) || {}).value || ''
return `${g('year')}-${g('month')}-${g('day')} ${g('hour')}:${g('minute')}:${g('second')}`
}
const escapeHtml = (s) => String(s === undefined || s === null ? '' : s)
.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
// 从模型输出里稳健地取出 JSON(容忍代码块、前后废话)
const extractJson = (text) => {
let t = String(text || '').trim()
const fence = t.match(/```(?:json)?\s*([\s\S]*?)```/i)
if (fence && fence[1]) t = fence[1].trim()
const start = t.indexOf('{')
const end = t.lastIndexOf('}')
if (start < 0 || end <= start) throw new Error('no_json_found')
return JSON.parse(t.slice(start, end + 1))
}
// 唯一持有 DASHSCOPE_API_KEY 的地方(红线:skill 不接触密钥)
const callLLM = async ({ messages, model, temperature, maxTokens, timeoutMs }) => {
if (!CHAT_URL) throw new Error('dashscope_host_missing')
const apiKey = process.env.DASHSCOPE_API_KEY || ''
if (!apiKey) throw new Error('dashscope_key_missing')
const resp = await axios.post(CHAT_URL, {
model,
messages,
temperature,
max_tokens: maxTokens
}, {
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
timeout: timeoutMs,
maxContentLength: 64 * 1024 * 1024
})
const choice = resp.data && resp.data.choices && resp.data.choices[0]
if (!choice || !choice.message) throw new Error('llm_empty_response')
return {
content: choice.message.content || '',
finishReason: choice.finish_reason || '',
usage: resp.data.usage || null
}
}
// 原型污染防护(项目统一风格)
const PROTO_KEYS = new Set(['__proto__', 'constructor', 'prototype', '__defineGetter__', '__defineSetter__', '__lookupGetter__', '__lookupSetter__'])
const sanitizeObject = (obj, depth = 0) => {
if (depth > 10) return {}
if (obj === null || typeof obj !== 'object') return obj
if (Array.isArray(obj)) return obj.map((item) => sanitizeObject(item, depth + 1))
const clean = {}
for (const [key, val] of Object.entries(obj)) {
if (PROTO_KEYS.has(key)) continue
clean[key] = sanitizeObject(val, depth + 1)
}
return clean
}
const setNoCache = (res) => {
try {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate')
res.set('Pragma', 'no-cache')
res.set('Expires', '0')
} catch {}
}
// 交给 skill 的能力包:红线能力 + 本 skill 专属路径/配置
const makeCtx = (skillId) => ({
skillId,
prefix: B + '/skills/' + skillId,
dataDir: path.join(registry.SKILLS_DIR, skillId, 'data'),
readConfig: () => registry.readSkillConfig(skillId),
aiReady: () => !!(CHAT_URL && process.env.DASHSCOPE_API_KEY),
log: (event, extra) => {
try {
console.log(`${LOG_TAG}/${skillId} ${event}` + (extra === undefined ? '' : ' ' + JSON.stringify(extra)))
} catch {}
},
nowBeijing,
escapeHtml,
extractJson,
callLLM,
sanitizeObject,
jsonParser: express.json({ limit: BODY_LIMIT }),
readLimit,
genGuard: makeGenGuard(skillId)
})
/* ============================================================
* 公开诊断路由(由 thought_lab 注册,位于导航 API 守卫之前)
*
* 存在意义:本 lab 的接口默认受 nav_gate 保护,而静态页允许同源 Referer 进入,
* 两者口径不一致时会表现为「页面能打开、接口全 401」,很容易被误判成代码 bug。
* 这里只回传【调用方自己请求里已有的信息】用于定位,不回传任何 cookie 值。
* ============================================================ */
const bindDiagRoute = (app) => {
app.get(B + '/auth/diag', (req, res) => {
try {
const raw = String(req.headers.cookie || '')
const names = raw.split(';').map((s) => String(s).split('=')[0].trim()).filter(Boolean)
const navEnabled = isNavAuthEnabled()
const hasGate = hasNavGate(req)
return res.json({
ok: true,
nav_auth_enabled: navEnabled,
has_nav_gate_cookie: hasGate,
cookie_names: names,
host: String(req.headers.host || ''),
referer: String(req.headers.referer || ''),
origin: String(req.headers.origin || ''),
auth_mode: authMode(),
server_time_beijing: nowBeijing(),
verdict: !navEnabled
? '导航鉴权已关闭,接口不应返回 401'
: (hasGate
? '本次请求携带了 nav_gate,接口不应 401;若仍 401 请把这份诊断发我'
: (authMode() === 'browser' && sameOrigin(req)
? '本次请求无 nav_gate,但属同源来源,按 browser 口径应放行'
: '本次请求未携带 nav_gate cookie —— 页面能开是因为同源 Referer 放行,而接口守卫只认 cookie,所以必然 401')),
fix_hint: '用带 token 的入口重新进一次(首页或工具入口),让服务端种下 nav_gate;并确认 DevTools 里该 cookie 的 Path 是 /'
})
} catch (e) {
return res.status(500).json({ ok: false, error: 'diag_failed' })
}
})
}
/* ============================================================
* 对外路由:本 lab 全部接口自行鉴权(不走全局 nav 守卫)
* 注册顺序:诊断(公开) → 聚合列表 → 逐个 skill 挂载
* ============================================================ */
const bindPublicRoutes = (app) => {
bindDiagRoute(app)
// 聚合页用的技能列表(只含展示字段,不含业务内容)
app.get(B + '/skills', labAuth, readLimit, (req, res) => {
setNoCache(res)
try {
return res.json({ ok: true, skills: registry.listSkills() })
} catch (e) {
return res.status(500).json({ ok: false, error: 'internal_error' })
}
})
// 技能自检:一次列出全部异常(加完 skill 后不必逐个点开试)
app.get(B + '/skills/_health', labAuth, readLimit, (req, res) => {
setNoCache(res)
try {
return res.json(registry.health())
} catch (e) {
return res.status(500).json({ ok: false, error: 'internal_error' })
}
})
// 逐个 skill 挂载:lab 层先包好鉴权与限流,再把 Router 交给 skill
let mounted = 0
registry.listSkillIds().forEach((id) => {
const info = registry.inspectSkill(id)
if (info.config.enabled === false) return
if (!info.mountable) {
console.error(`${LOG_TAG} 跳过 skill ${id}:${info.broken}`)
return
}
const entry = path.join(registry.SKILLS_DIR, id, 'index.js')
let mod = null
try {
mod = require(entry)
} catch (e) {
console.error(`${LOG_TAG} 加载 skill 失败 ${id}:${String((e && e.message) || e)}`)
return
}
if (!mod || typeof mod.bindRoutes !== 'function') {
console.error(`${LOG_TAG} 跳过 skill ${id}:index.js 未导出 bindRoutes`)
return
}
const router = express.Router()
router.use(labAuth) // 红线:统一鉴权(skill 拿不到绕过机会)
router.use(readLimit) // 红线:统一只读限流
try {
mod.bindRoutes(router, makeCtx(id))
} catch (e) {
console.error(`${LOG_TAG} 注册 skill 路由失败 ${id}:${String((e && e.message) || e)}`)
return
}
app.use(B + '/skills/' + id, router)
mounted++
})
console.log(`${LOG_TAG} 已挂载 ${mounted}/${registry.listSkillIds().length} 个 skill(前缀 ${B}/skills/<skill_id>/)`)
}
// 本 lab 全部接口统一注册在 bindPublicRoutes 内并自行鉴权(不走全局 nav 守卫)
// makeCtx 一并导出:无副作用的 ctx 工厂,供 dev_test_scripts 的离线链路测试复用,
// 避免测试脚本自己再抄一份 callLLM/extractJson(那会与红线实现漂移)。
module.exports = { bindPublicRoutes, makeCtx }
@@ -0,0 +1,139 @@
// ============================================================
// travel_citytour/registry.js —— 技能注册表(lab 扫描层)
//
// 职责(只有三件事,绝不做业务):
// 1. 扫 skills/ 下的目录,得到 skill_id 列表
// 2. 读每个 skill 的 config.json(聚合页唯一需要的文件)
// 3. 前后端交叉校验,输出健康状态(broken)
//
// 明确的「不做」:
// - 不读 qa.json / report_spec.json / rules.md 等任何业务文件
// - 不 require 任何 skill 的 index.js(挂载由 lab index.js 负责)
// - 不认任何业务字段名
//
// 契约(与 market_skills / data_gateway 的既有约定一致):
// 后端 skills/<id>/config.json + skills/<id>/index.js
// 前端 skills/<id>/index.html
// 三者缺一 → broken,聚合页置灰不可点
// ============================================================
const fs = require('fs')
const path = require('path')
const SKILLS_DIR = path.join(__dirname, 'skills')
const PUBLIC_SKILLS_DIR = path.join(process.cwd(), 'public', 'tools', 'thought_lab', 'labs', 'travel_citytour', 'skills')
// 只允许字母数字下划线,杜绝 ../ 之类的路径穿越
const normalizeSkillId = (id) => String(id || '').replace(/[^A-Za-z0-9_]/g, '')
const listSkillIds = () => {
try {
if (!fs.existsSync(SKILLS_DIR)) return []
return fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
.filter((n) => normalizeSkillId(n) === n)
} catch {
return []
}
}
const readSkillConfig = (id) => {
try {
const p = path.join(SKILLS_DIR, id, 'config.json')
if (!fs.existsSync(p)) return {}
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
return (cfg && typeof cfg === 'object') ? cfg : {}
} catch {
return {}
}
}
const exists = (p) => { try { return fs.existsSync(p) } catch { return false } }
// 单个 skill 的「可挂载性」与「可点性」判定
const inspectSkill = (id) => {
const dir = path.join(SKILLS_DIR, id)
const entry = path.join(dir, 'index.js')
const cfgFile = path.join(dir, 'config.json')
const pageFile = path.join(PUBLIC_SKILLS_DIR, id, 'index.html')
const iconFile = path.join(PUBLIC_SKILLS_DIR, id, 'icon.svg')
const hasConfig = exists(cfgFile)
const cfg = readSkillConfig(id)
const hasEntry = exists(entry)
const hasPage = exists(pageFile)
let broken = ''
if (!hasConfig) broken = '缺少 config.json'
else if (!hasEntry) broken = '缺少后端 index.js'
else if (!hasPage) broken = '缺少前端页面 index.html'
else if (cfg.id && String(cfg.id) !== id) broken = 'config.json 的 id 与目录名不一致'
return {
id,
config: cfg,
hasConfig,
hasEntry,
hasPage,
hasIcon: exists(iconFile),
mountable: hasConfig && hasEntry && !broken,
broken
}
}
// 供聚合页使用的列表(只暴露展示必需字段,不含任何业务内容)
const listSkills = () => {
const out = []
listSkillIds().forEach((id) => {
const s = inspectSkill(id)
const cfg = s.config
if (cfg.enabled === false) return
out.push({
id,
name: String(cfg.name || (cfg.skill && cfg.skill.name) || id),
desc: String(cfg.desc || ''),
icon: String(cfg.icon || '🧭'),
icon_url: s.hasIcon ? ('./skills/' + id + '/icon.svg') : '',
sort_order: Number(cfg.sort_order || 0),
page: './skills/' + id + '/index.html',
broken: s.broken
})
})
out.sort((a, b) => {
if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order
return String(a.id).localeCompare(String(b.id))
})
return out
}
// 自检:一次列出全部异常,方便加完 skill 后不用逐个点开试
const health = () => {
const items = []
listSkillIds().forEach((id) => {
const s = inspectSkill(id)
const cfg = s.config
items.push({
id,
enabled: cfg.enabled !== false,
mountable: s.mountable,
has_config: s.hasConfig,
has_backend_index: s.hasEntry,
has_frontend_page: s.hasPage,
has_icon: s.hasIcon,
broken: s.broken
})
})
const problems = items.filter((i) => i.broken)
return { ok: problems.length === 0, total: items.length, problems: problems.length, items, problems_detail: problems }
}
module.exports = {
SKILLS_DIR,
PUBLIC_SKILLS_DIR,
normalizeSkillId,
listSkillIds,
readSkillConfig,
inspectSkill,
listSkills,
health
}
@@ -0,0 +1,147 @@
// ============================================================
// sh_2d_car_hike/ai.js —— 本技能的 AI 生成链路(技能私有)
//
// 职责:把「本技能包 + 问答答案 + 目的地覆盖」组装成一次成卷的 prompt,
// 调 lab 层 ctx.callLLM 拿回结构化 JSON,做健壮解析,失败重试一次。
//
// 边界(红线):
// - 不接触密钥、不发 HTTP、不管超时:全部由 lab 层 ctx.callLLM 负责。
// - prompt 内容 100% 归本技能所有 —— 里面出现「浴场 / 车宿 / 两天」是本技能的业务,
// 不构成对别的 skill 的耦合(这正是 skill 目录隔离的意义)。
// ============================================================
// 只把「会进 prompt」的骨架字段投出去,避免把一堆 _comment 也塞进模型
const slimSkill = (skill) => ({
id: skill.id,
name: skill.name,
version: skill.version,
days: skill.days,
nights: skill.nights,
base_city: skill.base_city,
base_origin: skill.base_origin,
travel_mode: skill.travel_mode,
region: skill.region,
anchors: skill.anchors,
degradation_rules: skill.degradation_rules,
confidence_levels: skill.confidence_levels,
unverified_fields: skill.unverified_fields,
output_discipline: skill.output_discipline
})
const buildMessages = (bundle, answers, dest) => {
const system = [
'你是一个严格按「技能包」执行行程生成的引擎。下面给你一个技能包(骨架、规则、输出契约)与本次问答答案,请产出唯一一份行程 JSON。',
'',
'【硬性要求】',
'1. 只输出 JSON 本身,不要任何解释、前后缀、Markdown 代码块标记。',
'2. 结构必须严格符合下方「输出契约」,根字段一个都不能少。',
'3. skill.json 的 anchors 是骨架:每一张 card 必须对应一个 anchor_id,一个都不能少、不能多。',
'4. 契约里的 required_table_ids 是必须存在的表格 id。',
'5. 主观项(免费停车、本地人常去、浴场档次与卫生、车宿合法性)不允许标 high,且必须带 verify 说明核实方式。',
'6. 不确定就降置信度并写进 checklist,绝对不许编造店名与价格。',
'7. 触发降级规则时必须显式写出降级说明,不许静默降低要求。',
'8. timeline 行的 kind 只表示「时间点性质」,只能取 "hard" 或 "soft":凡骨架里 time_anchor.type 为 "hard" 的槽位,其对应时间行必须写 kind="hard";其余行写 "soft"。',
'8b. timeline 行的 activity 表示「活动类型」,可选,只能取 move / eat / hike / shop / sleep / walk 之一。kind 与 activity 是正交的两个字段:「21:10 抵达车宿点」应写 kind="hard" 且 activity="sleep",绝不能因为它是过夜就把 kind 写成 "sleep"。',
'9. checklist:必须覆盖每一个在卡片里出现过非 high 字段的 anchor_id,一个槽位至少一条,不许整槽位漏收。',
'10. map.markers 要覆盖当天的所有落脚点(徒步起终点、停车点、街区打卡点、吃饭点、浴场、超市、车宿点、打卡点),点位过少地图会显得很空。',
'11. map.routes[].marker_ids 里的每个 id 必须能在 map.markers 里找到,禁止引用未定义的点位 id。',
'12. card.day 只能填 1 或 2;第一天所有节点填 1,第二天填 2。',
'',
'===== 技能包:规则 rules.md =====',
String(bundle.rules || ''),
'',
'===== 技能包:骨架与降级规则 skill.json =====',
JSON.stringify(slimSkill(bundle.skill), null, 2),
'',
'===== 技能包:输出契约 output_schema.json =====',
JSON.stringify(bundle.schema || {}, null, 2)
].join('\n')
const user = [
'===== 本次输入 =====',
JSON.stringify(answers || {}, null, 2),
'',
dest
? '===== 该目的地在技能包里的已知覆盖(请以此为准,不要另造数据) =====\n' + JSON.stringify(dest, null, 2)
: '===== 该目的地是首次生成,技能包里没有覆盖数据,请按骨架与规则自行规划,并把不确定项如实标注置信度 =====',
'',
'请直接输出这份行程的 JSON。'
].join('\n')
return [
{ role: 'system', content: system },
{ role: 'user', content: user }
]
}
// 服务端权威字段:身份、版本、时间戳一律由服务端写死,避免模型编造
const finalizeMeta = (payload, bundle, answers, model, ctx) => {
const p = payload || {}
p.meta = p.meta || {}
p.meta.skill_id = bundle.skill.id
p.meta.skill_name = bundle.skill.name
p.meta.skill_version = bundle.skill.version
p.meta.schema_version = (bundle.schema && bundle.schema.version) || ''
p.meta.qa = answers || {}
p.meta.generated_at = ctx.nowBeijing()
p.meta.generator = 'ai:' + model
if (!p.meta.destination || typeof p.meta.destination !== 'object') p.meta.destination = {}
if (!p.meta.destination.name) p.meta.destination.name = String((answers && answers.destination) || '')
if (!p.meta.destination.key) {
const d = String(p.meta.destination.name || '').trim()
p.meta.destination.key = d || 'unknown'
}
if (!p.meta.disclaimer) {
p.meta.disclaimer = '本行程由 AI 按技能包规则生成;所有主观信息均已标注置信度,出行前请按『待核实清单』自行复核。'
}
return p
}
// 生成一次行程;解析失败自动重试一次(带上纠正提示)
const generate = async ({ bundle, answers, dest, aiConfig, ctx }) => {
const model = String(aiConfig.model || 'qwen-plus-latest')
const temperature = Number.isFinite(Number(aiConfig.temperature)) ? Number(aiConfig.temperature) : 0.3
const maxTokens = Number.isFinite(Number(aiConfig.max_tokens)) ? Number(aiConfig.max_tokens) : 8000
const timeoutMs = Number.isFinite(Number(aiConfig.timeout_ms)) ? Number(aiConfig.timeout_ms) : 240000
const base = buildMessages(bundle, answers, dest)
const started = Date.now()
let attempt = 0
let lastErr = null
let raw = ''
while (attempt < 2) {
attempt++
const messages = attempt === 1
? base
: base.concat([
{ role: 'assistant', content: raw.slice(0, 4000) },
{ role: 'user', content: '上面的输出不是合法 JSON(或已被截断)。请只输出完整合法的 JSON 对象本身,不要任何解释、不要代码块标记。' }
])
const r = await ctx.callLLM({ messages, model, temperature, maxTokens, timeoutMs })
raw = r.content
try {
const payload = ctx.extractJson(raw)
return {
payload: finalizeMeta(payload, bundle, answers, model, ctx),
model,
elapsed_ms: Date.now() - started,
attempts: attempt,
usage: r.usage,
finish_reason: r.finishReason,
raw
}
} catch (e) {
lastErr = e
if (r.finishReason === 'length') {
lastErr = new Error('output_truncated')
break
}
}
}
const err = new Error(lastErr ? String(lastErr.message || lastErr) : 'generate_failed')
err.raw = raw
throw err
}
module.exports = { generate, buildMessages, finalizeMeta }
@@ -0,0 +1,36 @@
{
"_comment": "技能:上海周边2日车宿徒步游 —— 本文件是【聚合页与 lab 扫描层唯一读取的文件】,必须保持极简。lab 层只看 id/name/desc/icon/enabled/sort_order,不读任何业务内容;业务参数(ui_mode / ai)只有本 skill 自己的后端会读。",
"id_comment": "技能唯一标识,必须与目录名完全一致(不一致时 lab 会标记 broken 并在聚合页置灰)",
"id": "sh_2d_car_hike",
"name_comment": "聚合页卡片标题",
"name": "上海周边2日车宿徒步游",
"desc_comment": "聚合页卡片一句话简介(tooltip)",
"desc": "自驾 + 车内过夜 + 两段徒步 + 街区/浴场/补给;上海周边 300km 内目的地",
"icon_comment": "聚合页卡片图标(emoji)。若本目录存在 icon.svg,聚合页优先用 icon.svg",
"icon": "🚗",
"sort_order_comment": "聚合页排序,数值越小越靠前",
"sort_order": 10,
"enabled_comment": "是否启用(false 时 lab 扫描层直接跳过本 skill,聚合页不显示)",
"enabled": true,
"ui_mode_comment": "弹窗交互模式:debug=交互详情弹窗(3s 自动关闭);normal=成功降级 Toast(2s),失败仍弹窗。本项归 skill 私有,各 skill 可不同",
"ui_mode": "debug",
"ai_comment": "本 skill 的生成链路 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。本 skill 为 2 天行程,实测整份 JSON 需 1.2-1.6 万 token,这里留 20000",
"max_tokens": 20000,
"timeout_ms_comment": "单次生成超时(毫秒)。实测约 33 token/s,1.6 万 token 需 6-8 分钟,故放到 10 分钟",
"timeout_ms": 600000
}
}
@@ -0,0 +1,115 @@
{
"_comment": "技能包:目的地清单与变量覆盖。骨架(skill.json 的 anchors)永远不变,本文件只覆盖‘换个目的地要换哪些值’。一个 skill 可挂多个目的地;同一目的地也可被其他 skill 挂载,互不干扰。",
"version": "0.1.0",
"sample_dir_note": "sample_ref 相对【本 skill 自己的前端目录】解析(public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_car_hike/),离线样例放在该目录下的 sample/ 里",
"destinations": [
{
"key": "rudong",
"name": "南通如东",
"admin": "江苏省南通市如东县",
"center": [32.31, 121.19],
"center_coord_precision": "approx",
"center_note": "县中心为掘港镇,坐标仅用于地图初始视野,非精确点位",
"terrain": "平原 · 沿海,无山",
"degradations_applied": ["terrain_flat", "no_large_bathhouse"],
"from_base_comment": "默认出发点=上海宝山富锦路地铁站 → 如东的里程/时长/过路费,均为估算示意,未接实时路况;换出发点必须重算这一组数字,不得沿用旧值",
"from_base": {
"origin": "上海宝山富锦路地铁站",
"highway": { "distance_km": 170, "hours": 2.6, "toll_cny": 85 },
"non_highway": { "distance_km": 162, "hours": 3.5, "toll_cny": 0 },
"basis": "估算示意"
},
"anchors_override_comment": "每个槽位在如东的落点候选。凡 confidence != high 的,生成时必须原样带进报告的‘待核实清单’",
"anchors_override": {
"district_arrive": {
"pick": "栟茶古镇一带(历史街区)",
"candidates": ["茶古镇老街一带", "如东县城掘港镇老街一带(闹市街区备选)"],
"confidence": "medium",
"verify": "古镇开放范围、夜间照明与停车安排,出发前查当地公告或致电古镇管理方"
},
"district_parking": {
"pick": "古镇外围绕行找路侧泊位或镇区公共停车",
"free_parking_expected": "unknown",
"confidence": "low",
"verify": "是否有免费停车场需现场确认,不可默认写成免费"
},
"lunch_d1": {
"pick": ["古镇周边本地小馆(海鲜/文蛤/河鲜类)", "镇区非游客主街的家常菜馆"],
"confidence": "low",
"verify": "店名与人均需现场或本地口碑核实;不确定时只给业态+位置描述,不得编造店名"
},
"hike_1": {
"pick": "沿海堤岸 / 湿地段徒步",
"terrain_note": "水线徒步,累计爬升约 0",
"confidence": "low",
"verify": "堤岸是否允许步行、是否有施工封闭,需现场确认"
},
"bathhouse": {
"pick": "沿海温泉 / 大型洗浴中心(降级替代)",
"degraded": true,
"confidence": "low",
"verify": "营业状态、是否需预约、免费停车是否落实,出发前电话确认"
},
"grocery": {
"pick": "浴场同一商圈的超市(不跑大卖场)",
"confidence": "low",
"verify": "超市是否还在营业(泡完汤约 20 点)、能否与浴场共用停车点,需现场核实"
},
"car_sleep": {
"pick": "县城公园类市政停车场(候选)",
"risk_level": "medium",
"confidence": "low",
"verify": "过夜停车是否被劝离,以现场标识与当地执法口径为准"
},
"morning_walk": {
"pick": "车宿点所在公园内环线",
"confidence": "medium",
"verify": "夜间路灯与公厕开放情况需现场确认"
},
"breakfast_wash": {
"pick": "县城本地早点铺 + 公园公厕/加油站卫生间洗漱",
"confidence": "low",
"verify": "早点铺开门时间(5-6 点是否营业)需现场确认"
},
"hike_2": {
"pick": "沿海林带 / 河道绿廊段徒步",
"terrain_note": "林道 + 水岸,累计爬升约 0",
"confidence": "low",
"verify": "林带开放情况与出入口位置需核实"
},
"photo_spots": {
"pick": ["海堤 / 范公堤类人文点(候选)", "古镇内人文点位(候选)"],
"confidence": "low",
"verify": "具体点位、开放时间与门票需核实"
},
"lunch_d2": {
"pick": ["县城本地家常菜老店(可停车优先)"],
"confidence": "low",
"verify": "店名与停车条件需现场核实"
}
},
"notes": [
"如东为平原沿海地区,已触发 terrain_flat:两段徒步均为平路,累计爬升约 0,报告必须显式写明‘平路徒步’,不得冒充山地徒步效果",
"本地无传统大型浴场,已触发 no_large_bathhouse:降级为温泉 / 大型洗浴中心并显式标注",
"默认出发点为上海宝山富锦路地铁站;换出发点后 from_base 的里程/时长/过路费必须重算",
"补给按用户口径改为‘浴场旁超市与浴场一起完成’,不再单独安排大卖场行程",
"沿海地区风力较大、夏秋季注意蚊虫与防晒;海鲜类餐食注意时令"
],
"runs": [
{
"date": "2026-09-19",
"status": "draft",
"generator": "manual_sample",
"skill_version": "0.3.0",
"sample_ref": "sample/rudong_2026-09-19.json"
}
]
}
]
}
@@ -0,0 +1,231 @@
// ============================================================
// sh_2d_car_hike/index.js —— 本技能的后端路由(技能私有、自包含)
//
// 契约(lab 扫描层唯一要求):
// module.exports = { bindRoutes(router, ctx) }
// - router:lab 层已经 .use(labAuth) + .use(readLimit) 之后再交给我,
// 所以本文件【不写任何鉴权代码】,也拿不到绕过鉴权的机会。
// - ctx :lab 层提供的红线能力(密钥/时间/转义/日志/数据目录…)。
//
// 挂载点:/api/thought_lab/travel_citytour/skills/sh_2d_car_hike/*
// 本技能与同 lab 其他技能零耦合:删除本目录即完全卸载。
// ============================================================
const fs = require('fs')
const path = require('path')
const ai = require('./ai')
const { validatePayload } = require('./validate')
const { repairPayload } = require('./repair')
const SKILL_DIR = __dirname
// 本技能的技能包文件清单。缺哪个都不算错(除 skill.json 外),只是能力变少。
const SKILL_FILES = {
skill: 'skill.json',
qa: 'qa.json',
schema: 'output_schema.json',
report_spec: 'report_spec.json',
destinations: 'destinations.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')
if (key === 'rules') { bundle.rules = txt; return }
try {
bundle[key] = JSON.parse(txt)
} catch (e) {
throw new Error('skill_file_broken:' + SKILL_FILES[key])
}
})
if (!bundle.skill || !bundle.skill.id) throw new Error('skill_meta_missing')
bundle.id = bundle.skill.id
return bundle
}
// 目的地覆盖:用户输入的目的地若在本技能已跑过,取出它的覆盖值
const findDestination = (bundle, destName) => {
const list = (bundle.destinations && bundle.destinations.destinations) || []
const key = String(destName || '').trim()
if (!key) return null
return list.find((d) => d.name === key || d.key === key || (Array.isArray(d.aliases) && d.aliases.indexOf(key) >= 0)) || null
}
// 按本技能 qa.json 的定义做通用必填校验(不写死任何字段名)
const validateAnswers = (bundle, answers) => {
const missing = []
const qa = bundle.qa || {}
const destInput = qa.destination_input || {}
if (destInput.required !== false && !String(answers.destination || '').trim()) {
missing.push(destInput.label || 'destination')
}
;(Array.isArray(qa.questions) ? qa.questions : []).forEach((q) => {
if (!q || q.required === false) return
const v = answers[q.key]
if (v === undefined || v === null || String(v).trim() === '') missing.push(q.label || q.key)
})
return missing
}
// ============================================================
// 最近一次生成结果(本技能私有 data/)
//
// 存在意义:一次生成约 4 分钟,而长请求经 8975 反代存在「服务端已返回、客户端没收到」的情况。
// 有了这份落盘结果,刷新页面后点「载入最近一次生成」即可看结果,不必重新烧 4 分钟。
// 各技能各存一份,互不覆盖(这是目录隔离顺带修掉的老毛病)。
// ============================================================
const LAST_FILE = 'last_result.json'
const LAST_KEEP_MS = 7 * 24 * 3600 * 1000
const writeLastResult = (ctx, envelope) => {
try {
fs.mkdirSync(ctx.dataDir, { recursive: true })
fs.writeFileSync(path.join(ctx.dataDir, LAST_FILE), JSON.stringify(envelope), 'utf-8')
} catch (e) {
ctx.log('last_result 写入失败', { error: String((e && e.message) || e) })
}
}
const readLastResult = (ctx) => {
try {
const file = path.join(ctx.dataDir, LAST_FILE)
if (!fs.existsSync(file)) return null
if (Date.now() - fs.statSync(file).mtimeMs > LAST_KEEP_MS) return null
return JSON.parse(fs.readFileSync(file, 'utf-8'))
} catch (e) {
return null
}
}
// ============================================================
// 路由:全部为相对路径,挂载前缀由 lab 层决定
// ============================================================
const bindRoutes = (router, ctx) => {
// 本技能元信息:页面双模式依据 + 渲染描述 + 已跑目的地 + 问答定义
router.get('/meta', (req, res) => {
try {
const cfg = ctx.readConfig()
const bundle = loadBundle()
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: bundle.id,
skill: {
id: bundle.skill.id,
name: bundle.skill.name,
version: bundle.skill.version,
days_label: bundle.skill.days ? `${bundle.skill.days} 天 ${bundle.skill.nights || bundle.skill.days - 1} 夜` : '',
base_origin: bundle.skill.base_origin || '',
anchors_count: Array.isArray(bundle.skill.anchors) ? bundle.skill.anchors.length : 0,
anchors: bundle.skill.anchors || []
},
qa: bundle.qa || {},
report_spec: bundle.report_spec || {},
destinations: (bundle.destinations && bundle.destinations.destinations) || []
})
} catch (e) {
const msg = String((e && e.message) || e)
return res.status(msg.indexOf('skill_file_broken') === 0 ? 500 : 500).json({ ok: false, error: msg })
}
})
// 最近一次生成结果
router.get('/last', (req, res) => {
const env = readLastResult(ctx)
if (!env) {
return res.status(404).json({ ok: false, error: 'no_last_result', hint: '还没有生成过,或结果已超过 7 天保留期' })
}
return res.json(env)
})
// 生成行程(鉴权已由 lab 层完成;ctx.genGuard 负责按 skill 单飞 + 全局并发上限 + 按 IP 限流)
router.post('/generate', ctx.jsonParser, ctx.genGuard, async (req, res) => {
const body = ctx.sanitizeObject(req.body || {})
let bundle = null
try {
bundle = loadBundle()
} catch (e) {
return res.status(500).json({ ok: false, error: String((e && e.message) || e) })
}
const answers = (body.answers && typeof body.answers === 'object' && !Array.isArray(body.answers)) ? body.answers : {}
const missing = validateAnswers(bundle, answers)
if (missing.length) return res.status(400).json({ ok: false, error: 'missing_answers', missing })
const cfg = ctx.readConfig()
const aiConfig = cfg.ai || {}
const dest = findDestination(bundle, answers.destination)
// 生成本身约 4 分钟,经 IIS 反代(8975)时可能被中途掐断。
// 这行日志用于区分「服务端已返回」与「客户端真的收到」——两者现象一样,但根因完全不同。
res.on('close', () => {
ctx.log('generate resp closed', { delivered: !!res.writableEnded, dest: answers.destination })
})
try {
ctx.log('generate start', { dest: answers.destination, model: aiConfig.model || 'qwen-plus-latest' })
const result = await ai.generate({ bundle, answers, dest, aiConfig, ctx })
// AI 一定会变形:先做无损修复,再校验(修复说明一并在响应里回传,前端交互弹窗可见)
const repaired = repairPayload(result.payload, bundle)
const check = validatePayload(repaired.payload, bundle)
const elapsed = result.elapsed_ms
ctx.log('generate done', {
dest: answers.destination, ms: elapsed, attempts: result.attempts,
repairs: repaired.fixes.length, errors: check.errors.length, warnings: check.warnings.length
})
const envelope = {
ok: check.errors.length === 0,
payload: repaired.payload,
report_spec: bundle.report_spec || {},
destination_override: dest ? { key: dest.key, name: dest.name, degradations_applied: dest.degradations_applied || [] } : null,
check,
meta: {
model: result.model,
elapsed_ms: elapsed,
attempts: result.attempts,
finish_reason: result.finish_reason,
usage: result.usage,
repairs: repaired.fixes,
is_new_destination: !dest
}
}
if (check.errors.length) {
envelope.error = 'payload_invalid'
envelope.raw_excerpt = String(result.raw || '').slice(0, 6000)
}
// 【为什么有 error 也照样落盘 + 照样用 200 回传】
// 一次生成要烧 3-4 分钟与上万 token。过去只要结构校验出现哪怕 1 个 error 就整份丢弃、
// 既不落盘也不回传 payload,用户什么都拿不到 —— 而那个 error 很可能只是校验器的误判
// (真实事故:校验器写死 day.label 含「第一天」,AI 标成「周六」→ 明明排了夜逛却报「缺夜逛」,
// 4 分钟的生成被白扔)。
// 现在改为:结果一律先落盘再回传;有结构问题时用 ok:false + error 字段如实告知,
// 前端会显示醒目的「N 处结构问题」标记,用户仍可查看/导出并自行判断是否可用。
// 用 200 而不是 422:422 会让前端走「失败」分支,payload 反而渲染不出来。(本 skill 私有决策)
writeLastResult(ctx, envelope)
return res.json(envelope)
} catch (e) {
const msg = String((e && e.message) || e)
ctx.log('generate failed', { dest: answers.destination, error: msg })
const status = msg === 'dashscope_key_missing' || msg === 'dashscope_host_missing' ? 500 : 502
const hint = msg === 'output_truncated'
? '模型输出被截断(max_tokens 不足),可在本技能的 config.json 调大 ai.max_tokens 后重试'
: (msg === 'no_json_found' ? '模型没有按要求输出 JSON,可降低 ai.temperature 或重试' : '')
return res.status(status).json({
ok: false,
error: msg,
hint,
raw_excerpt: String((e && e.raw) || '').slice(0, 6000)
})
}
})
}
module.exports = { bindRoutes, loadBundle, findDestination, validateAnswers }
@@ -0,0 +1,287 @@
{
"_comment": "技能包:输出契约。生成结果(payload)必须严格符合本结构;后端只按本结构做通用校验与透传,不认识任何具体业务字段。新增 skill 自带一份自己的 output_schema.json,互不干扰。",
"version": "0.2.0",
"dialect": "json-schema-lite",
"root_comment": "根对象字段固定;业务内容全部装在 cards / tables / timeline 里,因此换 skill 不需要改库、不需要改后端",
"root": {
"type": "object",
"required": ["meta", "summary", "timeline", "cards", "map", "tables", "cost", "checklist", "notes"],
"properties": {
"meta": { "$ref": "#/definitions/meta" },
"summary": { "$ref": "#/definitions/summary" },
"timeline": { "$ref": "#/definitions/timeline" },
"cards": { "type": "array", "items": { "$ref": "#/definitions/card" }, "minItems": 1 },
"map": { "$ref": "#/definitions/map" },
"tables": { "type": "array", "items": { "$ref": "#/definitions/table" } },
"cost": { "$ref": "#/definitions/cost" },
"checklist": { "type": "array", "items": { "$ref": "#/definitions/checklist_item" } },
"notes": { "type": "array", "items": { "$ref": "#/definitions/note" } }
}
},
"required_table_ids_comment": "这些 table 由本 skill 的 report_spec 按 pick_id 引用,必须存在,否则该区块渲染为空",
"required_table_ids": ["drive_out_compare", "drive_back_compare", "food_summary", "parking_summary"],
"required_card_anchor_ids_comment": "card.anchor_id 必须覆盖 skill.json 的全部 anchors,一个都不能少(槽位齐全即代表 15 条要求全落地)",
"required_card_anchor_ids_source": "skill.json#/anchors",
"definitions": {
"fact_comment": "可置信度化字段:纯字符串(视为 high,仅限客观信息)或对象(主观项必须用对象并带 verify)",
"fact": {
"oneOf": [
{ "type": "string" },
{
"type": "object",
"required": ["text", "confidence"],
"properties": {
"text": { "type": "string" },
"confidence": { "enum": ["high", "medium", "low"] },
"verify": { "type": "string", "description": "核实方式,confidence != high 时必填" }
}
}
]
},
"meta": {
"type": "object",
"required": ["skill_id", "skill_name", "skill_version", "schema_version", "destination", "qa", "generated_at", "generator", "disclaimer"],
"properties": {
"skill_id": { "type": "string" },
"skill_name": { "type": "string" },
"skill_version": { "type": "string" },
"schema_version": { "type": "string" },
"destination": {
"type": "object",
"required": ["key", "name"],
"properties": {
"key": { "type": "string" },
"name": { "type": "string" },
"admin": { "type": "string" },
"center": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 }
}
},
"qa": { "type": "object", "description": "问答答案原样回填,字段名由 qa.json 决定" },
"generated_at": { "type": "string", "description": "北京时间 YYYY-MM-DD HH:mm:ss" },
"generator": { "type": "string", "description": "manual_sample / ai:<model>" },
"disclaimer": { "type": "string" }
}
},
"summary": {
"type": "object",
"required": ["title", "one_liner", "stats"],
"properties": {
"title": { "type": "string" },
"one_liner": { "type": "string" },
"tags": { "type": "array", "items": { "type": "string" } },
"stats": {
"type": "array",
"items": {
"type": "object",
"required": ["label", "value"],
"properties": {
"label": { "type": "string" },
"value": { "type": "string" },
"hint": { "type": "string" }
}
}
}
}
},
"timeline": {
"type": "object",
"required": ["days"],
"properties": {
"days": {
"type": "array",
"items": {
"type": "object",
"required": ["label", "date", "rows"],
"properties": {
"label": { "type": "string" },
"date": { "type": "string" },
"rows": {
"type": "array",
"items": {
"type": "object",
"required": ["time", "title", "anchor_id", "kind"],
"properties": {
"time": { "type": "string" },
"title": { "type": "string" },
"anchor_id": { "type": "string" },
"kind": { "enum": ["hard", "soft"], "description": "时间点性质,只表性质、不表活动:hard=骨架声明为硬时间点(不可漂移);soft=常规。禁止把活动类型写进本字段。" },
"activity": { "enum": ["move", "eat", "hike", "shop", "sleep", "walk"], "description": "活动类型,可选。与 kind 正交——同一行既要能标 hard,也要能标 sleep。" },
"note": { "type": "string" }
}
}
}
}
}
}
}
},
"image": {
"type": "object",
"required": ["url", "kind", "caption", "disclaimer"],
"properties": {
"url": { "type": "string", "description": "优先本地相对路径(已落盘);远程 URL 视为未落盘" },
"kind": { "enum": ["ai_illustration", "map_capture", "icon"] },
"caption": { "type": "string" },
"disclaimer": { "type": "string" }
}
},
"card": {
"type": "object",
"required": ["id", "anchor_id", "day", "title", "fields"],
"properties": {
"id": { "type": "string" },
"anchor_id": { "type": "string", "description": "必须对应 skill.json 的一个 anchor.id" },
"day": { "type": "integer", "enum": [1, 2] },
"title": { "type": "string" },
"subtitle": { "type": "string" },
"image": { "$ref": "#/definitions/image" },
"badges": {
"type": "array",
"items": {
"type": "object",
"required": ["text"],
"properties": {
"text": { "type": "string" },
"tone": { "enum": ["info", "good", "warn", "danger", "plain"] }
}
}
},
"fields": {
"type": "array",
"items": {
"type": "object",
"required": ["label", "value"],
"properties": {
"label": { "type": "string" },
"value": { "$ref": "#/definitions/fact" },
"hint": { "type": "string" }
}
}
},
"bullets": { "type": "array", "items": { "type": "string" } },
"map_marker_ids": { "type": "array", "items": { "type": "string" } }
}
},
"marker": {
"type": "object",
"required": ["id", "name", "kind", "lat", "lng", "day", "seq"],
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"kind": { "enum": ["start", "end", "waypoint", "parking", "hike", "district", "food", "bathhouse", "shop", "sleep", "walk", "breakfast", "spot"] },
"lat": { "type": "number" },
"lng": { "type": "number" },
"coord_precision": { "enum": ["exact", "approx"], "description": "approx 表示仅用于示意图定位" },
"day": { "type": "integer", "enum": [1, 2] },
"seq": { "type": "integer" },
"note": { "type": "string" }
}
},
"route": {
"type": "object",
"required": ["id", "name", "kind", "marker_ids"],
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"kind": { "enum": ["drive", "hike", "stroll"] },
"marker_ids": { "type": "array", "items": { "type": "string" }, "minItems": 2 },
"color": { "type": "string" },
"dashed": { "type": "boolean" },
"note": { "type": "string", "description": "必须说明是点位顺序示意,非真实道路/轨迹" }
}
},
"map": {
"type": "object",
"required": ["center", "zoom", "markers", "routes"],
"properties": {
"center": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 },
"zoom": { "type": "integer" },
"markers": { "type": "array", "items": { "$ref": "#/definitions/marker" } },
"routes": { "type": "array", "items": { "$ref": "#/definitions/route" } }
}
},
"table": {
"type": "object",
"required": ["id", "title", "columns", "rows"],
"properties": {
"id": { "type": "string" },
"title": { "type": "string" },
"columns": {
"type": "array",
"items": {
"type": "object",
"required": ["key", "label"],
"properties": {
"key": { "type": "string" },
"label": { "type": "string" },
"align": { "enum": ["left", "center", "right"] },
"width": { "type": "string" }
}
}
},
"rows": { "type": "array", "items": { "type": "object", "description": "键为 columns[].key" } },
"note": { "type": "string" },
"highlight_row_keys_comment": "需要强调的行,值为 rows 里的 row._key",
"highlight_row_keys": { "type": "array", "items": { "type": "string" } }
}
},
"cost": {
"type": "object",
"required": ["currency", "items", "total"],
"properties": {
"currency": { "enum": ["CNY"] },
"items": {
"type": "array",
"items": {
"type": "object",
"required": ["label", "amount"],
"properties": {
"label": { "type": "string" },
"amount": { "type": "number" },
"unit": { "type": "string" },
"note": { "type": "string" }
}
}
},
"total": { "type": "number" },
"note": { "type": "string" }
}
},
"checklist_item": {
"type": "object",
"required": ["text", "confidence"],
"properties": {
"anchor_id": { "type": "string" },
"field": { "type": "string" },
"text": { "type": "string" },
"confidence": { "enum": ["medium", "low"] },
"verify": { "type": "string" }
}
},
"note": {
"type": "object",
"required": ["level", "title", "text"],
"properties": {
"level": { "enum": ["info", "warn", "danger"] },
"title": { "type": "string" },
"text": { "type": "string" }
}
}
}
}
@@ -0,0 +1,95 @@
{
"_comment": "技能包:问答脚本。生成前由通用层按本文件动态渲染问答表单;后端只透传答案,不解析字段含义。新增问答项不需要改后端代码。",
"version": "0.1.0",
"destination_input_comment": "目的地是通用层输入(skill ↔ 目的地多对多),本 skill 只负责覆盖它的叫法、提示与校验口径",
"destination_input": {
"label": "目的地",
"placeholder": "例如:南通如东、四明山",
"required": true,
"mode": "free_input_with_suggestion",
"suggestions_ref": "destinations.json",
"region_radius_km": 300,
"on_out_of_range": "block_with_hint",
"hint": "本 Skill 适用于上海周边 300km 内、可当日自驾抵达的目的地;超出范围建议改用「民宿游 / 海外游」等其他 Skill"
},
"questions_comment": "问答项:type 支持 date / time / select / number / text;affects 说明该项会影响哪些产出,便于页面提示用户",
"questions": [
{
"key": "departure_origin",
"label": "出发地点",
"type": "text",
"required": true,
"default": "上海宝山富锦路地铁站",
"affects": [
"去程与返程的总里程 / 时长 / 过路费",
"出发时间建议(路程变长必须提前出门)",
"地图上的起点位置"
],
"hint": "默认从宝山富锦路地铁站出发,可改成任意出发点;改点后去返程的里程、时长、过路费都会重算"
},
{
"key": "departure_date",
"label": "出行日期(周六)",
"type": "date",
"required": true,
"default_rule": "nearest_saturday",
"affects": [
"日出与天亮时间",
"季节穿着与蚊虫提示",
"浴场是否旺季",
"周日下午返程高峰时段",
"公园夜间人流与照明"
],
"hint": "本 Skill 固定周六出发、周日返程;日期只影响季节类信息"
},
{
"key": "departure_time",
"label": "出发时刻",
"type": "time",
"required": false,
"default": "07:00",
"affects": [
"抵达目的地时间",
"第一段徒步开始时刻",
"17:00 抵达浴场是否可达",
"22:00 抵达车宿点是否可达"
],
"hint": "默认 07:00 出门;若目的地车程偏长,会自动建议提前出发并在报告中说明"
},
{
"key": "vehicle_type",
"label": "车型",
"type": "select",
"required": true,
"default": "fuel",
"options": [
{ "value": "fuel", "label": "油车" },
{ "value": "ev", "label": "电车" }
],
"affects": [
"里程成本算法",
"沿途补能点(电车必须给充电站位置与数量)",
"非高速版必须提示充电桩密度与补能风险"
]
},
{
"key": "headcount",
"label": "出行人数",
"type": "number",
"required": true,
"default": 1,
"min": 1,
"max": 4,
"affects": [
"餐食推荐份量",
"单人能否点半份",
"车内睡眠空间安排"
]
}
],
"not_asked_comment": "按用户要求,口味与忌口不收集;如将来需要,直接往 questions 里追加即可,无需改后端",
"not_asked": ["taste_preference", "dietary_restriction"]
}
@@ -0,0 +1,121 @@
// ============================================================
// travel_citytour/repair.js —— 生成结果的无损修复(游山玩水 lab 本地)
//
// 设计前提:AI 一定会变形。所以我们不指望它 100% 守规矩,而是在服务端做
// 「可解释、无损、不改业务语义」的自动修复,把能修的修好,修不了的留给校验报出来。
//
// 当前修复项(全部与业务字段无关,换 skill 同样适用):
// 1. 地图路线引用了不存在点位 id → 先做归一名匹配(marker_x / x / Marker-X 视为同一个),
// 仍找不到的丢弃;剩余点位不足 2 个的路线整条丢弃(画不出来)
// 2. 待核实清单漏收槽位 → 为该槽位补一条通用条目(文案只用卡片标题,不编造事实)
// ============================================================
const isNonHighFact = (node) => {
if (Array.isArray(node)) return node.some(isNonHighFact)
if (node && typeof node === 'object') {
if ('confidence' in node) return node.confidence !== 'high'
return Object.keys(node).some((k) => isNonHighFact(node[k]))
}
return false
}
// marker_hike_1_start / Hike1Start / hike-1-start → hike1start
const normId = (s) => String(s || '')
.toLowerCase()
.replace(/^marker[_\-]?/, '')
.replace(/[^a-z0-9]/g, '')
const repairMarkerRefs = (map, fixes) => {
const markers = Array.isArray(map.markers) ? map.markers : []
const routes = Array.isArray(map.routes) ? map.routes : []
if (!markers.length || !routes.length) return
const exists = new Set(markers.map((m) => m && m.id))
const byNorm = {}
markers.forEach((m) => { if (m && m.id) byNorm[normId(m.id)] = m.id })
map.routes = routes.filter((r) => {
if (!r) return false
const raw = Array.isArray(r.marker_ids) ? r.marker_ids : []
const out = []
raw.forEach((id) => {
if (exists.has(id)) { out.push(id); return }
const hit = byNorm[normId(id)]
if (hit) { out.push(hit); fixes.push(`路线 ${r.id}:点位 ${id} → ${hit}(id 归一匹配)`); return }
fixes.push(`路线 ${r.id}:丢弃未定义点位 ${id}`)
})
const dedup = []
out.forEach((id) => { if (dedup.indexOf(id) < 0) dedup.push(id) })
r.marker_ids = dedup
if (dedup.length < 2) { fixes.push(`路线 ${r.id}:有效点位不足 2 个,已整条丢弃`); return false }
return true
})
}
const fillChecklistGaps = (payload, bundle, fixes) => {
const anchors = (bundle.skill && Array.isArray(bundle.skill.anchors)) ? bundle.skill.anchors : []
const anchorIds = anchors.map((a) => a.id)
const cards = Array.isArray(payload.cards) ? payload.cards : []
const checklist = Array.isArray(payload.checklist) ? payload.checklist : []
payload.checklist = checklist
const covered = new Set(checklist.map((c) => c && c.anchor_id).filter(Boolean))
// 清掉引用了未定义槽位的条目(AI 偶尔会造新槽位名)
payload.checklist = checklist.filter((c) => {
if (!c || !c.anchor_id) return true
if (anchorIds.indexOf(c.anchor_id) >= 0) return true
fixes.push(`清单:移除引用未定义槽位的条目(${c.anchor_id})`)
return false
})
const covered2 = new Set(payload.checklist.map((c) => c && c.anchor_id).filter(Boolean))
cards.forEach((c) => {
if (!c || !c.anchor_id) return
if (covered2.has(c.anchor_id)) return
if (!isNonHighFact(c)) return
payload.checklist.push({
anchor_id: c.anchor_id,
field: 'auto_added',
text: `「${String(c.title || c.anchor_id)}」含低置信信息,出行前需核实`,
confidence: 'low',
verify: '见该节点卡片中的「核实」说明;本条由服务端自动补齐(原清单漏收该槽位)'
})
fixes.push(`清单:为槽位 ${c.anchor_id} 自动补一条待核实项`)
})
}
// 3. 硬时间点行的 kind 与骨架声明不一致 → 无损对齐
// 骨架已声明该槽位是硬时间点,但 AI 可能把活动类型(sleep/eat/hike…)填进 kind;
// 这里把 kind 对齐为 hard,并把原活动名搬进 activity(该行还没有 activity 时),
// 不改任何事实、也不丢 AI 原本表达的语义。
const ACTIVITIES = ['move', 'eat', 'hike', 'shop', 'sleep', 'walk']
const alignHardKind = (payload, bundle, fixes) => {
const anchors = (bundle.skill && Array.isArray(bundle.skill.anchors)) ? bundle.skill.anchors : []
const hardIds = anchors.filter((a) => a.time_anchor && a.time_anchor.type === 'hard').map((a) => a.id)
if (!hardIds.length) return
const days = (payload.timeline && Array.isArray(payload.timeline.days)) ? payload.timeline.days : []
days.forEach((d) => {
;((d && d.rows) || []).forEach((r) => {
if (!r || hardIds.indexOf(r.anchor_id) < 0 || r.kind === 'hard') return
const old = String(r.kind || '未填')
const isActivity = ACTIVITIES.indexOf(r.kind) >= 0
if (isActivity && !r.activity) r.activity = r.kind
r.kind = 'hard'
fixes.push(`时序:槽位 ${r.anchor_id} 的 kind「${old}」对齐为 hard(骨架已声明该槽位为硬时间点`
+ (isActivity ? `,原值 ${old} 已搬入 activity` : '') + ')')
})
})
}
// 入口:返回修复后的 payload 与修复说明清单
const repairPayload = (payload, bundle) => {
const fixes = []
if (!payload || typeof payload !== 'object') return { payload, fixes }
try { repairMarkerRefs(payload.map || {}, fixes) } catch (e) { fixes.push('地图修复异常:' + String(e.message || e)) }
try { fillChecklistGaps(payload, bundle || {}, fixes) } catch (e) { fixes.push('清单修复异常:' + String(e.message || e)) }
try { alignHardKind(payload, bundle || {}, fixes) } catch (e) { fixes.push('硬时间点对齐异常:' + String(e.message || e)) }
return { payload, fixes }
}
module.exports = { repairPayload, normId }
@@ -0,0 +1,38 @@
{
"_comment": "技能包:渲染描述。通用渲染器只认 block 类型 + 数据路径 + pick_id,不认识任何业务字段;换 skill 只换这份文件,前端一行不改。",
"version": "0.1.0",
"block_types_comment": "通用渲染器支持的 8 种 block;skill 只能用这些类型组合页面,不得要求前端为某个 skill 写专用代码",
"block_types": ["overview", "timeline", "cards", "map", "table", "cost", "checklist", "notes"],
"layout_comment": "页面骨架:单列纵向",
"layout": "single_column",
"show_index_comment": "是否在页首显示区块锚点导航",
"show_index": true,
"sections_comment": "区块顺序即页面从上到下的顺序;data 为简单点号路径($.a.b),pick_id 用于从数组里按 id 取一项",
"sections": [
{ "id": "sec_overview", "type": "overview", "title": "行程概览", "data": "$.summary" },
{ "id": "sec_notes_top", "type": "notes", "title": "出行前必读", "data": "$.notes", "filter_levels": ["warn", "danger"] },
{ "id": "sec_timeline", "type": "timeline", "title": "两天时序", "data": "$.timeline" },
{ "id": "sec_cards", "type": "cards", "title": "节点详情", "data": "$.cards", "group_by": "day" },
{ "id": "sec_map", "type": "map", "title": "点位与路线", "data": "$.map" },
{ "id": "sec_drive_out", "type": "table", "title": "去程 · 双版本对比", "data": "$.tables", "pick_id": "drive_out_compare" },
{ "id": "sec_drive_back", "type": "table", "title": "返程 · 双版本对比", "data": "$.tables", "pick_id": "drive_back_compare" },
{ "id": "sec_food", "type": "table", "title": "三餐候选一览", "data": "$.tables", "pick_id": "food_summary" },
{ "id": "sec_parking", "type": "table", "title": "各节点停车一览", "data": "$.tables", "pick_id": "parking_summary" },
{ "id": "sec_cost", "type": "cost", "title": "费用合计", "data": "$.cost" },
{ "id": "sec_checklist", "type": "checklist", "title": "待核实清单", "data": "$.checklist" },
{ "id": "sec_notes", "type": "notes", "title": "提示与免责", "data": "$.notes" }
],
"confidence_badge_comment": "置信度徽标文案映射(渲染器通用逻辑,不由 skill 硬编)",
"confidence_badge": {
"high": "已核实",
"medium": "待确认",
"low": "需核实"
},
"image_disclaimer_default_comment": "图片一律按示意图处理,不冒充实景",
"image_disclaimer_default": "AI 示意图,非实景",
"map_route_disclaimer_default": "点位顺序示意,非真实道路/轨迹"
}
@@ -0,0 +1,167 @@
# 上海周边2日车宿徒步游 · 生成规则
> 本文件是 skill #1 的**行为规则**,也是我(Trae)以后迭代这个技能的唯一入口。
> 改动本文件必须同步升 `skill.json` 的 `version`,并在改动说明里写清改了什么。
> 本 skill 与其他 skill 完全独立,不引用任何外部规则文件。
---
## 一、角色与目标
你是一名**华东自驾路线规划师**,专门为单人自驾 + 车内过夜的周末行程做规划。
产出目标:给出一份**可直接照着执行的两天一夜行程**,并且**如实标注不确定性**。
宁可标"这条信息置信度低、请电话确认",也不许编一个看起来很像真的店名和价格。
固定前提(不随目的地变):
- 出发地:**默认上海宝山富锦路地铁站**(问答项 `departure_origin` 可改成任意出发点);出行方式:自驾 + 车内过夜(不订酒店)
- 天数:周六出发、周日返程,两天一夜
- 两次徒步,**各 1.5 小时**
- 一个人出行,餐食按单人设计
可变的只有:**目的地** + **问答结果**(出行时间 / 车型 / 人数)。
---
## 二、问答参数怎么用
| 参数 | 必须影响的产出 |
|---|---|
| 出发地点(默认宝山富锦路地铁站) | 去返程的**总里程 / 时长 / 过路费必须按新出发点重算**,不得沿用旧址;路程变长必须提前出发时间建议;地图起点位置随之改变 |
| 出行时间(日期) | 日出/天亮时间、浴场是否旺季、季节穿着与蚊虫提示、周日下午返程高峰时段、公园夜间是否有人流 |
| 车型(油车/电车) | 里程成本算法;电车必须给沿途**充电站**位置与数量,非高速版必须提示充电桩密度;油车给油价口径 |
| 人数(默认 1) | 餐食按人数设计;1 人时明确给"单人份是否有、能否点半份" |
时间口径统一按**北京时间**(Asia/Shanghai)展示,不得出现 UTC 裸时间。
---
## 三、时序硬点(不可漂移)
| 时间 | 动作 | 强度 |
|---|---|---|
| 周六 06:30-07:30 | 上海出发 | 软 |
| 周六 09:30 前后 | 抵达目的地,**直接徒步** | 软 |
| 周六 09:30-11:00 | 第一段徒步 1.5h | 含硬约束(时长) |
| 周六 11:15 前后 | 到街区,先解决停车 | 软 |
| 周六 11:30-12:40 | 午饭 | 软 |
| 周六 12:40-15:40 | 街区打卡动线约 3h | 含硬约束(时长) |
| **周六 17:00 前后** | **抵达浴场** | **硬** |
| 周六 19:40-20:15 | 浴场旁超市补给(与浴场一起完成) | 软 |
| **周六 ≤22:00** | **抵达车宿点(提前到不算漂移)** | **硬** |
| **周日 05:00** | **起床散步** | **硬** |
| 周日 06:15-07:15 | 早饭 + 洗漱 | 软 |
| 周日 08:15-09:45 | 第二段徒步 1.5h | 含硬约束(时长) |
| 周日 10:00-11:20 | 打卡点 1-2 个 | 软 |
| 周日 11:30-12:40 | 午饭 | 软 |
| 周日 13:00 后 | 返程 | 软 |
**硬点漂移即为不合格。** 若目的地车程过长导致 17:00 浴场 / 22:00 车宿不可达,必须调整出发时间建议并显式说明,而不是悄悄改硬点。
---
## 四、逐槽位规则
槽位的硬约束以 `skill.json` 的 `anchors` 为准,以下是执行时必须遵守的判定口径。
### drive_out / drive_back(双版本路线)
- 两套方案**必须并列展示**:`非高速版` / `高速版`。
- 每套必须含:总里程、预计时长、过路费、沿途服务区/加油站(电车为充电站)数量、拥堵风险。
- 必须给一句**取舍建议**:时间优先选哪套、省钱选哪套、风景选哪套。
- 非高速版的里程与时长**不得低于**高速版(如出现,说明数据有问题,需复核)。
- 出发点默认「上海宝山富锦路地铁站」,由问答项 `departure_origin` 覆盖;**换出发点后这一组数字必须重算**。
- 路线几何**只作为示意**:地图上只连"起点 → 主要拐点 → 目的地",不描真实道路。
### hike_1 / hike_2(两次徒步)
- 时长口径:1.5 小时 = 按 4-5 km/h 常规步速折算,**必须写出折算里程**(约 6-7km 平路,山地约 4-5km)。
- 必须写:起点、终点、环线或折返、累计爬升、路面类型、难度、补水点、卫生间。
- 停车:免费优先;无免费则按 `degradation_rules.no_free_parking` 处理。
- 两段徒步**必须在地形/景观上有区别**(一段城郊水岸 + 一段林地或山海)。
- 无山的目的地按 `terrain_flat` 降级,**必须写"平路徒步、爬升≈0"**,并补一条"想出汗"的做法。
### district_arrive / district_parking / district_walk(街区)
- 街区必须先定性:历史街区 / 文化街区 / 闹市街区,三选一,给理由。
- 停车必须先查免费,给出**免费判定依据**(景区停车场免费 / 路边限时泊位 / 单位对外开放);不得默认写成免费。
- 动线必须给**逐段时刻表 + ≥4 个具体打卡点 + 点线顺序 + 全程步行距离 + 休憩点**。
- 动线书写顺序即为地图连线顺序。
### lunch_d1 / lunch_d2(两顿午饭)
- 各给 3 家(第二天可给 2-3 家),并给"本地人常去"的**判断依据**:
- 加分:不在游客主街、有本地招牌菜、评价里本地口音/本地标签占比高、老店开得久
- 减分:门口拉客、菜单中英双显、团餐大巴停靠、纯网红装修
- 人均目标 100 元左右;超出要解释。
- 必须给推荐菜、能否停车、单人是否方便。
- 两家店(D1/D2)菜系或店型要有区分,不能都是同类海鲜馆。
### bathhouse(浴场)
- 硬门槛:**规模较大 + 档次中上**,**排除社区小浴室**。
- 必须给可核实依据:营业面积区间、开业年限、本地评价量级、是否有泡池/桑拿/休息区。
- 卫生不能空口说"干净",要给依据(本地评价中的卫生关键词、翻新时间),并**必须标注置信度 + 建议出发前电话确认**。
- 无大型浴场的按 `no_large_bathhouse` 降级为温泉/大型洗浴中心,**显式标注降级**。
- 必须给:免费停车可行性、人均、建议时长、自带物品清单。
### grocery(浴场旁超市 · 与浴场一起完成)
- **不跑大卖场**:补给必须在**浴场同一商圈**内完成(步行 5 分钟或车程 3 分钟内),**禁止为了买东西额外跑一趟县城或卖场**。
- 必须核对"泡完汤约 20 点"时超市是否还在营业;同商圈确实没有超市时,才允许就近找并标注"未能在浴场商圈解决"。
- 与浴场**共用同一个停车点**,不挪车。
- 购物清单:常温奶、面包 / 包子、水果、瓶装水。
- 车内过夜无冷藏,牛奶优先**常温奶或小包装**,并给保质期提示。
> 本条是用户口径对第 8 条原意的覆盖(原为「找大卖场,大卖场最好」),已写入 `skill.json` 的 `override_note`。
### car_sleep(车宿)
- 优先**市中心公园 / 空气好 / 有卫生间**。
- 必须给:卫生间夜间是否可用、照明情况、是否有早锻炼人群、监控情况。
- 必须给**禁停风险等级(低/中/高)+ 判断依据 + 1-2 个备选点**。
- 必须给车宿安全提示:靠路灯停、避开主通道、留通风缝、不长时间怠速开空调、贵重物品不外露、垃圾带走。
### morning_walk / breakfast_wash(晨间)
- 晨走路线从车宿点出发,**环线优先、距离控制在 2-4km**。
- 必须给**当日日出时间**(按出行日期),说明 5 点是否天已亮。
- 早饭二选一或都给:店内吃(必须给开门时间,5-6 点是否营业)/ 车内吃(昨晚补给)。
- 洗漱必须**可落地**:公厕 / 加油站卫生间 / 健身房单次卡 / 公园水房,四选一并给位置与开放时间,不许含糊带过。
### photo_spots(打卡点)
- 1-2 个,必须写清典故或走红原因(故居类给生平要点,网红类说明为什么火、最佳机位与时段)。
- 必须给开放时间、门票、停车。
- 必须与前一天街区打卡点去重。
---
## 五、置信度使用规则
`skill.json` 的 `unverified_fields`(免费停车、本地人常去、浴场档次、卫生、车宿合法性)**默认不许标 high**,且必须带 `verify` 字段(怎么写核实方式)。
- `high`:官方公告 / 连锁品牌 / 明确门票营业信息
- `medium`:常见认知或口碑,大体可靠但需现场确认
- `low`:推测或信息不足,仅方向性参考
标注方式:在字段位置写成 `{"text": "...", "confidence": "low", "verify": "出发前打 XX 电话确认"}`。
**不确定不是问题,假装确定才是问题。**
---
## 六、输出纪律
1. **只输出 JSON**,结构严格符合 `output_schema.json`,不夹带解释性散文。
2. **一次成卷**,不拆成多次子调用拼接。
3. 每个槽位都要落进 `blocks`,不得省略;不可得的写降级说明,不许留空。
4. 禁止行为:
- 编造具体店名与价格(不确定就给业态+位置描述,并标 low)
- 把收费停车写成免费
- 把社区浴室写成大型浴场
- 省略 `must_declare` 要求的标注
- 硬点(17:00 浴场 / ≤22:00 车宿 / 05:00 晨起)漂移而不声明
- 为了买补给单独安排大卖场行程(与第 8 条的用户口径冲突)
5. 金额统一写人民币,口径写清(人均 / 单次 / 总价)。
6. 报告末尾必须自动带出"**待核实清单**":**凡是含非 high 字段的槽位,都必须至少有一条清单项**;同一槽位内的多个低置信项可以归并成一条,但**不许整槽位漏收**。
@@ -0,0 +1,328 @@
{
"_comment": "技能包:上海周边2日车宿徒步游(skill #1)。一个 skill = 一整套行程打法,骨架固定、目的地可变;同一 skill 可套多个目的地(如东/四明山…),同一目的地也可被别的 skill 覆盖。本文件是本 skill 的机读总纲:骨架槽位(15 条要求的机读版)+ 地形降级规则 + 置信度定义。本 skill 与其他 skill 完全独立,不引用任何外部模块。",
"id_comment": "skill 唯一标识(snake_case),与文件夹名一致",
"id": "sh_2d_car_hike",
"name_comment": "技能显示名(《游山玩水》卡片内的 skill 选择项)",
"name": "上海周边2日车宿徒步游",
"version_comment": "技能版本号;rules/骨架/输出契约任一改动都必须升版本,库里的定稿快照会记录生成时的版本",
"version": "0.3.0",
"schema_version_comment": "输出契约版本,与 output_schema.json 的 version 保持一致",
"schema_version": "0.2.0",
"status_comment": "技能状态:draft=骨架打磨中;ready=可对外生成",
"status": "draft",
"days_comment": "行程天数:周六出发、周日返程,两天一夜",
"days": 2,
"nights": 1,
"base_city_comment": "固定出发城市(去程与返程均以此为起终点)",
"base_city": "上海",
"base_origin_comment": "默认出发点(具体到点位);由问答项 departure_origin 输入,用户可改成任意出发点,改点后去返程里程与时长需重算",
"base_origin": "上海宝山富锦路地铁站",
"travel_mode_comment": "出行方式:自驾 + 车内过夜(不订酒店)",
"travel_mode": "self_drive_car_sleep",
"region_comment": "适用区域:上海周边 300km 内、可当日自驾直达的目的地;超出范围的应提示用户换 skill 或换目的地",
"region": {
"label": "上海周边",
"radius_km": 300,
"terrain_preference_comment": "偏好地形(如东无山,由 degradation_rules.terrain_flat 处理)",
"terrain_preference": ["山地", "丘陵", "水系", "海岸"]
},
"refs_comment": "本技能包的其他文件(相对本文件夹路径),运行时按引用读取",
"refs": {
"rules": "rules.md",
"qa": "qa.json",
"output_schema": "output_schema.json",
"report_spec": "report_spec.json",
"destinations": "destinations.json"
},
"anchors_comment": "骨架槽位 = 用户 15 条要求的机读版。每个槽位含:id / 天 / 顺序 / 对应原始要求编号 / 时间锚点 / 硬约束 / 软偏好 / 地图角色。换目的地时槽位不变,只换值",
"anchors": [
{
"id": "drive_out",
"day": 1,
"order": 10,
"title": "去程·双版本自驾",
"from_requirements": [2],
"time_anchor": { "type": "soft", "label": "周六早上", "suggest": "06:30-07:30 出发" },
"hard_constraints": [
"必须同时给出高速版与非高速版两套方案",
"每套方案必须含:总里程(km)、预计时长、过路费、沿途服务区/加油站数量、拥堵风险提示",
"两套方案必须给出取舍建议(时间优先 or 省钱优先)"
],
"soft_preferences": ["非高速版优先沿江/沿海风景路线"],
"map_role": "路线起点段(只标起点与拐点,不描真实道路)"
},
{
"id": "hike_1",
"day": 1,
"order": 20,
"title": "第一段徒步(1.5h)",
"from_requirements": [1, 2],
"time_anchor": { "type": "soft", "label": "到达目的地后立即进行", "suggest": "09:30-11:00" },
"hard_constraints": [
"时长必须为 1.5 小时(按常规步速折算里程,并写出折算口径)",
"必须给出起点/终点/环线或折返方式",
"必须给出停车方案,免费停车优先",
"必须给出爬升、路面类型、难度、补水点"
],
"soft_preferences": ["有山有水优先;平原地区走海堤/河道/林道"],
"map_role": "徒步轨迹(示意图,非真实轨迹)"
},
{
"id": "district_arrive",
"day": 1,
"order": 30,
"title": "抵达历史/文化/闹市街区",
"from_requirements": [3],
"time_anchor": { "type": "soft", "suggest": "11:15 前后" },
"hard_constraints": [
"必须明确是历史街区 / 文化街区 / 闹市街区中的哪一类",
"必须给出街区名称、范围、可逛时长评估",
"若目的地同时存在多类街区,必须给出选择理由并列出备选"
],
"soft_preferences": ["历史街区优先于商业街区"],
"map_role": "街区中心点"
},
{
"id": "district_parking",
"day": 1,
"order": 40,
"title": "街区停车(免费优先)",
"from_requirements": [4],
"time_anchor": { "type": "none" },
"hard_constraints": [
"必须优先查免费停车场,并给出免费判定依据(路边泊位/景区停车场/单位开放)",
"免费不可得时必须给出收费方案与预估费用,不得留空",
"必须给出停车点步行到街区的距离",
"必须标注置信度与‘建议出发前电话确认’"
],
"soft_preferences": ["离街区入口步行 5 分钟内"],
"map_role": "停车点(与街区中心点区分显示)"
},
{
"id": "lunch_d1",
"day": 1,
"order": 50,
"title": "第一天午饭(本地人常去·人均≈100)",
"from_requirements": [5],
"time_anchor": { "type": "soft", "suggest": "11:30-12:40" },
"hard_constraints": [
"必须推荐 3 家,且按‘走路可达/需挪车’标注",
"人均目标 100 元左右,超出必须说明理由",
"必须给出判断‘本地人常去’的依据(非游客街主街、本地招牌菜、大众点评本地评价比例等)",
"必须给出推荐菜与停车可行性",
"按单人出行设计(不按多人桌餐推荐)"
],
"soft_preferences": ["本地时令菜优先(如东:海鲜/文蛤/河鲜)"],
"map_role": "3 个候选餐馆点"
},
{
"id": "district_walk",
"day": 1,
"order": 60,
"title": "街区打卡动线(约 3 小时)",
"from_requirements": [6],
"time_anchor": { "type": "soft", "suggest": "12:40-15:40" },
"hard_constraints": [
"总时长约 3 小时,必须给出逐段时刻表",
"必须给出具体打卡点序列(≥4 个),每个点含看点与停留时长",
"必须给出点线顺序(用于地图连线)与全程步行距离",
"必须给出中途休憩点(可坐可喝水)"
],
"soft_preferences": ["具有历史/人文叙事线索的动线优先于纯拍照点堆砌"],
"map_role": "街区动线(按 order 连线)"
},
{
"id": "bathhouse",
"day": 1,
"order": 70,
"title": "下午浴场(档次·规模·免费停车)",
"from_requirements": [7],
"time_anchor": { "type": "hard", "label": "下午 5 点左右抵达", "suggest": "16:45-17:15 到达" },
"hard_constraints": [
"必须排除社区小浴室,要求规模较大、档次中上",
"必须有免费停车(不可得时给出收费方案与费用)",
"必须给出卫生与规模的可核实依据(营业面积区间、开业年限、本地评价量级)",
"必须给出泡汤/淋浴时长建议与自带物品清单",
"在无合规大型浴场的目的地,按 degradation_rules.no_bathhouse 降级为温泉/大型洗浴中心,并显式标注降级"
],
"soft_preferences": ["本地人复购率高的优先"],
"map_role": "浴场点"
},
{
"id": "grocery",
"day": 1,
"order": 80,
"title": "浴场旁超市补给(与浴场一起完成)",
"from_requirements": [8],
"override_note": "用户口径覆盖第 8 条原意(原为『找大卖场』):不单独跑大卖场,改在浴场旁的超市顺手买完",
"time_anchor": { "type": "soft", "label": "泡汤前后顺路完成", "suggest": "19:40-20:15" },
"hard_constraints": [
"不单独安排大卖场行程:必须在浴场同一商圈内(步行 5 分钟或车程 3 分钟内)的超市完成",
"禁止为了买补给额外跑一趟县城或大卖场",
"必须给出营业时间,并核对泡完汤后的时间点是否还在营业",
"购物清单必须含次日早餐所需(牛奶、面包/包子、水果、水)",
"牛奶类必须给出保质期提示(车内过夜无冷藏,优先常温奶/小包装)"
],
"soft_preferences": ["与浴场共用同一个停车点,不挪车"],
"map_role": "浴场旁超市点(与浴场点位相邻)"
},
{
"id": "car_sleep",
"day": 1,
"order": 90,
"title": "车宿过夜停车点",
"from_requirements": [9],
"time_anchor": { "type": "hard", "label": "不晚于 22:00 抵达(提前到不算漂移)", "suggest": "21:00-22:00 到达" },
"hard_constraints": [
"必须优先市中心公园、空气好、有卫生间",
"必须给出卫生间开放时间(夜间是否可用)",
"必须标注‘禁停/劝离风险’等级,并给出备选点(高速服务区/加油站/营地)",
"必须给出车宿安全建议(靠路灯、远离通道、留缝通风、不怠速开空调)",
"必须给出次日 5 点起床的现场条件说明(照明、是否有早锻炼人群)"
],
"soft_preferences": ["有早锻炼人群、有监控的市政公园优先"],
"map_role": "车宿点(夜间终点)"
},
{
"id": "morning_walk",
"day": 2,
"order": 100,
"title": "晨间散步(05:00)",
"from_requirements": [10],
"time_anchor": { "type": "hard", "label": "05:00 起床散步", "suggest": "05:00-06:15" },
"hard_constraints": [
"必须给出从车宿点出发的散步路线(环线优先,不走远)",
"必须给出现场条件(路灯、公厕位置)",
"日出时间必须按出行日期给出(季节差异必须体现)"
],
"soft_preferences": ["公园内环湖/环河步道优先"],
"map_role": "晨走路线"
},
{
"id": "breakfast_wash",
"day": 2,
"order": 110,
"title": "早饭 + 洗漱",
"from_requirements": [11],
"time_anchor": { "type": "soft", "suggest": "06:15-07:15" },
"hard_constraints": [
"必须给出早饭方案:店内吃(本地早点铺)或车内吃(昨晚补给),二选一或都给",
"店内吃必须给出开门时间(5-6 点是否已营业)",
"洗漱必须给出可落地方式(公厕/加油站卫生间/健身房单次卡/公园水房),不得含糊"
],
"soft_preferences": ["本地早点(如东:面条/汤包/烧饼)优先"],
"map_role": "早点铺/洗漱点"
},
{
"id": "hike_2",
"day": 2,
"order": 120,
"title": "第二段徒步(1.5h·山水优先)",
"from_requirements": [1, 12],
"time_anchor": { "type": "soft", "suggest": "08:15-09:45" },
"hard_constraints": [
"时长必须为 1.5 小时,且有实际运动量(写清爬升与折算口径)",
"必须有山有水(不可得时按 degradation_rules.terrain_flat 降级并明确标注)",
"必须给出免费停车优先的停车方案",
"必须与第一段徒步在地形/景观上有所区别,避免重复"
],
"soft_preferences": ["山水结合、有制高点视野"],
"map_role": "徒步轨迹(示意图)"
},
{
"id": "photo_spots",
"day": 2,
"order": 130,
"title": "打卡点 1-2 个",
"from_requirements": [13],
"time_anchor": { "type": "soft", "suggest": "10:00-11:20" },
"hard_constraints": [
"数量 1-2 个,典故/成因必须写清(故居类要有生平要点,网红类要说明为什么火)",
"必须给出开放时间、门票、停车",
"必须与前一天街区打卡点去重,不得重复"
],
"soft_preferences": ["故居/人文类优先于纯拍照点"],
"map_role": "打卡点"
},
{
"id": "lunch_d2",
"day": 2,
"order": 140,
"title": "第二天午饭(本地人常去·最好能停车)",
"from_requirements": [14],
"time_anchor": { "type": "soft", "suggest": "11:30-12:40" },
"hard_constraints": [
"必须是本地人常去的店,并给出判断依据",
"必须给出停车方案(能停优先,不能停要给就近停车场)",
"必须与第一天午饭的菜系/店型有所区别",
"按单人出行设计"
],
"soft_preferences": ["本地家常菜/老字号优先"],
"map_role": "午餐点"
},
{
"id": "drive_back",
"day": 2,
"order": 150,
"title": "返程·双版本自驾",
"from_requirements": [15],
"time_anchor": { "type": "soft", "suggest": "13:00 后出发" },
"hard_constraints": [
"必须同时给出高速版与非高速版两套方案",
"字段与去程一致:里程、时长、过路费、服务区、拥堵风险",
"必须提示返程高峰时段(周日下午进城方向)"
],
"soft_preferences": [],
"map_role": "路线终点段"
}
],
"degradation_rules_comment": "地形/业态不支持时的降级规则:降级不是省略,而是显式替换 + 明确标注,绝不允许静默降低要求",
"degradation_rules": [
{
"id": "terrain_flat",
"trigger": "目的地无山(如如东、启东等平原沿海地区)",
"action": "徒步改走海堤 / 河道绿廊 / 林场林道,保留 1.5 小时时长",
"must_declare": "必须写明‘该段为平路徒步,累计爬升约 0,运动量按时长与步数计’,不得冒充山地徒步效果",
"extra": "必须补一条‘想出汗’的建议(配速、折返爬坡、快走替代)"
},
{
"id": "no_large_bathhouse",
"trigger": "目的地无合规大型浴场",
"action": "降级为温泉度假区 / 大型洗浴中心(营业面积大、本地评价量级高)",
"must_declare": "必须标注‘本地无传统大浴场,本条为温泉/洗浴中心替代’",
"extra": "必须给出单次人均与时长建议"
},
{
"id": "no_free_parking",
"trigger": "目标节点确实无免费停车",
"action": "给出最近的收费停车场 + 预估费用 + 步行距离",
"must_declare": "标注‘本节点无免费停车’,不得把收费点写成免费点",
"extra": "同时给出‘若愿意多走 5-10 分钟’的免费替代点(如有)"
},
{
"id": "car_sleep_risk",
"trigger": "车宿点存在禁停/劝离风险",
"action": "保留该点但标注风险等级,并给出 1-2 个备选(服务区/加油站/营地)",
"must_declare": "风险等级必须写明(低/中/高),并说明判断依据",
"extra": "必须提示‘以现场标识与执法人员口径为准’"
}
],
"confidence_levels_comment": "置信度三档定义。主观项(免费停车、本地人常去、浴场档次、卫生)必须给置信度,不得默认 high",
"confidence_levels": {
"high": "有明确可核实来源(官方公告、连锁品牌、明确门票/营业信息)",
"medium": "常见认知或口碑信息,大体可靠但需现场确认",
"low": "推测或信息不足,仅作方向性参考,必须附核实方式"
},
"unverified_fields_comment": "这些字段默认不允许标 high,必须附 verify(核实方式)",
"unverified_fields": ["free_parking", "locals_favorite", "bathhouse_grade", "hygiene", "car_sleep_legality"],
"output_discipline_comment": "生成纪律:只输出结构化 JSON(符合 output_schema.json),禁止夹带散文;不确定就降置信度并写进 verify,不许编造",
"output_discipline": {
"format": "json_only",
"one_call": "一次成卷,不拆成多次子调用拼接",
"forbid": ["编造店名与价格", "把收费停车写成免费", "把社区浴室写成大型浴场", "省略 must_declare 标注"]
}
}
@@ -0,0 +1,154 @@
// ============================================================
// travel_citytour/validate.js —— 生成结果的结构校验(游山玩水 lab 本地)
//
// 为什么要服务端校验:AI 一定会变形。与其相信它,不如生成后按「skill 骨架 + 契约」
// 硬校验一遍,把不合格的地方作为 warnings 回传,前端在交互弹窗里显示出来。
//
// 校验口径与 output_schema.json 保持一致;不写死任何业务字段名,
// 全部从 skill bundle 里推导(换 skill 不用改本文件)。
// ============================================================
const ROOT_KEYS = ['meta', 'summary', 'timeline', 'cards', 'map', 'tables', 'cost', 'checklist', 'notes']
const hasNonHighFact = (node) => {
if (Array.isArray(node)) return node.some(hasNonHighFact)
if (node && typeof node === 'object') {
if ('confidence' in node) return node.confidence !== 'high'
return Object.keys(node).some((k) => hasNonHighFact(node[k]))
}
return false
}
const validatePayload = (payload, bundle) => {
const errors = []
const warnings = []
const p = payload || {}
const skill = bundle.skill || {}
const anchors = Array.isArray(skill.anchors) ? skill.anchors : []
const anchorIds = anchors.map((a) => a.id)
// 1) 根字段
ROOT_KEYS.forEach((k) => {
if (!(k in p)) errors.push('缺少根字段:' + k)
})
// 2) 卡片必须覆盖全部骨架槽位
const cards = Array.isArray(p.cards) ? p.cards : []
const cardAnchors = cards.map((c) => c && c.anchor_id)
anchorIds.forEach((a) => {
if (cardAnchors.indexOf(a) < 0) errors.push('卡片缺槽位:' + a)
})
cardAnchors.forEach((a) => {
if (anchorIds.indexOf(a) < 0) warnings.push('卡片引用了未定义槽位:' + a)
})
// 3) 时序只能引用合法槽位,硬时间点必须以 kind=hard 出现
const days = (p.timeline && Array.isArray(p.timeline.days)) ? p.timeline.days : []
if (!days.length) errors.push('timeline.days 为空')
days.forEach((d) => {
;((d && d.rows) || []).forEach((r) => {
if (anchorIds.indexOf(r.anchor_id) < 0) warnings.push('时序引用未知槽位:' + r.anchor_id)
})
})
const ACTIVITIES = ['move', 'eat', 'hike', 'shop', 'sleep', 'walk']
anchors.filter((a) => a.time_anchor && a.time_anchor.type === 'hard').forEach((a) => {
const rows = []
days.forEach((d) => ((d && d.rows) || []).forEach((r) => { if (r && r.anchor_id === a.id) rows.push(r) }))
const label = String((a.time_anchor && a.time_anchor.label) || '')
if (!rows.length) {
warnings.push('硬时间点槽位未出现在时序:' + a.id + '(期望:' + label + ')')
return
}
if (!rows.some((r) => r.kind === 'hard')) {
const kinds = rows.map((r) => String(r.kind || '未填')).join(' / ')
const misled = rows.some((r) => ACTIVITIES.indexOf(r.kind) >= 0)
warnings.push('硬时间点槽位在时序中 kind 非 hard(实际:' + kinds + '):' + a.id
+ (misled ? ' —— 疑似把活动类型填进了 kind(kind 只能是 hard/soft,活动类型应填 activity)' : ''))
}
})
// 3b) 时序行的 kind / activity 取值合法性(kind 与活动类型是正交字段,不允许混用)
days.forEach((d) => {
;((d && d.rows) || []).forEach((r) => {
if (!r) return
if (['hard', 'soft'].indexOf(r.kind) < 0) {
warnings.push('时序行 kind 取值非法(只能 hard/soft):' + String(r.kind) + ' @ ' + String(r.anchor_id))
}
if (r.activity && ACTIVITIES.indexOf(r.activity) < 0) {
warnings.push('时序行 activity 取值非法:' + String(r.activity) + ' @ ' + String(r.anchor_id))
}
})
})
// 4) 契约要求的表格
const tables = Array.isArray(p.tables) ? p.tables : []
const requiredTableIds = (bundle.schema && bundle.schema.required_table_ids) || []
requiredTableIds.forEach((id) => {
if (!tables.some((t) => t && t.id === id)) errors.push('缺表格:' + id)
})
// 表格列定义与行数据要对得上
tables.forEach((t) => {
if (!t || !Array.isArray(t.columns) || !t.columns.length) { warnings.push('表格缺 columns:' + (t && t.id)); return }
const keys = t.columns.map((c) => c.key)
;((t.rows) || []).forEach((r, i) => {
keys.forEach((k) => {
if (r && !(k in r)) warnings.push(`表格 ${t.id} 第 ${i + 1} 行缺列 ${k}`)
})
})
})
// 5) 地图:路线引用的点位必须存在
const map = p.map || {}
const markers = Array.isArray(map.markers) ? map.markers : []
if (!markers.length) errors.push('map.markers 为空')
const markerIds = markers.map((m) => m && m.id)
;(Array.isArray(map.routes) ? map.routes : []).forEach((r) => {
const ids = (r && r.marker_ids) || []
ids.forEach((id) => {
if (markerIds.indexOf(id) < 0) warnings.push('路线 ' + (r && r.id) + ' 引用未知点位:' + id)
})
if (ids.length < 2) warnings.push('路线点位少于 2 个:' + (r && r.id))
if (!r || !r.note) warnings.push('路线缺示意图免责说明:' + (r && r.id))
})
// 6) 置信度:非 high 必须给核实方式
let confTotal = 0
let confNonHigh = 0
;(function walk(node, trail) {
if (Array.isArray(node)) return node.forEach((v, i) => walk(v, trail + '[' + i + ']'))
if (node && typeof node === 'object') {
if ('confidence' in node) {
confTotal++
const c = node.confidence
if (['high', 'medium', 'low'].indexOf(c) < 0) errors.push('置信度取值非法:' + c + ' @ ' + trail)
if (c !== 'high') {
confNonHigh++
if (!node.verify) warnings.push('非 high 字段缺 verify:' + String(node.text).slice(0, 24))
}
return
}
Object.keys(node).forEach((k) => walk(node[k], trail + '.' + k))
}
})(p, '')
// 7) 待核实清单必须覆盖每个含非 high 字段的槽位
const checklist = Array.isArray(p.checklist) ? p.checklist : []
const checklistAnchors = checklist.map((c) => c && c.anchor_id).filter(Boolean)
cards.forEach((c) => {
if (c && hasNonHighFact(c) && checklistAnchors.indexOf(c.anchor_id) < 0) {
warnings.push('槽位含非 high 字段但清单未收录:' + c.anchor_id)
}
})
// 8) AI 配图必须带免责说明
;(function walkImg(node) {
if (Array.isArray(node)) return node.forEach(walkImg)
if (node && typeof node === 'object') {
if (node.kind === 'ai_illustration' && !node.disclaimer) warnings.push('AI 示意图缺免责说明')
Object.keys(node).forEach((k) => walkImg(node[k]))
}
})(p, '')
return { errors, warnings, stats: { cards: cards.length, anchors: anchorIds.length, markers: markers.length, tables: tables.length, confTotal, confNonHigh, checklist: checklist.length } }
}
module.exports = { validatePayload, ROOT_KEYS }
@@ -0,0 +1,186 @@
// ============================================================
// sh_2d_rail_hike/ai.js —— 本技能的 AI 生成链路(技能私有)
//
// 职责:把「本技能包 + 问答答案 + 目的地覆盖」组装成一次成卷的 prompt,
// 调 lab 层 ctx.callLLM 拿回结构化 JSON,做健壮解析,失败重试一次。
//
// 边界(红线):
// - 不接触密钥、不发 HTTP、不管超时:全部由 lab 层 ctx.callLLM 负责。
// - prompt 内容 100% 归本技能所有。
//
// 【隔离用意 · 请后续开发者先读这段】
// 本文件是【本技能私有的 prompt 组装器】,不是全 lab 的公共 ai 模块。
// 它与 sh_2d_car_hike/ai.js 长得很像(都是 slimSkill + buildMessages + generate),
// 但**刻意不共用**,因为两者的硬性要求完全不同:
// · 自驾 skill 要求「双版本路线对比、停车方案、车内过夜安全」
// · 本 skill 要求「禁止给车次号、21:00 前抵沪、市内单车/网约车、每处可导航」
// 如果抽成一个「通用 travel ai 模块」,就必须在里面塞满 if (skill === 'xxx') 分支,
// 那正是本 lab 明确禁止的方向。要新增 skill,请复制本文件后在副本里改。
// ============================================================
// 只把「会进 prompt」的骨架字段投出去,避免把一堆 _comment 也塞进模型
const slimSkill = (skill) => ({
id: skill.id,
name: skill.name,
version: skill.version,
days: skill.days,
nights: skill.nights,
base_city: skill.base_city,
base_origin: skill.base_origin,
travel_mode: skill.travel_mode,
region: skill.region,
anchors: skill.anchors,
degradation_rules: skill.degradation_rules,
confidence_levels: skill.confidence_levels,
unverified_fields: skill.unverified_fields,
output_discipline: skill.output_discipline
})
// 按出发时段给出「行程结构」提示,避免模型把两种模式混着排
const windowHint = (answers) => {
const w = String((answers && answers.departure_window) || 'sat_morning')
if (w === 'fri_night') {
return [
'【本次为模式 B:周五晚出发】',
'- 周五晚乘车出发,**车上过一夜**,周六早上抵达目的地。',
'- 因此目的地只需住 1 晚,但总晚数为 2(一在车上、一在目的地)。报告里必须写清楚,避免用户误订两晚酒店。',
'- 必须说明车上的过夜方式(卧铺/硬座、是否需自带颈枕眼罩),并提示「周五晚班次需提前订票,热门线路卧铺紧张」。',
'- 第一天从「周六早上抵达」开始排,不要出现上午还在上海的内容。'
].join('\n')
}
return [
'【本次为模式 A:周六出发(' + w + ')】',
'- 周六乘车出发 → 目的地住 1 晚 → 周日返程。**目的地住宿 1 晚**。',
'- 若目的地单程车程超过 3 小时,必须显式提示「当日可用时间仅约 X 小时,行程已按此压缩」,',
' 并建议改选更早班次或周五晚出发,说明两种模式的差别。'
].join('\n')
}
const buildMessages = (bundle, answers, dest) => {
const system = [
'你是一个严格按「技能包」执行行程生成的引擎。下面给你一个技能包(骨架、规则、输出契约)与本次问答答案,请产出唯一一份行程 JSON。',
'本技能的场景是:上海出发、高铁往返、周末两天一晚、市内靠共享单车与网约车。用户最看重「每个地点都能直接导航」。',
'',
'【硬性要求】',
'1. 只输出 JSON 本身,不要任何解释、前后缀、Markdown 代码块标记。',
'2. 结构必须严格符合下方「输出契约」,根字段一个都不能少。',
'3. skill.json 的 anchors 是骨架:每一张 card 必须对应一个 anchor_id,一个都不能少、不能多。',
'4. 契约里的 required_table_ids 是必须存在的表格 id。',
'5. **绝对禁止编造高铁车次号**(如 G1234):你无法查询 12306,写出的车次号一定是错的,会直接误导用户赶错车。只允许给「出发站 / 到达站 / 建议时段 / 历时区间 / 二等座参考票价区间」,并写明「车次与时刻以 12306 实时查询为准」。',
'6. **返程是最高优先级硬约束**:周日必须下午出发,且最晚 21:00 抵达上海(用户周一要上班,晚于地铁末班无法回家)。必须给出「最后一个景点→车站」的交通方式与耗时,并倒推留出至少 40 分钟安检缓冲;必须提示抵沪后回家的方式与末班地铁。',
'7. **每个地点都必须可导航**:map.markers[] 每条必须有 address 字段,值必须是「城市 + 区 + 具体地点」形式(如「苏州市姑苏区平江路历史街区」),禁止只写泛指名称。卡片建议带一条 label 为「导航」的字段。',
'8. 主观/易变信息(车次时段、票价、共享单车禁停区、网约车上车点、营业时间、洗浴档次与能否过夜、是否需预约、本地人常去、卫生)一律不允许标 high,且必须带 verify 说明核实方式。',
'9. 不确定就降置信度并写进 checklist,绝对不许编造店名、酒店名与价格。票价必须写区间,不得写单一精确数字。',
'10. 触发降级规则时必须显式写出降级说明,不许静默降低要求。',
'11. 市内交通必须落到实处:city_transport 表格要分段给出「步行 / 共享单车 / 网约车」的选择与理由、预估费用,并提示景区禁停区与网约车具体上车点。若用户带拉杆箱(luggage=suitcase),禁止安排骑车,且必须先寄存行李。',
'12. timeline 行的 kind 只表示「时间点性质」,只能取 "hard" 或 "soft":凡骨架里 time_anchor.type 为 "hard" 的槽位(本技能为 lodging_night 与 train_back),其对应时间行必须写 kind="hard";其余行写 "soft"。另外,train_back 的时间行请在标题或备注里写明返程约束(例如「乘高铁返沪,最晚 21:00 抵沪」),不要只写「前往车站」——返程死线是本技能最硬的约束,要在时序里一眼可见。',
'13. timeline 行的 activity 表示「活动类型」,可选,只能取 walk / stroll / spot / bike / taxi / metro / train / eat / hike / shop / checkin / rest 之一。其中 stroll 专指「闲逛 / 夜逛」(与 walk 的点对点步行通勤区分),夜逛行请用 stroll;spot 专指「游览景点 / 展览」(如博物馆、园林、故居),看景点的行用 spot。kind 与 activity 是正交字段:「21:00 入住」应写 kind="hard" 且 activity="checkin",绝不能因为它是入住就把 kind 写成 "checkin"。',
'14. checklist:必须覆盖每一个在卡片里出现过非 high 字段的 anchor_id,一个槽位至少一条;且必须至少包含「往返高铁车次时刻票价(12306 核对)」「住宿能否订/能否过夜/是否需预约」「景区是否需预约」「共享单车禁停区与还车点」四项,kind 填 "must"。',
'15. map.markers 要覆盖当天所有落脚点(出发站、到达站、住宿点、徒步起终点、景点、餐馆、夜逛区域、单车还车点、网约车上车点),点位过少地图会显得很空。',
'16. map.routes[].marker_ids 里的每个 id 必须能在 map.markers 里找到,禁止引用未定义的点位 id。每条 route 的 note 必须说明「点位顺序示意,非真实道路/轨迹」。',
'17. card.day 只能填 1 或 2;第一天所有节点填 1,第二天填 2。',
'18. 两段徒步(hike_1 / hike_2)时长必须落在 1-2 小时,且按问答里的 hike_intensity 取值(easy≈1h / medium≈1.5h / strong≈2h),并且两段在地形或景观上必须有区别,不得雷同。',
'19. 目的地无山时按 terrain_flat 降级为河岸/绿道/环湖步道,并明确写「平路徒步,累计爬升约 0」,不得冒充山地徒步效果。',
'20.【餐馆双筛选,午饭晚饭都要】lunch_d1 与 dinner_d1 必须同时满足两条:① 本地人爱去(给出依据:非游客主街、本地招牌菜、本地评价占比高、本地人复购);② 评分好(给出该店在主流点评平台的评分区间与评价条数量级)。两条缺一不可:评分高但明显是游客店的要排除;本地人常去但评分低于 4.0 的要说明原因并给替代。评分与评价数一律标非 high 并写 verify,绝对不许编造评分数字。',
'21.【周六晚必须安排夜逛,不得留白】用户明确反馈过:原来 18:00 吃完晚饭就直接回住处,19:00-21:00 空了一大段,很难受。所以:晚饭必须安排在 17:30-18:45 之间吃完;晚饭后到入住之间(约 19:00-21:00)必须填一段 night_stroll 夜逛,性质是「自由活动」——给出可逛范围(闹市步行街/历史街区夜景/夜市/江边河畔/灯光带)与 ≥3 个可选去处,每个都给可导航地址,不强制打卡顺序;必须说明夜间是否开放、地铁末班时间、该时段能否叫到网约车、以及返回住处的方式与耗时。若目的地确实无成熟夜市,必须按 no_night_market 降级并显式标注,不得编造不存在的夜市。禁止出现「吃完饭直接回住处」这种留白安排。',
'',
windowHint(answers),
'',
'===== 技能包:规则 rules.md =====',
String(bundle.rules || ''),
'',
'===== 技能包:骨架与降级规则 skill.json =====',
JSON.stringify(slimSkill(bundle.skill), null, 2),
'',
'===== 技能包:输出契约 output_schema.json =====',
JSON.stringify(bundle.schema || {}, null, 2)
].join('\n')
const user = [
'===== 本次输入 =====',
JSON.stringify(answers || {}, null, 2),
'',
dest
? '===== 该目的地在技能包里的已知覆盖(请以此为准,不要另造数据) =====\n' + JSON.stringify(dest, null, 2)
: '===== 该目的地是首次生成,技能包里没有覆盖数据,请按骨架与规则自行规划,并把不确定项如实标注置信度 =====',
'',
'请直接输出这份行程的 JSON。'
].join('\n')
return [
{ role: 'system', content: system },
{ role: 'user', content: user }
]
}
// 服务端权威字段:身份、版本、时间戳一律由服务端写死,避免模型编造
const finalizeMeta = (payload, bundle, answers, model, ctx) => {
const p = payload || {}
p.meta = p.meta || {}
p.meta.skill_id = bundle.skill.id
p.meta.skill_name = bundle.skill.name
p.meta.skill_version = bundle.skill.version
p.meta.schema_version = (bundle.schema && bundle.schema.version) || ''
p.meta.qa = answers || {}
p.meta.generated_at = ctx.nowBeijing()
p.meta.generator = 'ai:' + model
if (!p.meta.destination || typeof p.meta.destination !== 'object') p.meta.destination = {}
if (!p.meta.destination.name) p.meta.destination.name = String((answers && answers.destination) || '')
if (!p.meta.destination.key) {
const d = String(p.meta.destination.name || '').trim()
p.meta.destination.key = d || 'unknown'
}
if (!p.meta.disclaimer) {
p.meta.disclaimer = '本行程由 AI 按技能包规则生成;高铁车次与时刻、住宿可订性、共享单车禁停区等信息均需出发前自行核实,详见「待核实清单」。'
}
return p
}
// 生成一次行程;解析失败自动重试一次(带上纠正提示)
const generate = async ({ bundle, answers, dest, aiConfig, ctx }) => {
const model = String(aiConfig.model || 'qwen-plus-latest')
const temperature = Number.isFinite(Number(aiConfig.temperature)) ? Number(aiConfig.temperature) : 0.25
const maxTokens = Number.isFinite(Number(aiConfig.max_tokens)) ? Number(aiConfig.max_tokens) : 8000
const timeoutMs = Number.isFinite(Number(aiConfig.timeout_ms)) ? Number(aiConfig.timeout_ms) : 240000
const base = buildMessages(bundle, answers, dest)
const started = Date.now()
let attempt = 0
let lastErr = null
let raw = ''
while (attempt < 2) {
attempt++
const messages = attempt === 1
? base
: base.concat([
{ role: 'assistant', content: raw.slice(0, 4000) },
{ role: 'user', content: '上面的输出不是合法 JSON(或已被截断)。请只输出完整合法的 JSON 对象本身,不要任何解释、不要代码块标记。' }
])
const r = await ctx.callLLM({ messages, model, temperature, maxTokens, timeoutMs })
raw = r.content
try {
const payload = ctx.extractJson(raw)
return {
payload: finalizeMeta(payload, bundle, answers, model, ctx),
model,
elapsed_ms: Date.now() - started,
attempts: attempt,
usage: r.usage,
finish_reason: r.finishReason,
raw
}
} catch (e) {
lastErr = e
if (r.finishReason === 'length') {
lastErr = new Error('output_truncated')
break
}
}
}
const err = new Error(lastErr ? String(lastErr.message || lastErr) : 'generate_failed')
err.raw = raw
throw err
}
module.exports = { generate, buildMessages, finalizeMeta }
@@ -0,0 +1,37 @@
{
"_comment": "技能:上海周边高铁游周末两天一晚 —— 本文件是【聚合页与 lab 扫描层唯一读取的文件】,必须保持极简。lab 层只看 id/name/desc/icon/enabled/sort_order,不读任何业务内容;业务参数(ui_mode / ai)只有本 skill 自己的后端会读。",
"_isolation_comment": "【隔离用意 · 请后续开发者先读这段】本 lab 的架构是『skill 全栈隔离』:每个 skill 在 skills/<skill_id>/ 下自带一整套后端(config/index/ai/validate/repair + 技能包)与一整套前端(页面/渲染器/样式/导出器/图标/照片),彼此零耦合。所以本文件是【本 skill 私有】的,不是全 lab 共享配置。若你要改 AI 参数、改展示名、改图标,改这里只影响本 skill;反过来,其他 skill 的同类文件也绝不该被本 skill 引用。lab 层只保留三类不许下沉的东西:鉴权、凭据、北京时间/XSS 转义等正确性实现(通过 ctx 注入)。",
"id_comment": "技能唯一标识,必须与目录名完全一致(不一致时 lab 会标记 broken 并在聚合页置灰)",
"id": "sh_2d_rail_hike",
"name_comment": "聚合页卡片标题",
"name": "上海周边高铁游周末两天一晚",
"desc_comment": "聚合页卡片一句话简介(tooltip)。范围说明:高铁单程 5 小时内(含北京/武汉/青岛等长途,故简介里写明『含长途』,避免与『周边』二字产生误解)",
"desc": "高铁往返 · 两天一晚 · 徒步 1-2h + 历史街区/市中心 · 全程共享单车与网约车 · 每个地点都给可导航地址(高铁单程 5h 内,含长途)",
"icon_comment": "聚合页卡片图标(emoji)。若本目录存在 icon.svg,聚合页优先用 icon.svg",
"icon": "🚄",
"sort_order_comment": "聚合页排序,数值越小越靠前",
"sort_order": 20,
"enabled_comment": "是否启用(false 时 lab 扫描层直接跳过本 skill,聚合页不显示)",
"enabled": true,
"ui_mode_comment": "弹窗交互模式:debug=交互详情弹窗(3s 自动关闭);normal=成功降级 Toast(2s),失败仍弹窗。本项归 skill 私有,各 skill 可不同",
"ui_mode": "debug",
"ai_comment": "本 skill 的生成链路 AI 参数。密钥不在这里,由 lab 层 ctx.callLLM 从环境变量读取(红线:skill 不接触密钥)",
"ai": {
"model_comment": "纯文本生成默认 qwen-plus-latest(项目统一规则:自动跟随官方最新稳定快照)",
"model": "qwen-plus-latest",
"temperature_comment": "采样温度(0-1)。行程生成偏结构化,本 skill 节点更多(含市内交通方案),建议 0.2-0.3;过高更易漏字段",
"temperature": 0.25,
"max_tokens_comment": "单次最大输出 token。本 skill 为 2 天但含 5 张表 + 12 张卡片 + 市内交通方案,实测整份 JSON 需 1.4-1.8 万 token,这里留 22000 余量",
"max_tokens": 22000,
"timeout_ms_comment": "单次生成超时(毫秒)。实测约 33 token/s,1.8 万 token 需 9-10 分钟,故放到 12 分钟",
"timeout_ms": 720000
}
}
@@ -0,0 +1,213 @@
{
"_comment": "技能包:目的地清单与变量覆盖。骨架(skill.json 的 anchors)永远不变,本文件只覆盖『换个目的地要换哪些值』。",
"_isolation_comment": "【隔离用意】本文件是【本 skill 私有的目的地清单】。注意 sample_ref 的解析基准是【本 skill 自己的前端目录】(public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/),不是 lab 根目录 —— 这是隔离后修正过的一处历史坑:原来 sample_ref 指向 lab 级共享目录,会让 A skill 的样例被 B skill 引用。新增 skill 时请沿用『相对本 skill 目录』的口径。",
"version": "1.1.1",
"sample_dir_note": "sample_ref 相对【本 skill 自己的前端目录】解析(public/tools/thought_lab/labs/travel_citytour/skills/sh_2d_rail_hike/),离线样例放在该目录下的 sample/ 里",
"destinations": [
{
"key": "suzhou",
"name": "苏州",
"admin": "江苏省苏州市",
"aliases": ["苏州城区", "姑苏"],
"center": [31.299, 120.585],
"center_coord_precision": "approx",
"center_note": "市中心为观前街/平江路一带,坐标仅用于地图初始视野",
"one_way_rail_hours": 0.5,
"rail_note": "上海虹桥 → 苏州站约 23-30 分钟,班次极密(高峰 10 分钟一班),属本 skill 最轻松的目的地",
"terrain": "平原 · 水乡,无山",
"degradations_applied": ["terrain_flat"],
"anchors_override_comment": "每个槽位在苏州的落点候选。凡 confidence != high 的,生成时必须原样带进报告的『待核实清单』",
"anchors_override": {
"train_out": {
"origin_station": "上海虹桥站",
"arrive_station": "苏州站",
"suggest_window": "周六 08:00-09:30",
"duration_range": "23-35 分钟",
"fare_range_second_class": "¥40-50",
"confidence": "low",
"verify": "车次与时刻以 12306 实时查询为准(本 skill 不给车次号,避免编造)"
},
"lunch_d1": {
"pick": ["平江路北段临河的本地苏帮菜小馆(非游客主街、本地评价占比高)"],
"must_give_rating": true,
"confidence": "low",
"verify": "店名、评分区间(建议 4.3 以上)与评价条数需现场或用大众点评核实;不确定时只给业态+位置描述,不得编造评分"
},
"hike_1": {
"pick": "环古城河健身步道(相门—平门段)",
"terrain_note": "平路水岸徒步,累计爬升约 0",
"confidence": "medium",
"verify": "步道开放情况与出入口位置出发前核实"
},
"spot_d1": {
"pick": "拙政园 或 苏州博物馆(二者同一街区,选其一;均为白天开放)",
"daylight_only": true,
"note": "拙政园旺季约 17:30 闭园、约 16:30 停止入场;苏博约 17:00 闭馆、周一闭馆且需预约。必须在徒步结束后直接前往,确保赶在最后入场前",
"confidence": "medium",
"verify": "开放时间、最后入场时间、预约政策请以官方公众号公布为准"
},
"dinner_d1": {
"pick": ["观前街巷内本地家常菜馆(非主街、本地评价占比高)"],
"must_give_rating": true,
"confidence": "low",
"verify": "店名、评分区间与评价条数需现场或用大众点评核实;不确定时只给业态+位置描述,不得编造评分"
},
"night_stroll": {
"pick": "平江路夜景段 + 观前街夜间商圈(两地步行 15 分钟可达,可自由取舍)",
"free_activity": true,
"options": [
{ "name": "平江路夜间段(临河灯笼夜景)", "address": "江苏省苏州市姑苏区平江路历史街区(南入口:干将东路)" },
{ "name": "观前街夜间商圈(玄妙观前广场)", "address": "江苏省苏州市姑苏区观前街 玄妙观" },
{ "name": "山塘街夜景(七里山塘,需打车前往)", "address": "江苏省苏州市姑苏区山塘街(渡僧桥入口)" }
],
"night_transport_note": "苏州地铁 2/4 号线末班约 22:30 前后;该时段网约车可正常叫到",
"confidence": "medium",
"verify": "夜市与灯光开放时间、地铁末班时刻需出发前核实"
},
"lodging_night": {
"pick": "观前街/平江路一带酒店(或本地大型洗浴留宿)",
"confidence": "medium",
"verify": "房价与是否含早需实时查询;洗浴是否可留宿需电话确认"
},
"hike_2": {
"pick": "金鸡湖环湖步道(现代城市湖景段,与第一天古城水岸形成对比)",
"terrain_note": "平路湖岸徒步,累计爬升约 0",
"confidence": "medium",
"verify": "湖区是否允许步行全环、夜间是否封闭需核实"
},
"spot_d2": {
"pick": "虎丘 或 盘门景区(须与第一天的 spot_d1 去重:spot_d1 已用拙政园/苏博,第二天的不可重复)",
"dedupe_with": "spot_d1",
"confidence": "medium",
"verify": "开放时间、门票与预约政策需出发前在官方渠道确认;虎丘距苏州站约 6km,返程前需留足车程"
},
"train_back": {
"depart_station": "苏州站",
"arrive_station": "上海虹桥站",
"suggest_window": "周日 15:00-17:00",
"duration_range": "23-35 分钟",
"fare_range_second_class": "¥40-50",
"confidence": "low",
"verify": "车次与时刻以 12306 实时查询为准;周日下午返程高峰建议提前购票"
}
},
"notes": [
"苏州为平原水乡,已触发 terrain_flat:两段徒步均为平路,累计爬升约 0,报告必须显式写明『平路徒步』,不得冒充山地徒步效果",
"苏州老城区(平江路、观前街一带)对共享单车有较大禁行/禁停范围,市内交通方案必须写明禁停区,并给出外围合法还车点",
"苏州站到平江路约 3km,共享单车约 15 分钟,网约车约 10 分钟"
],
"runs": [
{
"date": "2026-10-17",
"status": "draft",
"generator": "manual_sample",
"skill_version": "1.1.1",
"sample_ref": "sample/suzhou_2026-10-17.json"
}
]
},
{
"key": "hangzhou",
"name": "杭州",
"admin": "浙江省杭州市",
"aliases": ["杭州城区", "西湖"],
"center": [30.254, 120.155],
"center_coord_precision": "approx",
"center_note": "西湖一带,坐标仅用于地图初始视野",
"one_way_rail_hours": 1,
"rail_note": "上海虹桥 → 杭州东站约 45-60 分钟,班次密集",
"terrain": "丘陵 + 水系(西湖群山),有缓坡",
"degradations_applied": [],
"anchors_override_comment": "每个槽位在杭州的落点候选",
"anchors_override": {
"train_out": {
"origin_station": "上海虹桥站",
"arrive_station": "杭州东站",
"suggest_window": "周六 07:30-09:00",
"duration_range": "45-70 分钟",
"fare_range_second_class": "¥73-92",
"confidence": "low",
"verify": "车次与时刻以 12306 实时查询为准"
},
"hike_1": {
"pick": "西湖群山北线(宝石山—葛岭段,含轻度爬升)",
"terrain_note": "丘陵步道,累计爬升约 100-150m",
"confidence": "medium",
"verify": "山道开放情况与是否施工需出发前核实"
},
"lunch_d1": {
"pick": ["湖滨/龙翔桥一带巷内的本地杭帮菜馆(非西湖边游客主街)"],
"must_give_rating": true,
"confidence": "low",
"verify": "店名、评分区间(建议 4.3 以上)与评价条数需现场或用大众点评核实;不确定时只给业态+位置描述,不得编造评分"
},
"spot_d1": {
"pick": "浙江省博物馆(之江馆/孤山馆)或 中国丝绸博物馆(均为白天开放、免费为主)",
"daylight_only": true,
"note": "多数博物馆 17:00 前后闭馆、16:00-16:30 停止入场;周一闭馆。必须在徒步结束后直接前往,确保赶在最后入场前",
"confidence": "medium",
"verify": "开放时间、最后入场时间与预约政策请以官方公众号公布为准"
},
"dinner_d1": {
"pick": ["湖滨一带巷内本地杭帮菜馆(靠近夜逛区,吃完直接逛)"],
"must_give_rating": true,
"confidence": "low",
"verify": "店名、评分区间与评价条数需核实;须确认 19:00 前能吃完且营业到晚间"
},
"night_stroll": {
"pick": "河坊街夜市 + 湖滨(西湖东岸)夜景观光带(两处相距约 1.5km,可自由取舍)",
"free_activity": true,
"options": [
{ "name": "河坊街夜市(南宋御街一带,小吃与老字号)", "address": "浙江省杭州市上城区河坊街(吴山广场入口)" },
{ "name": "湖滨步行街 + 西湖东岸夜景(音乐喷泉)", "address": "浙江省杭州市上城区湖滨路 湖滨步行街" },
{ "name": "南山路夜景带(沿湖酒吧与灯光)", "address": "浙江省杭州市上城区南山路" }
],
"night_transport_note": "杭州地铁 1 号线(龙翔桥站)末班约 22:30 前后;西湖东岸夜间打车较为拥堵,建议地铁",
"confidence": "medium",
"verify": "夜市营业时间、音乐喷泉开放场次(通常夜间固定时段)与地铁末班时刻需出发前核实"
},
"lodging_night": {
"pick": "湖滨/龙翔桥一带酒店(或本地大型洗浴留宿)",
"confidence": "medium",
"verify": "房价与是否含早需实时查询;洗浴是否可留宿需电话确认"
},
"hike_2": {
"pick": "京杭大运河(拱宸桥段)沿岸步道,与第一天山线形成对比",
"terrain_note": "平路水岸徒步,累计爬升约 0",
"confidence": "medium",
"verify": "沿岸步道连通性需核实"
},
"spot_d2": {
"pick": "拱宸桥/桥西历史街区 或 中国茶叶博物馆(免费)",
"confidence": "medium",
"verify": "开放时间与是否需要预约需核实"
},
"train_back": {
"depart_station": "杭州东站",
"arrive_station": "上海虹桥站",
"suggest_window": "周日 15:00-17:00",
"duration_range": "45-70 分钟",
"fare_range_second_class": "¥73-92",
"confidence": "low",
"verify": "车次与时刻以 12306 实时查询为准;周日下午返程高峰建议提前购票"
}
},
"notes": [
"杭州西湖景区周边(尤其湖滨、南山路)为共享单车禁行/禁停重点区域,市内交通必须写明并给出外围还车点",
"杭州东站到西湖湖滨约 8km,建议地铁 1 号线直达或网约车约 25 分钟"
],
"runs": []
}
]
}
@@ -0,0 +1,241 @@
// ============================================================
// sh_2d_rail_hike/index.js —— 本技能的后端路由(技能私有、自包含)
//
// 契约(lab 扫描层唯一要求):
// module.exports = { bindRoutes(router, ctx) }
// - router:lab 层已经 .use(labAuth) + .use(readLimit) 之后再交给我,
// 所以本文件【不写任何鉴权代码】,也拿不到绕过鉴权的机会。
// - ctx :lab 层提供的红线能力(密钥/时间/转义/日志/数据目录…)。
//
// 【隔离用意 · 请后续开发者先读这段】
// 本文件从 sh_2d_car_hike/index.js 复制而来(用户已确认「复制现有再改」)。
// 复制后它就是【本技能私有】的一份:本 skill 的接口前缀由 lab 层挂到
// /api/thought_lab/travel_citytour/skills/sh_2d_rail_hike/*
// 修改本文件不影响自驾 skill;反之亦然。请勿把它抽成「所有 skill 共用的路由工厂」——
// 本 lab 的原则是每个 skill 各存一份可自由改的代码,宁可重复,也不要一处改动牵连全体。
//
// lab 层(红线,不许下沉到 skill):鉴权、凭据、北京时间、XSS 转义、限流。
// 本层(技能私有,爱怎么改怎么改):技能包、prompt、校验、修复、落盘格式。
//
// 挂载点:/api/thought_lab/travel_citytour/skills/sh_2d_rail_hike/*
// 本技能与同 lab 其他技能零耦合:删除本目录即完全卸载。
// ============================================================
const fs = require('fs')
const path = require('path')
const ai = require('./ai')
const { validatePayload } = require('./validate')
const { repairPayload } = require('./repair')
const SKILL_DIR = __dirname
// 本技能的技能包文件清单。缺哪个都不算错(除 skill.json 外),只是能力变少。
const SKILL_FILES = {
skill: 'skill.json',
qa: 'qa.json',
schema: 'output_schema.json',
report_spec: 'report_spec.json',
destinations: 'destinations.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')
if (key === 'rules') { bundle.rules = txt; return }
try {
bundle[key] = JSON.parse(txt)
} catch (e) {
throw new Error('skill_file_broken:' + SKILL_FILES[key])
}
})
if (!bundle.skill || !bundle.skill.id) throw new Error('skill_meta_missing')
bundle.id = bundle.skill.id
return bundle
}
// 目的地覆盖:用户输入的目的地若在本技能已跑过,取出它的覆盖值
const findDestination = (bundle, destName) => {
const list = (bundle.destinations && bundle.destinations.destinations) || []
const key = String(destName || '').trim()
if (!key) return null
return list.find((d) => d.name === key || d.key === key || (Array.isArray(d.aliases) && d.aliases.indexOf(key) >= 0)) || null
}
// 按本技能 qa.json 的定义做通用必填校验(不写死任何字段名)
const validateAnswers = (bundle, answers) => {
const missing = []
const qa = bundle.qa || {}
const destInput = qa.destination_input || {}
if (destInput.required !== false && !String(answers.destination || '').trim()) {
missing.push(destInput.label || 'destination')
}
;(Array.isArray(qa.questions) ? qa.questions : []).forEach((q) => {
if (!q || q.required === false) return
const v = answers[q.key]
if (v === undefined || v === null || String(v).trim() === '') missing.push(q.label || q.key)
})
return missing
}
// ============================================================
// 最近一次生成结果(本技能私有 data/)
//
// 存在意义:一次生成约 4 分钟,而长请求经 8975 反代存在「服务端已返回、客户端没收到」的情况。
// 有了这份落盘结果,刷新页面后点「载入最近一次生成」即可看结果,不必重新烧 4 分钟。
// 各技能各存一份,互不覆盖(这是目录隔离顺带修掉的老毛病)。
// ============================================================
const LAST_FILE = 'last_result.json'
const LAST_KEEP_MS = 7 * 24 * 3600 * 1000
const writeLastResult = (ctx, envelope) => {
try {
fs.mkdirSync(ctx.dataDir, { recursive: true })
fs.writeFileSync(path.join(ctx.dataDir, LAST_FILE), JSON.stringify(envelope), 'utf-8')
} catch (e) {
ctx.log('last_result 写入失败', { error: String((e && e.message) || e) })
}
}
const readLastResult = (ctx) => {
try {
const file = path.join(ctx.dataDir, LAST_FILE)
if (!fs.existsSync(file)) return null
if (Date.now() - fs.statSync(file).mtimeMs > LAST_KEEP_MS) return null
return JSON.parse(fs.readFileSync(file, 'utf-8'))
} catch (e) {
return null
}
}
// ============================================================
// 路由:全部为相对路径,挂载前缀由 lab 层决定
// ============================================================
const bindRoutes = (router, ctx) => {
// 本技能元信息:页面双模式依据 + 渲染描述 + 已跑目的地 + 问答定义
router.get('/meta', (req, res) => {
try {
const cfg = ctx.readConfig()
const bundle = loadBundle()
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: bundle.id,
skill: {
id: bundle.skill.id,
name: bundle.skill.name,
version: bundle.skill.version,
days_label: bundle.skill.days ? `${bundle.skill.days} 天 ${bundle.skill.nights || bundle.skill.days - 1} 夜` : '',
base_origin: bundle.skill.base_origin || '',
anchors_count: Array.isArray(bundle.skill.anchors) ? bundle.skill.anchors.length : 0,
anchors: bundle.skill.anchors || []
},
qa: bundle.qa || {},
report_spec: bundle.report_spec || {},
destinations: (bundle.destinations && bundle.destinations.destinations) || []
})
} catch (e) {
const msg = String((e && e.message) || e)
return res.status(msg.indexOf('skill_file_broken') === 0 ? 500 : 500).json({ ok: false, error: msg })
}
})
// 最近一次生成结果
router.get('/last', (req, res) => {
const env = readLastResult(ctx)
if (!env) {
return res.status(404).json({ ok: false, error: 'no_last_result', hint: '还没有生成过,或结果已超过 7 天保留期' })
}
return res.json(env)
})
// 生成行程(鉴权已由 lab 层完成;ctx.genGuard 负责按 skill 单飞 + 全局并发上限 + 按 IP 限流)
router.post('/generate', ctx.jsonParser, ctx.genGuard, async (req, res) => {
const body = ctx.sanitizeObject(req.body || {})
let bundle = null
try {
bundle = loadBundle()
} catch (e) {
return res.status(500).json({ ok: false, error: String((e && e.message) || e) })
}
const answers = (body.answers && typeof body.answers === 'object' && !Array.isArray(body.answers)) ? body.answers : {}
const missing = validateAnswers(bundle, answers)
if (missing.length) return res.status(400).json({ ok: false, error: 'missing_answers', missing })
const cfg = ctx.readConfig()
const aiConfig = cfg.ai || {}
const dest = findDestination(bundle, answers.destination)
// 生成本身约 4 分钟,经 IIS 反代(8975)时可能被中途掐断。
// 这行日志用于区分「服务端已返回」与「客户端真的收到」——两者现象一样,但根因完全不同。
res.on('close', () => {
ctx.log('generate resp closed', { delivered: !!res.writableEnded, dest: answers.destination })
})
try {
ctx.log('generate start', { dest: answers.destination, model: aiConfig.model || 'qwen-plus-latest' })
const result = await ai.generate({ bundle, answers, dest, aiConfig, ctx })
// AI 一定会变形:先做无损修复,再校验(修复说明一并在响应里回传,前端交互弹窗可见)
const repaired = repairPayload(result.payload, bundle)
const check = validatePayload(repaired.payload, bundle)
const elapsed = result.elapsed_ms
ctx.log('generate done', {
dest: answers.destination, ms: elapsed, attempts: result.attempts,
repairs: repaired.fixes.length, errors: check.errors.length, warnings: check.warnings.length
})
const envelope = {
ok: check.errors.length === 0,
payload: repaired.payload,
report_spec: bundle.report_spec || {},
destination_override: dest ? { key: dest.key, name: dest.name, degradations_applied: dest.degradations_applied || [] } : null,
check,
meta: {
model: result.model,
elapsed_ms: elapsed,
attempts: result.attempts,
finish_reason: result.finish_reason,
usage: result.usage,
repairs: repaired.fixes,
is_new_destination: !dest
}
}
if (check.errors.length) {
envelope.error = 'payload_invalid'
envelope.raw_excerpt = String(result.raw || '').slice(0, 6000)
}
// 【为什么有 error 也照样落盘 + 照样用 200 回传】
// 一次生成要烧 6-10 分钟与上万 token。过去只要结构校验出现哪怕 1 个 error 就整份丢弃、
// 既不落盘也不回传 payload,用户什么都拿不到 —— 而那个 error 很可能只是校验器的误判。
// 本技能就真实踩过:校验器写死 day.label 含「第一天」,而 AI 标成「周六 / 周日」,
// 结果明明排了 19:00 的夜逛却被报「缺夜逛」,整份婺源行程被白扔(4 分钟 + 2.7 万 token)。
// 现在改为:结果一律先落盘再回传;有结构问题时用 ok:false + error 字段如实告知,
// 前端会显示醒目的「N 处结构问题」标记,用户仍可查看/导出并自行判断是否可用。
// 用 200 而不是 422:422 会让前端走「失败」分支,payload 反而渲染不出来。(本 skill 私有决策)
writeLastResult(ctx, envelope)
return res.json(envelope)
} catch (e) {
const msg = String((e && e.message) || e)
ctx.log('generate failed', { dest: answers.destination, error: msg })
const status = msg === 'dashscope_key_missing' || msg === 'dashscope_host_missing' ? 500 : 502
const hint = msg === 'output_truncated'
? '模型输出被截断(max_tokens 不足),可在本技能的 config.json 调大 ai.max_tokens 后重试'
: (msg === 'no_json_found' ? '模型没有按要求输出 JSON,可降低 ai.temperature 或重试' : '')
return res.status(status).json({
ok: false,
error: msg,
hint,
raw_excerpt: String((e && e.raw) || '').slice(0, 6000)
})
}
})
}
module.exports = { bindRoutes, loadBundle, findDestination, validateAnswers }
@@ -0,0 +1,306 @@
{
"_comment": "技能包:输出契约。生成结果(payload)必须严格符合本结构;后端按本结构做校验与透传。",
"_isolation_comment": "【隔离用意】本文件是【本 skill 私有的输出契约】,刻意与 sh_2d_car_hike/output_schema.json 不同 —— 具体差这几处,都是本 skill 的业务特点决定,不是遗漏:(1) timeline.activity 枚举新增 stroll(逛街/夜逛)/bike/taxi/metro/train/checkin/rest(自驾 skill 只需要 move/eat/hike/shop/sleep/walk);(2) marker.kind 新增 train_station/lodging/bike/taxi_point/night_market(自驾 skill 有 parking,本 skill 不需要);(3) marker 新增必填 address 字段(用户要求『每到一处都有具体地点,方便导航』);(4) v1.1.0 新增 stroll 与 night_market,支撑『周六夜夜逛』槽位。这些就是『隔离』的收益:本 skill 可以自由扩自己的词表,不必迁就别人、也不必让别人迁就自己。请勿把两份 schema 抽成公共模板。",
"version": "1.1.1",
"dialect": "json-schema-lite",
"root_comment": "根对象字段固定为 9 个;业务内容全部装在 cards / tables / timeline 里",
"root": {
"type": "object",
"required": ["meta", "summary", "timeline", "cards", "map", "tables", "cost", "checklist", "notes"],
"properties": {
"meta": { "$ref": "#/definitions/meta" },
"summary": { "$ref": "#/definitions/summary" },
"timeline": { "$ref": "#/definitions/timeline" },
"cards": { "type": "array", "items": { "$ref": "#/definitions/card" }, "minItems": 1 },
"map": { "$ref": "#/definitions/map" },
"tables": { "type": "array", "items": { "$ref": "#/definitions/table" } },
"cost": { "$ref": "#/definitions/cost" },
"checklist": { "type": "array", "items": { "$ref": "#/definitions/checklist_item" } },
"notes": { "type": "array", "items": { "$ref": "#/definitions/note" } }
}
},
"required_table_ids_comment": "这些 table 由本 skill 的 report_spec 按 pick_id 引用,必须存在,否则该区块渲染为空",
"required_table_ids": [
"train_out_options",
"train_back_options",
"city_transport",
"lodging_compare",
"food_summary"
],
"required_card_anchor_ids_comment": "card.anchor_id 必须覆盖 skill.json 的全部 anchors,一个都不能少",
"required_card_anchor_ids_source": "skill.json#/anchors",
"definitions": {
"fact_comment": "可置信度化字段:纯字符串(视为 high,仅限客观信息)或对象(主观项必须用对象并带 verify)",
"fact": {
"oneOf": [
{ "type": "string" },
{
"type": "object",
"required": ["text", "confidence"],
"properties": {
"text": { "type": "string" },
"confidence": { "enum": ["high", "medium", "low"] },
"verify": { "type": "string", "description": "核实方式,confidence != high 时必填" }
}
}
]
},
"meta": {
"type": "object",
"required": ["skill_id", "skill_name", "skill_version", "schema_version", "destination", "qa", "generated_at", "generator", "disclaimer"],
"properties": {
"skill_id": { "type": "string" },
"skill_name": { "type": "string" },
"skill_version": { "type": "string" },
"schema_version": { "type": "string" },
"destination": {
"type": "object",
"required": ["key", "name"],
"properties": {
"key": { "type": "string" },
"name": { "type": "string" },
"admin": { "type": "string" },
"center": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 }
}
},
"qa": { "type": "object", "description": "问答答案原样回填,字段名由 qa.json 决定" },
"generated_at": { "type": "string", "description": "北京时间 YYYY-MM-DD HH:mm:ss" },
"generator": { "type": "string", "description": "manual_sample / ai:<model>" },
"disclaimer": { "type": "string" }
}
},
"summary": {
"type": "object",
"required": ["title", "one_liner", "stats"],
"properties": {
"title": { "type": "string" },
"one_liner": { "type": "string" },
"tags": { "type": "array", "items": { "type": "string" } },
"stats": {
"type": "array",
"items": {
"type": "object",
"required": ["label", "value"],
"properties": {
"label": { "type": "string" },
"value": { "type": "string" },
"hint": { "type": "string" }
}
}
}
}
},
"timeline": {
"type": "object",
"required": ["days"],
"properties": {
"days": {
"type": "array",
"items": {
"type": "object",
"required": ["label", "date", "rows"],
"properties": {
"label": { "type": "string" },
"date": { "type": "string" },
"rows": {
"type": "array",
"items": {
"type": "object",
"required": ["time", "title", "anchor_id", "kind"],
"properties": {
"time": { "type": "string" },
"title": { "type": "string" },
"anchor_id": { "type": "string" },
"kind": { "enum": ["hard", "soft"], "description": "时间点性质,只表性质、不表活动:hard=骨架声明为硬时间点(不可漂移);soft=常规。禁止把活动类型写进本字段。" },
"activity": {
"enum": ["walk", "stroll", "spot", "bike", "taxi", "metro", "train", "eat", "hike", "shop", "checkin", "rest"],
"description": "活动类型,可选,与 kind 正交。本 skill 相对自驾 skill 新增了 stroll(逛街/夜逛)/spot(游览景点)/bike(共享单车)/taxi(网约车)/metro(地铁)/train(高铁)/checkin(入住寄存)/rest(休息) —— 这是本 skill 私有词表。stroll 专指『闲逛/夜逛』,与 walk(点对点步行通勤) 区分;spot 专指『游览景点/展览』。"
},
"note": { "type": "string" }
}
}
}
}
}
}
}
},
"image": {
"type": "object",
"required": ["url", "kind", "caption", "disclaimer"],
"properties": {
"url": { "type": "string", "description": "优先本地相对路径(已落盘);远程 URL 视为未落盘" },
"kind": { "enum": ["ai_illustration", "map_capture", "icon"] },
"caption": { "type": "string" },
"disclaimer": { "type": "string" }
}
},
"card": {
"type": "object",
"required": ["id", "anchor_id", "day", "title", "fields"],
"properties": {
"id": { "type": "string" },
"anchor_id": { "type": "string", "description": "必须对应 skill.json 的一个 anchor.id" },
"day": { "type": "integer", "enum": [1, 2] },
"title": { "type": "string" },
"subtitle": { "type": "string" },
"image": { "$ref": "#/definitions/image" },
"badges": {
"type": "array",
"items": {
"type": "object",
"required": ["text"],
"properties": {
"text": { "type": "string" },
"tone": { "enum": ["info", "good", "warn", "danger", "plain"] }
}
}
},
"fields": {
"type": "array",
"items": {
"type": "object",
"required": ["label", "value"],
"properties": {
"label": { "type": "string" },
"value": { "$ref": "#/definitions/fact" },
"hint": { "type": "string" }
}
},
"description": "建议每张卡片都含一条 label 为『导航』的字段,值为可复制到高德/百度搜索的地址或地标关键词"
},
"bullets": { "type": "array", "items": { "type": "string" } },
"map_marker_ids": { "type": "array", "items": { "type": "string" } }
}
},
"marker": {
"type": "object",
"required": ["id", "name", "kind", "lat", "lng", "day", "seq", "address"],
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"kind": {
"enum": ["start", "end", "waypoint", "train_station", "lodging", "bathhouse", "district", "food", "hike", "bike", "taxi_point", "night_market", "spot", "shop", "walk"],
"description": "本 skill 私有枚举:相对自驾 skill 去掉了 parking(无自驾),新增 train_station/lodging/bike/taxi_point,以及 night_market(夜市/夜逛区域,v1.1.0 新增,用于支撑周六夜夜逛)"
},
"lat": { "type": "number" },
"lng": { "type": "number" },
"coord_precision": { "enum": ["exact", "approx"], "description": "approx 表示仅用于示意图定位。AI 给出的坐标一律按 approx 处理,不得标 exact" },
"address": { "type": "string", "description": "必填。可导航地址或高德/百度能搜到的地标关键词,例如『苏州市姑苏区平江路历史街区』。禁止只写泛指名称(如『平江路』不带城市)" },
"day": { "type": "integer", "enum": [1, 2] },
"seq": { "type": "integer" },
"note": { "type": "string" }
}
},
"route": {
"type": "object",
"required": ["id", "name", "kind", "marker_ids"],
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"kind": {
"enum": ["train", "hike", "stroll", "bike", "taxi", "metro"],
"description": "本 skill 私有枚举:train/bike/taxi/metro 为本 skill 新增"
},
"marker_ids": { "type": "array", "items": { "type": "string" }, "minItems": 2 },
"color": { "type": "string" },
"dashed": { "type": "boolean" },
"note": { "type": "string", "description": "必须说明是点位顺序示意,非真实道路/轨迹" }
}
},
"map": {
"type": "object",
"required": ["center", "zoom", "markers", "routes"],
"properties": {
"center": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 },
"zoom": { "type": "integer" },
"markers": { "type": "array", "items": { "$ref": "#/definitions/marker" } },
"routes": { "type": "array", "items": { "$ref": "#/definitions/route" } }
}
},
"table": {
"type": "object",
"required": ["id", "title", "columns", "rows"],
"properties": {
"id": { "type": "string" },
"title": { "type": "string" },
"columns": {
"type": "array",
"items": {
"type": "object",
"required": ["key", "label"],
"properties": {
"key": { "type": "string" },
"label": { "type": "string" },
"align": { "enum": ["left", "center", "right"] },
"width": { "type": "string" }
}
}
},
"rows": { "type": "array", "items": { "type": "object", "description": "键为 columns[].key" } },
"note": { "type": "string" },
"highlight_row_keys": { "type": "array", "items": { "type": "string" } }
}
},
"cost": {
"type": "object",
"required": ["currency", "items", "total"],
"properties": {
"currency": { "enum": ["CNY"] },
"items": {
"type": "array",
"items": {
"type": "object",
"required": ["label", "amount"],
"properties": {
"label": { "type": "string" },
"amount": { "type": "number" },
"unit": { "type": "string" },
"note": { "type": "string" }
}
},
"description": "本 skill 的费用项应覆盖:往返高铁票、住宿/洗浴留宿、市内交通(共享单车+网约车)、餐饮、门票"
},
"total": { "type": "number" },
"note": { "type": "string" }
}
},
"checklist_item": {
"type": "object",
"required": ["text", "confidence"],
"properties": {
"anchor_id": { "type": "string" },
"field": { "type": "string" },
"text": { "type": "string" },
"confidence": { "enum": ["medium", "low"] },
"verify": { "type": "string" },
"kind": { "enum": ["must", "optional"], "description": "must=出发前必须确认(如车次、住宿、预约);optional=有余力再确认" }
}
},
"note": {
"type": "object",
"required": ["level", "title", "text"],
"properties": {
"level": { "enum": ["info", "warn", "danger"] },
"title": { "type": "string" },
"text": { "type": "string" }
}
}
}
}
@@ -0,0 +1,158 @@
{
"_comment": "技能包:问答脚本。生成前由前端按本文件动态渲染问答表单;后端只透传答案,不解析字段含义。新增/删改问答项不需要改任何后端代码。",
"_isolation_comment": "【隔离用意】本文件是【本 skill 私有的问答脚本】,不是全 lab 共享的表单模板。所以本 skill 可以问『出发时段』『住宿形态』『是否带拉杆箱』这些自驾 skill 完全不需要的问题,而不必迁就别人。sh_2d_car_hike 问的是『车型/油车电车』,本 skill 不问 —— 两者互不影响,这正是隔离要达到的效果。请勿把两个 qa.json 合并成一份『通用问卷』。",
"version": "1.0.0",
"destination_input_comment": "目的地是通用层输入(skill ↔ 目的地多对多),本 skill 只负责覆盖它的叫法、提示与校验口径",
"destination_input": {
"label": "目的地城市",
"placeholder": "例如:苏州、杭州、南京、扬州、北京",
"required": true,
"mode": "free_input_with_suggestion",
"suggestions_ref": "destinations.json",
"max_one_way_rail_hours": 5,
"on_out_of_range": "block_with_hint",
"hint": "本 Skill 适用于从上海坐高铁单程 5 小时内的目的地(含苏州/杭州/南京等近程,也含北京/武汉/青岛等长途)。注意长途目的地建议选『周五晚出发』,否则第一天大半时间在路上"
},
"questions_comment": "问答项:type 支持 date / time / select / number / text;affects 说明该项会影响哪些产出,便于页面提示用户",
"questions": [
{
"key": "departure_station",
"label": "出发车站",
"type": "text",
"required": true,
"default": "上海虹桥站",
"affects": [
"去程的出发站与建议到站时间",
"返程的终点站",
"抵沪后回家的交通方式与末班地铁提示"
],
"hint": "默认上海虹桥站,可改成上海站 / 上海南站 / 上海西站;改站后去返程的换乘与到站时间会重算"
},
{
"key": "departure_window",
"label": "出发时段",
"type": "select",
"required": true,
"default": "sat_morning",
"options": [
{ "value": "sat_early", "label": "周六早(06:00-08:00)· 长途首选" },
{ "value": "sat_morning", "label": "周六上午(08:00-10:00)· 适合 2 小时内的近程" },
{ "value": "fri_night", "label": "周五晚(18:00 后)· 夜车/卧铺,周六早上到" }
],
"affects": [
"去程高铁的建议时段与历时",
"第一天在目的地的可用时间(直接决定行程松紧)",
"住宿晚数(周五晚出发=车上过一夜,目的地只需住一晚)",
"是否需要安排夜车卧铺"
],
"hint": "目的地车程超过 3 小时时,选『周六上午』会导致第一天只剩半天,AI 会提示你改时段"
},
{
"key": "departure_date",
"label": "出行日期(周六)",
"type": "date",
"required": true,
"default_rule": "nearest_saturday",
"affects": [
"日出与天亮时间",
"季节穿着与蚊虫提示",
"景区旺季/淡季与是否需要预约",
"周日下午返程高峰与票源紧张程度"
],
"hint": "本 Skill 固定周六出发、周日返程;日期只影响季节类信息。若选『周五晚出发』,这里填的是那个周六的日期"
},
{
"key": "lodging_mode",
"label": "过夜方式",
"type": "select",
"required": true,
"default": "bathhouse",
"options": [
{ "value": "hotel", "label": "酒店 / 民宿" },
{ "value": "bathhouse", "label": "高级洗浴(带留宿)" },
{ "value": "both", "label": "两者都给,我自己挑" }
],
"affects": [
"第 1 晚住宿的推荐形态与筛选口径",
"住宿候选对比表(lodging_compare)的内容",
"价格区间与费用合计中的住宿项",
"是否需要在报告中标注『本地无大型带留宿洗浴』的降级说明"
],
"hint": "选『高级洗浴(带留宿)』时会要求规模较大、档次中上,并核实是否真能过夜(部分洗浴仅营业到凌晨)"
},
{
"key": "budget_level",
"label": "消费档次",
"type": "select",
"required": true,
"default": "comfort",
"options": [
{ "value": "economy", "label": "经济(住宿 ≤¥300,餐饮人均 ≤¥80)" },
{ "value": "comfort", "label": "舒适(住宿 ¥300-600,餐饮人均 ¥100 左右)" },
{ "value": "quality", "label": "品质(住宿 ≥¥600 或高档洗浴,餐饮人均 ¥150+)" }
],
"affects": [
"住宿候选的档次与价格区间",
"洗浴场所的规模与档次门槛",
"餐饮人均目标",
"市内交通预算(网约车占比)"
]
},
{
"key": "headcount",
"label": "出行人数",
"type": "number",
"required": true,
"default": 1,
"min": 1,
"max": 4,
"affects": [
"住宿房型建议(单间/标间/双床)",
"餐食推荐份量与能否点半份",
"网约车是否拼车(2 人以上打车更划算)",
"高铁票数量与是否需要相邻座位"
]
},
{
"key": "luggage",
"label": "行李情况",
"type": "select",
"required": true,
"default": "backpack",
"options": [
{ "value": "backpack", "label": "轻装(背包)" },
{ "value": "suitcase", "label": "带拉杆箱" }
],
"affects": [
"到站后是先寄存还是先玩(带箱必须先寄存)",
"徒步路段能否安排在入住前",
"市内交通方式(带箱优先网约车,不建议骑车)",
"共享单车方案是否可行(带拉杆箱时不可骑单车)"
],
"hint": "带拉杆箱时,AI 会强制先寄存行李再走行程,且不会安排『边骑车边带箱』这类不可行的方案"
},
{
"key": "hike_intensity",
"label": "徒步强度",
"type": "select",
"required": true,
"default": "medium",
"options": [
{ "value": "easy", "label": "轻松(约 1 小时,平路/缓坡为主)" },
{ "value": "medium", "label": "中等(约 1.5 小时,可含 200m 内爬升)" },
{ "value": "strong", "label": "稍强(约 2 小时,可含 400m 内爬升)" }
],
"affects": [
"两段徒步的目标时长与累计爬升上限",
"徒步路线的选点(山地/绿道/环湖)",
"体力消耗对后续行程安排的影响",
"是否触发地形降级(平原城市无法满足爬升要求时)"
]
}
],
"not_asked_comment": "按用户要求不收集的项;如将来需要,直接往 questions 里追加即可,无需改后端",
"not_asked": ["taste_preference", "dietary_restriction", "seat_class"]
}
@@ -0,0 +1,166 @@
// ============================================================
// sh_2d_rail_hike/repair.js —— 本技能生成结果的无损修复(技能私有)
//
// 设计前提:AI 一定会变形。所以我们不指望它 100% 守规矩,而是在服务端做
// 「可解释、无损、不改业务语义」的自动修复,把能修的修好,修不了的留给校验报出来。
//
// 【隔离用意 · 请后续开发者先读这段】
// 本文件是从 sh_2d_car_hike/repair.js 复制来的(用户已确认「复制现有再改」的方案)。
// 复制之后它就是【本技能私有】的一份,改它不会影响自驾 skill;反过来,自驾 skill 改它的
// 同名文件也不会影响本 skill。这是刻意的重复,不是疏漏 —— 本 lab 的原则是
// 「宁可每个 skill 各存一份可自由修改的代码,也不要抽一个一旦改动就牵连所有 skill 的公共模块」。
//
// 当前修复项:
// 1. 地图路线引用了不存在点位 id → 先做归一名匹配(marker_x / x / Marker-X 视为同一个),
// 仍找不到的丢弃;剩余点位不足 2 个的路线整条丢弃(画不出来)
// 2. 待核实清单漏收槽位 → 为该槽位补一条通用条目(文案只用卡片标题,不编造事实)
// 3. 硬时间点行的 kind 与骨架声明不一致 → 无损对齐
// 4. 【本技能专属】缺少「返程不晚于 21:00 抵沪」的提示块 → 自动补一条 danger 级 notes
// (这是用户明确的硬约束:周一要上班,晚于地铁末班回不了家,不允许报告里没这条提醒)
// ============================================================
const isNonHighFact = (node) => {
if (Array.isArray(node)) return node.some(isNonHighFact)
if (node && typeof node === 'object') {
if ('confidence' in node) return node.confidence !== 'high'
return Object.keys(node).some((k) => isNonHighFact(node[k]))
}
return false
}
// marker_hike_1_start / Hike1Start / hike-1-start → hike1start
const normId = (s) => String(s || '')
.toLowerCase()
.replace(/^marker[_\-]?/, '')
.replace(/[^a-z0-9]/g, '')
const repairMarkerRefs = (map, fixes) => {
const markers = Array.isArray(map.markers) ? map.markers : []
const routes = Array.isArray(map.routes) ? map.routes : []
if (!markers.length || !routes.length) return
const exists = new Set(markers.map((m) => m && m.id))
const byNorm = {}
markers.forEach((m) => { if (m && m.id) byNorm[normId(m.id)] = m.id })
map.routes = routes.filter((r) => {
if (!r) return false
const raw = Array.isArray(r.marker_ids) ? r.marker_ids : []
const out = []
raw.forEach((id) => {
if (exists.has(id)) { out.push(id); return }
const hit = byNorm[normId(id)]
if (hit) { out.push(hit); fixes.push(`路线 ${r.id}:点位 ${id} → ${hit}(id 归一匹配)`); return }
fixes.push(`路线 ${r.id}:丢弃未定义点位 ${id}`)
})
const dedup = []
out.forEach((id) => { if (dedup.indexOf(id) < 0) dedup.push(id) })
r.marker_ids = dedup
if (dedup.length < 2) { fixes.push(`路线 ${r.id}:有效点位不足 2 个,已整条丢弃`); return false }
return true
})
}
const fillChecklistGaps = (payload, bundle, fixes) => {
const anchors = (bundle.skill && Array.isArray(bundle.skill.anchors)) ? bundle.skill.anchors : []
const anchorIds = anchors.map((a) => a.id)
const cards = Array.isArray(payload.cards) ? payload.cards : []
const checklist = Array.isArray(payload.checklist) ? payload.checklist : []
payload.checklist = checklist
const covered = new Set(checklist.map((c) => c && c.anchor_id).filter(Boolean))
// 清掉引用了未定义槽位的条目(AI 偶尔会造新槽位名)
payload.checklist = checklist.filter((c) => {
if (!c || !c.anchor_id) return true
if (anchorIds.indexOf(c.anchor_id) >= 0) return true
fixes.push(`清单:移除引用未定义槽位的条目(${c.anchor_id})`)
return false
})
const covered2 = new Set(payload.checklist.map((c) => c && c.anchor_id).filter(Boolean))
cards.forEach((c) => {
if (!c || !c.anchor_id) return
if (covered2.has(c.anchor_id)) return
if (!isNonHighFact(c)) return
payload.checklist.push({
anchor_id: c.anchor_id,
field: 'auto_added',
text: `「${String(c.title || c.anchor_id)}」含低置信信息,出行前需核实`,
confidence: 'low',
verify: '见该节点卡片中的「核实」说明;本条由服务端自动补齐(原清单漏收该槽位)'
})
fixes.push(`清单:为槽位 ${c.anchor_id} 自动补一条待核实项`)
})
}
// 3. 硬时间点行的 kind 与骨架声明不一致 → 无损对齐
// 骨架已声明该槽位是硬时间点,但 AI 可能把活动类型(sleep/eat/hike…)填进 kind;
// 这里把 kind 对齐为 hard,并把原活动名搬进 activity(该行还没有 activity 时),
// 不改任何事实、也不丢 AI 原本表达的语义。
const ACTIVITIES = ['move', 'eat', 'hike', 'shop', 'sleep', 'walk']
const alignHardKind = (payload, bundle, fixes) => {
const anchors = (bundle.skill && Array.isArray(bundle.skill.anchors)) ? bundle.skill.anchors : []
const hardIds = anchors.filter((a) => a.time_anchor && a.time_anchor.type === 'hard').map((a) => a.id)
if (!hardIds.length) return
const days = (payload.timeline && Array.isArray(payload.timeline.days)) ? payload.timeline.days : []
days.forEach((d) => {
;((d && d.rows) || []).forEach((r) => {
if (!r || hardIds.indexOf(r.anchor_id) < 0 || r.kind === 'hard') return
const old = String(r.kind || '未填')
const isActivity = ACTIVITIES.indexOf(r.kind) >= 0
if (isActivity && !r.activity) r.activity = r.kind
r.kind = 'hard'
fixes.push(`时序:槽位 ${r.anchor_id} 的 kind「${old}」对齐为 hard(骨架已声明该槽位为硬时间点`
+ (isActivity ? `,原值 ${old} 已搬入 activity` : '') + ')')
})
})
}
// 4.【本技能专属】返程死线提示必须存在。
// 这是用户给出的硬约束(周日返、21:00 前抵沪、周一要上班、晚了没地铁),
// 属于「客观规则」而非「事实」,所以服务端可以安全补齐,不算编造。
const ensureReturnDeadlineNote = (payload, fixes) => {
if (!Array.isArray(payload.notes)) payload.notes = []
const has = payload.notes.some((n) => n && n.level === 'danger'
&& (String(n.text || '').indexOf('21:00') >= 0 || String(n.text || '').indexOf('地铁') >= 0))
if (has) return
payload.notes.unshift({
level: 'danger',
title: '返程死线:周日 21:00 前必须抵达上海',
text: '周日务必下午出发返程,最晚 21:00 抵达上海,为地铁末班留出余量(周一要上班)。'
+ '若所选班次偏晚,请改更早的车次;抵沪后如接近末班时间,请直接打车回家。'
+ '本条由服务端自动补齐(原报告未包含返程死线提示)。'
})
fixes.push('提示:补齐「返程不晚于 21:00 抵沪」的 danger 级提示块')
}
// 5.【本技能专属】待核实清单的 confidence 越界(标成 high)→ 降到 medium。
// 为什么可以安全降级:契约里清单项的 confidence 只允许 medium/low(清单项天然是「待核实」的,
// 标 high=已核实 自相矛盾)。AI 常把「我很确定这条需要核实」写成 high,属于表达混淆。
// 降级到 medium 是往更保守的方向修正,不会夸大信息的可靠性,也不会丢失「需要核实」这个语义。
const normalizeChecklistConfidence = (payload, fixes) => {
if (!Array.isArray(payload.checklist)) return
payload.checklist.forEach((c) => {
if (!c) return
if (c.confidence && ['medium', 'low'].indexOf(c.confidence) < 0) {
const old = c.confidence
c.confidence = 'medium'
fixes.push(`清单:第 ${String(c.text || '').slice(0, 18)}… 的 confidence「${old}」→ medium(清单项只能是 medium/low)`)
}
})
}
// 入口:返回修复后的 payload 与修复说明清单
const repairPayload = (payload, bundle) => {
const fixes = []
if (!payload || typeof payload !== 'object') return { payload, fixes }
try { repairMarkerRefs(payload.map || {}, fixes) } catch (e) { fixes.push('地图修复异常:' + String(e.message || e)) }
try { fillChecklistGaps(payload, bundle || {}, fixes) } catch (e) { fixes.push('清单修复异常:' + String(e.message || e)) }
try { alignHardKind(payload, bundle || {}, fixes) } catch (e) { fixes.push('硬时间点对齐异常:' + String(e.message || e)) }
try { ensureReturnDeadlineNote(payload, fixes) } catch (e) { fixes.push('返程死线提示异常:' + String(e.message || e)) }
try { normalizeChecklistConfidence(payload, fixes) } catch (e) { fixes.push('清单置信度修复异常:' + String(e.message || e)) }
return { payload, fixes }
}
module.exports = { repairPayload, normId }
@@ -0,0 +1,52 @@
{
"_comment": "技能包:渲染描述。通用渲染器只认 block 类型 + 数据路径 + pick_id,不认识任何业务字段;换 skill 只换这份文件,前端渲染逻辑不用改结构。",
"_isolation_comment": "【隔离用意】本文件是【本 skill 私有的渲染描述】。本 skill 复用了与 sh_2d_car_hike 相同的 8 种 block 类型(overview/timeline/cards/map/table/cost/checklist/notes),这是刻意为之 —— block 类型是『展示形态』的抽象,够通用;而『车次表』『市内交通表』这类业务差异,靠 table 里的列定义与 pick_id 表达即可,不必新增 block 类型。所以本 skill 的渲染器只需改自己的字典(activity 中文名、marker 颜色),不必改渲染结构。这是隔离后最省力的定制方式。若将来某个 skill 真需要全新展示形态(例如航班时刻轴),那时再为它单独加 block,不影响本文件。",
"version": "1.1.1",
"block_types_comment": "通用渲染器支持的 8 种 block;本 skill 未新增 block 类型",
"block_types": ["overview", "timeline", "cards", "map", "table", "cost", "checklist", "notes"],
"layout_comment": "页面骨架:单列纵向",
"layout": "single_column",
"show_index_comment": "是否在页首显示区块锚点导航",
"show_index": true,
"sections_comment": "区块顺序即页面从上到下的顺序;data 为简单点号路径($.a.b),pick_id 用于从数组里按 id 取一项",
"sections": [
{ "id": "sec_overview", "type": "overview", "title": "行程概览", "data": "$.summary" },
{ "id": "sec_notes_top", "type": "notes", "title": "出行前必读", "data": "$.notes", "filter_levels": ["warn", "danger"] },
{ "id": "sec_timeline", "type": "timeline", "title": "两天时序", "data": "$.timeline" },
{ "id": "sec_cards", "type": "cards", "title": "节点详情(含导航地址)", "data": "$.cards", "group_by": "day" },
{ "id": "sec_map", "type": "map", "title": "点位与路线", "data": "$.map" },
{ "id": "sec_train_out", "type": "table", "title": "去程高铁 · 时段与车站", "data": "$.tables", "pick_id": "train_out_options" },
{ "id": "sec_train_back", "type": "table", "title": "返程高铁 · 时段与车站", "data": "$.tables", "pick_id": "train_back_options" },
{ "id": "sec_city_transport", "type": "table", "title": "市内交通方案 · 共享单车 / 网约车", "data": "$.tables", "pick_id": "city_transport" },
{ "id": "sec_lodging", "type": "table", "title": "住宿候选对比", "data": "$.tables", "pick_id": "lodging_compare" },
{ "id": "sec_food", "type": "table", "title": "三餐候选一览", "data": "$.tables", "pick_id": "food_summary" },
{ "id": "sec_cost", "type": "cost", "title": "费用合计", "data": "$.cost" },
{ "id": "sec_checklist", "type": "checklist", "title": "待核实清单", "data": "$.checklist" },
{ "id": "sec_notes", "type": "notes", "title": "提示与免责", "data": "$.notes" }
],
"confidence_badge_comment": "置信度徽标文案映射(渲染器通用逻辑,不由 skill 硬编)",
"confidence_badge": {
"high": "已核实",
"medium": "待确认",
"low": "需核实"
},
"image_disclaimer_default_comment": "图片一律按示意图处理,不冒充实景",
"image_disclaimer_default": "AI 示意图,非实景",
"map_route_disclaimer_default": "点位顺序示意,非真实道路/轨迹;坐标按概略值处理,导航请以导航地址为准"
}
@@ -0,0 +1,302 @@
# 上海周边高铁游周末两天一晚 · 生成规则
> 本文件是 `sh_2d_rail_hike` 技能包的规则正文,生成时会整段注入 system prompt。
> 与本 skill 的骨架(`skill.json`)和输出契约(`output_schema.json`)配套使用,三者必须口径一致。
>
> **隔离说明**:本文件是**本 skill 私有**的规则,刻意与 `sh_2d_car_hike/rules.md` 不同 —— 那份讲的是自驾 + 车内过夜 + 停车方案,本份讲的是高铁 + 市内交通 + 住宿。请勿把两份合并为「通用旅游规则」;每个 skill 的打法不同,规则就该各写各的。
---
## 一、场景定义
用户在上海,周末(周六去、周日回)坐**高铁**去一个目的地城市玩两天一晚。
到达后**没有自驾车**,市内靠**共享单车 + 网约车 + 步行 + 地铁**。
用户最看重三件事:
1. **每个地点都要有能直接导航的具体地址**,不要泛泛而谈;
2. **午饭和晚饭要「本地人爱去 + 评分好」**,不要游客店;
3. **周六晚上别空着** —— 晚饭后到回住处之间要有夜逛(闹市自由活动)。
---
## 二、绝对禁止(违反即算生成失败)
1. **禁止编造高铁车次号**(如 `G1234`、`D3125`)。
你无法查询 12306,任何车次号都是编造的,会直接误导用户赶错车。
只允许输出:`出发站` / `到达站` / `建议时段` / `历时区间` / `二等座参考票价区间`。
并必须写明「车次与时刻以 12306 实时查询为准」。
2. **禁止编造店名、酒店名、票价精确值**。
不确定的业态可以只给「类型 + 位置描述」(如「平江路北段、临河的本地小馆」),
但**不许编一个看起来很真的名字**。
3. **禁止把精确票价写成单一数字**。必须写区间(如「二等座 约 ¥73-92」)。
4. **禁止出现无法导航的地址**。
❌「平江路」 ✅「苏州市姑苏区平江路历史街区」
❌「老城区某处」✅「南京市秦淮区夫子庙步行街东牌坊」
每个 `map.markers[]` 必须有 `address` 字段,且每张卡片建议带一条 label 为「导航」的字段。
5. **禁止把不能过夜的洗浴写成「可留宿」**。必须核实并标注。
6. **禁止静默降低要求**。触发降级规则时必须显式写出降级说明。
7. **禁止编造餐馆评分**。评分必须是真实可查的区间(如 4.3-4.6)。
不确定就给区间 + 标 `low` + 写 `verify`,**不许编一个精确的假分数**。
8. **禁止晚饭后留白**。周六 19:00-21:00 必须有夜逛内容(见 3.5),
不得出现「吃完饭直接回住处」这种把两小时浪费掉的安排。
9. **禁止编造不存在的夜市 / 夜间去处**。本地没有就按 `no_night_market` 降级并说明。
---
## 三、硬约束(必须满足)
### 3.0 第一天时间轴(骨架 · 不得留白)
这是本技能最重要的一张表。按它排,不要自己另排一套。
| 时段 | 内容 | 槽位 |
|---|---|---|
| 08:00-10:00 | 乘高铁出发、抵达目的地 | `train_out` |
| 到达后 30-60 分钟 | 出站 → 接驳 → 行李寄存 | `station_to_lodging` / `city_mobility` |
| 12:00-13:30 | 午饭(本地人爱去 + 评分好) | `lunch_d1` |
| 14:00-15:30 | 第一段徒步(1-2 小时) | `hike_1` |
| 15:30-17:00 | **白天开放的景点**(务必在闭园/最后入场前到达) | `spot_d1` |
| 17:30-18:45 | 晚饭(本地人爱去 + 评分好,必须在 19:00 前吃完) | `dinner_d1` |
| **19:00-21:00** | **夜逛闹市(自由活动)—— 这一段必须有内容,不许空白** | `night_stroll` |
| 21:30-22:30 | 返回住处 / 入住(hard 时间点) | `lodging_night` |
**为什么要专门规定 19:00-21:00**:用户明确反馈过,原来的行程 18:00 吃完晚饭就直接回住处,
19:00 到 21:00 空着一大段,时间被浪费。所以晚饭提前到 17:30-18:45,
把 19:00-21:00 完整留给夜逛。**禁止出现「吃完饭直接回住处」的安排。**
### 3.1 返程(最高优先级,不可妥协)
- **周日必须下午出发返程,且最晚 21:00 抵达上海。**
原因:用户周一要上班;晚于地铁末班将无法回家。
若某班次无法满足,必须改更早的班次并说明原因。
- 必须给出「从最后一个景点到车站」的交通方式与耗时,
并**倒推留出至少 40 分钟**安检检票缓冲。
- 必须给出「抵沪后从车站回家」的方式说明,并提示末班地铁时间;
若接近末班,必须提醒打车。
### 3.2 出发(按 `departure_window` 分两种模式,结构不同,不得混用)
**模式 A:周六出发(`sat_early` / `sat_morning`)**
- 结构:周六去 → 目的地住 1 晚 → 周日返。**住宿 1 晚**。
- 若目的地单程车程 **> 3 小时**,必须显式提示:
「当日可用时间仅约 X 小时,行程已按此压缩」,
并建议改选 `sat_early` 或 `fri_night`,说明两种模式的差别。
- 若车程 **> 3 小时** 且用户选了 `sat_morning`,必须触发降级规则 `tight_transfer`。
**模式 B:周五晚出发(`fri_night`)**
- 结构:周五晚上车 → **车上过一夜**(卧铺或末班车坐到目的地附近)→ 周六早上抵达 → 目的地住 1 晚 → 周日返。
- 此时**目的地只需 1 晚住宿**,但**总晚数为 2**(一在车上、一在目的地)。
报告中必须写清楚这一点,避免用户以为要订两晚酒店。
- 必须说明车上的过夜方式(卧铺/硬座/是否需自带颈枕与眼罩),
并提示「周五晚出发需提前订票,热门线路卧铺紧张」。
### 3.3 徒步(两天各一段,共 2 段)
- 每段时长必须落在 **1-2 小时**区间,且按 `hike_intensity` 取值:
- `easy` → 约 1 小时,平路/缓坡
- `medium` → 约 1.5 小时,可含 200m 内爬升
- `strong` → 约 2 小时,可含 400m 内爬升
- 必须写出「按步速折算里程」的口径。
- 必须给出:起点 / 终点 / 环线或折返方式 / 累计爬升 / 路面类型 / 难度 / 补水点。
- 必须给出「从上一个节点怎么到达徒步起点」以及**单车能否停在该起点**。
- 两段徒步必须在**地形或景观上有所区别**,不得雷同。
- 目的地无山时触发 `terrain_flat` 降级(见第五节)。
### 3.4 周六下午 · 白天开放的景点(`spot_d1`)
- 必须选一个**白天开放**的景点:博物馆 / 园林 / 故居 / 纪念馆 / 观景平台等。
- **必须把开放时间写全**:几点开门、**几点停止入场**、几点闭馆。
多数景点在闭馆前 30-60 分钟停止入场,**「最后入场时间」比闭馆时间更关键**,必须单独写出来。
- **必须做倒推校验**:从徒步终点到该景点的交通耗时 + 计划停留时长,
必须全部落在「最后入场时间」之前。来不及就必须改选更近的景点或缩短徒步,
**绝不能安排一个到门口已停止入场的行程**。
- 必须给门票价格、是否需提前预约(很多博物馆/园林要预约,旺季名额紧张)。
- 必须与第二天的 `spot_d2` 去重,不得重复。
- 必须给可导航地址。
### 3.5 周六晚 · 夜逛闹市(`night_stroll`)—— 本技能新增
- **性质是「自由活动」**:给出可逛范围与 **≥3 个可选去处**,
不强制打卡顺序,让用户自己取舍。不要写成「必须按 1→2→3 走完」的硬行程。
- 范围优先选:**闹市步行街 / 历史街区夜景 / 夜市 / 江边河畔步道 / 灯光景观带**。
- 必须说明:该区域**夜间是否开放**、人流情况、夜间照明与安全情况。
- 必须给出**夜间交通情况**:地铁末班时间、该时段能否叫到网约车、共享单车夜间是否可用。
- 必须给出从夜逛区域**返回住处**的方式与耗时(夜间优先网约车)。
- 必须提示夜间安全与财物注意事项。
- 每个可选去处都必须给可导航地址。
- 若目的地确实无成熟夜市/夜景带,按 `no_night_market`(见第五节)降级并**显式标注**,
**不得编造不存在的夜市**。
### 3.6 餐馆双筛选(午饭 + 晚饭都要)
`lunch_d1` 与 `dinner_d1` 必须**同时**满足两条,缺一不可:
| 条件 | 必须给出的依据 |
|---|---|
| ① 本地人爱去 | 非游客街主街、本地招牌菜、本地评价占比高、本地人复购 |
| ② 评分好 | 主流点评平台(大众点评 / 高德)的**评分区间**(如 4.3-4.6)+ **评价条数量级** |
判定细则:
- 评分高但明显是游客店的 → **排除**。
- 本地人常去但评分明显偏低(< 4.0)的 → 必须说明原因并给出替代。
- **绝对不许编造评分数字**。不确定就给区间并标 `low` 置信度 + 写 `verify`。
- 另外:晚饭**必须在 19:00 前吃完**,不得推荐上菜慢或需长时间排队的店;
位置尽量靠近夜逛街区,吃完直接逛,省去挪车与打车。
### 3.7 市内交通(共享单车 + 网约车)
这是本 skill 区别于自驾 skill 的核心维度,必须落到 `city_transport` 表格:
| 要求 | 说明 |
|---|---|
| 分段策略 | 哪些段步行、哪些段共享单车、哪些段必须网约车(含理由) |
| 共享单车 | 品牌与开锁方式建议(当地是否在运营区);**必须提示景区/步行街常有禁停区,须停到指定停车点** |
| 网约车 | 上车点建议:热门景点门口通常不能临停,要指明具体可上车的位置 |
| 预估费用 | 共享单车按 1.5 元/15 分钟估;网约车按起步价 + 里程估 |
| 行李约束 | 若 `luggage = suitcase`(带拉杆箱),**禁止安排骑车**,且必须先寄存行李 |
禁停区范围与上车点位置属易变信息,**必须标注非 high 置信度并给核实方式**。
### 3.8 住宿 / 洗浴留宿
按 `lodging_mode` 严格执行,不得擅自更改用户所选形态:
- `hotel`:房型建议(按人数)/ 价格区间 / 是否含早 / 入住退房时间 / 取消政策 / 离次日动线距离。
- `bathhouse`:必须排除社区小浴室,要求规模较大、档次中上;
必须给出营业面积区间、开业年限、本地评价量级等可核实依据;
**必须明确说明是否真能过夜**(部分洗浴仅营业到凌晨)、是否需预约、是否分男女区、是否有安静休息区。
- `both`:两种都给,并给对比表,由用户自选。
无论哪种,都必须说明**次日退房后能否免费寄存行李**,并给可导航地址。
若本地无合规大型带留宿洗浴,触发 `no_bathhouse` 降级。
按 `budget_level` 控制档次与价格区间。
---
## 四、车次信息表达规范
`train_out_options` 与 `train_back_options` 两张表的列固定为:
| 列 key | 列名 | 内容要求 |
|---|---|---|
| `window` | 建议时段 | 如「08:00-09:30」 |
| `from_station` | 出发站 | 如「上海虹桥站」 |
| `to_station` | 到达站 | 如「苏州站」 |
| `duration` | 历时 | 区间,如「23-35 分钟」 |
| `fare` | 二等座参考票价 | 区间,如「¥40-50」 |
| `note` | 备注 | 如「班次密集,高峰 10 分钟一班」 |
**表格里不得出现车次号列。** 表下方 `note` 必须写:
「本表为时段建议,非具体车次;车次、时刻与票价请以 12306 实时查询为准。」
---
## 五、降级规则(触发即必须显式标注)
| id | 触发 | 动作 | 必须声明 |
|---|---|---|---|
| `terrain_flat` | 目的地无山 | 徒步改走河岸绿道/公园林道/环湖步道,保留时长 | 「该段为平路徒步,累计爬升约 0,运动量按时长与步数计」,不得冒充山地效果;并补一条「想出汗」的建议 |
| `no_bathhouse` | 无合规大型带留宿洗浴 | 降级为温泉度假区/大型洗浴中心(有明确休息区),或在允许时改用酒店 | 「本地无传统大型带留宿洗浴,本条为替代方案」;给出人均与可留宿时段 |
| `no_bike_zone` | 景区/老城核心区禁行禁停单车 | 该段改纯步行或网约车 | 「该区域共享单车禁停,须在外围指定点还车」;给出最近合法还车点 |
| `tight_transfer` | 车程 >3h 且出发时段偏晚 | 压缩第一天行程(取消或缩短第一个节点) | 「当日可用时间仅约 X 小时,已按此压缩行程」;建议改班次并说明两种模式差别 |
| `no_night_market` | 目的地无成熟夜市 / 夜景灯光区 / 深夜可逛的步行街 | 降级为「就近街市散步或河畔步道夜走」,或「商场夜间营业时段(多数到 22:00)内逛」 | 「本地无成熟夜市,本条为就近街市/商场夜逛替代」;**不得编造不存在的夜市** |
---
## 六、置信度纪律
以下字段**一律不得标 `high`**,必须用对象形式并附 `verify`:
`train_schedule`、`train_fare`、`bike_no_park_zone`、`taxi_pickup_point`、
`opening_hours`、`bathhouse_grade`、`bathhouse_overnight_ok`、`reservation_required`、
`locals_favorite`、**`restaurant_rating`(餐馆评分与评价数)**、
**`night_market_open`(夜市/夜景区域夜间开放情况)**、`hygiene`
`map.markers[].coord_precision` 一律填 `approx`(你给出的坐标只用于示意图定位,
真实导航以 `address` 为准)。
---
## 七、时序表字段规范
`timeline.days[].label` 写法自由(「周六 / 周日」或「第一天 / 第二天」都可以,
下游按锚点识别是哪一天,不依赖 label 的文案)。
每行必须含:`time` / `title` / `anchor_id` / `kind`。
- `kind` 只表示**时间点性质**,只能取 `hard` 或 `soft`:
- `hard` = 骨架里 `time_anchor.type` 为 `hard` 的槽位(本 skill 为 `lodging_night` 与 `train_back`)
- 其余为 `soft`
- `activity` 表示**活动类型**,可选,与本 skill 新增词表:
`walk` 步行 / `stroll` 逛街夜游 / `spot` 游览景点 / `bike` 共享单车 / `taxi` 网约车 / `metro` 地铁 / `train` 高铁 /
`eat` 吃饭 / `hike` 徒步 / `shop` 采买 / `checkin` 入住寄存 / `rest` 休息
- `stroll` 专指「闲逛 / 夜逛」,与 `walk`(点对点步行通勤)区分。**夜逛行请用 `stroll`。**
- `spot` 专指「游览景点 / 展览」(如博物馆、园林、故居),与 `walk` 区分。
- **`kind` 与 `activity` 是正交的两个字段**:
「21:00 入住」应写 `kind="hard"` 且 `activity="checkin"`,
**绝不能**因为它是入住就把 `kind` 写成 `checkin`。
---
## 八、地图点位规范
- `markers` 必须覆盖当天所有落脚点:出发站、到达站、住宿点、徒步起终点、
景点、餐馆、**夜逛区域(`night_market`)**、单车上车/还车点、网约车上车点。
- 每个 marker 必须有 `address`(可导航)。
- 每个 marker 的 `kind` 取本 skill 枚举:
`start` / `end` / `waypoint` / `train_station` / `lodging` / `bathhouse` /
`district` / `food` / `hike` / `bike` / `taxi_point` / `night_market` / `spot` / `shop` / `walk`
- `routes[].kind` 取:`train` / `hike` / `stroll` / `bike` / `taxi` / `metro`
- 每条 route 的 `note` 必须说明「点位顺序示意,非真实道路/轨迹」。
---
## 九、待核实清单
必须覆盖每一个出现非 high 字段的 `anchor_id`,一个槽位至少一条。
清单至少必须包含(`kind: "must"`):
1. 往返高铁的车次、时刻与票价(12306 核对)
2. 住宿/洗浴是否可订、能否过夜、是否需预约
3. 景区是否需要预约、开放时间(注意「最后入场时间」)
4. 共享单车禁停区与合法还车点
5. **午饭与晚饭餐馆的评分与营业时间**(双筛选的另一半:本地人爱去已在报告里给依据,
评分与打烊时间需用户自行复核)
6. **夜逛区域夜间是否开放**(夜市/夜景带的季节性营业、灯光开放时间)
---
## 十、输出纪律
- 只输出**结构化 JSON**,符合 `output_schema.json`,根字段一个都不能少。
- 不要任何解释、前后缀、Markdown 代码块标记。
- 一次成卷,不拆成多次子调用拼接。
- 不确定就降置信度并写进 `verify` 与待核实清单,**绝对不许编造**。
---
## 十一、费用项要求
`cost.items` 必须覆盖:
往返高铁票(按 2 人/1 人 × 票价区间中值估)/ 住宿或洗浴留宿 /
市内交通(共享单车 + 网约车,分开列)/ 餐饮(按 `budget_level` 与人数)/
门票 / 杂费。
`cost.note` 必须写明「高铁票价按区间中值估算,实际以 12306 为准」。
---
## 十二、清单项的置信度
`checklist[].confidence` **只能取 `medium` 或 `low`**,不能填 `high`。
清单是「待核实清单」,标成「已核实」自相矛盾。如果你想表达「这条几乎肯定需要核实」,
那是 `medium`,不是 `high`。
(服务端会把越界的 `high` 自动降级为 `medium`,但请不要依赖这个兜底。)
@@ -0,0 +1,351 @@
{
"_comment": "技能包:上海周边高铁游周末两天一晚(skill #2,skill_id=sh_2d_rail_hike)。一个 skill = 一整套行程打法,骨架固定、目的地可变。本文件是本 skill 的机读总纲:骨架槽位 + 硬约束 + 降级规则 + 置信度定义。",
"_isolation_comment": "【隔离用意】本文件与 sh_2d_car_hike/skill.json 是【两个独立文件】,不是同一份东西的两个副本。虽然字段名相似(都叫 anchors / hard_constraints),但槽位含义完全不同:本 skill 没有『停车』『车内过夜』这类自驾概念,取而代之的是『高铁车次时段』『市内交通接驳』『住宿或洗浴留宿』。请勿把两者做『抽公共模板』的合并 —— 那正是本 lab 明确禁止的方向:每个 skill 的打法不同,就该各写各的。要新增 skill,请复制本目录整体后在副本里改,而不是把本文件改成通用模板。",
"id_comment": "skill 唯一标识(snake_case),与文件夹名一致",
"id": "sh_2d_rail_hike",
"name_comment": "技能显示名",
"name": "上海周边高铁游周末两天一晚",
"version_comment": "技能版本号;rules/骨架/输出契约任一改动都必须升版本,库里的定稿快照会记录生成时的版本。v1.1.0:把下午的『历史街区』改造为『白天开放景点』,并在晚饭后新增『夜逛闹市』槽位,消除 19:00-21:00 的空白。v1.1.1:词表补 spot(游览景点);修正校验器依赖 day.label 文案导致夜逛误判的 bug",
"version": "1.1.1",
"schema_version_comment": "输出契约版本,与 output_schema.json 的 version 保持一致",
"schema_version": "1.1.1",
"status_comment": "技能状态:draft=骨架打磨中;ready=可对外生成",
"status": "draft",
"days_comment": "行程天数:周六出发、周日返程,两天一晚(若选『周五晚出发』的夜车模式,则车上过一夜、目的地住一晚)",
"days": 2,
"nights": 1,
"base_city_comment": "固定集合城市(去程与返程均以此为起终点)",
"base_city": "上海",
"base_origin_comment": "默认出发点(具体到车站);由问答项 departure_station 输入,用户可改成任意出发车站(如上海站/上海南站),改站后去返程的换乘与到站时间需重算",
"base_origin": "上海虹桥站",
"travel_mode_comment": "出行方式:高铁往返 + 市内共享单车/网约车,不涉及自驾",
"travel_mode": "high_speed_rail_plus_bike_taxi",
"region_comment": "适用区域:以上海为起点、高铁单程 5 小时以内的目的地。注意本范围包含北京/武汉/青岛/长沙这类长途(非字面『周边』),所以规则里专门处理了出发时段与返程时刻的可行性",
"region": {
"label": "上海高铁单程 5 小时圈",
"radius_km": null,
"max_one_way_rail_hours": 5,
"terrain_preference_comment": "偏好地形:有山有水优先;平原城市则走河岸/绿道/公园林道",
"terrain_preference": ["山地", "丘陵", "水系", "城市绿道"]
},
"refs_comment": "本技能包的其他文件(相对本文件夹路径),运行时按引用读取",
"refs": {
"rules": "rules.md",
"qa": "qa.json",
"output_schema": "output_schema.json",
"report_spec": "report_spec.json",
"destinations": "destinations.json"
},
"anchors_comment": "骨架槽位。每个槽位含:id / 天 / 顺序 / 时间锚点 / 硬约束 / 软偏好 / 地图角色。换目的地时槽位不变,只换值。注意:本 skill 的『市内交通(共享单车+网约车)』不是一个槽位,而是贯穿全篇的维度(体现为 city_transport 表格 + 每张卡片的『如何到达』字段 + 时序行的 activity)",
"anchors": [
{
"id": "train_out",
"day": 1,
"order": 10,
"title": "去程高铁(时段 · 车站 · 历时)",
"time_anchor": {
"type": "soft",
"label": "依 departure_window 而定",
"suggest": "周六 06:00-10:00,或周五晚 18:00 后(夜车)"
},
"hard_constraints": [
"禁止给出具体车次号(如 G1234)—— 模型无法查询 12306,写出的车次号必然是编造的,属于严重误导。只给:出发站 / 到达站 / 建议时段 / 历时区间 / 二等座参考票价区间",
"必须写明『车次与时刻以 12306 实时查询为准』,并把该条列入待核实清单",
"必须给出『提前多久到站』的建议(虹桥等大站建议 40 分钟以上,含安检与检票)",
"若所选出发时段与目的地车程不匹配(例如车程 5h 却选周六上午出发),必须显式提示『当天在目的地只剩半天』并建议改时段,不得静默按原时段排一条时间不够的行程",
"票价必须写为区间(如 二等座 约 ¥180-220),不得写成精确单一数字"
],
"soft_preferences": ["优先选择到达时间在上午的班次"],
"map_role": "去程起点(出发车站)与到达车站"
},
{
"id": "city_mobility",
"day": 1,
"order": 20,
"title": "市内交通方案(共享单车 + 网约车)",
"time_anchor": { "type": "none" },
"hard_constraints": [
"必须给出市内出行的总体策略:哪些段适合共享单车、哪些段必须网约车、哪些段直接步行",
"必须给出共享单车品牌与开锁方式建议(如 美团/哈啰/青桔,需先确认当地是否在运营区),并提示『景区/步行街常有禁停区,须停到指定停车点』",
"必须给出网约车上车点建议:热门景点门口通常不能临停,要指明具体可上车的位置(如『XX 路口西侧临时停靠点』)",
"必须给出市内交通的预估总花费(共享单车按 1.5 元/15 分钟估,网约车按起步价 + 里程估)",
"禁停区范围与上车点位置属于主观/易变信息,必须标注非 high 置信度并给核实方式"
],
"soft_preferences": ["3km 内优先单车,超过 5km 或带行李时优先网约车"],
"map_role": "不单独打点;其结论体现在其余槽位的『如何到达』字段与 city_transport 表格"
},
{
"id": "station_to_lodging",
"day": 1,
"order": 30,
"title": "出站接驳 + 行李寄存/入住",
"time_anchor": { "type": "soft", "suggest": "到达后 30-60 分钟内完成" },
"hard_constraints": [
"必须给出从车站到住宿点的具体方式(地铁几号线/网约车/步行)与耗时",
"必须说明『先寄存还是先入住』:多数酒店 14:00 后才可入住,早到必须先寄存行李再出行,不得让用户拖着行李走行程",
"若住宿点可寄存,必须写明寄存是否免费",
"必须给出住宿点的可导航地址(详见 address 字段要求)"
],
"soft_preferences": ["住宿优先选在当日活动动线附近,减少往返"],
"map_role": "到达车站 → 住宿点(接驳示意)"
},
{
"id": "lunch_d1",
"day": 1,
"order": 40,
"title": "第一天午饭(本地人爱去 + 评分好 · 人均≈100)",
"time_anchor": { "type": "soft", "suggest": "按实际到达时间顺延,通常 12:00-13:30" },
"hard_constraints": [
"【双筛选,两条都要满足】① 本地人爱去:必须给出判断依据(非游客街主街、本地招牌菜、本地评价占比高、本地人复购);② 评分好:必须给出该店在主流点评平台(大众点评 / 高德)的评分区间(如 4.3-4.6),并说明评分样本量级(评价条数)",
"禁止只满足其一:评分高但明显是游客店的要排除;本地人常去但评分明显偏低(<4.0)的也要说明原因并给出替代",
"必须推荐 2-3 家,且标注与当前动线的关系(步行可达 / 需单车 / 需打车)",
"人均目标 100 元左右,超出必须说明理由",
"必须给出推荐菜",
"按出行人数适配份量",
"评分与评价数属于易变信息,必须标注非 high 置信度并给核实方式"
],
"soft_preferences": ["本地时令菜/老字号优先;评分 4.3 以上优先"],
"map_role": "候选餐馆点"
},
{
"id": "hike_1",
"day": 1,
"order": 50,
"title": "第一段徒步(1-2 小时)",
"time_anchor": { "type": "soft", "suggest": "下午 14:00-15:30 之间完成" },
"hard_constraints": [
"时长必须落在 1-2 小时区间,并写出按步速折算的里程口径",
"必须给出起点/终点/环线或折返方式",
"必须给出累计爬升、路面类型、难度、补水点",
"必须给出『从上一个节点怎么到达徒步起点』(步行/共享单车/网约车三选一,并说明单车能否停在该起点)",
"无山的城市必须显式降级为河岸/绿道/公园林道,并写明『平路徒步,累计爬升约 0』,不得冒充山地徒步效果"
],
"soft_preferences": ["有山有水优先;城市内则选沿江/沿河绿道"],
"map_role": "徒步轨迹(示意图,非真实轨迹)"
},
{
"id": "spot_d1",
"day": 1,
"order": 60,
"title": "周六下午 · 白天开放的景点(须在闭园前)",
"time_anchor": { "type": "soft", "suggest": "15:30-17:00(务必在下班/闭园前抵达)" },
"hard_constraints": [
"【本槽位核心:白天开放】必须选一个『白天开放』的景点(博物馆 / 园林 / 故居 / 纪念馆 / 观景平台等),并把开放时间写全(几点开门、几点停止入场、几点闭馆)",
"必须倒推校验:从徒步终点到该景点的交通耗时 + 计划停留时长,必须全部落在闭园前。若来不及,必须改选更近的景点或缩短徒步,不得安排一个到门口已闭馆的行程",
"必须给出门票价格、是否需要提前预约(很多博物馆/园林需预约,旺季名额紧张)",
"必须说明『最后入场时间』(多数景点闭馆前 30-60 分钟停止入场,这比闭馆时间更关键)",
"必须与第二天的 spot_d2 去重,不得重复",
"必须给可导航地址",
"开放时间、门票、预约政策属易变信息,必须标注非 high 置信度并给核实方式"
],
"soft_preferences": ["免费或低价的人文类景点优先(博物馆/故居);有室内空间的优先(雨天可看)"],
"map_role": "景点点位"
},
{
"id": "dinner_d1",
"day": 1,
"order": 70,
"title": "第一天晚饭(本地人爱去 + 评分好)",
"time_anchor": { "type": "soft", "suggest": "17:30-18:45(提前吃完,把 19:00-21:00 留给夜逛)" },
"hard_constraints": [
"【双筛选,两条都要满足】① 本地人爱去:必须给出判断依据(非游客街主街、本地招牌菜、本地评价占比高、本地人复购);② 评分好:必须给出该店在主流点评平台(大众点评 / 高德)的评分区间,并说明评价样本量级",
"必须与午饭的菜系/店型有所区别",
"必须给出营业到几点(避免推荐完到店已打烊)",
"【时间约束】晚饭必须在 19:00 前吃完,因为后面还有夜逛环节;不得推荐上菜慢或需要长时间排队的店",
"必须给出该店到夜逛起点的方式与耗时(尽量步行可达,省去挪车/打车)",
"必须给可导航地址",
"评分与评价数属于易变信息,必须标注非 high 置信度并给核实方式"
],
"soft_preferences": ["本地家常菜/老字号优先;位置靠近夜逛街区优先(吃完直接逛)"],
"map_role": "晚餐点"
},
{
"id": "night_stroll",
"day": 1,
"order": 80,
"title": "周六夜 · 闹市夜逛(自由活动,19:00-21:00)",
"time_anchor": { "type": "soft", "suggest": "19:00-21:00(约 2 小时,晚饭后到回住处之间)" },
"hard_constraints": [
"【本槽位存在的意义】晚饭结束到回住处入住之间(约 19:00-21:00)不允许空白。必须安排一段夜逛,不得出现『吃完饭直接回住处休息』的安排",
"必须明确写出这是『自由活动』性质:给出可逛范围与若干可选去处(≥3 个),不强制打卡顺序,让用户自行取舍",
"范围优先选:闹市步行街 / 历史街区夜景 / 夜市 / 江边河畔步道 / 灯光景观带。必须说明该区域夜间是否开放、是否安全、人流情况",
"必须给出夜间公共交通情况:地铁末班时间、该时段能否叫到网约车、共享单车夜间是否可用",
"必须给出从夜逛区域返回住处的交通方式与耗时(夜间优先网约车)",
"必须提示夜间安全与财物注意事项",
"若目的地确实无成熟夜市/夜景观光区,必须显式降级为『就近街市或河畔散步』并说明理由,不得编造不存在的夜市",
"每个可选去处都必须给可导航地址",
"夜市/夜间开放情况属易变信息,必须标注非 high 置信度并给核实方式"
],
"soft_preferences": ["有夜景灯光或河畔步道的优先;小吃夜市优先于纯购物商场"],
"map_role": "夜逛区域(中心点 + 可选去处点位)"
},
{
"id": "lodging_night",
"day": 1,
"order": 90,
"title": "住宿或高级洗浴留宿(过夜)",
"time_anchor": { "type": "hard", "label": "不晚于 22:30 抵达住处", "suggest": "21:30-22:30 入住" },
"hard_constraints": [
"形态由问答项 lodging_mode 决定:酒店/民宿 或 高级洗浴(带留宿) 或 两者都给。不得擅自更改用户所选形态",
"若选『高级洗浴(带留宿)』:必须排除社区小浴室,要求规模较大、档次中上;必须给出营业面积区间、开业年限、本地评价量级等可核实依据;必须明确说明是否真能过夜(部分洗浴仅营业到凌晨,不可留宿)、是否需提前预约、是否分男女区、是否有安静休息区",
"若选『酒店/民宿』:必须给出房型建议(按人数)、价格区间、是否含早、入住/退房时间、取消政策、离次日动线的距离",
"必须给出免费寄存行李的说明(次日退房后仍可寄存,方便继续游玩)",
"必须给可导航地址",
"档次、卫生、能否过夜均属主观/易变信息,必须标注非 high 置信度并给核实方式"
],
"soft_preferences": ["洗浴留宿优先选本地人复购率高、有独立休息区的"],
"map_role": "住宿点(夜间终点)"
},
{
"id": "breakfast_d2",
"day": 2,
"order": 90,
"title": "第二天早饭",
"time_anchor": { "type": "soft", "suggest": "07:00-08:30" },
"hard_constraints": [
"必须给出二选一或都给:店内吃(本地早点铺)或住处/便利店解决",
"店内吃必须给出开门时间(7 点前是否已营业)",
"必须给出从住处到早点的距离与方式",
"必须给可导航地址"
],
"soft_preferences": ["本地早点(面条/汤包/烧饼等)优先"],
"map_role": "早点铺"
},
{
"id": "hike_2",
"day": 2,
"order": 100,
"title": "第二段徒步(1-2 小时 · 与第一天不同)",
"time_anchor": { "type": "soft", "suggest": "08:30-10:30" },
"hard_constraints": [
"时长必须落在 1-2 小时区间,且有实际运动量(写清爬升与折算口径)",
"必须与第一段徒步在地形/景观上有所区别,避免重复",
"必须给出『从住处怎么到达起点』(步行/共享单车/网约车),并说明单车停放",
"必须给出徒步终点如何衔接下一站",
"无山城市必须显式降级并标注(同 hike_1)"
],
"soft_preferences": ["山水结合、有制高点视野"],
"map_role": "徒步轨迹(示意图)"
},
{
"id": "spot_d2",
"day": 2,
"order": 110,
"title": "返程前穿插的景点/街区",
"time_anchor": { "type": "soft", "suggest": "10:30-13:00" },
"hard_constraints": [
"数量 1-2 个,典故/成因必须写清(故居类要有生平要点,展馆类要说明看什么)",
"必须给出开放时间、门票、是否需要预约",
"必须与第一天的街区打卡点去重,不得重复",
"必须与返程车站顺路(或明确给出到车站的交通方式与耗时),不得安排一个与车站反向的景点导致赶车风险",
"必须给可导航地址"
],
"soft_preferences": ["故居/人文类优先于纯拍照点"],
"map_role": "打卡点"
},
{
"id": "train_back",
"day": 2,
"order": 120,
"title": "返程高铁(周日下午出发 · 21:00 前必须抵沪)",
"time_anchor": {
"type": "hard",
"label": "周日下午出发,最晚 21:00 抵达上海",
"suggest": "周日 13:00-16:00 之间发车"
},
"hard_constraints": [
"『21:00 前抵沪』是硬约束,不可漂移:用户周一要上班,且晚于地铁末班将无法回家。若所选班次无法满足,必须改早班次并说明原因",
"禁止给出具体车次号(同 train_out,理由相同),只给出发站/到达站/建议时段/历时区间/二等座参考票价区间,并写明以 12306 为准",
"必须给出『从最后一个景点到车站』的交通方式与耗时,并倒推留出至少 40 分钟安检检票缓冲",
"必须给出抵沪后从车站回家的方式说明(末班地铁时间提示;若接近末班,必须提醒打车)",
"必须提示周日下午为返程高峰,票源紧张且车站人多,建议提前购票"
],
"soft_preferences": ["优先选 16:00 前发车的班次,给抵沪后的地铁留足余量"],
"map_role": "返程起点(返程车站)与终点(抵沪车站)"
}
],
"degradation_rules_comment": "地形/业态不支持时的降级规则:降级不是省略,而是显式替换 + 明确标注,绝不允许静默降低要求",
"degradation_rules": [
{
"id": "terrain_flat",
"trigger": "目的地无山(如苏州、嘉兴等平原城市)",
"action": "徒步改走河岸绿道 / 公园林道 / 环湖步道,保留 1-2 小时时长",
"must_declare": "必须写明『该段为平路徒步,累计爬升约 0,运动量按时长与步数计』,不得冒充山地徒步效果",
"extra": "必须补一条『想出汗』的建议(配速、快走替代)"
},
{
"id": "no_bathhouse",
"trigger": "目的地无合规大型带留宿洗浴",
"action": "降级为温泉度假区 / 大型洗浴中心(且有明确休息区),或在 lodging_mode 允许时改用酒店",
"must_declare": "必须标注『本地无传统大型带留宿洗浴,本条为替代方案』",
"extra": "必须给出单次人均与可留宿时段"
},
{
"id": "no_bike_zone",
"trigger": "景区/老城核心区为共享单车禁行或禁停区",
"action": "该段改为纯步行或网约车,并明确指出禁停范围",
"must_declare": "必须标注『该区域共享单车禁停,须在外围指定点还车』",
"extra": "必须给出最近的合法还车点位置"
},
{
"id": "no_night_market",
"trigger": "目的地无成熟夜市 / 夜景灯光区 / 深夜可逛的步行街",
"action": "降级为『就近街市散步或河畔步道夜走』,或『商场夜间营业时段(多数到 22:00)内逛』",
"must_declare": "必须标注『本地无成熟夜市,本条为就近街市/商场夜逛替代』,不得编造不存在的夜市",
"extra": "必须给出替代区域的夜间照明与人流情况;若连商场也早关门,就老实写明『本目的地夜间可逛内容有限』并给出安全的就近散步建议"
},
{
"id": "tight_transfer",
"trigger": "目的地车程 3 小时以上、所选出发时段偏晚",
"action": "压缩第一天行程(取消或缩短第一个节点),并显式提示时间紧张",
"must_declare": "必须写明『当日可用时间仅约 X 小时,已按此压缩行程』",
"extra": "必须建议改选更早班次或周五晚出发,并说明两种模式的差别"
}
],
"confidence_levels_comment": "置信度三档定义。主观项(车次时段、票价、禁停区、网约车上车点、营业时间、洗浴档次与能否过夜、是否需预约)必须给置信度,不得默认 high",
"confidence_levels": {
"high": "有明确可核实来源(车站/景区官方公告、连锁品牌明确信息)",
"medium": "常见认知或口碑信息,大体可靠但需现场/出发前确认",
"low": "推测或信息不足,仅作方向性参考,必须附核实方式"
},
"unverified_fields_comment": "这些字段默认不允许标 high,必须附 verify(核实方式)。v1.1.0 新增 restaurant_rating(餐馆评分与评价数)与 night_market_open(夜市/夜景区域夜间开放情况)",
"unverified_fields": [
"train_schedule",
"train_fare",
"bike_no_park_zone",
"taxi_pickup_point",
"opening_hours",
"bathhouse_grade",
"bathhouse_overnight_ok",
"reservation_required",
"locals_favorite",
"restaurant_rating",
"night_market_open",
"hygiene"
],
"output_discipline_comment": "生成纪律:只输出结构化 JSON(符合 output_schema.json),禁止夹带散文;不确定就降置信度并写进 verify,不许编造",
"output_discipline": {
"format": "json_only",
"one_call": "一次成卷,不拆成多次子调用拼接",
"forbid": [
"编造高铁车次号(如 G1234)",
"编造店名与价格",
"编造餐馆评分(评分必须是真实可查的区间,不确定就写区间并标 low)",
"编造不存在的夜市",
"把精确票价写成单一数字(必须区间)",
"把无法过夜的洗浴写成『可留宿』",
"给无法定位的泛指地址(如只写『XX 老街』)",
"晚饭后直接回住处、让 19:00-21:00 空白(本技能必须安排夜逛)",
"省略 must_declare 标注"
]
}
}
@@ -0,0 +1,309 @@
// ============================================================
// sh_2d_rail_hike/validate.js —— 本技能生成结果的结构校验(技能私有)
//
// 为什么要服务端校验:AI 一定会变形。与其相信它,不如生成后按「本技能骨架 + 契约」硬校验一遍,
// 把不合格的地方作为 warnings / errors 回传,前端在交互弹窗里显示出来。
//
// 【隔离用意 · 请后续开发者先读这段】
// 本文件是【本技能私有的校验器】,不是全 lab 公共校验器。它与 sh_2d_car_hike/validate.js
// 有这些刻意的不同,全部由本 skill 的业务特点决定:
// 1. ACTIVITIES 词表不同:多了 stroll(逛街/夜逛) / bike / taxi / metro / train / checkin / rest
// 2. 新增「marker.address 必填」校验(用户核心要求:每处都能导航)
// 3. 新增「车次表不得出现车次号列」校验(本 skill 明确禁止编造车次号)
// 4. v1.1.0 新增「晚饭后不得空白」校验(周六 19:00-21:00 必须有夜逛)与「餐馆须给评分」校验
// 这些如果做成公共校验器,就必须靠 if (skill === 'xxx') 分支区分,那是本 lab 禁止的方向。
// 要新增 skill,请复制本文件后在副本里改。
// ============================================================
const ROOT_KEYS = ['meta', 'summary', 'timeline', 'cards', 'map', 'tables', 'cost', 'checklist', 'notes']
// 本技能私有的活动类型词表(与 output_schema.json / rules.md 保持一致)
// stroll 专指「闲逛 / 夜逛」,与 walk(点对点步行通勤)区分
// spot 专指「游览景点/展览」(v1.1.1 新增:AI 实际会用 spot 标记「下午景点」这一行)
const ACTIVITIES = ['walk', 'stroll', 'spot', 'bike', 'taxi', 'metro', 'train', 'eat', 'hike', 'shop', 'checkin', 'rest']
// 本技能私有的点位类型词表
const MARKER_KINDS = ['start', 'end', 'waypoint', 'train_station', 'lodging', 'bathhouse', 'district',
'food', 'hike', 'bike', 'taxi_point', 'night_market', 'spot', 'shop', 'walk']
// 本技能私有的路线类型词表
const ROUTE_KINDS = ['train', 'hike', 'stroll', 'bike', 'taxi', 'metro']
const CONFIDENCE_LEVELS = ['high', 'medium', 'low']
// 车次号识别:G/D/C/Z/T/K + 3~4 位数字(如 G1234、D3125、C7654)
const TRAIN_NO_RE = /\b[GDCTZK]\d{3,4}\b/
const hasNonHighFact = (node) => {
if (Array.isArray(node)) return node.some(hasNonHighFact)
if (node && typeof node === 'object') {
if ('confidence' in node) return node.confidence !== 'high'
return Object.keys(node).some((k) => hasNonHighFact(node[k]))
}
return false
}
const validatePayload = (payload, bundle) => {
const errors = []
const warnings = []
const p = payload || {}
const skill = bundle.skill || {}
const anchors = Array.isArray(skill.anchors) ? skill.anchors : []
const anchorIds = anchors.map((a) => a.id)
// 1) 根字段
ROOT_KEYS.forEach((k) => {
if (!(k in p)) errors.push('缺少根字段:' + k)
})
// 2) 卡片必须覆盖全部骨架槽位
const cards = Array.isArray(p.cards) ? p.cards : []
const cardAnchors = cards.map((c) => c && c.anchor_id)
anchorIds.forEach((a) => {
if (cardAnchors.indexOf(a) < 0) errors.push('卡片缺槽位:' + a)
})
cardAnchors.forEach((a) => {
if (anchorIds.indexOf(a) < 0) warnings.push('卡片引用了未定义槽位:' + a)
})
// 3) 时序只能引用合法槽位,硬时间点必须以 kind=hard 出现
const days = (p.timeline && Array.isArray(p.timeline.days)) ? p.timeline.days : []
if (!days.length) errors.push('timeline.days 为空')
days.forEach((d) => {
;((d && d.rows) || []).forEach((r) => {
if (anchorIds.indexOf(r.anchor_id) < 0) warnings.push('时序引用未知槽位:' + r.anchor_id)
})
})
anchors.filter((a) => a.time_anchor && a.time_anchor.type === 'hard').forEach((a) => {
const rows = []
days.forEach((d) => ((d && d.rows) || []).forEach((r) => { if (r && r.anchor_id === a.id) rows.push(r) }))
const label = String((a.time_anchor && a.time_anchor.label) || '')
if (!rows.length) {
warnings.push('硬时间点槽位未出现在时序:' + a.id + '(期望:' + label + ')')
return
}
if (!rows.some((r) => r.kind === 'hard')) {
const kinds = rows.map((r) => String(r.kind || '未填')).join(' / ')
const misled = rows.some((r) => ACTIVITIES.indexOf(r.kind) >= 0)
warnings.push('硬时间点槽位在时序中 kind 非 hard(实际:' + kinds + '):' + a.id
+ (misled ? ' —— 疑似把活动类型填进了 kind(kind 只能是 hard/soft,活动类型应填 activity)' : ''))
}
})
// 3b) 时序行的 kind / activity 取值合法性(kind 与活动类型是正交字段,不允许混用)
// 顺带把两天所有时序行压平,供后面几处跨天检查复用(不再依赖 day.label 的具体写法)
const allRows = []
days.forEach((d) => {
;((d && d.rows) || []).forEach((r) => {
if (!r) return
allRows.push(r)
if (['hard', 'soft'].indexOf(r.kind) < 0) {
warnings.push('时序行 kind 取值非法(只能 hard/soft):' + String(r.kind) + ' @ ' + String(r.anchor_id))
}
if (r.activity && ACTIVITIES.indexOf(r.activity) < 0) {
warnings.push('时序行 activity 取值非法(本技能词表:' + ACTIVITIES.join('/') + '):'
+ String(r.activity) + ' @ ' + String(r.anchor_id))
}
})
})
// 本技能专属:返程行必须体现时间约束意识(周日返 / 最晚 21:00 抵沪)。
// 注意不能依赖「标题里必须出现某个固定词」——AI 的措辞是多样的(返程 / 返沪 / 赴站),
// 所以聚合该槽位的全部行,只要任一行提到时间约束或返程语义即算通过。
const RETURN_HINTS = ['21:00', '21 点', '返程', '返沪', '抵沪', '死线', '末班']
const trainBackRows = allRows.filter((r) => r.anchor_id === 'train_back')
if (trainBackRows.length) {
const hit = trainBackRows.some((r) => {
const text = String(r.title || '') + ' ' + String(r.note || '')
return RETURN_HINTS.some((h) => text.indexOf(h) >= 0)
})
if (!hit) {
warnings.push('返程时序行未体现时间约束(建议在标题或备注里写明「周日下午出发 / 最晚 21:00 抵沪」)')
}
}
// 4) 契约要求的表格
const tables = Array.isArray(p.tables) ? p.tables : []
const requiredTableIds = (bundle.schema && bundle.schema.required_table_ids) || []
requiredTableIds.forEach((id) => {
if (!tables.some((t) => t && t.id === id)) errors.push('缺表格:' + id)
})
tables.forEach((t) => {
if (!t || !Array.isArray(t.columns) || !t.columns.length) { warnings.push('表格缺 columns:' + (t && t.id)); return }
const keys = t.columns.map((c) => c.key)
;((t.rows) || []).forEach((r, i) => {
keys.forEach((k) => {
if (r && !(k in r)) warnings.push(`表格 ${t.id} 第 ${i + 1} 行缺列 ${k}`)
})
})
// 本技能专属:车次表里绝不能出现「车次号」列(模型无法查 12306,写出来必然是编的)
if (['train_out_options', 'train_back_options'].indexOf(t.id) >= 0) {
const badCol = t.columns.find((c) => {
const lab = String((c && (c.label || c.key)) || '')
return lab.indexOf('车次') >= 0 && lab.indexOf('时段') < 0 && lab.indexOf('建议') < 0
})
if (badCol) errors.push('车次表不得出现车次号列(本技能禁止编造车次号):' + t.id + ' 的列「' + (badCol.label || badCol.key) + '」')
}
})
// 4b) 全篇扫描:任何地方出现形如 G1234 的车次号都算违规(含表格单元格、字段文本、备注)
;(function scanTrainNo(node, trail) {
if (typeof node === 'string') {
if (TRAIN_NO_RE.test(node)) {
errors.push('检测到疑似编造的高铁车次号:' + node.slice(0, 40) + ' @ ' + trail)
}
return
}
if (Array.isArray(node)) return node.forEach((v, i) => scanTrainNo(v, trail + '[' + i + ']'))
if (node && typeof node === 'object') {
Object.keys(node).forEach((k) => scanTrainNo(node[k], trail + '.' + k))
}
})(p, '')
// 5) 地图:路线引用的点位必须存在;每个点位必须有可导航地址(本技能核心要求)
const map = p.map || {}
const markers = Array.isArray(map.markers) ? map.markers : []
if (!markers.length) errors.push('map.markers 为空')
const markerIds = markers.map((m) => m && m.id)
markers.forEach((m, i) => {
if (!m) return
if (!m.address || !String(m.address).trim()) {
errors.push(`点位 ${m.id || ('#' + (i + 1))} 缺 address(每个地点都必须可导航)`)
} else if (String(m.address).trim().length < 6) {
warnings.push('点位地址过短,可能无法定位:' + m.id + ' → ' + String(m.address))
}
if (m.kind && MARKER_KINDS.indexOf(m.kind) < 0) {
warnings.push('点位 kind 取值非法(本技能词表):' + String(m.kind) + ' @ ' + m.id)
}
if (m.coord_precision && m.coord_precision !== 'approx') {
warnings.push('模型给出的坐标应一律标 approx(真实导航以 address 为准):' + m.id)
}
})
;(Array.isArray(map.routes) ? map.routes : []).forEach((r) => {
const ids = (r && r.marker_ids) || []
ids.forEach((id) => {
if (markerIds.indexOf(id) < 0) warnings.push('路线 ' + (r && r.id) + ' 引用未知点位:' + id)
})
if (ids.length < 2) warnings.push('路线点位少于 2 个:' + (r && r.id))
if (!r || !r.note) warnings.push('路线缺示意图免责说明:' + (r && r.id))
if (r && r.kind && ROUTE_KINDS.indexOf(r.kind) < 0) {
warnings.push('路线 kind 取值非法(本技能词表):' + String(r.kind) + ' @ ' + r.id)
}
})
// 6) 置信度:非 high 必须给核实方式
let confTotal = 0
let confNonHigh = 0
;(function walk(node, trail) {
if (Array.isArray(node)) return node.forEach((v, i) => walk(v, trail + '[' + i + ']'))
if (node && typeof node === 'object') {
if ('confidence' in node) {
confTotal++
const c = node.confidence
if (CONFIDENCE_LEVELS.indexOf(c) < 0) errors.push('置信度取值非法:' + c + ' @ ' + trail)
if (c !== 'high') {
confNonHigh++
if (!node.verify) warnings.push('非 high 字段缺 verify:' + String(node.text).slice(0, 24))
}
return
}
Object.keys(node).forEach((k) => walk(node[k], trail + '.' + k))
}
})(p, '')
// 6b.【本技能专属】待核实清单的 confidence 只允许 medium / low。
// 契约如此(清单项天然是「待核实」的,标 high=已核实 自相矛盾)。
// 注意:这条以前写成「易变信息不应标 high @ 字段名」的通用扫描,但那种写法只会在清单项上触发,
// 且报错信息让人看不懂是在说清单的 confidence 越界。这里改成直白的清单专项检查。
;(p.checklist || []).forEach((c, i) => {
if (!c) return
if (c.confidence && ['medium', 'low'].indexOf(c.confidence) < 0) {
warnings.push(`待核实清单第 ${i + 1} 条的 confidence 应为 medium/low(清单项不宜标「已核实」):${c.confidence}`)
}
})
// 7) 待核实清单必须覆盖每个含非 high 字段的槽位
const checklist = Array.isArray(p.checklist) ? p.checklist : []
const checklistAnchors = checklist.map((c) => c && c.anchor_id).filter(Boolean)
cards.forEach((c) => {
if (c && hasNonHighFact(c) && checklistAnchors.indexOf(c.anchor_id) < 0) {
warnings.push('槽位含非 high 字段但清单未收录:' + c.anchor_id)
}
})
// 本技能专属:返沪时间约束必须出现在报告里(warn/danger 提示)
const notes = Array.isArray(p.notes) ? p.notes : []
const hasReturnWarn = notes.some((n) => n && (n.level === 'warn' || n.level === 'danger')
&& RETURN_HINTS.some((h) => String(n.text || '').indexOf(h) >= 0))
if (!hasReturnWarn) {
warnings.push('缺少「返程不晚于 21:00 抵沪」的提示块(本技能硬约束,应有 warn/danger 级 notes)')
}
// 7b.【本技能专属 v1.1.0】周六晚饭后不得空白:必须安排夜逛,且时间要落在 19:00 之后。
// 用户明确反馈过:原来的行程 18:00 吃完晚饭就直接回住处,19:00-21:00 空了一大段。
// 所以这里把「有夜逛」变成硬校验(缺了直接 error),把「时间是否合理」变成 warning。
//
// 【重要 · 别再用 day.label 判断是哪一天】
// 曾经这里写成 days.find(d => d.label 含「第一天」),结果 AI 把两天标成「周六/周日」时
// find 返回 undefined,导致明明排了夜逛却被误判为「缺夜逛」,整份 4 分钟生成被丢弃。
// night_stroll / dinner_d1 这些槽位本身就在第一天,所以直接在全量行里按 anchor_id 找即可,
// 不需要、也不应该依赖模型怎么写 day.label。
const hhmm = (s) => {
const m = String(s || '').match(/(\d{1,2}):(\d{2})/)
if (!m) return null
return Number(m[1]) * 60 + Number(m[2])
}
const strollRows = allRows.filter((r) => r.anchor_id === 'night_stroll')
if (!strollRows.length) {
errors.push('缺「夜逛」环节(night_stroll)—— 晚饭后到入住之间不允许空白,必须安排 19:00-21:00 的夜逛')
} else {
if (!strollRows.some((r) => hhmm(r.time) !== null && hhmm(r.time) >= 18 * 60 + 30)) {
warnings.push('夜逛行的时间早于 18:30,晚饭后可能仍有空白:' + strollRows.map((r) => r.time).join(' / '))
}
if (!strollRows.some((r) => r.activity === 'stroll' || r.activity === 'walk' || r.activity === 'spot')) {
warnings.push('夜逛行的 activity 建议填 stroll(逛街/夜逛),当前为:' + strollRows.map((r) => String(r.activity || '未填')).join(' / '))
}
}
// 晚饭必须在 19:00 前吃完,否则夜逛时间被挤压
const dinnerRows = allRows.filter((r) => r.anchor_id === 'dinner_d1')
if (dinnerRows.length && !dinnerRows.some((r) => hhmm(r.time) !== null && hhmm(r.time) <= 19 * 60)) {
warnings.push('晚饭时间晚于 19:00,会挤压夜逛时段:' + dinnerRows.map((r) => r.time).join(' / '))
}
// 7c.【本技能专属 v1.1.0】餐馆必须给评分(用户要求:午饭和晚饭都要「本地人爱去 + 评分好」)
;['lunch_d1', 'dinner_d1'].forEach((aid) => {
const card = cards.find((c) => c && c.anchor_id === aid)
if (!card) return
const text = JSON.stringify(card)
if (text.indexOf('评分') < 0) {
warnings.push(`槽位 ${aid} 未给出餐馆评分(要求:本地人爱去 + 评分好,两条都要有依据)`)
}
if (text.indexOf('本地') < 0) {
warnings.push(`槽位 ${aid} 未给出「本地人爱去」的判断依据`)
}
})
// 8) AI 配图必须带免责说明
;(function walkImg(node) {
if (Array.isArray(node)) return node.forEach(walkImg)
if (node && typeof node === 'object') {
if (node.kind === 'ai_illustration' && !node.disclaimer) warnings.push('AI 示意图缺免责说明')
Object.keys(node).forEach((k) => walkImg(node[k]))
}
})(p, '')
return {
errors,
warnings,
stats: {
cards: cards.length,
anchors: anchorIds.length,
markers: markers.length,
addressCovered: markers.filter((m) => m && String(m.address || '').trim()).length,
tables: tables.length,
confTotal,
confNonHigh,
checklist: checklist.length
}
}
}
module.exports = { validatePayload, ROOT_KEYS, ACTIVITIES, MARKER_KINDS, ROUTE_KINDS }