Compare commits
98
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
598c313e46 | ||
|
|
fa7e2cefa1 | ||
|
|
dbb8a93fa3 | ||
|
|
da8ecdce42 | ||
|
|
cd00c79262 | ||
|
|
48243bdf6b | ||
|
|
a10d29748a | ||
|
|
e069249512 | ||
|
|
ec464e3d9e | ||
|
|
e6115fbfd7 | ||
|
|
b0a3c6d099 | ||
|
|
81960f0be7 | ||
|
|
0a698aca17 | ||
|
|
40e286f26b | ||
|
|
9dececb78a | ||
|
|
a8fc01ee8b | ||
|
|
2a28466539 | ||
|
|
05435120a8 | ||
|
|
b15f1592fd | ||
|
|
5e6b7b1c5c | ||
|
|
c27aed5c16 | ||
|
|
3adb313fa1 | ||
|
|
8000170221 | ||
|
|
ff6ea73946 | ||
|
|
58ef04bfec | ||
|
|
04e387c7e9 | ||
|
|
7138af0b54 | ||
|
|
6bc7aef0f9 | ||
|
|
09a2212543 | ||
|
|
fa73fd0b6c | ||
|
|
df45cc81e7 | ||
|
|
d17031b9cc | ||
|
|
bb2e68fd3d | ||
|
|
0f05b3adf6 | ||
|
|
157d72f65d | ||
|
|
302c4aec28 | ||
|
|
465d9d38cc | ||
|
|
41aafca443 | ||
|
|
462bb9eec3 | ||
|
|
acf70034da | ||
|
|
18cdaa3251 | ||
|
|
07d77acc46 | ||
|
|
eb6e4329d4 | ||
|
|
6453bf1197 | ||
|
|
9b9121e362 | ||
|
|
58390e2e82 | ||
|
|
cc1ce4dae5 | ||
|
|
083ef77134 | ||
|
|
5ca5ffcd41 | ||
|
|
13ec3362aa | ||
|
|
502ae32c9f | ||
|
|
e34af87d96 | ||
|
|
2f73e25c4c | ||
|
|
619f30fcfe | ||
|
|
633e182a25 | ||
|
|
1c5cf01e4a | ||
|
|
346803ecd9 | ||
|
|
8ec50e141d | ||
|
|
6338f91f94 | ||
|
|
1bb44e0315 | ||
|
|
6e0e03b63b | ||
|
|
271b26e094 | ||
|
|
cfe7f9093a | ||
|
|
6a1684d693 | ||
|
|
b337f5dc06 | ||
|
|
07785330f1 | ||
|
|
baa900b46f | ||
|
|
9e884f68b3 | ||
|
|
03d16a63f9 | ||
|
|
a75c2696cc | ||
|
|
dc09306655 | ||
|
|
735d91cf26 | ||
|
|
477eb9f570 | ||
|
|
4e4f0798a8 | ||
|
|
9509889763 | ||
|
|
b2be690d14 | ||
|
|
3b1d2e3c70 | ||
|
|
2c39f8ec8f | ||
|
|
f7a7f3bb36 | ||
|
|
2b4bc9d997 | ||
|
|
7d84e36f5e | ||
|
|
88ce820fe9 | ||
|
|
522d9d8ec5 | ||
|
|
67c9c14564 | ||
|
|
ccd28566af | ||
|
|
e564db0b69 | ||
|
|
224d506f1b | ||
|
|
4030cf8504 | ||
|
|
5c56ba48e0 | ||
|
|
d3f838cc76 | ||
|
|
a8d29c1190 | ||
|
|
af2358c344 | ||
|
|
d567ad84c7 | ||
|
|
4a652bc38d | ||
|
|
703b67a313 | ||
|
|
ba19a68f59 | ||
|
|
d774f7a772 | ||
|
|
3510a1eebb |
+30
@@ -5,3 +5,33 @@ __pycache__/
|
||||
/deploy/systemd/*.service.override
|
||||
/services/privacy_gateway/__pycache__/
|
||||
.worktrees/
|
||||
build/deb/
|
||||
build/test-viz/
|
||||
dist/
|
||||
# Build artifacts in deb trees (populated by build scripts at build time)
|
||||
debian/kvm-mitm/usr/lib/
|
||||
debian/kvm-agent/usr/lib/
|
||||
debian/kvm-bridge/usr/lib/
|
||||
debian/kvm-rkllm/usr/lib/kvm-rkllm/rkllm_server/
|
||||
debian/kvm-rkllm/usr/lib/kvm-rkllm/librkllmrt.so
|
||||
debian/kvm-rkllm/lib/systemd/
|
||||
debian/kvm-npu/usr/local/bin/
|
||||
debian/kvm-npu/usr/share/kvm-npu/models/
|
||||
debian/kvm-privacy/usr/local/bin/
|
||||
|
||||
# Temporary test screenshots
|
||||
test-*.png
|
||||
e2e-verify-*.png
|
||||
|
||||
# Sensitive files
|
||||
api-key.txt
|
||||
|
||||
# Rust build artifacts
|
||||
target/
|
||||
|
||||
# Test output data
|
||||
testdata/results/
|
||||
|
||||
# IDE / tooling
|
||||
.playwright-mcp/
|
||||
firebase-debug.log
|
||||
|
||||
@@ -6,17 +6,20 @@
|
||||
- Git commit 消息使用英文
|
||||
|
||||
## 项目概述
|
||||
KVM-Privacy 是一个基于 KVM-over-IP 的三层隐私保护系统,运行在 NanoPC-T6 (RK3588) 上。
|
||||
KVM-Privacy 是一个基于 KVM-over-IP 的两层隐私保护系统,运行在 NanoPC-T6 (RK3588) 上。所有防护完全在 KVM 设备端完成,无需在被控机器上部署任何软件。
|
||||
|
||||
### 核心服务
|
||||
|
||||
| 服务 | 端口 | 说明 |
|
||||
|------|------|------|
|
||||
| KVM Server (Go) | 8080 | KVM 控制 + React WebUI |
|
||||
| info-privacy-rs (Rust) | 8001 | RKNN PII 检测 |
|
||||
| mem-bridge router | 8002 | AI 路由服务 |
|
||||
| mem-bridge memory | 8003 | 会话存储 + FAISS 向量搜索 |
|
||||
| Privacy Gateway (Python) | 8888 | mitmproxy 网络拦截 |
|
||||
| 服务 | 端口 | DEB 包 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| KVM Server (Go) | 8080 | kvm-server | KVM 控制 + React WebUI + WebRTC |
|
||||
| info-privacy-rs (Rust) | 8001 | kvm-privacy | RKNN PII 检测/脱敏 |
|
||||
| NPU Daemon (Rust) | 8004 | kvm-npu | 集中 RKNN 推理 (OCR/Face) |
|
||||
| mem-bridge memory | 8001 | kvm-bridge | 会话存储 + FAISS 向量搜索 |
|
||||
| mem-bridge router | 8002 | kvm-bridge | AI 路由服务 |
|
||||
| Privacy Gateway (Python) | 8888 | kvm-mitm | mitmproxy 网络拦截 |
|
||||
| KVM Agent (Python) | 8890 | kvm-agent | AI Agent daemon |
|
||||
| RKLLM Server (Python) | 8891 | kvm-rkllm | 本地 LLM (Qwen3-0.6B NPU) |
|
||||
|
||||
### 架构
|
||||
|
||||
@@ -38,9 +41,15 @@ KVM-Privacy 是一个基于 KVM-over-IP 的三层隐私保护系统,运行在
|
||||
services/
|
||||
kvm_agent/ # Python - KVM AI Agent
|
||||
privacy_gateway/ # Python - mitmproxy 隐私网关
|
||||
rkllm_server/ # Python - RKLLM 本地 LLM 服务
|
||||
npu_daemon/ # Rust - NPU 推理守护进程
|
||||
tools/
|
||||
workflow-dashboard/ # 测试工具(非生产),独立 pyproject.toml
|
||||
KVM/ # Git submodule - Go KVM 服务端
|
||||
deps/ # Git submodules - 依赖项目
|
||||
deploy/systemd/ # systemd 服务文件
|
||||
debian/ # DEB 包定义 (kvm-mitm, kvm-agent, kvm-bridge, kvm-npu, kvm-privacy, kvm-rkllm, kvm-meta)
|
||||
scripts/build-debs.sh # 全栈 DEB 构建脚本
|
||||
```
|
||||
|
||||
## 编码规范
|
||||
@@ -80,6 +89,23 @@ action → sleep → screenshot → OCR → semantic assert
|
||||
- 仅用于判断"屏幕是否变化"
|
||||
- OCR 语义验证 > 像素对比
|
||||
|
||||
## 前端架构变更(2026-03)
|
||||
|
||||
### GatewayMode 类型
|
||||
- `privacyMode` 已从 `boolean` 升级为 `'off' | 'audit' | 'redact'`
|
||||
- 类型定义:`deps/KVM/web/src/types/privacy.ts`
|
||||
- PiiOverlay:`audit` = 红色边框,`redact` = 黑色填充,`off` = 清空
|
||||
|
||||
### 页面 Tab 结构
|
||||
- **PrivacyPage**:5 tabs — dashboard / network / screen / rules / settings
|
||||
- screen tab:`GET /api/v1/privacy/screen/detections`
|
||||
- rules tab:`GET/POST/PUT/DELETE /api/v1/privacy/patterns`
|
||||
- **AuditPage**:2 tabs — logs / recordings(OCR/patterns 已迁移到 Privacy)
|
||||
|
||||
### Agent 运行期间 HID 禁用
|
||||
- kvmStore 所有 HID 发送函数检查 `useAgentStore.getState().status === 'running'`
|
||||
- ConsolePage 粘贴/虚拟键盘按钮在 Agent 运行时禁用
|
||||
|
||||
## 架构决策:鼠标优先
|
||||
|
||||
### 背景
|
||||
@@ -106,18 +132,114 @@ escape, enter, tab, backspace, delete, space, up/down/left/right, shift, f1-f5,
|
||||
|
||||
```bash
|
||||
# 运行 KVM Agent 单次任务
|
||||
python -m kvm_agent --task "打开记事本" --kvm-url http://localhost:8080
|
||||
python3 -m kvm_agent --task "打开记事本" --kvm-url http://localhost:8080
|
||||
|
||||
# 运行测试
|
||||
cd services/kvm_agent && python -m pytest tests/ -v
|
||||
# 运行单元测试(不需要实体设备)
|
||||
cd services/kvm_agent && python3 -m pytest tests/ --ignore=tests/test_integration.py -v
|
||||
|
||||
# 运行集成测试(需要实体 KVM 设备)
|
||||
cd services/kvm_agent && python3 -m pytest tests/test_integration.py -v
|
||||
|
||||
# Go 后端编译(go.mod 在 deps/KVM/go/;系统 go 是 1.15 需用绝对路径)
|
||||
cd deps/KVM/go && /usr/local/go/bin/go build ./...
|
||||
|
||||
# kvm-server deb 打包(需要 Go 1.24 在 PATH 前面)
|
||||
cd deps/KVM && PATH="/usr/local/go/bin:$PATH" bash scripts/build-deb.sh
|
||||
|
||||
# 构建全部 DEB 包(Python + Rust + 闭源库)
|
||||
bash scripts/build-debs.sh
|
||||
# 跳过 Rust 编译(使用已有二进制)
|
||||
bash scripts/build-debs.sh --skip-rust
|
||||
|
||||
# 前端编译
|
||||
cd deps/KVM/web && npm run build
|
||||
|
||||
# Dashboard (测试工具,非生产)
|
||||
cd tools/workflow-dashboard && python3 -m workflow_dashboard
|
||||
# Dashboard (Docker)
|
||||
cd tools/workflow-dashboard && docker compose up -d
|
||||
|
||||
# 查看服务状态
|
||||
systemctl status kvm-agent mem-bridge-memory mem-bridge-router info-privacy privacy-gateway
|
||||
systemctl status kvm-agent mem-bridge-memory mem-bridge-router info-privacy privacy-gateway npu-daemon rkllm-server
|
||||
|
||||
# 设备连接
|
||||
ssh pi@192.168.123.181
|
||||
```
|
||||
|
||||
## Deb 包开发完整工作流
|
||||
|
||||
### 修改 → 构建 → 验证
|
||||
|
||||
每次修改 deb 包相关文件(debian/control, postinst, prerm, systemd service, C 源码)后,按以下流程验证:
|
||||
|
||||
```bash
|
||||
# 1. 构建
|
||||
cd deps/KVM && PATH="/usr/local/go/bin:$PATH" bash scripts/build-deb.sh
|
||||
|
||||
# 2. 静态检查包内容(无需设备)
|
||||
DEB=$(ls deps/KVM/dist/kvm-server_*.deb | tail -1)
|
||||
dpkg -I "$DEB" | grep "Depends\|Recommends" # 检查依赖
|
||||
dpkg -c "$DEB" | grep "\.service\|\.timer\|usr/lib" # 检查安装文件
|
||||
dpkg -x "$DEB" /tmp/kvm-check && grep -n "systemctl" /tmp/kvm-check/DEBIAN/postinst
|
||||
dpkg -x "$DEB" /tmp/kvm-check && grep -n "systemctl" /tmp/kvm-check/DEBIAN/prerm
|
||||
grep "Requires\|Wants" /tmp/kvm-check/lib/systemd/system/kvm-server.service
|
||||
|
||||
# 3. 拷贝到目标设备
|
||||
scp "$DEB" pi@192.168.123.181:/tmp/
|
||||
```
|
||||
|
||||
### 标准安装/卸载/重装验证循环
|
||||
|
||||
**必须按此顺序在目标设备执行(ssh pi@192.168.123.181):**
|
||||
|
||||
```bash
|
||||
# [1] 首次安装
|
||||
sudo dpkg -i /tmp/kvm-server_*.deb
|
||||
sleep 5
|
||||
systemctl is-active kvm-server && echo "PASS" || echo "FAIL"
|
||||
curl -sf http://localhost:8080/api/v1/kvm/stream/stats
|
||||
|
||||
# [2] 卸载(保留 conffiles)
|
||||
sudo dpkg --remove kvm-server
|
||||
systemctl is-active kvm-server && echo "FAIL" || echo "PASS: stopped"
|
||||
ls /etc/kvm/config.json && echo "PASS: config preserved" || echo "FAIL: config lost"
|
||||
|
||||
# [3] 重装(验证 conffile 不被覆盖、服务恢复)
|
||||
sudo dpkg -i /tmp/kvm-server_*.deb
|
||||
sleep 5
|
||||
systemctl is-active kvm-server && echo "PASS" || echo "FAIL"
|
||||
curl -sf http://localhost:8080/api/v1/kvm/stream/stats
|
||||
|
||||
# [4] Purge(验证干净卸载)
|
||||
sudo dpkg --purge kvm-server
|
||||
ls /etc/kvm/config.json 2>/dev/null && echo "FAIL: conffile not purged" || echo "PASS: purged"
|
||||
|
||||
# [5] 最终 clean install
|
||||
sudo dpkg -i /tmp/kvm-server_*.deb
|
||||
```
|
||||
|
||||
### 关键验证点
|
||||
|
||||
| 验证项 | 命令 | 期望结果 |
|
||||
|--------|------|---------|
|
||||
| 安装不卡住 | `time dpkg -i *.deb` | < 60s |
|
||||
| 服务启动 | `systemctl is-active kvm-server` | `active` |
|
||||
| USB gadget 软依赖 | `systemctl is-active kvm-usb-gadget \|\| true` | 失败不影响 kvm-server |
|
||||
| OCR timer 已移除 | `systemctl list-units \| grep ocr-snapshot` | 空 |
|
||||
| API 可达 | `curl localhost:8080/api/v1/kvm/stream/stats` | JSON 响应 |
|
||||
| Conffile 保留 | `ls /etc/kvm/config.json` after remove | 文件存在 |
|
||||
| Conffile 清除 | `ls /etc/kvm/config.json` after purge | 文件不存在 |
|
||||
|
||||
### 常见 systemd 包问题(已修复)
|
||||
|
||||
| 问题 | 症状 | 修复 |
|
||||
|------|------|------|
|
||||
| 双重启动 | postinst 有手动 `systemctl restart` + dh_installsystemd 各触发一次 | 删除手动 systemctl 区域 |
|
||||
| 双重 stop | prerm 有手动 `systemctl stop` + dh 的 `deb-systemd-invoke stop` | 删除手动 systemctl stop |
|
||||
| USB gadget 硬依赖 | `Requires=kvm-usb-gadget` → gadget 失败导致 kvm-server 不启动 | 改为 `Wants=` |
|
||||
| setup-usb-gadget 崩溃 | `set -e` + UDC 绑定失败 → 整个脚本 exit 1 | 移除 `set -e`,容错化 |
|
||||
| 过时 timer 噪音 | kvm-ocr-snapshot.timer 每秒触发 oneshot,native 模式不需要 | 从包中移除 |
|
||||
|
||||
## 目标设备
|
||||
- 硬件:NanoPC-T6 (RK3588, 8核 ARM64, 6 TOPS NPU)
|
||||
- 系统:Debian/Ubuntu ARM64
|
||||
@@ -129,9 +251,20 @@ ssh pi@192.168.123.181
|
||||
|
||||
| 类别 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 安全模型 | ✅ 良好 | 三层防护,白名单模式,Unicode NFKC 归一化 |
|
||||
| 安全模型 | ✅ 良好 | 两层防护(视频遮蔽+网络拦截),白名单模式,Unicode NFKC 归一化 |
|
||||
| 鼠标优先 | ✅ 完成 | mouse_ops + LLM 提示词 + agent 翻译层 |
|
||||
| 多 UI 状态 | ✅ 完成 | screen_state.py 检测 BIOS/锁屏/睡眠/桌面 |
|
||||
| 测试覆盖 | ✅ 良好 | safety/screen_state/mouse_ops 单元测试 + integration |
|
||||
| 隐私截图 | ❌ 未实现 | Go 端 privacy mode 不影响截图内容 |
|
||||
| 测试覆盖 | ✅ 良好 | 单元测试 331 个;integration 需实体设备(--ignore 跳过) |
|
||||
| 隐私遮蔽 | ✅ 完成 | C 视频管道实时 NV12 黑色填充 PII 区域 (WF6 Phase 6A-2) |
|
||||
| test_integration 一致性 | ⚠️ 部分 | 清理代码已迁移到 mouse_ops,测试目标仍用原始组合键 |
|
||||
| Privacy/Audit 重组 | ✅ 完成 | PrivacyPage 5 Tab,AuditPage 2 Tab,新增 5 条 API 路由 |
|
||||
| GatewayMode 类型 | ✅ 完成 | boolean→'off'\|'audit'\|'redact',PiiOverlay 视觉区分 |
|
||||
| Agent HID 保护 | ✅ 完成 | Agent 运行时前端键鼠/粘贴被阻断 |
|
||||
| OCR 内联 | ✅ 完成 | CGo libkvm_ocr.so 内联调用, 延迟 800ms→60ms (WF6) |
|
||||
| NPU 调度 | ✅ 完成 | OCR(Core0/1) + Embedding(Core2), 无争用 (WF6) |
|
||||
| CPU 亲和性 | ✅ 完成 | A76(Go+HID) / A55(PII+mem-bridge) (WF6) |
|
||||
| DMA-buf 零拷贝 | ✅ 完成 | V4L2→MPP 零拷贝编码 (WF6) |
|
||||
|
||||
## 架构文档
|
||||
|
||||
详细子系统文档见 [docs/architecture/](docs/architecture/overview.md)。
|
||||
|
||||
+134
-91
@@ -1,138 +1,181 @@
|
||||
# DEVELOP
|
||||
# 开发指南
|
||||
|
||||
## 环境要求
|
||||
|
||||
- 开发机:Linux x86_64,Python 3.10+,Go 1.21+
|
||||
- 目标设备:NanoPC-T6 (RK3588),192.168.123.181,user: pi
|
||||
- SSH 访问:`sshpass -p "pi" ssh pi@192.168.123.181`
|
||||
- 目标设备: NanoPC-T6 (RK3588), Debian/Ubuntu ARM64
|
||||
- Go: 1.22+ (`/usr/local/go/bin/go`)
|
||||
- Python: 3.12+
|
||||
- Node: 18+ (前端构建)
|
||||
- NPU: rknn-toolkit-lite2 2.3.2
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
KVM-privacy/
|
||||
├── deps/ # git submodule 依赖
|
||||
│ ├── KVM/ # KVM 核心(Go + React)
|
||||
│ ├── embedding/ # mem-bridge 记忆/路由服务
|
||||
│ └── info-privacy-rs/ # Rust PII 检测脱敏
|
||||
├── deps/
|
||||
│ ├── KVM/ # Go KVM 核心 + React + C 视频管道
|
||||
│ │ ├── go/ # Go 后端 (28 个 internal 包)
|
||||
│ │ ├── web/ # React 前端
|
||||
│ │ ├── src/video/ # C 视频管道 (V4L2/RGA/MPP)
|
||||
│ │ ├── src/audit/ # C OCR 模块 (RKNN)
|
||||
│ │ ├── debian/ # kvm-server deb 包定义
|
||||
│ │ └── scripts/ # 构建脚本
|
||||
│ ├── embedding/ # Python mem-bridge
|
||||
│ └── info-privacy-rs/ # Rust PII 检测
|
||||
├── services/
|
||||
│ └── privacy_gateway/ # mitmproxy 隐私网关(Python)
|
||||
├── deploy/
|
||||
│ └── systemd/ # systemd 服务文件(部署到设备)
|
||||
├── scripts/ # 部署脚本
|
||||
├── docs/plans/ # 设计文档(归档用,不做日常维护)
|
||||
└── Makefile
|
||||
│ ├── kvm_agent/ # Python AI Agent (26 模块)
|
||||
│ └── privacy_gateway/ # Python mitmproxy 网关
|
||||
├── debian/ # 5 个额外 deb 包定义
|
||||
├── deploy/systemd/ # 8 个 systemd 服务文件
|
||||
├── scripts/ # 构建/部署脚本
|
||||
└── docs/architecture/ # 子系统架构文档
|
||||
```
|
||||
|
||||
## 设备上的实际路径
|
||||
## 常用命令
|
||||
|
||||
| 本地开发路径 | 设备部署路径 |
|
||||
|------------|------------|
|
||||
| `deps/KVM/` | `/home/pi/Desktop/KVM/` |
|
||||
| `deps/embedding/` | `/home/pi/Desktop/embed-db/` |
|
||||
| `deps/info-privacy-rs/` | `/home/pi/Desktop/info-privacy-rs/` |
|
||||
| `services/privacy_gateway/` | `/data/project/KVM-privacy/services/privacy_gateway/` |
|
||||
|
||||
## Privacy Gateway 开发
|
||||
|
||||
### 本地测试
|
||||
### Go 后端
|
||||
|
||||
```bash
|
||||
cd services/privacy_gateway
|
||||
pip install mitmproxy httpx cryptography
|
||||
python -c "from audit_logger import AuditLogger; l=AuditLogger('/tmp/test.db'); print('OK')"
|
||||
# 编译 (go.mod 在 deps/KVM/go/)
|
||||
cd deps/KVM/go && /usr/local/go/bin/go build ./...
|
||||
|
||||
# 运行测试
|
||||
cd deps/KVM/go && /usr/local/go/bin/go test ./... -count=1
|
||||
|
||||
# 仅运行 api 包测试
|
||||
cd deps/KVM/go && /usr/local/go/bin/go test ./internal/api/... -v
|
||||
|
||||
# Go vet
|
||||
cd deps/KVM/go && /usr/local/go/bin/go vet ./...
|
||||
```
|
||||
|
||||
### 服务文件说明
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `addon.py` | mitmproxy 核心:AI 域名匹配 → 拦截解密 → 脱敏 → 放行 |
|
||||
| `interceptor.py` | 请求内容提取(Content-Type 检查、文件解析) |
|
||||
| `upload_scanner.py` | 调用 info-privacy-rs API 完成脱敏 |
|
||||
| `cert_manager.py` | 设备 CA 证书生成,存储于 `/etc/kvm-privacy/ca/` |
|
||||
| `audit_logger.py` | SQLite append-only 审计,不记录原文 |
|
||||
| `ai_domains.txt` | 拦截域名白名单 |
|
||||
|
||||
## 部署流程
|
||||
|
||||
### 初次部署(在本机执行)
|
||||
### deb 包构建
|
||||
|
||||
```bash
|
||||
# 克隆项目(含 submodule)
|
||||
git clone --recurse-submodules http://edge-stack.synology.me:3000/qiurui/KVM-privacy.git
|
||||
cd KVM-privacy
|
||||
# kvm-server (Go + C + React)
|
||||
cd deps/KVM && PATH="/usr/local/go/bin:$PATH" bash scripts/build-deb.sh
|
||||
|
||||
# 部署 Privacy Gateway(安装 mitmproxy + 上传服务文件 + 生成 CA 证书)
|
||||
make deploy-gateway
|
||||
# kvm-server + kvm-ocr
|
||||
cd deps/KVM && PATH="/usr/local/go/bin:$PATH" bash scripts/build-deb.sh --ocr
|
||||
|
||||
# 部署 mem-bridge 服务(上传 systemd 文件并启动)
|
||||
make deploy-memory
|
||||
# Python 包 (kvm-agent, kvm-mitm)
|
||||
bash scripts/build-python-debs.sh
|
||||
|
||||
# 其他包
|
||||
dpkg-deb --build debian/kvm-privacy build/deb/
|
||||
dpkg-deb --build debian/kvm-bridge build/deb/
|
||||
dpkg-deb --build debian/kvm-meta build/deb/
|
||||
```
|
||||
|
||||
### 更新 Privacy Gateway 服务文件
|
||||
|
||||
```bash
|
||||
sshpass -p pi scp services/privacy_gateway/*.py pi@192.168.123.181:/data/project/KVM-privacy/services/privacy_gateway/
|
||||
sshpass -p pi ssh pi@192.168.123.181 "sudo systemctl restart privacy-gateway"
|
||||
### kvm-server 构建顺序
|
||||
```
|
||||
1. npm build (前端)
|
||||
2. cmake (视频 C 库, 需要 rockchip_mpp)
|
||||
3. cmake (OCR 共享库, 需要 rknn_api) ← 必须在 Go 之前
|
||||
4. go build (自动检测 .so → CGo flags)
|
||||
5. dpkg-buildpackage
|
||||
```
|
||||
|
||||
### systemd 服务管理
|
||||
### 前端
|
||||
|
||||
```bash
|
||||
# 查看所有 KVM-Privacy 相关服务状态
|
||||
cd deps/KVM/web && npm install && npm run build
|
||||
```
|
||||
|
||||
### Python Agent
|
||||
|
||||
```bash
|
||||
# 运行单次任务
|
||||
python3 -m kvm_agent run --task "打开记事本" --kvm-url http://localhost:8080
|
||||
|
||||
# Daemon 模式
|
||||
python3 -m kvm_agent daemon
|
||||
|
||||
# 单元测试 (不需要设备)
|
||||
cd services/kvm_agent && python3 -m pytest tests/ --ignore=tests/test_integration.py -v
|
||||
|
||||
# 集成测试 (需要实体设备)
|
||||
cd services/kvm_agent && python3 -m pytest tests/test_integration.py -v
|
||||
```
|
||||
|
||||
### 服务管理
|
||||
|
||||
```bash
|
||||
# 查看状态
|
||||
systemctl status kvm-server kvm-agent info-privacy mem-bridge-memory mem-bridge-router
|
||||
|
||||
# 日志
|
||||
journalctl -u kvm-server -f
|
||||
journalctl -u kvm-agent -f
|
||||
|
||||
# Makefile 快捷命令
|
||||
make status
|
||||
|
||||
# 查看指定服务日志
|
||||
make logs SVC=info-privacy-rs
|
||||
make logs SVC=mem-bridge-memory
|
||||
make logs SVC=privacy-gateway
|
||||
make logs SVC=kvm-server
|
||||
```
|
||||
|
||||
## mem-bridge 开发(deps/embedding)
|
||||
## deb 包验证流程
|
||||
|
||||
开发路径:`/data/rockchip/embedding/`,设备路径:`/home/pi/Desktop/embed-db/`
|
||||
每次修改 deb 相关文件后,在目标设备执行完整验证循环:
|
||||
|
||||
```bash
|
||||
cd /data/rockchip/embedding
|
||||
python -m venv venv && source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
# 1. 构建
|
||||
cd deps/KVM && PATH="/usr/local/go/bin:$PATH" bash scripts/build-deb.sh
|
||||
|
||||
# 运行 memory-service(端口 8003)
|
||||
python server.py --service memory
|
||||
# 2. 静态检查
|
||||
DEB=$(ls build/deb/kvm-server_*.deb | tail -1)
|
||||
dpkg -I "$DEB" | grep "Depends\|Recommends"
|
||||
dpkg -c "$DEB" | grep "\.service\|usr/lib"
|
||||
|
||||
# 运行 router-service(端口 8002)
|
||||
python server.py --service router
|
||||
# 3. 拷贝到设备
|
||||
scp "$DEB" pi@192.168.123.181:/tmp/
|
||||
|
||||
# 4. 安装/卸载/重装/purge/reinstall 验证
|
||||
ssh pi@192.168.123.181 << 'EOF'
|
||||
sudo dpkg -i /tmp/kvm-server_*.deb && sleep 5
|
||||
systemctl is-active kvm-server && echo "PASS" || echo "FAIL"
|
||||
sudo dpkg --remove kvm-server
|
||||
sudo dpkg -i /tmp/kvm-server_*.deb
|
||||
EOF
|
||||
```
|
||||
|
||||
## info-privacy-rs 开发(deps/info-privacy-rs)
|
||||
|
||||
开发路径:`/data/rockchip/info-privacy-rs/`,设备路径:`/home/pi/Desktop/info-privacy-rs/`
|
||||
## info-privacy-rs 开发
|
||||
|
||||
```bash
|
||||
# 交叉编译(在 x86 开发机,目标 aarch64)
|
||||
cd /data/rockchip/info-privacy-rs
|
||||
# 交叉编译 (x86 → aarch64)
|
||||
cd deps/info-privacy-rs
|
||||
cargo build --release --target aarch64-unknown-linux-gnu
|
||||
|
||||
# 本地编译 (ARM64 设备上)
|
||||
cargo build --release --features "rknn,rga"
|
||||
|
||||
# 上传到设备
|
||||
scp target/aarch64-unknown-linux-gnu/release/info-privacy-rs pi@192.168.123.181:/home/pi/Desktop/info-privacy-rs/target/release/
|
||||
scp target/release/info-privacy-rs pi@192.168.123.181:/home/pi/Desktop/info-privacy-rs/
|
||||
|
||||
# 验证
|
||||
curl http://192.168.123.181:8000/api/v1/health
|
||||
```
|
||||
|
||||
在设备上验证:
|
||||
## mem-bridge 开发
|
||||
|
||||
```bash
|
||||
curl http://192.168.123.181:8001/health
|
||||
# → {"status":"ok","rknn":true}
|
||||
cd deps/embedding
|
||||
|
||||
# Memory 服务
|
||||
python server.py --service memory # port 8001
|
||||
|
||||
# Router 服务
|
||||
python server.py --service router # port 8002
|
||||
```
|
||||
|
||||
## KVM 隐私 API
|
||||
## 关键 API 端点
|
||||
|
||||
KVM 服务在 8080 端口新增以下隐私相关端点(需 Bearer Token 鉴权):
|
||||
|
||||
```
|
||||
GET/POST /api/v1/privacy/masking 视频 PII 遮罩开关
|
||||
GET /api/v1/privacy/bboxes 当前活跃遮罩区域(调试)
|
||||
GET /api/v1/privacy/stats 今日拦截统计
|
||||
GET /api/v1/privacy/audit 审计日志(分页)
|
||||
GET /api/v1/privacy/cert 下载 CA 证书
|
||||
GET /api/v1/memory-admin/stats 记忆使用统计
|
||||
GET /api/v1/memory-admin/sessions 活跃会话列表
|
||||
```
|
||||
| 服务 | 端点 | 用途 |
|
||||
|------|------|------|
|
||||
| KVM | GET /api/health | 健康检查 |
|
||||
| KVM | GET /api/kvm/screenshot | 截图 (JPEG) |
|
||||
| KVM | GET/POST /api/v1/privacy/mode | 隐私模式 |
|
||||
| KVM | GET /api/v1/kvm/stream/stats | 视频流统计 |
|
||||
| Agent | POST /api/v1/agent/tasks | 提交任务 |
|
||||
| Agent | GET /api/v1/agent/status | Agent 状态 |
|
||||
| Privacy | POST /api/v1/analyze | PII 检测 |
|
||||
| Privacy | POST /api/v1/redact | PII 脱敏 |
|
||||
|
||||
@@ -1,66 +1,87 @@
|
||||
# KVM-Privacy Hub
|
||||
|
||||
在 KVM-over-IP 系统基础上叠加三层隐私保护能力,运行于 NanoPC-T6 (RK3588)。
|
||||
在 KVM-over-IP 系统基础上叠加两层隐私保护能力(视频遮蔽 + 网络拦截),运行于 NanoPC-T6 (RK3588)。所有防护完全在 KVM 设备端完成,无需在被控机器上部署任何软件。
|
||||
|
||||
## 架构概览
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ KVM 控制台 (8080) │
|
||||
│ Go 单体 + React Web UI + WebRTC 推流 │
|
||||
└──────────────┬──────────────────────┬───────────────────┘
|
||||
│ │
|
||||
┌──────────▼──────────┐ ┌────────▼────────────────┐
|
||||
│ Phase 1: 视频遮罩 │ │ Phase 3: AI 路由层 │
|
||||
│ OCR 检测 → YUV黑框 │ │ mem-bridge 8003/8002 │
|
||||
│ /api/v1/privacy/ │ │ Qwen2-1.5B(Ollama本地) │
|
||||
└──────────┬──────────┘ └─────────────────────────┘
|
||||
│
|
||||
┌──────────▼──────────┐
|
||||
│ Phase 2: 隐私网关 │
|
||||
│ mitmproxy (8888) │
|
||||
│ 拦截 AI 平台上传 │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
┌──────────▼──────────┐
|
||||
│ info-privacy-rs │
|
||||
│ RKNN PII 检测/脱敏 │
|
||||
│ Rust 加速 (8001) │
|
||||
└─────────────────────┘
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ KVM WebUI (React) │
|
||||
│ http://localhost:8080 │
|
||||
└────────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────────▼──────────────────────────────────┐
|
||||
│ KVM Server (Go, port 8080) │
|
||||
│ 视频管道(MPP) | HID | WebRTC | RBAC | 审计 | 隐私遮蔽 │
|
||||
└──┬──────────┬──────────┬──────────────────────┬───────────┘
|
||||
│ │ │ │
|
||||
┌──▼────┐ ┌──▼──────┐ ┌─▼───────────┐ ┌───────▼──────────┐
|
||||
│隐私网关│ │KVM Agent│ │info-privacy │ │ mem-bridge │
|
||||
│mitmproxy│ │Python │ │Rust/RKNN │ │Memory:8001 │
|
||||
│ :8888 │ │ :8890 │ │ :8000 │ │Router:8002 │
|
||||
└────────┘ └─────────┘ └────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
## 端口规划
|
||||
|
||||
| 服务 | 端口 | 说明 |
|
||||
|------|------|------|
|
||||
| kvm-server | 8080 | KVM 主服务(systemd 管理) |
|
||||
| info-privacy-rs | 8001 | Rust/RKNN PII 检测与脱敏 |
|
||||
| mem-bridge memory | 8003 | 会话记忆服务 |
|
||||
| mem-bridge router | 8002 | AI 路由服务 |
|
||||
| privacy-gateway | 8888 | mitmproxy 代理网关 |
|
||||
| 服务 | 端口 | 包名 | 说明 |
|
||||
|------|------|------|------|
|
||||
| KVM Server | 8080 | kvm-server | Go 主服务 + React WebUI + WebRTC |
|
||||
| info-privacy-rs | 8000 | kvm-privacy | Rust/RKNN PII 检测与脱敏 |
|
||||
| mem-bridge memory | 8001 | kvm-bridge | FAISS 向量搜索 + 会话存储 |
|
||||
| mem-bridge router | 8002 | kvm-bridge | AI 路由 (OpenAI/Gemini/本地) |
|
||||
| Privacy Gateway | 8888 | kvm-mitm | mitmproxy 代理网关 |
|
||||
| KVM Agent | 8890 | kvm-agent | Python AI Agent daemon |
|
||||
|
||||
## 依赖项目(submodule)
|
||||
## Debian 包
|
||||
|
||||
| 包名 | 架构 | 说明 |
|
||||
|------|------|------|
|
||||
| kvm-server | arm64 | Go KVM 核心 + C 视频管道 |
|
||||
| kvm-agent | all | Python AI Agent |
|
||||
| kvm-privacy | arm64 | Rust PII 检测 |
|
||||
| kvm-bridge | all | mem-bridge 记忆服务 |
|
||||
| kvm-mitm | all | mitmproxy 隐私网关 |
|
||||
| kvm-meta | all | 元包 (依赖全部) |
|
||||
|
||||
## 依赖项目 (submodule)
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `deps/KVM` | KVM-over-IP 核心服务(Go + React) |
|
||||
| `deps/embedding` | mem-bridge 记忆服务与 AI 路由 |
|
||||
| `deps/info-privacy-rs` | Rust/RKNN 加速的 PII 检测脱敏服务 |
|
||||
| `deps/KVM` | KVM-over-IP 核心 (Go + React + C 视频管道) |
|
||||
| `deps/embedding` | mem-bridge 记忆服务与 AI 路由 (Python) |
|
||||
| `deps/info-privacy-rs` | Rust/RKNN PII 检测脱敏引擎 |
|
||||
|
||||
## 快速部署
|
||||
|
||||
```bash
|
||||
# 克隆
|
||||
git clone --recurse-submodules http://edge-stack.synology.me:3000/qiurui/KVM-privacy.git
|
||||
cd KVM-privacy
|
||||
|
||||
# 部署隐私网关 + systemd 服务
|
||||
make deploy-gateway
|
||||
# 构建 kvm-server deb 包
|
||||
cd deps/KVM && PATH="/usr/local/go/bin:$PATH" bash scripts/build-deb.sh
|
||||
|
||||
# 部署 mem-bridge 记忆服务
|
||||
make deploy-memory
|
||||
# 构建 Python deb 包
|
||||
bash scripts/build-python-debs.sh
|
||||
|
||||
# 查看所有服务状态
|
||||
make status
|
||||
# 部署到目标设备
|
||||
scp build/deb/*.deb pi@192.168.123.181:/tmp/
|
||||
ssh pi@192.168.123.181 'sudo dpkg -i /tmp/kvm-*.deb && sudo apt-get install -f'
|
||||
|
||||
# 查看服务状态
|
||||
ssh pi@192.168.123.181 'systemctl status kvm-server kvm-agent info-privacy'
|
||||
```
|
||||
|
||||
详见 [DEVELOP.md](DEVELOP.md)。
|
||||
## 文档
|
||||
|
||||
- [系统架构总览](docs/architecture/overview.md)
|
||||
- [KVM Server (Go)](docs/architecture/kvm-server.md)
|
||||
- [原生视频管道](docs/architecture/video-pipeline.md)
|
||||
- [KVM Agent (Python)](docs/architecture/kvm-agent.md)
|
||||
- [隐私系统](docs/architecture/privacy-system.md)
|
||||
- [记忆桥接](docs/architecture/memory-bridge.md)
|
||||
- [RK3588 硬件优化](docs/architecture/hardware.md)
|
||||
- [部署打包](docs/architecture/deployment.md)
|
||||
- [开发指南](DEVELOP.md)
|
||||
- [版本历史](RELEASE.md)
|
||||
|
||||
+67
-28
@@ -1,34 +1,73 @@
|
||||
# RELEASE
|
||||
# 版本历史
|
||||
|
||||
## v1.0.0 (2026-03-05)
|
||||
|
||||
生产就绪版本。完成两层隐私保护(视频遮蔽 + 网络拦截) + AI 自主操控 + RK3588 硬件加速。
|
||||
|
||||
### 核心功能
|
||||
|
||||
**KVM Server (Go)**
|
||||
- 140+ REST API 端点, WebRTC 视频推流, USB HID 控制
|
||||
- RBAC 权限控制 (admin/auditor/operator) + JWT + TOTP 双因素认证
|
||||
- SM3 哈希链审计日志 + syslog 转发
|
||||
- HLS 视频录制 + SSH 终端会话 + 虚拟 USB 存储
|
||||
|
||||
**原生视频管道 (C/C++ + CGo)**
|
||||
- V4L2→RGA→MPP 直接编码 (绕过 GStreamer)
|
||||
- DMA-buf 零拷贝 (1080p@30fps 节省 ~90MB/s)
|
||||
- 隐私遮蔽: NV12 黑色填充 PII 区域 (编码前实时处理)
|
||||
- 自适应码率 AIMD (PLI 驱动)
|
||||
- ForceIDR 即时关键帧
|
||||
|
||||
**AI 自主操控 (Python)**
|
||||
- 鼠标优先架构: 20 键白名单, 组合键全部鼠标翻译
|
||||
- Vision LLM 规划 (gpt-4o / gemini-2.5-pro)
|
||||
- OCR 感知 + 屏幕状态检测 (BIOS/锁屏/睡眠/桌面)
|
||||
- 安全防护: Unicode NFKC 归一化 + 危险命令 regex
|
||||
- 任务队列 daemon + 模板回放
|
||||
|
||||
**隐私系统**
|
||||
- 三模式: off / audit (红色边框) / redact (黑色遮蔽)
|
||||
- mitmproxy 网络拦截: AI 域名匹配 → PII 检测/脱敏
|
||||
- info-privacy-rs: Rust/RKNN 加速 PII 检测 (docx/xlsx/pdf/img)
|
||||
- 完全设备端防护: 无需被控机器部署任何软件
|
||||
- Go 端实时视频遮蔽: OCR→regex→bbox→C pipeline
|
||||
|
||||
**记忆系统**
|
||||
- FAISS 向量搜索 + BM25 混合评分
|
||||
- RKNNLite 嵌入 (multilingual-e5-small, 384-dim)
|
||||
- AI 路由: OpenAI/Gemini/Deepseek/本地 Ollama
|
||||
|
||||
### RK3588 硬件优化 (WF6)
|
||||
- NPU 三核调度: OCR(Core 0/1) + Embedding(Core 2), 消除争用
|
||||
- CPU 亲和性: A76(Go+HID+编码) / A55(PII+mem-bridge)
|
||||
- OCR 内联化: CGo libkvm_ocr.so, 延迟 800ms→60ms
|
||||
- DMA-buf: V4L2→MPP 零拷贝编码
|
||||
|
||||
### 打包与部署
|
||||
- 6 个 Debian 包: kvm-server, kvm-agent, kvm-privacy, kvm-bridge, kvm-mitm, kvm-meta
|
||||
- 8 个 systemd 服务, CPU 亲和性配置
|
||||
- 完整安装/卸载/升级验证循环
|
||||
|
||||
### 前端
|
||||
- React WebUI: 5 Tab 隐私页面, 2 Tab 审计页面
|
||||
- GatewayMode 三态: off/audit/redact
|
||||
- PiiOverlay: audit=红色边框, redact=黑色填充
|
||||
- Agent 运行时 HID 前端禁用
|
||||
- 用户管理卡片网格布局
|
||||
|
||||
---
|
||||
|
||||
## v0.1.0 (2026-02-28)
|
||||
|
||||
初始版本,KVM-Privacy Hub 三层隐私能力落地。
|
||||
初始原型版本。
|
||||
|
||||
### 新功能
|
||||
|
||||
**Phase 1 — 视频 PII 遮罩**
|
||||
- OCR 检测屏幕 PII(身份证号、手机号等),在 WebRTC 推流前对帧叠加黑色矩形
|
||||
- Go API:`/api/v1/privacy/masking`(开关控制)、`/api/v1/privacy/bboxes`(遮罩坐标)
|
||||
- `frame_redactor.go`:纯 Go 实现 YUV420 矩形黑化,零外部依赖
|
||||
|
||||
**Phase 2 — AI 隐私网关**
|
||||
- mitmproxy 代理(端口 8888),拦截上传至 Claude/GPT 等 AI 平台的附件
|
||||
- 调用 info-privacy-rs 自动脱敏(PII → `████`),内存处理不落盘
|
||||
- SQLite append-only 审计日志,记录 PII 类型和文件哈希,不存原文
|
||||
- CA 证书管理 + KVM API 下载端点
|
||||
|
||||
**Phase 3 — AI 路由层**
|
||||
- mem-bridge 记忆服务(端口 8003):多维向量记忆 + 事实抽取
|
||||
- mem-bridge 路由服务(端口 8002):本地 Qwen2-1.5B / 云端 Claude 自动路由
|
||||
- KVM API 反向代理:`/api/v1/memory-admin/` 管理端点
|
||||
|
||||
### 依赖升级
|
||||
|
||||
- 替换 Python 版 info-privacy 为 Rust/RKNN 加速版 info-privacy-rs(端口 8001)
|
||||
- mem-bridge memory-service 端口 8001 → 8003(避免与 info-privacy-rs 冲突)
|
||||
### 功能
|
||||
- Phase 1: 视频 PII 遮罩 (OCR 检测→YUV 黑框)
|
||||
- Phase 2: mitmproxy 隐私网关 (AI 平台上传拦截)
|
||||
- Phase 3: mem-bridge 记忆服务 + AI 路由
|
||||
|
||||
### 已知限制
|
||||
|
||||
- eth1 网卡未接线,透明代理模式暂不可用,需手动配置系统代理指向 192.168.123.x:8888
|
||||
- Ollama/Qwen2-1.5B 需手动安装(`scripts/install_ollama.sh`),约占 1GB 存储
|
||||
- YUV 遮罩基于 OCR 检测坐标,OCR 触发间隔内新出现的 PII 不实时遮罩
|
||||
- 遮罩基于 OCR 间隔触发, 非实时
|
||||
- 透明代理需手动配置
|
||||
- Ollama 需手动安装
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
"""Root conftest — shared fixtures for all Python test suites."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure services/ packages are importable from project root
|
||||
_SERVICES = Path(__file__).parent / "services"
|
||||
if str(_SERVICES) not in sys.path:
|
||||
sys.path.insert(0, str(_SERVICES))
|
||||
|
||||
TESTDATA_DIR = Path(__file__).parent / "testdata"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def testdata_dir() -> Path:
|
||||
"""Return the project-wide testdata/ directory."""
|
||||
return TESTDATA_DIR
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def pii_samples(testdata_dir: Path) -> dict:
|
||||
"""Load testdata/pii/samples.json as a dict."""
|
||||
return json.loads((testdata_dir / "pii" / "samples.json").read_text())
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def pii_positive(pii_samples: dict) -> dict[str, list[dict]]:
|
||||
"""Shortcut: positive PII samples keyed by entity type."""
|
||||
return pii_samples["positive"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def pii_negative(pii_samples: dict) -> dict[str, list[dict]]:
|
||||
"""Shortcut: negative (false-positive trap) samples keyed by entity type."""
|
||||
return pii_samples["negative"]
|
||||
|
||||
|
||||
# ── Workflow fixtures (testdata/workflow/) ────────────────────────────────
|
||||
|
||||
def _load_json_dir(directory: Path) -> dict[str, dict]:
|
||||
"""Load all .json files from a directory into a {stem: data} dict."""
|
||||
result = {}
|
||||
if directory.is_dir():
|
||||
for f in sorted(directory.glob("*.json")):
|
||||
result[f.stem] = json.loads(f.read_text())
|
||||
return result
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def workflow_scenes(testdata_dir: Path) -> dict[str, dict]:
|
||||
"""Load testdata/workflow/scenes/*.json as {name: data}."""
|
||||
return _load_json_dir(testdata_dir / "workflow" / "scenes")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def llm_responses(testdata_dir: Path) -> dict[str, dict]:
|
||||
"""Load testdata/workflow/llm_responses/*.json as {name: data}."""
|
||||
return _load_json_dir(testdata_dir / "workflow" / "llm_responses")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def memory_responses(testdata_dir: Path) -> dict[str, dict]:
|
||||
"""Load testdata/workflow/memory/*.json as {name: data}."""
|
||||
return _load_json_dir(testdata_dir / "workflow" / "memory")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def operation_templates(testdata_dir: Path) -> dict[str, dict]:
|
||||
"""Load testdata/workflow/templates/*.json as {name: data}."""
|
||||
return _load_json_dir(testdata_dir / "workflow" / "templates")
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
Package: kvm-agent
|
||||
Version: 1.0.0-4
|
||||
Architecture: all
|
||||
Maintainer: KVM-Privacy <noreply@kvm-privacy.local>
|
||||
Depends: python3 (>= 3.9)
|
||||
Recommends: python3-httpx, python3-yaml, python3-aiohttp
|
||||
Description: KVM AI Agent v2
|
||||
Self-contained AI agent daemon for autonomous KVM control.
|
||||
Uses MariaDB for task queue persistence.
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
case "$1" in
|
||||
configure)
|
||||
mkdir -p /etc/kvm-agent
|
||||
if [ ! -f /etc/kvm-agent/secrets.env ]; then
|
||||
cat > /etc/kvm-agent/secrets.env <<'EOF'
|
||||
KVM_JWT_TOKEN=
|
||||
LLM_API_KEY=
|
||||
KVM_AGENT_DB_HOST=localhost
|
||||
KVM_AGENT_DB_USER=kvm_agent
|
||||
KVM_AGENT_DB_PASS=changeme
|
||||
KVM_AGENT_DB_NAME=kvm
|
||||
EOF
|
||||
chmod 600 /etc/kvm-agent/secrets.env
|
||||
chown root:root /etc/kvm-agent/secrets.env
|
||||
echo "WARNING: Edit /etc/kvm-agent/secrets.env with actual credentials"
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
systemctl enable kvm-agent.service 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
case "$1" in
|
||||
purge)
|
||||
# Clean runtime-generated __pycache__ files
|
||||
rm -rf /usr/lib/kvm-agent/kvm_agent/__pycache__
|
||||
rmdir --ignore-fail-on-non-empty /usr/lib/kvm-agent/kvm_agent 2>/dev/null || true
|
||||
rmdir --ignore-fail-on-non-empty /usr/lib/kvm-agent 2>/dev/null || true
|
||||
# Clean config directory
|
||||
rm -rf /etc/kvm-agent
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
case "$1" in
|
||||
remove|purge)
|
||||
systemctl stop kvm-agent.service 2>/dev/null || true
|
||||
systemctl disable kvm-agent.service 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=KVM AI Agent v2
|
||||
After=network.target mariadb.service
|
||||
Wants=mem-bridge-memory.service
|
||||
StartLimitInterval=300
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
EnvironmentFile=/etc/kvm-agent/secrets.env
|
||||
ExecStart=/usr/bin/kvm-agent daemon
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
MemoryMax=512M
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
export PYTHONPATH=/usr/lib/kvm-agent
|
||||
exec python3 -m kvm_agent "$@"
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
Package: kvm-bridge
|
||||
Version: 1.0.0-1
|
||||
Architecture: all
|
||||
Maintainer: KVM-Privacy <noreply@kvm-privacy.local>
|
||||
Depends: python3 (>= 3.9), python3-venv
|
||||
Description: KVM mem-bridge session memory service
|
||||
FAISS vector search + SQLite session storage for AI agent memory.
|
||||
Provides memory and router sub-services.
|
||||
Installed in isolated Python venv at /opt/kvm-bridge.
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
VENV_DIR="/opt/kvm-bridge"
|
||||
LIB_DIR="/usr/lib/kvm-bridge"
|
||||
REQ_FILE="$LIB_DIR/requirements.txt"
|
||||
|
||||
if [ "$1" = "configure" ]; then
|
||||
# Create venv if not exists
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
# Install dependencies
|
||||
if [ -f "$REQ_FILE" ]; then
|
||||
"$VENV_DIR/bin/pip" install --quiet -r "$REQ_FILE" || true
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable mem-bridge-memory.service mem-bridge-router.service || true
|
||||
# Do not auto-start — bridge is disabled by default in kvm.toml
|
||||
echo "kvm-bridge installed. Enable in /etc/kvm/kvm.toml: [services.bridge] enabled = true"
|
||||
fi
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if [ "$1" = "remove" ] || [ "$1" = "purge" ]; then
|
||||
systemctl stop mem-bridge-memory.service mem-bridge-router.service || true
|
||||
systemctl disable mem-bridge-memory.service mem-bridge-router.service || true
|
||||
fi
|
||||
|
||||
if [ "$1" = "purge" ]; then
|
||||
rm -rf /opt/kvm-bridge
|
||||
fi
|
||||
@@ -0,0 +1,26 @@
|
||||
[Unit]
|
||||
Description=Mem-Bridge Memory Service
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
StartLimitInterval=300
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
Group=pi
|
||||
WorkingDirectory=/home/pi/Desktop/embed-db
|
||||
Environment=PYTHONPATH=/home/pi/Desktop/embed-db/src
|
||||
EnvironmentFile=-/home/pi/Desktop/embed-db/.env
|
||||
ExecStart=/home/pi/Desktop/embed-db/venv/bin/python server.py --service memory
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
MemoryMax=512M
|
||||
# RK3588: pin to A55 small cores 2-3 (Python FAISS + embedding)
|
||||
CPUAffinity=2 3
|
||||
LimitNOFILE=4096
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,27 @@
|
||||
[Unit]
|
||||
Description=Mem-Bridge Router Service
|
||||
After=network.target mem-bridge-memory.service
|
||||
Wants=network.target
|
||||
Requires=mem-bridge-memory.service
|
||||
StartLimitInterval=300
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
Group=pi
|
||||
WorkingDirectory=/home/pi/Desktop/embed-db
|
||||
Environment=PYTHONPATH=/home/pi/Desktop/embed-db/src
|
||||
EnvironmentFile=-/home/pi/Desktop/embed-db/.env
|
||||
ExecStart=/home/pi/Desktop/embed-db/venv/bin/python server.py --service router
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
MemoryMax=2G
|
||||
# RK3588: pin to A55 small cores 2-3 (AI routing + LLM orchestration)
|
||||
CPUAffinity=2 3
|
||||
LimitNOFILE=4096
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
export PYTHONPATH=/usr/lib/kvm-bridge
|
||||
exec /opt/kvm-bridge/bin/python /usr/lib/kvm-bridge/server.py --service memory "$@"
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
export PYTHONPATH=/usr/lib/kvm-bridge
|
||||
exec /opt/kvm-bridge/bin/python /usr/lib/kvm-bridge/server.py --service router "$@"
|
||||
@@ -0,0 +1 @@
|
||||
"""mem-bridge: 记忆注入 + 多模型路由中间层。"""
|
||||
@@ -0,0 +1,95 @@
|
||||
"""后端适配器:统一 chat() 接口,支持 Anthropic / OpenAI / 兼容格式。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import AsyncIterator
|
||||
|
||||
from mem_bridge.config import BackendConfig
|
||||
from mem_bridge.models import ChatMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseBackend:
|
||||
"""所有后端的基类。"""
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
stream: bool = False,
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
) -> str | AsyncIterator[str]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class AnthropicBackend(BaseBackend):
|
||||
def __init__(self, cfg: BackendConfig) -> None:
|
||||
import anthropic
|
||||
self._client = anthropic.AsyncAnthropic(api_key=cfg.api_key)
|
||||
self._model = cfg.model
|
||||
|
||||
async def chat(self, messages, stream=False, temperature=None, max_tokens=None):
|
||||
system_parts = [m.content for m in messages if m.role == "system"]
|
||||
user_messages = [
|
||||
{"role": m.role, "content": m.content}
|
||||
for m in messages if m.role != "system"
|
||||
]
|
||||
kwargs: dict = {"model": self._model, "messages": user_messages, "max_tokens": max_tokens or 2048}
|
||||
if system_parts:
|
||||
kwargs["system"] = "\n".join(system_parts)
|
||||
if temperature is not None:
|
||||
kwargs["temperature"] = temperature
|
||||
|
||||
if stream:
|
||||
async def _stream_gen():
|
||||
async with self._client.messages.stream(**kwargs) as s:
|
||||
async for text in s.text_stream:
|
||||
yield text
|
||||
return _stream_gen()
|
||||
|
||||
resp = await self._client.messages.create(**kwargs)
|
||||
return resp.content[0].text
|
||||
|
||||
|
||||
class OpenAIBackend(BaseBackend):
|
||||
def __init__(self, cfg: BackendConfig) -> None:
|
||||
from openai import AsyncOpenAI
|
||||
kwargs: dict = {"api_key": cfg.api_key}
|
||||
if cfg.base_url:
|
||||
kwargs["base_url"] = cfg.base_url
|
||||
self._client = AsyncOpenAI(**kwargs)
|
||||
self._model = cfg.model
|
||||
|
||||
async def chat(self, messages, stream=False, temperature=None, max_tokens=None):
|
||||
payload = [{"role": m.role, "content": m.content} for m in messages]
|
||||
kwargs: dict = {"model": self._model, "messages": payload, "stream": stream}
|
||||
if temperature is not None:
|
||||
kwargs["temperature"] = temperature
|
||||
if max_tokens is not None:
|
||||
kwargs["max_tokens"] = max_tokens
|
||||
|
||||
if stream:
|
||||
async def _stream_gen():
|
||||
# chat.completions.create() 返回 AsyncStream,不是 coroutine
|
||||
# 不能 await,直接在 async for 中迭代
|
||||
async for chunk in self._client.chat.completions.create(**kwargs):
|
||||
delta = chunk.choices[0].delta.content
|
||||
if delta:
|
||||
yield delta
|
||||
return _stream_gen()
|
||||
|
||||
resp = await self._client.chat.completions.create(**kwargs)
|
||||
return resp.choices[0].message.content or ""
|
||||
|
||||
|
||||
def make_backend(name: str, cfg: BackendConfig) -> BaseBackend:
|
||||
"""工厂函数:根据 type 创建对应后端实例。"""
|
||||
if cfg.type == "anthropic":
|
||||
return AnthropicBackend(cfg)
|
||||
if cfg.type in ("openai", "compatible"):
|
||||
return OpenAIBackend(cfg)
|
||||
if cfg.type == "ollama":
|
||||
# Ollama 提供 OpenAI 兼容 API,复用 OpenAIBackend
|
||||
cfg.base_url = cfg.base_url or "http://localhost:11434/v1"
|
||||
return OpenAIBackend(cfg)
|
||||
raise ValueError(f"未知后端类型: {cfg.type!r}(backend={name!r})")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""语义复杂度评分器:query 与复杂任务模板集的最大余弦相似度。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ComplexityScorer:
|
||||
"""启动时预计算模板向量,运行时 <2ms 评分。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
embedder,
|
||||
complex_templates: list[str],
|
||||
threshold: float = 0.72,
|
||||
) -> None:
|
||||
self._threshold = threshold
|
||||
if complex_templates:
|
||||
vecs = embedder.embed(complex_templates, prefix="passage")
|
||||
self._template_vecs: np.ndarray = vecs # (N, dim)
|
||||
else:
|
||||
self._template_vecs = np.empty((0, 1), dtype=np.float32)
|
||||
self._embedder = embedder
|
||||
|
||||
def score(self, query: str) -> float:
|
||||
"""返回 0-1 复杂度分数。"""
|
||||
if self._template_vecs.shape[0] == 0:
|
||||
return 0.0
|
||||
q_vec = self._embedder.embed_query(query) # (dim,)
|
||||
sims = self._template_vecs @ q_vec # (N,)
|
||||
return float(sims.max())
|
||||
|
||||
def is_complex(self, query: str) -> bool:
|
||||
return self.score(query) > self._threshold
|
||||
|
||||
@staticmethod
|
||||
def load_templates(path: Path) -> list[str]:
|
||||
"""从文件加载模板,忽略空行和 # 注释行。"""
|
||||
templates = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
templates.append(line)
|
||||
return templates
|
||||
@@ -0,0 +1,157 @@
|
||||
"""token 预算管理与上下文压缩器(五层优先级)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# tiktoken 可选,回退到字符估算
|
||||
try:
|
||||
import tiktoken
|
||||
_ENC = tiktoken.get_encoding("cl100k_base")
|
||||
def count_tokens(text: str) -> int:
|
||||
return len(_ENC.encode(text))
|
||||
except Exception:
|
||||
def count_tokens(text: str) -> int: # type: ignore[misc]
|
||||
return max(1, len(text) // 4)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompressResult:
|
||||
facts: list[dict[str, Any]]
|
||||
long_term: list[dict[str, Any]]
|
||||
short_term: list[dict[str, Any]]
|
||||
formatted: str
|
||||
tokens_used: int
|
||||
budget: dict[str, int]
|
||||
|
||||
|
||||
class Compressor:
|
||||
"""五层优先级 token 预算压缩器。
|
||||
|
||||
优先级(从高到低):
|
||||
1. facts(事实) — 语义检索,category 独立 top-k
|
||||
2. working_memory — 最近 N 轮,无条件注入
|
||||
3. summary — 当前 session 摘要(在 short_term 中 role='summary')
|
||||
4. history — 语义历史(short_term 中非 summary 且非 working_memory)
|
||||
5. knowledge(long_term)— 文档知识库
|
||||
"""
|
||||
|
||||
FACTS_RATIO = 0.25
|
||||
LONG_RATIO = 0.25
|
||||
SHORT_RATIO = 0.35
|
||||
|
||||
def __init__(self, token_budget: int = 2000) -> None:
|
||||
self._budget = token_budget
|
||||
|
||||
def compress(
|
||||
self,
|
||||
facts: list[dict[str, Any]],
|
||||
working_memory: list[dict[str, Any]],
|
||||
long_term: list[dict[str, Any]],
|
||||
short_term: list[dict[str, Any]],
|
||||
) -> CompressResult:
|
||||
facts_budget = int(self._budget * self.FACTS_RATIO)
|
||||
long_budget = int(self._budget * self.LONG_RATIO)
|
||||
short_budget = int(self._budget * self.SHORT_RATIO)
|
||||
|
||||
# facts:按预算截断
|
||||
selected_facts = self._trim_items(facts, "content", facts_budget)
|
||||
|
||||
# working_memory:计算 token 消耗(无条件注入)
|
||||
wm_tokens = sum(count_tokens(t.get("content", "")) for t in working_memory)
|
||||
|
||||
# working_memory 中的 id 集合,用于去重
|
||||
wm_ids = {t.get("id") for t in working_memory if t.get("id") is not None}
|
||||
|
||||
# summary 单独提取(置于 short_term 最前)
|
||||
summaries = [t for t in short_term if t.get("role") == "summary"]
|
||||
# history = short_term 中非 summary 且不在 working_memory 中
|
||||
history = [
|
||||
t for t in short_term
|
||||
if t.get("role") != "summary" and t.get("id") not in wm_ids
|
||||
]
|
||||
|
||||
remaining_short = max(0, short_budget - wm_tokens)
|
||||
selected_summaries = self._trim_items(summaries, "content", remaining_short // 2)
|
||||
selected_history = self._trim_items(
|
||||
history, "content",
|
||||
max(0, remaining_short - sum(count_tokens(t.get("content", "")) for t in selected_summaries))
|
||||
)
|
||||
|
||||
selected_long = self._trim_items(long_term, "chunk", long_budget)
|
||||
|
||||
formatted = self._format(
|
||||
selected_facts, working_memory, selected_summaries, selected_history, selected_long
|
||||
)
|
||||
tokens_used = count_tokens(formatted)
|
||||
|
||||
return CompressResult(
|
||||
facts=selected_facts,
|
||||
long_term=selected_long,
|
||||
short_term=selected_summaries + selected_history,
|
||||
formatted=formatted,
|
||||
tokens_used=tokens_used,
|
||||
budget={
|
||||
"facts": facts_budget,
|
||||
"long_term": long_budget,
|
||||
"short_term": short_budget,
|
||||
"used": tokens_used,
|
||||
},
|
||||
)
|
||||
|
||||
def _trim_items(
|
||||
self, items: list[dict], text_key: str, budget: int
|
||||
) -> list[dict]:
|
||||
result, used = [], 0
|
||||
for item in items:
|
||||
n = count_tokens(item.get(text_key, ""))
|
||||
if used + n > budget:
|
||||
break
|
||||
result.append(item)
|
||||
used += n
|
||||
return result
|
||||
|
||||
def _format(
|
||||
self,
|
||||
facts: list[dict],
|
||||
working_memory: list[dict],
|
||||
summaries: list[dict],
|
||||
history: list[dict],
|
||||
long_term: list[dict],
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
|
||||
if facts:
|
||||
lines = []
|
||||
for f in facts:
|
||||
cat = f.get("category", "other").upper()
|
||||
content = f.get("content", "")
|
||||
lines.append(f"{cat}: {content}")
|
||||
parts.append(f"<facts>\n{chr(10).join(lines)}\n</facts>")
|
||||
|
||||
if working_memory:
|
||||
wm_lines = "\n".join(
|
||||
f"{t.get('role', 'unknown').upper()}: {t.get('content', '')}"
|
||||
for t in working_memory
|
||||
)
|
||||
parts.append(f"<working_memory>\n{wm_lines}\n</working_memory>")
|
||||
|
||||
short_items = summaries + history
|
||||
if short_items:
|
||||
hist_lines = "\n".join(
|
||||
f"{t.get('role', 'unknown').upper()}: {t.get('content', '')}"
|
||||
for t in short_items
|
||||
)
|
||||
parts.append(f"<history>\n{hist_lines}\n</history>")
|
||||
|
||||
if long_term:
|
||||
chunks = "\n\n".join(
|
||||
f"[{item.get('path', '?')}]\n{item.get('chunk', '')}"
|
||||
for item in long_term
|
||||
)
|
||||
parts.append(f"<knowledge>\n{chunks}\n</knowledge>")
|
||||
|
||||
return "\n\n".join(parts)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""mem-bridge 配置:YAML 驱动的 dataclass。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackendConfig:
|
||||
type: str # anthropic | openai | compatible
|
||||
model: str
|
||||
api_key: str = ""
|
||||
base_url: str = "" # compatible 类型使用
|
||||
|
||||
|
||||
@dataclass
|
||||
class BridgeConfig:
|
||||
# memory-service
|
||||
memory_host: str = "0.0.0.0"
|
||||
memory_port: int = 8001
|
||||
db_dir: Path = field(default_factory=lambda: Path("~/.embed_db").expanduser())
|
||||
short_term_top_k: int = 5
|
||||
long_term_top_k: int = 3
|
||||
recent_turns_verbatim: int = 2
|
||||
|
||||
# router-service
|
||||
router_host: str = "0.0.0.0"
|
||||
router_port: int = 8000
|
||||
token_budget: int = 2000
|
||||
complexity_threshold: float = 0.72
|
||||
complexity_templates_path: Path = field(
|
||||
default_factory=lambda: Path("config/complexity_templates.txt")
|
||||
)
|
||||
default_backend: str = ""
|
||||
heavy_backend: str = ""
|
||||
backends: dict[str, BackendConfig] = field(default_factory=dict)
|
||||
fallback_chain: list[str] = field(default_factory=list)
|
||||
fallback_timeout: float = 10.0
|
||||
routing_rules: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
# 摘要归档配置
|
||||
summarization_enabled: bool = True
|
||||
summarization_window_size: int = 20
|
||||
summarization_backend: str = "" # 空时使用 default_backend
|
||||
|
||||
# Query 改写配置
|
||||
query_rewrite_enabled: bool = False
|
||||
query_rewrite_backend: str = "" # 空时使用 default_backend
|
||||
|
||||
# System Prompt 模板配置
|
||||
prompt_templates_dir: Path = field(
|
||||
default_factory=lambda: Path("config/prompts")
|
||||
)
|
||||
prompt_templates_active: list[str] = field(default_factory=list)
|
||||
|
||||
# 事实提取配置
|
||||
fact_extraction_enabled: bool = False
|
||||
fact_extraction_backend: str = ""
|
||||
fact_extraction_window_turns: int = 6
|
||||
fact_extraction_dedup_threshold: float = 0.92
|
||||
|
||||
# 混合检索配置
|
||||
hybrid_search_enabled: bool = True
|
||||
hybrid_search_bm25_weight: float = 0.3
|
||||
|
||||
# 时间衰减配置
|
||||
time_decay_enabled: bool = True
|
||||
time_decay_lambda: float = 0.05
|
||||
|
||||
# Working Memory 层
|
||||
working_memory_turns: int = 2
|
||||
|
||||
# facts category 独立 top-k
|
||||
fact_category_top_k: dict[str, int] = field(
|
||||
default_factory=lambda: {
|
||||
"preference": 5,
|
||||
"background": 5,
|
||||
"goal": 3,
|
||||
"habit": 3,
|
||||
"other": 2,
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, path: Path) -> BridgeConfig:
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
mem = raw.get("memory_service", {})
|
||||
rtr = raw.get("router_service", {})
|
||||
complexity = rtr.get("complexity", {})
|
||||
|
||||
backends: dict[str, BackendConfig] = {}
|
||||
for name, bcfg in rtr.get("backends", {}).items():
|
||||
if name in ("default", "heavy"):
|
||||
continue
|
||||
api_key = bcfg.get("api_key", "")
|
||||
# 展开环境变量 ${VAR}
|
||||
if api_key.startswith("${") and api_key.endswith("}"):
|
||||
api_key = os.environ.get(api_key[2:-1], "")
|
||||
backends[name] = BackendConfig(
|
||||
type=bcfg["type"],
|
||||
model=bcfg["model"],
|
||||
api_key=api_key,
|
||||
base_url=bcfg.get("base_url", ""),
|
||||
)
|
||||
|
||||
summ = mem.get("summarization", {})
|
||||
qr = rtr.get("query_rewrite", {})
|
||||
pt = rtr.get("prompt_templates", {})
|
||||
|
||||
_fe = mem.get("fact_extraction", {})
|
||||
_hs = mem.get("hybrid_search", {})
|
||||
_td = mem.get("time_decay", {})
|
||||
_default_cat_top_k = {"preference": 5, "background": 5, "goal": 3, "habit": 3, "other": 2}
|
||||
|
||||
db_dir_raw = mem.get("db_dir", "~/.embed_db")
|
||||
return cls(
|
||||
memory_host=mem.get("host", "0.0.0.0"),
|
||||
memory_port=mem.get("port", 8001),
|
||||
db_dir=Path(db_dir_raw).expanduser(),
|
||||
short_term_top_k=mem.get("short_term_top_k", 5),
|
||||
long_term_top_k=mem.get("long_term_top_k", 3),
|
||||
recent_turns_verbatim=mem.get("recent_turns_verbatim", 2),
|
||||
router_host=rtr.get("host", "0.0.0.0"),
|
||||
router_port=rtr.get("port", 8000),
|
||||
token_budget=rtr.get("token_budget", 2000),
|
||||
complexity_threshold=complexity.get("threshold", 0.72),
|
||||
complexity_templates_path=Path(
|
||||
complexity.get("templates", "config/complexity_templates.txt")
|
||||
),
|
||||
default_backend=rtr.get("backends", {}).get("default", ""),
|
||||
heavy_backend=rtr.get("backends", {}).get("heavy", ""),
|
||||
backends=backends,
|
||||
fallback_chain=rtr.get("fallback_chain", []),
|
||||
fallback_timeout=rtr.get("fallback_timeout", 10.0),
|
||||
routing_rules=rtr.get("routing_rules", {}),
|
||||
summarization_enabled=summ.get("enabled", True),
|
||||
summarization_window_size=summ.get("window_size", 20),
|
||||
summarization_backend=summ.get("backend") or "",
|
||||
query_rewrite_enabled=qr.get("enabled", False),
|
||||
query_rewrite_backend=qr.get("backend") or "",
|
||||
prompt_templates_dir=Path(pt.get("dir") or "config/prompts"),
|
||||
prompt_templates_active=pt.get("active") or [],
|
||||
fact_extraction_enabled=_fe.get("enabled", False),
|
||||
fact_extraction_backend=_fe.get("backend") or "",
|
||||
fact_extraction_window_turns=_fe.get("window_turns", 6),
|
||||
fact_extraction_dedup_threshold=_fe.get("dedup_threshold", 0.92),
|
||||
hybrid_search_enabled=_hs.get("enabled", True),
|
||||
hybrid_search_bm25_weight=_hs.get("bm25_weight", 0.3),
|
||||
time_decay_enabled=_td.get("enabled", True),
|
||||
time_decay_lambda=_td.get("lambda", 0.05),
|
||||
working_memory_turns=mem.get("working_memory_turns", 2),
|
||||
fact_category_top_k={**_default_cat_top_k, **(mem.get("fact_category_top_k") or {})},
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""会话上下文管理:滑动窗口 + 异步摘要归档。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mem_bridge.backends import BaseBackend
|
||||
from mem_bridge.turn_store import TurnStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SUMMARY_SYSTEM = "你是对话摘要助手。"
|
||||
_SUMMARY_PROMPT = (
|
||||
"请将以下对话历史压缩为1-2段摘要,保留关键信息、决定和结论,省略闲聊:\n\n{turns}"
|
||||
)
|
||||
|
||||
|
||||
class ContextManager:
|
||||
"""滑动窗口 + 异步摘要归档。
|
||||
|
||||
当会话普通轮次超出 window_size 时,将旧轮次摘要后归档为
|
||||
role='summary' 的特殊轮次,并删除原始旧轮次。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
turn_store: TurnStore,
|
||||
backend: BaseBackend,
|
||||
window_size: int = 20,
|
||||
) -> None:
|
||||
self._store = turn_store
|
||||
self._backend = backend
|
||||
self._window_size = window_size
|
||||
|
||||
async def check_and_summarize(self, session_id: str) -> None:
|
||||
"""检查会话是否超出滑动窗口,超出则生成摘要归档旧轮次。"""
|
||||
turns = self._store.list_turns(session_id)
|
||||
regular = [t for t in turns if t["role"] != "summary"]
|
||||
if len(regular) <= self._window_size:
|
||||
return
|
||||
old_turns = regular[: len(regular) - self._window_size]
|
||||
await self._summarize_and_archive(session_id, old_turns)
|
||||
|
||||
async def _summarize_and_archive(
|
||||
self, session_id: str, old_turns: list[dict]
|
||||
) -> None:
|
||||
"""调用 LLM 生成摘要,存入 TurnStore,删除旧轮次。"""
|
||||
from mem_bridge.models import ChatMessage # 延迟加载,避免循环 import
|
||||
|
||||
turns_text = "\n".join(
|
||||
f"{t['role'].upper()}: {t['content']}" for t in old_turns
|
||||
)
|
||||
messages = [
|
||||
ChatMessage(role="system", content=_SUMMARY_SYSTEM),
|
||||
ChatMessage(
|
||||
role="user",
|
||||
content=_SUMMARY_PROMPT.format(turns=turns_text),
|
||||
),
|
||||
]
|
||||
try:
|
||||
summary = str(await self._backend.chat(messages, stream=False))
|
||||
self._store.add_turn(session_id, "summary", summary)
|
||||
ids = [t["id"] for t in old_turns]
|
||||
self._store.delete_turns_by_ids(ids)
|
||||
logger.info(
|
||||
"session %s: 归档 %d 轮,生成摘要(%d 字)",
|
||||
session_id, len(old_turns), len(str(summary)),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("摘要生成失败,跳过归档: %s", e)
|
||||
@@ -0,0 +1,129 @@
|
||||
"""LLM 异步提取对话事实,写入 FactStore。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mem_bridge.backends import BaseBackend
|
||||
from mem_bridge.fact_store import FactStore
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_EXTRACT_SYSTEM = (
|
||||
"你是事实提取助手。从对话中提取用户的持久化事实。"
|
||||
"只提取明确信息,不推断,不重复已知事实。"
|
||||
"category 枚举: preference/background/goal/habit/other。"
|
||||
"输出 JSON 数组,不要其他文字。"
|
||||
)
|
||||
_EXTRACT_PROMPT = (
|
||||
"从以下对话中提取用户的持久化事实(姓名、偏好、背景、目标、习惯等)。\n"
|
||||
"若无可提取事实,返回空数组 []。\n"
|
||||
"格式:[{{\"content\":\"...\", \"category\":\"...\", \"entity\":\"user\","
|
||||
" \"tags\":[\"...\"], \"confidence\":0.9}}]\n\n对话:\n{turns_text}"
|
||||
)
|
||||
|
||||
|
||||
class FactExtractor:
|
||||
"""LLM 异步提取对话事实,写入 FactStore。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
backend: BaseBackend,
|
||||
fact_store: FactStore,
|
||||
embedder: Any,
|
||||
write_lock: Any,
|
||||
enabled: bool = False,
|
||||
window_turns: int = 6,
|
||||
dedup_threshold: float = 0.92,
|
||||
) -> None:
|
||||
self._backend = backend
|
||||
self._fact_store = fact_store
|
||||
self._embedder = embedder
|
||||
self._write_lock = write_lock
|
||||
self._enabled = enabled
|
||||
self._window_turns = window_turns
|
||||
self._dedup_threshold = dedup_threshold
|
||||
|
||||
async def extract_and_store(self, session_id: str, turns: list[dict]) -> int:
|
||||
"""从最近轮次提取事实,返回新增条数。失败静默跳过。"""
|
||||
if not self._enabled:
|
||||
return 0
|
||||
recent = turns[-self._window_turns:]
|
||||
if not recent:
|
||||
return 0
|
||||
|
||||
turns_text = "\n".join(
|
||||
f"{t['role'].upper()}: {t['content']}" for t in recent
|
||||
)
|
||||
from mem_bridge.models import ChatMessage
|
||||
|
||||
messages = [
|
||||
ChatMessage(role="system", content=_EXTRACT_SYSTEM),
|
||||
ChatMessage(role="user", content=_EXTRACT_PROMPT.format(turns_text=turns_text)),
|
||||
]
|
||||
try:
|
||||
raw = str(await self._backend.chat(messages, stream=False))
|
||||
candidates = self._parse_json(raw)
|
||||
return await self._store_candidates(session_id, candidates)
|
||||
except Exception as e:
|
||||
logger.warning("事实提取失败,跳过: %s", e)
|
||||
return 0
|
||||
|
||||
def _parse_json(self, raw: str) -> list[dict]:
|
||||
"""容错解析 LLM 返回的 JSON 数组。"""
|
||||
match = re.search(r"\[.*\]", raw, re.DOTALL)
|
||||
if not match:
|
||||
return []
|
||||
try:
|
||||
result = json.loads(match.group())
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return []
|
||||
|
||||
async def _store_candidates(
|
||||
self, session_id: str, candidates: list[dict]
|
||||
) -> int:
|
||||
added = 0
|
||||
for c in candidates:
|
||||
content = str(c.get("content", "")).strip()
|
||||
if not content:
|
||||
continue
|
||||
category = str(c.get("category", "other"))
|
||||
entity = c.get("entity") or None
|
||||
tags = c.get("tags", [])
|
||||
if not isinstance(tags, list):
|
||||
tags = []
|
||||
confidence = float(c.get("confidence", 1.0))
|
||||
|
||||
vec = np.array(self._embedder.embed_query(content), dtype=np.float32)
|
||||
|
||||
# 语义去重检查 + 写入全部纳入锁保护,避免并发 TOCTOU 竞态
|
||||
async with self._write_lock:
|
||||
ids, dists = self._fact_store.faiss_search(vec, top_k=1)
|
||||
if ids.size > 0 and float(dists[0]) >= self._dedup_threshold:
|
||||
existing = self._fact_store.search_by_faiss_ids([int(ids[0])])
|
||||
if existing:
|
||||
old = existing[0]
|
||||
if len(content) > len(old["content"]):
|
||||
# 新内容更具体 → 更新
|
||||
self._fact_store.update_fact(old["id"], content, confidence)
|
||||
logger.debug("更新事实 id=%d: %r", old["id"], content[:50])
|
||||
elif content != old["content"]:
|
||||
# 相似但不更具体 → 标记冲突
|
||||
self._fact_store.mark_conflict(old["id"])
|
||||
logger.debug("标记冲突 id=%d", old["id"])
|
||||
continue
|
||||
self._fact_store.add_fact(
|
||||
content, category, entity, tags,
|
||||
session_id, vec, confidence,
|
||||
)
|
||||
added += 1
|
||||
logger.debug("新增事实: category=%s content=%r", category, content[:50])
|
||||
return added
|
||||
@@ -0,0 +1,230 @@
|
||||
"""跨 session 持久化事实存储:SQLite + facts.faiss。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS facts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'other',
|
||||
entity TEXT,
|
||||
tags TEXT DEFAULT '[]',
|
||||
source_session TEXT NOT NULL,
|
||||
faiss_id INTEGER UNIQUE,
|
||||
confidence REAL DEFAULT 1.0,
|
||||
conflict INTEGER DEFAULT 0,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_facts_category ON facts(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_facts_entity ON facts(entity);
|
||||
CREATE INDEX IF NOT EXISTS idx_facts_faiss ON facts(faiss_id);
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts USING fts5(
|
||||
content, category, entity, tags,
|
||||
content='facts', content_rowid='id'
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS facts_ai AFTER INSERT ON facts BEGIN
|
||||
INSERT INTO facts_fts(rowid, content, category, entity, tags)
|
||||
VALUES (new.id, new.content, new.category, new.entity, new.tags);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS facts_ad AFTER DELETE ON facts BEGIN
|
||||
INSERT INTO facts_fts(facts_fts, rowid, content, category, entity, tags)
|
||||
VALUES ('delete', old.id, old.content, old.category, old.entity, old.tags);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS facts_au AFTER UPDATE ON facts BEGIN
|
||||
INSERT INTO facts_fts(facts_fts, rowid, content, category, entity, tags)
|
||||
VALUES ('delete', old.id, old.content, old.category, old.entity, old.tags);
|
||||
INSERT INTO facts_fts(rowid, content, category, entity, tags)
|
||||
VALUES (new.id, new.content, new.category, new.entity, new.tags);
|
||||
END;
|
||||
"""
|
||||
|
||||
|
||||
class FactStore:
|
||||
"""跨 session 持久化事实存储,SQLite + facts.faiss(IndexFlatIP)。"""
|
||||
|
||||
def __init__(self, db_path: Path, index_path: Path, emb_dim: int = 384) -> None:
|
||||
self._db_path = db_path
|
||||
self._index_path = index_path
|
||||
self._emb_dim = emb_dim
|
||||
self._index = None
|
||||
self._init()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self._db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL;")
|
||||
conn.execute("PRAGMA synchronous=NORMAL;")
|
||||
return conn
|
||||
|
||||
def _init(self) -> None:
|
||||
with self._connect() as conn:
|
||||
conn.executescript(_DDL)
|
||||
|
||||
def load_index(self) -> None:
|
||||
"""启动时从磁盘加载 facts.faiss(mmap=False 保持可写)。"""
|
||||
import faiss # type: ignore[import]
|
||||
|
||||
if self._index_path.exists():
|
||||
try:
|
||||
self._index = faiss.read_index(str(self._index_path))
|
||||
logger.info("facts.faiss 已加载: ntotal=%d", self._index.ntotal)
|
||||
except Exception as e:
|
||||
logger.warning("facts.faiss 加载失败,从空索引开始: %s", e)
|
||||
|
||||
def _ensure_index(self) -> None:
|
||||
if self._index is None:
|
||||
import faiss # type: ignore[import]
|
||||
flat = faiss.IndexFlatIP(self._emb_dim)
|
||||
self._index = faiss.IndexIDMap2(flat)
|
||||
|
||||
def _save_index(self) -> None:
|
||||
if self._index is None:
|
||||
return
|
||||
import faiss # type: ignore[import]
|
||||
self._index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
faiss.write_index(self._index, str(self._index_path))
|
||||
|
||||
def next_faiss_id(self) -> int:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute("SELECT MAX(faiss_id) FROM facts").fetchone()
|
||||
return (row[0] + 1) if row[0] is not None else 0
|
||||
|
||||
def add_fact(
|
||||
self,
|
||||
content: str,
|
||||
category: str,
|
||||
entity: str | None,
|
||||
tags: list[str],
|
||||
source_session: str,
|
||||
vec: np.ndarray,
|
||||
confidence: float = 1.0,
|
||||
conflict: bool = False,
|
||||
) -> int:
|
||||
"""写入事实,返回 fact id。调用方需在写锁内调用此方法。"""
|
||||
self._ensure_index()
|
||||
faiss_id = self.next_faiss_id()
|
||||
now = time.time()
|
||||
vec_arr = vec.astype(np.float32).reshape(1, -1)
|
||||
id_arr = np.array([faiss_id], dtype=np.int64)
|
||||
self._index.add_with_ids(vec_arr, id_arr) # type: ignore[union-attr]
|
||||
self._save_index()
|
||||
with self._connect() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO facts"
|
||||
"(content,category,entity,tags,source_session,"
|
||||
"faiss_id,confidence,conflict,created_at,updated_at)"
|
||||
" VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
content, category, entity,
|
||||
json.dumps(tags, ensure_ascii=False),
|
||||
source_session, faiss_id, confidence,
|
||||
int(conflict), now, now,
|
||||
),
|
||||
)
|
||||
return cur.lastrowid # type: ignore[return-value]
|
||||
|
||||
def update_fact(self, fact_id: int, content: str, confidence: float) -> None:
|
||||
"""更新事实内容(去重时发现新内容更具体时调用)。"""
|
||||
now = time.time()
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE facts SET content=?, confidence=?, updated_at=? WHERE id=?",
|
||||
(content, confidence, now, fact_id),
|
||||
)
|
||||
|
||||
def mark_conflict(self, fact_id: int) -> None:
|
||||
"""标记事实存在冲突(保留双方,上层注入时取最新)。"""
|
||||
now = time.time()
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE facts SET conflict=1, updated_at=? WHERE id=?",
|
||||
(now, fact_id),
|
||||
)
|
||||
|
||||
def search_by_faiss_ids(self, faiss_ids: list[int]) -> list[dict]:
|
||||
if not faiss_ids:
|
||||
return []
|
||||
placeholders = ",".join("?" * len(faiss_ids))
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM facts WHERE faiss_id IN ({placeholders})",
|
||||
faiss_ids,
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def faiss_search(
|
||||
self, query_vec: np.ndarray, top_k: int = 20
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""语义搜索,返回 (ids, distances),空索引时返回空数组。"""
|
||||
if self._index is None or self._index.ntotal == 0:
|
||||
return np.array([], dtype=np.int64), np.array([], dtype=np.float32)
|
||||
query = query_vec.astype(np.float32).reshape(1, -1)
|
||||
k = min(top_k, self._index.ntotal)
|
||||
distances, ids = self._index.search(query, k)
|
||||
valid = ids[0] >= 0
|
||||
return ids[0][valid], distances[0][valid]
|
||||
|
||||
def bm25_search(self, query: str, limit: int = 20) -> list[dict]:
|
||||
"""FTS5 BM25 全文检索(bm25_score 为负值,越负越相关)。"""
|
||||
tokens = [t for t in query.split() if t]
|
||||
if not tokens:
|
||||
return []
|
||||
fts_query = " OR ".join(tokens)
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT f.id, f.faiss_id, f.content, f.category, f.entity,
|
||||
f.tags, f.confidence, f.conflict,
|
||||
f.created_at, f.updated_at,
|
||||
bm25(facts_fts) AS bm25_score
|
||||
FROM facts_fts
|
||||
JOIN facts f ON f.id = facts_fts.rowid
|
||||
WHERE facts_fts MATCH ?
|
||||
ORDER BY bm25_score
|
||||
LIMIT ?
|
||||
""",
|
||||
(fts_query, limit),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def list_facts(
|
||||
self,
|
||||
category: str | None = None,
|
||||
entity: str | None = None,
|
||||
) -> list[dict]:
|
||||
conditions: list[str] = []
|
||||
params: list[str] = []
|
||||
if category:
|
||||
conditions.append("category=?")
|
||||
params.append(category)
|
||||
if entity:
|
||||
conditions.append("entity=?")
|
||||
params.append(entity)
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM facts {where} ORDER BY updated_at DESC",
|
||||
params,
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def delete_fact(self, fact_id: int) -> None:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT faiss_id FROM facts WHERE id=?", (fact_id,)
|
||||
).fetchone()
|
||||
conn.execute("DELETE FROM facts WHERE id=?", (fact_id,))
|
||||
if row and row["faiss_id"] is not None and self._index is not None:
|
||||
ids_to_remove = np.array([row["faiss_id"]], dtype=np.int64)
|
||||
self._index.remove_ids(ids_to_remove)
|
||||
self._save_index()
|
||||
@@ -0,0 +1,520 @@
|
||||
"""Memory Service FastAPI 应用:对话轮次存取 + 上下文压缩检索。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Coroutine
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mem_bridge.context_manager import ContextManager
|
||||
from mem_bridge.fact_extractor import FactExtractor
|
||||
|
||||
import numpy as np
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
|
||||
from embed_db.config import Config
|
||||
from embed_db.index import VectorIndex
|
||||
from embed_db.pipeline import Pipeline
|
||||
from mem_bridge.compressor import Compressor
|
||||
from mem_bridge.config import BridgeConfig
|
||||
from mem_bridge.fact_store import FactStore
|
||||
from mem_bridge.turn_store import TurnStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 模块级全局 bridge_cfg,供单元测试直接操作(由 create_memory_app 注入)
|
||||
bridge_cfg: BridgeConfig = BridgeConfig()
|
||||
|
||||
|
||||
def _log_task_exception(task: asyncio.Task) -> None: # type: ignore[type-arg]
|
||||
"""后台 task 完成回调:记录异常,避免异常静默丢失。"""
|
||||
if task.cancelled():
|
||||
return
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
logger.warning("后台 task 异常: %s", exc, exc_info=exc)
|
||||
|
||||
|
||||
def _safe_create_task(coro: Coroutine[Any, Any, Any]) -> None:
|
||||
"""在已运行的事件循环中创建 task,无循环时静默丢弃。"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
task = loop.create_task(coro)
|
||||
task.add_done_callback(_log_task_exception)
|
||||
except RuntimeError:
|
||||
try:
|
||||
coro.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _hybrid_scores(
|
||||
semantic_ids: np.ndarray,
|
||||
semantic_dists: np.ndarray,
|
||||
bm25_rows: list[dict],
|
||||
bm25_weight: float,
|
||||
) -> dict[int, float]:
|
||||
"""将语义分数和 BM25 分数归一化后混合,返回 {faiss_id: score}。"""
|
||||
scores: dict[int, float] = {}
|
||||
|
||||
# 语义分数(已归一化到 [0,1],直接使用)
|
||||
for fid, dist in zip(semantic_ids, semantic_dists):
|
||||
scores[int(fid)] = float(dist) * (1 - bm25_weight)
|
||||
|
||||
# BM25 分数(负值,越负越相关,归一化到 [0,1])
|
||||
if bm25_rows:
|
||||
min_raw = min(r["bm25_score"] for r in bm25_rows)
|
||||
max_raw = max(r["bm25_score"] for r in bm25_rows)
|
||||
span = max_raw - min_raw
|
||||
for r in bm25_rows:
|
||||
fid = r.get("faiss_id")
|
||||
if fid is None:
|
||||
continue
|
||||
if span == 0.0:
|
||||
# 单条结果,赋予中性权重
|
||||
norm_bm25 = 0.5
|
||||
else:
|
||||
norm = (r["bm25_score"] - min_raw) / span
|
||||
norm_bm25 = 1.0 - norm # 越负越相关,翻转为越大越好
|
||||
scores[int(fid)] = scores.get(int(fid), 0.0) + norm_bm25 * bm25_weight
|
||||
|
||||
return scores
|
||||
|
||||
|
||||
def _apply_time_decay(turns: list[dict], cfg: BridgeConfig | None = None) -> list[dict]:
|
||||
"""对历史轮次的 score 应用时间衰减。
|
||||
|
||||
Args:
|
||||
turns: 包含 score 和 created_at 字段的轮次列表。
|
||||
cfg: BridgeConfig 实例;为 None 时使用模块级全局 bridge_cfg。
|
||||
"""
|
||||
import time as _time
|
||||
_cfg = cfg if cfg is not None else bridge_cfg
|
||||
if not _cfg.time_decay_enabled:
|
||||
return turns
|
||||
now = _time.time()
|
||||
lam = _cfg.time_decay_lambda
|
||||
result = []
|
||||
for t in turns:
|
||||
age_hours = (now - t.get("created_at", now)) / 3600.0
|
||||
decay = math.exp(-lam * age_hours)
|
||||
result.append({**t, "score": float(t.get("score", 1.0)) * decay})
|
||||
return sorted(result, key=lambda x: x.get("score", 0.0), reverse=True)
|
||||
|
||||
|
||||
class AddTurnRequest(BaseModel):
|
||||
session_id: str
|
||||
role: str
|
||||
content: str
|
||||
|
||||
|
||||
class AddFactRequest(BaseModel):
|
||||
content: str
|
||||
category: str = "other"
|
||||
entity: str | None = None
|
||||
tags: list[str] = []
|
||||
|
||||
|
||||
def create_memory_app(
|
||||
bridge_cfg: BridgeConfig,
|
||||
embed_cfg: Config,
|
||||
embedder: Any | None = None,
|
||||
) -> FastAPI:
|
||||
import sys as _sys
|
||||
_sys.modules[__name__].bridge_cfg = bridge_cfg # 同步到模块级全局,便于单元测试访问
|
||||
app = FastAPI(title="mem-bridge memory-service")
|
||||
|
||||
# ── 基础组件 ──────────────────────────────────────
|
||||
bridge_cfg.db_dir.mkdir(parents=True, exist_ok=True)
|
||||
turn_store = TurnStore(bridge_cfg.db_dir / "turns.db")
|
||||
|
||||
turns_index_cfg = Config(
|
||||
db_dir=bridge_cfg.db_dir,
|
||||
model_path=embed_cfg.model_path,
|
||||
index_path=bridge_cfg.db_dir / "turns.faiss",
|
||||
meta_path=embed_cfg.meta_path,
|
||||
emb_dim=embed_cfg.emb_dim,
|
||||
nlist=embed_cfg.nlist,
|
||||
pq_m=embed_cfg.pq_m,
|
||||
min_vectors_for_ivf=embed_cfg.min_vectors_for_ivf,
|
||||
)
|
||||
turns_index = VectorIndex(turns_index_cfg)
|
||||
turns_index_path = bridge_cfg.db_dir / "turns.faiss"
|
||||
if turns_index_path.exists():
|
||||
try:
|
||||
turns_index.load(path=turns_index_path, mmap=False)
|
||||
except Exception as e:
|
||||
logger.warning("turns.faiss 加载失败,从空索引开始: %s", e)
|
||||
|
||||
# ── FactStore ──────────────────────────────────────
|
||||
fact_store = FactStore(
|
||||
db_path=bridge_cfg.db_dir / "facts.db",
|
||||
index_path=bridge_cfg.db_dir / "facts.faiss",
|
||||
emb_dim=embed_cfg.emb_dim,
|
||||
)
|
||||
fact_store.load_index()
|
||||
|
||||
# ── 写锁(懒初始化,Python 3.9 兼容)─────────────
|
||||
_write_lock: asyncio.Lock | None = None
|
||||
_facts_write_lock: asyncio.Lock | None = None
|
||||
|
||||
doc_pipeline = Pipeline(embed_cfg)
|
||||
if embedder is not None:
|
||||
doc_pipeline._embedder = embedder
|
||||
compressor = Compressor(token_budget=bridge_cfg.token_budget)
|
||||
|
||||
# ── ContextManager ─────────────────────────────────
|
||||
context_mgr: ContextManager | None = None
|
||||
if bridge_cfg.summarization_enabled and bridge_cfg.backends:
|
||||
summ_name = bridge_cfg.summarization_backend or bridge_cfg.default_backend
|
||||
if not summ_name:
|
||||
logger.warning("summarization_enabled=true 但未配置 default_backend,跳过 ContextManager")
|
||||
else:
|
||||
summ_cfg = bridge_cfg.backends.get(summ_name)
|
||||
if not summ_cfg:
|
||||
logger.warning("摘要后端 %r 未找到,跳过 ContextManager", summ_name)
|
||||
else:
|
||||
from mem_bridge.backends import make_backend
|
||||
from mem_bridge.context_manager import ContextManager
|
||||
summ_backend = make_backend(summ_name, summ_cfg)
|
||||
context_mgr = ContextManager(
|
||||
turn_store=turn_store,
|
||||
backend=summ_backend,
|
||||
window_size=bridge_cfg.summarization_window_size,
|
||||
)
|
||||
logger.info(
|
||||
"ContextManager 已初始化,后端=%r,窗口大小=%d",
|
||||
summ_name, bridge_cfg.summarization_window_size,
|
||||
)
|
||||
|
||||
# ── FactExtractor ──────────────────────────────────
|
||||
fact_extractor: FactExtractor | None = None
|
||||
if bridge_cfg.fact_extraction_enabled and bridge_cfg.backends:
|
||||
fe_name = bridge_cfg.fact_extraction_backend or bridge_cfg.default_backend
|
||||
if not fe_name:
|
||||
logger.warning("fact_extraction_enabled=true 但未配置 default_backend,跳过 FactExtractor")
|
||||
else:
|
||||
fe_cfg = bridge_cfg.backends.get(fe_name)
|
||||
if not fe_cfg:
|
||||
logger.warning("事实提取后端 %r 未找到,跳过 FactExtractor", fe_name)
|
||||
else:
|
||||
from mem_bridge.backends import make_backend
|
||||
from mem_bridge.fact_extractor import FactExtractor
|
||||
fe_backend = make_backend(fe_name, fe_cfg)
|
||||
|
||||
class _LazyFactExtractor:
|
||||
"""包装 FactExtractor,懒获取 _facts_write_lock。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._inner: FactExtractor | None = None
|
||||
self._backend = fe_backend
|
||||
|
||||
def _get_inner(self, lock: asyncio.Lock) -> FactExtractor:
|
||||
if self._inner is None:
|
||||
self._inner = FactExtractor(
|
||||
backend=self._backend,
|
||||
fact_store=fact_store,
|
||||
embedder=_get_embedder(),
|
||||
write_lock=lock,
|
||||
enabled=True,
|
||||
window_turns=bridge_cfg.fact_extraction_window_turns,
|
||||
dedup_threshold=bridge_cfg.fact_extraction_dedup_threshold,
|
||||
)
|
||||
return self._inner
|
||||
|
||||
async def extract_and_store(
|
||||
self, session_id: str, turns: list[dict], lock: asyncio.Lock
|
||||
) -> int:
|
||||
return await self._get_inner(lock).extract_and_store(
|
||||
session_id, turns
|
||||
)
|
||||
|
||||
fact_extractor = _LazyFactExtractor() # type: ignore[assignment]
|
||||
logger.info("FactExtractor 已配置,后端=%r", fe_name)
|
||||
|
||||
def _get_embedder() -> Any:
|
||||
if embedder is not None:
|
||||
return embedder
|
||||
doc_pipeline._ensure_embedder()
|
||||
return doc_pipeline._embedder
|
||||
|
||||
# ── 工具函数 ───────────────────────────────────────
|
||||
|
||||
def _hybrid_search_facts(q_vec: np.ndarray, query: str, top_k: int = 20) -> list[dict]:
|
||||
"""语义 + BM25 混合搜索事实,按 category top-k 筛选。"""
|
||||
sem_ids, sem_dists = fact_store.faiss_search(q_vec, top_k=top_k)
|
||||
bm25_rows: list[dict] = []
|
||||
if bridge_cfg.hybrid_search_enabled:
|
||||
try:
|
||||
bm25_rows = fact_store.bm25_search(query, limit=top_k)
|
||||
except Exception as e:
|
||||
logger.debug("BM25 搜索失败,退化为纯语义: %s", e)
|
||||
|
||||
scores = _hybrid_scores(
|
||||
sem_ids, sem_dists, bm25_rows,
|
||||
bridge_cfg.hybrid_search_bm25_weight if bridge_cfg.hybrid_search_enabled else 0.0,
|
||||
)
|
||||
|
||||
# 回查事实文本
|
||||
all_fids = list(scores.keys())
|
||||
facts = fact_store.search_by_faiss_ids(all_fids)
|
||||
for f in facts:
|
||||
f["score"] = scores.get(f.get("faiss_id", -1), 0.0)
|
||||
|
||||
# 按 category 独立 top-k
|
||||
cat_counts: dict[str, int] = {}
|
||||
selected: list[dict] = []
|
||||
cat_limits = bridge_cfg.fact_category_top_k
|
||||
default_limit = 2
|
||||
for f in sorted(facts, key=lambda x: x.get("score", 0.0), reverse=True):
|
||||
cat = f.get("category", "other")
|
||||
limit = cat_limits.get(cat, default_limit)
|
||||
if cat_counts.get(cat, 0) < limit:
|
||||
selected.append(f)
|
||||
cat_counts[cat] = cat_counts.get(cat, 0) + 1
|
||||
return selected
|
||||
|
||||
def _decay(turns: list[dict]) -> list[dict]:
|
||||
"""调用模块级 _apply_time_decay,传入当前 bridge_cfg。"""
|
||||
return _apply_time_decay(turns, cfg=bridge_cfg)
|
||||
|
||||
# ── 路由 ───────────────────────────────────────────
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.post("/memory/turn")
|
||||
async def add_turn(req: AddTurnRequest) -> dict[str, Any]:
|
||||
nonlocal _write_lock, _facts_write_lock
|
||||
if _write_lock is None:
|
||||
_write_lock = asyncio.Lock()
|
||||
if _facts_write_lock is None:
|
||||
_facts_write_lock = asyncio.Lock()
|
||||
|
||||
emb = _get_embedder()
|
||||
vec = emb.embed_query(req.content)
|
||||
from mem_bridge.compressor import count_tokens
|
||||
token_count = count_tokens(req.content)
|
||||
vec_array = np.array([vec], dtype=np.float32)
|
||||
|
||||
async with _write_lock:
|
||||
faiss_id = turn_store.next_faiss_id()
|
||||
id_array = np.array([faiss_id], dtype=np.int64)
|
||||
if not turns_index.is_trained:
|
||||
turns_index.build(vec_array, id_array)
|
||||
else:
|
||||
turns_index.add(vec_array, id_array)
|
||||
turns_index.save()
|
||||
turn_store.add_turn(
|
||||
req.session_id, req.role, req.content,
|
||||
faiss_id=faiss_id, token_count=token_count,
|
||||
)
|
||||
|
||||
# 异步触发摘要归档
|
||||
if context_mgr is not None:
|
||||
_safe_create_task(context_mgr.check_and_summarize(req.session_id))
|
||||
|
||||
# 异步触发事实提取(user+assistant 均触发)
|
||||
if fact_extractor is not None:
|
||||
all_turns = turn_store.list_turns(req.session_id)
|
||||
_safe_create_task(
|
||||
fact_extractor.extract_and_store(req.session_id, all_turns, _facts_write_lock) # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
return {"ok": True, "faiss_id": faiss_id}
|
||||
|
||||
@app.get("/memory/context")
|
||||
async def get_context(
|
||||
session_id: str, query: str, token_budget: int = 2000
|
||||
) -> dict[str, Any]:
|
||||
emb = _get_embedder()
|
||||
q_vec = emb.embed_query(query)
|
||||
comp = Compressor(token_budget=token_budget)
|
||||
|
||||
# ── 1. facts(语义 + BM25 混合,category top-k)
|
||||
facts: list[dict[str, Any]] = _hybrid_search_facts(q_vec, query)
|
||||
|
||||
# ── 2. working_memory(最近 N 轮,无条件注入)
|
||||
working_memory = turn_store.get_recent_turns(
|
||||
session_id, n=bridge_cfg.working_memory_turns
|
||||
)
|
||||
|
||||
# ── 3. knowledge(文档知识库)
|
||||
long_term: list[dict[str, Any]] = []
|
||||
try:
|
||||
doc_pipeline._ensure_index_loaded()
|
||||
sem_results = doc_pipeline.search(query, top_k=bridge_cfg.long_term_top_k * 2)
|
||||
# BM25 混合(MetaStore FTS5)
|
||||
if bridge_cfg.hybrid_search_enabled:
|
||||
try:
|
||||
bm25_doc = doc_pipeline._store.bm25_search(query, limit=bridge_cfg.long_term_top_k * 2)
|
||||
except Exception as e:
|
||||
logger.debug("BM25 搜索失败,退化为纯语义: %s", e)
|
||||
bm25_doc = []
|
||||
sem_ids_doc = np.array([r.get("faiss_id", -1) for r in sem_results], dtype=np.int64)
|
||||
sem_dists_doc = np.array([r.get("score", 0.0) for r in sem_results], dtype=np.float32)
|
||||
scores_doc = _hybrid_scores(
|
||||
sem_ids_doc, sem_dists_doc, bm25_doc,
|
||||
bridge_cfg.hybrid_search_bm25_weight,
|
||||
)
|
||||
for r in sem_results:
|
||||
r["score"] = scores_doc.get(r.get("faiss_id", -1), r.get("score", 0.0))
|
||||
long_term = [
|
||||
{"chunk": r["chunk"], "path": r["path"], "score": r["score"]}
|
||||
for r in sorted(sem_results, key=lambda x: x.get("score", 0.0), reverse=True)[
|
||||
: bridge_cfg.long_term_top_k
|
||||
]
|
||||
]
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
# ── 4. short_term(summary + history)
|
||||
short_term: list[dict[str, Any]] = []
|
||||
all_session_turns = turn_store.list_turns(session_id)
|
||||
|
||||
# summary 轮次
|
||||
summary_turns = [t for t in all_session_turns if t["role"] == "summary"]
|
||||
if summary_turns:
|
||||
latest = summary_turns[-1]
|
||||
short_term.append({"role": "summary", "content": latest["content"], "score": 1.0})
|
||||
|
||||
# history:语义搜索 + BM25 混合 + 时间衰减
|
||||
session_faiss_ids = turn_store.get_faiss_ids(session_id)
|
||||
if session_faiss_ids:
|
||||
try:
|
||||
ids, dists = turns_index.search(q_vec, top_k=bridge_cfg.short_term_top_k * 2)
|
||||
valid_fids = set(session_faiss_ids)
|
||||
turns_by_fid = {
|
||||
t["faiss_id"]: t for t in all_session_turns
|
||||
if t.get("faiss_id") is not None
|
||||
}
|
||||
sem_hist: list[dict[str, Any]] = []
|
||||
for fid, dist in zip(ids, dists):
|
||||
if fid in valid_fids and fid in turns_by_fid:
|
||||
t = turns_by_fid[fid]
|
||||
if t.get("role") != "summary":
|
||||
sem_hist.append({
|
||||
"id": t["id"],
|
||||
"role": t["role"],
|
||||
"content": t["content"],
|
||||
"score": float(dist),
|
||||
"created_at": t.get("created_at", 0.0),
|
||||
})
|
||||
|
||||
# BM25 混合
|
||||
if bridge_cfg.hybrid_search_enabled:
|
||||
try:
|
||||
bm25_turns = turn_store.bm25_search(
|
||||
session_id, query, limit=bridge_cfg.short_term_top_k * 2
|
||||
)
|
||||
sem_ids_h = np.array([t.get("faiss_id", -1) for t in sem_hist], dtype=np.int64)
|
||||
sem_dists_h = np.array([t.get("score", 0.0) for t in sem_hist], dtype=np.float32)
|
||||
scores_h = _hybrid_scores(
|
||||
sem_ids_h, sem_dists_h, bm25_turns,
|
||||
bridge_cfg.hybrid_search_bm25_weight,
|
||||
)
|
||||
for t in sem_hist:
|
||||
fid = t.get("faiss_id") or turns_by_fid.get(t["id"], {}).get("faiss_id")
|
||||
if fid is not None:
|
||||
t["score"] = scores_h.get(int(fid), t.get("score", 0.0))
|
||||
except Exception as e:
|
||||
logger.debug("BM25 搜索失败,退化为纯语义: %s", e)
|
||||
|
||||
# 时间衰减
|
||||
sem_hist = _decay(sem_hist)
|
||||
short_term.extend(sem_hist[: bridge_cfg.short_term_top_k])
|
||||
|
||||
except Exception:
|
||||
recent = turn_store.get_recent_turns(session_id, n=bridge_cfg.recent_turns_verbatim)
|
||||
short_term.extend(
|
||||
{"role": t["role"], "content": t["content"], "score": 1.0,
|
||||
"id": t.get("id"), "created_at": t.get("created_at", 0.0)}
|
||||
for t in recent
|
||||
)
|
||||
|
||||
result = comp.compress(
|
||||
facts=facts,
|
||||
working_memory=working_memory,
|
||||
long_term=long_term,
|
||||
short_term=short_term,
|
||||
)
|
||||
return {
|
||||
"facts": result.facts,
|
||||
"long_term": result.long_term,
|
||||
"short_term": result.short_term,
|
||||
"formatted": result.formatted,
|
||||
"tokens_used": result.tokens_used,
|
||||
"budget": result.budget,
|
||||
}
|
||||
|
||||
@app.delete("/memory/session/{session_id}")
|
||||
async def delete_session(session_id: str) -> dict[str, bool]:
|
||||
turn_store.delete_session(session_id)
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/memory/search")
|
||||
async def search(q: str, limit: int = 5) -> dict[str, Any]:
|
||||
try:
|
||||
results = doc_pipeline.search(q, top_k=limit)
|
||||
return {"results": results}
|
||||
except RuntimeError:
|
||||
return {"results": []}
|
||||
|
||||
@app.get("/memory/facts")
|
||||
async def list_facts(
|
||||
category: str | None = None,
|
||||
entity: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
facts = fact_store.list_facts(category=category, entity=entity)
|
||||
return {"facts": facts}
|
||||
|
||||
@app.post("/memory/fact")
|
||||
async def add_fact_manual(req: AddFactRequest) -> dict[str, Any]:
|
||||
nonlocal _facts_write_lock
|
||||
if _facts_write_lock is None:
|
||||
_facts_write_lock = asyncio.Lock()
|
||||
emb = _get_embedder()
|
||||
vec = np.array(emb.embed_query(req.content), dtype=np.float32)
|
||||
async with _facts_write_lock:
|
||||
fact_id = fact_store.add_fact(
|
||||
req.content, req.category, req.entity, req.tags,
|
||||
"manual", vec,
|
||||
)
|
||||
return {"ok": True, "id": fact_id}
|
||||
|
||||
@app.delete("/memory/fact/{fact_id}")
|
||||
async def delete_fact(fact_id: int) -> dict[str, bool]:
|
||||
fact_store.delete_fact(fact_id)
|
||||
return {"ok": True}
|
||||
|
||||
# ── 管理端点 ──
|
||||
|
||||
@app.get("/admin/sessions")
|
||||
async def admin_list_sessions() -> dict[str, Any]:
|
||||
"""列出所有活跃会话(含轮次数和最近活跃时间)。"""
|
||||
sessions = turn_store.list_sessions()
|
||||
return {"sessions": sessions, "total": len(sessions)}
|
||||
|
||||
@app.delete("/admin/sessions/{session_id}")
|
||||
async def admin_delete_session(session_id: str) -> dict[str, bool]:
|
||||
"""清除指定会话的全部记忆(轮次 + 事实)。"""
|
||||
turn_store.delete_session(session_id)
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/admin/stats")
|
||||
async def admin_stats() -> dict[str, Any]:
|
||||
"""记忆使用统计:会话数、总轮次数、事实数。"""
|
||||
sessions = turn_store.list_sessions()
|
||||
total_turns = turn_store.count_turns()
|
||||
facts = fact_store.list_facts()
|
||||
return {
|
||||
"session_count": len(sessions),
|
||||
"turn_count": total_turns,
|
||||
"fact_count": len(facts),
|
||||
}
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,37 @@
|
||||
"""OpenAI 兼容 Pydantic 模型 + 内部 ContextResponse。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
role: str
|
||||
content: str
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
model: str = "mem-bridge"
|
||||
messages: list[ChatMessage]
|
||||
stream: bool = False
|
||||
temperature: float | None = None
|
||||
max_tokens: int | None = None
|
||||
session_id: str | None = Field(default=None, alias="x_session_id")
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
class ContextResponse(BaseModel):
|
||||
long_term: list[dict[str, Any]]
|
||||
short_term: list[dict[str, Any]]
|
||||
formatted: str
|
||||
tokens_used: int
|
||||
budget: dict[str, int]
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
id: str
|
||||
object: str = "chat.completion"
|
||||
model: str
|
||||
choices: list[dict[str, Any]]
|
||||
usage: dict[str, int] = Field(default_factory=dict)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""提示词优化:Query 改写 + System Prompt 模板管理。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mem_bridge.backends import BaseBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REWRITE_SYSTEM = (
|
||||
"你是检索助手,将用户问题改写为更适合语义检索的规范表达,"
|
||||
"不超过50字,只输出改写结果,不要任何解释。"
|
||||
)
|
||||
|
||||
|
||||
class QueryRewriter:
|
||||
"""将用户问题改写为规范语义表达,提升检索召回率。"""
|
||||
|
||||
def __init__(self, backend: BaseBackend, enabled: bool = False) -> None:
|
||||
self._backend = backend
|
||||
self._enabled = enabled
|
||||
|
||||
async def rewrite(self, query: str) -> str:
|
||||
"""改写查询;禁用或失败时静默回退原始 query。"""
|
||||
if not self._enabled or not query.strip():
|
||||
return query
|
||||
from mem_bridge.models import ChatMessage
|
||||
|
||||
try:
|
||||
messages = [
|
||||
ChatMessage(role="system", content=REWRITE_SYSTEM),
|
||||
ChatMessage(role="user", content=query),
|
||||
]
|
||||
result = await self._backend.chat(messages, stream=False)
|
||||
rewritten = str(result).strip()
|
||||
return rewritten if rewritten else query
|
||||
except Exception as e:
|
||||
logger.warning("Query 改写失败,使用原始 query: %s", e)
|
||||
return query
|
||||
|
||||
|
||||
class PromptBuilder:
|
||||
"""System Prompt 模板管理:按序拼接模板 + 原始 system + 记忆上下文。"""
|
||||
|
||||
def __init__(self, templates: dict[str, str]) -> None:
|
||||
self._templates = templates # 有序 dict(Python 3.7+ 保证插入顺序)
|
||||
|
||||
def build(self, original_system: str, context_text: str) -> str:
|
||||
"""拼接最终 system prompt。
|
||||
|
||||
拼接顺序:
|
||||
1. 各模板文本(按 active 列表顺序)
|
||||
2. original_system(若非空)
|
||||
3. context_text(记忆注入,若非空)
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for content in self._templates.values():
|
||||
if content.strip():
|
||||
parts.append(content.strip())
|
||||
if original_system.strip():
|
||||
parts.append(original_system.strip())
|
||||
if context_text.strip():
|
||||
parts.append(context_text.strip())
|
||||
return "\n\n".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def load_templates(dir: Path, names: list[str]) -> dict[str, str]:
|
||||
"""从目录加载指定模板文件(.txt),不存在的静默跳过。"""
|
||||
templates: dict[str, str] = {}
|
||||
for name in names:
|
||||
path = dir / f"{name}.txt"
|
||||
if path.exists():
|
||||
templates[name] = path.read_text(encoding="utf-8")
|
||||
else:
|
||||
logger.warning("模板文件不存在,跳过: %s", path)
|
||||
return templates
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Router Service:OpenAI 兼容代理 + 复杂度路由 + 记忆注入。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from mem_bridge.backends import BaseBackend, make_backend
|
||||
from mem_bridge.complexity import ComplexityScorer
|
||||
from mem_bridge.config import BridgeConfig
|
||||
from mem_bridge.models import ChatRequest, ChatMessage
|
||||
from mem_bridge.prompt_optimizer import PromptBuilder, QueryRewriter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_create_task(coro) -> None:
|
||||
"""在已运行的事件循环中创建 task,无事件循环时静默丢弃(fire-and-forget)。
|
||||
|
||||
使用 get_running_loop() 替代已废弃的 get_event_loop(),兼容 Python 3.10+/3.12。
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(coro)
|
||||
except RuntimeError:
|
||||
# 无运行中的事件循环(TestClient 同步环境或模块级调用)
|
||||
try:
|
||||
coro.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def create_router_app(
|
||||
bridge_cfg: BridgeConfig,
|
||||
embedder=None,
|
||||
backends_override: dict[str, BaseBackend] | None = None,
|
||||
memory_base_url: str | None = "http://localhost:8001",
|
||||
prompt_builder: PromptBuilder | None = None,
|
||||
query_rewriter: QueryRewriter | None = None,
|
||||
) -> FastAPI:
|
||||
app = FastAPI(title="mem-bridge router-service")
|
||||
|
||||
# 初始化后端
|
||||
backends: dict[str, BaseBackend] = backends_override or {}
|
||||
if not backends_override:
|
||||
for name, cfg in bridge_cfg.backends.items():
|
||||
backends[name] = make_backend(name, cfg)
|
||||
|
||||
# 初始化复杂度评分器
|
||||
scorer: ComplexityScorer | None = None
|
||||
if embedder and bridge_cfg.complexity_templates_path.exists():
|
||||
templates = ComplexityScorer.load_templates(bridge_cfg.complexity_templates_path)
|
||||
scorer = ComplexityScorer(embedder, templates, threshold=bridge_cfg.complexity_threshold)
|
||||
logger.info("ComplexityScorer loaded, %d templates", len(templates))
|
||||
|
||||
# 初始化 PromptBuilder
|
||||
if prompt_builder is None:
|
||||
pt_templates = PromptBuilder.load_templates(
|
||||
bridge_cfg.prompt_templates_dir,
|
||||
bridge_cfg.prompt_templates_active,
|
||||
)
|
||||
prompt_builder = PromptBuilder(pt_templates)
|
||||
|
||||
# 初始化 QueryRewriter
|
||||
if query_rewriter is None:
|
||||
qr_backend_name = bridge_cfg.query_rewrite_backend or bridge_cfg.default_backend
|
||||
qr_bk: BaseBackend | None = backends.get(qr_backend_name) or (
|
||||
next(iter(backends.values())) if backends else None
|
||||
)
|
||||
if qr_bk is not None:
|
||||
query_rewriter = QueryRewriter(
|
||||
backend=qr_bk,
|
||||
enabled=bridge_cfg.query_rewrite_enabled,
|
||||
)
|
||||
else:
|
||||
# 无后端时创建禁用状态的 QueryRewriter(enabled=False 不会实际调用)
|
||||
|
||||
class _NullBackend(BaseBackend):
|
||||
pass
|
||||
|
||||
query_rewriter = QueryRewriter(backend=_NullBackend(), enabled=False)
|
||||
|
||||
def _select_backend(query: str, task_type: str = "") -> tuple[BaseBackend, list[str]]:
|
||||
"""Select primary backend and build fallback chain.
|
||||
|
||||
Returns (primary_backend, fallback_backend_names).
|
||||
"""
|
||||
# Task-type routing takes priority
|
||||
if task_type and task_type in bridge_cfg.routing_rules:
|
||||
name = bridge_cfg.routing_rules[task_type]
|
||||
if name in backends:
|
||||
chain = [n for n in bridge_cfg.fallback_chain if n != name and n in backends]
|
||||
return backends[name], chain
|
||||
|
||||
# Complexity-based routing
|
||||
if scorer and scorer.is_complex(query):
|
||||
name = bridge_cfg.heavy_backend or bridge_cfg.default_backend
|
||||
else:
|
||||
name = bridge_cfg.default_backend
|
||||
if not name or name not in backends:
|
||||
name = next(iter(backends))
|
||||
|
||||
chain = [n for n in bridge_cfg.fallback_chain if n != name and n in backends]
|
||||
return backends[name], chain
|
||||
|
||||
async def _call_with_fallback(
|
||||
primary: BaseBackend,
|
||||
fallback_names: list[str],
|
||||
messages: list[ChatMessage],
|
||||
stream: bool,
|
||||
temperature: float | None,
|
||||
max_tokens: int | None,
|
||||
):
|
||||
"""Call primary backend, fallback to chain on timeout/error."""
|
||||
timeout = bridge_cfg.fallback_timeout
|
||||
|
||||
# Try primary
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
primary.chat(messages, stream=stream,
|
||||
temperature=temperature, max_tokens=max_tokens),
|
||||
timeout=timeout,
|
||||
)
|
||||
except (asyncio.TimeoutError, Exception) as e:
|
||||
logger.warning("Primary backend failed: %s — trying fallback chain", e)
|
||||
|
||||
# Try fallback chain
|
||||
for fb_name in fallback_names:
|
||||
fb = backends.get(fb_name)
|
||||
if fb is None:
|
||||
continue
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
fb.chat(messages, stream=stream,
|
||||
temperature=temperature, max_tokens=max_tokens),
|
||||
timeout=timeout,
|
||||
)
|
||||
logger.info("Fallback to %s succeeded", fb_name)
|
||||
return result
|
||||
except (asyncio.TimeoutError, Exception) as e:
|
||||
logger.warning("Fallback %s failed: %s", fb_name, e)
|
||||
|
||||
raise RuntimeError("All backends failed (primary + fallback chain)")
|
||||
|
||||
async def _fetch_context(session_id: str, query: str) -> str:
|
||||
if not memory_base_url:
|
||||
return ""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(
|
||||
f"{memory_base_url}/memory/context",
|
||||
params={"session_id": session_id, "query": query,
|
||||
"token_budget": bridge_cfg.token_budget},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json().get("formatted", "")
|
||||
except Exception as e:
|
||||
logger.warning("memory-service 不可达: %s", e)
|
||||
return ""
|
||||
|
||||
async def _store_turn(session_id: str, role: str, content: str) -> None:
|
||||
if not memory_base_url:
|
||||
return
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
await client.post(
|
||||
f"{memory_base_url}/memory/turn",
|
||||
json={"session_id": session_id, "role": role, "content": content},
|
||||
)
|
||||
except Exception:
|
||||
pass # fire-and-forget,失败静默
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/v1/models")
|
||||
async def list_models():
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": name, "object": "model", "owned_by": "mem-bridge"}
|
||||
for name in backends
|
||||
],
|
||||
}
|
||||
|
||||
@app.post("/v1/chat/completions")
|
||||
async def chat_completions(request: Request):
|
||||
body = await request.json()
|
||||
req = ChatRequest.model_validate(body)
|
||||
|
||||
# 提取 session_id
|
||||
client_host = request.client.host if request.client else "local"
|
||||
session_id = (
|
||||
request.headers.get("X-Session-Id")
|
||||
or req.session_id
|
||||
or hashlib.md5(f"{client_host}:{req.model}".encode()).hexdigest()[:16]
|
||||
)
|
||||
|
||||
# 提取 task_type(用于路由规则)
|
||||
task_type = request.headers.get("X-Task-Type", "")
|
||||
|
||||
query = req.messages[-1].content if req.messages else ""
|
||||
|
||||
# Query 改写(可选,enabled=False 时直接返回原始 query)
|
||||
query_for_retrieval = await query_rewriter.rewrite(query)
|
||||
|
||||
# 拉取记忆上下文(使用改写后的 query)
|
||||
context_text = await _fetch_context(session_id, query_for_retrieval)
|
||||
|
||||
# 构建最终 system prompt(模板 + 原始 system + 记忆上下文)
|
||||
messages = list(req.messages)
|
||||
original_system = next(
|
||||
(m.content for m in messages if m.role == "system"), ""
|
||||
)
|
||||
final_system = prompt_builder.build(original_system, context_text)
|
||||
messages = [m for m in messages if m.role != "system"]
|
||||
if final_system:
|
||||
messages.insert(0, ChatMessage(role="system", content=final_system))
|
||||
|
||||
# 选择后端 + fallback 链
|
||||
backend, fallback_names = _select_backend(query, task_type)
|
||||
|
||||
# 存储用户消息(fire-and-forget)
|
||||
_safe_create_task(_store_turn(session_id, "user", query))
|
||||
|
||||
if req.stream:
|
||||
async def stream_gen():
|
||||
full_response: list[str] = []
|
||||
gen = await _call_with_fallback(
|
||||
backend, fallback_names, messages, stream=True,
|
||||
temperature=req.temperature, max_tokens=req.max_tokens,
|
||||
)
|
||||
async for chunk in gen:
|
||||
full_response.append(chunk)
|
||||
yield f"data: {chunk}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
_safe_create_task(_store_turn(session_id, "assistant", "".join(full_response)))
|
||||
return StreamingResponse(stream_gen(), media_type="text/event-stream")
|
||||
|
||||
content = await _call_with_fallback(
|
||||
backend, fallback_names, messages, stream=False,
|
||||
temperature=req.temperature, max_tokens=req.max_tokens,
|
||||
)
|
||||
_safe_create_task(_store_turn(session_id, "assistant", str(content)))
|
||||
|
||||
return {
|
||||
"id": f"chatcmpl-{uuid.uuid4().hex[:8]}",
|
||||
"object": "chat.completion",
|
||||
"model": req.model,
|
||||
"choices": [{"index": 0, "message": {"role": "assistant", "content": content}, "finish_reason": "stop"}],
|
||||
"usage": {},
|
||||
}
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,168 @@
|
||||
"""对话轮次 SQLite 存储。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS turns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
faiss_id INTEGER,
|
||||
token_count INTEGER DEFAULT 0,
|
||||
created_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
created_at REAL NOT NULL,
|
||||
last_active REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_turns_session ON turns(session_id, created_at);
|
||||
"""
|
||||
|
||||
_FTS_DDL = """
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS turns_fts USING fts5(
|
||||
content, role, session_id,
|
||||
content='turns', content_rowid='id'
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS turns_ai AFTER INSERT ON turns BEGIN
|
||||
INSERT INTO turns_fts(rowid, content, role, session_id)
|
||||
VALUES (new.id, new.content, new.role, new.session_id);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS turns_ad AFTER DELETE ON turns BEGIN
|
||||
INSERT INTO turns_fts(turns_fts, rowid, content, role, session_id)
|
||||
VALUES ('delete', old.id, old.content, old.role, old.session_id);
|
||||
END;
|
||||
"""
|
||||
|
||||
|
||||
class TurnStore:
|
||||
def __init__(self, db_path: Path) -> None:
|
||||
self._db_path = db_path
|
||||
self._init()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self._db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL;")
|
||||
conn.execute("PRAGMA synchronous=NORMAL;")
|
||||
return conn
|
||||
|
||||
def _init(self) -> None:
|
||||
with self._connect() as conn:
|
||||
conn.executescript(_DDL)
|
||||
conn.executescript(_FTS_DDL)
|
||||
|
||||
def add_turn(
|
||||
self, session_id: str, role: str, content: str,
|
||||
faiss_id: int | None = None, token_count: int = 0,
|
||||
) -> int:
|
||||
now = time.time()
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO sessions VALUES (?,?,?)",
|
||||
(session_id, now, now),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE sessions SET last_active=? WHERE session_id=?",
|
||||
(now, session_id),
|
||||
)
|
||||
cur = conn.execute(
|
||||
"INSERT INTO turns(session_id,role,content,faiss_id,token_count,created_at)"
|
||||
" VALUES (?,?,?,?,?,?)",
|
||||
(session_id, role, content, faiss_id, token_count, now),
|
||||
)
|
||||
return cur.lastrowid # type: ignore[return-value]
|
||||
|
||||
def list_turns(self, session_id: str) -> list[dict]:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM turns WHERE session_id=? ORDER BY created_at",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_recent_turns(self, session_id: str, n: int = 2) -> list[dict]:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM turns WHERE session_id=? ORDER BY created_at DESC LIMIT ?",
|
||||
(session_id, n),
|
||||
).fetchall()
|
||||
return [dict(r) for r in reversed(rows)]
|
||||
|
||||
def get_faiss_ids(self, session_id: str) -> list[int]:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT faiss_id FROM turns WHERE session_id=? AND faiss_id IS NOT NULL",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
def next_faiss_id(self) -> int:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute("SELECT MAX(faiss_id) FROM turns").fetchone()
|
||||
return (row[0] + 1) if row[0] is not None else 0
|
||||
|
||||
def list_sessions(self) -> list[dict]:
|
||||
"""返回所有活跃会话列表,含最近活跃时间和轮次数。"""
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT s.session_id, s.created_at, s.last_active, "
|
||||
" COUNT(t.id) AS turn_count "
|
||||
"FROM sessions s "
|
||||
"LEFT JOIN turns t ON t.session_id = s.session_id "
|
||||
"GROUP BY s.session_id "
|
||||
"ORDER BY s.last_active DESC",
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def count_turns(self) -> int:
|
||||
"""返回所有会话的总轮次数(用于统计)。"""
|
||||
with self._connect() as conn:
|
||||
row = conn.execute("SELECT COUNT(*) FROM turns").fetchone()
|
||||
return row[0] if row else 0
|
||||
|
||||
def delete_session(self, session_id: str) -> None:
|
||||
with self._connect() as conn:
|
||||
conn.execute("DELETE FROM turns WHERE session_id=?", (session_id,))
|
||||
conn.execute("DELETE FROM sessions WHERE session_id=?", (session_id,))
|
||||
|
||||
def delete_turns_by_ids(self, ids: list[int]) -> None:
|
||||
"""批量删除指定 id 的轮次(摘要归档时清理旧数据)。"""
|
||||
if not ids:
|
||||
return
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
with self._connect() as conn:
|
||||
conn.execute(f"DELETE FROM turns WHERE id IN ({placeholders})", ids)
|
||||
|
||||
def bm25_search(self, session_id: str, query: str, limit: int = 20) -> list[dict]:
|
||||
"""FTS5 BM25 全文检索指定 session 内的对话轮次。
|
||||
|
||||
bm25_score 为负值,越负越相关。
|
||||
空查询直接返回空列表。
|
||||
"""
|
||||
tokens = [t for t in query.split() if t]
|
||||
if not tokens:
|
||||
return []
|
||||
fts_query = " OR ".join(tokens)
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT t.id, t.session_id, t.role, t.content,
|
||||
t.faiss_id, t.token_count, t.created_at,
|
||||
bm25(turns_fts) AS bm25_score
|
||||
FROM turns_fts
|
||||
JOIN turns t ON t.id = turns_fts.rowid
|
||||
WHERE turns_fts MATCH ? AND t.session_id = ?
|
||||
ORDER BY bm25_score
|
||||
LIMIT ?
|
||||
""",
|
||||
(fts_query, session_id, limit),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
@@ -0,0 +1,27 @@
|
||||
# RK3588 设备端依赖
|
||||
# 系统已预装:Python 3.12.3、numpy 2.4.2、opencv-python 4.13.0
|
||||
|
||||
# 推理(需手动安装 wheel)
|
||||
# rknn-toolkit-lite2==2.3.2
|
||||
|
||||
# 推理后端(RKNN 优先;fastembed 作为回退)
|
||||
fastembed>=0.4.0 # 回退后端:ONNX 推理,无需 PyTorch
|
||||
tokenizers>=0.15.0 # ONNX 后端分词器
|
||||
|
||||
# 向量索引
|
||||
faiss-cpu>=1.7.4
|
||||
|
||||
# 文档解析
|
||||
pymupdf>=1.23.0
|
||||
python-docx>=1.1.0
|
||||
openpyxl>=3.1.0
|
||||
pytesseract>=0.3.10
|
||||
|
||||
# mem-bridge 中间层服务
|
||||
fastapi>=0.111.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
httpx>=0.27.0
|
||||
pyyaml>=6.0.1
|
||||
tiktoken>=0.7.0
|
||||
anthropic>=0.28.0
|
||||
openai>=1.35.0
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
"""mem-bridge 启动入口:单进程启动 memory-service 和 router-service。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="mem-bridge 服务启动")
|
||||
parser.add_argument("--config", default="config.yaml", help="配置文件路径")
|
||||
parser.add_argument(
|
||||
"--service", choices=["memory", "router", "both"], default="both",
|
||||
help="启动哪个服务(默认 both)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug", action="store_true",
|
||||
default=os.environ.get("KVM_DEBUG", "").lower() in ("1", "true"),
|
||||
help="Enable debug logging (env: KVM_DEBUG=1)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
is_tty = sys.stderr.isatty()
|
||||
logging.basicConfig(
|
||||
format="%(asctime)s %(levelname)s [%(name)s]: %(message)s" if is_tty
|
||||
else "[%(name)s] %(levelname)s: %(message)s",
|
||||
datefmt="%H:%M:%S" if is_tty else None,
|
||||
level=logging.DEBUG if args.debug else logging.INFO,
|
||||
)
|
||||
|
||||
cfg_path = Path(args.config)
|
||||
if not cfg_path.exists():
|
||||
logger.error("配置文件不存在: %s", cfg_path)
|
||||
raise SystemExit(1)
|
||||
|
||||
from mem_bridge.config import BridgeConfig
|
||||
bridge_cfg = BridgeConfig.from_yaml(cfg_path)
|
||||
|
||||
from embed_db.config import Config as EmbedConfig
|
||||
embed_cfg = EmbedConfig(db_dir=bridge_cfg.db_dir)
|
||||
|
||||
if args.service == "memory":
|
||||
from mem_bridge.memory_service import create_memory_app
|
||||
app = create_memory_app(bridge_cfg, embed_cfg)
|
||||
uvicorn.run(app, host=bridge_cfg.memory_host, port=bridge_cfg.memory_port)
|
||||
return
|
||||
|
||||
if args.service == "router":
|
||||
from mem_bridge.router_service import create_router_app
|
||||
app = create_router_app(
|
||||
bridge_cfg,
|
||||
memory_base_url=f"http://localhost:{bridge_cfg.memory_port}",
|
||||
)
|
||||
uvicorn.run(app, host=bridge_cfg.router_host, port=bridge_cfg.router_port)
|
||||
return
|
||||
|
||||
# both: 守护线程运行 memory-service,主线程运行 router-service
|
||||
from mem_bridge.memory_service import create_memory_app
|
||||
from mem_bridge.router_service import create_router_app
|
||||
|
||||
memory_app = create_memory_app(bridge_cfg, embed_cfg)
|
||||
router_app = create_router_app(
|
||||
bridge_cfg,
|
||||
memory_base_url=f"http://localhost:{bridge_cfg.memory_port}",
|
||||
)
|
||||
|
||||
def run_memory() -> None:
|
||||
uvicorn.run(
|
||||
memory_app,
|
||||
host=bridge_cfg.memory_host,
|
||||
port=bridge_cfg.memory_port,
|
||||
log_level="debug" if args.debug else "info",
|
||||
)
|
||||
|
||||
t = threading.Thread(target=run_memory, daemon=True)
|
||||
t.start()
|
||||
logger.info("memory-service 启动中: http://localhost:%d", bridge_cfg.memory_port)
|
||||
|
||||
uvicorn.run(
|
||||
router_app,
|
||||
host=bridge_cfg.router_host,
|
||||
port=bridge_cfg.router_port,
|
||||
log_level="debug" if args.debug else "info",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
Package: kvm-meta
|
||||
Version: 1.0.0-1
|
||||
Architecture: all
|
||||
Maintainer: KVM-Privacy <noreply@kvm-privacy.local>
|
||||
Depends: kvm-server (>= 1.0.0), kvm-agent (>= 1.0.0), kvm-privacy (>= 1.0.0)
|
||||
Recommends: kvm-bridge, kvm-npu, kvm-rkllm
|
||||
Description: KVM-Privacy complete product (metapackage)
|
||||
Installs the full KVM-Privacy product stack:
|
||||
kvm-server (Go KVM core), kvm-agent (AI automation),
|
||||
kvm-privacy (PII detection), kvm-npu (NPU inference),
|
||||
kvm-rkllm (local LLM), and optionally kvm-bridge (memory).
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
Package: kvm-mitm
|
||||
Version: 1.0.0-2
|
||||
Architecture: all
|
||||
Maintainer: KVM-Privacy <noreply@kvm-privacy.local>
|
||||
Depends: python3 (>= 3.9)
|
||||
Recommends: dnsmasq, iptables, python3-aiohttp, python3-httpx
|
||||
Description: KVM Privacy Gateway + Document Processor
|
||||
Privacy MITM interceptor (regular + transparent mode),
|
||||
document privacy processor, and LAN gateway scripts.
|
||||
Scans uploads for PII using info-privacy-rs.
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
case "$1" in
|
||||
configure)
|
||||
mkdir -p /etc/kvm-privacy
|
||||
if [ ! -f /etc/kvm-privacy/secrets.env ]; then
|
||||
cat > /etc/kvm-privacy/secrets.env <<'EOF'
|
||||
KVM_MITM_DB_HOST=localhost
|
||||
KVM_MITM_DB_USER=kvm_mitm
|
||||
KVM_MITM_DB_PASS=changeme
|
||||
KVM_MITM_DB_NAME=kvm
|
||||
EOF
|
||||
chmod 600 /etc/kvm-privacy/secrets.env
|
||||
chown root:root /etc/kvm-privacy/secrets.env
|
||||
echo "WARNING: Edit /etc/kvm-privacy/secrets.env with actual DB password"
|
||||
fi
|
||||
|
||||
# Install dnsmasq config if not present
|
||||
mkdir -p /etc/kvm
|
||||
if [ ! -f /etc/kvm/dnsmasq-lan.conf ]; then
|
||||
cp /usr/lib/kvm-mitm/network/dnsmasq-lan.conf /etc/kvm/dnsmasq-lan.conf
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable kvm-mitm.service 2>/dev/null || true
|
||||
systemctl enable doc-processor.service 2>/dev/null || true
|
||||
# gateway disabled by default — user must enable in kvm.toml
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
case "$1" in
|
||||
purge)
|
||||
# Clean runtime-generated __pycache__ files
|
||||
rm -rf /usr/lib/kvm-mitm/privacy_gateway/__pycache__
|
||||
rm -rf /usr/lib/kvm-mitm/privacy_gateway/tests/__pycache__
|
||||
rm -rf /usr/lib/kvm-mitm/doc_processor/__pycache__
|
||||
rm -rf /usr/lib/kvm-mitm/kvm_common/__pycache__
|
||||
rm -rf /usr/lib/kvm-mitm/network
|
||||
rmdir --ignore-fail-on-non-empty /usr/lib/kvm-mitm/privacy_gateway/tests 2>/dev/null || true
|
||||
rmdir --ignore-fail-on-non-empty /usr/lib/kvm-mitm/privacy_gateway 2>/dev/null || true
|
||||
rmdir --ignore-fail-on-non-empty /usr/lib/kvm-mitm/doc_processor/static 2>/dev/null || true
|
||||
rmdir --ignore-fail-on-non-empty /usr/lib/kvm-mitm/doc_processor 2>/dev/null || true
|
||||
rmdir --ignore-fail-on-non-empty /usr/lib/kvm-mitm/kvm_common 2>/dev/null || true
|
||||
rmdir --ignore-fail-on-non-empty /usr/lib/kvm-mitm 2>/dev/null || true
|
||||
# Clean config directory
|
||||
rm -rf /etc/kvm-privacy
|
||||
rm -f /etc/kvm/dnsmasq-lan.conf
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
case "$1" in
|
||||
remove|purge)
|
||||
systemctl stop kvm-mitm.service 2>/dev/null || true
|
||||
systemctl disable kvm-mitm.service 2>/dev/null || true
|
||||
systemctl stop doc-processor.service 2>/dev/null || true
|
||||
systemctl disable doc-processor.service 2>/dev/null || true
|
||||
systemctl stop kvm-gateway.service 2>/dev/null || true
|
||||
systemctl disable kvm-gateway.service 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=KVM LAN Gateway (NAT + Transparent Proxy)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
Before=privacy-gateway.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/lib/kvm-mitm/network/lan-gateway.sh
|
||||
ExecStop=/bin/bash -c "pkill -F /run/kvm-dnsmasq.pid 2>/dev/null || true"
|
||||
Environment=KVM_WAN_IF=eth0
|
||||
Environment=KVM_LAN_IF=eth1
|
||||
Environment=KVM_LAN_IP=10.42.0.1
|
||||
Environment=KVM_LAN_SUBNET=10.42.0.0/24
|
||||
Environment=KVM_PROXY_PORT=8888
|
||||
Environment=KVM_DNSMASQ_CONF=/etc/kvm/dnsmasq-lan.conf
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=KVM Privacy MITM Interceptor
|
||||
After=network.target mariadb.service info-privacy.service
|
||||
Wants=info-privacy.service
|
||||
StartLimitInterval=300
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
EnvironmentFile=/etc/kvm-privacy/secrets.env
|
||||
ExecStart=/usr/bin/kvm-mitm
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
MemoryMax=768M
|
||||
LimitNOFILE=8192
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,26 @@
|
||||
[Unit]
|
||||
Description=KVM Privacy Gateway (mitmproxy transparent)
|
||||
After=network.target info-privacy.service
|
||||
Wants=network.target info-privacy.service
|
||||
StartLimitInterval=300
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/local/bin/mitmdump \
|
||||
--mode transparent \
|
||||
--listen-host 0.0.0.0 \
|
||||
--listen-port 8888 \
|
||||
--ssl-insecure \
|
||||
-s /usr/lib/kvm-mitm/privacy_gateway/addon.py
|
||||
Environment=PYTHONPATH=/usr/lib/kvm-mitm
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
MemoryMax=768M
|
||||
LimitNOFILE=8192
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
export PYTHONPATH=/usr/lib/kvm-mitm
|
||||
exec python3 -m privacy_gateway.mitm_launcher "$@"
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
Package: kvm-npu
|
||||
Version: 1.0.0-1
|
||||
Architecture: arm64
|
||||
Maintainer: KVM-Privacy <noreply@kvm-privacy.local>
|
||||
Description: NPU Daemon - Centralized RKNN inference (OCR, face, redaction)
|
||||
Manages RKNN NPU cores on RK3588 for OCR detection,
|
||||
recognition, face detection, and privacy redaction.
|
||||
Requires librknnrt.so at /usr/lib/ (from rknn-toolkit-lite2).
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if [ "$1" = "configure" ]; then
|
||||
systemctl daemon-reload
|
||||
systemctl enable npu-daemon.service
|
||||
systemctl start npu-daemon.service || true
|
||||
fi
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if [ "$1" = "remove" ] || [ "$1" = "purge" ]; then
|
||||
systemctl stop npu-daemon.service || true
|
||||
systemctl disable npu-daemon.service || true
|
||||
fi
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8004
|
||||
|
||||
models:
|
||||
ocr_det: /usr/share/kvm-npu/models/ocr/ppocrv4_det.rknn
|
||||
ocr_rec_ch: /usr/share/kvm-npu/models/ocr/ch/ppocrv4_rec.rknn
|
||||
ocr_rec_en: /usr/share/kvm-npu/models/ocr/en/ppocrv4_rec.rknn
|
||||
ocr_dict_ch: /usr/share/kvm-npu/models/ocr/ch/ppocr_keys.txt
|
||||
ocr_dict_en: /usr/share/kvm-npu/models/ocr/en/ppocr_keys.txt
|
||||
face_det: /usr/share/kvm-npu/models/face/face_detection_short_range_rk3588.rknn
|
||||
|
||||
cores:
|
||||
ocr_det: core0
|
||||
ocr_rec: core1
|
||||
face_det: core0
|
||||
|
||||
scheduler:
|
||||
max_concurrent: 2
|
||||
queue_size: 32
|
||||
@@ -0,0 +1,38 @@
|
||||
[Unit]
|
||||
Description=NPU Daemon - Centralized RKNN inference service
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
Group=pi
|
||||
ExecStart=/usr/local/bin/npu-daemon --config /etc/npu-daemon/config.yaml
|
||||
|
||||
# Environment
|
||||
Environment=RUST_LOG=npu_daemon=info
|
||||
Environment=RKNN_LIB_DIR=/usr/lib
|
||||
|
||||
# CPU affinity: A55 cores (4-7) for NPU management overhead
|
||||
CPUAffinity=4 5 6 7
|
||||
|
||||
# Resource limits
|
||||
LimitNOFILE=65535
|
||||
MemoryMax=512M
|
||||
|
||||
# Restart policy
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
StartLimitBurst=5
|
||||
StartLimitIntervalSec=60
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadOnlyPaths=/usr/share/kvm-npu
|
||||
ReadWritePaths=/tmp
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
Package: kvm-privacy
|
||||
Version: 1.0.0-1
|
||||
Architecture: arm64
|
||||
Maintainer: KVM-Privacy <noreply@kvm-privacy.local>
|
||||
Depends: kvm-npu (>= 1.0.0)
|
||||
Description: KVM Privacy PII detection service (Rust/RKNN)
|
||||
Document privacy scanning and redaction service built with Rust.
|
||||
Uses NPU Daemon for OCR and face detection on RK3588.
|
||||
Provides REST API for analyze/redact operations.
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if [ "$1" = "configure" ]; then
|
||||
# Create configs symlinks (info-privacy-rs reads relative configs/ path)
|
||||
mkdir -p /usr/lib/kvm-privacy/configs
|
||||
ln -sf /etc/kvm-privacy/pii_rules.yaml /usr/lib/kvm-privacy/configs/pii_rules.yaml
|
||||
ln -sf /etc/kvm-privacy/surnames.txt /usr/lib/kvm-privacy/configs/surnames.txt
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable info-privacy.service
|
||||
systemctl start info-privacy.service || true
|
||||
fi
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if [ "$1" = "remove" ] || [ "$1" = "purge" ]; then
|
||||
systemctl stop info-privacy.service || true
|
||||
systemctl disable info-privacy.service || true
|
||||
fi
|
||||
@@ -0,0 +1,75 @@
|
||||
# PII 正则规则
|
||||
regex:
|
||||
id_card:
|
||||
pattern: '(?<!\d)([1-9]\d{5})(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dX](?!\d)'
|
||||
security_level: high
|
||||
phone:
|
||||
pattern: '(?<!\d)((\+?86[-\s]?)?1[3-9]\d{9})(?!\d)'
|
||||
security_level: high
|
||||
bank_card:
|
||||
pattern: '(?<!\d)([1-9]\d{15,18})(?!\d)'
|
||||
security_level: high
|
||||
email:
|
||||
pattern: '\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b'
|
||||
security_level: medium
|
||||
license_plate:
|
||||
pattern: '[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤川青藏琼宁夏][A-Z][A-Z0-9]{5,6}'
|
||||
security_level: medium
|
||||
|
||||
# 保密关键词
|
||||
confidential_keywords:
|
||||
- 机密
|
||||
- 绝密
|
||||
- 保密
|
||||
- 内部文件
|
||||
- 内部资料
|
||||
- CONFIDENTIAL
|
||||
- SECRET
|
||||
- TOP SECRET
|
||||
- RESTRICTED
|
||||
- FOR INTERNAL USE ONLY
|
||||
|
||||
# OCR + 人脸检测配置(RK3588 RKNN 子进程)
|
||||
#
|
||||
# 子模块代码路径(git submodule update --init 后自动可用):
|
||||
# deps/ocr-rknn/ → PP-OCR RKNN Python 推理代码
|
||||
# deps/mediapipe-rknn → MediaPipe 人脸检测 RKNN 代码
|
||||
#
|
||||
# RKNN 模型文件需单独下载:
|
||||
# OCR: cd deps/ocr-rknn && python download_models.py --lang ch
|
||||
# 然后用 convert_to_rknn.py 转换,或直接复制预置 .rknn 文件
|
||||
# Face: cd deps/mediapipe-rknn && bash models/download_models.sh face
|
||||
# 然后用 convert.py 转换
|
||||
#
|
||||
# det_model / rec_model / face.model 为空时,对应功能自动禁用。
|
||||
rknn:
|
||||
ocr:
|
||||
det_model: /data/project/KVM-privacy/deps/info-privacy-rs/deps/ocr-rknn/models/PP-OCRv4/det/ppocrv4_det_ch_int8.rknn
|
||||
rec_model: /data/project/KVM-privacy/deps/info-privacy-rs/deps/ocr-rknn/models/PP-OCRv4/rec/ppocrv4_rec_ch_fp16.rknn
|
||||
dict_path: deps/ocr-rknn/dicts/ppocr_keys_v1.txt
|
||||
target: rk3588
|
||||
ppocr_dir: ""
|
||||
ocr_rknn_dir: deps/ocr-rknn
|
||||
python_bin: python3
|
||||
face:
|
||||
model: /data/project/KVM-privacy/deps/info-privacy-rs/deps/mediapipe-rknn/models/face_detection_short_range_rk3588.rknn
|
||||
mediapipe_src: deps/mediapipe-rknn
|
||||
python_bin: python3
|
||||
|
||||
# 中文姓名词典规则
|
||||
ner:
|
||||
name:
|
||||
security_level: medium
|
||||
surnames_file: configs/surnames.txt
|
||||
address:
|
||||
security_level: medium
|
||||
triggers:
|
||||
- 省
|
||||
- 市
|
||||
- 区
|
||||
- 县
|
||||
- 街道
|
||||
- 路
|
||||
- 号
|
||||
- 小区
|
||||
- 村
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
# 常见中文姓氏
|
||||
李
|
||||
王
|
||||
张
|
||||
刘
|
||||
陈
|
||||
杨
|
||||
黄
|
||||
赵
|
||||
吴
|
||||
周
|
||||
徐
|
||||
孙
|
||||
马
|
||||
朱
|
||||
胡
|
||||
郭
|
||||
何
|
||||
高
|
||||
林
|
||||
郑
|
||||
谢
|
||||
罗
|
||||
梁
|
||||
宋
|
||||
唐
|
||||
许
|
||||
邓
|
||||
韩
|
||||
冯
|
||||
曹
|
||||
彭
|
||||
曾
|
||||
萧
|
||||
田
|
||||
董
|
||||
潘
|
||||
袁
|
||||
蔡
|
||||
蒋
|
||||
余
|
||||
于
|
||||
杜
|
||||
叶
|
||||
程
|
||||
苏
|
||||
魏
|
||||
吕
|
||||
丁
|
||||
任
|
||||
卢
|
||||
姚
|
||||
沈
|
||||
钟
|
||||
姜
|
||||
崔
|
||||
谭
|
||||
陆
|
||||
汪
|
||||
范
|
||||
廖
|
||||
石
|
||||
金
|
||||
韦
|
||||
贾
|
||||
夏
|
||||
付
|
||||
方
|
||||
邹
|
||||
熊
|
||||
白
|
||||
孟
|
||||
秦
|
||||
邱
|
||||
侯
|
||||
江
|
||||
尹
|
||||
薛
|
||||
闫
|
||||
段
|
||||
雷
|
||||
龙
|
||||
黎
|
||||
史
|
||||
陶
|
||||
贺
|
||||
顾
|
||||
毛
|
||||
郝
|
||||
龚
|
||||
邵
|
||||
万
|
||||
钱
|
||||
严
|
||||
覃
|
||||
武
|
||||
戴
|
||||
莫
|
||||
孔
|
||||
向
|
||||
汤
|
||||
@@ -0,0 +1,26 @@
|
||||
[Unit]
|
||||
Description=Info-Privacy-RS PII Detection Service (Rust/RKNN)
|
||||
After=network.target npu-daemon.service
|
||||
Wants=network.target npu-daemon.service
|
||||
StartLimitInterval=300
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
Group=pi
|
||||
WorkingDirectory=/usr/lib/kvm-privacy
|
||||
ExecStart=/usr/local/bin/info-privacy-rs
|
||||
Environment=PORT=8001
|
||||
Environment=NPU_DAEMON_URL=http://localhost:8004
|
||||
Environment=PII_RULES=/etc/kvm-privacy/pii_rules.yaml
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
MemoryMax=512M
|
||||
CPUAffinity=0 1
|
||||
LimitNOFILE=4096
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Face Detection Worker Daemon — 供 Rust 子进程调用(JSON line 协议)
|
||||
|
||||
协议:
|
||||
stdin 每行一个 JSON:{"image_path": "/tmp/foo.jpg", "page": 1, "target": "rk3588"}
|
||||
stdout 每行一个 JSON:{"ok": true, "faces": [...]} 或 {"ok": false, "error": "..."}
|
||||
|
||||
每个 face:
|
||||
{"bbox": [x1,y1,x2,y2], "page": int, "score": float}
|
||||
|
||||
后端优先级:
|
||||
1. NPU Daemon HTTP API(NPU_DAEMON_URL 环境变量)
|
||||
2. mediapipe-rknn 板端后端(face_detection.FaceDetector)
|
||||
3. mediapipe_rknn Python 包(x86 开发机)
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
|
||||
_NPU_DAEMON_URL = os.environ.get("NPU_DAEMON_URL", "") # e.g. http://localhost:8004
|
||||
_MEDIAPIPE_SRC = os.environ.get("MEDIAPIPE_SRC", "/data/rockchip/mediapipe/src")
|
||||
_FACE_MODEL = os.environ.get("FACE_MODEL",
|
||||
"/data/rockchip/mediapipe/models/rknn/face_detection_short_range_rk3588.rknn")
|
||||
_TARGET = os.environ.get("RKNN_TARGET", "rk3588")
|
||||
|
||||
if _MEDIAPIPE_SRC not in sys.path:
|
||||
sys.path.insert(0, _MEDIAPIPE_SRC)
|
||||
|
||||
|
||||
# ── 后端 C:NPU Daemon HTTP API(推荐,集中调度)──────────────────
|
||||
def _run_npu_daemon(image_path: str, page: int):
|
||||
"""通过 NPU Daemon HTTP API 检测人脸。"""
|
||||
import io
|
||||
import requests
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
# Fix EXIF orientation and get corrected dimensions
|
||||
img = Image.open(image_path)
|
||||
img = ImageOps.exif_transpose(img)
|
||||
img_w, img_h = img.size
|
||||
|
||||
# Re-encode with correct orientation for NPU Daemon
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=90)
|
||||
buf.seek(0)
|
||||
|
||||
url = f"{_NPU_DAEMON_URL}/api/v1/face/detect"
|
||||
resp = requests.post(
|
||||
url,
|
||||
files={"image": ("image.jpg", buf, "image/jpeg")},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
# Convert NPU Daemon normalized {x,y,width,height} → pixel [x1,y1,x2,y2]
|
||||
faces = []
|
||||
for face in data.get("faces", []):
|
||||
x1 = face["x"] * img_w
|
||||
y1 = face["y"] * img_h
|
||||
x2 = (face["x"] + face["width"]) * img_w
|
||||
y2 = (face["y"] + face["height"]) * img_h
|
||||
faces.append({
|
||||
"bbox": [float(x1), float(y1), float(x2), float(y2)],
|
||||
"page": page,
|
||||
"score": float(face["score"]),
|
||||
})
|
||||
return faces
|
||||
|
||||
|
||||
# ── 后端 A:mediapipe-rknn 项目(板端,face_detection.FaceDetector)────────
|
||||
def _try_board_backend(model_path: str, target: str):
|
||||
"""尝试板端 mediapipe-rknn 风格后端,失败返回 None。"""
|
||||
try:
|
||||
from face_detection import FaceDetector
|
||||
det = FaceDetector(model_path, target=target)
|
||||
return ("board", det)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _run_board(det, image_path: str, page: int):
|
||||
"""使用板端 FaceDetector 检测,返回 blocks。"""
|
||||
import cv2
|
||||
img = cv2.imread(image_path)
|
||||
if img is None:
|
||||
raise ValueError(f"无法读取图像: {image_path}")
|
||||
|
||||
boxes, _kps, scores = det.detect(img)
|
||||
faces = []
|
||||
for bbox, score in zip(boxes, scores):
|
||||
if float(score) < 0.5:
|
||||
continue
|
||||
x1, y1, x2, y2 = float(bbox[0]), float(bbox[1]), float(bbox[2]), float(bbox[3])
|
||||
faces.append({
|
||||
"bbox": [x1, y1, x2, y2],
|
||||
"page": page,
|
||||
"score": float(score),
|
||||
})
|
||||
return faces
|
||||
|
||||
|
||||
# ── 后端 B:mediapipe_rknn Python 包(x86 开发机)───────────────────────────
|
||||
def _try_x86_backend(model_path: str):
|
||||
"""尝试 x86 mediapipe_rknn 包风格后端,失败返回 None。"""
|
||||
try:
|
||||
from mediapipe_rknn.solutions import FaceDetection
|
||||
det = FaceDetection(model_path=model_path)
|
||||
det.load()
|
||||
return ("x86", det)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _run_x86(det, image_path: str, page: int):
|
||||
"""使用 x86 mediapipe_rknn FaceDetection 检测,返回 blocks。"""
|
||||
import cv2
|
||||
img = cv2.imread(image_path)
|
||||
if img is None:
|
||||
raise ValueError(f"无法读取图像: {image_path}")
|
||||
|
||||
results = det.detect(img)
|
||||
faces = []
|
||||
if results:
|
||||
for r in results:
|
||||
bbox = r.bbox # [x1, y1, x2, y2]
|
||||
score = getattr(r, "score", 1.0)
|
||||
if float(score) < 0.5:
|
||||
continue
|
||||
faces.append({
|
||||
"bbox": [float(v) for v in bbox],
|
||||
"page": page,
|
||||
"score": float(score),
|
||||
})
|
||||
return faces
|
||||
|
||||
|
||||
# ── 主循环 ────────────────────────────────────────────────────────────────────
|
||||
def main():
|
||||
sys.stdout.write(json.dumps({"ready": True}) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
use_npu_daemon = bool(_NPU_DAEMON_URL)
|
||||
if use_npu_daemon:
|
||||
print(f"[face_worker] Using NPU Daemon at {_NPU_DAEMON_URL}", file=sys.stderr)
|
||||
|
||||
backend_tag = None
|
||||
detector = None
|
||||
|
||||
for raw_line in sys.stdin:
|
||||
raw_line = raw_line.strip()
|
||||
if not raw_line:
|
||||
continue
|
||||
try:
|
||||
req = json.loads(raw_line)
|
||||
image_path = req["image_path"]
|
||||
page = int(req.get("page", 1))
|
||||
target = req.get("target", _TARGET)
|
||||
|
||||
if use_npu_daemon:
|
||||
faces = _run_npu_daemon(image_path, page)
|
||||
else:
|
||||
# 延迟初始化(只加载一次)
|
||||
if detector is None:
|
||||
result = _try_board_backend(_FACE_MODEL, target)
|
||||
if result:
|
||||
backend_tag, detector = result
|
||||
else:
|
||||
result = _try_x86_backend(_FACE_MODEL)
|
||||
if result:
|
||||
backend_tag, detector = result
|
||||
else:
|
||||
raise RuntimeError("无法初始化任何人脸检测后端(board 和 x86 均失败)")
|
||||
|
||||
if backend_tag == "board":
|
||||
faces = _run_board(detector, image_path, page)
|
||||
else:
|
||||
faces = _run_x86(detector, image_path, page)
|
||||
|
||||
sys.stdout.write(json.dumps({"ok": True, "faces": faces}) + "\n")
|
||||
except Exception as e:
|
||||
sys.stdout.write(json.dumps({"ok": False, "error": str(e)}) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OCR Worker Daemon — 供 Rust 子进程调用(JSON line 协议)
|
||||
|
||||
协议:
|
||||
stdin 每行一个 JSON:{"image_path": "/tmp/foo.jpg", "page": 1, "target": "rk3588"}
|
||||
stdout 每行一个 JSON:{"ok": true, "blocks": [...]} 或 {"ok": false, "error": "..."}
|
||||
|
||||
每个 block:
|
||||
{"text": str, "bbox": [x1,y1,x2,y2], "page": int, "layer": "image", "confidence": float}
|
||||
|
||||
后端优先级:
|
||||
1. NPU Daemon HTTP API(NPU_DAEMON_URL 环境变量)
|
||||
2. ppocr_rknn.PPOcrRknn(rknnlite,板端友好)
|
||||
3. ppocr_det + ppocr_rec(rknn full API)
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
|
||||
# ── 路径配置(均可通过环境变量覆盖)────────────────────────────────
|
||||
_NPU_DAEMON_URL = os.environ.get("NPU_DAEMON_URL", "") # e.g. http://localhost:8004
|
||||
_OCR_RKNN_DIR = os.environ.get("OCR_RKNN_DIR", "") # ppocr_rknn.py 所在目录
|
||||
_PPOCR_DIR = os.environ.get("OCR_PPOCR_DIR",
|
||||
"/data/rockchip/rknn_model_zoo/examples/PPOCR/PPOCR-System/python")
|
||||
_DET_MODEL = os.environ.get("OCR_DET_MODEL",
|
||||
"/data/rockchip/paddle_ocr/models/rknn/PP-OCRv4/det/ppocrv4_det_ch_int8.rknn")
|
||||
_REC_MODEL = os.environ.get("OCR_REC_MODEL",
|
||||
"/data/rockchip/paddle_ocr/models/rknn/PP-OCRv4/rec/ppocrv4_rec_ch_fp16.rknn")
|
||||
_DICT_PATH = os.environ.get("OCR_DICT_PATH",
|
||||
"/data/rockchip/rknn_model_zoo/examples/PPOCR/PPOCR-System/model/ppocr_keys_v1.txt")
|
||||
_TARGET = os.environ.get("RKNN_TARGET", "rk3588")
|
||||
|
||||
|
||||
# ── 图像预处理(EXIF 方向 + 缩放)───────────────────────────────
|
||||
_MAX_OCR_EDGE = int(os.environ.get("OCR_MAX_EDGE", "960"))
|
||||
|
||||
def _preprocess_image(image_path: str):
|
||||
"""Apply EXIF orientation and resize large images for OCR accuracy.
|
||||
|
||||
Returns (bytes, content_type) ready for HTTP upload.
|
||||
PP-OCRv4 det model input is 480×480; images much larger than ~960px
|
||||
lose text detail after downsampling. EXIF rotation is critical —
|
||||
the RKNN image crate does not auto-rotate.
|
||||
"""
|
||||
from PIL import Image, ImageOps
|
||||
import io
|
||||
|
||||
img = Image.open(image_path)
|
||||
|
||||
# Fix EXIF orientation (camera photos are often rotated)
|
||||
img = ImageOps.exif_transpose(img)
|
||||
|
||||
# Resize if too large — maintain aspect ratio
|
||||
max_edge = max(img.size)
|
||||
if max_edge > _MAX_OCR_EDGE:
|
||||
ratio = _MAX_OCR_EDGE / max_edge
|
||||
new_size = (int(img.size[0] * ratio), int(img.size[1] * ratio))
|
||||
img = img.resize(new_size, Image.LANCZOS)
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=90)
|
||||
buf.seek(0)
|
||||
return buf
|
||||
|
||||
|
||||
# ── 后端 C:NPU Daemon HTTP API(推荐,集中调度)──────────────────
|
||||
def _run_npu_daemon(image_path: str, page: int):
|
||||
"""通过 NPU Daemon HTTP API 运行 OCR。"""
|
||||
import requests
|
||||
|
||||
url = f"{_NPU_DAEMON_URL}/api/v1/ocr/analyze"
|
||||
image_buf = _preprocess_image(image_path)
|
||||
resp = requests.post(
|
||||
url,
|
||||
files={"image": ("image.jpg", image_buf, "image/jpeg")},
|
||||
data={"priority": "p3"},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
# Convert NPU Daemon format {x,y,w,h} → worker format [x1,y1,x2,y2]
|
||||
blocks = []
|
||||
for region in data.get("regions", []):
|
||||
x, y = region["x"], region["y"]
|
||||
w, h = region["w"], region["h"]
|
||||
blocks.append({
|
||||
"text": region["text"],
|
||||
"bbox": [float(x), float(y), float(x + w), float(y + h)],
|
||||
"page": page,
|
||||
"layer": "image",
|
||||
"confidence": float(region["confidence"]),
|
||||
})
|
||||
return blocks
|
||||
|
||||
|
||||
# ── 后端 A:ppocr_rknn.PPOcrRknn(支持 rknnlite,推荐板端)──────────
|
||||
def _try_ppocr_rknn_backend(target: str):
|
||||
"""尝试使用 PPOcrRknn 后端,失败返回 None。"""
|
||||
if not _OCR_RKNN_DIR or not os.path.isdir(_OCR_RKNN_DIR):
|
||||
return None
|
||||
if _OCR_RKNN_DIR not in sys.path:
|
||||
sys.path.insert(0, _OCR_RKNN_DIR)
|
||||
try:
|
||||
from ppocr_rknn import PPOcrRknn
|
||||
# PPOcrRknn.__init__ 会 print() 初始化信息到 stdout,必须临时重定向到 stderr
|
||||
# 避免污染 Rust worker 的 JSON line 协议
|
||||
_orig_stdout = sys.stdout
|
||||
sys.stdout = sys.stderr
|
||||
try:
|
||||
ocr = PPOcrRknn(
|
||||
lang="ch",
|
||||
target=target,
|
||||
det_model_path=_DET_MODEL,
|
||||
rec_model_path=_REC_MODEL,
|
||||
dict_path=_DICT_PATH,
|
||||
use_cls=False,
|
||||
)
|
||||
finally:
|
||||
sys.stdout = _orig_stdout
|
||||
return ocr
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _run_ppocr_rknn(ocr, image_path: str, page: int):
|
||||
"""使用 PPOcrRknn 运行 OCR,返回 blocks 列表。"""
|
||||
results = ocr.run(image_path) # [(box[4,2], text, score), ...]
|
||||
blocks = []
|
||||
for box, text, score in results:
|
||||
if float(score) < 0.5:
|
||||
continue
|
||||
xs = box[:, 0]
|
||||
ys = box[:, 1]
|
||||
blocks.append({
|
||||
"text": str(text),
|
||||
"bbox": [float(xs.min()), float(ys.min()),
|
||||
float(xs.max()), float(ys.max())],
|
||||
"page": page,
|
||||
"layer": "image",
|
||||
"confidence": float(score),
|
||||
})
|
||||
return blocks
|
||||
|
||||
|
||||
# ── 后端 B:ppocr_det + ppocr_rec(需要 rknn full API)──────────────
|
||||
def _load_legacy_models(target: str):
|
||||
if _PPOCR_DIR not in sys.path:
|
||||
sys.path.insert(0, _PPOCR_DIR)
|
||||
import ppocr_det as predict_det
|
||||
import ppocr_rec as predict_rec
|
||||
|
||||
class Args:
|
||||
det_model_path = _DET_MODEL
|
||||
rec_model_path = _REC_MODEL
|
||||
dict_path = _DICT_PATH
|
||||
|
||||
args = Args()
|
||||
args.target = target
|
||||
args.device_id = None
|
||||
|
||||
detector = predict_det.TextDetector(args)
|
||||
recognizer = predict_rec.TextRecognizer(args)
|
||||
return detector, recognizer, predict_det
|
||||
|
||||
|
||||
def _run_legacy(detector, recognizer, predict_det, image_path: str, page: int):
|
||||
import cv2
|
||||
|
||||
img = cv2.imread(image_path)
|
||||
if img is None:
|
||||
raise ValueError(f"无法读取图像: {image_path}")
|
||||
|
||||
ori_im = img.copy()
|
||||
dt_boxes = detector.run(img)
|
||||
if dt_boxes is None or len(dt_boxes) == 0:
|
||||
return []
|
||||
|
||||
img_crop_list = [
|
||||
predict_det.get_rotate_crop_image(ori_im, box)
|
||||
for box in sorted(dt_boxes, key=lambda b: (b[0][1], b[0][0]))
|
||||
]
|
||||
rec_res = recognizer.run(img_crop_list)
|
||||
|
||||
blocks = []
|
||||
for box, rec in zip(dt_boxes, rec_res):
|
||||
if isinstance(rec, (list, tuple)) and rec:
|
||||
text = rec[0][0] if isinstance(rec[0], (list, tuple)) else rec[0]
|
||||
score = rec[0][1] if isinstance(rec[0], (list, tuple)) else (rec[1] if len(rec) > 1 else 1.0)
|
||||
else:
|
||||
continue
|
||||
if float(score) < 0.5:
|
||||
continue
|
||||
xs, ys = box[:, 0], box[:, 1]
|
||||
blocks.append({
|
||||
"text": str(text),
|
||||
"bbox": [float(xs.min()), float(ys.min()),
|
||||
float(xs.max()), float(ys.max())],
|
||||
"page": page,
|
||||
"layer": "image",
|
||||
"confidence": float(score),
|
||||
})
|
||||
return blocks
|
||||
|
||||
|
||||
# ── 主循环 ────────────────────────────────────────────────────────────
|
||||
def main():
|
||||
sys.stdout.write(json.dumps({"ready": True}) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
use_npu_daemon = bool(_NPU_DAEMON_URL)
|
||||
if use_npu_daemon:
|
||||
print(f"[ocr_worker] Using NPU Daemon at {_NPU_DAEMON_URL}", file=sys.stderr)
|
||||
|
||||
# 延迟初始化(仅 local RKNN 后端需要)
|
||||
ppocr_rknn = None # 后端 A
|
||||
det = rec = det_mod = None # 后端 B
|
||||
backend = None
|
||||
target = _TARGET
|
||||
|
||||
for raw_line in sys.stdin:
|
||||
raw_line = raw_line.strip()
|
||||
if not raw_line:
|
||||
continue
|
||||
try:
|
||||
req = json.loads(raw_line)
|
||||
image_path = req["image_path"]
|
||||
page = int(req.get("page", 1))
|
||||
req_target = req.get("target", target)
|
||||
|
||||
if use_npu_daemon:
|
||||
blocks = _run_npu_daemon(image_path, page)
|
||||
else:
|
||||
# 首次或 target 变更时加载模型
|
||||
if backend is None or req_target != target:
|
||||
target = req_target
|
||||
ppocr_rknn = _try_ppocr_rknn_backend(target)
|
||||
if ppocr_rknn is not None:
|
||||
backend = "ppocr_rknn"
|
||||
else:
|
||||
det, rec, det_mod = _load_legacy_models(target)
|
||||
backend = "legacy"
|
||||
|
||||
if backend == "ppocr_rknn":
|
||||
blocks = _run_ppocr_rknn(ppocr_rknn, image_path, page)
|
||||
else:
|
||||
blocks = _run_legacy(det, rec, det_mod, image_path, page)
|
||||
|
||||
sys.stdout.write(json.dumps({"ok": True, "blocks": blocks}) + "\n")
|
||||
except Exception as e:
|
||||
sys.stdout.write(json.dumps({"ok": False, "error": str(e)}) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
Package: kvm-rkllm
|
||||
Version: 1.0.0-1
|
||||
Architecture: arm64
|
||||
Maintainer: KVM-Privacy <noreply@kvm-privacy.local>
|
||||
Depends: python3 (>= 3.9)
|
||||
Recommends: python3-fastapi, python3-uvicorn, python3-pydantic
|
||||
Description: RKLLM NPU inference server (Qwen on RK3588)
|
||||
OpenAI-compatible chat API backed by RKLLM NPU runtime.
|
||||
Includes librkllmrt.so for RK3588 NPU acceleration.
|
||||
LLM model not included - set RKLLM_MODEL env var.
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if [ "$1" = "configure" ]; then
|
||||
# Ensure librkllmrt.so is discoverable by the dynamic linker
|
||||
ldconfig 2>/dev/null || true
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable rkllm-server.service
|
||||
systemctl start rkllm-server.service || true
|
||||
fi
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if [ "$1" = "remove" ] || [ "$1" = "purge" ]; then
|
||||
systemctl stop rkllm-server.service || true
|
||||
systemctl disable rkllm-server.service || true
|
||||
fi
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
export PYTHONPATH=/usr/lib/kvm-rkllm
|
||||
exec python3 -m rkllm_server "$@"
|
||||
@@ -0,0 +1,119 @@
|
||||
# KVM-Privacy TOML Configuration
|
||||
# Place at /etc/kvm/kvm.toml (takes priority over config.json)
|
||||
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 8080
|
||||
log_level = "info" # debug, info, warn, error
|
||||
dev_mode = false
|
||||
|
||||
[server.tls]
|
||||
enabled = false
|
||||
auto_cert = true # auto-generate self-signed cert
|
||||
cert_path = "/etc/kvm/tls/cert.pem"
|
||||
key_path = "/etc/kvm/tls/key.pem"
|
||||
|
||||
[database]
|
||||
host = "127.0.0.1"
|
||||
port = 3306
|
||||
user = "kvm"
|
||||
password = ""
|
||||
database = "kvm"
|
||||
|
||||
[audit]
|
||||
enabled = true
|
||||
retention_days = 30
|
||||
syslog_enabled = false
|
||||
# syslog_address = "localhost:514"
|
||||
# syslog_protocol = "udp"
|
||||
|
||||
[auth]
|
||||
jwt_expiry_hours = 24
|
||||
totp_issuer = "KVM"
|
||||
max_login_attempts = 5
|
||||
lockout_minutes = 15
|
||||
password_min_length = 12
|
||||
|
||||
[hid]
|
||||
keyboard_device = "/dev/hidg0"
|
||||
mouse_device = "/dev/hidg1"
|
||||
enabled = true
|
||||
|
||||
[gstreamer]
|
||||
video_device = "auto"
|
||||
width = 1920
|
||||
height = 1080
|
||||
fps = 30
|
||||
codec = "h264"
|
||||
bitrate = 2000000
|
||||
pipeline_backend = "native" # "gstreamer" or "native" (RKMPP+RGA)
|
||||
|
||||
[ocr]
|
||||
enabled = true
|
||||
binary_path = "/usr/bin/kvm-ocr"
|
||||
model_path = "/etc/kvm/models/ocr"
|
||||
language = "ch"
|
||||
sample_interval_sec = 2
|
||||
block_critical = true
|
||||
|
||||
[recording]
|
||||
enabled = false
|
||||
storage_path = "/var/lib/kvm/recordings"
|
||||
retention_days = 30
|
||||
|
||||
[terminal]
|
||||
enabled = true
|
||||
max_sessions = 10
|
||||
|
||||
[wireguard]
|
||||
enabled = false
|
||||
interface = "wg0"
|
||||
listen_port = 51820
|
||||
|
||||
[storage]
|
||||
enabled = false
|
||||
media_dir = "/var/lib/kvm/media"
|
||||
|
||||
# ── LAN Gateway (transparent proxy) ───────────────────
|
||||
# Requires NanoPC-T6 dual Ethernet: eth0=WAN, eth1=LAN→controlled PC
|
||||
|
||||
[network]
|
||||
gateway_enabled = false # enable dual-NIC transparent gateway
|
||||
wan_interface = "eth0"
|
||||
lan_interface = "eth1"
|
||||
lan_subnet = "10.42.0.0/24"
|
||||
lan_gateway = "10.42.0.1"
|
||||
dhcp_range = "10.42.0.100,10.42.0.200,12h"
|
||||
transparent_proxy = true
|
||||
|
||||
# ── Sub-service discovery ──────────────────────────────
|
||||
|
||||
[services.privacy]
|
||||
enabled = true
|
||||
address = "127.0.0.1:8000"
|
||||
|
||||
[services.agent]
|
||||
enabled = true
|
||||
address = "127.0.0.1:8890"
|
||||
|
||||
[services.gateway]
|
||||
enabled = true
|
||||
mode = "audit" # off, audit, redact
|
||||
|
||||
[services.bridge]
|
||||
enabled = false
|
||||
memory_address = "127.0.0.1:8001"
|
||||
router_address = "127.0.0.1:8002"
|
||||
|
||||
# ── DDNS Dynamic DNS ──────────────────────────────────
|
||||
|
||||
[ddns]
|
||||
enabled = false
|
||||
provider = "duckdns" # cloudflare | duckdns | noip | custom
|
||||
domain = ""
|
||||
update_interval_sec = 300
|
||||
api_token = "" # Cloudflare API Token
|
||||
duckdns_token = "" # DuckDNS Token
|
||||
noip_user = "" # No-IP username
|
||||
noip_pass = "" # No-IP password
|
||||
webhook_url = "" # Custom: supports {ip} {domain} templates
|
||||
@@ -0,0 +1,13 @@
|
||||
# systemd-networkd: WAN interface (DHCP client)
|
||||
[Match]
|
||||
Name=eth0
|
||||
|
||||
[Network]
|
||||
DHCP=yes
|
||||
DNS=8.8.8.8
|
||||
DNS=1.1.1.1
|
||||
|
||||
[DHCP]
|
||||
UseDNS=true
|
||||
UseNTP=true
|
||||
RouteMetric=100
|
||||
@@ -0,0 +1,8 @@
|
||||
# systemd-networkd: LAN interface (static IP, no DHCP)
|
||||
[Match]
|
||||
Name=eth1
|
||||
|
||||
[Network]
|
||||
Address=10.42.0.1/24
|
||||
DHCPServer=false
|
||||
IPForward=yes
|
||||
@@ -0,0 +1,32 @@
|
||||
# dnsmasq-lan.conf — DHCP + DNS for KVM controlled PC on LAN (eth1)
|
||||
#
|
||||
# Controlled PC gets: IP 10.42.0.100-200, GW 10.42.0.1, DNS 10.42.0.1
|
||||
# dnsmasq forwards DNS upstream via /etc/resolv.conf
|
||||
|
||||
# Only listen on LAN interface
|
||||
interface=eth1
|
||||
bind-interfaces
|
||||
|
||||
# DHCP range: 10.42.0.100 to 10.42.0.200, lease 12 hours
|
||||
dhcp-range=10.42.0.100,10.42.0.200,12h
|
||||
|
||||
# Gateway = this device
|
||||
dhcp-option=3,10.42.0.1
|
||||
|
||||
# DNS = this device (dnsmasq also serves DNS)
|
||||
dhcp-option=6,10.42.0.1
|
||||
|
||||
# Domain
|
||||
domain=kvm.local
|
||||
|
||||
# Logging
|
||||
log-dhcp
|
||||
|
||||
# Do not read /etc/hosts
|
||||
no-hosts
|
||||
|
||||
# PID file
|
||||
pid-file=/run/kvm-dnsmasq.pid
|
||||
|
||||
# Do not daemonize (systemd manages lifecycle)
|
||||
keep-in-foreground
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
# lan-gateway.sh — Configure NanoPC-T6 as a transparent gateway for the controlled PC.
|
||||
#
|
||||
# Network topology:
|
||||
# Internet ←─eth0─ NanoPC-T6 ─eth1─→ Controlled PC (DHCP 10.42.0.x)
|
||||
# │
|
||||
# iptables PREROUTING
|
||||
# REDIRECT :80,:443 → :8888
|
||||
# │
|
||||
# mitmproxy:8888
|
||||
# (transparent mode)
|
||||
#
|
||||
# Environment variables (or defaults):
|
||||
# KVM_WAN_IF — WAN interface (default: eth0)
|
||||
# KVM_LAN_IF — LAN interface (default: eth1)
|
||||
# KVM_LAN_IP — LAN gateway IP (default: 10.42.0.1)
|
||||
# KVM_LAN_SUBNET — LAN subnet (default: 10.42.0.0/24)
|
||||
# KVM_PROXY_PORT — mitmproxy listen port (default: 8888)
|
||||
# KVM_DNSMASQ_CONF — dnsmasq config path (default: /etc/kvm/dnsmasq-lan.conf)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
WAN="${KVM_WAN_IF:-eth0}"
|
||||
LAN="${KVM_LAN_IF:-eth1}"
|
||||
LAN_IP="${KVM_LAN_IP:-10.42.0.1}"
|
||||
LAN_SUBNET="${KVM_LAN_SUBNET:-10.42.0.0/24}"
|
||||
PROXY_PORT="${KVM_PROXY_PORT:-8888}"
|
||||
DNSMASQ_CONF="${KVM_DNSMASQ_CONF:-/etc/kvm/dnsmasq-lan.conf}"
|
||||
|
||||
log() { echo "[kvm-gateway] $(date '+%H:%M:%S') $*"; }
|
||||
|
||||
# ── Cleanup function for idempotent re-runs ──
|
||||
cleanup_rules() {
|
||||
log "Flushing existing kvm-gateway iptables rules..."
|
||||
iptables -t nat -D POSTROUTING -o "$WAN" -s "$LAN_SUBNET" -j MASQUERADE 2>/dev/null || true
|
||||
iptables -D FORWARD -i "$LAN" -o "$WAN" -j ACCEPT 2>/dev/null || true
|
||||
iptables -D FORWARD -i "$WAN" -o "$LAN" -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
|
||||
iptables -t nat -D PREROUTING -i "$LAN" -p tcp --dport 80 -j REDIRECT --to-port "$PROXY_PORT" 2>/dev/null || true
|
||||
iptables -t nat -D PREROUTING -i "$LAN" -p tcp --dport 443 -j REDIRECT --to-port "$PROXY_PORT" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ── 1. Configure LAN interface ──
|
||||
log "Configuring $LAN with IP $LAN_IP/24"
|
||||
ip addr flush dev "$LAN" 2>/dev/null || true
|
||||
ip addr add "$LAN_IP/24" dev "$LAN" 2>/dev/null || true
|
||||
ip link set "$LAN" up
|
||||
|
||||
# ── 2. Enable IP forwarding ──
|
||||
log "Enabling IP forwarding"
|
||||
sysctl -w net.ipv4.ip_forward=1 >/dev/null
|
||||
|
||||
# ── 3. Clean up old rules then apply NAT ──
|
||||
cleanup_rules
|
||||
|
||||
log "Setting up NAT ($LAN_SUBNET → $WAN)"
|
||||
iptables -t nat -A POSTROUTING -o "$WAN" -s "$LAN_SUBNET" -j MASQUERADE
|
||||
iptables -A FORWARD -i "$LAN" -o "$WAN" -j ACCEPT
|
||||
iptables -A FORWARD -i "$WAN" -o "$LAN" -m state --state RELATED,ESTABLISHED -j ACCEPT
|
||||
|
||||
# ── 4. Transparent proxy redirect (HTTP/HTTPS → mitmproxy) ──
|
||||
log "Redirecting HTTP/HTTPS from $LAN to mitmproxy :$PROXY_PORT"
|
||||
iptables -t nat -A PREROUTING -i "$LAN" -p tcp --dport 80 -j REDIRECT --to-port "$PROXY_PORT"
|
||||
iptables -t nat -A PREROUTING -i "$LAN" -p tcp --dport 443 -j REDIRECT --to-port "$PROXY_PORT"
|
||||
|
||||
# ── 5. Start dnsmasq for DHCP + DNS on LAN ──
|
||||
if [ -f "$DNSMASQ_CONF" ]; then
|
||||
# Kill any existing dnsmasq on LAN interface
|
||||
pkill -f "dnsmasq.*$DNSMASQ_CONF" 2>/dev/null || true
|
||||
sleep 0.5
|
||||
log "Starting dnsmasq (DHCP + DNS) on $LAN"
|
||||
dnsmasq --conf-file="$DNSMASQ_CONF" --pid-file=/run/kvm-dnsmasq.pid
|
||||
else
|
||||
log "WARNING: dnsmasq config not found at $DNSMASQ_CONF — DHCP not started"
|
||||
fi
|
||||
|
||||
log "Gateway setup complete. Controlled PC should get IP via DHCP on $LAN."
|
||||
@@ -1,16 +1,24 @@
|
||||
[Unit]
|
||||
Description=Info-Privacy-RS PII Detection Service (Rust/RKNN)
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
After=network.target npu-daemon.service
|
||||
Wants=network.target npu-daemon.service
|
||||
StartLimitInterval=300
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
Group=pi
|
||||
WorkingDirectory=/home/pi/Desktop/info-privacy-rs
|
||||
ExecStart=/home/pi/Desktop/info-privacy-rs/target/release/info-privacy-rs
|
||||
WorkingDirectory=/usr/lib/kvm-privacy
|
||||
ExecStart=/usr/local/bin/info-privacy-rs
|
||||
Environment=PORT=8001
|
||||
Environment=NPU_DAEMON_URL=http://localhost:8004
|
||||
Environment=PII_RULES=/etc/kvm-privacy/pii_rules.yaml
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
RestartSec=10
|
||||
MemoryMax=512M
|
||||
CPUAffinity=0 1
|
||||
LimitNOFILE=4096
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# /etc/systemd/journald.conf.d/kvm.conf
|
||||
#
|
||||
# KVM-Privacy journald configuration for NanoPC-T6 (eMMC protection).
|
||||
# Volatile mode keeps logs in RAM only — protects eMMC from write wear.
|
||||
# Ring buffer capped at 128M, entries retained for 7 days max.
|
||||
#
|
||||
# Install: cp deploy/systemd/journald-kvm.conf /etc/systemd/journald.conf.d/kvm.conf
|
||||
# Reload: systemctl restart systemd-journald
|
||||
|
||||
[Journal]
|
||||
Storage=volatile
|
||||
RuntimeMaxUse=128M
|
||||
RuntimeKeepFree=64M
|
||||
RuntimeMaxFileSize=32M
|
||||
MaxRetentionSec=7day
|
||||
RateLimitIntervalSec=5s
|
||||
RateLimitBurst=1000
|
||||
Compress=yes
|
||||
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=KVM AI Agent v2
|
||||
After=network.target mariadb.service
|
||||
Wants=mem-bridge-memory.service
|
||||
StartLimitInterval=300
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
EnvironmentFile=/etc/kvm-agent/secrets.env
|
||||
ExecStart=/usr/bin/kvm-agent daemon
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
MemoryMax=512M
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=KVM LAN Gateway (NAT + Transparent Proxy)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
Before=privacy-gateway.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/data/project/KVM-privacy/deploy/network/lan-gateway.sh
|
||||
ExecStop=/bin/bash -c "pkill -F /run/kvm-dnsmasq.pid 2>/dev/null || true"
|
||||
Environment=KVM_WAN_IF=eth0
|
||||
Environment=KVM_LAN_IF=eth1
|
||||
Environment=KVM_LAN_IP=10.42.0.1
|
||||
Environment=KVM_LAN_SUBNET=10.42.0.0/24
|
||||
Environment=KVM_PROXY_PORT=8888
|
||||
Environment=KVM_DNSMASQ_CONF=/etc/kvm/dnsmasq-lan.conf
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=KVM Privacy MITM Interceptor
|
||||
After=network.target mariadb.service info-privacy.service
|
||||
Wants=info-privacy.service
|
||||
StartLimitInterval=300
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
EnvironmentFile=/etc/kvm-privacy/secrets.env
|
||||
ExecStart=/usr/bin/kvm-mitm
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
MemoryMax=768M
|
||||
LimitNOFILE=8192
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -2,16 +2,23 @@
|
||||
Description=Mem-Bridge Memory Service
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
StartLimitInterval=300
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
Group=pi
|
||||
WorkingDirectory=/home/pi/Desktop/embed-db
|
||||
Environment=PYTHONPATH=/home/pi/Desktop/embed-db/src
|
||||
EnvironmentFile=-/home/pi/Desktop/embed-db/.env
|
||||
ExecStart=/home/pi/Desktop/embed-db/venv/bin/python server.py --service memory
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
RestartSec=10
|
||||
MemoryMax=512M
|
||||
# RK3588: pin to A55 small cores 2-3 (Python FAISS + embedding)
|
||||
CPUAffinity=2 3
|
||||
LimitNOFILE=4096
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
|
||||
@@ -3,16 +3,23 @@ Description=Mem-Bridge Router Service
|
||||
After=network.target mem-bridge-memory.service
|
||||
Wants=network.target
|
||||
Requires=mem-bridge-memory.service
|
||||
StartLimitInterval=300
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
Group=pi
|
||||
WorkingDirectory=/home/pi/Desktop/embed-db
|
||||
Environment=PYTHONPATH=/home/pi/Desktop/embed-db/src
|
||||
EnvironmentFile=-/home/pi/Desktop/embed-db/.env
|
||||
ExecStart=/home/pi/Desktop/embed-db/venv/bin/python server.py --service router
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
RestartSec=10
|
||||
MemoryMax=2G
|
||||
# RK3588: pin to A55 small cores 2-3 (AI routing + LLM orchestration)
|
||||
CPUAffinity=2 3
|
||||
LimitNOFILE=4096
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=KVM Privacy Gateway REST API
|
||||
After=network.target privacy-gateway.service
|
||||
Wants=privacy-gateway.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/data/project/KVM-privacy/services
|
||||
ExecStart=/usr/bin/python3 -m privacy_gateway
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
MemoryMax=128M
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -2,19 +2,23 @@
|
||||
Description=KVM Privacy Gateway (mitmproxy)
|
||||
After=network.target info-privacy.service
|
||||
Wants=network.target info-privacy.service
|
||||
StartLimitInterval=300
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/data/project/KVM-privacy/services/privacy_gateway
|
||||
ExecStart=/usr/local/bin/mitmdump \
|
||||
--mode regular \
|
||||
--mode transparent \
|
||||
--listen-host 0.0.0.0 \
|
||||
--listen-port 8888 \
|
||||
--ssl-insecure \
|
||||
-s /data/project/KVM-privacy/services/privacy_gateway/addon.py
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
MemoryMax=768M
|
||||
LimitNOFILE=8192
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
[Unit]
|
||||
Description=RKLLM Server (Qwen3-0.6B on RK3588 NPU, port 8891)
|
||||
Documentation=https://github.com/airockchip/rknn-llm
|
||||
After=network.target
|
||||
Before=mem-bridge-router.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
WorkingDirectory=/usr/lib/kvm-rkllm
|
||||
Environment=RKLLM_MODEL=/opt/fileguard/models/Qwen3-0.6B-rk3588-w8a8-opt-1-hybrid-ratio-0.5.rkllm
|
||||
Environment=RKLLM_PORT=8891
|
||||
Environment=PYTHONPATH=/usr/lib/kvm-rkllm
|
||||
ExecStart=/usr/bin/python3 -m rkllm_server
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
CPUAffinity=0 1
|
||||
MemoryMax=2G
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=rkllm-server
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=KVM-Privacy Workflow Dashboard (port 9099)
|
||||
After=network.target npu-daemon.service
|
||||
Wants=npu-daemon.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
Group=pi
|
||||
WorkingDirectory=/data/project/KVM-privacy/tools/workflow-dashboard
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=/usr/bin/python3 -m workflow_dashboard
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
MemoryMax=256M
|
||||
CPUAffinity=4 5 6 7
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Vendored
+1
-1
Submodule deps/KVM updated: 5369dcddd7...876a345c42
Vendored
+1
-1
Submodule deps/embedding updated: eb29ab36b4...d974f89337
Vendored
+1
-1
Submodule deps/info-privacy-rs updated: b1ed721f37...6b93cbcadd
@@ -0,0 +1,23 @@
|
||||
services:
|
||||
workflow-dashboard:
|
||||
build:
|
||||
context: ./tools/workflow-dashboard
|
||||
ports:
|
||||
- "9099:9099"
|
||||
volumes:
|
||||
- ./tools/workflow-dashboard/testdata/results:/app/testdata/results
|
||||
environment:
|
||||
- RESULTS_DIR=testdata/results
|
||||
- KVM_URL=http://host.docker.internal:8080
|
||||
- KVM_JWT_TOKEN=${KVM_JWT_TOKEN:-}
|
||||
- PRIVACY_RS_URL=http://host.docker.internal:8001
|
||||
- AGENT_URL=http://host.docker.internal:8890
|
||||
- NPU_DAEMON_URL=http://host.docker.internal:8004
|
||||
- RKLLM_URL=http://host.docker.internal:8891
|
||||
- LLM_API_KEY=${LLM_API_KEY:-}
|
||||
- LLM_MODEL=${LLM_MODEL:-gpt-4o}
|
||||
- no_proxy=host.docker.internal,localhost,127.0.0.1
|
||||
- NO_PROXY=host.docker.internal,localhost,127.0.0.1
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,801 @@
|
||||
# KVM-Privacy AI Agent 架构评估:本地计算卸载与 Token 优化
|
||||
|
||||
> 评估日期:2026-03-06
|
||||
> 目标硬件:NanoPC-T6 (RK3588, 8 核 ARM64, 6 TOPS NPU)
|
||||
> 当前代码库:`services/kvm_agent/`
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [现有架构分析](#1-现有架构分析)
|
||||
2. [Playwright 工作流模式借鉴](#2-playwright-工作流模式借鉴)
|
||||
3. [最新 LLM 能力评估 (2025-2026)](#3-最新-llm-能力评估-2025-2026)
|
||||
4. [本地计算卸载策略](#4-本地计算卸载策略)
|
||||
5. [记忆系统 Token 优化](#5-记忆系统-token-优化)
|
||||
6. [Token 消耗定量分析](#6-token-消耗定量分析)
|
||||
7. [架构升级建议](#7-架构升级建议)
|
||||
8. [实施路线图](#8-实施路线图)
|
||||
|
||||
---
|
||||
|
||||
## 1. 现有架构分析
|
||||
|
||||
### 1.1 当前数据流
|
||||
|
||||
```
|
||||
截图 (JPEG ~50-150KB)
|
||||
↓
|
||||
OCR (本地 RKNN NPU, ~60ms) → SceneGraph (UIElement[])
|
||||
↓
|
||||
screen_state.py 检测 → PCState (SLEEP/BIOS/LOCK/DESKTOP/APP)
|
||||
↓
|
||||
hybrid_planner.py 三路分发:
|
||||
├── Path 1: TemplateStore (FAISS 语义匹配) → 本地重放 (0 token)
|
||||
├── Path 2: 本地 RKLLM (Qwen2.5-1.5B, text-only) → 简单任务
|
||||
└── Path 3: 云端 LLM (gemini-2.5-pro) → 复杂任务 (高 token)
|
||||
↓
|
||||
Action 执行 (HID: 鼠标/键盘)
|
||||
↓
|
||||
Validator 验证 (pixel_diff + OCR)
|
||||
↓
|
||||
Memory 记录 (mem-bridge: FAISS + SQLite)
|
||||
```
|
||||
|
||||
### 1.2 现有代码结构映射
|
||||
|
||||
| 模块 | 文件 | 职责 | 计算位置 |
|
||||
|------|------|------|----------|
|
||||
| 感知 | `perception.py` | OCR → SceneGraph → UIElement | 本地 NPU |
|
||||
| 状态检测 | `screen_state.py` | 截图哈希 + OCR 关键词 → PCState | 本地 CPU |
|
||||
| LLM 规划 | `llm_planner.py` | 截图+任务 → Action JSON | 云端 |
|
||||
| 混合规划 | `hybrid_planner.py` | 模板/本地/云端三路分发 | 混合 |
|
||||
| 验证 | `validator.py` | pixel_diff + OCR 验证 | 本地 CPU |
|
||||
| 模板 | `template_store.py` | FAISS 语义匹配 + 重放 | 本地 (RKNN embedding) |
|
||||
| 记忆 | `memory_client.py` | mem-bridge REST 客户端 | 本地 (FAISS) |
|
||||
| 执行 | `agent.py` | perceive-decide-act 循环 | 混合 |
|
||||
|
||||
### 1.3 现有架构优势
|
||||
|
||||
项目已具备良好的混合架构基础:
|
||||
|
||||
1. **三路分发机制** (`hybrid_planner.py`): 模板 → 本地 LLM → 云端 LLM 的降级链
|
||||
2. **OCR 优先坐标** (`agent.py:_click_with_retry`): 利用本地 OCR 精确定位,减少 LLM 坐标猜测
|
||||
3. **模板重放** (`template_store.py`): 已知任务零 token 执行
|
||||
4. **SceneGraph 结构化** (`perception.py:to_text_summary`): 区域分组、大小信息,减少 LLM 理解成本
|
||||
|
||||
### 1.4 已识别瓶颈
|
||||
|
||||
| 瓶颈 | 影响 | 量化 |
|
||||
|------|------|------|
|
||||
| 每步发送完整截图 | 高 token 消耗 | ~1290 tokens/image (1024x1024) |
|
||||
| system prompt 每次重发 | 固定 token 开销 | ~2000 tokens/turn |
|
||||
| scene_text 未压缩 | 冗余信息 | 30 个元素 ~1500 tokens |
|
||||
| 无状态 LLM 调用 | 历史仅保留最近 3 条 | 丢失上下文,重复规划 |
|
||||
| 验证-重试循环 | 失败时多次 LLM 调用 | 最坏 3x token 消耗 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Playwright 工作流模式借鉴
|
||||
|
||||
### 2.1 Playwright 核心设计理念
|
||||
|
||||
Playwright 作为 2025-2026 年最主流的浏览器自动化框架,其设计理念对 KVM Agent 有重要参考价值:
|
||||
|
||||
**自动等待 (Auto-Waiting)**
|
||||
|
||||
Playwright 在执行每个操作前自动进行 actionability 检查:元素可见、可交互、稳定(无动画)、无遮挡。KVM Agent 当前仅使用 `asyncio.sleep()` 固定延迟,缺乏自适应等待。
|
||||
|
||||
借鉴:引入 **OCR 稳定性检测** — 连续两次 OCR 结果一致才认为屏幕稳定,替代固定延迟。
|
||||
|
||||
**重试机制 (Retry)**
|
||||
|
||||
Playwright 2026 的企业级框架推荐 `trace: 'on-first-retry'` 模式 — 首次重试时记录完整 trace 以便调试,而非每次都记录。
|
||||
|
||||
借鉴:KVM Agent 的 `_click_with_retry` 已实现 3 次重试,但缺少 **重试证据记录**。应在首次重试时保存截图+OCR 状态,用于后续分析和模板优化。
|
||||
|
||||
**Accessibility Tree vs 截图**
|
||||
|
||||
Playwright MCP (2026) 使用浏览器的 accessibility tree 而非截图进行元素定位,token 消耗降低 4x(114K → 27K tokens)。
|
||||
|
||||
关键差异:KVM Agent 无法访问 DOM/accessibility tree,只能通过 OCR 构建 SceneGraph。但 **SceneGraph 本质上就是 KVM 的 "accessibility tree"**,应进一步结构化以减少 LLM 理解成本。
|
||||
|
||||
### 2.2 ActionEngine 状态机模式 (2026 最新研究)
|
||||
|
||||
Georgia Tech 与 Microsoft Research 联合发表的 [ActionEngine](https://arxiv.org/abs/2602.20502) 提出了从 **响应式** (每步调用 LLM) 到 **编程式** (状态机驱动) 的范式转换:
|
||||
|
||||
- **Crawling Agent**: 离线探索 GUI,构建可更新的状态机记忆
|
||||
- **Execution Agent**: 利用状态机记忆合成完整 Python 程序执行任务
|
||||
- **效果**: WebArena Reddit 任务 95% 成功率,平均仅 **1 次 LLM 调用**,成本降低 **11.8x**
|
||||
|
||||
**对 KVM Agent 的启示**:
|
||||
|
||||
当前 `template_store.py` 的模板重放已实现类似思路,但模板是线性序列,缺乏分支和条件跳转。将模板升级为 **状态机** 可显著减少 LLM 调用:
|
||||
|
||||
```python
|
||||
# 当前: 线性模板
|
||||
steps = [click("搜索"), type("notepad"), click("记事本")]
|
||||
|
||||
# 升级: 状态机模板
|
||||
states = {
|
||||
"start": {"action": click("搜索"), "next": "search_open"},
|
||||
"search_open": {
|
||||
"verify": ocr_contains("输入搜索内容"),
|
||||
"action": type("notepad"),
|
||||
"next": "results_shown",
|
||||
"fallback": "start", # 搜索框未打开,重试
|
||||
},
|
||||
"results_shown": {
|
||||
"verify": ocr_contains("记事本"),
|
||||
"action": click("记事本"),
|
||||
"next": "done",
|
||||
"fallback": "search_open", # 结果未出现,重试搜索
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 可借鉴模式总结
|
||||
|
||||
| Playwright 模式 | KVM Agent 当前实现 | 建议改进 |
|
||||
|-----------------|-------------------|----------|
|
||||
| Auto-Wait | 固定 sleep | OCR 稳定性检测 |
|
||||
| Retry + Trace | 3 次重试,无 trace | 首次重试保存证据 |
|
||||
| Element Selector | OCR find_by_text (模糊匹配) | 增加位置+类型联合匹配 |
|
||||
| Assertion retry | validate_click 单次 | 异步轮询等待条件满足 |
|
||||
| 状态机 (ActionEngine) | 线性模板 | 带条件分支的状态机模板 |
|
||||
| Accessibility tree | SceneGraph | 进一步结构化 + 压缩 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 最新 LLM 能力评估 (2025-2026)
|
||||
|
||||
### 3.1 主流模型对比 (2026-03)
|
||||
|
||||
| 模型 | 输入价格 (/M tokens) | 输出价格 (/M tokens) | Vision | Agent 能力 | 适用场景 |
|
||||
|------|---------------------|---------------------|--------|-----------|---------|
|
||||
| GPT-5.4 | $2.50 | $10.00 | 强 | 强 | 复杂推理+视觉 |
|
||||
| Claude Opus 4.6 | $15.00 | $75.00 | 强 | 极强 (Computer Use) | 代码+长推理 |
|
||||
| Gemini 2.5 Pro | $1.25 | $10.00 | 强 | 强 | **性价比首选** |
|
||||
| GPT-4o | $2.50 | $10.00 | 强 | 中 | 通用视觉任务 |
|
||||
| Claude Sonnet 4.5 | $3.00 | $15.00 | 强 | 强 | 平衡性价比 |
|
||||
|
||||
**当前配置 `gemini-2.5-pro` 是正确的性价比选择**,输入价格仅 $1.25/M,适合高频 agent 调用。
|
||||
|
||||
### 3.2 Computer Use 能力进展
|
||||
|
||||
2025-2026 年 LLM 的 Computer Use 能力取得重大进展:
|
||||
|
||||
- **Claude Computer Use**: Anthropic 在 2025 年推出 Chrome 扩展,允许 Claude 直接控制浏览器。Claude Opus 4.5/4.6 在 agentic tool use 方面表现最强
|
||||
- **GPT-5.x 推理突破**: GPT-5.2 突破 ARC-AGI-1 的 90% 门槛;GPT-5.4 在编程和专业工作流方面定位高端
|
||||
- **结构化输出**: 所有主流模型现在原生支持 JSON Schema 强制输出,KVM Agent 的 `_parse_action` JSON 解析可更可靠
|
||||
- **视觉理解提升**: 对 UI 元素、按钮、文本的识别精度显著提升,理论上可减少 OCR 依赖
|
||||
|
||||
### 3.3 对 KVM Agent 的影响
|
||||
|
||||
**短期可用改进**:
|
||||
1. **启用结构化输出** (Structured Output / JSON mode): `gemini-2.5-pro` 支持 `response_format: json_object`,可消除 JSON 解析失败
|
||||
2. **减少 system prompt**: 利用模型内在的 Computer Use 知识,system prompt 可精简 30-50%
|
||||
3. **图像分辨率控制**: 发送低分辨率截图 (512x384 而非 1920x1080) 可减少 ~75% 图像 token
|
||||
|
||||
**中期可探索方向**:
|
||||
1. **原生 Computer Use API**: 如果 Anthropic 开放 Computer Use API 的坐标输出,可直接替代 OCR + LLM 双调用
|
||||
2. **多模态本地模型**: Qwen2.5-VL-3B 已可在 RK3588 NPU 上运行,作为轻量级视觉理解备选
|
||||
|
||||
---
|
||||
|
||||
## 4. 本地计算卸载策略
|
||||
|
||||
### 4.1 RK3588 NPU 能力现状 (2025-2026)
|
||||
|
||||
根据最新 benchmark 数据和 RKNN-LLM 生态:
|
||||
|
||||
| 模型 | 参数量 | 量化 | 推理速度 | 内存占用 | 用途 |
|
||||
|------|--------|------|---------|---------|------|
|
||||
| TinyLlama 1.1B | 1.1B | W8A8 | 10-15 tok/s | ~1.5 GB | 简单文本任务 |
|
||||
| Qwen2.5-1.5B | 1.5B | W8A8 | 8-12 tok/s | ~2 GB | **当前本地 LLM** |
|
||||
| Qwen2.5-VL-3B | 3B | W8A8 | ~5-8 tok/s | ~4.6 GB | **潜力视觉模型** |
|
||||
| InternVL3.5-1B | 1B | W8A8 | ~24 tok/s | ~1.5 GB | 轻量视觉理解 |
|
||||
| InternVL3.5-2B | 2B | W8A8 | ~11.2 tok/s | ~3 GB | 中等视觉理解 |
|
||||
| ResNet18 (分类) | 11M | INT8 | 244 FPS | ~50 MB | 图像分类 |
|
||||
| multilingual-e5-small | 118M | INT8 | <10ms | ~200 MB | **当前 embedding** |
|
||||
|
||||
**关键限制**: RK3588 NPU 仅支持 W8A8 量化(RK3576 支持 W4A16),大模型精度受限。
|
||||
|
||||
### 4.2 本地计算资源分配
|
||||
|
||||
当前 NPU 核心分配已优化 (CLAUDE.md 记录):
|
||||
- **OCR**: Core 0/1 (高优先级,实时性要求)
|
||||
- **Embedding**: Core 2 (mem-bridge 语义搜索)
|
||||
|
||||
建议增加的本地计算任务:
|
||||
|
||||
#### 4.2.1 已在本地运行 (保持不变)
|
||||
|
||||
| 任务 | 实现 | 延迟 | 占用 |
|
||||
|------|------|------|------|
|
||||
| OCR (PaddleOCR RKNN) | CGo libkvm_ocr.so | ~60ms | NPU Core 0/1 |
|
||||
| Embedding (e5-small) | RKNN Lite | ~10ms | NPU Core 2 |
|
||||
| pixel_diff 验证 | Python 字节比较 | ~1ms | CPU |
|
||||
| 屏幕状态检测 | Python 哈希+关键词 | ~1ms | CPU |
|
||||
|
||||
#### 4.2.2 建议迁移到本地
|
||||
|
||||
| 任务 | 推荐模型 | 预估延迟 | 节省 | 优先级 |
|
||||
|------|---------|---------|------|--------|
|
||||
| **屏幕指纹匹配** | FAISS 向量 (复用 e5) | ~15ms | 跳过 LLM 调用 | **P0** |
|
||||
| **简单动作选择** | 规则引擎 + 模板 | ~5ms | 完全跳过 LLM | **P0** |
|
||||
| **对话框分类** | ResNet18 微调 | ~4ms | 跳过 LLM 规划 | **P1** |
|
||||
| **视觉验证** | InternVL3.5-1B | ~40ms | 替代 LLM 验证 | **P2** |
|
||||
| **本地 VLM 规划** | Qwen2.5-VL-3B | ~5s | 减少云端调用 | **P3** |
|
||||
|
||||
#### 4.2.3 必须保留在云端
|
||||
|
||||
| 任务 | 原因 |
|
||||
|------|------|
|
||||
| 复杂多步规划 | 需要强推理能力 (>7B 参数级别) |
|
||||
| 自然语言理解 | 任务描述解析需要大模型 |
|
||||
| 异常恢复决策 | 模板未覆盖的新场景 |
|
||||
| 跨应用工作流 | 多窗口切换需要全局理解 |
|
||||
|
||||
### 4.3 屏幕指纹系统 (P0 优先)
|
||||
|
||||
**核心思路**: 对已知屏幕状态计算向量指纹,下次遇到相同/相似屏幕时直接查表,完全跳过 LLM 调用。
|
||||
|
||||
```python
|
||||
# 概念设计
|
||||
class ScreenFingerprint:
|
||||
"""基于 OCR SceneGraph 的屏幕指纹。"""
|
||||
|
||||
def compute_fingerprint(self, scene: SceneGraph) -> np.ndarray:
|
||||
"""将 SceneGraph 转换为固定长度向量。
|
||||
|
||||
方法: 将排序后的 element texts 拼接,通过 e5-small 生成 embedding。
|
||||
优势: 复用已有 RKNN embedding 模型,无额外部署。
|
||||
"""
|
||||
text = " ".join(sorted(el.text for el in scene.elements[:20]))
|
||||
return self.embedding_model.encode(text) # ~10ms on NPU
|
||||
|
||||
async def lookup(self, fingerprint: np.ndarray) -> Optional[Action]:
|
||||
"""FAISS 最近邻搜索已知屏幕 → 预定义动作。"""
|
||||
scores, indices = self.index.search(fingerprint, k=1)
|
||||
if scores[0][0] > 0.92: # 高相似度阈值
|
||||
return self.action_table[indices[0][0]]
|
||||
return None # 未知屏幕,走 LLM 路径
|
||||
```
|
||||
|
||||
**预期效果**: 对重复任务(如"打开记事本"),第 2 次执行可实现 0 token、~25ms 延迟。
|
||||
|
||||
### 4.4 本地 VLM 评估 (Qwen2.5-VL-3B)
|
||||
|
||||
Qwen2.5-VL-3B 已有 RK3588 NPU 部署方案 ([Qengineering/Qwen2.5-VL-3B-NPU](https://github.com/Qengineering/Qwen2.5-VL-3B-NPU)):
|
||||
|
||||
**优势**:
|
||||
- 直接理解截图,无需 OCR → SceneGraph → text 转换
|
||||
- W8A8 量化后 ~4.6 GB 内存,RK3588 8GB 版本可承载
|
||||
- 支持中英文 UI 理解
|
||||
|
||||
**劣势**:
|
||||
- 推理速度 ~5-8 tok/s,单次规划需 3-5 秒
|
||||
- 与 OCR (NPU Core 0/1) 存在资源竞争
|
||||
- 复杂任务的推理质量远不如 Gemini/GPT
|
||||
|
||||
**建议**: 作为 **中间层** 使用 — 屏幕指纹未命中但任务简单时,先尝试本地 VLM,失败再回退云端。这需要 NPU 调度器升级以支持动态核心分配。
|
||||
|
||||
---
|
||||
|
||||
## 5. 记忆系统 Token 优化
|
||||
|
||||
### 5.1 当前 Token 消耗模型
|
||||
|
||||
分析 `llm_planner.py` 的 LLM 调用构成:
|
||||
|
||||
```
|
||||
每次 LLM 调用的 token 构成:
|
||||
┌─────────────────────────────────────────┐
|
||||
│ System Prompt (AGENT_SYSTEM_PROMPT) │ ~2,000 tokens (固定)
|
||||
│ Task + Step number │ ~30 tokens
|
||||
│ Historical Context (mem-bridge) │ ~500-2,000 tokens
|
||||
│ Scene Text (OCR elements, 30 items) │ ~800-1,500 tokens
|
||||
│ Recent Actions (history[-3:]) │ ~100-200 tokens
|
||||
│ Screenshot (JPEG base64, high detail) │ ~1,290 tokens (1024x1024)
|
||||
├─────────────────────────────────────────┤
|
||||
│ 输入总计 │ ~4,720-7,020 tokens
|
||||
│ 输出 (Action JSON) │ ~50-100 tokens
|
||||
│ max_tokens 设置 │ 512 tokens (上限)
|
||||
└─────────────────────────────────────────┘
|
||||
|
||||
一个 30 步任务的总 token 消耗:
|
||||
- 最佳情况 (模板命中): 0 tokens
|
||||
- 典型情况 (混合): ~30,000-50,000 tokens
|
||||
- 最坏情况 (全云端+重试): ~200,000+ tokens
|
||||
```
|
||||
|
||||
**成本估算** (Gemini 2.5 Pro 价格):
|
||||
- 典型任务: 40K input + 3K output = $0.05 + $0.03 = **$0.08/任务**
|
||||
- 高频使用 (100 任务/天): **$8/天 = $240/月**
|
||||
|
||||
### 5.2 优化策略矩阵
|
||||
|
||||
#### 策略 A: System Prompt 缓存 (预期节省 40%)
|
||||
|
||||
**现状**: `AGENT_SYSTEM_PROMPT` (~2000 tokens) 每次调用都完整发送。
|
||||
|
||||
**优化**: Gemini 2.5 Pro 和 GPT-4o 均支持 [system instruction caching](https://ai.google.dev/gemini-api/docs/caching),缓存后输入成本降低 ~90%,延迟降低 ~75%。
|
||||
|
||||
```python
|
||||
# llm_planner.py 改进
|
||||
class LLMPlanner:
|
||||
def __init__(self, ...):
|
||||
# 缓存 system prompt (首次调用创建,后续复用)
|
||||
self._cached_system = None
|
||||
|
||||
async def _ensure_cache(self):
|
||||
if self._cached_system is None:
|
||||
# Gemini: cachedContents API
|
||||
# OpenAI: 自动缓存长前缀
|
||||
self._cached_system = await self._create_cache(AGENT_SYSTEM_PROMPT)
|
||||
```
|
||||
|
||||
**预期效果**: 每步节省 ~2000 input tokens,30 步任务节省 ~60K tokens。
|
||||
|
||||
#### 策略 B: 渐进式上下文压缩 (预期节省 30%)
|
||||
|
||||
**现状**: `history` 仅保留最近 3 条 action 描述,更早的历史完全丢失。
|
||||
|
||||
**优化**: 实现三级上下文窗口:
|
||||
|
||||
```
|
||||
Level 1 (最近 3 步): 完整 action + scene_text
|
||||
Level 2 (3-10 步前): 压缩摘要 "步骤4-8: 打开搜索→输入notepad→点击结果"
|
||||
Level 3 (10 步前): 单句任务进度 "已完成: 打开记事本, 输入文本"
|
||||
```
|
||||
|
||||
```python
|
||||
# 上下文压缩器
|
||||
class ContextCompressor:
|
||||
def compress(self, history: list[str], step: int) -> str:
|
||||
recent = history[-3:] # Level 1: 完整
|
||||
middle = self._summarize(history[-10:-3]) # Level 2: 摘要
|
||||
old = self._progress_line(history[:-10]) # Level 3: 进度
|
||||
return f"{old}\n{middle}\n{'|'.join(recent)}"
|
||||
|
||||
def _summarize(self, actions: list[str]) -> str:
|
||||
# 本地规则压缩,不调用 LLM
|
||||
types = [a.split(":")[0].strip("[] 0123456789") for a in actions]
|
||||
return f"步骤概要: {', '.join(types)}"
|
||||
```
|
||||
|
||||
#### 策略 C: SceneGraph 智能裁剪 (预期节省 25%)
|
||||
|
||||
**现状**: `to_text_summary()` 发送最多 30 个元素,包含大量无关信息。
|
||||
|
||||
**优化**: 基于任务相关性和元素类型进行裁剪:
|
||||
|
||||
```python
|
||||
def to_text_summary_optimized(self, task_keywords: list[str], max_elements: int = 15) -> str:
|
||||
"""任务感知的 SceneGraph 压缩。"""
|
||||
scored = []
|
||||
for el in self.elements:
|
||||
score = 0
|
||||
# 任务相关性加权
|
||||
for kw in task_keywords:
|
||||
if kw.lower() in el.text.lower():
|
||||
score += 10
|
||||
# 可交互元素优先 (按钮大小)
|
||||
if el.width > 0.03 and el.height > 0.015:
|
||||
score += 5
|
||||
# 屏幕中心区域优先
|
||||
if 0.1 < el.center_y < 0.9:
|
||||
score += 3
|
||||
scored.append((score, el))
|
||||
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
top_elements = [el for _, el in scored[:max_elements]]
|
||||
|
||||
# 紧凑格式: 去掉 conf 字段,合并坐标
|
||||
lines = [f"[{el.text}]({el.center_x:.2f},{el.center_y:.2f})"
|
||||
for el in top_elements]
|
||||
return " | ".join(lines)
|
||||
```
|
||||
|
||||
**效果**: 30 个元素的 ~1500 tokens → 15 个相关元素的 ~400 tokens。
|
||||
|
||||
#### 策略 D: 图像 Token 优化 (预期节省 50%)
|
||||
|
||||
**现状**: 截图以 `detail: "high"` 发送,消耗 ~1290 tokens。
|
||||
|
||||
**优化方案**:
|
||||
|
||||
1. **降低图像分辨率**: 发送 512x384 替代 1920x1080
|
||||
2. **使用 `detail: "low"`**: 固定消耗 85 tokens (但精度下降)
|
||||
3. **条件性发送图像**: 当 SceneGraph 已有足够信息时不发送截图
|
||||
|
||||
```python
|
||||
async def plan_action(self, screenshot_bytes, task, step, ...):
|
||||
# 如果 scene_text 包含足够元素且任务简单,跳过图像
|
||||
if scene_text and len(scene.elements) > 10 and self._is_simple_action(task):
|
||||
# Text-only 模式: 节省 ~1290 tokens
|
||||
user_content = [text_only_prompt]
|
||||
else:
|
||||
# Vision 模式: 包含截图
|
||||
user_content = [text_prompt, image_prompt]
|
||||
```
|
||||
|
||||
**实测建议**: 对 "点击已知按钮" 类操作使用 text-only 模式;对 "在屏幕中找到..." 类操作使用 vision 模式。
|
||||
|
||||
#### 策略 E: 情景记忆 (Episodic Memory)
|
||||
|
||||
**现状**: `memory_client.py` 存储对话轮次,但检索粒度粗 — `get_context()` 返回原始文本。
|
||||
|
||||
**参考**: Amazon Bedrock AgentCore 的情景记忆设计将记忆分为四层:
|
||||
- **Summarization**: 管理上下文长度
|
||||
- **Semantic**: 存储事实
|
||||
- **Preference**: 处理个性化
|
||||
- **Episodic**: 捕获经验
|
||||
|
||||
**KVM Agent 情景记忆设计**:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class Episode:
|
||||
"""一次完整任务执行的压缩记录。"""
|
||||
task: str # "打开记事本"
|
||||
task_embedding: np.ndarray # FAISS 索引用
|
||||
success: bool
|
||||
total_steps: int
|
||||
key_actions: list[str] # 关键动作(非全部)
|
||||
obstacles: list[str] # 遇到的障碍
|
||||
screen_fingerprints: list[str] # 关键屏幕状态指纹
|
||||
duration_ms: int
|
||||
|
||||
class EpisodicMemory:
|
||||
async def recall(self, task: str) -> Optional[Episode]:
|
||||
"""语义搜索相似任务经验。"""
|
||||
# 复用 RKNN e5-small embedding
|
||||
embedding = self.encode(task)
|
||||
matches = self.faiss_index.search(embedding, k=3)
|
||||
return self._best_match(matches)
|
||||
|
||||
def to_context_hint(self, episode: Episode) -> str:
|
||||
"""将情景压缩为 LLM 提示 (~200 tokens)。"""
|
||||
return (
|
||||
f"Past experience: '{episode.task}' succeeded in {episode.total_steps} steps. "
|
||||
f"Key: {', '.join(episode.key_actions[:5])}. "
|
||||
f"Obstacles: {', '.join(episode.obstacles[:3])}."
|
||||
)
|
||||
```
|
||||
|
||||
**预期效果**: 上下文注入从 ~2000 tokens 降至 ~200 tokens,且信息更精准。
|
||||
|
||||
### 5.3 优化策略汇总
|
||||
|
||||
| 策略 | 实现难度 | Token 节省 | 延迟影响 | 优先级 |
|
||||
|------|---------|-----------|---------|--------|
|
||||
| A: System Prompt 缓存 | 低 | 40% input | -75% 首 token | **P0** |
|
||||
| B: 渐进式上下文压缩 | 低 | 30% context | 无 | **P0** |
|
||||
| C: SceneGraph 裁剪 | 中 | 25% scene | 无 | **P1** |
|
||||
| D: 图像 Token 优化 | 中 | 50% image | 需验证精度 | **P1** |
|
||||
| E: 情景记忆 | 中 | 75% context | ~15ms 本地查询 | **P1** |
|
||||
| F: 屏幕指纹跳过 | 高 | 100% (跳过 LLM) | ~25ms | **P0** |
|
||||
|
||||
**综合节省预估**: 对典型 30 步任务,从 ~180K tokens 降至 ~50K tokens (节省 ~72%)。
|
||||
|
||||
---
|
||||
|
||||
## 6. Token 消耗定量分析
|
||||
|
||||
### 6.1 当前消耗拆解
|
||||
|
||||
基于 `llm_planner.py` 代码分析,以一个 15 步 "打开记事本并输入文本" 任务为例:
|
||||
|
||||
```
|
||||
Step 1: 识别桌面状态
|
||||
system_prompt: 2,000 tokens
|
||||
task+step: 30 tokens
|
||||
scene_text: 1,200 tokens (桌面图标 25 个元素)
|
||||
screenshot: 1,290 tokens
|
||||
output: 60 tokens
|
||||
小计: 4,580 tokens
|
||||
|
||||
Step 2-5: 搜索+打开记事本
|
||||
每步类似: 4,200 tokens x 4 = 16,800 tokens
|
||||
|
||||
Step 6-15: 输入文本+验证
|
||||
每步 (text-only 潜力): 3,200 tokens x 10 = 32,000 tokens
|
||||
|
||||
总计: ~53,380 tokens
|
||||
成本: $0.067 input + $0.009 output = $0.076
|
||||
```
|
||||
|
||||
### 6.2 优化后消耗预估
|
||||
|
||||
```
|
||||
Step 1: 屏幕指纹命中 → 模板重放
|
||||
消耗: 0 tokens (本地 FAISS 查询 ~15ms)
|
||||
|
||||
Step 2-5: 模板重放 (已知流程)
|
||||
消耗: 0 tokens
|
||||
|
||||
Step 6: 模板未覆盖,本地 RKLLM 处理
|
||||
消耗: 0 cloud tokens (本地 ~2s)
|
||||
|
||||
Step 7-10: 简单输入,text-only 模式
|
||||
system (cached): 200 tokens (缓存折扣)
|
||||
compressed context: 150 tokens
|
||||
pruned scene: 400 tokens
|
||||
no screenshot: 0 tokens
|
||||
output: 60 tokens
|
||||
小计: 810 tokens x 4 = 3,240 tokens
|
||||
|
||||
Step 11-15: 验证+完成
|
||||
混合模式: ~1,500 tokens x 5 = 7,500 tokens
|
||||
|
||||
总计: ~10,740 tokens (节省 80%)
|
||||
成本: $0.013 input + $0.005 output = $0.018
|
||||
```
|
||||
|
||||
### 6.3 月度成本对比
|
||||
|
||||
| 场景 | 当前 | 优化后 | 节省 |
|
||||
|------|------|--------|------|
|
||||
| 轻度 (10 任务/天) | $24/月 | $5.4/月 | 78% |
|
||||
| 中度 (50 任务/天) | $120/月 | $27/月 | 78% |
|
||||
| 重度 (200 任务/天) | $480/月 | $108/月 | 78% |
|
||||
|
||||
---
|
||||
|
||||
## 7. 架构升级建议
|
||||
|
||||
### 7.1 目标架构:Memory-Augmented Programmatic Agent
|
||||
|
||||
```
|
||||
用户任务
|
||||
↓
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Task Router (本地, <5ms) │
|
||||
│ ├── 屏幕指纹匹配 → 状态机模板 (0 token) │
|
||||
│ ├── 情景记忆命中 → 经验引导规划 (少量 token) │
|
||||
│ └── 未知任务 → 完整 LLM 规划 │
|
||||
└─────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 分层执行引擎 │
|
||||
│ │
|
||||
│ Layer 1: 确定性执行 (本地) │
|
||||
│ 状态机模板重放 + OCR 验证 │
|
||||
│ 延迟: ~100ms/步, 成本: 0 │
|
||||
│ │
|
||||
│ Layer 2: 本地智能 (NPU) │
|
||||
│ RKLLM (Qwen2.5-1.5B) text 规划 │
|
||||
│ InternVL3.5-1B 视觉验证 (可选) │
|
||||
│ 延迟: ~2s/步, 成本: 0 │
|
||||
│ │
|
||||
│ Layer 3: 云端智能 (API) │
|
||||
│ Gemini 2.5 Pro 视觉规划 │
|
||||
│ + 缓存 system prompt │
|
||||
│ + 压缩上下文 │
|
||||
│ + 自适应图像发送 │
|
||||
│ 延迟: ~1-3s/步, 成本: ~$0.005/步 │
|
||||
│ │
|
||||
│ Layer 4: 人工干预 │
|
||||
│ WebUI 提示用户协助 │
|
||||
│ 超时自动 abort │
|
||||
└─────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 经验学习回路 │
|
||||
│ 成功 → 记录情景 + 生成/更新状态机模板 │
|
||||
│ 失败 → 记录障碍 + 降低模板置信度 │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.2 降级链路设计
|
||||
|
||||
```
|
||||
模板匹配 (相似度>0.92)
|
||||
├── 成功 → 完成 (0 token)
|
||||
└── 模板步骤验证失败
|
||||
↓
|
||||
本地 RKLLM (简单任务 + 记忆命中)
|
||||
├── 成功 → 完成 (0 cloud token)
|
||||
└── 本地 LLM 输出无效/解析失败
|
||||
↓
|
||||
云端 LLM (text-only 模式,若 SceneGraph 充分)
|
||||
├── 成功 → 完成 (~800 tokens)
|
||||
└── 需要视觉理解
|
||||
↓
|
||||
云端 LLM (vision 模式,压缩上下文)
|
||||
├── 成功 → 完成 (~3000 tokens)
|
||||
└── 连续 3 次失败
|
||||
↓
|
||||
用户干预请求
|
||||
```
|
||||
|
||||
### 7.3 关键组件改动
|
||||
|
||||
#### 7.3.1 `hybrid_planner.py` 增强
|
||||
|
||||
```python
|
||||
class HybridPlannerV3:
|
||||
"""四层降级 + 屏幕指纹 + 情景记忆。"""
|
||||
|
||||
async def plan(self, screenshot, task, step, history, max_steps):
|
||||
# 1. 屏幕指纹快速查表 (新增)
|
||||
scene = await self._perception.perceive(screenshot)
|
||||
fingerprint_action = await self._fingerprint.lookup(scene)
|
||||
if fingerprint_action:
|
||||
return PlanResult(action=fingerprint_action, source="fingerprint")
|
||||
|
||||
# 2. 模板匹配 (已有,增加状态机支持)
|
||||
if step == 0:
|
||||
self._active_template = await self._templates.find_template(task)
|
||||
if self._active_template:
|
||||
result = await self._replay_step(screenshot)
|
||||
if result:
|
||||
return result
|
||||
|
||||
# 3. 情景记忆引导 (新增)
|
||||
episode = await self._episodic.recall(task)
|
||||
context_hint = self._episodic.to_context_hint(episode) if episode else ""
|
||||
|
||||
# 4. 本地 RKLLM (已有,增加情景上下文)
|
||||
if await self._should_use_local(task, history):
|
||||
result = await self._local_llm_plan(screenshot, task, step, history, max_steps)
|
||||
if result:
|
||||
return result
|
||||
|
||||
# 5. 云端 LLM (优化: 自适应图像+压缩上下文)
|
||||
return await self._cloud_plan_optimized(
|
||||
screenshot, task, step, history, max_steps,
|
||||
scene=scene, context_hint=context_hint,
|
||||
)
|
||||
```
|
||||
|
||||
#### 7.3.2 `llm_planner.py` Token 优化
|
||||
|
||||
```python
|
||||
class LLMPlannerV3:
|
||||
"""Token 优化版 LLM 规划器。"""
|
||||
|
||||
async def plan_action(self, screenshot_bytes, task, step, history, **kwargs):
|
||||
scene = kwargs.get("scene")
|
||||
context_hint = kwargs.get("context_hint", "")
|
||||
|
||||
# 自适应图像发送
|
||||
include_image = self._should_send_image(scene, task, step)
|
||||
|
||||
# 压缩上下文
|
||||
compressed_context = self._compress_context(history, context_hint)
|
||||
|
||||
# 任务感知 SceneGraph 裁剪
|
||||
task_keywords = self._extract_keywords(task)
|
||||
pruned_scene = scene.to_text_summary_optimized(task_keywords, max_elements=15)
|
||||
|
||||
# 构建消息 (利用 system prompt 缓存)
|
||||
messages = self._build_messages(
|
||||
task, step, pruned_scene, compressed_context,
|
||||
screenshot_bytes if include_image else None,
|
||||
)
|
||||
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
max_tokens=128, # 降低:Action JSON 通常 <100 tokens
|
||||
temperature=self.temperature,
|
||||
response_format={"type": "json_object"}, # 结构化输出
|
||||
)
|
||||
...
|
||||
|
||||
def _should_send_image(self, scene, task, step) -> bool:
|
||||
"""决定是否发送截图。"""
|
||||
# 首步总是发送图像 (需要视觉理解)
|
||||
if step == 0:
|
||||
return True
|
||||
# 多元素场景 + 非导航任务 → text-only
|
||||
if scene and len(scene.elements) > 8:
|
||||
simple_verbs = ["点击", "输入", "click", "type", "关闭", "close"]
|
||||
if any(v in task.lower() for v in simple_verbs):
|
||||
return False
|
||||
return True
|
||||
```
|
||||
|
||||
### 7.4 NPU 资源调度升级
|
||||
|
||||
```
|
||||
当前 NPU 调度 (静态):
|
||||
Core 0: OCR (独占)
|
||||
Core 1: OCR (独占)
|
||||
Core 2: Embedding (独占)
|
||||
|
||||
建议 NPU 调度 (动态):
|
||||
Core 0-1: OCR (高优先级,按需)
|
||||
Core 2: Embedding / 分类模型 (共享)
|
||||
Core 0-2: 本地 VLM (低优先级,OCR 空闲时)
|
||||
|
||||
调度策略:
|
||||
- OCR 请求到达时中断 VLM 推理
|
||||
- VLM 推理在 OCR 间隙执行 (OCR ~60ms, 间隔 ~1000ms)
|
||||
- Embedding 查询 (~10ms) 与 OCR 无冲突
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 实施路线图
|
||||
|
||||
### Phase 1: 低风险高收益 (1-2 周)
|
||||
|
||||
**目标**: 不改架构,通过配置和参数优化减少 40% token 消耗。
|
||||
|
||||
| 任务 | 改动文件 | 预期效果 |
|
||||
|------|---------|---------|
|
||||
| System prompt 缓存 | `llm_planner.py` | -40% input tokens |
|
||||
| `max_tokens` 降至 128 | `agent.yaml`, `llm_planner.py` | -75% 输出上限 |
|
||||
| `response_format: json_object` | `llm_planner.py` | 消除解析失败 |
|
||||
| `detail: "auto"` 图像 | `llm_planner.py` | -30% image tokens |
|
||||
| history 从 3 条扩到 5 条但压缩 | `llm_planner.py` | 更好上下文 |
|
||||
|
||||
### Phase 2: 屏幕指纹 + 情景记忆 (2-4 周)
|
||||
|
||||
**目标**: 对重复任务实现零 token 执行。
|
||||
|
||||
| 任务 | 新建/改动文件 | 预期效果 |
|
||||
|------|-------------|---------|
|
||||
| ScreenFingerprint 类 | 新建 `screen_fingerprint.py` | 已知屏幕跳过 LLM |
|
||||
| EpisodicMemory 类 | 新建 `episodic_memory.py` | 精简上下文注入 |
|
||||
| 状态机模板 | 改造 `template_store.py` | 条件分支+自动修复 |
|
||||
| HybridPlanner V3 | 改造 `hybrid_planner.py` | 四层降级链 |
|
||||
| SceneGraph 裁剪 | 改造 `perception.py` | 任务感知元素选择 |
|
||||
|
||||
### Phase 3: 本地 VLM 集成 (4-8 周)
|
||||
|
||||
**目标**: 简单视觉任务本地完成,进一步减少云端依赖。
|
||||
|
||||
| 任务 | 改动 | 预期效果 |
|
||||
|------|------|---------|
|
||||
| InternVL3.5-1B 部署 | 新建 `local_vlm.py` + RKNN 模型 | 本地视觉验证 |
|
||||
| NPU 动态调度器 | 改造 NPU 核心分配 | OCR/VLM 共存 |
|
||||
| 对话框分类器 (ResNet18) | 微调+部署 | 弹窗自动处理 |
|
||||
| Qwen2.5-VL-3B 评估 | 可选,视 Phase 2 效果 | 本地视觉规划 |
|
||||
|
||||
### Phase 4: 自学习闭环 (8-12 周)
|
||||
|
||||
**目标**: Agent 从执行经验中自动学习,持续优化。
|
||||
|
||||
| 任务 | 改动 | 预期效果 |
|
||||
|------|------|---------|
|
||||
| 自动模板生成 | 成功任务 → 状态机模板 | 自动积累知识 |
|
||||
| 模板退化检测 | 置信度衰减机制 | UI 变化时自动更新 |
|
||||
| A/B 路由优化 | 本地 vs 云端效果对比 | 自动调整路由阈值 |
|
||||
| 批量指纹预计算 | 离线 GUI 探索 | 冷启动优化 |
|
||||
|
||||
---
|
||||
|
||||
## 参考资料
|
||||
|
||||
### Playwright 与自动化模式
|
||||
- [Playwright Auto-Waiting](https://playwright.dev/docs/actionability)
|
||||
- [Playwright MCP: AI-Powered Test Automation 2026](https://www.testleaf.com/blog/playwright-mcp-ai-test-automation-2026/)
|
||||
- [Playwright and Playwright MCP: A Field Guide for Agentic Browser Automation](https://medium.com/@adnanmasood/playwright-and-playwright-mcp-a-field-guide-for-agentic-browser-automation-f11b9daa3627)
|
||||
|
||||
### LLM 与 Agent 架构
|
||||
- [ActionEngine: From Reactive to Programmatic GUI Agents via State Machine Memory](https://arxiv.org/abs/2602.20502)
|
||||
- [2025: The Year in LLMs](https://simonwillison.net/2025/Dec/31/the-year-in-llms/)
|
||||
- [GPT-5.4 Targets Anthropic's Claude With Premium Pricing](https://www.trendingtopics.eu/gpt-5-4-targets-anthropics-claude-with-premium-pricing-and-coding-muscle/)
|
||||
- [Best LLM for Coding 2026](https://smartscope.blog/en/generative-ai/chatgpt/llm-coding-benchmark-comparison-2026/)
|
||||
|
||||
### RK3588 NPU 与本地推理
|
||||
- [Rockchip RK3588 NPU Deep Dive](https://tinycomputers.io/posts/rockchip-rk3588-npu-benchmarks.html)
|
||||
- [RKNN-LLM (GitHub)](https://github.com/airockchip/rknn-llm)
|
||||
- [Qwen2.5-VL-3B on RK3588 NPU](https://github.com/Qengineering/Qwen2.5-VL-3B-NPU)
|
||||
- [Qwen3-VL-2B on RK3588 NPU](https://github.com/Qengineering/Qwen3-VL-2B-NPU)
|
||||
- [InternVL3.5-4B on RK3588 NPU](https://github.com/Qengineering/InternVL3.5-4B-NPU)
|
||||
- [Edge AI using the Rockchip NPU](https://tristanpenman.com/blog/posts/2025/07/20/edge-ai-using-the-rockchip-npu/)
|
||||
|
||||
### Token 优化与记忆架构
|
||||
- [LLM Token Optimization: Cut Costs & Latency in 2026](https://redis.io/blog/llm-token-optimization-speed-up-apps/)
|
||||
- [The Hidden Economics of AI Agents](https://online.stevens.edu/blog/hidden-economics-ai-agents-token-costs-latency/)
|
||||
- [LLM Cost Optimization: Complete Guide](https://ai.koombea.com/blog/llm-cost-optimization)
|
||||
- [Position: Episodic Memory is the Missing Piece for Long-Term LLM Agents](https://arxiv.org/pdf/2502.06975)
|
||||
- [Memory in the Age of AI Agents: A Survey](https://arxiv.org/abs/2512.13564)
|
||||
- [LLM Chat History Summarization Guide 2025](https://mem0.ai/blog/llm-chat-history-summarization-guide-2025)
|
||||
- [Amazon Bedrock AgentCore Episodic Memory](https://aws.amazon.com/blogs/machine-learning/build-agents-to-learn-from-experiences-using-amazon-bedrock-agentcore-episodic-memory/)
|
||||
@@ -0,0 +1,147 @@
|
||||
# 部署打包架构
|
||||
|
||||
> 5 个 Debian 包 + 8 个 systemd 服务
|
||||
|
||||
## 1. 包依赖关系
|
||||
|
||||
```
|
||||
kvm-meta (元包)
|
||||
├── Depends: kvm-server
|
||||
├── Depends: kvm-agent
|
||||
├── Depends: kvm-privacy
|
||||
└── Recommends: kvm-bridge
|
||||
│
|
||||
┌────┴─────────────────────────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
kvm-server (Go) kvm-agent (Python)
|
||||
├─ /usr/bin/kvm-server ├─ /usr/bin/kvm-agent
|
||||
├─ /usr/lib/kvm/*.so ├─ /usr/lib/kvm-agent/
|
||||
├─ /etc/kvm/ ├─ /etc/kvm-agent/
|
||||
├─ kvm-server.service └─ kvm-agent.service
|
||||
└─ kvm-usb-gadget.service
|
||||
│
|
||||
┌────┴─────────────────────────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
kvm-privacy (Rust) kvm-bridge (Python)
|
||||
├─ /usr/bin/info-privacy ├─ /opt/kvm-bridge/
|
||||
├─ /usr/lib/kvm-privacy/ ├─ mem-bridge-memory.service
|
||||
└─ info-privacy.service └─ mem-bridge-router.service
|
||||
|
||||
kvm-mitm (Python, 可选)
|
||||
├─ /usr/bin/kvm-mitm
|
||||
├─ /usr/lib/kvm-mitm/
|
||||
└─ kvm-mitm.service
|
||||
```
|
||||
|
||||
## 2. 包清单
|
||||
|
||||
| 包名 | 版本 | 架构 | 语言 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| kvm-server | 1.0.0-6 | arm64 | Go + C | KVM 核心服务 |
|
||||
| kvm-agent | 1.0.0-3 | all | Python | AI Agent daemon |
|
||||
| kvm-privacy | 1.0.0-1 | arm64 | Rust | PII 检测/脱敏 |
|
||||
| kvm-bridge | 1.0.0-1 | all | Python | 记忆 + AI 路由 |
|
||||
| kvm-mitm | 1.0.0-1 | all | Python | mitmproxy 网关 |
|
||||
| kvm-meta | 1.0.0-1 | all | — | 元包 (全部依赖) |
|
||||
|
||||
## 3. 构建命令
|
||||
|
||||
### kvm-server
|
||||
```bash
|
||||
cd deps/KVM && PATH="/usr/local/go/bin:$PATH" bash scripts/build-deb.sh
|
||||
# 输出: build/deb/kvm-server_1.0.0-6_arm64.deb
|
||||
```
|
||||
|
||||
### Python 包 (kvm-agent, kvm-mitm)
|
||||
```bash
|
||||
bash scripts/build-python-debs.sh
|
||||
# 输出: build/deb/kvm-agent_1.0.0-3_all.deb
|
||||
# build/deb/kvm-mitm_1.0.0-1_all.deb
|
||||
```
|
||||
|
||||
### 其他包
|
||||
```bash
|
||||
# kvm-privacy, kvm-bridge, kvm-meta 使用 dpkg-deb 直接打包
|
||||
dpkg-deb --build debian/kvm-privacy build/deb/
|
||||
dpkg-deb --build debian/kvm-bridge build/deb/
|
||||
dpkg-deb --build debian/kvm-meta build/deb/
|
||||
```
|
||||
|
||||
## 4. systemd 服务
|
||||
|
||||
| 服务 | 包 | 端口 | CPU 亲和性 |
|
||||
|------|-----|------|-----------|
|
||||
| kvm-server | kvm-server | 8080 | 4 5 6 7 (A76) |
|
||||
| kvm-usb-gadget | kvm-server | — | — |
|
||||
| kvm-agent | kvm-agent | 8890 | — |
|
||||
| info-privacy | kvm-privacy | 8000 | 0 1 (A55) |
|
||||
| mem-bridge-memory | kvm-bridge | 8001 | 2 3 (A55) |
|
||||
| mem-bridge-router | kvm-bridge | 8002 | 2 3 (A55) |
|
||||
| kvm-mitm | kvm-mitm | 8888 | — |
|
||||
| privacy-gateway | kvm-mitm | 8888 | — |
|
||||
|
||||
## 5. 安装验证清单
|
||||
|
||||
### 标准验证流程
|
||||
```bash
|
||||
# [1] 首次安装
|
||||
sudo dpkg -i kvm-server_*.deb
|
||||
sleep 5
|
||||
systemctl is-active kvm-server # → active
|
||||
|
||||
# [2] API 可达
|
||||
curl -sf http://localhost:8080/api/v1/kvm/stream/stats # → JSON
|
||||
|
||||
# [3] 卸载 (保留 conffiles)
|
||||
sudo dpkg --remove kvm-server
|
||||
systemctl is-active kvm-server # → inactive
|
||||
ls /etc/kvm/config.json # → 存在
|
||||
|
||||
# [4] 重装
|
||||
sudo dpkg -i kvm-server_*.deb
|
||||
systemctl is-active kvm-server # → active
|
||||
|
||||
# [5] Purge
|
||||
sudo dpkg --purge kvm-server
|
||||
ls /etc/kvm/config.json 2>/dev/null # → 不存在
|
||||
|
||||
# [6] Clean install
|
||||
sudo dpkg -i kvm-server_*.deb
|
||||
```
|
||||
|
||||
### 关键检查项
|
||||
| 检查 | 命令 | 期望 |
|
||||
|------|------|------|
|
||||
| 安装 < 60s | `time dpkg -i` | < 60s |
|
||||
| 服务启动 | `systemctl is-active` | active |
|
||||
| USB gadget 软依赖 | gadget 失败不影响 kvm-server | Wants= |
|
||||
| API 可达 | `curl :8080/api/health` | 200 |
|
||||
| conffile 保留 | remove 后 config 存在 | 存在 |
|
||||
| conffile 清除 | purge 后 config 不存在 | 不存在 |
|
||||
|
||||
## 6. 远程部署
|
||||
|
||||
```bash
|
||||
# 拷贝到目标设备
|
||||
scp build/deb/*.deb pi@192.168.123.181:/tmp/
|
||||
|
||||
# 安装
|
||||
ssh pi@192.168.123.181 'sudo dpkg -i /tmp/kvm-*.deb && sudo apt-get install -f'
|
||||
|
||||
# 验证
|
||||
ssh pi@192.168.123.181 'systemctl status kvm-server kvm-agent info-privacy'
|
||||
```
|
||||
|
||||
## 7. 配置文件位置
|
||||
|
||||
| 文件 | 包 | 用途 |
|
||||
|------|-----|------|
|
||||
| /etc/kvm/config.json | kvm-server | KVM 主配置 |
|
||||
| /etc/kvm/kvm-default.json | kvm-server | 默认配置模板 |
|
||||
| /etc/kvm/turnserver.conf | kvm-server | TURN 服务配置 |
|
||||
| /etc/kvm/sensitive_commands.json | kvm-server | 敏感命令规则 |
|
||||
| /etc/kvm-agent/secrets.env | kvm-agent | API 密钥 |
|
||||
| /etc/kvm-privacy/secrets.env | kvm-mitm | MITM 数据库凭证 |
|
||||
| /var/lib/kvm-privacy/state.json | kvm-server | 隐私模式状态 |
|
||||
@@ -0,0 +1,144 @@
|
||||
# RK3588 硬件优化架构
|
||||
|
||||
> 平台: NanoPC-T6 (RK3588) | 8核 ARM64 | 6 TOPS NPU | RGA 2D
|
||||
|
||||
## 1. 硬件资源
|
||||
|
||||
### CPU 布局
|
||||
```
|
||||
Core 0-3: ARM Cortex-A55 (低功耗, 1.8 GHz)
|
||||
Core 4-7: ARM Cortex-A76 (高性能, 2.4 GHz)
|
||||
```
|
||||
|
||||
### 核心分配方案
|
||||
| 核心 | 类型 | 分配 | 服务 |
|
||||
|------|------|------|------|
|
||||
| 0-1 | A55 | info-privacy-rs + OCR 后处理 | PII 检测 |
|
||||
| 2-3 | A55 | mem-bridge | 记忆 + 路由 |
|
||||
| 4 | A76 | HID Dispatcher (SCHED_RR) | 实时 HID |
|
||||
| 5-6 | A76 | Go runtime (GOMAXPROCS=3) | kvm-server |
|
||||
| 7 | A76 | 视频编码回调线程 | V4L2→MPP |
|
||||
|
||||
### systemd 亲和性配置
|
||||
```ini
|
||||
# kvm-server.service
|
||||
CPUAffinity=4 5 6 7
|
||||
|
||||
# info-privacy.service
|
||||
CPUAffinity=0 1
|
||||
|
||||
# mem-bridge-memory.service / mem-bridge-router.service
|
||||
CPUAffinity=2 3
|
||||
```
|
||||
|
||||
### Go 进程级别
|
||||
```go
|
||||
// main.go
|
||||
func pinToA76BigCores() {
|
||||
var mask [16]byte // 128 cores max
|
||||
mask[0] = 0xF0 // bits 4,5,6,7
|
||||
syscall.RawSyscall(SYS_SCHED_SETAFFINITY, 0, 16, uintptr(unsafe.Pointer(&mask[0])))
|
||||
runtime.GOMAXPROCS(3)
|
||||
}
|
||||
```
|
||||
|
||||
## 2. NPU (Neural Processing Unit)
|
||||
|
||||
### 三核调度
|
||||
```
|
||||
NPU Core 0: OCR text detection (RKNN_NPU_CORE_0)
|
||||
NPU Core 1: OCR text recognition (RKNN_NPU_CORE_1)
|
||||
NPU Core 2: Embedding vectorization (RKNN_NPU_CORE_2)
|
||||
```
|
||||
|
||||
### 之前的问题
|
||||
embedding 使用 `NPU_CORE_ALL`(全部 3 核),与 OCR 的 CORE_0/CORE_1 争用,导致推理延迟抖动。
|
||||
|
||||
### 修复
|
||||
```python
|
||||
# deps/embedding/src/embed_db/embedder.py
|
||||
ret = rknn.init_runtime(core_mask=RKNNLite.NPU_CORE_2) # 独占 Core 2
|
||||
```
|
||||
|
||||
### 验证
|
||||
```bash
|
||||
cat /sys/kernel/debug/rknpu/load
|
||||
# 应显示三核独立负载,无争用
|
||||
```
|
||||
|
||||
## 3. RGA (2D 图形加速器)
|
||||
|
||||
### 用途
|
||||
- **色彩转换**: BGR24 → NV12 (im2d.h: imcvtcolor)
|
||||
- **缩放**: 任意分辨率 → 编码目标分辨率 (imresize)
|
||||
- **隐私遮蔽**: NV12 区域黑色填充 (memset, RGA imfill 未验证)
|
||||
|
||||
### 色彩转换路径
|
||||
```
|
||||
V4L2 BGR24 帧
|
||||
→ wrapbuffer_virtualaddr(src, w, h, RK_FORMAT_BGR_888)
|
||||
→ wrapbuffer_virtualaddr(dst, w, h, RK_FORMAT_YCbCr_420_SP)
|
||||
→ imcvtcolor(src, dst, src_format, dst_format)
|
||||
→ NV12 数据
|
||||
```
|
||||
|
||||
## 4. MPP (Media Process Platform)
|
||||
|
||||
### H.264 编码
|
||||
- 编码器类型: MPP_VIDEO_CodingAVC
|
||||
- 帧率: 1-60 fps (动态可调)
|
||||
- 码率: 100Kbps - 20Mbps (动态可调)
|
||||
- GOP: 与帧率相同 (每秒一个关键帧)
|
||||
|
||||
### 关键操作
|
||||
```c
|
||||
mpp_encoder_force_idr() // 立即产生 IDR 关键帧
|
||||
mpp_encoder_update_rc() // 动态更新码率+帧率
|
||||
```
|
||||
|
||||
### DMA-buf 零拷贝
|
||||
```
|
||||
V4L2 buffer → VIDIOC_EXPBUF → DMA-buf fd
|
||||
→ mpp_buffer_import_with_tag(fd) // MPP 直接使用 V4L2 buffer
|
||||
→ 编码 (零 CPU memcpy)
|
||||
```
|
||||
|
||||
启用条件:
|
||||
1. V4L2 输出 NV12 格式
|
||||
2. 无缩放 (capture == encode 分辨率)
|
||||
3. 无隐私遮蔽 (遮蔽会修改 buffer)
|
||||
4. DMA-buf 导出成功
|
||||
|
||||
节省: 1080p@30fps 约 90MB/s 内存带宽
|
||||
|
||||
## 5. OCR 内联化
|
||||
|
||||
### 之前: exec 模式
|
||||
```
|
||||
Go → exec.Command("kvm-ocr", args...) → fork+exec
|
||||
延迟: 800-2000ms (含进程启动 + RKNN 模型加载)
|
||||
```
|
||||
|
||||
### 之后: CGo 内联
|
||||
```
|
||||
Go → CGo → libkvm_ocr.so → RKNN (常驻, 无 fork)
|
||||
延迟: 60-100ms
|
||||
```
|
||||
|
||||
### 实现
|
||||
- **C 层**: `src/audit/ocr_analyzer.cpp` 新增 `extern "C"` API
|
||||
- **共享库**: CMakeLists.txt 新增 `libkvm_ocr SHARED` 目标
|
||||
- **Go 桥接**: `ocr_bridge_cgo.go` (build tag: `cgo && rknn`)
|
||||
- **接口**: `OCRAnalyzer` 统一 exec/CGo 两种实现
|
||||
|
||||
## 6. 构建集成
|
||||
|
||||
```bash
|
||||
# 构建顺序 (build-deb.sh):
|
||||
1. 前端 (npm build)
|
||||
2. 视频 C 库 (cmake, 需要 rockchip_mpp)
|
||||
3. OCR 共享库 (cmake, 需要 rknn_api) ← 必须在 Go 之前!
|
||||
4. Go 后端 (自动检测 .so → 设置 CGo flags + build tags)
|
||||
5. OCR 独立二进制 (可选, --ocr flag)
|
||||
6. dpkg-buildpackage
|
||||
```
|
||||
@@ -0,0 +1,179 @@
|
||||
# KVM Agent (Python) 架构
|
||||
|
||||
> 子系统: services/kvm_agent/ | 端口: 8890 | 包名: kvm-agent
|
||||
|
||||
## 1. 概述
|
||||
|
||||
KVM Agent 是基于 Vision LLM 的 AI 自主操控系统。通过 OCR 感知屏幕内容、LLM 规划操作序列、鼠标/键盘执行动作、截图验证结果,实现对目标主机的自动化操作。
|
||||
|
||||
## 2. 核心循环
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ KVMAgent.run_task() │
|
||||
│ │
|
||||
│ 1. 获取 HID 控制权 │
|
||||
│ 2. 设置隐私模式 (audit) │
|
||||
│ │
|
||||
│ ┌─ while step < max_steps: ───────────┐ │
|
||||
│ │ │ │
|
||||
│ │ ┌─ perceive ──────────────────┐ │ │
|
||||
│ │ │ screenshot() → JPEG │ │ │
|
||||
│ │ │ screen_state.detect() → │ │ │
|
||||
│ │ │ SLEEP? → wake_from_sleep │ │ │
|
||||
│ │ │ perception.perceive() → │ │ │
|
||||
│ │ │ SceneGraph + raw_text │ │ │
|
||||
│ │ └─────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─ plan ──────────────────────┐ │ │
|
||||
│ │ │ LLMPlanner.plan_action( │ │ │
|
||||
│ │ │ screenshot, task, │ │ │
|
||||
│ │ │ scene_text, history, │ │ │
|
||||
│ │ │ memory_context) │ │ │
|
||||
│ │ │ → Action{type, x, y, text} │ │ │
|
||||
│ │ └─────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─ validate + execute ────────┐ │ │
|
||||
│ │ │ safety.validate(action) │ │ │
|
||||
│ │ │ _execute_action(action) │ │ │
|
||||
│ │ │ validator.verify() │ │ │
|
||||
│ │ │ type=done? → return success │ │ │
|
||||
│ │ └─────────────────────────────┘ │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 3. 释放 HID 控制权 │
|
||||
│ 4. 恢复隐私模式 │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 3. 模块清单
|
||||
|
||||
| 模块 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| Agent 主循环 | agent.py | perceive→plan→execute→verify 循环 |
|
||||
| 感知引擎 | perception.py | OCR 结果归一化 + SceneGraph 构建 |
|
||||
| LLM 规划器 | llm_planner.py | Vision LLM 调用 + 动作解析 |
|
||||
| 混合规划器 | hybrid_planner.py | LLM + 本地模型混合决策 |
|
||||
| 鼠标操作 | mouse_ops.py | click_element, close_window, drag 等 |
|
||||
| 窗口管理 | window_manager.py | 全屏化、贴靠、最小化 |
|
||||
| 屏幕状态 | screen_state.py | BIOS/锁屏/睡眠/桌面 检测 |
|
||||
| 验证器 | validator.py | 截图对比 + OCR 语义验证 |
|
||||
| 安全层 | safety.py | 白名单 + Unicode 归一化 + 危险命令拦截 |
|
||||
| KVM 客户端 | kvm_client.py | HTTP REST 封装 (截图/HID/OCR) |
|
||||
| 任务执行器 | runner.py | 持久化队列 + daemon 进程 |
|
||||
| 记忆客户端 | memory_client.py | mem-bridge 会话存储 |
|
||||
| 模板系统 | template_store.py / template_recorder.py | 成功任务转模板 |
|
||||
| 配置 | config.py | YAML + ENV + dataclass |
|
||||
| 动作定义 | actions.py | Action 类型枚举 |
|
||||
| API 服务器 | api_server.py | aiohttp REST (port 8890) |
|
||||
| CLI | \_\_main\_\_.py | run/daemon/queue 子命令 |
|
||||
|
||||
## 4. 鼠标优先架构
|
||||
|
||||
### 设计原因
|
||||
实测暴露组合键系统性风险:
|
||||
- Alt+F4 → Windows 关机对话框
|
||||
- Win+D → 二次触发恢复窗口
|
||||
- IME 拦截 Enter/Space
|
||||
|
||||
### 安全键白名单 (20 个)
|
||||
```
|
||||
escape, enter, tab, backspace, delete, space,
|
||||
up, down, left, right, shift,
|
||||
f1, f2, f3, f4, f5, f11, f12
|
||||
```
|
||||
|
||||
所有其他键和所有组合键被 safety.py 拒绝。
|
||||
|
||||
### 快捷键翻译表
|
||||
| 快捷键 | 鼠标替代 | 函数 |
|
||||
|--------|---------|------|
|
||||
| Alt+F4 | 点击标题栏 X | close_window() |
|
||||
| Win+D | 点击 (0.999, 0.999) | show_desktop() |
|
||||
| Win+R | 搜索→输入→点击 | launch_from_taskbar() |
|
||||
| Ctrl+S | OCR 找"保存"→点击 | click_element("保存") |
|
||||
|
||||
### mouse_ops.py 核心函数
|
||||
```python
|
||||
click_element(kvm, text, perception) # OCR 找文字→点击中心
|
||||
close_window() # 标题栏 X 按钮
|
||||
show_desktop() # 任务栏右下角
|
||||
launch_from_taskbar(app) # 搜索框启动应用
|
||||
safe_cleanup() # 状态机清理 (关机→取消等)
|
||||
drag(x1, y1, x2, y2, steps=10) # 平滑拖拽
|
||||
```
|
||||
|
||||
## 5. 屏幕状态检测
|
||||
|
||||
```python
|
||||
class PCState(Enum):
|
||||
UNKNOWN # 未知
|
||||
SLEEP # 休眠 (HDMI 冻结)
|
||||
BIOS # BIOS 界面
|
||||
BOOT # 启动中
|
||||
LOCK_SCREEN # 锁屏
|
||||
DESKTOP # 桌面
|
||||
APP_WINDOW # 应用窗口
|
||||
```
|
||||
|
||||
| 状态 | 检测方法 | 信心度 |
|
||||
|------|---------|--------|
|
||||
| SLEEP | 连续 3+ 张截图 MD5 相同 | 0.95 |
|
||||
| BIOS | ≥2 个 BIOS 关键词 | 0.85 |
|
||||
| LOCK_SCREEN | lock 词 + 无任务栏 | 0.80 |
|
||||
| DESKTOP | 桌面指示词 + 无应用指示词 | 0.75 |
|
||||
| APP_WINDOW | 应用指示词存在 | 0.70 |
|
||||
|
||||
## 6. 安全验证
|
||||
|
||||
### validate_type_action(text) 三步检查
|
||||
1. **长度限制**: ≤ 10000 字符
|
||||
2. **Unicode NFKC 归一化**: 全角→ASCII (rm→rm)
|
||||
3. **逐行 regex 匹配**: rm -rf, shutdown, dd, chmod 777, curl|sh 等
|
||||
|
||||
### BLOCKED_PATTERNS
|
||||
```python
|
||||
r'rm\s+(-[rfvdi]*\s+)*/', # rm -rf /
|
||||
r'shutdown|reboot|halt', # 系统关机
|
||||
r'dd\s+if=', # 磁盘写入
|
||||
r'chmod\s+777', # 权限打开
|
||||
r'curl.*\|\s*(ba)?sh', # RCE
|
||||
r':\(\)\s*\{\s*:\s*\|', # Fork bomb
|
||||
```
|
||||
|
||||
## 7. API (port 8890)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/v1/agent/status | 运行状态 + 队列深度 |
|
||||
| GET | /api/v1/agent/tasks | 任务列表 |
|
||||
| POST | /api/v1/agent/tasks | 提交新任务 |
|
||||
| GET | /api/v1/agent/tasks/{id} | 任务详情 |
|
||||
| DELETE | /api/v1/agent/tasks/{id} | 取消任务 |
|
||||
| GET | /api/v1/agent/config | 获取配置 |
|
||||
| PATCH | /api/v1/agent/config | 修改配置 |
|
||||
|
||||
## 8. 配置
|
||||
|
||||
```yaml
|
||||
# configs/agent.yaml
|
||||
kvm_url: "http://localhost:8080"
|
||||
kvm_jwt_token: "${KVM_JWT_TOKEN}"
|
||||
llm_model: "gpt-4o"
|
||||
max_steps: 30
|
||||
step_delay: 1.0
|
||||
click_verify_enabled: true
|
||||
memory_enabled: true
|
||||
template_enabled: true
|
||||
```
|
||||
|
||||
## 9. 测试
|
||||
|
||||
```bash
|
||||
# 单元测试 (19 个文件, 不需要设备)
|
||||
cd services/kvm_agent
|
||||
python3 -m pytest tests/ --ignore=tests/test_integration.py -v
|
||||
|
||||
# 集成测试 (需要实体 KVM 设备)
|
||||
python3 -m pytest tests/test_integration.py -v
|
||||
```
|
||||
@@ -0,0 +1,192 @@
|
||||
# KVM Server (Go) 架构
|
||||
|
||||
> 子系统: deps/KVM/go/ | 端口: 8080 | 包名: kvm-server
|
||||
|
||||
## 1. 概述
|
||||
|
||||
KVM Server 是系统核心,Go 单体服务提供 140+ REST API、WebRTC 视频推流、USB HID 控制、RBAC 鉴权、审计日志和 Web UI。
|
||||
|
||||
## 2. 包结构
|
||||
|
||||
```
|
||||
go/
|
||||
├── cmd/kvm-server/main.go # 入口 (18 阶段初始化)
|
||||
└── internal/
|
||||
├── api/ # HTTP 路由 + 处理器 (16 files)
|
||||
│ ├── router.go # 140+ 路由注册
|
||||
│ ├── middleware.go # CORS/JWT/RBAC/审计/gzip
|
||||
│ ├── kvm_handler.go # 视频/HID/录制/OCR
|
||||
│ ├── privacy_handler.go # 隐私模式 + PII 检测 + 遮蔽回调
|
||||
│ ├── auth_handler.go # 登录/TOTP/JWT
|
||||
│ ├── user_handler.go # RBAC 用户管理
|
||||
│ ├── audit_handler.go # 审计日志/录制/时间线
|
||||
│ ├── system_handler.go # 健康/指标/系统信息
|
||||
│ ├── terminal_handler.go # SSH 终端会话
|
||||
│ ├── storage_handler.go # 虚拟 USB 存储
|
||||
│ ├── config_handler.go # 服务发现
|
||||
│ └── proxy_handler.go # Agent 反代 (→:8890)
|
||||
├── videopipe/ # 原生视频管道 (CGo)
|
||||
├── gstreamer/ # GStreamer 备选管道
|
||||
├── webrtc/ # pion WebRTC 推流
|
||||
├── hid/ # USB HID 控制
|
||||
│ ├── manager.go # 键鼠统一接口
|
||||
│ ├── dispatcher.go # RT 优先级 HID 线程
|
||||
│ ├── keyboard.go # USB 键盘协议
|
||||
│ ├── mouse.go # USB 鼠标协议
|
||||
│ └── charmap.go # 字符→HID 键码映射
|
||||
├── ai/ # OCR + 模式匹配
|
||||
│ ├── ocr_bridge.go # exec 模式 OCR
|
||||
│ ├── ocr_bridge_cgo.go # CGo 内联 OCR (build: cgo && rknn)
|
||||
│ ├── ocr_monitor.go # 事件驱动 OCR 触发
|
||||
│ └── pattern_evaluator.go # 敏感命令匹配
|
||||
├── auth/ # JWT + TOTP
|
||||
├── rbac/ # Casbin ACL
|
||||
├── audit/ # 审计日志 + syslog
|
||||
├── store/ # GORM MySQL 存储
|
||||
├── config/ # TOML/JSON 配置
|
||||
├── control/ # HID 互斥控制
|
||||
├── recorder/ # HLS 录制
|
||||
├── signaling/ # WebSocket 信令
|
||||
├── terminal/ # SSH/PTY 终端
|
||||
├── storage/ # USB 虚拟媒体
|
||||
├── gateway/ # 隐私网关集成
|
||||
├── crypto/ # SM3 国密
|
||||
├── logging/ # slog JSON
|
||||
├── metrics/ # Prometheus 指标
|
||||
├── timeline/ # 事件时间线
|
||||
├── turn/ # STUN/TURN 凭证
|
||||
├── web/ # 嵌入 React UI
|
||||
└── wireguard/ # WireGuard (stub)
|
||||
```
|
||||
|
||||
## 3. 启动序列 (main.go)
|
||||
|
||||
```
|
||||
1. pinToA76BigCores() CPU 亲和性: cores 4-7
|
||||
2. config.Load() /etc/kvm/config.json
|
||||
3. logging.Setup() slog JSON
|
||||
4. store.OpenDB() MySQL + auto-migrate
|
||||
5. auth.Init() JWT secret + TOTP
|
||||
6. rbac.Init() Casbin ACL 策略
|
||||
7. audit.Init() 审计日志 + syslog (可选)
|
||||
8. hid.Init() /dev/hidg0 + /dev/hidg1
|
||||
9. videoPipeline.Start() Native MPP 或 GStreamer
|
||||
10. webrtc.Init() pion PeerConnection 工厂
|
||||
11. recorder.Init() HLS 分段录制
|
||||
12. signaling.Init() WebSocket 信令
|
||||
13. ocr.Init() OCR Monitor (事件驱动)
|
||||
14. privacy.Init() 隐私处理器 + 遮蔽回调
|
||||
15. terminal.Init() SSH 终端管理
|
||||
16. router.Build() 140+ 路由 + 中间件
|
||||
17. http.ListenAndServe() :8080
|
||||
18. signal.Notify() SIGINT/SIGTERM 优雅关闭
|
||||
```
|
||||
|
||||
## 4. 核心 API 端点
|
||||
|
||||
### 认证
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | /api/auth/login | 用户名+密码登录 |
|
||||
| POST | /api/auth/login/totp | TOTP 二次验证 |
|
||||
| POST | /api/auth/logout | 登出 |
|
||||
| GET | /api/auth/me | 当前用户信息 |
|
||||
|
||||
### KVM 控制
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/kvm/screenshot | JPEG 截图 |
|
||||
| POST | /api/kvm/mouse/move | 鼠标移动 (归一化坐标) |
|
||||
| POST | /api/kvm/mouse/click | 鼠标点击 |
|
||||
| POST | /api/kvm/keyboard/type | 文本输入 |
|
||||
| POST | /api/kvm/keyboard/key | 单键按下 |
|
||||
| POST | /api/kvm/video/keyframe | 强制 IDR |
|
||||
| PUT | /api/kvm/video/settings | 更新码率/帧率 |
|
||||
|
||||
### 隐私
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET/POST | /api/v1/privacy/mode | 隐私模式 (off/audit/redact) |
|
||||
| GET | /api/v1/privacy/bboxes | 当前 PII 区域坐标 |
|
||||
| GET | /api/v1/privacy/screen/detections | OCR 检测结果 |
|
||||
| CRUD | /api/v1/privacy/patterns | 自定义检测规则 |
|
||||
|
||||
### 审计
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/audit/logs | 审计日志 (分页) |
|
||||
| POST | /api/audit/logs/verify | SM3 哈希链验证 |
|
||||
| GET | /api/audit/recordings | 录制列表 |
|
||||
| GET | /api/audit/timeline | 事件时间线 |
|
||||
| GET | /api/audit/ai | OCR/模式匹配日志 |
|
||||
|
||||
完整路由清单见 `go/internal/api/router.go`。
|
||||
|
||||
## 5. HID 控制子系统
|
||||
|
||||
### 线程模型
|
||||
```
|
||||
WebSocket 消息 → signaling.OnBinaryHID()
|
||||
→ control.TryAcquire(clientID) [互斥, 5min 超时]
|
||||
→ HIDDispatcher.Dispatch(event) [非阻塞, ch 缓冲 1024]
|
||||
→ Worker 线程 (A76 core 4, SCHED_RR 实时优先级)
|
||||
├─ 鼠标移动合并 (高频消息优化)
|
||||
├─ write(/dev/hidg0) 或 write(/dev/hidg1)
|
||||
├─ onKeyPress() → OCR L1 触发
|
||||
└─ onMouseClick() → OCR L2 触发
|
||||
```
|
||||
|
||||
### HID 协议
|
||||
- 键盘: USB HID Report (8 bytes: modifiers + keycode)
|
||||
- 鼠标: 绝对坐标 (x:u16 y:u16 buttons:u8)
|
||||
|
||||
## 6. OCR 监控
|
||||
|
||||
### 触发机制 (事件驱动, 非轮询)
|
||||
| 级别 | 触发源 | 冷却时间 |
|
||||
|------|--------|---------|
|
||||
| L1 | 控制键 (Enter, Ctrl+C) | 100ms |
|
||||
| L2 | 鼠标点击 | 300ms |
|
||||
| L3 | 普通按键 | 500ms |
|
||||
|
||||
### 双模式实现
|
||||
- **exec 模式**: fork `/usr/bin/kvm-ocr` (800ms, 通用)
|
||||
- **CGo 模式**: libkvm_ocr.so 内联调用 (60ms, 需 RKNN)
|
||||
- Build tag: `//go:build cgo && rknn`
|
||||
|
||||
## 7. 配置
|
||||
|
||||
配置文件: `/etc/kvm/config.json` (JSON) 或 `.toml`
|
||||
|
||||
关键配置节:
|
||||
```toml
|
||||
[server]
|
||||
port = 8080
|
||||
|
||||
[gstreamer]
|
||||
pipeline_backend = "native" # "native" 或 "gstreamer"
|
||||
|
||||
[hid]
|
||||
keyboard_device = "/dev/hidg0"
|
||||
mouse_device = "/dev/hidg1"
|
||||
|
||||
[ocr]
|
||||
enabled = true
|
||||
binary_path = "/usr/bin/kvm-ocr"
|
||||
sample_interval_sec = 2
|
||||
|
||||
[services.privacy]
|
||||
enabled = true
|
||||
address = "127.0.0.1:8000"
|
||||
```
|
||||
|
||||
## 8. 数据库模型
|
||||
|
||||
| 表 | 说明 |
|
||||
|-----|------|
|
||||
| users | 用户 + 密码哈希 + 角色 |
|
||||
| sessions | 登录会话 |
|
||||
| audit_log | SM3 哈希链审计 |
|
||||
| recordings | HLS 录制元数据 |
|
||||
| ai_logs | OCR 检测 + 模式匹配 |
|
||||
| request_logs | HTTP 请求审计 |
|
||||
@@ -0,0 +1,110 @@
|
||||
# 记忆桥接 (mem-bridge) 架构
|
||||
|
||||
> 子系统: deps/embedding/ | 端口: 8001 (memory) + 8002 (router) | 包名: kvm-bridge
|
||||
|
||||
## 1. 概述
|
||||
|
||||
mem-bridge 提供两个 FastAPI 服务:Memory Service 负责向量搜索和会话存储,Router Service 负责 LLM 路由和上下文注入。
|
||||
|
||||
## 2. 双服务架构
|
||||
|
||||
```
|
||||
Agent/前端 → Router Service (port 8002)
|
||||
├─ 复杂度评分
|
||||
├─ Query 改写
|
||||
├─ Memory 检索 (→ port 8001)
|
||||
├─ 上下文注入
|
||||
└─ 后端路由 (OpenAI/Gemini/Deepseek/本地)
|
||||
│
|
||||
▼
|
||||
Memory Service (port 8001)
|
||||
├─ Turn Store (对话轮次, SQLite)
|
||||
├─ Fact Store (事实提取)
|
||||
├─ Compressor (上下文压缩)
|
||||
└─ Vector Index (FAISS + RKNNLite)
|
||||
├─ multilingual-e5-small (384-dim)
|
||||
├─ IVF-PQ 索引 (>5000 向量)
|
||||
└─ FlatIP 备选 (<5000 向量)
|
||||
```
|
||||
|
||||
## 3. Memory Service API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | /v1/add_turn | 保存对话轮次 |
|
||||
| POST | /v1/search | 语义搜索 (混合 BM25 + embedding) |
|
||||
| POST | /v1/compress | 上下文压缩 (token 限制) |
|
||||
| POST | /v1/facts | 事实提取和存储 |
|
||||
|
||||
### 混合搜索评分
|
||||
```
|
||||
final = semantic_score * (1 - bm25_weight) + bm25_score * bm25_weight
|
||||
```
|
||||
|
||||
## 4. Router Service API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | /v1/chat/completions | OpenAI 兼容聊天 API |
|
||||
|
||||
### 路由策略
|
||||
1. 复杂度评分 (ComplexityScorer)
|
||||
2. Memory 检索上下文
|
||||
3. Query 改写 (QueryRewriter)
|
||||
4. 后端选择:
|
||||
- gpt-4o → OpenAI API
|
||||
- gemini-2.5-pro → Google API
|
||||
- deepseek → Deepseek API
|
||||
- local → Ollama (Qwen2-1.5B)
|
||||
|
||||
## 5. 嵌入模型
|
||||
|
||||
| 参数 | 值 |
|
||||
|------|-----|
|
||||
| 模型 | multilingual-e5-small |
|
||||
| 维度 | 384 |
|
||||
| 量化 | RKNN INT8 (NPU Core 2) |
|
||||
| FAISS 索引 | IVF-PQ (nlist=100, m=48) |
|
||||
| 压缩比 | 32:1 (每向量 48 bytes) |
|
||||
| 最大存储 | 3GB |
|
||||
|
||||
## 6. NPU 核心分配
|
||||
|
||||
```
|
||||
Core 0: OCR detection (RKNN_NPU_CORE_0)
|
||||
Core 1: OCR recognition (RKNN_NPU_CORE_1)
|
||||
Core 2: Embedding (RKNN_NPU_CORE_2) ← 独占, 不与 OCR 争用
|
||||
```
|
||||
|
||||
## 7. 配置
|
||||
|
||||
```yaml
|
||||
db_dir: "~/.embed_db/"
|
||||
memory_host: "0.0.0.0"
|
||||
memory_port: 8001
|
||||
router_host: "0.0.0.0"
|
||||
router_port: 8002
|
||||
|
||||
backends:
|
||||
openai:
|
||||
api_key: "${OPENAI_API_KEY}"
|
||||
model: "gpt-4o"
|
||||
google:
|
||||
api_key: "${GOOGLE_API_KEY}"
|
||||
model: "gemini-2.5-pro"
|
||||
|
||||
default_backend: "openai"
|
||||
```
|
||||
|
||||
## 8. systemd 服务
|
||||
|
||||
```ini
|
||||
# mem-bridge-memory.service
|
||||
ExecStart=python server.py --service memory
|
||||
CPUAffinity=2 3 # A55 小核
|
||||
|
||||
# mem-bridge-router.service
|
||||
Requires=mem-bridge-memory.service
|
||||
ExecStart=python server.py --service router
|
||||
CPUAffinity=2 3 # A55 小核
|
||||
```
|
||||
@@ -0,0 +1,185 @@
|
||||
# KVM-Privacy 系统架构总览
|
||||
|
||||
> 最后更新: 2026-03-05 | 版本: 1.0.0
|
||||
|
||||
## 1. 系统定位
|
||||
|
||||
KVM-Privacy 是基于 KVM-over-IP 的两层隐私保护系统,运行于 NanoPC-T6 (RK3588)。通过硬件加速(NPU + RGA + MPP)实现实时 PII 检测、视频流遮蔽、网络上传拦截和 AI 自主操控。所有防护完全在 KVM 设备端完成,无需在被控机器上部署任何软件。
|
||||
|
||||
## 2. 服务架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ KVM WebUI (React) │
|
||||
│ http://localhost:8080 │
|
||||
└──────────────┬──────────────────────────────────────────────┘
|
||||
│ HTTP + WebSocket + WebRTC
|
||||
┌──────────────▼──────────────────────────────────────────────┐
|
||||
│ KVM Server (Go, port 8080) │
|
||||
│ ┌──────────┐ ┌───────┐ ┌────────┐ ┌──────┐ ┌───────────┐ │
|
||||
│ │ 视频管道 │ │ HID │ │ WebRTC │ │ RBAC │ │ 隐私处理 │ │
|
||||
│ │ V4L2→MPP │ │hidg0/1│ │ pion │ │Casbin│ │ OCR+遮蔽 │ │
|
||||
│ └──────────┘ └───────┘ └────────┘ └──────┘ └───────────┘ │
|
||||
│ ┌──────────┐ ┌───────┐ ┌────────┐ ┌──────┐ ┌───────────┐ │
|
||||
│ │ 审计日志 │ │ 录制 │ │ 终端 │ │ 存储 │ │ Agent代理 │ │
|
||||
│ │ SM3链+DB │ │ HLS │ │SSH/WS │ │虚拟U盘│ │ →:8890 │ │
|
||||
│ └──────────┘ └───────┘ └────────┘ └──────┘ └───────────┘ │
|
||||
└──┬───────┬───────────┬──────────────────────┬──────────────┘
|
||||
│ │ │ │
|
||||
│ ┌────▼────┐ ┌────▼──────────┐ ┌───────▼──────────┐
|
||||
│ │隐私网关 │ │ KVM Agent │ │ info-privacy-rs │
|
||||
│ │mitmproxy│ │ Python 8890 │ │ Rust/RKNN 8000 │
|
||||
│ │port 8888│ │ LLM+OCR+HID │ │ PII 检测/脱敏 │
|
||||
│ └────┬────┘ └───────┬───────┘ └──────────────────┘
|
||||
│ │ │
|
||||
│ ┌────▼────┐ ┌─────▼──────────────────┐
|
||||
│ │ 审计DB │ │ mem-bridge (Python) │
|
||||
│ │MariaDB/ │ │ Memory :8001 (FAISS) │
|
||||
│ │SQLite │ │ Router :8002 (LLM路由) │
|
||||
│ └─────────┘ └────────────────────────┘
|
||||
│
|
||||
┌──▼──────────────────────┐
|
||||
│ 目标主机 (HDMI + USB) │
|
||||
│ V4L2 采集 + HID gadget │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
## 3. 服务端口规划
|
||||
|
||||
| 服务 | 端口 | 协议 | 包名 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| KVM Server | 8080 | HTTP/WS/WebRTC | kvm-server | Go 主服务 |
|
||||
| info-privacy-rs | 8000 | HTTP | kvm-privacy | Rust PII 检测/脱敏 |
|
||||
| mem-bridge memory | 8001 | HTTP | kvm-bridge | FAISS 向量搜索 + 会话存储 |
|
||||
| mem-bridge router | 8002 | HTTP | kvm-bridge | AI 路由 (OpenAI/Gemini/本地) |
|
||||
| Privacy Gateway | 8888 | HTTP Proxy | kvm-mitm | mitmproxy 网络拦截 |
|
||||
| KVM Agent | 8890 | HTTP | kvm-agent | Python AI Agent daemon |
|
||||
| Privacy API | 8889 | HTTP | kvm-mitm | 隐私网关 REST API |
|
||||
|
||||
## 4. 子系统划分
|
||||
|
||||
| 子系统 | 语言 | 文档 | 核心职责 |
|
||||
|--------|------|------|---------|
|
||||
| KVM Server | Go 1.24 | [kvm-server.md](kvm-server.md) | 视频流、HID、WebRTC、RBAC、审计 |
|
||||
| 原生视频管道 | C/C++ + CGo | [video-pipeline.md](video-pipeline.md) | V4L2→RGA→MPP 编码、隐私遮蔽、DMA-buf |
|
||||
| KVM Agent | Python 3.12 | [kvm-agent.md](kvm-agent.md) | AI 自主操控(感知→规划→执行→验证) |
|
||||
| 隐私系统 | Go+Python+Rust | [privacy-system.md](privacy-system.md) | PII 检测、视频遮蔽、网络拦截、Chrome 扩展 |
|
||||
| 记忆桥接 | Python 3.12 | [memory-bridge.md](memory-bridge.md) | FAISS 向量搜索、LLM 路由、上下文压缩 |
|
||||
| 硬件优化 | C/C++ | [hardware.md](hardware.md) | RK3588 NPU/RGA/DMA-buf/CPU 亲和性 |
|
||||
| 部署打包 | Shell/Debian | [deployment.md](deployment.md) | 5 个 deb 包、systemd 服务、CI 流程 |
|
||||
|
||||
## 5. 数据流概览
|
||||
|
||||
### 5.1 视频流路径
|
||||
```
|
||||
HDMI 输入 → V4L2 (/dev/video0) → [BGR/NV12]
|
||||
→ RGA 色彩转换/缩放 → NV12 buffer
|
||||
→ 隐私遮蔽 (redact_nv12_regions, 如果 mode=redact)
|
||||
→ MPP H.264 编码 (或 DMA-buf 零拷贝)
|
||||
→ RTP 打包 (pion/rtp)
|
||||
→ WebRTC DTLS-SRTP → 浏览器 <video>
|
||||
```
|
||||
|
||||
### 5.2 OCR 感知路径
|
||||
```
|
||||
V4L2 BGR 帧 → JPEG 编码 → /tmp/kvm-ocr/frame.jpg
|
||||
→ HID 事件触发 OCR (L1/L2/L3 优先级)
|
||||
→ kvm-ocr 分析 (exec 或 CGo 内联)
|
||||
→ 文本 + 区域坐标 → 模式匹配 (sensitive_commands.json)
|
||||
→ 危险命令? → HID 阻断
|
||||
→ mode=redact? → detectPIIBboxes → SetRedactRegions → 视频管道
|
||||
```
|
||||
|
||||
### 5.3 Agent 自主操控
|
||||
```
|
||||
用户提交任务 → POST /api/v1/agent/tasks
|
||||
→ LLM 规划 (Vision: screenshot + OCR + 历史)
|
||||
→ 执行动作 (鼠标优先, 安全白名单)
|
||||
→ 截图验证 (OCR 语义检查)
|
||||
→ 成功? → 保存模板 | 失败? → 重试/放弃
|
||||
```
|
||||
|
||||
### 5.4 隐私拦截
|
||||
```
|
||||
浏览器流量 → mitmproxy (8888)
|
||||
→ AI 域名匹配 → 文件上传检测
|
||||
→ info-privacy-rs API 分析 PII
|
||||
→ audit: 记录日志 | redact: 脱敏后放行 | off: 直通
|
||||
```
|
||||
|
||||
## 6. 技术栈
|
||||
|
||||
| 层 | 技术 | 版本 |
|
||||
|----|------|------|
|
||||
| 前端 | React + TypeScript | 18+ / 5.3+ |
|
||||
| Go 后端 | Go + gorm + pion WebRTC | 1.24.0 |
|
||||
| Python 服务 | asyncio + aiohttp + httpx | 3.12 |
|
||||
| Rust 加速 | Axum + RKNN + RGA | 2024 edition |
|
||||
| 视频编码 | Rockchip MPP + RGA (native) / GStreamer (fallback) | — |
|
||||
| 向量搜索 | FAISS + RKNNLite (embedding) | — |
|
||||
| 数据库 | MariaDB + SQLite | 10.4+ |
|
||||
| 认证 | JWT (RS256) + TOTP + Casbin RBAC | — |
|
||||
| 国密 | SM3 哈希 (审计链) | emmansun/gmsm |
|
||||
|
||||
## 7. 安全架构
|
||||
|
||||
### 纵深防御(设备端完成,不依赖被控机器)
|
||||
```
|
||||
Layer 1: Agent Safety (Python)
|
||||
├─ 20 键白名单, 组合键全禁
|
||||
├─ Unicode NFKC 归一化 (防全角绕过)
|
||||
├─ 危险命令 regex 拦截
|
||||
└─ 文本长度 ≤10000
|
||||
|
||||
Layer 2: KVM Server (Go)
|
||||
├─ OCR 模式匹配 (sensitive_commands.json)
|
||||
├─ HID 实时阻断 (检测到危险命令)
|
||||
├─ RBAC 权限控制 (admin/auditor/operator)
|
||||
├─ JWT + TOTP 双因素认证
|
||||
└─ SM3 哈希链审计日志
|
||||
|
||||
Layer 3: Privacy Gateway (Python + Rust)
|
||||
├─ MITM SSL 拦截 AI 平台上传
|
||||
├─ info-privacy-rs PII 检测/脱敏
|
||||
└─ append-only 审计日志
|
||||
```
|
||||
|
||||
## 8. 目录结构
|
||||
|
||||
```
|
||||
KVM-privacy/
|
||||
├── CLAUDE.md # 项目编码规范
|
||||
├── README.md # 项目概览
|
||||
├── DEVELOP.md # 开发指南
|
||||
├── RELEASE.md # 版本历史
|
||||
├── Makefile # 部署命令
|
||||
├── debian/ # 5 个 deb 包定义
|
||||
│ ├── kvm-agent/ # Python AI Agent
|
||||
│ ├── kvm-bridge/ # mem-bridge 记忆服务
|
||||
│ ├── kvm-meta/ # 元包 (依赖全部)
|
||||
│ ├── kvm-mitm/ # mitmproxy 网关
|
||||
│ └── kvm-privacy/ # Rust PII 检测
|
||||
├── deploy/
|
||||
│ ├── kvm.toml.example # KVM 配置模板
|
||||
│ └── systemd/ # 8 个 systemd 服务文件
|
||||
├── deps/ # Git submodules
|
||||
│ ├── KVM/ # Go KVM 核心 + React WebUI
|
||||
│ │ ├── go/ # Go 后端 (28 个 internal 包)
|
||||
│ │ ├── web/ # React 前端
|
||||
│ │ ├── src/video/ # C 视频管道 (V4L2/RGA/MPP)
|
||||
│ │ ├── src/audit/ # C OCR 模块 (RKNN)
|
||||
│ │ ├── systemd/ # KVM systemd 服务
|
||||
│ │ ├── debian/ # kvm-server deb 定义
|
||||
│ │ └── scripts/ # 构建脚本
|
||||
│ ├── embedding/ # Python mem-bridge
|
||||
│ └── info-privacy-rs/ # Rust PII 检测
|
||||
├── services/
|
||||
│ ├── kvm_agent/ # Python AI Agent (26 个模块)
|
||||
│ └── privacy_gateway/ # Python mitmproxy 网关
|
||||
├── scripts/ # 部署/测试脚本
|
||||
├── docs/
|
||||
│ ├── architecture/ # 子系统架构文档 (本目录)
|
||||
│ └── plans/ # 历史设计文档 (14 个)
|
||||
└── web/
|
||||
└── src/ # TypeScript 组件库
|
||||
```
|
||||
@@ -0,0 +1,135 @@
|
||||
# 隐私系统架构
|
||||
|
||||
> 跨子系统: Go privacy_handler + Python gateway + Rust info-privacy-rs
|
||||
|
||||
## 1. 概述
|
||||
|
||||
隐私系统提供两层 PII 防护:视频流实时遮蔽、网络上传拦截脱敏。所有防护完全在 KVM 设备端完成,无需在被控机器上部署任何软件。
|
||||
|
||||
## 2. 两层防护
|
||||
|
||||
```
|
||||
Layer 1: 视频流遮蔽 (Go + C)
|
||||
├─ OCR 检测屏幕 PII
|
||||
├─ 归一化坐标 → C 视频管道
|
||||
└─ NV12 黑色填充 → MPP 编码输出
|
||||
|
||||
Layer 2: 网络拦截 (Python + Rust)
|
||||
├─ mitmproxy SSL 中间人 (port 8888)
|
||||
├─ AI 域名匹配 → 文件上传检测
|
||||
├─ info-privacy-rs 分析/脱敏
|
||||
└─ append-only 审计日志
|
||||
```
|
||||
|
||||
## 3. 隐私模式
|
||||
|
||||
| 模式 | 视频流 | 网络 | 前端 |
|
||||
|------|--------|------|------|
|
||||
| off | 无处理 | 直通 | 无 overlay |
|
||||
| audit | 红色边框 overlay | 记录日志,放行 | 红色边框 |
|
||||
| redact | 黑色填充遮蔽 | 脱敏后放行 | 黑色填充 |
|
||||
|
||||
## 4. 视频遮蔽 (Go + C)
|
||||
|
||||
### 数据流
|
||||
```
|
||||
OCRMonitor.processFrame()
|
||||
→ OCRResult{text, regions}
|
||||
→ onAfterScan callback
|
||||
→ PrivacyHandler.ScanAndUpdateRedaction()
|
||||
→ detectPIIBboxes() [Go regex: phone/id_card/bank_card/email]
|
||||
→ onRedactUpdate(bboxes [][4]float32)
|
||||
→ NativePipeline.SetRedactRegions()
|
||||
→ bridgeSetRedactBboxes() [CGo]
|
||||
→ video_pipeline_set_redact_bboxes() [C, mutex]
|
||||
→ 每帧编码前: redact_nv12_regions()
|
||||
```
|
||||
|
||||
### PII 检测正则 (Go, privacy_handler.go)
|
||||
```go
|
||||
phone: 1[3-9]\d{9}
|
||||
id_card: \d{17}[\dXx]
|
||||
bank_card: \d{16,19}
|
||||
email: [\w.+-]+@[\w.-]+\.\w{2,}
|
||||
```
|
||||
|
||||
### NV12 黑色填充 (C, color_convert.cpp)
|
||||
- Y plane = 16 (BT.601 黑色, 非 0)
|
||||
- UV plane = 128 (中性色度)
|
||||
- 坐标对齐到 2 像素 (色度子采样)
|
||||
|
||||
## 5. Privacy Gateway (Python)
|
||||
|
||||
### 组件
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| addon.py | mitmproxy 插件: 域名匹配→拦截→脱敏 |
|
||||
| interceptor.py | 请求内容提取 (multipart/JSON) |
|
||||
| upload_scanner.py | 调用 info-privacy-rs 分析 |
|
||||
| cert_manager.py | CA 证书生成 (/etc/kvm-privacy/ca/) |
|
||||
| audit_logger.py | 审计日志 (MariaDB/SQLite) |
|
||||
| privacy_api.py | REST API (port 8889) |
|
||||
|
||||
### AI 域名白名单
|
||||
```
|
||||
api.openai.com
|
||||
api.anthropic.com
|
||||
generativelanguage.googleapis.com
|
||||
api.deepseek.com
|
||||
...
|
||||
```
|
||||
|
||||
### 拦截流程
|
||||
```
|
||||
HTTP Request → addon.py:request()
|
||||
→ should_intercept(url)?
|
||||
→ 文件上传? → upload_scanner.scan()
|
||||
→ POST info-privacy-rs /api/v1/analyze
|
||||
→ classification:
|
||||
normal → 放行 (toast)
|
||||
sensitive_partial → 记录 + 用户决定
|
||||
classified → 拦截 (阻止)
|
||||
→ mode=redact? → POST /api/v1/redact → 替换内容
|
||||
```
|
||||
|
||||
## 6. info-privacy-rs (Rust)
|
||||
|
||||
### API
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | /api/v1/analyze | 检测 PII (返回 DetectionReport) |
|
||||
| POST | /api/v1/redact | 脱敏 (需传 redact_types 配置!) |
|
||||
| GET | /api/v1/health | 健康检查 |
|
||||
| GET | /api/v1/types | 支持的实体类型列表 |
|
||||
|
||||
### 支持格式
|
||||
docx, xlsx, pdf, jpg, jpeg, png, bmp (**不支持 txt**)
|
||||
|
||||
### 实体类型
|
||||
```
|
||||
id_card, phone, bank_card, email,
|
||||
license_plate, name, address, face
|
||||
```
|
||||
|
||||
### 分类标准
|
||||
- **normal**: 无 PII
|
||||
- **sensitive_partial**: < 5 个高风险实体
|
||||
- **classified**: ≥ 5 个高风险实体 (id_card/phone/bank_card)
|
||||
|
||||
### 重要: redact 必须传 config
|
||||
```bash
|
||||
# 正确 (返回脱敏文件)
|
||||
curl -F "file=@doc.pdf" -F 'config={"redact_types":["id_card","phone"]}' \
|
||||
http://localhost:8000/api/v1/redact
|
||||
|
||||
# 错误 (返回原文件!)
|
||||
curl -F "file=@doc.pdf" http://localhost:8000/api/v1/redact
|
||||
```
|
||||
|
||||
## 7. 状态存储
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| /var/lib/kvm-privacy/state.json | `{"mode":"off\|audit\|redact"}` |
|
||||
| /var/lib/kvm-privacy/audit.db | SQLite 审计日志 |
|
||||
| /etc/kvm-privacy/ca/ca.crt | MITM CA 证书 |
|
||||
@@ -0,0 +1,183 @@
|
||||
# 原生视频管道架构
|
||||
|
||||
> 子系统: deps/KVM/src/video/ + go/internal/videopipe/
|
||||
|
||||
## 1. 概述
|
||||
|
||||
原生视频管道绕过 GStreamer,直接使用 RK3588 硬件加速器链:V4L2 采集 → RGA 色彩转换/缩放 → MPP H.264 编码。通过 CGo 桥接到 Go 层,最终由 pion/rtp 打包推送到 WebRTC 客户端。
|
||||
|
||||
## 2. 管道架构
|
||||
|
||||
```
|
||||
HDMI-RX (/dev/video0)
|
||||
│
|
||||
▼ V4L2 MMAP (4 buffers, BGR24 或 NV12)
|
||||
│
|
||||
┌───▼──────────────────────────────────────┐
|
||||
│ v4l2_capture.cpp │
|
||||
│ ├─ pthread capture_thread (select loop) │
|
||||
│ ├─ DMA-buf EXPBUF (零拷贝, 可选) │
|
||||
│ └─ 回调: data + size + pts + buffer_idx │
|
||||
└───┬──────────────────────────────────────┘
|
||||
│
|
||||
┌───▼──────────────────────────────────────┐
|
||||
│ video_pipeline.cpp (internal_v4l2_callback)│
|
||||
│ │
|
||||
│ 1. BGR 快照回调 (snapshot_fps 控制频率) │
|
||||
│ └─ NV12→BGR 转换 (如果源是 NV12) │
|
||||
│ │
|
||||
│ 2. 色彩转换 + 缩放 │
|
||||
│ ├─ BGR→NV12: convert_bgr_to_nv12() │
|
||||
│ ├─ BGR→NV12+resize: _resize() │
|
||||
│ ├─ NV12→NV12 resize: resize_nv12() │
|
||||
│ └─ NV12 直拷: memcpy │
|
||||
│ │
|
||||
│ 3. 隐私遮蔽 (mutex 保护) │
|
||||
│ └─ redact_nv12_regions(nv12, bboxes) │
|
||||
│ │
|
||||
│ 4. MPP 编码 │
|
||||
│ ├─ DMA-buf: mpp_encoder_encode_dmabuf │
|
||||
│ └─ memcpy: mpp_encoder_encode_nv12 │
|
||||
└───┬──────────────────────────────────────┘
|
||||
│
|
||||
┌───▼──────────────────────────────────────┐
|
||||
│ mpp_encoder.cpp │
|
||||
│ ├─ MppApi::encode_put_frame │
|
||||
│ ├─ MppApi::encode_get_packet │
|
||||
│ ├─ IDR 强制 (MPP_ENC_SET_IDR_FRAME) │
|
||||
│ └─ 动态码率 (MPP_ENC_SET_RC_CFG) │
|
||||
└───┬──────────────────────────────────────┘
|
||||
│ H.264 NAL units
|
||||
┌───▼──────────────────────────────────────┐
|
||||
│ Go: cgo_bridge.go → pipeline.go │
|
||||
│ ├─ onH264(data, pts, keyframe) │
|
||||
│ ├─ RTPPacketizer (pion/rtp H.264) │
|
||||
│ └─ AdaptiveController (PLI→码率调整) │
|
||||
└───┬──────────────────────────────────────┘
|
||||
│ RTP packets
|
||||
▼ WebRTC PeerConnection → 浏览器
|
||||
```
|
||||
|
||||
## 3. 源文件清单
|
||||
|
||||
### C/C++ 层 (deps/KVM/src/video/)
|
||||
|
||||
| 文件 | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| video_pipeline.h | 55 | 公共 C API (create/start/stop/redact) |
|
||||
| video_pipeline.cpp | 364 | 管道编排: 采集→转换→遮蔽→编码 |
|
||||
| v4l2_capture.h | 55 | V4L2 采集 API (含 DMA-buf) |
|
||||
| v4l2_capture.cpp | 354 | V4L2 MMAP + EXPBUF + capture thread |
|
||||
| mpp_encoder.h | 30 | MPP 编码器 API (含 DMA-buf encode) |
|
||||
| mpp_encoder.cpp | ~300 | Rockchip MPP H.264 编码 |
|
||||
| color_convert.h | 20 | 色彩转换 API |
|
||||
| color_convert.cpp | ~400 | BGR↔NV12 + RGA resize + NV12 redact |
|
||||
| CMakeLists.txt | 45 | 构建 libmpp_video.so |
|
||||
|
||||
### CGo 桥接层 (go/internal/videopipe/)
|
||||
|
||||
| 文件 | Build Tag | 职责 |
|
||||
|------|-----------|------|
|
||||
| cgo_bridge.go | cgo | Go→C 函数封装 + RedactBbox 类型 |
|
||||
| cgo_exports.go | cgo | //export Go 回调 (H.264 + BGR snapshot) |
|
||||
| cgo_shim.c | — | C 回调 shim (路由到 Go) |
|
||||
| pipeline.go | — | NativePipeline 公共接口 |
|
||||
| adaptive.go | — | AIMD 自适应码率控制 |
|
||||
| rtp_packetizer.go | — | H.264 NAL→RTP 转换 |
|
||||
|
||||
## 4. 关键数据结构
|
||||
|
||||
### VideoPipeline (C++)
|
||||
```c
|
||||
struct VideoPipeline {
|
||||
MppEncoder* encoder;
|
||||
V4L2Capture* capture;
|
||||
H264PacketCallback h264_callback;
|
||||
BGRFrameCallback bgr_callback;
|
||||
|
||||
int width, height; // 编码目标分辨率
|
||||
int cap_width, cap_height; // V4L2 实际采集分辨率
|
||||
uint32_t cap_format; // BGR24 或 NV12
|
||||
bool needs_resize; // 采集≠编码分辨率时启用
|
||||
|
||||
uint8_t* nv12_buffer; // 可复用转换缓冲区
|
||||
uint8_t* bgr_buffer; // NV12→BGR 快照缓冲区
|
||||
|
||||
int snapshot_fps; // BGR 快照帧率 (0=每帧)
|
||||
int snapshot_interval; // = capture_fps / snapshot_fps
|
||||
|
||||
float* redact_bboxes; // [x0,y0,w0,h0, x1,y1,w1,h1, ...]
|
||||
int redact_count;
|
||||
std::mutex redact_mutex; // 保护 bbox 更新
|
||||
};
|
||||
```
|
||||
|
||||
### NativePipeline (Go)
|
||||
```go
|
||||
type NativePipeline struct {
|
||||
cfg Config // Device, Width, Height, FPS, Bitrate
|
||||
onH264 func([]byte, int64, bool) // H.264 输出
|
||||
onSnapshot func([]byte) // JPEG 快照
|
||||
pipelineID int // C 回调路由 ID
|
||||
}
|
||||
```
|
||||
|
||||
## 5. DMA-buf 零拷贝路径
|
||||
|
||||
当满足以下条件时,跳过 CPU memcpy:
|
||||
1. V4L2 输出为 NV12(非 BGR)
|
||||
2. 无缩放(cap_width == width && cap_height == height)
|
||||
3. 无隐私遮蔽(redact_count == 0)
|
||||
4. DMA-buf 导出成功(VIDIOC_EXPBUF)
|
||||
|
||||
```
|
||||
V4L2 DMA-buf fd → MPP mpp_buffer_import_with_tag → 零拷贝编码
|
||||
```
|
||||
|
||||
节省 1080p@30fps 约 90MB/s 内存带宽。
|
||||
|
||||
## 6. 隐私遮蔽机制
|
||||
|
||||
### 调用链
|
||||
```
|
||||
Go: PrivacyHandler.ScanAndUpdateRedaction()
|
||||
→ detectPIIBboxes() (regex: phone/id_card/bank_card/email)
|
||||
→ onRedactUpdate(bboxes) callback
|
||||
→ NativePipeline.SetRedactRegions(regions)
|
||||
→ bridgeSetRedactBboxes() [CGo]
|
||||
→ video_pipeline_set_redact_bboxes() [C, mutex 保护]
|
||||
```
|
||||
|
||||
### NV12 遮蔽算法 (color_convert.cpp)
|
||||
```
|
||||
对每个 bbox (归一化 0.0-1.0):
|
||||
1. 转换为像素坐标 (对齐到 2 像素, NV12 色度采样)
|
||||
2. Y 平面: memset 为 16 (BT.601 黑色)
|
||||
3. UV 平面: memset 为 128 (中性色度)
|
||||
```
|
||||
|
||||
## 7. 自适应码率
|
||||
|
||||
AIMD (Additive Increase Multiplicative Decrease) 算法:
|
||||
- PLI (Picture Loss Indication) 作为拥塞信号
|
||||
- 拥塞时码率减半 (multiplicative decrease ÷2)
|
||||
- 稳定期线性增加 (additive increase)
|
||||
- 码率范围: 100Kbps ~ 20Mbps
|
||||
- FPS 范围: 1 ~ 60
|
||||
|
||||
## 8. 构建
|
||||
|
||||
```bash
|
||||
# 构建 C 库 (需要 rockchip_mpp, rga 开发库)
|
||||
cd deps/KVM
|
||||
cmake -B build/video -S src/video -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build/video -j$(nproc)
|
||||
|
||||
# Go 构建 (链接 libmpp_video.so)
|
||||
cd go
|
||||
CGO_CFLAGS="-I../src/video" \
|
||||
CGO_LDFLAGS="-L../build/video -lmpp_video -Wl,-rpath,/usr/lib/kvm" \
|
||||
CGO_ENABLED=1 go build ./cmd/kvm-server/
|
||||
```
|
||||
|
||||
安装位置: `/usr/lib/kvm/libmpp_video.so`
|
||||
@@ -0,0 +1,218 @@
|
||||
# AI Agent Command Bar + Privacy Gateway Three-Mode Design
|
||||
|
||||
**Date:** 2026-03-03
|
||||
**Branch:** feature/enhance-testing-security-states
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Add two missing frontend entry points:
|
||||
1. **AI Agent Command Bar** — bottom of ConsolePage, submit tasks to KVM Agent
|
||||
2. **Privacy Gateway Mode Selector** — extend /privacy page CertificateTab with OFF/AUDIT/REDACT modes
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser (port 8080)
|
||||
├── /api/v1/agent/* → Go reverse proxy → KVM Agent (port 8890)
|
||||
└── /api/v1/privacy/* → Go reverse proxy → Privacy Gateway (port 8889)
|
||||
```
|
||||
|
||||
Go adds `proxy_handler.go` using `httputil.ReverseProxy`, one instance per target.
|
||||
|
||||
## Backend Changes
|
||||
|
||||
### Go: proxy_handler.go (new)
|
||||
|
||||
- `AgentProxy`: `/api/v1/agent/*` → `http://localhost:8890`
|
||||
- `PrivacyProxy`: `/api/v1/privacy/*` → `http://localhost:8889`
|
||||
- Register in `router.go`
|
||||
- Strip prefix before forwarding
|
||||
- Log proxy errors
|
||||
|
||||
### privacy_api.py (redesign)
|
||||
|
||||
**Remove:** `privacy_enabled: bool`, old boolean format
|
||||
|
||||
**New state.json format:**
|
||||
```json
|
||||
{"mode": "off"}
|
||||
```
|
||||
|
||||
**Endpoints:**
|
||||
```
|
||||
GET /api/v1/privacy/mode → {mode: "off"|"audit"|"redact"}
|
||||
POST /api/v1/privacy/mode body: {mode: "off"|"audit"|"redact"} → {mode: "..."}
|
||||
GET /api/v1/privacy/stats → {requests, files, pii_by_type, actions:{allow,audit,redact}, top_domains}
|
||||
GET /api/v1/privacy/audit ?limit=50 → [{...log entries}]
|
||||
GET /api/v1/privacy/cert → (unchanged)
|
||||
POST /api/v1/privacy/masking → (unchanged)
|
||||
```
|
||||
|
||||
### addon.py (update)
|
||||
|
||||
Read `mode` from state.json, three-branch logic:
|
||||
```python
|
||||
if mode == "off": pass_through() # no logging
|
||||
elif mode == "audit": log_request(); allow() # log only
|
||||
elif mode == "redact": log_request(); redact() # log + redact
|
||||
```
|
||||
|
||||
### audit_logger.py (extend)
|
||||
|
||||
Log schema per intercepted request:
|
||||
```json
|
||||
{
|
||||
"timestamp": "ISO8601",
|
||||
"mode": "audit|redact",
|
||||
"domain": "api.openai.com",
|
||||
"method": "POST",
|
||||
"path": "/v1/chat/completions",
|
||||
"action": "allow|redact",
|
||||
"files_detected": 1,
|
||||
"pii_types": ["id_card", "phone"],
|
||||
"source_ip": "192.168.1.x",
|
||||
"request_size_bytes": 12345
|
||||
}
|
||||
```
|
||||
|
||||
Storage: SQLite table `audit_log`, supports `GET /api/v1/privacy/audit?limit=50`.
|
||||
|
||||
### runner.py (task cancellation)
|
||||
|
||||
- Add `asyncio.Event cancel_event` per running task
|
||||
- `DELETE /api/v1/agent/tasks/{id}` sets the event
|
||||
- runner checks event between action steps → sets status `CANCELLED`
|
||||
- KVM HID cleanup via `safe_cleanup()` on cancel
|
||||
|
||||
### kvm_agent/api_server.py
|
||||
|
||||
No changes needed — `DELETE /api/v1/agent/tasks/{id}` already exists.
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
### New: `AgentCommandBar.tsx`
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ [任务描述输入框.............................] [执行] [终止] │
|
||||
│ 状态: 空闲 | 运行中 (task_id: abc123) │
|
||||
│ 最近: ✅ 打开记事本 ❌ 找不到文件 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Placed below `.video-container` in ConsolePage
|
||||
- [终止] button visible only when task is running
|
||||
- Task history: last 5 entries, success/fail icons
|
||||
|
||||
### New: `agentStore.ts`
|
||||
|
||||
```typescript
|
||||
interface AgentStore {
|
||||
status: "idle" | "running" | "error"
|
||||
currentTask: Task | null
|
||||
taskHistory: Task[]
|
||||
submitTask(description: string): Promise<void>
|
||||
cancelTask(id: string): Promise<void>
|
||||
pollStatus(id: string): void // 2s interval, stops on terminal state
|
||||
}
|
||||
```
|
||||
|
||||
### Modified: `PrivacyPage.tsx` — CertificateTab
|
||||
|
||||
Replace binary ToggleCard with three-segment mode selector:
|
||||
|
||||
```
|
||||
隐私网关模式:
|
||||
┌──────────┬──────────┬──────────┐
|
||||
│ 关闭 │ 审计 │ 脱敏 │
|
||||
└──────────┴──────────┴──────────┘
|
||||
描述: 不拦截任何请求 / 记录但不拦截 / 记录并脱敏文件
|
||||
```
|
||||
|
||||
- Calls `POST /api/v1/privacy/mode {mode: "..."}`
|
||||
- Loads current mode on mount via `GET /api/v1/privacy/mode`
|
||||
|
||||
### i18n keys to add
|
||||
|
||||
```json
|
||||
"agent.command_placeholder": "描述任务,例如:打开记事本",
|
||||
"agent.submit": "执行",
|
||||
"agent.cancel": "终止",
|
||||
"agent.status_idle": "空闲",
|
||||
"agent.status_running": "运行中",
|
||||
"agent.task_success": "成功",
|
||||
"agent.task_failed": "失败",
|
||||
"agent.task_cancelled": "已终止",
|
||||
"privacy.mode_off": "关闭",
|
||||
"privacy.mode_audit": "审计",
|
||||
"privacy.mode_redact": "脱敏",
|
||||
"privacy.mode_off_desc": "不拦截任何请求",
|
||||
"privacy.mode_audit_desc": "记录 PII 但不拦截",
|
||||
"privacy.mode_redact_desc": "记录并自动脱敏文件"
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Agent Task Submission
|
||||
|
||||
```
|
||||
User input → agentStore.submitTask()
|
||||
→ POST /api/v1/agent/tasks {task_type: "general", description: "..."}
|
||||
→ Go proxy → api_server.py
|
||||
→ TaskQueue (SQLite) → runner.py executes
|
||||
→ Poll GET /api/v1/agent/tasks/{id} every 2s
|
||||
→ On COMPLETED/FAILED/CANCELLED → stop polling, update history
|
||||
```
|
||||
|
||||
### Agent Task Cancellation
|
||||
|
||||
```
|
||||
User clicks [终止] → agentStore.cancelTask(id)
|
||||
→ DELETE /api/v1/agent/tasks/{id}
|
||||
→ Go proxy → api_server.py
|
||||
→ Sets cancel_event on runner
|
||||
→ runner finishes current action step → stops
|
||||
→ safe_cleanup() → status = CANCELLED
|
||||
→ Frontend poll detects CANCELLED → shows "已终止"
|
||||
```
|
||||
|
||||
### Privacy Mode Switch
|
||||
|
||||
```
|
||||
User clicks mode → POST /api/v1/privacy/mode {mode: "audit"}
|
||||
→ Go proxy → privacy_api.py
|
||||
→ Writes state.json {mode: "audit"}
|
||||
→ addon.py reads mode on next intercept
|
||||
```
|
||||
|
||||
### Audit Log Query (PrivacyPage AuditTab)
|
||||
|
||||
```
|
||||
AuditTab mount → GET /api/v1/privacy/audit?limit=50
|
||||
→ Go proxy → privacy_api.py
|
||||
→ SQLite query audit_log → return entries
|
||||
→ Render table with timestamp/domain/action/pii columns
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
| Layer | What to test |
|
||||
|-------|-------------|
|
||||
| Go proxy | `httptest` mock downstream, verify routing + error forwarding |
|
||||
| privacy_api | pytest: mode switch, stats format, audit log insert/query |
|
||||
| addon.py | mock three-mode branches (off/audit/redact) |
|
||||
| runner.py | cancel_event propagation, CANCELLED status |
|
||||
| AgentCommandBar | vitest: submit → poll → complete flow, cancel button visibility |
|
||||
| PrivacyPage | vitest: mode selector loads current mode, calls correct endpoint |
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Go proxy** (unblocks all frontend API calls)
|
||||
2. **privacy_api.py redesign** (mode enum + audit log)
|
||||
3. **addon.py update** (three-mode branch)
|
||||
4. **runner.py cancellation**
|
||||
5. **agentStore.ts + AgentCommandBar.tsx**
|
||||
6. **PrivacyPage CertificateTab mode selector**
|
||||
7. **i18n + wiring**
|
||||
8. **Tests**
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,278 @@
|
||||
# Frontend Enhancement Design
|
||||
|
||||
**Date:** 2026-03-03
|
||||
**Branch:** feature/enhance-testing-security-states
|
||||
**Scope:** KVM WebUI 隐私功能集成 + Chrome 扩展 Popup 仪表板化
|
||||
|
||||
---
|
||||
|
||||
## 背景与目标
|
||||
|
||||
当前前端状态:
|
||||
- KVM WebUI (React 18 + Vite) 功能完整,但缺少隐私功能入口
|
||||
- `web/src/PrivacyPage.tsx` 已实现三 tab 布局,但未集成到 KVM WebUI
|
||||
- Chrome 扩展 popup 为简单状态页(320px),缺乏统计可视化
|
||||
- 视频流隐私遮罩 API 已有(`/api/v1/privacy/bboxes`),前端无叠层实现
|
||||
|
||||
目标:
|
||||
1. Phase A:将隐私功能集成进 KVM WebUI,含视频 PII Canvas 叠层
|
||||
2. Phase B:Chrome 扩展 popup 仪表板化,含统计卡片、PII 分布图、近期扫描列表
|
||||
|
||||
---
|
||||
|
||||
## Phase A:KVM WebUI 隐私功能集成
|
||||
|
||||
### A1. 路由与导航
|
||||
|
||||
**文件:`deps/KVM/web/src/App.tsx`**
|
||||
|
||||
添加路由:
|
||||
```tsx
|
||||
<Route path="/privacy" element={<PrivacyPage />} />
|
||||
```
|
||||
|
||||
**文件:`deps/KVM/web/src/components/layout/Navbar.tsx`**
|
||||
|
||||
导航项列表末尾添加:
|
||||
```tsx
|
||||
{ path: '/privacy', icon: ShieldCheckIcon, labelKey: 'nav.privacy' }
|
||||
```
|
||||
|
||||
i18n key 添加到 `public/locales/zh/translation.json` 和 `en/translation.json`:
|
||||
```json
|
||||
{ "nav": { "privacy": "隐私" } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### A2. PrivacyPage(自包含,写入 submodule)
|
||||
|
||||
**文件:`deps/KVM/web/src/pages/PrivacyPage.tsx`(新增)**
|
||||
|
||||
完整独立实现,不跨目录引用 `web/src/`。包含三个 tab:
|
||||
|
||||
#### DashboardTab
|
||||
- 统计卡片行:拦截次数 / 已扫描文件 / 检出 PII 数 / 涉及域名数
|
||||
- PII 类型分布表(id_card、phone、bank_card、email、license_plate、name、address、face)
|
||||
- 今日域名 Top 5(按请求量排序)
|
||||
- 数据来源:`GET /api/v1/privacy/stats` → `PrivacyStats`
|
||||
- 刷新:30 秒自动刷新 + 手动刷新按钮
|
||||
|
||||
#### AuditTab
|
||||
- 分页表格(page_size=20)
|
||||
- 列:时间 / 域名 / 动作(allow/block/redact)/ PII 类型 / 文件名 / 文件大小 / 客户端 IP
|
||||
- 过滤:域名输入框(防抖 500ms)
|
||||
- 动作徽章颜色:allow=绿 / block=红 / redact=黄
|
||||
- 数据来源:`GET /api/v1/privacy/audit?page=N&domain=X`
|
||||
|
||||
#### CertificateTab
|
||||
- 隐私模式开关(大号 toggle):`GET/POST /api/v1/privacy/mode`
|
||||
- 开启时同步写入 `ocrStore.privacyMode = true`
|
||||
- 视频遮罩开关:`GET/POST /api/v1/privacy/masking`
|
||||
- CA 证书下载按钮:链接至 `GET /api/v1/privacy/cert`
|
||||
- 代理配置说明(手动设置系统代理 127.0.0.1:8888 的步骤)
|
||||
|
||||
**TypeScript 接口**(在同文件或单独的 `types/privacy.ts`):
|
||||
```typescript
|
||||
interface PrivacyStats {
|
||||
requests: number;
|
||||
files: number;
|
||||
pii_by_type: Record<string, number>;
|
||||
actions: { allow: number; block: number; redact: number };
|
||||
top_domains: Array<{ domain: string; count: number }>;
|
||||
}
|
||||
|
||||
interface PrivacyAuditEntry {
|
||||
id: number;
|
||||
ts: string;
|
||||
domain: string;
|
||||
pii_types: Record<string, number>;
|
||||
action: 'allow' | 'block' | 'redact';
|
||||
filename: string;
|
||||
file_size: number;
|
||||
client_ip: string;
|
||||
}
|
||||
```
|
||||
|
||||
**样式:** 复用 KVM WebUI 已有 Tailwind 类 + CSS 变量,不引入新的 CSS 文件。
|
||||
|
||||
---
|
||||
|
||||
### A3. PiiOverlay Canvas 叠层
|
||||
|
||||
**文件:`deps/KVM/web/src/components/console/PiiOverlay.tsx`(新增)**
|
||||
|
||||
```
|
||||
Props:
|
||||
enabled: boolean // 隐私模式是否开启
|
||||
videoRef: RefObject<HTMLVideoElement>
|
||||
|
||||
行为:
|
||||
- enabled=false → canvas display:none,不轮询
|
||||
- enabled=true → 每 2000ms 轮询 GET /api/v1/privacy/bboxes
|
||||
- 响应格式:{ bboxes: [{x, y, w, h, label}] }(归一化 0-1)
|
||||
- 绘制:ctx.fillStyle = 'rgba(0,0,0,0.85)',fillRect(x*W, y*H, w*W, h*H)
|
||||
- canvas 大小跟随 videoRef 实际渲染尺寸(ResizeObserver)
|
||||
- unmount 时 clearInterval + observer.disconnect()
|
||||
```
|
||||
|
||||
**挂载点(`ConsolePage.tsx` 修改):**
|
||||
```tsx
|
||||
// VideoPlayer 外层加 relative 容器
|
||||
<div style={{ position: 'relative' }}>
|
||||
<VideoPlayer ref={videoRef} ... />
|
||||
<PiiOverlay enabled={privacyMode} videoRef={videoRef} />
|
||||
</div>
|
||||
```
|
||||
|
||||
`privacyMode` 从 `kvmStore` 读取(需在 store 中添加该字段,初始值通过 `GET /api/v1/privacy/mode` 加载)。
|
||||
|
||||
---
|
||||
|
||||
### A4. `/api/v1/privacy/bboxes` 端点约定
|
||||
|
||||
Go 后端需提供(已有 `/api/v1/privacy/masking`,类似扩展):
|
||||
```json
|
||||
GET /api/v1/privacy/bboxes
|
||||
Response: {
|
||||
"bboxes": [
|
||||
{ "x": 0.1, "y": 0.2, "w": 0.3, "h": 0.05, "label": "id_card" }
|
||||
]
|
||||
}
|
||||
```
|
||||
若无活跃遮罩,返回 `{ "bboxes": [] }`。
|
||||
|
||||
---
|
||||
|
||||
## Phase B:Chrome 扩展 Popup 仪表板化
|
||||
|
||||
### B1. 布局变更
|
||||
|
||||
- `popup/popup.html`:width 320px → 400px,height 从固定 → min-height 480px
|
||||
- 结构改为三区域:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────┐ 400px
|
||||
│ Header: Logo + 状态指示 + [⚙] 按钮 │ 52px
|
||||
├────────────────────────────────────────┤
|
||||
│ Stats Bar: 扫描 / PII / 拦截 / 脱敏 │ 72px
|
||||
├──────────────────┬─────────────────────┤
|
||||
│ PII 分布标题 │ │
|
||||
│ 手机号 ██████ 2 │ │ ~120px
|
||||
│ 身份证 ███ 1 │ │
|
||||
│ 邮箱 ██ 1 │ │
|
||||
├────────────────────────────────────────┤
|
||||
│ Recent Scans 标题 │
|
||||
│ ✅ report.pdf normal 1分前 │ ~150px
|
||||
│ 🚫 id_scan.jpg sensitive 5分前 │
|
||||
│ ✅ contract.docx normal 12分前 │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
设置视图(点击 ⚙ 切换,不跳转新页面):
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ ← 返回 设置 │
|
||||
├────────────────────────────────────────┤
|
||||
│ 服务地址 [http://localhost:8001 ] │
|
||||
│ 自动拦截高风险文件 [●———] │
|
||||
│ 净文件扫描通知 [●———] │
|
||||
│ [保存] │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### B2. Stats Bar 数据
|
||||
|
||||
来源:`chrome.runtime.sendMessage({ type: 'getStats' })`
|
||||
|
||||
Service Worker 已有 `getStats()` 返回:
|
||||
```javascript
|
||||
{ todayScans, todayPII, todayBlocked, totalScans }
|
||||
```
|
||||
|
||||
需**扩展**返回 `todayRedacted`(脱敏次数)。
|
||||
在 `service-worker.js` 的 `recordScan()` 中增加计数。
|
||||
|
||||
---
|
||||
|
||||
### B3. PII 分布图(纯 CSS 条形图)
|
||||
|
||||
不引入第三方库。使用 HTML + CSS:
|
||||
|
||||
```html
|
||||
<div class="pii-bar-row">
|
||||
<span class="pii-label">手机号</span>
|
||||
<div class="pii-bar-track">
|
||||
<div class="pii-bar-fill" style="width: 66%"></div>
|
||||
</div>
|
||||
<span class="pii-count">2</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
宽度百分比 = `(count / maxCount) * 100`,颜色对应 PII 类型(与 `ui.js` 已有颜色一致)。
|
||||
|
||||
数据来源:`scanHistory` 条目中的 `piiTypes`(现有 `reportScan` 消息里有 `pii_types`)。
|
||||
|
||||
---
|
||||
|
||||
### B4. Recent Scans 列表
|
||||
|
||||
从 `chrome.storage.local` 读 `scanHistory`,取最新 5 条:
|
||||
|
||||
| 图标 | 条件 |
|
||||
|------|------|
|
||||
| ✅ | result = 'normal' |
|
||||
| ⚠️ | result = 'sensitive_partial' |
|
||||
| 🚫 | result = 'classified' 或 action = 'block' |
|
||||
| ✂️ | action = 'redact' |
|
||||
|
||||
时间显示:相对时间(`1分前`、`5分前`、`1小时前`)。
|
||||
|
||||
---
|
||||
|
||||
### B5. 涉及文件清单
|
||||
|
||||
| 文件 | 变更类型 | 说明 |
|
||||
|------|--------|------|
|
||||
| `web/chrome-extension/popup/popup.html` | 修改 | 宽度 400px,新增结构区块 |
|
||||
| `web/chrome-extension/popup/popup.js` | 修改 | 渲染 stats/pii/history,设置视图切换 |
|
||||
| `web/chrome-extension/popup/popup.css` | 修改 | stats bar、pii bar、recent list 样式 |
|
||||
| `web/chrome-extension/background/service-worker.js` | 修改 | getStats 增加 todayRedacted |
|
||||
| `web/chrome-extension/_locales/zh_CN/messages.json` | 修改 | 新 i18n key |
|
||||
| `web/chrome-extension/_locales/en/messages.json` | 修改 | 新 i18n key |
|
||||
|
||||
---
|
||||
|
||||
## 涉及文件汇总(Phase A)
|
||||
|
||||
| 文件 | 变更类型 | 说明 |
|
||||
|------|--------|------|
|
||||
| `deps/KVM/web/src/App.tsx` | 修改 | 添加 `/privacy` 路由 |
|
||||
| `deps/KVM/web/src/components/layout/Navbar.tsx` | 修改 | 添加隐私导航项 |
|
||||
| `deps/KVM/web/src/pages/PrivacyPage.tsx` | 新增 | 完整隐私页面(三 tab)|
|
||||
| `deps/KVM/web/src/components/console/PiiOverlay.tsx` | 新增 | Canvas PII 叠层 |
|
||||
| `deps/KVM/web/src/pages/ConsolePage.tsx` | 修改 | 挂载 PiiOverlay |
|
||||
| `deps/KVM/web/src/stores/kvmStore.ts` | 修改 | 添加 privacyMode 字段 |
|
||||
| `deps/KVM/web/public/locales/zh/translation.json` | 修改 | nav.privacy 等 key |
|
||||
| `deps/KVM/web/public/locales/en/translation.json` | 修改 | nav.privacy 等 key |
|
||||
|
||||
---
|
||||
|
||||
## 与现有计划的对应
|
||||
|
||||
| 原计划 | 本设计 |
|
||||
|--------|--------|
|
||||
| Phase 1: Web UI 隐私仪表盘 + 审计日志 | ✅ Phase A(PrivacyPage 三 tab)|
|
||||
| Phase 3: KVM 视频 PII 遮罩叠层 | ✅ Phase A(PiiOverlay Canvas)|
|
||||
| Chrome 扩展 UI 改进(未列入原计划)| ✅ Phase B(popup 仪表板化)|
|
||||
|
||||
---
|
||||
|
||||
## 不在本次范围内
|
||||
|
||||
- Go 后端 `frame_redactor.go` 修复(视频截图遮罩 bug)
|
||||
- native 视频管道切换(config.json 改动)
|
||||
- 审计日志轮换策略
|
||||
- 透明代理 iptables 配置
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,353 @@
|
||||
# KVM-Privacy Deb Packaging & Architecture Redesign
|
||||
|
||||
**Date:** 2026-03-04
|
||||
**Branch:** feature/enhance-testing-security-states
|
||||
**Status:** Approved — pending implementation plan
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals
|
||||
|
||||
1. Produce proper Debian packages for all KVM-Privacy services
|
||||
2. Eliminate Python packaging dependency hell via PyInstaller binaries
|
||||
3. Replace SQLite with MariaDB (already a system dependency) to fix multi-process write contention
|
||||
4. Maximise service independence: each service owns exactly the data it writes
|
||||
5. Address P0/P1 robustness issues identified in architecture review
|
||||
|
||||
---
|
||||
|
||||
## 2. Package Structure
|
||||
|
||||
| Package | Version | Contents | Build tool |
|
||||
|---------|---------|----------|-----------|
|
||||
| `kvm-server` | 1.0.0-3 | Go binary + embedded React frontend + Privacy Go handlers | Go + npm + dpkg-buildpackage |
|
||||
| `kvm-mitm` | 1.0.0-1 | PyInstaller self-contained binary (mitmproxy + addon) | PyInstaller + dpkg-deb |
|
||||
| `kvm-agent` | 1.0.0-1 | PyInstaller self-contained binary (AI agent daemon) | PyInstaller + dpkg-deb |
|
||||
|
||||
---
|
||||
|
||||
## 3. Database Architecture
|
||||
|
||||
### 3.1 MariaDB replaces SQLite everywhere
|
||||
|
||||
MariaDB is **already installed** on the target machine (`kvm-server` depends on `mariadb-server`).
|
||||
|
||||
**New tables** in the existing `kvm` database:
|
||||
|
||||
```sql
|
||||
-- Privacy gateway audit log (replaces /var/lib/kvm-privacy/audit.db)
|
||||
CREATE TABLE kvm.privacy_audit_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ts DATETIME(3) NOT NULL DEFAULT NOW(3),
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
pii_types JSON,
|
||||
action VARCHAR(20) NOT NULL, -- allow | redact | scan_failed
|
||||
doc_hash VARCHAR(64),
|
||||
file_count INT DEFAULT 0,
|
||||
client_ip VARCHAR(45) DEFAULT '',
|
||||
request_url VARCHAR(2048) DEFAULT '',
|
||||
filename VARCHAR(255) DEFAULT '',
|
||||
file_size INT DEFAULT 0,
|
||||
redacted_pii_count INT DEFAULT 0,
|
||||
INDEX idx_ts (ts),
|
||||
INDEX idx_domain (domain),
|
||||
INDEX idx_action (action)
|
||||
);
|
||||
|
||||
-- Agent task queue (replaces task_queue.db)
|
||||
CREATE TABLE kvm.agent_tasks (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
task_type VARCHAR(50) NOT NULL DEFAULT 'task',
|
||||
description TEXT NOT NULL,
|
||||
variables JSON NOT NULL DEFAULT ('{}'),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
created_at DOUBLE NOT NULL,
|
||||
started_at DOUBLE,
|
||||
finished_at DOUBLE,
|
||||
result TEXT DEFAULT '',
|
||||
success TINYINT(1) DEFAULT 0,
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created (created_at)
|
||||
);
|
||||
```
|
||||
|
||||
**Existing tables unchanged:** `audit_logs`, `request_logs`, `ai_interaction_logs`, `users`, `sessions`, `recordings`, etc.
|
||||
|
||||
### 3.2 Per-service MariaDB users (principle of least privilege)
|
||||
|
||||
```sql
|
||||
CREATE USER 'kvm_mitm'@'localhost' IDENTIFIED BY '...';
|
||||
CREATE USER 'kvm_agent'@'localhost' IDENTIFIED BY '...';
|
||||
CREATE USER 'kvm_server'@'localhost' IDENTIFIED BY '...'; -- already exists; extend grants
|
||||
|
||||
GRANT INSERT, SELECT ON kvm.privacy_audit_log TO 'kvm_mitm'@'localhost';
|
||||
GRANT ALL PRIVILEGES ON kvm.agent_tasks TO 'kvm_agent'@'localhost';
|
||||
GRANT SELECT ON kvm.privacy_audit_log TO 'kvm_server'@'localhost';
|
||||
GRANT SELECT ON kvm.agent_tasks TO 'kvm_server'@'localhost';
|
||||
```
|
||||
|
||||
### 3.3 Test database
|
||||
|
||||
```sql
|
||||
CREATE DATABASE kvm_test;
|
||||
-- Grant same users full access to kvm_test for CI
|
||||
GRANT ALL PRIVILEGES ON kvm_test.* TO 'kvm_mitm'@'localhost';
|
||||
GRANT ALL PRIVILEGES ON kvm_test.* TO 'kvm_agent'@'localhost';
|
||||
GRANT ALL PRIVILEGES ON kvm_test.* TO 'kvm_server'@'localhost';
|
||||
```
|
||||
|
||||
Tests `setUp` creates tables; `tearDown` truncates them.
|
||||
Python tests use `PyMySQL`; Go tests use GORM with `kvm_test` DSN.
|
||||
|
||||
### 3.4 Python connector
|
||||
|
||||
**PyMySQL** (pure Python, zero C extensions → PyInstaller compatible).
|
||||
Replace all `import sqlite3` with `import pymysql`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Service Ownership Boundaries
|
||||
|
||||
Each service has **exclusive write access** to its own data; all other services read-only.
|
||||
|
||||
| Service | Writes | Reads (read-only) | External deps |
|
||||
|---------|--------|-------------------|--------------|
|
||||
| `kvm-server` | `state.json` (atomic rename) | `kvm.privacy_audit_log`, `kvm.agent_tasks` | MariaDB |
|
||||
| `kvm-mitm` | `kvm.privacy_audit_log`, `/etc/kvm-privacy/ca/` | `state.json` | MariaDB, info-privacy :8001 |
|
||||
| `kvm-agent` | `kvm.agent_tasks` | KVM API :8080, LLM API | MariaDB, mem-bridge :8003 (soft) |
|
||||
|
||||
`state.json` stays as a **file** (hot path: mitmproxy reads it per-request; file page-cache read is ~10× faster than a MariaDB round-trip). Go writes it with atomic `rename`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Package B — privacy_api Moved into Go kvm-server
|
||||
|
||||
Remove the `/api/v1/privacy/*` reverse-proxy route.
|
||||
Add `go/internal/api/privacy_handler.go` using the existing GORM `*gorm.DB`:
|
||||
|
||||
| Endpoint | Method | Action |
|
||||
|----------|--------|--------|
|
||||
| `/api/v1/privacy/mode` | GET | Read `state.json` |
|
||||
| `/api/v1/privacy/mode` | POST/PUT | Validate mode, atomic-write `state.json` |
|
||||
| `/api/v1/privacy/stats` | GET | Aggregate query on `kvm.privacy_audit_log` |
|
||||
| `/api/v1/privacy/audit` | GET | Paginated query on `kvm.privacy_audit_log` |
|
||||
| `/api/v1/privacy/cert` | GET | Read `/etc/kvm-privacy/ca/ca.crt` (503 if not yet generated) |
|
||||
|
||||
`/api/v1/agent/*` proxy to :8890 **unchanged**.
|
||||
|
||||
---
|
||||
|
||||
## 6. Package C — PyInstaller Binaries
|
||||
|
||||
### 6.1 kvm-mitm
|
||||
|
||||
**Key change:** replace `-s addon.py` file-load with programmatic API so all Python source is compiled into the binary:
|
||||
|
||||
```python
|
||||
# services/privacy_gateway/mitm_launcher.py (new entry point)
|
||||
from mitmproxy.tools.dump import DumpMaster
|
||||
from mitmproxy import options
|
||||
from privacy_gateway.addon import PrivacyGatewayAddon # compiled in
|
||||
import asyncio, signal, sys
|
||||
|
||||
async def main():
|
||||
opts = options.Options(
|
||||
listen_host="0.0.0.0", listen_port=8888,
|
||||
mode="regular", ssl_insecure=True,
|
||||
)
|
||||
master = DumpMaster(opts, with_termlog=False, with_dumper=False)
|
||||
master.addons.add(PrivacyGatewayAddon())
|
||||
loop = asyncio.get_event_loop()
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
loop.add_signal_handler(sig, master.shutdown)
|
||||
await master.run()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
Build command:
|
||||
```bash
|
||||
pyinstaller --onefile \
|
||||
--collect-all mitmproxy \
|
||||
--hidden-import pymysql \
|
||||
--name kvm-mitm \
|
||||
services/privacy_gateway/mitm_launcher.py
|
||||
# → dist/kvm-mitm (~120 MB raw, ~40 MB xz in deb)
|
||||
```
|
||||
|
||||
Installed to: `/usr/bin/kvm-mitm`
|
||||
|
||||
### 6.2 kvm-agent
|
||||
|
||||
`kvm_agent/__main__.py` is already a clean CLI entry point (`run` / `daemon` / `queue` subcommands).
|
||||
|
||||
Build command:
|
||||
```bash
|
||||
pyinstaller --onefile \
|
||||
--hidden-import kvm_agent.actions \
|
||||
--hidden-import kvm_agent.workflows \
|
||||
--hidden-import pymysql \
|
||||
--name kvm-agent \
|
||||
services/kvm_agent/__main__.py
|
||||
# → dist/kvm-agent (~80 MB raw, ~25 MB xz in deb)
|
||||
```
|
||||
|
||||
Installed to: `/usr/bin/kvm-agent`
|
||||
|
||||
---
|
||||
|
||||
## 7. Credential Externalisation
|
||||
|
||||
Sensitive values removed from `.service` files.
|
||||
Each service reads an `EnvironmentFile`:
|
||||
|
||||
| File | Owner | Permissions | Contents |
|
||||
|------|-------|-------------|----------|
|
||||
| `/etc/kvm-privacy/secrets.env` | root | 600 | `KVM_MITM_DB_PASS=...` |
|
||||
| `/etc/kvm-agent/secrets.env` | root | 600 | `KVM_JWT_TOKEN=...`, `LLM_API_KEY=...`, `KVM_AGENT_DB_PASS=...` |
|
||||
|
||||
`deb postinst` creates empty template files with correct permissions if absent.
|
||||
|
||||
---
|
||||
|
||||
## 8. Robustness Improvements
|
||||
|
||||
| Issue | Fix | Where |
|
||||
|-------|-----|-------|
|
||||
| SQLite multi-process write contention | Replaced by MariaDB | audit_logger.py, runner.py |
|
||||
| state.json TOCTOU | Go atomic `rename` write | privacy_handler.go |
|
||||
| Credentials in service files | EnvironmentFile (600) | all .service files |
|
||||
| Unlimited restart loop | `StartLimitInterval=300; StartLimitBurst=5` | all .service files |
|
||||
| kvm-agent: memory unavailable at start | Exponential-backoff reconnect in `memory_client.py` | memory_client.py |
|
||||
| CA cert generation blocks startup | Background generation; `/cert` returns 503 until ready | cert_manager.py |
|
||||
| info-privacy down → silent pass-through | Explicit degradation: audit→log `scan_failed`, redact→block upload | addon.py |
|
||||
|
||||
---
|
||||
|
||||
## 9. systemd Service Files (final)
|
||||
|
||||
### kvm-mitm.service
|
||||
```ini
|
||||
[Unit]
|
||||
Description=KVM Privacy MITM Interceptor
|
||||
After=network.target mariadb.service info-privacy.service
|
||||
Wants=info-privacy.service
|
||||
StartLimitInterval=300
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
EnvironmentFile=/etc/kvm-privacy/secrets.env
|
||||
ExecStart=/usr/bin/kvm-mitm
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
MemoryMax=768M
|
||||
LimitNOFILE=8192
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### kvm-agent.service
|
||||
```ini
|
||||
[Unit]
|
||||
Description=KVM AI Agent v2
|
||||
After=network.target mariadb.service
|
||||
Wants=mem-bridge-memory.service
|
||||
StartLimitInterval=300
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
EnvironmentFile=/etc/kvm-agent/secrets.env
|
||||
ExecStart=/usr/bin/kvm-agent daemon
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
MemoryMax=512M
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Build Script
|
||||
|
||||
`scripts/build-python-debs.sh` (new):
|
||||
|
||||
```
|
||||
1. Pre-flight: verify pyinstaller, pymysql installed; verify MariaDB reachable
|
||||
2. Build kvm-mitm binary (pyinstaller)
|
||||
3. Build kvm-agent binary (pyinstaller)
|
||||
4. Assemble kvm-mitm deb:
|
||||
- DEBIAN/{control, postinst, prerm, postrm, conffiles}
|
||||
- usr/bin/kvm-mitm
|
||||
- lib/systemd/system/kvm-mitm.service
|
||||
- etc/kvm-privacy/ (directory, postinst sets perms)
|
||||
5. Assemble kvm-agent deb:
|
||||
- DEBIAN/{control, postinst, prerm, postrm, conffiles}
|
||||
- usr/bin/kvm-agent
|
||||
- lib/systemd/system/kvm-agent.service
|
||||
- etc/kvm-agent/agent.yaml (conffile)
|
||||
6. dpkg-deb --build for each
|
||||
7. Output → build/deb/
|
||||
```
|
||||
|
||||
`kvm-server` rebuild uses existing `scripts/build-deb.sh` with `--version 1.0.0-3`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Installation Sequence
|
||||
|
||||
```bash
|
||||
# 1. Install/upgrade kvm-server (creates MariaDB users + tables in postinst)
|
||||
sudo dpkg -i kvm-server_1.0.0-3_arm64.deb
|
||||
|
||||
# 2. Install kvm-mitm
|
||||
sudo dpkg -i kvm-mitm_1.0.0-1_arm64.deb
|
||||
|
||||
# 3. Install kvm-agent
|
||||
sudo dpkg -i kvm-agent_1.0.0-1_arm64.deb
|
||||
|
||||
# 4. Populate secrets files (one-time, per deployment)
|
||||
sudo editor /etc/kvm-privacy/secrets.env
|
||||
sudo editor /etc/kvm-agent/secrets.env
|
||||
|
||||
# 5. Start new services
|
||||
sudo systemctl start kvm-mitm kvm-agent
|
||||
|
||||
# 6. Verify
|
||||
systemctl status kvm-server kvm-mitm kvm-agent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Files Changed / Created
|
||||
|
||||
### New files
|
||||
- `go/internal/api/privacy_handler.go`
|
||||
- `go/internal/api/privacy_handler_test.go`
|
||||
- `services/privacy_gateway/mitm_launcher.py`
|
||||
- `scripts/build-python-debs.sh`
|
||||
- `debian/kvm-mitm/` (DEBIAN control tree)
|
||||
- `debian/kvm-agent/` (DEBIAN control tree)
|
||||
|
||||
### Modified files
|
||||
- `go/internal/api/router.go` — remove privacy/* proxy, add privacy handler
|
||||
- `services/privacy_gateway/audit_logger.py` — sqlite3 → PyMySQL
|
||||
- `services/privacy_gateway/addon.py` — explicit scan_failed degradation
|
||||
- `services/privacy_gateway/cert_manager.py` — non-blocking background generation
|
||||
- `services/kvm_agent/runner.py` — sqlite3 → PyMySQL
|
||||
- `services/kvm_agent/config.py` — `queue_db_path` → MariaDB DSN fields
|
||||
- `services/kvm_agent/configs/agent.yaml` — update DB config
|
||||
- `services/kvm_agent/memory_client.py` — exponential-backoff reconnect
|
||||
- `services/kvm_agent/tests/test_runner*.py` — SQLite tmp → MariaDB kvm_test
|
||||
- `deploy/systemd/kvm-agent.service` — EnvironmentFile, StartLimit
|
||||
- `deps/KVM/debian/kvm-server/DEBIAN/postinst` — add privacy_audit_log, agent_tasks DDL + user grants
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
# 导航权限收紧 + 用户管理卡片布局设计
|
||||
|
||||
Date: 2026-03-04
|
||||
|
||||
## 背景
|
||||
|
||||
1. 导航栏 Privacy 和 AI Agent 链接对 operator/viewer 角色无条件可见,与后端 RBAC 策略不一致。
|
||||
2. 用户管理 Tab(SettingsPage → Users)使用传统表格布局,在 800px 容器内横向溢出,操作按钮文字冗长。
|
||||
|
||||
## 问题一:导航权限控制
|
||||
|
||||
### 现状
|
||||
```
|
||||
审计中心 → auditor | security_admin | system_admin ✅(已控制)
|
||||
隐私保护 → 所有人可见 ❌
|
||||
AI Agent → 所有人可见 ❌
|
||||
```
|
||||
|
||||
### 方案:扩展现有角色判断
|
||||
|
||||
在 `Layout.tsx` 增加 `isPrivileged` 变量(auditor/security_admin/system_admin),复用于三个受控链接:
|
||||
|
||||
```tsx
|
||||
const isPrivileged = user?.role_name === 'auditor'
|
||||
|| user?.role_name === 'security_admin'
|
||||
|| user?.role_name === 'system_admin'
|
||||
|
||||
// 审计、隐私保护、AI Agent 均用 isPrivileged 控制
|
||||
{isPrivileged && <Link to="/audit">...</Link>}
|
||||
{isPrivileged && <Link to="/privacy">...</Link>}
|
||||
{isPrivileged && <Link to="/agent">...</Link>}
|
||||
```
|
||||
|
||||
**改动范围:** `Layout.tsx` 仅重构现有条件判断,不增加新逻辑。
|
||||
|
||||
## 问题二:用户管理卡片布局(方案 A)
|
||||
|
||||
### 目标
|
||||
- 替换宽表格为响应式卡片网格
|
||||
- 每张卡片信息完整、操作直观
|
||||
- 与 AuditPage 录像卡片风格一致
|
||||
|
||||
### 卡片设计
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ [AB] admin [自己] │ ← 头像(角色色)+ 用户名 + 自己标记
|
||||
│ system_admin 角色徽章 │ ← 角色
|
||||
│ ─────────────────────────────── │
|
||||
│ 2FA: 已启用 状态: 正常 │ ← 状态行
|
||||
│ 最后登录: 2026-03-04 09:11 │ ← 时间
|
||||
│ ─────────────────────────────── │
|
||||
│ [禁用] [解锁] [删除] │ ← 操作(非自身才显示)
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 布局规则
|
||||
- 容器:`display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr))`
|
||||
- 头像:40×40px 圆形,背景色取角色色(蓝/橙/绿/灰),文字为用户名首字母大写
|
||||
- 操作按钮:图标(SVG inline)+ 短文字,hover 显色
|
||||
|
||||
### 文件改动
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `components/settings/UserManagementTab.tsx` | JSX 重构为卡片网格,state/函数不变 |
|
||||
| `pages/UsersPage.css` | 新增卡片 CSS(`.user-card`, `.user-avatar`, `.user-card-grid`) |
|
||||
| `components/Layout.tsx` | Privacy/Agent 链接添加 isPrivileged 条件 |
|
||||
|
||||
## 验证
|
||||
1. `npm run build` 无 TypeScript 错误
|
||||
2. operator 登录 → 导航栏无 Privacy/Audit/Agent 链接
|
||||
3. system_admin 登录 → Settings > Users Tab 显示卡片布局
|
||||
4. 卡片操作功能:启用/禁用/解锁/删除正常工作
|
||||
@@ -0,0 +1,502 @@
|
||||
# 导航权限收紧 + 用户管理卡片布局实现计划
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** operator/viewer 角色不可见 Privacy/Audit/Agent 导航链接;用户管理 Tab 表格替换为响应式卡片网格。
|
||||
|
||||
**Architecture:** 纯前端改动,不涉及后端路由或 RBAC。Layout.tsx 提取 `isPrivileged` 复用于三个受控链接;UserManagementTab.tsx 仅替换 JSX render 层(state/函数完全不变);新增卡片 CSS 到 UsersPage.css。
|
||||
|
||||
**Tech Stack:** React 18 + TypeScript + CSS variables(项目既有 `--primary`, `--warning`, `--success`, `--danger`, `--border`, `--bg-gray`, `--bg-dark`, `--text-primary`, `--text-secondary`)
|
||||
|
||||
---
|
||||
|
||||
### Task 1:Layout.tsx 导航权限收紧
|
||||
|
||||
**Files:**
|
||||
- Modify: `deps/KVM/web/src/components/Layout.tsx:33-37`
|
||||
|
||||
**Step 1:修改 Layout.tsx**
|
||||
|
||||
将原来三处条件替换为共享 `isPrivileged` 常量:
|
||||
|
||||
```tsx
|
||||
// 在 const { user, logout } = useAuthStore() 之后添加
|
||||
const isPrivileged = user?.role_name === 'auditor'
|
||||
|| user?.role_name === 'security_admin'
|
||||
|| user?.role_name === 'system_admin'
|
||||
```
|
||||
|
||||
然后将导航菜单改为:
|
||||
|
||||
```tsx
|
||||
<div className="navbar-menu">
|
||||
<Link to="/" className="nav-link">{t('nav.console')}</Link>
|
||||
<Link to="/terminal" className="nav-link">{t('nav.terminal')}</Link>
|
||||
<Link to="/settings" className="nav-link">{t('nav.settings')}</Link>
|
||||
{isPrivileged && (
|
||||
<Link to="/audit" className="nav-link">{t('nav.audit')}</Link>
|
||||
)}
|
||||
{isPrivileged && (
|
||||
<Link to="/privacy" className="nav-link">{t('nav.privacy')}</Link>
|
||||
)}
|
||||
{isPrivileged && (
|
||||
<Link to="/agent" className="nav-link">{t('nav.agent')}</Link>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Step 2:编译验证**
|
||||
|
||||
```bash
|
||||
cd deps/KVM/web && npm run build 2>&1 | tail -5
|
||||
```
|
||||
|
||||
期望:`✓ built in` 无 TypeScript 错误
|
||||
|
||||
**Step 3:提交**
|
||||
|
||||
```bash
|
||||
git add deps/KVM/web/src/components/Layout.tsx
|
||||
git commit -m "feat: restrict privacy/audit/agent nav links to privileged roles"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2:UsersPage.css 新增卡片样式
|
||||
|
||||
**Files:**
|
||||
- Modify: `deps/KVM/web/src/pages/UsersPage.css`(在文件末尾追加)
|
||||
|
||||
**Step 1:在 UsersPage.css 末尾追加以下 CSS**
|
||||
|
||||
```css
|
||||
/* ── User Card Grid ── */
|
||||
.user-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.user-card {
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.user-card:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Header: avatar + name */
|
||||
.user-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.user-avatar.role-system_admin { background: rgba(59,130,246,0.2); color: var(--primary); }
|
||||
.user-avatar.role-security_admin { background: rgba(245,158,11,0.2); color: var(--warning); }
|
||||
.user-avatar.role-auditor { background: rgba(16,185,129,0.2); color: var(--success); }
|
||||
.user-avatar.role-operator { background: rgba(156,163,175,0.2); color: var(--text-secondary); }
|
||||
|
||||
.user-card-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-card-username {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Role row */
|
||||
.user-card-role {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-card-role select {
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Meta: 2FA + status + last login */
|
||||
.user-card-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.user-card-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.user-card-meta-row .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-card-meta-row .dot.enabled { background: var(--success); }
|
||||
.user-card-meta-row .dot.disabled { background: var(--danger); }
|
||||
|
||||
/* Actions */
|
||||
.user-card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.user-card-actions .btn-sm {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Empty state for card grid */
|
||||
.user-card-empty {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 40px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2:编译验证(CSS 无 TS 类型检查,只需确认无语法破坏)**
|
||||
|
||||
```bash
|
||||
cd deps/KVM/web && npm run build 2>&1 | tail -5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3:UserManagementTab.tsx 卡片 JSX 重构
|
||||
|
||||
**Files:**
|
||||
- Modify: `deps/KVM/web/src/components/settings/UserManagementTab.tsx`
|
||||
|
||||
只替换 `return (...)` 中的表格部分,**所有 state、函数、import 完全不变**。
|
||||
|
||||
**Step 1:将 UserManagementTab.tsx 的 return 块替换为以下内容**
|
||||
|
||||
```tsx
|
||||
return (
|
||||
<div className="users-container">
|
||||
<div className="users-header">
|
||||
<h2>{t('users.title')}</h2>
|
||||
<button className="btn-primary" onClick={() => setShowCreate(true)}>
|
||||
{t('users.create')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-error">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="alert-close">×</button>
|
||||
</div>
|
||||
)}
|
||||
{message && (
|
||||
<div className="alert alert-success">
|
||||
{message}
|
||||
<button onClick={() => setMessage('')} className="alert-close">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create User Form — overlay 不变 */}
|
||||
{showCreate && (
|
||||
<div className="create-form-overlay">
|
||||
<div className="create-form">
|
||||
<h3>{t('users.create_title')}</h3>
|
||||
<form onSubmit={handleCreate}>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>{t('users.username')} *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newUser.username}
|
||||
onChange={(e) => setNewUser({ ...newUser, username: e.target.value })}
|
||||
placeholder={t('users.username_placeholder')}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('users.password')} * ({t('users.password_hint')})</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newUser.password}
|
||||
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
|
||||
placeholder={t('users.password_placeholder')}
|
||||
required
|
||||
minLength={12}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>{t('users.display_name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newUser.display_name}
|
||||
onChange={(e) => setNewUser({ ...newUser, display_name: e.target.value })}
|
||||
placeholder={t('users.display_name_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('users.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={newUser.email}
|
||||
onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
|
||||
placeholder={t('users.email_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('users.role')}</label>
|
||||
<select
|
||||
value={newUser.role_name}
|
||||
onChange={(e) => setNewUser({ ...newUser, role_name: e.target.value })}
|
||||
>
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<option key={role} value={role}>{ROLE_LABELS[role]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-secondary" onClick={() => setShowCreate(false)}>
|
||||
{t('users.cancel')}
|
||||
</button>
|
||||
<button type="submit" className="btn-primary">
|
||||
{t('users.submit_create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Card Grid */}
|
||||
{loading ? (
|
||||
<div className="loading">{t('users.loading')}</div>
|
||||
) : (
|
||||
<div className="user-card-grid">
|
||||
{users.length === 0 ? (
|
||||
<div className="user-card-empty">{t('users.no_data')}</div>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<div key={user.id} className="user-card">
|
||||
|
||||
{/* Header: avatar + username + self badge */}
|
||||
<div className="user-card-header">
|
||||
<div className={`user-avatar role-${user.role_name}`}>
|
||||
{user.username.charAt(0)}
|
||||
</div>
|
||||
<div className="user-card-name">
|
||||
<span className="user-card-username">
|
||||
{user.username}
|
||||
{user.id === currentUser?.id && (
|
||||
<span className="badge-self">{t('users.current_user')}</span>
|
||||
)}
|
||||
</span>
|
||||
{user.display_name && (
|
||||
<span style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
|
||||
{user.display_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role — click to edit (non-self) */}
|
||||
<div className="user-card-role">
|
||||
{editingRole === user.id ? (
|
||||
<select
|
||||
value={user.role_name}
|
||||
onChange={(e) => handleSetRole(user.id, e.target.value)}
|
||||
onBlur={() => setEditingRole(null)}
|
||||
autoFocus
|
||||
>
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<option key={role} value={role}>{ROLE_LABELS[role]}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span
|
||||
className={`role-badge role-${user.role_name}`}
|
||||
onClick={() => user.id !== currentUser?.id && setEditingRole(user.id)}
|
||||
title={user.id !== currentUser?.id ? t('users.click_edit_role') : ''}
|
||||
style={{ cursor: user.id !== currentUser?.id ? 'pointer' : 'default' }}
|
||||
>
|
||||
{ROLE_LABELS[user.role_name] || user.role_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meta: 2FA / status / last login */}
|
||||
<div className="user-card-meta">
|
||||
<div className="user-card-meta-row">
|
||||
<span className={`dot ${user.totp_enabled ? 'enabled' : 'disabled'}`} />
|
||||
<span>2FA: {user.totp_enabled ? t('users.totp_enabled') : t('users.totp_disabled')}</span>
|
||||
</div>
|
||||
<div className="user-card-meta-row">
|
||||
<span className={`dot ${user.enabled ? 'enabled' : 'disabled'}`} />
|
||||
<span>{user.enabled ? t('users.status_active') : t('users.status_disabled')}</span>
|
||||
</div>
|
||||
<div className="user-card-meta-row">
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: '11px' }}>
|
||||
{user.last_login_at
|
||||
? new Date(user.last_login_at).toLocaleString()
|
||||
: t('users.never_logged_in')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions (non-self only) */}
|
||||
{user.id !== currentUser?.id && (
|
||||
<div className="user-card-actions">
|
||||
<button
|
||||
className={`btn-sm ${user.enabled ? 'btn-toggle' : 'btn-unlock'}`}
|
||||
onClick={() => handleToggleEnabled(user)}
|
||||
title={user.enabled ? t('users.action_disable') : t('users.action_enable')}
|
||||
>
|
||||
{user.enabled ? t('users.action_disable') : t('users.action_enable')}
|
||||
</button>
|
||||
<button
|
||||
className="btn-sm btn-unlock"
|
||||
onClick={() => handleUnlock(user.id)}
|
||||
title={t('users.action_unlock')}
|
||||
>
|
||||
{t('users.action_unlock')}
|
||||
</button>
|
||||
<button
|
||||
className="btn-sm btn-delete"
|
||||
onClick={() => handleDelete(user)}
|
||||
title={t('users.action_delete')}
|
||||
>
|
||||
{t('users.action_delete')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
```
|
||||
|
||||
**Step 2:编译验证**
|
||||
|
||||
```bash
|
||||
cd deps/KVM/web && npm run build 2>&1 | tail -8
|
||||
```
|
||||
|
||||
期望:`✓ built in` 无 TypeScript 错误
|
||||
|
||||
**Step 3:提交**
|
||||
|
||||
```bash
|
||||
git add deps/KVM/web/src/components/settings/UserManagementTab.tsx \
|
||||
deps/KVM/web/src/pages/UsersPage.css
|
||||
git commit -m "feat: user management card grid layout in settings tab"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4:全量构建并安装 deb
|
||||
|
||||
**Step 1:递增 changelog 版本到 1.0.0-6**
|
||||
|
||||
在 `deps/KVM/debian/changelog` 顶部插入:
|
||||
|
||||
```
|
||||
kvm-server (1.0.0-6) unstable; urgency=medium
|
||||
|
||||
* Restrict privacy/audit/agent nav to privileged roles (auditor+)
|
||||
* User management: replace table with responsive card grid
|
||||
|
||||
-- KVM Project <kvm@example.com> Tue, 04 Mar 2026 12:00:00 +0000
|
||||
```
|
||||
|
||||
**Step 2:构建 deb**
|
||||
|
||||
```bash
|
||||
cd /data/project/KVM-privacy/deps/KVM && PATH="/usr/local/go/bin:$PATH" bash scripts/build-deb.sh 2>&1 | tail -20
|
||||
```
|
||||
|
||||
期望:`kvm-server_1.0.0-6_arm64.deb` 生成
|
||||
|
||||
**Step 3:安装**
|
||||
|
||||
```bash
|
||||
sudo dpkg -i /data/project/KVM-privacy/deps/KVM/build/deb/kvm-server_1.0.0-6_arm64.deb
|
||||
```
|
||||
|
||||
**Step 4:验证服务**
|
||||
|
||||
```bash
|
||||
systemctl is-active kvm-server && curl -sf http://localhost:8080/api/health
|
||||
```
|
||||
|
||||
期望:`active` + `{"status":"ok"}`
|
||||
|
||||
---
|
||||
|
||||
### 验证清单
|
||||
|
||||
1. `npm run build` TypeScript 无错误
|
||||
2. operator 登录 → 导航栏只显示:Console / Terminal / Settings
|
||||
3. system_admin 登录 → 导航栏显示全部链接;Settings > Users Tab 为卡片布局
|
||||
4. 卡片头像字母色 = 对应角色色(蓝/橙/绿/灰)
|
||||
5. 点击角色徽章 → 出现下拉切换(非自身)
|
||||
6. 禁用/解锁/删除按钮功能正常
|
||||
7. 创建用户 overlay 正常打开/关闭
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,139 @@
|
||||
# 设计文档:WebRTC 稳定性增强 + 虚拟媒体存储
|
||||
|
||||
日期:2026-03-04
|
||||
分支:feature/enhance-testing-security-states
|
||||
|
||||
---
|
||||
|
||||
## 一、WebRTC 稳定性(方案 B)
|
||||
|
||||
### 根因
|
||||
|
||||
| 问题 | 文件 | 当前值 | 目标值 |
|
||||
|------|------|--------|--------|
|
||||
| GStreamer PLI 是空函数 | gstreamer/pipeline.go:1007 | no-op | 切 native 管道 |
|
||||
| WS 心跳间隔过长 | signaling/websocket.go | 27s ping / 30s pong | 10s / 15s |
|
||||
| stale 检测阈值过长 | useWebRTC.ts:182 | 8 次×2s=16s | 4 次×2s=8s |
|
||||
| disconnected 超时过长 | useWebRTC.ts:248 | 3000ms | 1000ms |
|
||||
| track.onmute/onended 无监听 | useWebRTC.ts | 无 | 即时触发重连 |
|
||||
| framesDecoded 无冻结检测 | useWebRTC.ts | 无 | bytes↑但 frames 停滞→重连 |
|
||||
| WriteRTP 错误无处理 | webrtc/server.go | 忽略 | 日志+移除死 peer |
|
||||
|
||||
### 方案
|
||||
|
||||
#### 1. 切换 native 视频管道(解决 PLI 无响应)
|
||||
- `deps/KVM/config/kvm-default.json`:`pipeline_backend` 改为 `"native"`
|
||||
- native 管道已实现,`ForceIDR()` 通过 MPP 即时触发,PLI→IDR 延迟 <1ms
|
||||
|
||||
#### 2. 调优心跳参数(Go)
|
||||
- `signaling/websocket.go`:
|
||||
- `pongWait` 30s → 15s
|
||||
- `pingPeriod` 27s → 10s(= pongWait × 2/3)
|
||||
- `writeWait` 10s 不变
|
||||
|
||||
#### 3. 前端稳定性增强(TypeScript)
|
||||
- `useWebRTC.ts`:
|
||||
- stale 阈值:`>= 8` → `>= 4`(8s)
|
||||
- disconnected timeout:3000 → 1000ms
|
||||
- `ontrack` 回调中监听 `track.onmute` 和 `track.onended` → 立即调用 `scheduleReconnect`
|
||||
- stats 循环中增加 framesDecoded 冻结检测:当 bytesDelta > 0 但 framesDelta === 0 连续 4 次,触发重连
|
||||
|
||||
#### 4. WriteRTP 错误处理(Go)
|
||||
- `webrtc/server.go` `WriteRTP` 方法:错误时 log + 收集死 peer ID,写完后统一移除
|
||||
|
||||
---
|
||||
|
||||
## 二、虚拟媒体存储(USB Mass Storage Gadget)
|
||||
|
||||
### 架构
|
||||
|
||||
```
|
||||
用户浏览器
|
||||
↕ 上传/管理 ISO 文件(multipart)
|
||||
Go API (/api/kvm/storage/*)
|
||||
↕ 读写 configfs
|
||||
Linux USB Gadget (mass_storage.0)
|
||||
↕ USB OTG (fe800000.usb)
|
||||
被控机(Target PC 看到 USB CD-ROM/磁盘)
|
||||
```
|
||||
|
||||
### 核心设计决策
|
||||
|
||||
**预置 mass_storage 功能(空文件状态)**:gadget 初始化时就创建 `functions/mass_storage.0`,`lun.0/file` 为空(被控机看到"无介质")。挂载时只写文件路径,**无需重绑 UDC,HID 不中断**。
|
||||
|
||||
**媒体目录**:`/var/lib/kvm/media/`(persistent,随 deb 创建)
|
||||
|
||||
**文件类型**:
|
||||
- `.iso` → 自动 cdrom=true, ro=true
|
||||
- `.img` → cdrom=false, ro=false(可写磁盘)
|
||||
|
||||
### 组件
|
||||
|
||||
#### A. 脚本层
|
||||
`setup-usb-gadget.sh` 追加:
|
||||
```bash
|
||||
modprobe usb_f_mass_storage 2>/dev/null || true
|
||||
mkdir -p functions/mass_storage.0
|
||||
echo 1 > functions/mass_storage.0/stall
|
||||
echo 1 > functions/mass_storage.0/lun.0/removable
|
||||
echo "" > functions/mass_storage.0/lun.0/file # 初始无介质
|
||||
ln -s functions/mass_storage.0 configs/c.1/
|
||||
```
|
||||
|
||||
#### B. Go 层
|
||||
新包 `go/internal/storage/manager.go`:
|
||||
```
|
||||
StorageManager
|
||||
├── Mount(filename, cdrom) error // 写 lun.0/file
|
||||
├── Unmount() error // 写空字符串
|
||||
├── Status() StorageStatus // 读 lun.0/file
|
||||
└── ListImages() []ImageInfo // 扫描 /var/lib/kvm/media/
|
||||
```
|
||||
|
||||
新文件 `go/internal/api/storage_handler.go`:
|
||||
```
|
||||
GET /api/kvm/storage/status → 当前挂载状态
|
||||
POST /api/kvm/storage/mount → { filename, cdrom }
|
||||
POST /api/kvm/storage/unmount → 弹出
|
||||
GET /api/kvm/storage/images → 镜像列表
|
||||
POST /api/kvm/storage/upload → multipart 上传(限 8GB)
|
||||
DELETE /api/kvm/storage/images/{name} → 删除镜像
|
||||
```
|
||||
|
||||
`router.go` 注册 6 条路由(withAuth 保护)。
|
||||
|
||||
#### C. 前端层
|
||||
- `web/src/services/api/kvm.ts`:新增 `storageAPI` 方法组
|
||||
- `web/src/stores/storageStore.ts`:zustand store,管理 status + images 列表
|
||||
- `web/src/components/VirtualMediaModal.tsx`:
|
||||
- 镜像列表(文件名、大小、类型、挂载按钮)
|
||||
- 拖拽/点击上传区(进度条)
|
||||
- 当前挂载状态 badge
|
||||
- 弹出按钮
|
||||
- `web/src/components/QuickActions.tsx`:
|
||||
- 移除 `disabled`,onClick 打开 VirtualMediaModal
|
||||
- "挂载 ISO" / "挂载 CD-ROM" / "弹出媒体" 按钮连接 store
|
||||
|
||||
### 安全考虑
|
||||
|
||||
- 上传文件名: 仅允许 `[a-zA-Z0-9_\-.]`,拒绝路径穿越(`../`)
|
||||
- 文件大小: 上限 8GB(multipart limit)
|
||||
- 仅允许 `.iso` 和 `.img` 扩展名
|
||||
- RBAC: 存储操作要求 `operator` 以上角色
|
||||
|
||||
---
|
||||
|
||||
## 三、实现顺序(单次部署)
|
||||
|
||||
1. native pipeline 配置切换(1个文件)
|
||||
2. Go 心跳参数(1个文件)
|
||||
3. Go WriteRTP 错误处理(1个文件)
|
||||
4. 前端 useWebRTC 4项修复(1个文件)
|
||||
5. setup-usb-gadget.sh 追加 mass_storage
|
||||
6. Go StorageManager(新包)
|
||||
7. Go StorageHandler(新文件)
|
||||
8. router.go 注册路由
|
||||
9. main.go 初始化 StorageManager
|
||||
10. 前端 kvmAPI + storageStore + VirtualMediaModal
|
||||
11. QuickActions 激活按钮
|
||||
12. deb 打包安装(changelog → 1.0.0-7)
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user