Initial commit: Neural Trace:观察本地 Qwen3.5-4B 计算过程的极简可视化工作台

This commit is contained in:
WpyQwq
2026-09-19 11:59:24 +08:00
commit 8495e2f33a
15 changed files with 2576 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.venv/
__pycache__/
*.pyc
+47
View File
@@ -0,0 +1,47 @@
# Neural Trace
一个用于观察本地 Qwen3.5-4B 计算过程的极简可视化工作台。
当前版本已完成:
- 默认 3D trace space:32 层节点、DeltaNet 状态路径、Full Attention 连线、残差流和可点击节点
- Qwen3.5 混合架构视图:DeltaNet / 线性注意力、Full Attention、Residual Stream、Logits
- Token 流动、激活场、attention map、输出分布和事件时间轴
- 可播放/暂停/调速/拖动的前向计算模拟器
- `D:\watch\_LLM\_think` 模型目录健康检查
- FastAPI SSE 遥测接口,已准备真实 Transformers forward/backward hooks
- Forward、Backward、Attention、Delta State 四种观测模式
- 可选图片输入:通过 POST SSE 将图像送入 Qwen3.5 Vision Encoder,并显示视觉输入/编码事件
## 启动前端
```powershell
Set-Location 'E:\neural-dialogue-visualizer'
npm install
npm run dev
```
打开 `http://localhost:5173`。
## 启动本地遥测服务
```powershell
Set-Location 'E:\neural-dialogue-visualizer'
py -3.12 -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r server\requirements.txt
$env:MODEL_DIR = 'D:\watch\_LLM\_think'
python -m uvicorn server.main:app --reload --port 8000
```
模型文件齐全后,如需启用真实 hook runner:
```powershell
pip install -r server\requirements-model.txt
```
真实运行会尝试懒加载本地 Transformers 模型,并在层级模块上注册 forward hook 和 backward hook。当前真实 hooks 路线使用 SafeTensors checkpoint;GGUF 适合 llama.cpp 推理,但不能直接提供这里所需的 PyTorch autograd 层级事件。4B 模型的 backward 需要较大的显存;如果量化 checkpoint 不支持 autograd,服务会保留 mock 流并返回明确的 runtime error。
文本 trace 使用 GET `/api/stream?prompt=...&mode=...`;带图片时使用 POST `/api/stream`,请求体为 `{"prompt":"...","mode":"forward","image_data":"data:image/png;base64,..."}`。前端的回形针按钮会自动使用 POST 路径,图片限制为 4 MB。
如果模型仍在下载,服务会返回 `mock` 状态,前端继续显示模拟 trace;文件齐全后会显示 `CHECKPOINT READY`。只有 Transformers hook runner 成功加载后才会显示 `MODEL READY`。界面展示的是可观测的激活、状态、注意力和梯度信号,不把隐藏推理文字冒充成“模型思想”。
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0b0d10" />
<title>Neural Trace</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+827
View File
@@ -0,0 +1,827 @@
{
"name": "neural-dialogue-visualizer",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neural-dialogue-visualizer",
"version": "0.1.0",
"dependencies": {
"@vitejs/plugin-react": "latest",
"lucide-react": "latest",
"react": "latest",
"react-dom": "latest",
"vite": "latest"
}
},
"node_modules/@oxc-project/types": {
"version": "0.147.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz",
"integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz",
"integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz",
"integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz",
"integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz",
"integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz",
"integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz",
"integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz",
"integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz",
"integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz",
"integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz",
"integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz",
"integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz",
"integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz",
"integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz",
"integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz",
"integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
"integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
"babel-plugin-react-compiler": "^1.0.0",
"oxc-transform-react": "^0.145.0",
"vite": "^8.0.0"
},
"peerDependenciesMeta": {
"@rolldown/plugin-babel": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
},
"oxc-transform-react": {
"optional": true
}
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.33.0",
"lightningcss-darwin-arm64": "1.33.0",
"lightningcss-darwin-x64": "1.33.0",
"lightningcss-freebsd-x64": "1.33.0",
"lightningcss-linux-arm-gnueabihf": "1.33.0",
"lightningcss-linux-arm64-gnu": "1.33.0",
"lightningcss-linux-arm64-musl": "1.33.0",
"lightningcss-linux-x64-gnu": "1.33.0",
"lightningcss-linux-x64-musl": "1.33.0",
"lightningcss-win32-arm64-msvc": "1.33.0",
"lightningcss-win32-x64-msvc": "1.33.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
"cpu": [
"arm"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
"cpu": [
"arm64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
"cpu": [
"x64"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lucide-react": {
"version": "1.38.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.38.0.tgz",
"integrity": "sha512-xZCyBd/wiVUDactoCc+42TjL0aB7EBOXsuX+tjz+W/sGzw2KhHpL1NOH3FIaVUcpimvUBpIYfz34Ofj9S5JEzQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.8"
}
},
"node_modules/rolldown": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz",
"integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==",
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.147.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.6",
"@rolldown/binding-android-arm64": "1.2.6",
"@rolldown/binding-darwin-arm64": "1.2.6",
"@rolldown/binding-darwin-x64": "1.2.6",
"@rolldown/binding-freebsd-x64": "1.2.6",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.6",
"@rolldown/binding-linux-arm64-gnu": "1.2.6",
"@rolldown/binding-linux-arm64-musl": "1.2.6",
"@rolldown/binding-linux-ppc64-gnu": "1.2.6",
"@rolldown/binding-linux-s390x-gnu": "1.2.6",
"@rolldown/binding-linux-x64-gnu": "1.2.6",
"@rolldown/binding-linux-x64-musl": "1.2.6",
"@rolldown/binding-openharmony-arm64": "1.2.6",
"@rolldown/binding-win32-arm64-msvc": "1.2.6",
"@rolldown/binding-win32-x64-msvc": "1.2.6"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/vite": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
"postcss": "^8.5.26",
"rolldown": "~1.2.4",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.4.0 || ^0.5.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "neural-dialogue-visualizer",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@vitejs/plugin-react": "latest",
"lucide-react": "latest",
"react": "latest",
"react-dom": "latest",
"vite": "latest"
}
}
+1
View File
@@ -0,0 +1 @@
"""Neural Trace local telemetry server."""
+149
View File
@@ -0,0 +1,149 @@
"""Local telemetry service for Neural Trace.
The service exposes one stable SSE contract for the browser. It uses the
actual local checkpoint when the optional model runtime can load it; otherwise
it emits clearly labelled mock events so the UI remains usable during download.
"""
from __future__ import annotations
import asyncio
import json
import os
from pathlib import Path
from typing import Any, AsyncIterator
from fastapi import FastAPI, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from .model_runtime import ModelRuntime
MODEL_DIR = Path(os.getenv("MODEL_DIR", r"D:\watch\_LLM\_think"))
MODEL_NAME = os.getenv("MODEL_NAME", "Qwen3.5-4B")
runtime = ModelRuntime(MODEL_DIR, MODEL_NAME)
class StreamRequest(BaseModel):
prompt: str = "解释一下量子纠缠"
mode: str = "forward"
image_data: str | None = None
app = FastAPI(title="Neural Trace Telemetry", version="0.2.0")
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:5174",
"http://127.0.0.1:5174",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/api/health")
def health() -> dict[str, Any]:
return {
"connected": runtime.ready,
"downloaded": runtime.checkpoint_ready,
"model": MODEL_NAME,
"modelPath": str(runtime.checkpoint_path or MODEL_DIR) if MODEL_DIR.exists() else "waiting for local model",
"mode": "model" if runtime.ready else "mock",
"runtime": runtime.status,
"error": runtime.error,
"architecture": runtime.architecture,
}
async def mock_trace(prompt: str, mode: str, image_data: str | None = None) -> AsyncIterator[str]:
tokens = list(prompt[:10]) or ["·"]
stages = ["L01", "L04", "L08", "L12", "L16", "L20", "L24", "L28"]
if mode == "backward":
stages = list(reversed(stages))
if image_data:
vision_payload = {
"step": 0,
"kind": "vision_encoder",
"value": 0.72,
"shape": [1, vision_payload_size(image_data), 3],
"source": "mock",
"mode": mode,
}
yield f"data: {json.dumps(vision_payload, ensure_ascii=False)}\n\n"
for step in range(48):
if mode == "backward":
kind = "gradient"
elif mode == "attention":
kind = "attention_output"
elif mode == "state":
kind = "delta_state"
elif step % 16 == 11:
kind = "mtp_logits"
elif step % 12 == 3:
kind = "router_weights"
elif step % 12 == 7:
kind = "expert_mixture"
else:
kind = "full_attention" if step % 4 == 0 else "delta_state"
payload = {
"step": step,
"token": tokens[step % len(tokens)],
"layer": stages[step % len(stages)],
"kind": kind,
"value": round(0.2 + ((step * 17) % 70) / 100, 3),
"source": "mock",
"mode": mode,
}
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
await asyncio.sleep(0.08)
suffix = ",并经过视觉编码分支" if image_data else ""
answer = f"演示输出:已完成对“{prompt}”的可观测计算{suffix}。真实模型接入后,这里会替换为本地 Qwen3.5-4B 的生成结果。"
yield f"data: {json.dumps({'step': 48, 'kind': 'generation', 'text': answer, 'value': 1.0, 'source': 'mock', 'mode': mode}, ensure_ascii=False)}\n\n"
def vision_payload_size(image_data: str) -> int:
return 196
async def runtime_trace(prompt: str, mode: str, image_data: str | None = None) -> AsyncIterator[str]:
events = await asyncio.to_thread(runtime.start_trace, prompt, mode, image_data)
if events is None:
if runtime.error:
yield f"data: {json.dumps({'step': 0, 'kind': 'runtime_error', 'error': runtime.error, 'source': 'model_runtime', 'mode': mode}, ensure_ascii=False)}\n\n"
async for event in mock_trace(prompt, mode, image_data):
yield event
return
while True:
payload = await asyncio.to_thread(events.get)
if payload.get("kind") == "done":
break
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
@app.get("/api/stream")
async def stream(
prompt: str = Query(default="解释一下量子纠缠"),
mode: str = Query(default="forward"),
) -> StreamingResponse:
allowed_modes = {"forward", "backward", "attention", "state"}
safe_mode = mode if mode in allowed_modes else "forward"
return StreamingResponse(
runtime_trace(prompt, safe_mode),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
@app.post("/api/stream")
async def stream_post(request: StreamRequest) -> StreamingResponse:
safe_mode = request.mode if request.mode in {"forward", "backward", "attention", "state"} else "forward"
return StreamingResponse(
runtime_trace(request.prompt.strip() or "解释一下量子纠缠", safe_mode, request.image_data),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
+512
View File
@@ -0,0 +1,512 @@
"""Lazy local model adapter for observable forward and backward traces.
The adapter is deliberately conservative: it summarizes tensors in the
runtime process instead of sending activations or weights to the browser.
Large checkpoints are loaded only after the first stream request and failures
fall back to the mock event contract in ``main.py``.
"""
from __future__ import annotations
import importlib.util
import base64
import json
import queue
import re
import threading
from io import BytesIO
from pathlib import Path
from typing import Any
DEFAULT_ARCHITECTURE = {
"model_type": "qwen3_5",
"num_layers": 32,
"pattern": "8 × (3L + 1A)",
"hidden_size": 2560,
"vocab_size": 248320,
"context_length": 262144,
"intermediate_size": 9216,
"delta_heads_v": 32,
"delta_heads_qk": 16,
"attention_heads": 16,
"attention_kv_heads": 4,
"attention_head_dim": 256,
"rope_dim": 64,
"vision_encoder": True,
"sparse_moe": True,
"mtp": True,
"precision": "BF16 / INT4",
}
class ModelRuntime:
def __init__(self, model_dir: Path, model_name: str) -> None:
self.model_dir = model_dir
self.model_name = model_name
self.status = "idle"
self.error: str | None = None
self.model: Any = None
self.processor: Any = None
self._torch: Any = None
self._hooks: list[Any] = []
self._load_lock = threading.Lock()
self._trace_context = threading.local()
@property
def ready(self) -> bool:
return self.status == "ready" and self.model is not None
@property
def checkpoint_path(self) -> Path | None:
if not self.model_dir.exists():
return None
candidates = [self.model_dir]
candidates.extend(path for path in sorted(self.model_dir.iterdir()) if path.is_dir())
for path in candidates:
has_config = (path / "config.json").exists()
has_weights = any(path.glob("*.safetensors")) or any(path.glob("*.safetensors.index.json"))
if has_config and has_weights:
return path
return None
@property
def checkpoint_ready(self) -> bool:
return self.checkpoint_path is not None
@property
def architecture(self) -> dict[str, Any]:
architecture = dict(DEFAULT_ARCHITECTURE)
checkpoint_path = self.checkpoint_path
if checkpoint_path is None:
return architecture
try:
config = json.loads((checkpoint_path / "config.json").read_text(encoding="utf-8"))
except (OSError, ValueError, UnicodeDecodeError):
return architecture
text_config = config.get("text_config") if isinstance(config.get("text_config"), dict) else config
field_map = {
"num_hidden_layers": "num_layers",
"hidden_size": "hidden_size",
"vocab_size": "vocab_size",
"max_position_embeddings": "context_length",
"intermediate_size": "intermediate_size",
"num_attention_heads": "attention_heads",
"num_key_value_heads": "attention_kv_heads",
"head_dim": "attention_head_dim",
"rope_theta_dim": "rope_dim",
"model_type": "model_type",
}
for source_key, target_key in field_map.items():
value = text_config.get(source_key)
if isinstance(value, (int, float, str)):
architecture[target_key] = value
if not architecture.get("attention_head_dim") and architecture.get("attention_heads"):
architecture["attention_head_dim"] = architecture["hidden_size"] // architecture["attention_heads"]
return architecture
def ensure_loaded(self) -> bool:
if self.ready:
return True
checkpoint_path = self.checkpoint_path
if checkpoint_path is None:
self.status = "downloading"
return False
with self._load_lock:
if self.ready:
return True
self.status = "loading"
self.error = None
try:
import torch
from transformers import AutoModelForCausalLM, AutoProcessor
self._torch = torch
try:
self.processor = AutoProcessor.from_pretrained(checkpoint_path, local_files_only=True, trust_remote_code=True)
except Exception:
from transformers import AutoTokenizer
self.processor = AutoTokenizer.from_pretrained(checkpoint_path, local_files_only=True, trust_remote_code=True)
model_class = AutoModelForCausalLM
image_text_model = getattr(__import__("transformers", fromlist=["AutoModelForImageTextToText"]), "AutoModelForImageTextToText", None)
if image_text_model is not None:
model_class = image_text_model
load_kwargs = {
"local_files_only": True,
"trust_remote_code": True,
"torch_dtype": "auto",
}
if importlib.util.find_spec("accelerate") is not None:
load_kwargs.update(device_map="auto", low_cpu_mem_usage=True)
try:
self.model = model_class.from_pretrained(
checkpoint_path,
**load_kwargs,
)
except Exception:
self.model = AutoModelForCausalLM.from_pretrained(
checkpoint_path,
**load_kwargs,
)
self.model.eval()
self._install_hooks()
self.status = "ready"
return True
except Exception as exc: # pragma: no cover - depends on local GPU/runtime
self.status = "error"
self.error = f"{type(exc).__name__}: {exc}"
self.model = None
return False
def start_trace(self, prompt: str, mode: str, image_data: str | None = None) -> queue.Queue[dict[str, Any]] | None:
if not self.ensure_loaded():
return None
events: queue.Queue[dict[str, Any]] = queue.Queue()
worker = threading.Thread(target=self._trace_worker, args=(prompt, mode, image_data, events), daemon=True)
worker.start()
return events
def _install_hooks(self) -> None:
if self._hooks or self.model is None:
return
seen_layers: set[int] = set()
for name, module in self.model.named_modules():
match = re.search(r"(?:^|\.)(?:language_model\.)?(?:layers|(?:model|transformer|decoder)\.(?:layers|h))\.(\d+)$", name)
if match is not None:
layer_index = int(match.group(1))
if layer_index not in seen_layers:
seen_layers.add(layer_index)
self._hooks.append(module.register_forward_hook(self._make_forward_hook(layer_index)))
self._hooks.append(module.register_full_backward_hook(self._make_backward_hook(layer_index)))
elif re.search(r"(?:^|\.)\b(?:visual|vision_model|vision_tower)\b$", name):
self._hooks.append(module.register_forward_hook(self._make_vision_hook()))
elif re.search(r"(?:^|\.)\b(?:router|routing|gate|experts|expert_gate|mtp)\b$", name, re.IGNORECASE):
kind = "router_weights" if re.search(r"router|routing|gate", name, re.IGNORECASE) else "expert_mixture"
if re.search(r"mtp", name, re.IGNORECASE):
kind = "mtp_logits"
self._hooks.append(module.register_forward_hook(self._make_aux_hook(name, kind)))
elif re.search(r"(?:^|\.)lm_head$", name):
self._hooks.append(module.register_forward_hook(self._make_logits_hook()))
def _make_forward_hook(self, layer_index: int):
def forward_hook(_module: Any, _inputs: Any, output: Any) -> None:
context = getattr(self._trace_context, "queue", None)
if context is None:
return
tensor = self._first_tensor(output)
summary = self._summarize(tensor)
full_attention = (layer_index + 1) % 4 == 0
mode = getattr(self._trace_context, "mode", "forward")
step = getattr(self._trace_context, "step", 0)
self._trace_context.step = step + 1
if mode == "attention":
kind = "attention_output" if full_attention else "delta_context"
elif mode == "state":
kind = "delta_state"
else:
kind = "full_attention" if full_attention else "delta_state"
context.put({
"step": min(step, 511),
"layer": f"L{layer_index + 1:02d}",
"kind": kind,
"mode": mode,
"value": summary["rms"],
"mean": summary["mean"],
"shape": summary["shape"],
"source": "forward_hook",
})
return forward_hook
def _make_vision_hook(self):
def vision_hook(_module: Any, _inputs: Any, output: Any) -> None:
context = getattr(self._trace_context, "queue", None)
if context is None:
return
tensor = self._first_tensor(output)
summary = self._summarize(tensor)
step = getattr(self._trace_context, "step", 0)
self._trace_context.step = step + 1
context.put({
"step": min(step, 511),
"kind": "vision_encoder",
"mode": getattr(self._trace_context, "mode", "forward"),
"value": summary["rms"],
"mean": summary["mean"],
"shape": summary["shape"],
"source": "vision_hook",
})
return vision_hook
def _make_aux_hook(self, module_name: str, kind: str):
def aux_hook(_module: Any, _inputs: Any, output: Any) -> None:
context = getattr(self._trace_context, "queue", None)
if context is None:
return
tensor = self._first_tensor(output)
summary = self._summarize(tensor)
layer_match = re.search(r"layers\.(\d+)", module_name)
layer = f"L{int(layer_match.group(1)) + 1:02d}" if layer_match else "AUX"
step = getattr(self._trace_context, "step", 0)
self._trace_context.step = step + 1
context.put({
"step": min(step, 511),
"layer": layer,
"kind": kind,
"mode": getattr(self._trace_context, "mode", "forward"),
"value": summary["rms"],
"mean": summary["mean"],
"shape": summary["shape"],
"module": module_name,
"source": "aux_hook",
})
return aux_hook
def _make_backward_hook(self, layer_index: int):
def backward_hook(_module: Any, _grad_input: Any, grad_output: Any) -> None:
context = getattr(self._trace_context, "queue", None)
if context is None or getattr(self._trace_context, "mode", "forward") != "backward":
return
tensor = self._first_tensor(grad_output)
summary = self._summarize(tensor)
step = getattr(self._trace_context, "step", 0)
self._trace_context.step = step + 1
context.put({
"step": min(step, 511),
"layer": f"L{layer_index + 1:02d}",
"kind": "gradient",
"value": summary["rms"],
"mean": summary["mean"],
"shape": summary["shape"],
"source": "backward_hook",
})
return backward_hook
def _make_logits_hook(self):
def logits_hook(_module: Any, _inputs: Any, output: Any) -> None:
context = getattr(self._trace_context, "queue", None)
if context is None:
return
tensor = self._first_tensor(output)
if tensor is not None and tensor.ndim >= 2:
tensor = tensor[:, -1, :]
summary = self._summarize(tensor)
step = getattr(self._trace_context, "step", 0)
self._trace_context.step = step + 1
context.put({
"step": min(step, 511),
"layer": "OUT",
"kind": "logits",
"value": summary["rms"],
"mean": summary["mean"],
"shape": summary["shape"],
"source": "logits_hook",
})
return logits_hook
def _trace_worker(self, prompt: str, mode: str, image_data: str | None, events: queue.Queue[dict[str, Any]]) -> None:
self._trace_context.queue = events
self._trace_context.mode = mode
self._trace_context.step = 0
try:
inputs = self._encode(prompt, image_data)
if image_data and "pixel_values" in inputs:
summary = self._summarize(inputs["pixel_values"])
events.put({
"step": 0,
"kind": "vision_input",
"mode": mode,
"value": summary["rms"],
"mean": summary["mean"],
"shape": summary["shape"],
"source": "processor",
})
if mode == "backward":
self._run_backward(inputs, events)
elif mode == "attention":
self._run_attention(inputs, events)
else:
self._run_generation(inputs, events)
except Exception as exc: # pragma: no cover - depends on checkpoint/runtime
events.put({"kind": "runtime_error", "error": f"{type(exc).__name__}: {exc}", "source": "model_runtime"})
finally:
events.put({"kind": "done", "source": "model_runtime"})
self._trace_context.queue = None
def _run_attention(self, inputs: dict[str, Any], events: queue.Queue[dict[str, Any]]) -> None:
torch = self._torch
try:
with torch.inference_mode():
outputs = self.model(
**inputs,
use_cache=False,
output_attentions=True,
return_dict=True,
)
attentions = getattr(outputs, "attentions", None)
emitted = False
if attentions:
for layer_index, attention in enumerate(attentions):
if attention is None or getattr(attention, "ndim", 0) < 4:
continue
size = min(7, int(attention.shape[-1]))
matrix = attention[0, :, -size:, -size:].float().mean(dim=0)
values = matrix.detach().cpu().reshape(-1).tolist()
summary = self._summarize(matrix)
step = getattr(self._trace_context, "step", 0)
self._trace_context.step = step + 1
events.put({
"step": min(step, 511),
"layer": f"L{layer_index + 1:02d}",
"kind": "attention_weight",
"mode": "attention",
"value": summary["rms"],
"mean": summary["mean"],
"shape": list(attention.shape),
"attention": [round(float(value), 6) for value in values],
"attention_shape": [size, size],
"source": "attention_output",
})
emitted = True
if not emitted:
events.put({
"step": min(getattr(self._trace_context, "step", 0), 511),
"kind": "attention_unavailable",
"mode": "attention",
"source": "model_runtime",
})
except Exception:
events.put({
"step": min(getattr(self._trace_context, "step", 0), 511),
"kind": "attention_unavailable",
"mode": "attention",
"source": "model_runtime",
})
self._run_generation(inputs, events)
def _encode(self, prompt: str, image_data: str | None = None) -> dict[str, Any]:
processor = self.processor
rendered_prompt = prompt
image = None
if image_data:
try:
from PIL import Image
encoded_image = image_data.split(",", 1)[-1]
image = Image.open(BytesIO(base64.b64decode(encoded_image))).convert("RGB")
except Exception as exc:
raise RuntimeError(f"invalid image input: {type(exc).__name__}") from exc
apply_chat_template = getattr(processor, "apply_chat_template", None)
if callable(apply_chat_template):
try:
content = [{"type": "image", "image": image}, {"type": "text", "text": prompt}] if image is not None else prompt
rendered_prompt = apply_chat_template(
[{"role": "user", "content": content}],
tokenize=False,
add_generation_prompt=True,
)
except Exception:
rendered_prompt = prompt
processor_kwargs: dict[str, Any] = {"text": rendered_prompt, "return_tensors": "pt", "truncation": True, "max_length": 256}
if image is not None:
processor_kwargs["images"] = image
try:
encoded = processor(**processor_kwargs)
except Exception:
processor_kwargs.pop("truncation", None)
processor_kwargs.pop("max_length", None)
encoded = processor(**processor_kwargs)
device = self._input_device()
return {key: value.to(device) if hasattr(value, "to") else value for key, value in encoded.items()}
def _run_generation(self, inputs: dict[str, Any], events: queue.Queue[dict[str, Any]]) -> None:
torch = self._torch
with torch.inference_mode():
generated = self.model.generate(**inputs, max_new_tokens=32, do_sample=False, use_cache=True)
if hasattr(generated, "sequences"):
generated = generated.sequences
input_length = 0
if "input_ids" in inputs and getattr(inputs["input_ids"], "ndim", 0) >= 2:
input_length = int(inputs["input_ids"].shape[-1])
if getattr(generated, "ndim", 0) >= 2 and input_length:
generated = generated[:, input_length:]
try:
text = self.processor.batch_decode(generated, skip_special_tokens=True)[0]
except Exception:
tokenizer = getattr(self.processor, "tokenizer", None)
try:
text = tokenizer.batch_decode(generated, skip_special_tokens=True)[0] if tokenizer is not None else ""
except Exception:
text = ""
events.put({
"step": min(getattr(self._trace_context, "step", 0), 511),
"kind": "generation",
"token": text[-1:] if text else "",
"text": text,
"value": 1.0,
"source": "model_runtime",
})
def _run_backward(self, inputs: dict[str, Any], events: queue.Queue[dict[str, Any]]) -> None:
torch = self._torch
if "input_ids" not in inputs:
raise RuntimeError("processor did not return input_ids; backward trace requires text tokens")
self.model.zero_grad(set_to_none=True)
labels = inputs["input_ids"].clone()
with torch.enable_grad():
outputs = self.model(**inputs, labels=labels, use_cache=False)
loss = outputs.loss
loss.backward()
events.put({
"step": min(getattr(self._trace_context, "step", 0), 511),
"kind": "loss",
"loss": float(loss.detach().float().item()),
"value": float(loss.detach().float().item()),
"source": "autograd",
})
def _input_device(self):
try:
return self.model.device
except Exception:
for parameter in self.model.parameters():
if parameter.device.type != "meta":
return parameter.device
return self._torch.device("cpu")
def _first_tensor(self, value: Any):
if self._torch is not None and self._torch.is_tensor(value):
return value
if isinstance(value, (tuple, list)):
for item in value:
tensor = self._first_tensor(item)
if tensor is not None:
return tensor
if isinstance(value, dict):
for item in value.values():
tensor = self._first_tensor(item)
if tensor is not None:
return tensor
return None
def _summarize(self, tensor: Any) -> dict[str, Any]:
if tensor is None:
return {"mean": 0.0, "rms": 0.0, "shape": []}
torch = self._torch
sampled = tensor.detach().float().reshape(-1)
if sampled.numel() > 4096:
stride = max(1, sampled.numel() // 4096)
sampled = sampled[::stride]
mean = float(sampled.mean().item())
rms = float(torch.sqrt(torch.mean(sampled * sampled)).item())
return {"mean": round(mean, 6), "rms": round(rms, 6), "shape": list(tensor.shape)}
+4
View File
@@ -0,0 +1,4 @@
# Install this optional set after the Qwen3.5 checkpoint is fully downloaded.
torch>=2.6
transformers>=4.57
accelerate>=1.3
+2
View File
@@ -0,0 +1,2 @@
fastapi>=0.115
uvicorn[standard]>=0.34
+515
View File
@@ -0,0 +1,515 @@
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react'
import {
Activity,
ArrowDown,
BrainCircuit,
ChevronDown,
CircleHelp,
CirclePause,
CirclePlay,
Cpu,
Database,
GitBranch,
Layers3,
MoreHorizontal,
Paperclip,
Pause,
Play,
Radio,
RefreshCw,
Search,
Send,
Settings2,
SlidersHorizontal,
Sparkles,
TerminalSquare,
TimerReset,
Waypoints,
X,
} from 'lucide-react'
const Trace3DCanvas = lazy(() => import('./Trace3DCanvas.jsx'))
const TOKENS = ['请', '解', '释', '量', '子', '纠', '缠']
const STAGES = [
{ id: 'stage-01', index: '01', title: 'L01—04', type: 'hybrid', caption: '3L + 1A' },
{ id: 'stage-02', index: '02', title: 'L05—08', type: 'hybrid', caption: '3L + 1A' },
{ id: 'stage-03', index: '03', title: 'L09—12', type: 'hybrid', caption: '3L + 1A' },
{ id: 'stage-04', index: '04', title: 'L13—16', type: 'hybrid', caption: '3L + 1A' },
{ id: 'stage-05', index: '05', title: 'L17—20', type: 'hybrid', caption: '3L + 1A' },
{ id: 'stage-06', index: '06', title: 'L21—24', type: 'hybrid', caption: '3L + 1A' },
{ id: 'stage-07', index: '07', title: 'L25—28', type: 'hybrid', caption: '3L + 1A' },
{ id: 'stage-08', index: '08', title: 'L29—32', type: 'hybrid', caption: '3L + 1A' },
]
const EVENT_SEED = [
{ time: '00:12.84', text: 'residual stream merged', tone: 'cyan' },
{ time: '00:12.77', text: 'attention pattern stabilized', tone: 'amber' },
{ time: '00:12.68', text: 'token 03 → layer 08', tone: 'cyan' },
{ time: '00:12.51', text: 'delta state updated', tone: 'muted' },
{ time: '00:12.36', text: 'input embedding ready', tone: 'muted' },
]
const DEFAULT_ARCHITECTURE = {
num_layers: 32,
pattern: '8 × (3L + 1A)',
hidden_size: 2560,
vocab_size: 248320,
context_length: 262144,
intermediate_size: 9216,
attention_heads: 16,
attention_kv_heads: 4,
attention_head_dim: 256,
vision_encoder: true,
sparse_moe: true,
mtp: true,
precision: 'BF16 / INT4',
}
const compactNumber = (value) => {
const number = Number(value)
if (!Number.isFinite(number)) return '—'
if (number >= 1000000) return `${(number / 1000000).toFixed(number % 1000000 ? 1 : 0)}M`
if (number >= 100000) return `${Math.round(number / 1000)}K`
return number.toLocaleString('en-US')
}
function IconButton({ label, children, active = false, onClick }) {
return (
<button className={`icon-button ${active ? 'is-active' : ''}`} aria-label={label} title={label} onClick={onClick}>
{children}
</button>
)
}
function StatusDot({ tone = 'green' }) {
return <span className={`status-dot status-${tone}`} aria-hidden="true" />
}
function App() {
const [isPlaying, setIsPlaying] = useState(true)
const [step, setStep] = useState(128)
const [activeToken, setActiveToken] = useState(3)
const [selectedLayer, setSelectedLayer] = useState('L08')
const [speed, setSpeed] = useState(1)
const [prompt, setPrompt] = useState('解释一下量子纠缠')
const [attachment, setAttachment] = useState(null)
const [responseText, setResponseText] = useState('')
const [responseSource, setResponseSource] = useState('demo')
const [attentionValues, setAttentionValues] = useState(null)
const [liveSignal, setLiveSignal] = useState({ kind: 'waiting', value: null, mean: null, shape: null })
const [events, setEvents] = useState(EVENT_SEED)
const [runtime, setRuntime] = useState({ connected: false, model: 'Qwen3.5-4B', modelPath: 'waiting for local model', architecture: DEFAULT_ARCHITECTURE })
const [activeView, setActiveView] = useState('trace')
const [spaceMode, setSpaceMode] = useState('3d')
const [processMode, setProcessMode] = useState('forward')
const [showHistory, setShowHistory] = useState(false)
const [notice, setNotice] = useState('')
const noticeTimer = useRef(null)
const streamAbortRef = useRef(null)
const traceRef = useRef({ step, activeToken, selectedLayer })
traceRef.current = { step, activeToken, selectedLayer }
useEffect(() => {
let mounted = true
const refreshRuntime = () => {
fetch('/api/health')
.then((response) => response.json())
.then((data) => {
if (mounted) setRuntime(data)
})
.catch(() => {})
}
refreshRuntime()
const timer = window.setInterval(refreshRuntime, 3500)
return () => {
mounted = false
window.clearInterval(timer)
}
}, [])
useEffect(() => {
if (!isPlaying) return undefined
const timer = window.setInterval(() => {
setStep((current) => (current >= 512 ? 0 : current + 1))
setActiveToken((current) => (current + 1) % TOKENS.length)
}, Math.max(180, 850 / speed))
return () => window.clearInterval(timer)
}, [isPlaying, speed])
useEffect(() => {
const timer = window.setInterval(() => {
const currentTrace = traceRef.current
setEvents((current) => {
const next = [
{ time: `00:${String(12 + (currentTrace.step % 40)).padStart(2, '0')}.${String(currentTrace.step % 100).padStart(2, '0')}`, text: `${TOKENS[currentTrace.activeToken]} → ${currentTrace.selectedLayer}`, tone: currentTrace.activeToken % 2 ? 'amber' : 'cyan' },
...current,
]
return next.slice(0, 5)
})
}, 1800)
return () => window.clearInterval(timer)
}, [])
useEffect(() => () => streamAbortRef.current?.abort(), [])
const notify = (message) => {
setNotice(message)
window.clearTimeout(noticeTimer.current)
noticeTimer.current = window.setTimeout(() => setNotice(''), 2600)
}
const handleAttachment = (event) => {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) return
if (file.size > 4 * 1024 * 1024) {
notify('图片需小于 4 MB')
return
}
const reader = new FileReader()
reader.onload = () => setAttachment({ name: file.name, dataUrl: String(reader.result) })
reader.readAsDataURL(file)
}
const consumeTraceStream = async (tracePrompt, traceMode, traceAttachment = null) => {
streamAbortRef.current?.abort()
const controller = new AbortController()
streamAbortRef.current = controller
try {
const requestUrl = traceAttachment ? '/api/stream' : `/api/stream?prompt=${encodeURIComponent(tracePrompt)}&mode=${traceMode}`
const requestOptions = traceAttachment ? {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: tracePrompt, mode: traceMode, image_data: traceAttachment.dataUrl }),
} : {}
const response = await fetch(requestUrl, { ...requestOptions, signal: controller.signal })
if (!response.ok || !response.body) return
setIsPlaying(false)
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { value, done } = await reader.read()
buffer += decoder.decode(value || new Uint8Array(), { stream: !done })
const blocks = buffer.split('\n\n')
buffer = blocks.pop() || ''
for (const block of blocks) {
const line = block.split('\n').find((entry) => entry.startsWith('data: '))
if (!line) continue
const payload = JSON.parse(line.slice(6))
const tokenIndex = TOKENS.indexOf(payload.token)
setStep(Math.min(512, payload.step * 11))
setActiveToken(tokenIndex >= 0 ? tokenIndex : payload.step % TOKENS.length)
if (payload.layer) setSelectedLayer(payload.layer)
if (payload.text) {
setResponseText(payload.text)
setResponseSource(payload.source === 'model_runtime' ? 'model' : 'demo')
}
if (payload.attention) setAttentionValues(payload.attention)
if (payload.kind && payload.kind !== 'runtime_error' && payload.kind !== 'generation') {
setLiveSignal({
kind: payload.kind,
value: Number.isFinite(Number(payload.value)) ? Number(payload.value) : null,
mean: Number.isFinite(Number(payload.mean)) ? Number(payload.mean) : null,
shape: Array.isArray(payload.shape) ? payload.shape : null,
})
}
if (payload.kind === 'runtime_error') notify(`真实模型运行错误:${payload.error}`)
const eventText = payload.kind === 'runtime_error'
? `runtime error · ${payload.error}`
: [payload.kind, payload.layer].filter(Boolean).join(' · ')
const eventTone = ['full_attention', 'attention_weight', 'attention_output', 'gradient', 'loss', 'logits', 'runtime_error', 'attention_unavailable', 'vision_input', 'vision_encoder', 'router_weights', 'expert_mixture', 'mtp_logits'].includes(payload.kind) ? 'amber' : 'cyan'
setEvents((current) => [{ time: 'stream', text: eventText, tone: eventTone }, ...current].slice(0, 5))
}
if (done) break
}
notify('遥测流已完成')
} catch (error) {
if (error.name !== 'AbortError') notify('后端未连接,继续使用本地模拟')
}
}
const submitPrompt = (event) => {
event.preventDefault()
if (!prompt.trim()) return
setStep(0)
setActiveToken(0)
setIsPlaying(true)
setResponseText('')
setResponseSource('demo')
setAttentionValues(null)
setLiveSignal({ kind: 'waiting', value: null, mean: null, shape: null })
setEvents((current) => [{ time: '00:00.00', text: `trace started · ${prompt.trim()}`, tone: 'cyan' }, ...current].slice(0, 5))
notify(processMode === 'backward' ? '反向传播观测已开始' : '新一轮计算观测已开始')
void consumeTraceStream(prompt.trim(), processMode, attachment)
}
const progress = (step / 512) * 100
const graphState = useMemo(() => ({
activeStage: Math.floor(step / 64) % STAGES.length,
pulse: (step % 32) / 32,
}), [step])
const selectedLayerNumber = Number(selectedLayer.match(/\d+/)?.[0] || 8)
const selectedNodeDescription = selectedLayer === 'OUT' ? 'logits projection' : selectedLayer === 'AUX' ? 'router / expert telemetry' : selectedLayer.includes('—') ? 'hybrid block · 3L + 1A' : selectedLayerNumber % 4 === 0 ? 'full attention block' : 'DeltaNet block'
const processLabel = { forward: 'FORWARD PASS', backward: 'BACKWARD PASS', attention: 'ATTENTION WEIGHTS', state: 'DELTA STATE' }[processMode]
const architecture = { ...DEFAULT_ARCHITECTURE, ...(runtime.architecture || {}) }
return (
<div className="app-shell">
<header className="topbar">
<div className="brand-block">
<div className="brand-mark"><BrainCircuit size={16} strokeWidth={1.8} /></div>
<span className="brand-name">NEURAL TRACE</span>
<span className="brand-divider" />
<span className="brand-context">LOCAL OBSERVABILITY</span>
</div>
<div className="topbar-actions">
<div className="runtime-status">
<StatusDot tone={runtime.connected ? 'green' : runtime.downloaded ? 'cyan' : 'amber'} />
<span>{runtime.connected ? 'MODEL READY' : runtime.downloaded ? 'CHECKPOINT READY' : 'DEMO TELEMETRY'}</span>
</div>
<div className="model-select"><Cpu size={14} /><span>{runtime.model}</span><ChevronDown size={13} /></div>
<IconButton label="Help"><CircleHelp size={16} /></IconButton>
<IconButton label="Settings"><Settings2 size={16} /></IconButton>
</div>
</header>
<div className="workspace">
<aside className={`sidebar ${showHistory ? 'is-open' : ''}`}>
<div className="sidebar-head">
<span>SESSIONS</span>
<IconButton label="More session options"><MoreHorizontal size={16} /></IconButton>
</div>
<form className="new-trace" onSubmit={submitPrompt}>
<label htmlFor="prompt">Start a trace</label>
<div className="prompt-field">
<input id="prompt" value={prompt} onChange={(event) => setPrompt(event.target.value)} />
<label className="attachment-button" title="Attach image" aria-label="Attach image"><Paperclip size={14} /><input type="file" accept="image/*" onChange={handleAttachment} /></label>
<button type="submit" aria-label="Start trace"><Send size={14} /></button>
</div>
{attachment && <div className="attachment-chip"><span><Paperclip size={12} />{attachment.name}</span><button type="button" onClick={() => setAttachment(null)} aria-label="Remove image"><X size={12} /></button></div>}
</form>
<div className="session-list">
<button className="session-row is-selected" onClick={() => notify('当前 trace 已选中')}>
<div className="session-icon"><Activity size={15} /></div>
<div className="session-copy"><strong>量子纠缠 · trace 04</strong><span>现在 · 128 / 512 steps</span></div>
<span className="session-live"><StatusDot tone="green" /></span>
</button>
<button className="session-row" onClick={() => notify('历史 trace 还原功能即将接入')}>
<div className="session-icon muted"><GitBranch size={15} /></div>
<div className="session-copy"><strong>DeltaNet 状态比较</strong><span>昨天 · 384 steps</span></div>
</button>
<button className="session-row" onClick={() => notify('历史 trace 还原功能即将接入')}>
<div className="session-icon muted"><Waypoints size={15} /></div>
<div className="session-copy"><strong>长上下文流向</strong><span>8 月 30 日 · 1.2k steps</span></div>
</button>
</div>
<div className="sidebar-footer">
<div className="storage-line"><Database size={14} /><span>LOCAL CACHE</span><span className="storage-value">4.06 GB</span></div>
<div className="storage-bar"><span style={{ width: '36%' }} /></div>
<div className="path-note">{runtime.modelPath || 'D:\\watch\\_LLM\\_think'}</div>
</div>
</aside>
<main className="main-stage">
<div className="stage-header">
<div>
<div className="crumb"><span>TRACE</span><ArrowDown size={12} /><span>{processLabel}</span></div>
<h1>Token flow</h1>
</div>
<div className="stage-header-actions">
<div className="view-switcher" role="tablist" aria-label="Trace view">
<button className={activeView === 'trace' && spaceMode === '2d' ? 'is-selected' : ''} onClick={() => { setActiveView('trace'); setSpaceMode('2d') }}>2D Graph</button>
<button className={activeView === 'trace' && spaceMode === '3d' ? 'is-selected' : ''} onClick={() => { setActiveView('trace'); setSpaceMode('3d') }}>3D Space</button>
<button className={activeView === 'weights' ? 'is-selected' : ''} onClick={() => setActiveView('weights')}>Weights</button>
</div>
<IconButton label="Reset trace" onClick={() => { setStep(0); setActiveToken(0); notify('trace 已重置') }}><RefreshCw size={15} /></IconButton>
</div>
</div>
<div className="token-strip">
<span className="strip-label">INPUT TOKENS</span>
<div className="token-list">
{TOKENS.map((token, index) => <button key={token + index} className={`token ${activeToken === index ? 'is-active' : ''}`} onClick={() => setActiveToken(index)}>{token}</button>)}
</div>
<span className="token-count">7 TOKENS</span>
</div>
<div className="process-strip">
<span className="strip-label">PROCESS</span>
<div className="process-switcher" role="tablist" aria-label="Observable compute process">
<button className={processMode === 'forward' ? 'is-selected' : ''} onClick={() => setProcessMode('forward')}>Forward</button>
<button className={processMode === 'backward' ? 'is-selected' : ''} onClick={() => setProcessMode('backward')}>Backward</button>
<button className={processMode === 'attention' ? 'is-selected' : ''} onClick={() => setProcessMode('attention')}>Attention</button>
<button className={processMode === 'state' ? 'is-selected' : ''} onClick={() => setProcessMode('state')}>State</button>
</div>
<span className="process-note">{processMode === 'backward' ? 'gradient / credit assignment' : processMode === 'attention' ? 'token-to-token routing' : processMode === 'state' ? 'recurrent state update' : 'activation / logits'}</span>
</div>
{activeView === 'trace' ? (
spaceMode === '3d' ? (
<Suspense fallback={<div className="three-d-panel is-loading"><RefreshCw size={17} className="spin" /><span>Loading 3D trace space</span></div>}>
<Trace3DCanvas layerCount={Number(architecture.num_layers) || 32} pattern={architecture.pattern} hasImage={Boolean(attachment)} signalKind={liveSignal.kind} step={step} selectedLayer={selectedLayer} setSelectedLayer={setSelectedLayer} activeToken={activeToken} mode={processMode} signalValue={liveSignal.value} />
</Suspense>
) : (
<TraceGraph graphState={graphState} selectedLayer={selectedLayer} setSelectedLayer={setSelectedLayer} activeToken={activeToken} />
)
) : (
<WeightsView onSelect={() => notify('权重检查需要完整精度模型')} />
)}
<div className="bottom-grid">
<div className="activation-panel panel-line">
<div className="panel-heading"><span>{processMode === 'backward' ? 'GRADIENT FIELD' : 'ACTIVATION FIELD'}</span><span className="panel-meta">{selectedLayer} · {processMode === 'backward' ? 'credit signal' : 'hidden state'}</span></div>
<NeuronField step={step} />
</div>
<div className="output-panel panel-line">
<div className="panel-heading"><span>OUTPUT DISTRIBUTION</span><span className="panel-meta">next token</span></div>
<div className="output-row"><span className="output-token">纠</span><div className="output-bar"><span style={{ width: '68%' }} /></div><span className="output-value">0.68</span></div>
<div className="output-row"><span className="output-token">缠</span><div className="output-bar"><span style={{ width: '22%' }} /></div><span className="output-value">0.22</span></div>
<div className="output-row"><span className="output-token">是</span><div className="output-bar"><span style={{ width: '07%' }} /></div><span className="output-value">0.07</span></div>
</div>
</div>
<TraceTransport isPlaying={isPlaying} setIsPlaying={setIsPlaying} speed={speed} setSpeed={setSpeed} progress={progress} step={step} setStep={setStep} processLabel={processLabel} />
</main>
<aside className="inspector">
<div className="inspector-tabs">
<button className="is-selected">INSPECTOR</button>
<button>EVENTS <span className="tab-count">{events.length}</span></button>
</div>
<div className="inspector-scroll">
<section className="inspect-section selected-node">
<div className="section-kicker">SELECTED NODE</div>
<div className="node-title-row"><div className="node-symbol hybrid"><Layers3 size={17} /></div><div><h2>{selectedLayer}</h2><p>{selectedNodeDescription}</p></div><span className="node-live">ACTIVE</span></div>
<div className="metric-grid"><Metric label="HEADS" value={architecture.attention_heads} /><Metric label="KV HEADS" value={architecture.attention_kv_heads} /><Metric label="HEAD DIM" value={architecture.attention_head_dim} /><Metric label="RESIDUAL" value={compactNumber(architecture.hidden_size)} /></div>
<div className="signal-readout"><div><span>LAST EVENT</span><strong>{liveSignal.kind}</strong></div><div><span>RMS</span><strong>{liveSignal.value == null ? '—' : liveSignal.value.toFixed(4)}</strong></div><div><span>MEAN</span><strong>{liveSignal.mean == null ? '—' : liveSignal.mean.toFixed(4)}</strong></div><div><span>SHAPE</span><strong>{liveSignal.shape?.length ? liveSignal.shape.join(' × ') : '—'}</strong></div></div>
</section>
<section className="inspect-section">
<div className="section-heading"><span>ATTENTION MAP</span><span className="section-hint">token × token</span></div>
<AttentionMap activeToken={activeToken} values={attentionValues} />
<div className="legend"><span><i className="legend-swatch cyan" />active path</span><span><i className="legend-swatch gray" />context</span></div>
</section>
<section className="inspect-section">
<div className="section-heading"><span>LIVE EVENTS</span><span className="section-hint">streaming</span></div>
<div className="event-list">
{events.map((event, index) => <div className="event-row" key={`${event.time}-${index}`}><span className={`event-dot ${event.tone}`} /><span className="event-time">{event.time}</span><span className="event-text">{event.text}</span></div>)}
</div>
</section>
<section className="inspect-section response-section">
<div className="section-heading"><span>ASSISTANT OUTPUT</span><span className="section-hint">{responseSource}</span></div>
<div className={`response-copy ${responseText ? 'has-output' : ''}`}>{responseText || '等待生成事件…'}</div>
{!responseText && <div className="response-caret" />}
</section>
<section className="inspect-section architecture-note">
<div className="section-heading"><span>MODEL ARCHITECTURE</span><IconButton label="Architecture info"><CircleHelp size={14} /></IconButton></div>
<div className="architecture-line"><span>Layers</span><strong>{architecture.num_layers}</strong></div>
<div className="architecture-line"><span>Pattern</span><strong>{architecture.pattern}</strong></div>
<div className="architecture-line"><span>Context</span><strong>{compactNumber(architecture.context_length)}</strong></div>
<div className="architecture-line"><span>FFN</span><strong>{compactNumber(architecture.intermediate_size)}</strong></div>
<div className="architecture-line"><span>Vocabulary</span><strong>{compactNumber(architecture.vocab_size)}</strong></div>
<div className="architecture-line"><span>Vision encoder</span><strong>{architecture.vision_encoder ? 'unified' : '—'}</strong></div>
<div className="architecture-line"><span>Sparse MoE</span><strong>{architecture.sparse_moe ? 'router + experts' : '—'}</strong></div>
<div className="architecture-line"><span>MTP head</span><strong>{architecture.mtp ? 'trained' : '—'}</strong></div>
<div className="architecture-line"><span>Precision</span><strong>{architecture.precision}</strong></div>
</section>
</div>
</aside>
</div>
<button className="mobile-history" onClick={() => setShowHistory((current) => !current)} aria-label="Toggle sessions"><Search size={17} /></button>
{notice && <div className="toast"><Radio size={14} />{notice}<button onClick={() => setNotice('')} aria-label="Dismiss"><X size={13} /></button></div>}
</div>
)
}
function TraceGraph({ graphState, selectedLayer, setSelectedLayer, activeToken }) {
const packetOffset = graphState.pulse * 100
const selectedLayerNumber = Number(selectedLayer.match(/\d+/)?.[0] || 0)
return (
<section className="graph-panel">
<div className="graph-toolbar"><div className="graph-legend"><span><i className="legend-swatch cyan" />data flow</span><span><i className="legend-swatch amber" />attention</span><span><i className="legend-swatch gray" />residual</span></div><div className="graph-coordinates">X 04.82&nbsp;&nbsp; Y 08.16&nbsp;&nbsp; Z 00.00</div></div>
<div className="graph-canvas">
<div className="grid-overlay" />
<svg className="flow-svg" viewBox="0 0 1080 390" preserveAspectRatio="none" aria-label="Model architecture data flow">
<defs>
<linearGradient id="cyan-flow" x1="0" x2="1"><stop offset="0" stopColor="#78e4e8" stopOpacity="0.18" /><stop offset="1" stopColor="#78e4e8" stopOpacity="0.8" /></linearGradient>
<linearGradient id="amber-flow" x1="0" x2="1"><stop offset="0" stopColor="#efb56b" stopOpacity="0.12" /><stop offset="1" stopColor="#efb56b" stopOpacity="0.75" /></linearGradient>
</defs>
<path className="flow-line residual" d="M72 194 H1008" />
{STAGES.map((stage, index) => {
const x = 150 + index * 112
const active = graphState.activeStage === index
return <g key={stage.id}>
<path className="flow-line data" d={`M${x - 44} 194 H${x + 44}`} />
{active && <circle className={`flow-packet ${index % 2 ? 'amber' : ''}`} cx={x - 44 + packetOffset * 0.88} cy="194" r="4" />}
<line className="branch-line" x1={x} y1="194" x2={x} y2="124" />
<line className="branch-line" x1={x} y1="194" x2={x} y2="264" />
<circle className={`stage-port ${active ? 'active' : ''}`} cx={x} cy="194" r="7" />
<circle className="branch-port" cx={x} cy="124" r="3" />
<circle className="branch-port" cx={x} cy="264" r="3" />
</g>
})}
<path className="flow-line data" d="M32 194 H108" />
<path className="flow-line data" d="M972 194 H1048" />
<circle className="stage-port input" cx="32" cy="194" r="7" />
<circle className="stage-port output" cx="1048" cy="194" r="7" />
</svg>
<div className="flow-node input-node"><span className="node-index">IN</span><strong>embeddings</strong><small>7 × 2,560</small></div>
{STAGES.map((stage, index) => {
const active = graphState.activeStage === index
const selected = selectedLayer === stage.title || (selectedLayerNumber >= index * 4 + 1 && selectedLayerNumber <= index * 4 + 4)
return <button key={stage.id} className={`flow-node stage-node ${stage.type} ${active ? 'active' : ''} ${selected ? 'selected' : ''}`} style={{ left: `${11.1 + index * 10.37}%` }} onClick={() => setSelectedLayer(stage.title)}>
<span className="node-index">{stage.index}</span><strong>{stage.title}</strong><span className="micro-layers" aria-label="3 DeltaNet layers and 1 full attention layer"><i /><i /><i /><i className="attention-mini" /></span><small>{stage.caption}</small>
</button>
})}
<div className="flow-node output-node"><span className="node-index">OUT</span><strong>logits</strong><small>248,320 dim</small></div>
<div className="graph-annotation annotation-top"><span className="annotation-rule" />gated state update</div>
<div className="graph-annotation annotation-bottom"><span className="annotation-rule amber-rule" />softmax projection</div>
</div>
<div className="graph-footer"><div><span className="graph-footer-label">RESIDUAL STREAM</span><span className="graph-footer-value">hidden state / layer {selectedLayer.replace('L', '')}</span></div><div className="graph-footer-right"><span>ACTIVE TOKEN</span><strong>{String(activeToken + 1).padStart(2, '0')} · {TOKENS[activeToken]}</strong></div></div>
</section>
)
}
function NeuronField({ step }) {
const bars = Array.from({ length: 44 }, (_, index) => ((Math.sin(index * 1.7 + step * 0.035) + 1) / 2) * 0.75 + 0.08)
return <div className="neuron-field" aria-label="Activation field">
{bars.map((value, index) => <span key={index} className={index % 9 === 0 ? 'hot' : ''} style={{ height: `${value * 100}%` }} />)}
</div>
}
function AttentionMap({ activeToken, values }) {
const liveValues = Array.isArray(values) && values.length > 0 ? values.map(Number) : null
const livePeak = liveValues ? Math.max(...liveValues, 0.0001) : 1
return <div className="attention-map">{Array.from({ length: 49 }, (_, index) => {
const row = Math.floor(index / 7)
const column = index % 7
const fallback = Math.max(0.06, 0.12 + Math.sin((row + 1) * (column + 2) + activeToken) * 0.11 + (row === activeToken ? 0.28 : 0))
const liveIntensity = liveValues ? Math.min(0.88, 0.08 + ((liveValues[index % liveValues.length] || 0) / livePeak) * 0.8) : fallback
const focus = row === activeToken || column === activeToken
const intensity = focus ? Math.min(0.96, liveIntensity + 0.12) : liveIntensity
return <span key={index} style={{ backgroundColor: `rgba(120, 228, 232, ${intensity})` }} />
})}</div>
}
function Metric({ label, value }) {
return <div className="metric"><span>{label}</span><strong>{value}</strong></div>
}
function WeightsView({ onSelect }) {
return <section className="weights-panel"><div className="weights-header"><div><div className="crumb"><span>TRACE</span><ArrowDown size={12} /><span>WEIGHTS</span></div><h2>Parameter surface</h2><p>Weight-level inspection is reserved for the full-precision checkpoint.</p></div><button className="outline-button" onClick={onSelect}><SlidersHorizontal size={14} />View requirement</button></div><div className="weights-empty"><Sparkles size={22} /><span>Use BF16 safetensors for faithful gradients</span><small>The current runtime can still expose activation flow and token-level events.</small></div></section>
}
function TraceTransport({ isPlaying, setIsPlaying, speed, setSpeed, progress, step, setStep, processLabel }) {
return <div className="transport"><button className="transport-play" onClick={() => setIsPlaying((current) => !current)} aria-label={isPlaying ? 'Pause trace' : 'Play trace'}>{isPlaying ? <Pause size={15} fill="currentColor" /> : <Play size={15} fill="currentColor" />}</button><div className="transport-meta"><span className="transport-label">{processLabel}</span><strong>Step {step} / 512</strong></div><input className="scrubber" type="range" min="0" max="512" value={step} onChange={(event) => setStep(Number(event.target.value))} style={{ '--progress': `${progress}%` }} aria-label="Trace step" /><div className="speed-control"><TimerReset size={14} /><select value={speed} onChange={(event) => setSpeed(Number(event.target.value))} aria-label="Playback speed"><option value="0.5">0.5×</option><option value="1">1×</option><option value="2">2×</option><option value="4">4×</option></select></div><span className="transport-state"><span className="transport-state-dot" />{isPlaying ? 'LIVE' : 'PAUSED'}</span></div>
}
export default App
+150
View File
@@ -0,0 +1,150 @@
import { useMemo, useRef, useState } from 'react'
const DEFAULT_LAYER_COUNT = 32
const NEURON_COUNT = 6
const layerLabel = (index) => `L${String(index + 1).padStart(2, '0')}`
function layerPosition(index, layerCount = DEFAULT_LAYER_COUNT) {
const progress = index / Math.max(1, layerCount - 1)
return [
(progress - 0.5) * 15.4,
Math.sin(progress * Math.PI * 3.2) * 0.68,
Math.cos(progress * Math.PI * 2.4) * 0.72,
]
}
function projectPoint(point, yaw, pitch, zoom = 1) {
const [x, y, z] = point
const yawCos = Math.cos(yaw)
const yawSin = Math.sin(yaw)
const rotatedX = x * yawCos - z * yawSin
const rotatedZ = x * yawSin + z * yawCos
const pitchCos = Math.cos(pitch)
const pitchSin = Math.sin(pitch)
const rotatedY = y * pitchCos - rotatedZ * pitchSin
const depth = y * pitchSin + rotatedZ * pitchCos
const perspective = 1 / (1 + depth * 0.047)
return {
x: 500 + rotatedX * 47 * perspective * zoom,
y: 230 - rotatedY * 56 * perspective * zoom,
depth,
perspective,
}
}
function pointString(points) {
return points.map((point) => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' ')
}
function makeLayers(step, selectedLayer, yaw, pitch, signalValue, layerCount, zoom) {
const activeIndex = step % layerCount
const liveSignal = Number.isFinite(Number(signalValue)) ? Math.min(1, Math.max(0, Number(signalValue))) : null
return Array.from({ length: layerCount }, (_, index) => {
const label = layerLabel(index)
const fullAttention = (index + 1) % 4 === 0
const base = layerPosition(index, layerCount)
const simulatedEnergy = 0.2 + ((Math.sin(index * 1.74 + step * 0.11) + 1) / 2) * 0.8
const energy = liveSignal == null ? simulatedEnergy : Math.min(1, 0.16 + liveSignal * 0.72 + ((Math.sin(index * 1.74 + step * 0.11) + 1) / 2) * 0.18)
const node = projectPoint(base, yaw, pitch, zoom)
const neurons = Array.from({ length: NEURON_COUNT }, (_, neuronIndex) => {
const angle = (neuronIndex / NEURON_COUNT) * Math.PI * 2
const radius = 0.26 + energy * 0.05
return projectPoint([base[0] + Math.cos(angle) * radius, base[1] + Math.sin(angle) * radius, base[2] + Math.sin(angle * 1.8) * 0.08], yaw, pitch, zoom)
})
return { index, label, fullAttention, energy, node, neurons, active: index === activeIndex, selected: selectedLayer === label }
})
}
function FlowPacket({ step, yaw, pitch, mode, layerCount, zoom }) {
const reverse = mode === 'backward'
const currentIndex = reverse ? layerCount - 1 - (step % layerCount) : step % layerCount
const nextIndex = reverse ? Math.max(0, currentIndex - 1) : Math.min(layerCount - 1, currentIndex + 1)
const progress = (step % 16) / 16
const current = layerPosition(currentIndex, layerCount)
const next = layerPosition(nextIndex, layerCount)
const point = current.map((value, index) => value + (next[index] - value) * progress)
const projected = projectPoint([point[0], point[1] + 0.14, point[2]], yaw, pitch, zoom)
return <circle cx={projected.x} cy={projected.y} r={4.4 * projected.perspective} fill={reverse ? '#efb56b' : '#78e4e8'} className="svg-packet" />
}
function ProjectionGrid() {
return <g className="projection-grid">
{Array.from({ length: 13 }, (_, index) => {
const x = 40 + index * 77
return <line key={`v-${index}`} x1={x} y1="58" x2={x} y2="407" />
})}
{Array.from({ length: 7 }, (_, index) => {
const y = 70 + index * 54
return <line key={`h-${index}`} x1="30" y1={y} x2="970" y2={y} />
})}
</g>
}
export default function Trace3DCanvas({ layerCount = DEFAULT_LAYER_COUNT, pattern = '8 × (3L + 1A)', hasImage = false, signalKind = 'waiting', step, selectedLayer, setSelectedLayer, activeToken, mode = 'forward', signalValue = null }) {
const [orbit, setOrbit] = useState({ yaw: -0.12, pitch: 0.13 })
const [zoom, setZoom] = useState(1)
const dragRef = useRef(null)
const safeLayerCount = Math.max(1, Number(layerCount) || DEFAULT_LAYER_COUNT)
const layers = useMemo(() => makeLayers(step, selectedLayer, orbit.yaw, orbit.pitch, signalValue, safeLayerCount, zoom), [orbit.pitch, orbit.yaw, safeLayerCount, selectedLayer, signalValue, step, zoom])
const sortedLayers = useMemo(() => [...layers].sort((a, b) => a.node.depth - b.node.depth), [layers])
const residualPoints = useMemo(() => layers.map((layer) => layer.node), [layers])
const attentionLinks = useMemo(() => layers.filter((layer) => layer.fullAttention).map((layer) => {
const next = layers[Math.min(layers.length - 1, layer.index + 1)]
return { id: layer.label, points: [layer.node, next.node] }
}), [layers])
const auxLayer = layers.find((layer) => layer.selected) || layers[0]
const auxLabel = signalKind === 'router_weights' ? 'ROUTER' : signalKind === 'expert_mixture' ? 'EXPERT MIX' : signalKind === 'mtp_logits' ? 'MTP' : ''
const handlePointerDown = (event) => {
event.currentTarget.setPointerCapture(event.pointerId)
dragRef.current = { x: event.clientX, y: event.clientY, ...orbit }
}
const handlePointerMove = (event) => {
if (!dragRef.current) return
setOrbit({
yaw: dragRef.current.yaw + (event.clientX - dragRef.current.x) * 0.007,
pitch: Math.max(-0.45, Math.min(0.45, dragRef.current.pitch + (event.clientY - dragRef.current.y) * 0.004)),
})
}
const stopDragging = () => { dragRef.current = null }
const handleWheel = (event) => {
event.preventDefault()
setZoom((current) => Math.max(0.72, Math.min(1.55, current - event.deltaY * 0.001)))
}
return <section className="three-d-panel">
<div className="three-d-toolbar">
<div className="three-d-title"><span className="three-d-mark" /><div><strong>3D TRACE SPACE</strong><small>observable compute · step {step} / 512</small></div></div>
<div className="three-d-toolbar-meta"><span><i className="legend-swatch cyan" />DeltaNet state</span><span><i className="legend-swatch amber" />full attention</span>{hasImage && <span><i className="legend-swatch amber" />vision input</span>}<span>token {String(activeToken + 1).padStart(2, '0')}</span></div>
</div>
<div className="three-d-viewport svg-viewport" onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={stopDragging} onPointerCancel={stopDragging} onPointerLeave={stopDragging} onWheel={handleWheel}>
<svg viewBox="0 0 1000 460" role="img" aria-label="3D projected model architecture">
<defs>
<filter id="node-glow" x="-100%" y="-100%" width="300%" height="300%"><feGaussianBlur stdDeviation="3" result="blur" /><feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge></filter>
</defs>
<rect width="1000" height="460" fill="#0b0d10" />
<ProjectionGrid />
{hasImage && <g className="svg-vision-branch"><line x1="76" y1="365" x2={layers[0].node.x} y2={layers[0].node.y} /><circle cx="76" cy="365" r="6" /><text x="58" y="386">IMAGE</text></g>}
<polyline points={pointString(residualPoints)} className={`svg-residual ${mode === 'state' ? 'is-emphasis' : ''} ${mode === 'backward' ? 'is-backward' : ''}`} />
{attentionLinks.map((link) => <line key={link.id} x1={link.points[0].x} y1={link.points[0].y} x2={link.points[1].x} y2={link.points[1].y} className={`svg-attention ${mode === 'attention' ? 'is-emphasis' : ''}`} />)}
{auxLabel && auxLayer && <g className="svg-aux-branch"><line x1={auxLayer.node.x} y1={auxLayer.node.y} x2={auxLayer.node.x + 58} y2={auxLayer.node.y - 34} /><circle cx={auxLayer.node.x + 58} cy={auxLayer.node.y - 34} r="4" /><text x={auxLayer.node.x + 66} y={auxLayer.node.y - 31}>{auxLabel}</text></g>}
{sortedLayers.map((layer) => <g key={layer.label} role="button" tabIndex="0" aria-label={`Inspect ${layer.label}`} className={`svg-layer ${layer.selected ? 'is-selected' : ''} ${layer.active ? 'is-active' : ''}`} onClick={(event) => { event.stopPropagation(); setSelectedLayer(layer.label) }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setSelectedLayer(layer.label) } }}>
{layer.neurons.map((neuron, index) => <circle key={index} cx={neuron.x} cy={neuron.y} r={layer.active ? 2.5 : 1.8} fill={layer.fullAttention ? '#efb56b' : '#78e4e8'} opacity={(0.18 + layer.energy * 0.45) * neuron.perspective} />)}
{layer.selected && <circle cx={layer.node.x} cy={layer.node.y} r={12 * layer.node.perspective} className="svg-selection" />}
<circle cx={layer.node.x} cy={layer.node.y} r={(layer.active ? 7.6 : 5.2) * layer.node.perspective} fill={layer.fullAttention ? '#efb56b' : '#78e4e8'} opacity={0.98} filter={layer.active ? 'url(#node-glow)' : undefined} />
<text x={layer.node.x} y={layer.node.y - 14 * layer.node.perspective} className="svg-layer-label" opacity={0.45 + layer.node.perspective * 0.55}>{layer.label}</text>
</g>)}
<FlowPacket step={step} yaw={orbit.yaw} pitch={orbit.pitch} mode={mode} layerCount={safeLayerCount} zoom={zoom} />
<g className="svg-axes"><line x1="48" y1="416" x2="127" y2="416" /><line x1="48" y1="416" x2="48" y2="337" /><text x="132" y="420">X</text><text x="43" y="330">Y</text><text x="35" y="438">Z / depth</text></g>
</svg>
<div className="three-d-axis axis-x">RESIDUAL STREAM <span>→</span></div>
<div className="three-d-axis axis-y">HIDDEN STATE <span>↕</span></div>
<div className="three-d-readout"><span>ACTIVE LAYER</span><strong>{selectedLayer}</strong><small>click a node to inspect</small></div>
<div className="three-d-hint">DRAG TO ORBIT&nbsp;&nbsp;·&nbsp;&nbsp;SCROLL TO ZOOM</div>
</div>
<div className="three-d-footer"><span>{safeLayerCount} LAYERS · {pattern}</span><span>{hasImage ? 'TELEMETRY: VISION / ACTIVATION / STATE / LOGITS' : 'TELEMETRY: ACTIVATION / STATE / LOGITS'}</span><span>PRECISION: BF16 / INT4</span></div>
</section>
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './styles.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+311
View File
@@ -0,0 +1,311 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap');
:root {
color-scheme: dark;
font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
--bg: #0b0d10;
--surface: #11151a;
--surface-2: #151a20;
--surface-3: #1a2027;
--border: #242b33;
--border-bright: #303a44;
--text: #e6edf0;
--muted: #7b8790;
--muted-2: #56616a;
--cyan: #78e4e8;
--amber: #efb56b;
--green: #8dd8ae;
--mono: 'DM Mono', 'Cascadia Code', monospace;
}
* { box-sizing: border-box; }
html, body, #root { min-width: 320px; min-height: 100%; margin: 0; }
body { background: var(--bg); color: var(--text); }
button, input, select { font: inherit; }
button { color: inherit; }
.app-shell { min-height: 100vh; background: var(--bg); overflow: hidden; }
.topbar { height: 58px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; padding: 0 22px; background: rgba(11,13,16,.96); }
.brand-block, .topbar-actions, .runtime-status, .model-select, .stage-header-actions, .view-switcher, .crumb, .token-strip, .token-list, .graph-toolbar, .graph-legend, .graph-coordinates, .graph-footer, .graph-footer-right, .panel-heading, .node-title-row, .section-heading, .legend, .storage-line, .transport, .transport-meta, .speed-control { display: flex; align-items: center; }
.brand-block { gap: 10px; }
.brand-mark { display: grid; place-items: center; width: 26px; height: 26px; color: var(--cyan); border: 1px solid rgba(120,228,232,.4); border-radius: 6px; }
.brand-name { font-family: var(--mono); font-size: 12px; letter-spacing: .12em; color: #f2f6f6; }
.brand-divider { height: 14px; width: 1px; background: var(--border-bright); margin: 0 3px; }
.brand-context, .runtime-status, .model-select, .graph-coordinates, .strip-label, .token-count, .section-kicker, .section-heading, .graph-footer-label, .transport-label, .storage-line, .path-note { font-family: var(--mono); font-size: 10px; letter-spacing: .08em; }
.brand-context { color: var(--muted-2); }
.topbar-actions { gap: 16px; }
.runtime-status { gap: 8px; color: var(--muted); }
.status-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.status-green { background: var(--green); box-shadow: 0 0 0 3px rgba(141,216,174,.08); }
.status-amber { background: var(--amber); box-shadow: 0 0 0 3px rgba(239,181,107,.08); }
.model-select { gap: 8px; padding: 7px 10px; border: 1px solid var(--border); background: var(--surface); color: #b9c4c8; font-size: 12px; }
.model-select svg:first-child { color: var(--cyan); }
.icon-button { display: grid; place-items: center; width: 29px; height: 29px; border: 0; background: transparent; color: var(--muted); cursor: pointer; transition: color .18s ease, background .18s ease; }
.icon-button:hover, .icon-button.is-active { color: var(--text); background: var(--surface-3); }
.workspace { min-height: calc(100vh - 58px); display: grid; grid-template-columns: 248px minmax(560px, 1fr) 298px; }
.sidebar, .inspector { background: var(--surface); }
.sidebar { border-right: 1px solid var(--border); display: flex; flex-direction: column; min-width: 0; }
.sidebar-head { display: flex; justify-content: space-between; align-items: center; padding: 20px 18px 12px; font-family: var(--mono); font-size: 10px; letter-spacing: .12em; color: var(--muted-2); }
.new-trace { padding: 8px 15px 22px; }
.new-trace label { display: block; color: var(--muted); font-size: 11px; margin-bottom: 8px; }
.prompt-field { display: flex; border: 1px solid var(--border); background: #0d1014; }
.prompt-field:focus-within { border-color: rgba(120,228,232,.5); }
.prompt-field input { flex: 1; min-width: 0; padding: 10px; color: var(--text); outline: none; border: 0; background: transparent; font-size: 12px; }
.attachment-button { position: relative; display: grid; place-items: center; width: 30px; color: var(--muted-2); cursor: pointer; }
.attachment-button:hover { color: var(--cyan); }
.attachment-button input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
.prompt-field button { width: 34px; border: 0; border-left: 1px solid var(--border); background: transparent; color: var(--cyan); cursor: pointer; }
.attachment-chip { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 7px; padding: 6px 8px; border: 1px solid rgba(120,228,232,.25); background: #0d1719; color: #a9d8da; font-size: 10px; }
.attachment-chip span { display: inline-flex; align-items: center; gap: 6px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.attachment-chip button { display: grid; place-items: center; padding: 0; border: 0; color: var(--muted-2); background: transparent; cursor: pointer; }
.attachment-chip button:hover { color: var(--cyan); }
.session-list { display: grid; gap: 2px; padding: 0 9px; }
.session-row { min-width: 0; display: flex; align-items: center; gap: 10px; padding: 10px 9px; border: 1px solid transparent; background: transparent; text-align: left; cursor: pointer; }
.session-row:hover, .session-row.is-selected { background: var(--surface-2); border-color: var(--border); }
.session-icon { width: 27px; height: 27px; display: grid; place-items: center; color: var(--cyan); border: 1px solid rgba(120,228,232,.23); }
.session-icon.muted { color: var(--muted-2); border-color: var(--border); }
.session-copy { min-width: 0; display: grid; gap: 4px; }
.session-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; font-weight: 500; }
.session-copy span { color: var(--muted-2); font-size: 10px; }
.session-live { margin-left: auto; align-self: flex-start; padding-top: 5px; }
.sidebar-footer { margin-top: auto; padding: 18px; border-top: 1px solid var(--border); }
.storage-line { gap: 8px; color: var(--muted); }
.storage-line svg { color: var(--muted-2); }
.storage-value { margin-left: auto; color: #bac5c8; }
.storage-bar { height: 3px; margin: 12px 0 9px; background: var(--surface-3); }
.storage-bar span { display: block; height: 100%; background: var(--cyan); }
.path-note { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted-2); font-size: 9px; letter-spacing: 0; }
.main-stage { min-width: 0; padding: 26px 28px 22px; background: #0c0f12; }
.stage-header { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 22px; }
.crumb { gap: 7px; color: var(--muted-2); font-family: var(--mono); font-size: 9px; letter-spacing: .1em; }
.stage-header h1 { margin: 8px 0 0; font-size: 23px; font-weight: 500; letter-spacing: -.03em; }
.stage-header-actions { gap: 10px; }
.view-switcher { border: 1px solid var(--border); padding: 3px; background: var(--surface); }
.view-switcher button { padding: 6px 10px; border: 0; background: transparent; color: var(--muted); font-size: 11px; cursor: pointer; }
.view-switcher button.is-selected { color: var(--text); background: var(--surface-3); }
.token-strip { gap: 18px; height: 42px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.strip-label, .token-count { color: var(--muted-2); white-space: nowrap; }
.token-list { gap: 5px; }
.token { min-width: 25px; height: 25px; border: 1px solid var(--border); background: var(--surface); color: #aab6ba; font-family: var(--mono); font-size: 12px; cursor: pointer; }
.token:hover, .token.is-active { color: var(--cyan); border-color: rgba(120,228,232,.65); background: rgba(120,228,232,.08); }
.token-count { margin-left: auto; }
.process-strip { display: flex; align-items: center; gap: 13px; min-height: 40px; border-bottom: 1px solid var(--border); }
.process-switcher { display: flex; align-items: center; gap: 3px; }
.process-switcher button { padding: 5px 8px; border: 1px solid transparent; background: transparent; color: var(--muted-2); font-family: var(--mono); font-size: 9px; cursor: pointer; }
.process-switcher button:hover, .process-switcher button.is-selected { border-color: var(--border-bright); color: var(--text); background: var(--surface-2); }
.process-switcher button.is-selected { color: var(--cyan); }
.process-note { margin-left: auto; color: var(--muted-2); font-family: var(--mono); font-size: 9px; }
.graph-panel { margin-top: 18px; border: 1px solid var(--border); background: var(--surface); }
.graph-toolbar { justify-content: space-between; padding: 11px 14px; border-bottom: 1px solid var(--border); }
.graph-legend { gap: 14px; color: var(--muted); font-size: 10px; }
.graph-legend span, .legend span { display: inline-flex; gap: 6px; align-items: center; }
.legend-swatch { display: inline-block; width: 8px; height: 2px; background: var(--muted-2); }
.legend-swatch.cyan { background: var(--cyan); }
.legend-swatch.amber { background: var(--amber); }
.graph-coordinates { color: var(--muted-2); }
.graph-canvas { position: relative; height: 390px; overflow: hidden; }
.grid-overlay { position: absolute; inset: 0; opacity: .4; background-image: linear-gradient(rgba(122,139,150,.07) 1px, transparent 1px), linear-gradient(90deg, rgba(122,139,150,.07) 1px, transparent 1px); background-size: 34px 34px; mask-image: linear-gradient(to bottom, transparent, black 15%, black 85%, transparent); }
.flow-svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.flow-line { fill: none; stroke-width: 1.2; vector-effect: non-scaling-stroke; }
.flow-line.residual { stroke: #3a444c; stroke-dasharray: 2 6; }
.flow-line.data { stroke: url(#cyan-flow); }
.flow-line.attention { stroke: url(#amber-flow); }
.branch-line { stroke: #2b343b; stroke-width: 1; stroke-dasharray: 2 4; }
.stage-port, .branch-port { fill: #0c0f12; stroke: #53616a; stroke-width: 1.2; }
.stage-port.active { fill: var(--cyan); stroke: var(--cyan); filter: drop-shadow(0 0 5px rgba(120,228,232,.6)); }
.stage-port.input, .stage-port.output { fill: #0c0f12; stroke: var(--cyan); }
.flow-packet { fill: var(--cyan); filter: drop-shadow(0 0 4px rgba(120,228,232,.7)); }
.flow-packet.amber { fill: var(--amber); filter: drop-shadow(0 0 4px rgba(239,181,107,.7)); }
.flow-node { position: absolute; top: 50%; transform: translate(-50%, -50%); display: grid; justify-items: center; gap: 3px; min-width: 82px; padding: 9px 6px; border: 1px solid var(--border); background: rgba(17,21,26,.92); text-align: center; z-index: 1; }
.flow-node strong { font-size: 11px; font-weight: 500; color: #d1dcdf; }
.flow-node small { font-family: var(--mono); font-size: 8px; color: var(--muted-2); white-space: nowrap; }
.flow-node .node-index { font-family: var(--mono); color: var(--muted-2); font-size: 8px; }
.input-node { left: 3%; border-color: rgba(120,228,232,.36); }
.output-node { left: 97%; border-color: rgba(239,181,107,.36); }
.stage-node { cursor: pointer; }
.stage-node:hover, .stage-node.selected, .stage-node.active { border-color: rgba(120,228,232,.72); background: #152024; }
.stage-node.hybrid.active, .stage-node.hybrid.selected { border-color: rgba(120,228,232,.72); background: #152024; }
.stage-node.hybrid .node-index { color: var(--cyan); }
.micro-layers { display: flex; gap: 3px; height: 4px; align-items: center; }
.micro-layers i { display: block; width: 9px; height: 2px; background: rgba(120,228,232,.58); }
.micro-layers i.attention-mini { background: var(--amber); }
.graph-annotation { position: absolute; font-family: var(--mono); color: var(--muted-2); font-size: 9px; display: flex; align-items: center; gap: 7px; }
.annotation-top { left: 40%; top: 28%; }
.annotation-bottom { right: 7%; bottom: 28%; }
.annotation-rule { display: block; width: 22px; height: 1px; background: var(--cyan); }
.amber-rule { background: var(--amber); }
.graph-footer { justify-content: space-between; padding: 11px 14px; border-top: 1px solid var(--border); }
.graph-footer > div { display: grid; gap: 4px; }
.graph-footer-label { color: var(--muted-2); }
.graph-footer-value { font-size: 11px; color: #adb9bc; }
.graph-footer-right { gap: 12px; font-family: var(--mono); font-size: 9px; color: var(--muted-2); }
.graph-footer-right strong { color: var(--cyan); font-weight: 400; }
.bottom-grid { display: grid; grid-template-columns: 1.35fr 1fr; gap: 18px; margin-top: 18px; }
.panel-line { min-width: 0; border-top: 1px solid var(--border); padding-top: 12px; }
.panel-heading { justify-content: space-between; color: var(--muted); font-family: var(--mono); font-size: 10px; letter-spacing: .09em; }
.panel-meta { color: var(--muted-2); letter-spacing: 0; }
.neuron-field { height: 64px; display: flex; align-items: flex-end; gap: 4px; padding-top: 12px; }
.neuron-field span { flex: 1; max-width: 12px; min-height: 3px; background: rgba(120,228,232,.3); transition: height .22s ease; }
.neuron-field span:nth-child(3n) { background: rgba(120,228,232,.55); }
.neuron-field span.hot { background: var(--amber); box-shadow: 0 0 6px rgba(239,181,107,.25); }
.output-panel { padding-left: 10px; }
.output-row { display: flex; align-items: center; gap: 10px; margin-top: 10px; }
.output-token { width: 18px; color: #cad5d8; font-family: var(--mono); font-size: 12px; }
.output-bar { height: 5px; flex: 1; background: var(--surface-3); }
.output-bar span { display: block; height: 100%; background: var(--cyan); }
.output-row:nth-child(3) .output-bar span { background: var(--amber); }
.output-value { width: 34px; color: var(--muted); font-family: var(--mono); font-size: 10px; text-align: right; }
.transport { gap: 13px; margin-top: 19px; padding: 10px 0 0; border-top: 1px solid var(--border); }
.transport-play { display: grid; place-items: center; width: 28px; height: 28px; border: 1px solid var(--border-bright); background: var(--surface-2); color: var(--cyan); cursor: pointer; }
.transport-meta { justify-content: space-between; width: 96px; gap: 5px; flex-direction: column; align-items: flex-start; }
.transport-label { color: var(--muted-2); }
.transport-meta strong { font-family: var(--mono); font-size: 10px; color: #bfcbce; font-weight: 400; }
.scrubber { --progress: 25%; flex: 1; height: 4px; accent-color: var(--cyan); cursor: pointer; }
.speed-control { gap: 5px; color: var(--muted-2); border-left: 1px solid var(--border); padding-left: 13px; }
.speed-control select { border: 0; color: var(--muted); background: transparent; outline: none; font-family: var(--mono); font-size: 10px; }
.transport-state { display: inline-flex; align-items: center; gap: 6px; color: var(--muted-2); font-family: var(--mono); font-size: 9px; }
.transport-state-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--cyan); }
.inspector { border-left: 1px solid var(--border); min-width: 0; }
.inspector-tabs { height: 58px; display: flex; align-items: flex-end; gap: 20px; padding: 0 18px; border-bottom: 1px solid var(--border); }
.inspector-tabs button { position: relative; padding: 0 0 14px; border: 0; background: transparent; color: var(--muted-2); font-family: var(--mono); font-size: 10px; letter-spacing: .08em; cursor: pointer; }
.inspector-tabs button.is-selected { color: var(--text); }
.inspector-tabs button.is-selected::after { position: absolute; content: ''; bottom: -1px; left: 0; right: 0; height: 1px; background: var(--cyan); }
.tab-count { color: var(--cyan); }
.inspector-scroll { max-height: calc(100vh - 116px); overflow-y: auto; }
.inspect-section { padding: 18px; border-bottom: 1px solid var(--border); }
.section-kicker { color: var(--cyan); margin-bottom: 13px; }
.node-title-row { gap: 10px; align-items: flex-start; }
.node-symbol { flex: 0 0 auto; display: grid; place-items: center; width: 31px; height: 31px; border: 1px solid rgba(120,228,232,.45); color: var(--cyan); }
.node-title-row h2 { margin: 0 0 3px; font-size: 14px; font-weight: 500; }
.node-title-row p { margin: 0; color: var(--muted-2); font-size: 10px; }
.node-live { margin-left: auto; padding-top: 4px; color: var(--amber); font-family: var(--mono); font-size: 9px; }
.metric-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin-top: 18px; border: 1px solid var(--border); background: var(--border); }
.metric { display: grid; gap: 6px; padding: 9px; background: var(--surface); }
.metric span { color: var(--muted-2); font-family: var(--mono); font-size: 8px; }
.metric strong { color: #c9d5d8; font-family: var(--mono); font-size: 12px; font-weight: 400; }
.signal-readout { display: grid; grid-template-columns: repeat(2, 1fr); gap: 9px 14px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); }
.signal-readout div { display: grid; gap: 5px; min-width: 0; }
.signal-readout span { color: var(--muted-2); font-family: var(--mono); font-size: 8px; letter-spacing: .04em; }
.signal-readout strong { overflow: hidden; color: #a9d8da; font-family: var(--mono); font-size: 10px; font-weight: 400; text-overflow: ellipsis; white-space: nowrap; }
.section-heading { justify-content: space-between; color: var(--muted); font-family: var(--mono); font-size: 10px; letter-spacing: .08em; }
.section-hint { color: var(--muted-2); font-size: 9px; letter-spacing: 0; }
.attention-map { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; margin-top: 14px; padding: 8px; border: 1px solid var(--border); background: #0d1114; }
.attention-map span { aspect-ratio: 1; min-width: 0; }
.legend { gap: 13px; margin-top: 10px; color: var(--muted-2); font-size: 9px; }
.event-list { display: grid; gap: 11px; margin-top: 15px; }
.event-row { display: grid; grid-template-columns: 6px 54px 1fr; align-items: center; gap: 8px; min-width: 0; }
.event-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--muted-2); }
.event-dot.cyan { background: var(--cyan); }
.event-dot.amber { background: var(--amber); }
.event-time { color: var(--muted-2); font-family: var(--mono); font-size: 9px; }
.event-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #aab6ba; font-size: 10px; }
.response-copy { min-height: 58px; margin-top: 14px; padding: 11px; border-left: 1px solid var(--border-bright); background: #0d1114; color: var(--muted-2); font-size: 11px; line-height: 1.6; }
.response-copy.has-output { color: #c3d0d3; }
.response-caret { width: 5px; height: 13px; margin: -27px 0 0 12px; background: var(--cyan); animation: caret-blink 1s steps(2, start) infinite; }
@keyframes caret-blink { 50% { opacity: 0; } }
.architecture-note .section-heading .icon-button { margin: -8px -8px -8px 0; }
.architecture-line { display: flex; justify-content: space-between; padding-top: 11px; color: var(--muted); font-size: 11px; }
.architecture-line strong { color: #c8d3d5; font-family: var(--mono); font-size: 10px; font-weight: 400; }
.weights-panel { min-height: 436px; border: 1px solid var(--border); background: var(--surface); padding: 26px; margin-top: 18px; }
.weights-header { display: flex; justify-content: space-between; align-items: flex-start; }
.weights-header h2 { margin: 9px 0 8px; font-size: 20px; font-weight: 500; }
.weights-header p { margin: 0; color: var(--muted); font-size: 12px; }
.outline-button { display: inline-flex; align-items: center; gap: 8px; padding: 8px 10px; border: 1px solid var(--border-bright); color: var(--muted); background: transparent; cursor: pointer; font-size: 11px; }
.weights-empty { min-height: 230px; display: grid; place-content: center; justify-items: center; gap: 10px; color: var(--cyan); text-align: center; }
.weights-empty span { color: #c7d2d5; font-size: 13px; }
.weights-empty small { color: var(--muted-2); font-size: 10px; }
.toast { position: fixed; right: 24px; bottom: 24px; display: flex; align-items: center; gap: 9px; padding: 11px 12px; border: 1px solid rgba(120,228,232,.32); background: #10191b; color: #c9d8da; box-shadow: 0 8px 30px rgba(0,0,0,.25); font-size: 12px; z-index: 5; }
.toast svg { color: var(--cyan); }
.toast button { display: grid; place-items: center; margin-left: 6px; padding: 0; border: 0; color: var(--muted); background: transparent; cursor: pointer; }
.mobile-history { display: none; }
.three-d-panel { margin-top: 18px; border: 1px solid var(--border); background: #0a0e11; overflow: hidden; }
.three-d-toolbar { height: 51px; display: flex; align-items: center; justify-content: space-between; padding: 0 14px; border-bottom: 1px solid var(--border); background: rgba(17,21,26,.86); }
.three-d-title { display: flex; align-items: center; gap: 10px; }
.three-d-mark { width: 8px; height: 8px; border: 1px solid var(--cyan); background: rgba(120,228,232,.35); box-shadow: 0 0 10px rgba(120,228,232,.42); transform: rotate(45deg); }
.three-d-title div { display: grid; gap: 3px; }
.three-d-title strong { color: #d8e2e4; font-family: var(--mono); font-size: 10px; letter-spacing: .09em; font-weight: 400; }
.three-d-title small { color: var(--muted-2); font-family: var(--mono); font-size: 9px; }
.three-d-toolbar-meta { display: flex; align-items: center; gap: 13px; color: var(--muted-2); font-family: var(--mono); font-size: 9px; }
.three-d-toolbar-meta span { display: inline-flex; align-items: center; gap: 6px; }
.three-d-viewport { position: relative; height: 390px; background: radial-gradient(circle at 50% 42%, rgba(27,49,52,.28), transparent 48%); }
.three-d-viewport canvas { display: block; background: #0b0d10; }
.svg-viewport { cursor: grab; touch-action: none; user-select: none; }
.svg-viewport:active { cursor: grabbing; }
.svg-viewport svg { display: block; width: 100%; height: 100%; }
.projection-grid line { stroke: rgba(119,142,151,.09); stroke-width: 1; }
.svg-residual { fill: none; stroke: url(#stream-line); stroke-width: 2; stroke-dasharray: 3 7; vector-effect: non-scaling-stroke; transition: stroke-width .2s ease, opacity .2s ease; }
.svg-residual.is-emphasis { stroke-width: 3.1; opacity: 1; }
.svg-residual.is-backward { stroke: #efb56b; stroke-dasharray: 8 5; }
.svg-attention { stroke: rgba(239,181,107,.58); stroke-width: 1.15; stroke-dasharray: 3 4; vector-effect: non-scaling-stroke; transition: stroke-width .2s ease, opacity .2s ease; }
.svg-attention.is-emphasis { stroke-width: 2.4; opacity: 1; }
.svg-layer { cursor: pointer; }
.svg-layer:hover circle:last-of-type { stroke: #e6edf0; stroke-width: 1.5; }
.svg-selection { fill: none; stroke: #e6edf0; stroke-width: 1.3; stroke-dasharray: 2 2; vector-effect: non-scaling-stroke; }
.svg-layer-label { fill: #92a1a6; font-family: var(--mono); font-size: 10px; letter-spacing: .05em; text-anchor: middle; }
.svg-packet { filter: drop-shadow(0 0 5px rgba(120,228,232,.8)); }
.svg-vision-branch line { stroke: var(--amber); stroke-width: 1.2; stroke-dasharray: 4 5; opacity: .7; }
.svg-vision-branch circle { fill: var(--amber); opacity: .9; }
.svg-vision-branch text { fill: var(--amber); font-family: var(--mono); font-size: 9px; letter-spacing: .08em; }
.svg-aux-branch line { stroke: #efb56b; stroke-width: 1.2; stroke-dasharray: 2 4; opacity: .82; }
.svg-aux-branch circle { fill: #efb56b; filter: drop-shadow(0 0 4px rgba(239,181,107,.75)); }
.svg-aux-branch text { fill: #efb56b; font-family: var(--mono); font-size: 9px; letter-spacing: .08em; }
.svg-axes line { stroke: rgba(120,228,232,.45); stroke-width: 1; }
.svg-axes text { fill: rgba(120,228,232,.6); font-family: var(--mono); font-size: 9px; }
.three-d-axis { position: absolute; color: rgba(120,228,232,.5); font-family: var(--mono); font-size: 8px; letter-spacing: .08em; pointer-events: none; }
.three-d-axis span { color: var(--cyan); font-size: 12px; }
.axis-x { left: 16px; bottom: 16px; }
.axis-y { left: 16px; top: 18px; writing-mode: vertical-rl; }
.three-d-readout { position: absolute; top: 16px; right: 16px; display: grid; gap: 4px; padding-left: 10px; border-left: 1px solid rgba(120,228,232,.45); pointer-events: none; }
.three-d-readout span, .three-d-readout small { color: var(--muted-2); font-family: var(--mono); font-size: 8px; letter-spacing: .08em; }
.three-d-readout strong { color: var(--cyan); font-family: var(--mono); font-size: 12px; font-weight: 400; }
.three-d-hint { position: absolute; left: 50%; bottom: 15px; transform: translateX(-50%); color: rgba(123,135,144,.65); font-family: var(--mono); font-size: 8px; letter-spacing: .06em; pointer-events: none; white-space: nowrap; }
.three-d-footer { display: flex; justify-content: space-between; gap: 12px; padding: 10px 14px; border-top: 1px solid var(--border); color: var(--muted-2); font-family: var(--mono); font-size: 9px; }
.three-d-panel.is-loading { min-height: 442px; display: grid; place-content: center; justify-items: center; gap: 10px; color: var(--muted); font-family: var(--mono); font-size: 10px; }
.spin { animation: spin 1.2s linear infinite; color: var(--cyan); }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 1180px) {
.workspace { grid-template-columns: 220px minmax(520px, 1fr); }
.inspector { display: none; }
.brand-context { display: none; }
}
@media (max-width: 780px) {
.topbar { padding: 0 14px; }
.topbar-actions { gap: 5px; }
.runtime-status span, .model-select svg:first-child, .model-select svg:last-child { display: none; }
.model-select { padding: 7px; }
.workspace { display: block; }
.sidebar { position: fixed; top: 58px; left: 0; bottom: 0; width: min(84vw, 280px); z-index: 4; transform: translateX(-100%); transition: transform .2s ease; box-shadow: 12px 0 30px rgba(0,0,0,.3); }
.sidebar.is-open { transform: translateX(0); }
.main-stage { padding: 20px 14px; }
.stage-header { align-items: flex-start; }
.stage-header h1 { font-size: 20px; }
.graph-toolbar { align-items: flex-start; gap: 10px; }
.graph-legend { flex-wrap: wrap; gap: 8px; }
.graph-coordinates { display: none; }
.graph-canvas { height: 340px; min-width: 620px; }
.graph-panel { overflow-x: auto; }
.process-strip { gap: 8px; overflow-x: auto; }
.process-note { display: none; }
.three-d-toolbar { height: auto; min-height: 51px; align-items: flex-start; gap: 10px; padding-top: 11px; padding-bottom: 11px; }
.three-d-toolbar-meta { flex-wrap: wrap; justify-content: flex-end; }
.three-d-viewport { height: 330px; }
.three-d-footer { flex-wrap: wrap; }
.three-d-hint { display: none; }
.bottom-grid { grid-template-columns: 1fr; gap: 14px; }
.output-panel { padding-left: 0; }
.transport-meta { width: 78px; }
.transport-state { display: none; }
.mobile-history { position: fixed; left: 14px; bottom: 14px; display: grid; place-items: center; width: 38px; height: 38px; border: 1px solid var(--border-bright); background: var(--surface-2); color: var(--cyan); z-index: 3; }
.toast { right: 14px; bottom: 14px; left: 64px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; }
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://127.0.0.1:8000',
},
},
})