add code-space-browser demo and ai-suite post

This commit is contained in:
Fam Zheng 2026-04-10 22:42:47 +01:00
parent 26bc9c64ea
commit 73b2fee0a5
4 changed files with 672 additions and 1 deletions

75
content/posts/ai-suite.md Normal file
View 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 不只是为了跑代码,它给整个系统提供了一个结构化的工作台。
有了 GiteaAI 的工作流有地方落:代码改动变成 commit 和 PR任务追踪用 issue讨论在 comment 里沉淀。这比在聊天框里说一句「帮我改一下那个文件」然后就消失在历史里强太多了。
noc 拿着 Gitea 的 admin token可以创建 repo、提交代码、管理 issue。Webhook 把事件推过来noc 决定要不要介入。这是一个闭环:聊天里说「开个 issue 跟踪一下」AI 立刻在 Gitea 上建好PR 提上来AI 自动 reviewCI 挂了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。需要跑 Pythonuv 管理环境。需要操作 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 reviewLife Loop 能跑定时任务和反思。接下来想做的是让界面之间的联动更自然——聊天里提到的事自动变成 issuePR merge 后自动通知,那种感觉。

View 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 加载,整个东西纯前端,打开就能跑。
这种项目最有意思的地方在于,它让你换一个视角去理解平时天天面对的东西。代码不只是文本,它是有结构、有层次、有「形状」的。

View File

@ -1,6 +1,6 @@
--- ---
title: "你好,世界" title: "你好,世界"
date: 2026-04-10 date: 2026-04-10T12:00:00+01:00
draft: false draft: false
summary: "第一篇,随便聊聊。" summary: "第一篇,随便聊聊。"
--- ---

View 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>