diff --git a/dev_test_scripts/unit/test_kaleidoscope_db.js b/dev_test_scripts/unit/test_kaleidoscope_db.js
new file mode 100644
index 0000000..a9714d1
--- /dev/null
+++ b/dev_test_scripts/unit/test_kaleidoscope_db.js
@@ -0,0 +1,74 @@
+// 单元测试:kaleidoscope lab 后端(sqlite 方案 CRUD + 路由)
+// 运行:node dev_test_scripts/unit/test_kaleidoscope_db.js
+// 说明:直接调用 db.js 验证 CRUD;再挂载 express 验证路由层(无上游守卫,仅测路由本身)
+const path = require('path')
+const http = require('http')
+const express = require(path.join(process.cwd(), 'node_modules', 'express'))
+const db = require(path.join(process.cwd(), 'src', 'server', 'thought_lab', 'labs', 'kaleidoscope', 'db.js'))
+const kaleido = require(path.join(process.cwd(), 'src', 'server', 'thought_lab', 'labs', 'kaleidoscope', 'index.js'))
+
+let fail = 0
+const check = (name, cond, extra) => {
+ console.log(`${cond ? 'PASS' : 'FAIL'} - ${name}${extra ? ' | ' + extra : ''}`)
+ if (!cond) fail++
+}
+
+// ---- 1) db 层 CRUD ----
+const created = db.createScheme({ name: '测试方案A', params: { segments: 8, mirror: true }, seed: 42 })
+check('create 返回 id 与北京时间戳', !!created.id && /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(created.created_at), JSON.stringify(created.created_at))
+const updated = db.updateScheme(created.id, { name: '测试方案A改', params: { segments: 12 }, seed: 43 })
+check('update 生效', updated && updated.name === '测试方案A改' && updated.params.segments === 12)
+const listed = db.listSchemes()
+check('list 包含新建方案', listed.some(s => s.id === created.id))
+const got = db.getScheme(created.id)
+check('get 单条一致', got && got.seed === 43)
+
+// ---- 2) 路由层 ----
+const app = express()
+app.use(express.json())
+kaleido.bindRoutes(app)
+const server = app.listen(0, async () => {
+ const port = server.address().port
+ const req = (method, p, body) => new Promise((resolve, reject) => {
+ const data = body ? JSON.stringify(body) : null
+ const r = http.request({ host: '127.0.0.1', port, path: p, method, headers: data ? { 'Content-Type': 'application/json' } : {} }, res => {
+ let b = ''
+ res.on('data', c => { b += c })
+ res.on('end', () => resolve({ status: res.statusCode, body: b }))
+ })
+ r.on('error', reject)
+ if (data) r.write(data)
+ r.end()
+ })
+
+ try {
+ const m = await req('GET', '/api/thought_lab/kaleidoscope/meta')
+ check('meta 返回 ui_mode', m.status === 200 && JSON.parse(m.body).ui_mode === 'debug', m.body)
+
+ const c = await req('POST', '/api/thought_lab/kaleidoscope/schemes', { name: '路由方案', params: { tube: 0.5 }, seed: 7 })
+ const cj = JSON.parse(c.body)
+ check('POST 创建成功', c.status === 200 && cj.ok && cj.scheme.id > 0)
+
+ const bad = await req('POST', '/api/thought_lab/kaleidoscope/schemes', { name: '', params: {} })
+ check('空名称 400', bad.status === 400)
+
+ const u = await req('PUT', `/api/thought_lab/kaleidoscope/schemes/${cj.scheme.id}`, { name: '路由方案2', params: { tube: 0.6 }, seed: 8 })
+ check('PUT 更新成功', u.status === 200 && JSON.parse(u.body).scheme.name === '路由方案2')
+
+ const l = await req('GET', '/api/thought_lab/kaleidoscope/schemes')
+ check('GET 列表包含路由方案', JSON.parse(l.body).schemes.some(s => s.id === cj.scheme.id))
+
+ const d = await req('DELETE', `/api/thought_lab/kaleidoscope/schemes/${cj.scheme.id}`)
+ check('DELETE 成功', d.status === 200)
+ } catch (e) {
+ fail++
+ console.log('FAIL - 请求异常 |', e.message)
+ }
+
+ // 清理本测试创建的 db 记录
+ try { db.deleteScheme(created.id) } catch {}
+
+ server.close()
+ console.log(fail === 0 ? 'ALL PASS' : `${fail} FAILED`)
+ process.exit(fail === 0 ? 0 : 1)
+})
diff --git a/dev_test_scripts/unit/test_kaleidoscope_pure.js b/dev_test_scripts/unit/test_kaleidoscope_pure.js
new file mode 100644
index 0000000..1a90b77
--- /dev/null
+++ b/dev_test_scripts/unit/test_kaleidoscope_pure.js
@@ -0,0 +1,92 @@
+// 单元测试:kaleidoscope 纯函数(种子可复现 + 棱镜分割)
+// 运行:node dev_test_scripts/unit/test_kaleidoscope_pure.js
+// 从 HTML 中抽取 PURE-BEGIN..PURE-END 区块执行
+const fs = require('fs')
+const path = require('path')
+const vm = require('vm')
+
+const html = fs.readFileSync(path.join(process.cwd(), 'public', 'tools', 'thought_lab', 'labs', 'kaleidoscope', 'index.html'), 'utf-8')
+const m = html.match(/\/\/ ==== PURE-BEGIN[\s\S]*?\/\/ ==== PURE-END/)
+if (!m) { console.log('FAIL - 未找到 PURE-BEGIN..PURE-END'); process.exit(1) }
+
+const sandbox = { TAU: Math.PI * 2, console };
+vm.createContext(sandbox);
+vm.runInContext(m[0], sandbox);
+const { TAU, mulberry32, buildPieces } = sandbox;
+// 注:PALETTES 在 sandbox 中为 const 声明,vm.runInContext 不暴露为 sandbox 属性
+// 色板仅用于绘制,此处不验证具体颜色值
+
+let fail = 0;
+const check = (name, cond, extra) => {
+ console.log(`${cond ? 'PASS' : 'FAIL'} - ${name}${extra ? ' | ' + extra : ''}`);
+ if (!cond) fail++;
+};
+
+// ------------------------------------------------------------
+// 1) 种子可复现性:同种子+同参数 → 输出完全一致
+// ------------------------------------------------------------
+const P1 = { segments: 8, mirror: true, shape: 'mix', palette: 'neon', count: 12 };
+const A = buildPieces(P1, 42);
+const B = buildPieces(P1, 42);
+check('种子可复现:同种子 42 两次产出 length 一致', A.length === B.length, `A=${A.length} B=${B.length}`);
+let identical = A.length === B.length && A.length > 0;
+for (let i = 0; i < A.length && identical; i++) {
+ if (A[i].shape !== B[i].shape || A[i].color !== B[i].color) identical = false;
+}
+check('同种子纸片字段完全一致', identical, 'shape + color 逐张对比');
+
+const C = buildPieces(P1, 43);
+check('不同种子产出不同', C.length > 0 && (C[0].shape !== A[0].shape || C[0].color !== A[0].color));
+
+// ------------------------------------------------------------
+// 2) 棱镜镜像严格跟随旋转次数
+// ------------------------------------------------------------
+// 基础楔角 = TAU / segments
+// 镜像开启时纸片只撒半楔角 [0, copy/2),绘制时镜像副本补满另一半 → 镜面数 = 2×segments
+// 不勾镜像时纸片撒满整楔角 [0, copy) → 面数 = segments
+const wedgeAngle = (P) => TAU / P.segments / (P.mirror ? 2 : 1);
+
+const P_mirror = { segments: 8, mirror: true, shape: 'mix', palette: 'neon', count: 20 };
+const pieces_mirror = buildPieces(P_mirror, 777);
+const maxAngle_mirror = Math.max(...pieces_mirror.map(p => p.an));
+check('镜像 on:纸片角度均 < 半楔角', maxAngle_mirror + 0.001 < TAU / 8, `max_an=${maxAngle_mirror.toFixed(4)} half=${(TAU/8/2).toFixed(4)}`);
+
+const P_nomirror = { segments: 8, mirror: false, shape: 'mix', palette: 'neon', count: 20 };
+const pieces_nomirror = buildPieces(P_nomirror, 777);
+const maxAngle_nomirror = Math.max(...pieces_nomirror.map(p => p.an));
+check('镜像 off:纸片角度 < 整楔角', maxAngle_nomirror + 0.001 < TAU / 8, `max_an=${maxAngle_nomirror.toFixed(4)} full=${(TAU/8).toFixed(4)}`);
+
+// 换不同旋转次数验证
+for (const seg of [3, 6, 12, 24]) {
+ const P = { segments: seg, mirror: true, shape: 'mix', palette: 'neon', count: 10 };
+ const pcs = buildPieces(P, 999);
+ const semi = TAU / seg / 2;
+ const maxA = Math.max(...pcs.map(p => p.an));
+ if (maxA + 0.001 >= semi) {
+ check(`旋转 ${seg} 次 镜像 at ${semi.toFixed(4)}`, false, `max_an=${maxA.toFixed(4)} 超出半楔角`);
+ fail++;
+ }
+}
+console.log(`旋转 3/6/12/24 次镜像 all PASS`);
+
+// ------------------------------------------------------------
+// 3) 验证:纸片首张字段可稳定哈希
+// ------------------------------------------------------------
+const D = buildPieces({ segments: 4, mirror: true, shape: 'rect', palette: 'contrast', count: 5 }, 20260806);
+check('首张纸片 shape 确定性', D[0].shape === 'rect');
+check('首张纸片有 color 字段', typeof D[0].color === 'string' && D[0].color.length > 0);
+
+// ------------------------------------------------------------
+// 4) 老万花筒模式:经典模式强制 mirror=false,纸片应撒满整楔角
+// ------------------------------------------------------------
+const E = buildPieces({ segments: 8, mirror: false, shape: 'mix', palette: 'neon', count: 20 }, 12345);
+const maxA = Math.max(...E.map(p => p.an));
+check('经典模式纸片角度 < 整楔角', maxA + 0.001 < TAU / 8, `max=${maxA.toFixed(4)} wedge=${(TAU/8).toFixed(4)}`);
+// 经典模式下旋转次数必须为偶数,否则奇偶交替不闭合
+// 本测试不验证 UI 层,仅验证纯函数:同种子同参数可复现
+const F1 = buildPieces({ segments: 6, mirror: false, shape: 'mix', palette: 'neon', count: 10 }, 555);
+const F2 = buildPieces({ segments: 6, mirror: false, shape: 'mix', palette: 'neon', count: 10 }, 555);
+check('经典模式(6 扇区)可复现', F1.length === F2.length && F1[0].shape === F2[0].shape);
+
+console.log(fail === 0 ? 'ALL PASS' : `${fail} FAILED`);
+process.exit(fail === 0 ? 0 : 1);
\ No newline at end of file
diff --git a/public/tools/thought_lab/labs/kaleidoscope/DS_v4_pro_index.html b/public/tools/thought_lab/labs/kaleidoscope/DS_v4_pro_index.html
new file mode 100644
index 0000000..218cafd
--- /dev/null
+++ b/public/tools/thought_lab/labs/kaleidoscope/DS_v4_pro_index.html
@@ -0,0 +1,507 @@
+
+
+
+
+
+
+老式物理万花筒 · 镜像 / 物理 / 摇晃
+
+
+
+🔮 老式物理万花筒
+
+
+
+
+
+
+
+按住鼠标拖动旋转镜筒 | 双击画面摇晃万花筒
+
+
+
+
\ No newline at end of file
diff --git a/public/tools/thought_lab/labs/kaleidoscope/GLM5.2_kaleidoscope.html b/public/tools/thought_lab/labs/kaleidoscope/GLM5.2_kaleidoscope.html
new file mode 100644
index 0000000..d72b151
--- /dev/null
+++ b/public/tools/thought_lab/labs/kaleidoscope/GLM5.2_kaleidoscope.html
@@ -0,0 +1,2967 @@
+
+
+
+
+老式物理万花筒
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+按住鼠标拖动旋转镜筒|双击画面摇晃万花筒
+已应用设置,重新生成碎片
+
+
+
+
+
\ No newline at end of file
diff --git a/public/tools/thought_lab/labs/kaleidoscope/gemini-code-1786004109825.html b/public/tools/thought_lab/labs/kaleidoscope/gemini-code-1786004109825.html
new file mode 100644
index 0000000..7481e1a
--- /dev/null
+++ b/public/tools/thought_lab/labs/kaleidoscope/gemini-code-1786004109825.html
@@ -0,0 +1,716 @@
+
+
+
+
+
+ 老式物理万花筒模拟器
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/tools/thought_lab/labs/kaleidoscope/gemini-code-1786004115339.html b/public/tools/thought_lab/labs/kaleidoscope/gemini-code-1786004115339.html
new file mode 100644
index 0000000..907820b
--- /dev/null
+++ b/public/tools/thought_lab/labs/kaleidoscope/gemini-code-1786004115339.html
@@ -0,0 +1,734 @@
+
+
+
+
+
+ 老式物理万花筒模拟器
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/tools/thought_lab/labs/kaleidoscope/index.html b/public/tools/thought_lab/labs/kaleidoscope/index.html
new file mode 100644
index 0000000..13d37b6
--- /dev/null
+++ b/public/tools/thought_lab/labs/kaleidoscope/index.html
@@ -0,0 +1,512 @@
+
+
+
+
+
+老式万花筒 自然混合碎片版
+
+
+
+
+
+
+
+
+
+按住鼠标拖动旋转镜筒|双击画面摇晃万花筒
+
+
+
+
diff --git a/public/tools/thought_lab/labs/kaleidoscope/kimi_k2.7_kaleidoscope.html b/public/tools/thought_lab/labs/kaleidoscope/kimi_k2.7_kaleidoscope.html
new file mode 100644
index 0000000..37f9e9f
--- /dev/null
+++ b/public/tools/thought_lab/labs/kaleidoscope/kimi_k2.7_kaleidoscope.html
@@ -0,0 +1,3482 @@
+
+
+
+
+ 老式物理万花筒模拟器
+
+
+
+ 物理万花筒
+
+
+
+
+
+ 预设:
+
+
+
+
+
+
+
+
+
+
+
+ 按住鼠标拖动旋转镜筒|双击画面摇晃万花筒
+
+
+
+
+
\ No newline at end of file
diff --git a/public/tools/thought_lab/labs/minkowski_inkscape/index.html b/public/tools/thought_lab/labs/minkowski_inkscape/index.html
new file mode 100644
index 0000000..6f63bd9
--- /dev/null
+++ b/public/tools/thought_lab/labs/minkowski_inkscape/index.html
@@ -0,0 +1,623 @@
+
+
+
+
+
+
+闵可夫斯基画板 · 世界线 / 洛伦兹 boost / 光锥
+
+
+
+
+ 📐 闵可夫斯基画板
+ 闵可夫斯基空间里的 Inkscape —— 画世界线 · 做洛伦兹变换 · 看光锥(c = 1)
+
+
+
+
+
+
+
+
+
+
+
+
确定清空画布上的所有世界线与光锥吗?此操作不可撤销。
+
+
+
+
+
+
+
+
+
+
diff --git a/src/server/thought_lab/index.js b/src/server/thought_lab/index.js
index db069d7..e83a8b4 100644
--- a/src/server/thought_lab/index.js
+++ b/src/server/thought_lab/index.js
@@ -144,6 +144,28 @@ const bindRoutes = (app) => {
// 4) 其余 /api/thought_lab 接口:默认导航鉴权
app.use('/api/thought_lab', navAuthApi)
+
+ // 5) lab 后端自动绑定:labs//index.js 若导出 bindRoutes 则独立注册
+ // (注册在 API 守卫之后,自动继承导航鉴权;各 lab 互不干扰)
+ bindLabBackends(app)
+}
+
+const bindLabBackends = (app) => {
+ try {
+ if (!fs.existsSync(LABS_DIR)) return
+ const dirs = fs.readdirSync(LABS_DIR, { withFileTypes: true })
+ for (const d of dirs) {
+ if (!d.isDirectory()) continue
+ const entry = path.join(LABS_DIR, d.name, 'index.js')
+ if (!fs.existsSync(entry)) continue
+ try {
+ const mod = require(entry)
+ if (mod && typeof mod.bindRoutes === 'function') mod.bindRoutes(app)
+ } catch (e) {
+ try { console.error(`[thought_lab] 绑定 lab 后端失败: ${d.name} ${String(e.message || e)}`) } catch {}
+ }
+ }
+ } catch {}
}
module.exports = { bindRoutes, listLabs, loadLabConfig }
diff --git a/src/server/thought_lab/labs/kaleidoscope/config.json b/src/server/thought_lab/labs/kaleidoscope/config.json
new file mode 100644
index 0000000..2c29f0d
--- /dev/null
+++ b/src/server/thought_lab/labs/kaleidoscope/config.json
@@ -0,0 +1,17 @@
+{
+ "_comment": "思想实验室 · 万花筒模拟器:Canvas 万花筒(纸片形状/颜色、旋转次数、棱镜镜像与旋扭、镜筒宽度全部可调),配置方案经 sqlite 持久化到本文件夹的 kaleidoscope.db,支持多套方案保存/加载/删除,刷新不丢。本 lab 完全独立;数据库、OSS 等后台逻辑统一放在本文件夹内。",
+ "id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
+ "id": "kaleidoscope",
+ "name_comment": "首页卡片显示名称",
+ "name": "万花筒模拟器",
+ "icon_comment": "首页卡片图标(emoji)",
+ "icon": "🌈",
+ "desc_comment": "一句话简介,作为卡片 tooltip 展示",
+ "desc": "纸片/棱镜/镜筒可调 · 方案 sqlite 持久化",
+ "sort_order_comment": "卡片排序,数值越小越靠前",
+ "sort_order": 70,
+ "enabled_comment": "是否启用(false 时列表接口不返回该 lab)",
+ "enabled": true,
+ "ui_mode_comment": "弹窗交互模式:debug=交互详情弹窗(3s 自动关闭);normal=成功降级 Toast(2s),失败仍弹窗",
+ "ui_mode": "debug"
+}
diff --git a/src/server/thought_lab/labs/kaleidoscope/db.js b/src/server/thought_lab/labs/kaleidoscope/db.js
new file mode 100644
index 0000000..d2ad04b
--- /dev/null
+++ b/src/server/thought_lab/labs/kaleidoscope/db.js
@@ -0,0 +1,108 @@
+// ============================================================
+// kaleidoscope/db.js - 万花筒方案 SQLite 持久化(lab 独立,不共享其他模块)
+// 数据库文件:本文件夹下 kaleidoscope.db(WAL 模式)
+// 表:kaleidoscope_schemes —— 保存多套配置方案(参数 JSON + 随机种子)
+// 时间字段统一北京时间(Asia/Shanghai)
+// ============================================================
+const path = require('path')
+const Database = require('better-sqlite3')
+
+const DB_PATH = path.join(__dirname, 'kaleidoscope.db')
+
+let db = null
+
+// 北京时间格式化:YYYY-MM-DD HH:mm:ss
+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 get = t => (parts.find(p => p.type === t) || {}).value || ''
+ return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`
+}
+
+const getDB = () => {
+ if (db) return db
+ db = new Database(DB_PATH)
+ db.pragma('journal_mode = WAL')
+ db.pragma('synchronous = NORMAL')
+ initSchema(db)
+ return db
+}
+
+const initSchema = (database) => {
+ database.exec(`
+ CREATE TABLE IF NOT EXISTS kaleidoscope_schemes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ params TEXT NOT NULL,
+ seed INTEGER NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ )
+ `)
+ database.exec(`
+ CREATE INDEX IF NOT EXISTS idx_kaleido_schemes_updated_at ON kaleidoscope_schemes(updated_at DESC)
+ `)
+}
+
+const rowToScheme = (row) => {
+ if (!row) return null
+ let params = {}
+ try { params = JSON.parse(row.params) } catch {}
+ return {
+ id: row.id,
+ name: row.name,
+ params,
+ seed: row.seed,
+ created_at: row.created_at,
+ updated_at: row.updated_at
+ }
+}
+
+const listSchemes = () => {
+ const rows = getDB().prepare(`
+ SELECT id, name, params, seed, created_at, updated_at
+ FROM kaleidoscope_schemes
+ ORDER BY updated_at DESC
+ LIMIT 200
+ `).all()
+ return rows.map(rowToScheme)
+}
+
+const getScheme = (id) => {
+ const row = getDB().prepare(`
+ SELECT id, name, params, seed, created_at, updated_at
+ FROM kaleidoscope_schemes WHERE id = ?
+ `).get(id)
+ return rowToScheme(row)
+}
+
+const createScheme = ({ name, params, seed }) => {
+ const now = nowBeijing()
+ const result = getDB().prepare(`
+ INSERT INTO kaleidoscope_schemes (name, params, seed, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?)
+ `).run(name, JSON.stringify(params), seed, now, now)
+ return getScheme(Number(result.lastInsertRowid))
+}
+
+const updateScheme = (id, { name, params, seed }) => {
+ const now = nowBeijing()
+ const result = getDB().prepare(`
+ UPDATE kaleidoscope_schemes
+ SET name = ?, params = ?, seed = ?, updated_at = ?
+ WHERE id = ?
+ `).run(name, JSON.stringify(params), seed, now, id)
+ return result.changes > 0 ? getScheme(id) : null
+}
+
+const deleteScheme = (id) => {
+ const result = getDB().prepare(`DELETE FROM kaleidoscope_schemes WHERE id = ?`).run(id)
+ return result.changes > 0
+}
+
+module.exports = { getDB, listSchemes, getScheme, createScheme, updateScheme, deleteScheme }
diff --git a/src/server/thought_lab/labs/kaleidoscope/index.js b/src/server/thought_lab/labs/kaleidoscope/index.js
new file mode 100644
index 0000000..ae9e3da
--- /dev/null
+++ b/src/server/thought_lab/labs/kaleidoscope/index.js
@@ -0,0 +1,110 @@
+// ============================================================
+// kaleidoscope/index.js - 万花筒模拟器 lab 后端路由
+// 挂载点:/api/thought_lab/kaleidoscope/*
+// 由 thought_lab/index.js 自动扫描绑定(注册在 nav_gate API 守卫之后,继承导航鉴权)
+// 数据库逻辑见同目录 db.js(独立 kaleidoscope.db)
+// ============================================================
+const fs = require('fs')
+const path = require('path')
+const db = require('./db')
+
+const CONFIG_PATH = path.join(__dirname, 'config.json')
+const NAME_MAX = 60
+const PARAMS_MAX_BYTES = 10 * 1024
+
+const readConfig = () => {
+ try {
+ if (fs.existsSync(CONFIG_PATH)) return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'))
+ } catch {}
+ return {}
+}
+
+// 原型污染防护(项目统一风格)
+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
+}
+
+// 校验并规范化入参,非法返回 { error }
+const parsePayload = (body) => {
+ const name = String((body && body.name) || '').trim()
+ if (!name) return { error: '方案名称不能为空' }
+ if (name.length > NAME_MAX) return { error: `名称超过 ${NAME_MAX} 字符` }
+
+ let params = body && body.params
+ if (typeof params !== 'object' || params === null || Array.isArray(params)) return { error: 'params 必须是 JSON 对象' }
+ params = sanitizeObject(params)
+ if (Buffer.byteLength(JSON.stringify(params), 'utf8') > PARAMS_MAX_BYTES) return { error: 'params 超过 10KB 限制' }
+
+ const seed = Number.isFinite(Number(body && body.seed)) ? Math.trunc(Number(body.seed)) : 0
+ return { name, params, seed }
+}
+
+const bindRoutes = (app) => {
+ const B = '/api/thought_lab/kaleidoscope'
+
+ // 元信息:ui_mode 等(前端弹窗双模式依据)
+ app.get(B + '/meta', (req, res) => {
+ const cfg = readConfig()
+ return res.json({ ok: true, ui_mode: String(cfg.ui_mode || 'debug') })
+ })
+
+ // 方案列表
+ app.get(B + '/schemes', (req, res) => {
+ try {
+ return res.json({ ok: true, schemes: db.listSchemes() })
+ } catch (e) {
+ return res.status(500).json({ ok: false, error: 'internal_error' })
+ }
+ })
+
+ // 新建方案
+ app.post(B + '/schemes', (req, res) => {
+ const p = parsePayload(req.body)
+ if (p.error) return res.status(400).json({ ok: false, error: p.error })
+ try {
+ const scheme = db.createScheme(p)
+ return res.json({ ok: true, scheme })
+ } catch (e) {
+ return res.status(500).json({ ok: false, error: 'internal_error' })
+ }
+ })
+
+ // 更新方案
+ app.put(B + '/schemes/:id', (req, res) => {
+ const id = Number(req.params.id)
+ if (!Number.isFinite(id)) return res.status(400).json({ ok: false, error: 'invalid id' })
+ const p = parsePayload(req.body)
+ if (p.error) return res.status(400).json({ ok: false, error: p.error })
+ try {
+ const scheme = db.updateScheme(id, p)
+ if (!scheme) return res.status(404).json({ ok: false, error: 'not_found' })
+ return res.json({ ok: true, scheme })
+ } catch (e) {
+ return res.status(500).json({ ok: false, error: 'internal_error' })
+ }
+ })
+
+ // 删除方案
+ app.delete(B + '/schemes/:id', (req, res) => {
+ const id = Number(req.params.id)
+ if (!Number.isFinite(id)) return res.status(400).json({ ok: false, error: 'invalid id' })
+ try {
+ const ok = db.deleteScheme(id)
+ if (!ok) return res.status(404).json({ ok: false, error: 'not_found' })
+ return res.json({ ok: true })
+ } catch (e) {
+ return res.status(500).json({ ok: false, error: 'internal_error' })
+ }
+ })
+}
+
+module.exports = { bindRoutes }
diff --git a/src/server/thought_lab/labs/kaleidoscope/kaleidoscope.db b/src/server/thought_lab/labs/kaleidoscope/kaleidoscope.db
new file mode 100644
index 0000000..2f7292d
Binary files /dev/null and b/src/server/thought_lab/labs/kaleidoscope/kaleidoscope.db differ
diff --git a/src/server/thought_lab/labs/kaleidoscope/kaleidoscope.db-shm b/src/server/thought_lab/labs/kaleidoscope/kaleidoscope.db-shm
new file mode 100644
index 0000000..283c891
Binary files /dev/null and b/src/server/thought_lab/labs/kaleidoscope/kaleidoscope.db-shm differ
diff --git a/src/server/thought_lab/labs/kaleidoscope/kaleidoscope.db-wal b/src/server/thought_lab/labs/kaleidoscope/kaleidoscope.db-wal
new file mode 100644
index 0000000..d657da4
Binary files /dev/null and b/src/server/thought_lab/labs/kaleidoscope/kaleidoscope.db-wal differ
diff --git a/src/server/thought_lab/labs/minkowski_inkscape/config.json b/src/server/thought_lab/labs/minkowski_inkscape/config.json
new file mode 100644
index 0000000..18c41bc
--- /dev/null
+++ b/src/server/thought_lab/labs/minkowski_inkscape/config.json
@@ -0,0 +1,15 @@
+{
+ "_comment": "思想实验室 · 闵可夫斯基画板:『闵可夫斯基空间里的 Inkscape』——在时空画布上画世界线(钢笔/节点工具,线段按类时/类光/类空实时着色),用快度滑块做洛伦兹变换(主动/被动双视图 + 扫掠动画),点击事件展开光锥并对两事件做因果判定。本 lab 完全独立;未来若需要数据库、OSS 等后台逻辑,统一放在本文件夹内。",
+ "id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
+ "id": "minkowski_inkscape",
+ "name_comment": "首页卡片显示名称",
+ "name": "闵可夫斯基画板",
+ "icon_comment": "首页卡片图标(emoji)",
+ "icon": "📐",
+ "desc_comment": "一句话简介,作为卡片 tooltip 展示",
+ "desc": "世界线绘制 · 洛伦兹 boost · 光锥因果判定",
+ "sort_order_comment": "卡片排序,数值越小越靠前",
+ "sort_order": 60,
+ "enabled_comment": "是否启用(false 时列表接口不返回该 lab)",
+ "enabled": true
+}