From 876a3b87685ec1b7dff6b6c2f0da4a7d550c1ad2 Mon Sep 17 00:00:00 2001 From: yangxiangyuan Date: Thu, 6 Aug 2026 17:41:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(thought-lab):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E4=B8=87=E8=8A=B1=E7=AD=92=E4=B8=8E=E9=97=B5=E5=8F=AF=E5=A4=AB?= =?UTF-8?q?=E6=96=AF=E5=9F=BA=E7=94=BB=E6=9D=BF=E4=B8=A4=E4=B8=AA=E6=80=9D?= =?UTF-8?q?=E6=83=B3=E5=AE=9E=E9=AA=8C=E5=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增两个独立思想实验室: 1. 万花筒模拟器:支持可调参数物理万花筒效果,配置方案SQLite持久化 2. 闵可夫斯基画板:支持世界线绘制、洛伦兹变换、光锥因果判定 同时添加自动绑定实验室后端的逻辑,以及配套的单元测试与前端页面 --- dev_test_scripts/unit/test_kaleidoscope_db.js | 74 + .../unit/test_kaleidoscope_pure.js | 92 + .../labs/kaleidoscope/DS_v4_pro_index.html | 507 +++ .../kaleidoscope/GLM5.2_kaleidoscope.html | 2967 ++++++++++++++ .../gemini-code-1786004109825.html | 716 ++++ .../gemini-code-1786004115339.html | 734 ++++ .../thought_lab/labs/kaleidoscope/index.html | 512 +++ .../kaleidoscope/kimi_k2.7_kaleidoscope.html | 3482 +++++++++++++++++ .../labs/minkowski_inkscape/index.html | 623 +++ src/server/thought_lab/index.js | 22 + .../thought_lab/labs/kaleidoscope/config.json | 17 + .../thought_lab/labs/kaleidoscope/db.js | 108 + .../thought_lab/labs/kaleidoscope/index.js | 110 + .../labs/kaleidoscope/kaleidoscope.db | Bin 0 -> 4096 bytes .../labs/kaleidoscope/kaleidoscope.db-shm | Bin 0 -> 32768 bytes .../labs/kaleidoscope/kaleidoscope.db-wal | Bin 0 -> 90672 bytes .../labs/minkowski_inkscape/config.json | 15 + 17 files changed, 9979 insertions(+) create mode 100644 dev_test_scripts/unit/test_kaleidoscope_db.js create mode 100644 dev_test_scripts/unit/test_kaleidoscope_pure.js create mode 100644 public/tools/thought_lab/labs/kaleidoscope/DS_v4_pro_index.html create mode 100644 public/tools/thought_lab/labs/kaleidoscope/GLM5.2_kaleidoscope.html create mode 100644 public/tools/thought_lab/labs/kaleidoscope/gemini-code-1786004109825.html create mode 100644 public/tools/thought_lab/labs/kaleidoscope/gemini-code-1786004115339.html create mode 100644 public/tools/thought_lab/labs/kaleidoscope/index.html create mode 100644 public/tools/thought_lab/labs/kaleidoscope/kimi_k2.7_kaleidoscope.html create mode 100644 public/tools/thought_lab/labs/minkowski_inkscape/index.html create mode 100644 src/server/thought_lab/labs/kaleidoscope/config.json create mode 100644 src/server/thought_lab/labs/kaleidoscope/db.js create mode 100644 src/server/thought_lab/labs/kaleidoscope/index.js create mode 100644 src/server/thought_lab/labs/kaleidoscope/kaleidoscope.db create mode 100644 src/server/thought_lab/labs/kaleidoscope/kaleidoscope.db-shm create mode 100644 src/server/thought_lab/labs/kaleidoscope/kaleidoscope.db-wal create mode 100644 src/server/thought_lab/labs/minkowski_inkscape/config.json 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 @@ + + + + +老式物理万花筒 + + + +
+
+ +
120
+
+
+ +
6
+
+
+ +
1
+
+
+ +
0.5
+
+
+ +
1.95
+
+
+ +
0.07
+
+
+ +
0.11
+
+
+ + +
+ +
+
按住鼠标拖动旋转镜筒|双击画面摇晃万花筒
+
已应用设置,重新生成碎片
+ + + + +
\ 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 @@ + + + + + + 老式物理万花筒模拟器 + + + + + +
+
+ + + 120 +
+ +
+ + + 6 +
+ +
+ + + 1.00 +
+ +
+ + + 0.50 +
+ +
+ + + 3.00 +
+ +
+ + + 0.07 +
+ +
+ + + 0.11 +
+ +
+ + +
+ +
+ + +
+
+ + +
+ +
+ + + + + + + \ 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 @@ + + + + + + 老式物理万花筒模拟器 + + + + + +
+
+ + + 120 +
+ +
+ + + 6 +
+ +
+ + + 1.00 +
+ +
+ + + 0.50 +
+ +
+ + + 3.00 +
+ +
+ + + 0.07 +
+ +
+ + + 0.11 +
+ +
+ + +
+ +
+ + +
+
+ + +
+ +
+ + + + + + + \ 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 @@ + + + + + 老式物理万花筒模拟器 + + + +

