add code-space-browser demo and ai-suite post
This commit is contained in:
parent
26bc9c64ea
commit
73b2fee0a5
75
content/posts/ai-suite.md
Normal file
75
content/posts/ai-suite.md
Normal file
@ -0,0 +1,75 @@
|
||||
---
|
||||
title: "工程化 AI 助理:一个整合方案"
|
||||
date: 2026-04-10T22:30:00+01:00
|
||||
draft: false
|
||||
summary: "把 AI 聊天、代码协作和后台任务整合到一个套件里,跑在一台 VPS 上。"
|
||||
---
|
||||
|
||||
AI 助手的问题不是不够聪明,而是太碎片化。聊天用一个 bot,代码用 Copilot,自动化用另一套东西。它们之间没有共享状态,每个都是孤岛。
|
||||
|
||||
我想要的是一个整合的方案:同一个 AI 内核,接入不同的工作场景,共享上下文和记忆。于是造了这么个东西。
|
||||
|
||||
## 架构
|
||||
|
||||
一个 Rust binary,跑三个循环:
|
||||
|
||||
```
|
||||
noc (Rust binary)
|
||||
├── Telegram 消息循环 → 聊天
|
||||
├── Axum HTTP server → Gitea webhook
|
||||
└── Life loop → 定时任务、反思、自主行为
|
||||
```
|
||||
|
||||
后端是 OpenAI 兼容的 LLM API(目前用 vLLM 跑 Gemma 4),状态存 SQLite。整个东西部署在一台 4C8G 的 VPS 上,加上 Docker 跑 Gitea、Caddy 做反代和 HTTPS。
|
||||
|
||||
## 三种界面,一个内核
|
||||
|
||||
**聊天**是最基础的。Telegram bot,流式输出,支持工具调用。AI 可以直接跑 shell 命令、执行 Python、调 Gitea API,或者 spawn 一个 Claude Code 子进程去处理复杂任务。
|
||||
|
||||
**Gitea Bot** 是把 AI 拉进代码流程。在 PR 或 issue 里 @bot,它会拿到 diff 或 issue 内容,跑一轮分析,把结果作为 comment 贴回去。这不是一个独立的 review 工具,而是同一个 AI 以另一种方式出现——它知道你们之前在聊天里讨论了什么。
|
||||
|
||||
**Life Loop** 是后台引擎。timer 驱动,跑定时巡检、异步任务,也负责 AI 的自我反思。每次对话结束后,它会回顾交互内容,更新内部状态——不是存对话日志,而是沉淀对当前情况的理解。
|
||||
|
||||
关键是这三个界面共享同一份 persona、memory 和 inner state。不管 AI 在哪个场景出现,它对你的理解是连续的。
|
||||
|
||||
## Gitea 带来的秩序
|
||||
|
||||
自建 Gitea 不只是为了跑代码,它给整个系统提供了一个结构化的工作台。
|
||||
|
||||
有了 Gitea,AI 的工作流有地方落:代码改动变成 commit 和 PR,任务追踪用 issue,讨论在 comment 里沉淀。这比在聊天框里说一句「帮我改一下那个文件」然后就消失在历史里强太多了。
|
||||
|
||||
noc 拿着 Gitea 的 admin token,可以创建 repo、提交代码、管理 issue。Webhook 把事件推过来,noc 决定要不要介入。这是一个闭环:聊天里说「开个 issue 跟踪一下」,AI 立刻在 Gitea 上建好;PR 提上来,AI 自动 review;CI 挂了,AI 主动分析原因贴 comment。
|
||||
|
||||
代码和讨论都有迹可循,不会散落在聊天记录里。
|
||||
|
||||
## 工具体系
|
||||
|
||||
noc 自带一组内置工具:
|
||||
|
||||
- `run_shell` — 执行任意 shell 命令
|
||||
- `run_python` — uv run 执行 Python,支持声明依赖自动安装
|
||||
- `call_gitea_api` — 直接调 Gitea REST API
|
||||
- `spawn_agent` — 启动 Claude Code 子进程处理复杂任务
|
||||
- `update_memory` / `update_inner_state` — AI 管理自己的记忆和状态
|
||||
- `set_timer` — 设定定时任务
|
||||
|
||||
外部工具通过脚本扩展:在 `tools/` 目录放一个实现了 `--schema` 接口的可执行文件就行,noc 每次请求自动发现。
|
||||
|
||||
设计原则是 noc 只做调度和人格层,重活交给专业工具。需要写代码?spawn Claude Code。需要跑 Python?uv 管理环境。需要操作 git?调 Gitea API。不重复造轮子。
|
||||
|
||||
## 部署
|
||||
|
||||
整个 suite 跑在一台 VPS 上:
|
||||
|
||||
- **noc**: systemd user service
|
||||
- **Gitea**: Docker,数据挂载到 `/data/noc/gitea/`
|
||||
- **Caddy**: 系统级 service,自动 HTTPS,按子域名路由
|
||||
|
||||
`make deploy` 从本地编译 + scp 到 VPS + 重启服务,一把梭。
|
||||
|
||||
所有数据在 `/data/noc/` 下面,备份和迁移都简单。
|
||||
|
||||
## 现状
|
||||
|
||||
能用了,但还在早期。聊天和工具调用比较稳定,Gitea Bot 有基础的 @mention 响应和 PR review,Life Loop 能跑定时任务和反思。接下来想做的是让界面之间的联动更自然——聊天里提到的事自动变成 issue,PR merge 后自动通知,那种感觉。
|
||||
|
||||
28
content/posts/code-space-browser.md
Normal file
28
content/posts/code-space-browser.md
Normal file
@ -0,0 +1,28 @@
|
||||
---
|
||||
title: "把代码库变成 3D 空间"
|
||||
date: 2026-04-10T22:00:00+01:00
|
||||
draft: false
|
||||
summary: "用 Three.js 做了个代码结构可视化,可以在浏览器里「走进」一个项目。"
|
||||
---
|
||||
|
||||
写代码的时候经常想,项目结构在脑子里其实是有空间感的——哪些模块挨着,哪个文件最大,什么东西藏得最深。但 IDE 的文件树是扁平的,看不出这些。
|
||||
|
||||
所以试着做了个东西:把项目结构用 treemap 算法排布成 3D 方块,文件大小决定方块面积,目录层级对应高度。文件表面还渲染了(假的)代码,鼠标悬停能看到文件信息。
|
||||
|
||||
[在线体验 →](/demos/code-space-browser.html)
|
||||
|
||||
## 怎么做的
|
||||
|
||||
核心就三件事:
|
||||
|
||||
**Squarified Treemap** — 经典的矩形填充算法,把一组带权重的项目排成接近正方形的矩形。权重是文件行数的对数,这样大文件和小文件的差距不会太夸张。
|
||||
|
||||
**Three.js 渲染** — 每个文件是一个 box,顶面用 Canvas 2D 画出代码纹理(带行号和语法高亮)。目录是半透明平台,一层叠一层。灯光、雾效、边缘发光让整个场景有点赛博朋克的感觉。
|
||||
|
||||
**交互** — 鼠标拖拽旋转,滚轮缩放,右键平移。悬停显示文件信息。甚至留了 WebXR 接口——如果你有 VR 头显,可以直接走进去看。
|
||||
|
||||
## 一些感想
|
||||
|
||||
500 多行代码,一个 HTML 文件,不依赖任何构建工具。Three.js 从 CDN 加载,整个东西纯前端,打开就能跑。
|
||||
|
||||
这种项目最有意思的地方在于,它让你换一个视角去理解平时天天面对的东西。代码不只是文本,它是有结构、有层次、有「形状」的。
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "你好,世界"
|
||||
date: 2026-04-10
|
||||
date: 2026-04-10T12:00:00+01:00
|
||||
draft: false
|
||||
summary: "第一篇,随便聊聊。"
|
||||
---
|
||||
|
||||
568
static/demos/code-space-browser.html
Normal file
568
static/demos/code-space-browser.html
Normal file
@ -0,0 +1,568 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Code Space Browser</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { overflow: hidden; background: #0a0a0f; font-family: 'Courier New', monospace; }
|
||||
canvas { display: block; }
|
||||
#info {
|
||||
position: fixed; top: 16px; left: 16px; color: #8af; font-size: 13px;
|
||||
background: rgba(0,0,0,0.7); padding: 10px 14px; border-radius: 8px;
|
||||
border: 1px solid rgba(100,160,255,0.2); z-index: 10; line-height: 1.6;
|
||||
}
|
||||
#info kbd { background: rgba(100,160,255,0.15); padding: 2px 6px; border-radius: 3px; font-size: 11px; }
|
||||
#tooltip {
|
||||
position: fixed; display: none; color: #cef; font-size: 12px;
|
||||
background: rgba(5,5,20,0.9); padding: 8px 12px; border-radius: 6px;
|
||||
border: 1px solid rgba(100,180,255,0.3); pointer-events: none; z-index: 20;
|
||||
max-width: 300px; white-space: pre-wrap;
|
||||
}
|
||||
#vrButton {
|
||||
position: fixed; bottom: 20px; right: 20px; z-index: 10;
|
||||
background: linear-gradient(135deg, #2a6aff, #0af); color: #fff;
|
||||
border: none; padding: 12px 24px; border-radius: 8px; font-size: 14px;
|
||||
cursor: pointer; font-weight: bold; display: none;
|
||||
}
|
||||
#vrButton:hover { filter: brightness(1.2); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="info">
|
||||
<strong style="color:#5bf">⬡ Code Space Browser</strong><br>
|
||||
<kbd>Scroll</kbd> zoom · <kbd>Drag</kbd> rotate · <kbd>Right-drag</kbd> pan<br>
|
||||
<kbd>Hover</kbd> file info · <kbd>Click</kbd> inspect
|
||||
</div>
|
||||
<div id="tooltip"></div>
|
||||
<button id="vrButton">Enter VR</button>
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
|
||||
// ── Sample Codebase ──
|
||||
const codebase = {
|
||||
name: "my-project", children: [
|
||||
{ name: "src", children: [
|
||||
{ name: "main.ts", lines: 245 },
|
||||
{ name: "app.ts", lines: 380 },
|
||||
{ name: "config.ts", lines: 65 },
|
||||
{ name: "components", children: [
|
||||
{ name: "Header.tsx", lines: 120 },
|
||||
{ name: "Sidebar.tsx", lines: 210 },
|
||||
{ name: "Editor.tsx", lines: 580 },
|
||||
{ name: "FileTree.tsx", lines: 175 },
|
||||
{ name: "StatusBar.tsx", lines: 88 },
|
||||
{ name: "Modal.tsx", lines: 145 },
|
||||
]},
|
||||
{ name: "utils", children: [
|
||||
{ name: "parser.ts", lines: 420 },
|
||||
{ name: "formatter.ts", lines: 190 },
|
||||
{ name: "helpers.ts", lines: 135 },
|
||||
{ name: "logger.ts", lines: 78 },
|
||||
]},
|
||||
{ name: "services", children: [
|
||||
{ name: "api.ts", lines: 310 },
|
||||
{ name: "auth.ts", lines: 225 },
|
||||
{ name: "storage.ts", lines: 160 },
|
||||
{ name: "websocket.ts", lines: 290 },
|
||||
]},
|
||||
{ name: "types", children: [
|
||||
{ name: "index.d.ts", lines: 340 },
|
||||
{ name: "api.d.ts", lines: 180 },
|
||||
{ name: "components.d.ts", lines: 95 },
|
||||
]},
|
||||
]},
|
||||
{ name: "tests", children: [
|
||||
{ name: "unit", children: [
|
||||
{ name: "parser.test.ts", lines: 520 },
|
||||
{ name: "formatter.test.ts", lines: 280 },
|
||||
{ name: "auth.test.ts", lines: 190 },
|
||||
]},
|
||||
{ name: "e2e", children: [
|
||||
{ name: "app.e2e.ts", lines: 380 },
|
||||
{ name: "editor.e2e.ts", lines: 450 },
|
||||
]},
|
||||
]},
|
||||
{ name: "scripts", children: [
|
||||
{ name: "build.sh", lines: 45 },
|
||||
{ name: "deploy.sh", lines: 78 },
|
||||
{ name: "seed.ts", lines: 120 },
|
||||
]},
|
||||
{ name: "package.json", lines: 42 },
|
||||
{ name: "tsconfig.json", lines: 28 },
|
||||
{ name: "README.md", lines: 156 },
|
||||
{ name: "Dockerfile", lines: 35 },
|
||||
]
|
||||
};
|
||||
|
||||
// ── Fake code generator ──
|
||||
const keywords = ['const','let','function','return','if','else','for','import','export','async','await','class','interface','type','from','new','this','try','catch','throw'];
|
||||
const types = ['string','number','boolean','void','Promise','Array','Map','Record','any'];
|
||||
const vars = ['data','result','config','items','ctx','req','res','err','buf','node','key','val','opts','state','props','ref','cb','fn','idx','len'];
|
||||
|
||||
function generateFakeCode(lines) {
|
||||
const out = [];
|
||||
let indent = 0;
|
||||
for (let i = 0; i < lines; i++) {
|
||||
const r = Math.random();
|
||||
if (r < 0.05 && indent > 0) { indent--; out.push(' '.repeat(indent) + '}'); continue; }
|
||||
if (r < 0.1) { out.push(''); continue; }
|
||||
if (r < 0.15) { out.push(' '.repeat(indent) + '// ' + vars[Math.random()*vars.length|0] + ' processing'); continue; }
|
||||
const pre = ' '.repeat(indent);
|
||||
if (r < 0.25) {
|
||||
const kw = Math.random() < 0.5 ? 'function' : 'const';
|
||||
const v = vars[Math.random()*vars.length|0];
|
||||
const t = types[Math.random()*types.length|0];
|
||||
out.push(pre + `${kw} ${v}: ${t} = {`);
|
||||
indent++; continue;
|
||||
}
|
||||
if (r < 0.35) {
|
||||
out.push(pre + `if (${vars[Math.random()*vars.length|0]}) {`);
|
||||
indent++; continue;
|
||||
}
|
||||
const v1 = vars[Math.random()*vars.length|0];
|
||||
const v2 = vars[Math.random()*vars.length|0];
|
||||
out.push(pre + `${keywords[Math.random()*keywords.length|0]} ${v1} = ${v2};`);
|
||||
}
|
||||
while (indent > 0) { indent--; out.push(' '.repeat(indent) + '}'); }
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Squarified Treemap ──
|
||||
function squarify(items, rect) {
|
||||
if (!items.length) return [];
|
||||
const sorted = [...items].sort((a, b) => b.size - a.size);
|
||||
const totalSize = sorted.reduce((s, i) => s + i.size, 0);
|
||||
const results = [];
|
||||
layoutStrip(sorted, rect, totalSize, results);
|
||||
return results;
|
||||
}
|
||||
|
||||
function layoutStrip(items, rect, totalArea, results) {
|
||||
if (items.length === 0) return;
|
||||
if (items.length === 1) {
|
||||
results.push({ ...items[0], rect: { ...rect } });
|
||||
return;
|
||||
}
|
||||
|
||||
const { x, y, w, h } = rect;
|
||||
const vertical = h <= w;
|
||||
const side = vertical ? h : w;
|
||||
|
||||
let strip = [items[0]];
|
||||
let stripArea = items[0].size;
|
||||
let bestRatio = worstRatio(strip, stripArea, side, totalArea);
|
||||
|
||||
let i = 1;
|
||||
for (; i < items.length; i++) {
|
||||
const newStrip = [...strip, items[i]];
|
||||
const newStripArea = stripArea + items[i].size;
|
||||
const newRatio = worstRatio(newStrip, newStripArea, side, totalArea);
|
||||
if (newRatio > bestRatio) break;
|
||||
strip = newStrip;
|
||||
stripArea = newStripArea;
|
||||
bestRatio = newRatio;
|
||||
}
|
||||
|
||||
const stripFraction = stripArea / totalArea;
|
||||
let offset = 0;
|
||||
|
||||
for (const item of strip) {
|
||||
const frac = item.size / stripArea;
|
||||
let r;
|
||||
if (vertical) {
|
||||
const sw = w * stripFraction;
|
||||
r = { x: x, y: y + offset * h, w: sw, h: frac * h };
|
||||
offset += frac;
|
||||
} else {
|
||||
const sh = h * stripFraction;
|
||||
r = { x: x + offset * w, y: y, w: frac * w, h: sh };
|
||||
offset += frac;
|
||||
}
|
||||
results.push({ ...item, rect: r });
|
||||
}
|
||||
|
||||
const remaining = items.slice(i);
|
||||
if (remaining.length > 0) {
|
||||
const remArea = totalArea - stripArea;
|
||||
let newRect;
|
||||
if (vertical) {
|
||||
newRect = { x: x + w * stripFraction, y, w: w * (1 - stripFraction), h };
|
||||
} else {
|
||||
newRect = { x, y: y + h * stripFraction, w, h: h * (1 - stripFraction) };
|
||||
}
|
||||
layoutStrip(remaining, newRect, remArea, results);
|
||||
}
|
||||
}
|
||||
|
||||
function worstRatio(strip, stripArea, side, totalArea) {
|
||||
const stripLen = (stripArea / totalArea) * side;
|
||||
if (stripLen === 0) return Infinity;
|
||||
let worst = 0;
|
||||
for (const item of strip) {
|
||||
const itemLen = (item.size / stripArea) * side; // incorrect, should use other dim
|
||||
const area = (item.size / totalArea) * side * side; // approx
|
||||
// simplified: use fraction-based
|
||||
const frac = item.size / stripArea;
|
||||
const w = stripLen;
|
||||
const h = frac * (side);
|
||||
const ratio = Math.max(w / h, h / w);
|
||||
worst = Math.max(worst, ratio);
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
// ── Color palette ──
|
||||
const dirColors = [0x1a3a5c, 0x1a4c3c, 0x3c1a4c, 0x4c3c1a, 0x1a2c4c, 0x2c1a1a, 0x1a4c4c, 0x3c2a1a];
|
||||
const fileColors = {
|
||||
ts: 0x2255aa, tsx: 0x2266bb, js: 0xccaa22, json: 0x44aa44,
|
||||
md: 0x8866cc, sh: 0x55aa77, d: 0x336699, Dockerfile: 0x2299cc
|
||||
};
|
||||
|
||||
function getFileColor(name) {
|
||||
if (name.includes('.d.ts') || name.includes('.d.')) return fileColors.d;
|
||||
const ext = name.split('.').pop();
|
||||
return fileColors[ext] || 0x3377aa;
|
||||
}
|
||||
|
||||
// ── Scene setup ──
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x06060f);
|
||||
scene.fog = new THREE.FogExp2(0x06060f, 0.015);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||
camera.position.set(0, 18, 25);
|
||||
camera.lookAt(0, 3, 0);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.xr.enabled = true;
|
||||
document.body.appendChild(renderer.domElement);
|
||||
|
||||
// Lights
|
||||
scene.add(new THREE.AmbientLight(0x334466, 0.6));
|
||||
const dirLight = new THREE.DirectionalLight(0x88aaff, 0.8);
|
||||
dirLight.position.set(10, 20, 15);
|
||||
scene.add(dirLight);
|
||||
const pointLight = new THREE.PointLight(0x4488ff, 0.5, 50);
|
||||
pointLight.position.set(0, 10, 0);
|
||||
scene.add(pointLight);
|
||||
|
||||
// Grid helper
|
||||
const gridHelper = new THREE.GridHelper(40, 40, 0x111133, 0x0a0a22);
|
||||
gridHelper.position.y = -0.05;
|
||||
scene.add(gridHelper);
|
||||
|
||||
// ── Build treemap layers ──
|
||||
const LAYER_HEIGHT = 4.0;
|
||||
const LAYER_SIZE = 16;
|
||||
const PADDING = 0.15;
|
||||
const interactables = [];
|
||||
const allMeshes = [];
|
||||
|
||||
function getNodeSize(node) {
|
||||
if (node.lines) return Math.log2(node.lines + 1);
|
||||
if (node.children) return node.children.reduce((s, c) => s + getNodeSize(c), 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
function buildLayer(node, depth, offsetX, offsetZ, parentW, parentH) {
|
||||
if (!node.children) return;
|
||||
|
||||
const items = node.children.map(c => ({
|
||||
...c,
|
||||
size: getNodeSize(c),
|
||||
}));
|
||||
|
||||
const rect = { x: 0, y: 0, w: parentW, h: parentH };
|
||||
const layout = squarify(items, rect);
|
||||
const y = depth * LAYER_HEIGHT;
|
||||
|
||||
// Platform
|
||||
const platGeo = new THREE.BoxGeometry(parentW + 0.3, 0.08, parentH + 0.3);
|
||||
const platMat = new THREE.MeshStandardMaterial({
|
||||
color: dirColors[depth % dirColors.length],
|
||||
transparent: true, opacity: 0.35, roughness: 0.8
|
||||
});
|
||||
const platform = new THREE.Mesh(platGeo, platMat);
|
||||
platform.position.set(offsetX + parentW / 2, y - 0.04, offsetZ + parentH / 2);
|
||||
scene.add(platform);
|
||||
allMeshes.push(platform);
|
||||
|
||||
// Label for this directory
|
||||
const labelCanvas = document.createElement('canvas');
|
||||
labelCanvas.width = 512; labelCanvas.height = 64;
|
||||
const lctx = labelCanvas.getContext('2d');
|
||||
lctx.fillStyle = '#88bbff';
|
||||
lctx.font = 'bold 28px Courier New';
|
||||
lctx.fillText('📁 ' + node.name + '/', 10, 40);
|
||||
const labelTex = new THREE.CanvasTexture(labelCanvas);
|
||||
const labelGeo = new THREE.PlaneGeometry(parentW * 0.6, parentW * 0.6 * 64 / 512);
|
||||
const labelMat = new THREE.MeshBasicMaterial({ map: labelTex, transparent: true, depthWrite: false });
|
||||
const labelMesh = new THREE.Mesh(labelGeo, labelMat);
|
||||
labelMesh.position.set(offsetX + parentW / 2, y + 0.1, offsetZ - 0.5);
|
||||
labelMesh.rotation.x = -Math.PI / 2;
|
||||
scene.add(labelMesh);
|
||||
allMeshes.push(labelMesh);
|
||||
|
||||
for (const item of layout) {
|
||||
const r = item.rect;
|
||||
const px = offsetX + r.x + PADDING / 2;
|
||||
const pz = offsetZ + r.y + PADDING / 2;
|
||||
const pw = r.w - PADDING;
|
||||
const ph = r.h - PADDING;
|
||||
|
||||
if (pw < 0.1 || ph < 0.1) continue;
|
||||
|
||||
if (item.children) {
|
||||
// Directory → recurse
|
||||
buildLayer(item, depth + 1, px, pz, pw, ph);
|
||||
|
||||
// Small marker on current level
|
||||
const mGeo = new THREE.BoxGeometry(pw, 0.15, ph);
|
||||
const mMat = new THREE.MeshStandardMaterial({
|
||||
color: dirColors[(depth + 1) % dirColors.length],
|
||||
transparent: true, opacity: 0.5, roughness: 0.6
|
||||
});
|
||||
const marker = new THREE.Mesh(mGeo, mMat);
|
||||
marker.position.set(px + pw / 2, y + 0.08, pz + ph / 2);
|
||||
scene.add(marker);
|
||||
allMeshes.push(marker);
|
||||
|
||||
// Directory label on marker
|
||||
const dc = document.createElement('canvas');
|
||||
dc.width = 256; dc.height = 48;
|
||||
const dctx = dc.getContext('2d');
|
||||
dctx.fillStyle = '#66aadd';
|
||||
dctx.font = '20px Courier New';
|
||||
dctx.fillText('📂 ' + item.name, 5, 30);
|
||||
const dtex = new THREE.CanvasTexture(dc);
|
||||
const dgeo = new THREE.PlaneGeometry(pw * 0.8, pw * 0.8 * 48 / 256);
|
||||
const dmat = new THREE.MeshBasicMaterial({ map: dtex, transparent: true });
|
||||
const dlbl = new THREE.Mesh(dgeo, dmat);
|
||||
dlbl.position.set(px + pw / 2, y + 0.2, pz + ph / 2);
|
||||
dlbl.rotation.x = -Math.PI / 2;
|
||||
scene.add(dlbl);
|
||||
allMeshes.push(dlbl);
|
||||
|
||||
} else {
|
||||
// File → render code on surface
|
||||
const height = 0.12 + Math.log2(item.lines + 1) * 0.04;
|
||||
const geo = new THREE.BoxGeometry(pw, height, ph);
|
||||
const color = getFileColor(item.name);
|
||||
|
||||
// Code texture
|
||||
const canvas = document.createElement('canvas');
|
||||
const res = 512;
|
||||
canvas.width = res; canvas.height = res;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = '#0c0c18';
|
||||
ctx.fillRect(0, 0, res, res);
|
||||
|
||||
// File name header
|
||||
ctx.fillStyle = '#5599dd';
|
||||
ctx.font = 'bold 14px Courier New';
|
||||
ctx.fillText(item.name, 8, 16);
|
||||
ctx.fillStyle = '#334455';
|
||||
ctx.fillRect(0, 20, res, 1);
|
||||
|
||||
// Fake code lines
|
||||
const codeLines = generateFakeCode(Math.min(item.lines, 40));
|
||||
const fontSize = 9;
|
||||
ctx.font = `${fontSize}px Courier New`;
|
||||
for (let li = 0; li < codeLines.length && li < 50; li++) {
|
||||
const line = codeLines[li];
|
||||
const yy = 30 + li * (fontSize + 2);
|
||||
if (yy > res - 5) break;
|
||||
|
||||
// Line number
|
||||
ctx.fillStyle = '#333355';
|
||||
ctx.fillText(String(li + 1).padStart(3), 4, yy);
|
||||
|
||||
// Syntax coloring
|
||||
const tokens = line.split(/(\s+)/);
|
||||
let xOff = 30;
|
||||
for (const tok of tokens) {
|
||||
if (keywords.includes(tok.trim())) ctx.fillStyle = '#cc77dd';
|
||||
else if (types.includes(tok.trim())) ctx.fillStyle = '#44bbaa';
|
||||
else if (tok.startsWith('//')) ctx.fillStyle = '#446644';
|
||||
else if (tok === '{' || tok === '}') ctx.fillStyle = '#ccaa44';
|
||||
else ctx.fillStyle = '#8899aa';
|
||||
ctx.fillText(tok, xOff, yy);
|
||||
xOff += ctx.measureText(tok).width;
|
||||
}
|
||||
}
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
|
||||
// Top face gets code texture, sides get solid color
|
||||
const materials = [
|
||||
new THREE.MeshStandardMaterial({ color, roughness: 0.7 }),
|
||||
new THREE.MeshStandardMaterial({ color, roughness: 0.7 }),
|
||||
new THREE.MeshStandardMaterial({ map: texture, roughness: 0.5 }),
|
||||
new THREE.MeshStandardMaterial({ color: 0x050510, roughness: 0.9 }),
|
||||
new THREE.MeshStandardMaterial({ color, roughness: 0.7 }),
|
||||
new THREE.MeshStandardMaterial({ color, roughness: 0.7 }),
|
||||
];
|
||||
|
||||
const mesh = new THREE.Mesh(geo, materials);
|
||||
mesh.position.set(px + pw / 2, y + height / 2, pz + ph / 2);
|
||||
scene.add(mesh);
|
||||
allMeshes.push(mesh);
|
||||
|
||||
// Edge glow
|
||||
const edgeGeo = new THREE.EdgesGeometry(geo);
|
||||
const edgeMat = new THREE.LineBasicMaterial({ color: 0x3366aa, transparent: true, opacity: 0.3 });
|
||||
const edges = new THREE.LineSegments(edgeGeo, edgeMat);
|
||||
mesh.add(edges);
|
||||
|
||||
mesh.userData = {
|
||||
type: 'file',
|
||||
name: item.name,
|
||||
lines: item.lines,
|
||||
size: item.size,
|
||||
};
|
||||
interactables.push(mesh);
|
||||
|
||||
// Vertical connection line to platform
|
||||
if (depth > 0) {
|
||||
const lineGeo = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(px + pw / 2, y, pz + ph / 2),
|
||||
new THREE.Vector3(px + pw / 2, y - LAYER_HEIGHT + 0.2, pz + ph / 2),
|
||||
]);
|
||||
const lineMat = new THREE.LineBasicMaterial({ color: 0x223355, transparent: true, opacity: 0.2 });
|
||||
scene.add(new THREE.LineSegments(lineGeo, lineMat));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildLayer(codebase, 0, -LAYER_SIZE / 2, -LAYER_SIZE / 2, LAYER_SIZE, LAYER_SIZE);
|
||||
|
||||
// ── Orbit Controls (manual) ──
|
||||
let isDragging = false, isRightDrag = false;
|
||||
let prevX = 0, prevY = 0;
|
||||
let theta = -0.3, phi = 0.8, radius = 30;
|
||||
let targetX = 0, targetY = 5, targetZ = 0;
|
||||
|
||||
function updateCamera() {
|
||||
camera.position.x = targetX + radius * Math.sin(phi) * Math.sin(theta);
|
||||
camera.position.y = targetY + radius * Math.cos(phi);
|
||||
camera.position.z = targetZ + radius * Math.sin(phi) * Math.cos(theta);
|
||||
camera.lookAt(targetX, targetY, targetZ);
|
||||
}
|
||||
updateCamera();
|
||||
|
||||
renderer.domElement.addEventListener('mousedown', e => {
|
||||
if (e.button === 2) isRightDrag = true;
|
||||
else isDragging = true;
|
||||
prevX = e.clientX; prevY = e.clientY;
|
||||
});
|
||||
renderer.domElement.addEventListener('mousemove', e => {
|
||||
if (isDragging) {
|
||||
theta -= (e.clientX - prevX) * 0.005;
|
||||
phi = Math.max(0.1, Math.min(Math.PI - 0.1, phi - (e.clientY - prevY) * 0.005));
|
||||
prevX = e.clientX; prevY = e.clientY;
|
||||
updateCamera();
|
||||
}
|
||||
if (isRightDrag) {
|
||||
const dx = (e.clientX - prevX) * 0.03;
|
||||
const dy = (e.clientY - prevY) * 0.03;
|
||||
targetX -= dx * Math.cos(theta);
|
||||
targetZ += dx * Math.sin(theta);
|
||||
targetY += dy;
|
||||
prevX = e.clientX; prevY = e.clientY;
|
||||
updateCamera();
|
||||
}
|
||||
});
|
||||
window.addEventListener('mouseup', () => { isDragging = false; isRightDrag = false; });
|
||||
renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
|
||||
renderer.domElement.addEventListener('wheel', e => {
|
||||
radius = Math.max(5, Math.min(80, radius + e.deltaY * 0.02));
|
||||
updateCamera();
|
||||
});
|
||||
|
||||
// ── Hover / Click ──
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const mouse = new THREE.Vector2();
|
||||
const tooltip = document.getElementById('tooltip');
|
||||
let hoveredMesh = null;
|
||||
|
||||
renderer.domElement.addEventListener('mousemove', e => {
|
||||
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
||||
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
const hits = raycaster.intersectObjects(interactables);
|
||||
|
||||
if (hoveredMesh && hoveredMesh !== (hits[0]?.object)) {
|
||||
// un-hover
|
||||
if (hoveredMesh.userData.originalY !== undefined) {
|
||||
hoveredMesh.position.y = hoveredMesh.userData.originalY;
|
||||
}
|
||||
hoveredMesh = null;
|
||||
tooltip.style.display = 'none';
|
||||
}
|
||||
|
||||
if (hits.length > 0) {
|
||||
const obj = hits[0].object;
|
||||
if (obj.userData.type === 'file') {
|
||||
hoveredMesh = obj;
|
||||
if (obj.userData.originalY === undefined) obj.userData.originalY = obj.position.y;
|
||||
obj.position.y = obj.userData.originalY + 0.1;
|
||||
|
||||
tooltip.style.display = 'block';
|
||||
tooltip.style.left = (e.clientX + 15) + 'px';
|
||||
tooltip.style.top = (e.clientY - 10) + 'px';
|
||||
tooltip.textContent = `📄 ${obj.userData.name}\n ${obj.userData.lines} lines\n size: ${obj.userData.size.toFixed(1)}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── WebXR ──
|
||||
if (navigator.xr) {
|
||||
navigator.xr.isSessionSupported('immersive-vr').then(ok => {
|
||||
if (ok) {
|
||||
const btn = document.getElementById('vrButton');
|
||||
btn.style.display = 'block';
|
||||
btn.addEventListener('click', () => {
|
||||
navigator.xr.requestSession('immersive-vr', { optionalFeatures: ['local-floor'] }).then(session => {
|
||||
renderer.xr.setSession(session);
|
||||
btn.style.display = 'none';
|
||||
session.addEventListener('end', () => { btn.style.display = 'block'; });
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Animate ──
|
||||
const clock = new THREE.Clock();
|
||||
renderer.setAnimationLoop(() => {
|
||||
const t = clock.getElapsedTime();
|
||||
pointLight.position.x = Math.sin(t * 0.3) * 8;
|
||||
pointLight.position.z = Math.cos(t * 0.3) * 8;
|
||||
renderer.render(scene, camera);
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user