物理万花筒

+ +
+
+ + + 120 +
+ +
+ + + 6 +
+ +
+ + + 1.00 +
+ +
+ + + 0.50 +
+ +
+ + + 3.00 +
+ +
+ + + 0.07 +
+ +
+ + + 0.11 +
+ + + + + + + + + +
+
+ +
+ 预设: + + + + + +
+ +
+ +
+ +
按住鼠标拖动旋转镜筒|双击画面摇晃万花筒
+ + + + +
\ 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) +
+ +
+
+ 工具 + + + +
+
+ 世界线 + + + +
+
+ 视图 + + +
+
+ boost φ + + φ=0.00 v=0.00c + +
+
+ +
+
+ +
+
+ +
+ + + + + + 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 0000000000000000000000000000000000000000..2f7292d0901b002071ec05a1b6f78ffec474647f GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WY8*Tcq?H7PdmbONfxh!rNF%PGBcUP9eH0E~K%rh@#O%gM{#`(i#_JTD(^p5~XY*N-3LKi0S2weROj ztlrnWyq$hogP*+4(9SJyd#!2tZ+ojLrGfwf0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK%lb%z3+b{ zIy;`h2&_?{pRMd>KZhCRBxh@UNoxXK6BuMW!<=_*Eb|cf2Z7D(WG@Ff%5hFpf9Dbe z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N K0t5(jP2dZbDJ+Tr literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d657da4958c754f6a479301150bb0a8b04ea1d9f GIT binary patch literal 90672 zcmeI*eQXnD9Ki8wiyJS2LKHR=jK?Ly=GtZJ)-ehsl&#=o3>ZZLgG<+*TV;KrcSB&A zWI&>kAg>xsjK&znL?Rgp>Y^A7Mq+kp6!rgg$B#F=jED;Ila)oGZwMxrmS!`<+Pn*$Zpcw5Z4K9fHRDVO3%|2SC((U`F zi#6662c;R8#R~-i1Q0*~0R#|0009ILKmY+rT5HWKC@HaQ(gV$Hsb7<@ z8XUf8t7-Ik{XW_6U0&~#hizLF4MbFjXj!~xt|h;~?Y8WFEY;`MK${v4#k627E_x2O zsu5K)tFvbHZnlM7G*wQOa7eCe@cUN!8s)W(b*sINo8(o#O|o}`e@$J3=w`LA!S5K@ z*4QoC@7w5?8`g;DhWh%!HSs_q5Yf!nX{ws}j^Pc12{oXrp(b(qZrX8sJY>2)wMWI` zpfk?%|G8dpWY*Fx`4v{Dk_p^_V=H9 zA^BoY^0jBZ9g3#5MAWFRDV}PF5(y^~v4rB$6YXl}gRU}Hm9wncSym;NS9;3JJe4k! zDlx3oy#)s21>Rox+iainCp~>#pii>*jqSksb_5VW009ILKmY**5I_I{1P~atK!L^P zo?@2FA$rcrBRG7|SDSzSXpz&%Be2-NmBfRB00IagfB*srAb4wv98l8n0^O3mJJ$!!{v&_@0tg_000IagfB*srAdvF{nLL8kBBQ`r zGCZRod8}t>QbD<^v&7^gfJv2*$%+@~T=B-LXFiNvC0-!sj~NDl00IagfB*srAbGy+#9kD#h^vdP)JN!6{-Bgl#u zc)ji+|B|J7r->IBzsC}Lh5!NxAbiv1|WbRYQ`~oBViP;PR1Q0*~ z0R#|0009ILKmdWU7Z_!{fOX04kE;)C`OJtHkY-BEFEIAMD0_ea0tg_000IagfB*sr zATR+0awT5i!mojgXE$i?8u0?D>jK=5U;-R{3;_WI5I_I{1Q0*~0R#|0;EoX(WxT-b z)9XH&UwL9~dVT?~WcM<^z#Vh!Fi->#KmY**5I_I{1Q0*~ftwfTT4I|l73{hDu8Im> z)%5ZXMQaVjRmG!3!aEd)5{$J+b;aYVawuAOm#BADI+R#E5De=(6;D~|BDX_{2ijC! zH`+wiSX4C9)Ru@E)iuQ<)<(jKL@c3r^hCSrQ1qw5n&{8za*5CF&7x6RX|>ULTvbEH zLV2eoUHbE&Kl@$=m2OXEwMms2u+13smENPZz32N5t(tf8xo=b&|-62wu-9{;ZRHq#^P#|7Hm}`s%Bgh$huFV_I&;J zou51V((?}XOZI-|9UM=;B>RE@0tg_000IagfB*srAb`LP1+J4v@bBtW1cB{ucNUW1 literal 0 HcmV?d00001 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 +}