Author SHA1 Message Date
qiuruiandClaude Opus 4.6 598c313e46 fix: add "context" key to /memory/context response for Python compatibility
MemoryClient.get_context() reads resp["context"] but the Rust API
was only returning "formatted". Add "context" as an alias field so
both Python callers (kvm_agent MemoryClient and workflow-dashboard
memory_service) work without changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 09:52:07 +00:00
qiuruiandClaude Opus 4.6 fa7e2cefa1 feat: add api crate with dual-port axum servers (Phase 8B-6)
Memory API (port 8001): turn CRUD, fact CRUD, context compression,
semantic search, session management, and admin stats endpoints.
Router API (port 8002): OpenAI-compatible chat completions with
SSE streaming support and /v1/models endpoint.

Includes binary entry point, YAML config loading, CORS support,
and 7 integration tests covering all major endpoints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 09:42:37 +00:00
qiuruiandClaude Opus 4.6 dbb8a93fa3 feat: add router crate for embed-db-rs (Phase 8B-5)
LLM routing with complexity scoring, backend fallback chains, and SSE
streaming. Replaces Python mem_bridge router_service/backends/complexity.

- ComplexityScorer: keyword + length heuristics (no embedding dependency)
- LlmBackend trait + OpenAICompatibleBackend (openai/compatible/ollama)
- FallbackChain: per-backend timeout with ordered fallback
- RoutingRules: task-type mapping + complexity-based routing
- SSE stream parsing for chat_stream responses
- 11 tests covering all modules

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 09:30:11 +00:00
qiuruiandClaude Opus 4.6 da8ecdce42 feat: add memory crate for embed-db-rs (Phase 8B-4)
TurnManager + FactManager + Compressor + time decay:
- TurnManager: embed → USearch add → SQLite store, semantic search
- FactManager: fact CRUD with hybrid (semantic + BM25) search
- Compressor: 5-layer priority token budget (facts 25%, long 25%, short 35%)
- Time decay: exp(-lambda * age_hours), default lambda=0.05
- Token counting: len/4 approximation
- 18 tests passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 09:24:28 +00:00
qiuruiandClaude Opus 4.6 cd00c79262 feat: add search crate for embed-db-rs (Phase 8B-3)
Port BM25Index from PyO3 (deps/embedding/embed_rs/src/bm25.rs) to pure
Rust API with tantivy 0.22, and add HybridScorer for fusing semantic
and BM25 search results. All 12 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 09:08:18 +00:00
qiuruiandClaude Opus 4.6 48243bdf6b feat: add meta-store crate for embed-db-rs (Phase 8B-2)
SQLite-backed metadata storage with 3 tables (chunks, turns, facts)
and FTS5 full-text search. Replaces Python store.py, turn_store.py,
and fact_store.py with type-safe Rust equivalents. All 20 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 09:02:26 +00:00
qiuruiandClaude Opus 4.6 a10d29748a feat: add embedder crate for embed-db-rs (Phase 8B-1)
Implement text embedding inference crate with pluggable backend trait,
HuggingFace tokenizer wrapper, mean pooling, and L2 normalization.

- EmbedBackend trait + MockBackend for testing
- OnnxBackend (feature-gated "onnx") using ort with load-dynamic
- Tokenizer wrapper: load from tokenizer.json, padding/truncation
- Embedder: E5-style prefix formatting, batch splitting, L2 normalize
- EmbedConfig: seq_len=128, batch_size=32, emb_dim=384 defaults
- 19 unit tests + 1 doc test, all passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 08:54:40 +00:00
qiuruiandClaude Opus 4.6 e069249512 fix: integrate LLM verifier into proxy handler, remove dead dependency
- Integrate LlmVerifier into scan_and_redact(): when classification is
  sensitive_partial, split entities by confidence, verify low-confidence
  ones via RKLLM, rebuild summary from verified entities
- Pass verifier from proxy.rs process_upload() to scanner
- Remove #[allow(dead_code)] from llm_verifier field
- Remove unused multer dependency from Cargo.toml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 06:30:46 +00:00
qiuruiandClaude Opus 4.6 ec464e3d9e feat: add Rust Privacy Gateway (Phase 6 — hudsucker)
Rust replacement for the Python mitmproxy-based Privacy Gateway.
Uses hudsucker for transparent HTTPS proxying and axum for the
REST management API.

Modules:
- proxy.rs: hudsucker HttpHandler — intercepts file uploads to AI
  service domains, scans for PII via info-privacy-rs, and
  optionally redacts content before forwarding
- interceptor.rs: manual multipart/form-data parsing and rebuilding
- scanner.rs: PII scanning via info-privacy-rs /api/v1/analyze + redact
- llm_verifier.rs: RKLLM false-positive verification for uncertain
  entity detections
- audit.rs: MariaDB audit logging (sqlx) + KVM backend event posting
- domains.rs: AI domain whitelist (file or built-in defaults)
- state.rs: privacy mode state.json reading/writing (off/audit/redact)
- rest_api.rs: axum REST API on port 8889 (mode, stats, audit, cert)
- config.rs: environment variable configuration with CLI overrides
- main.rs: clap CLI + dual-server startup (proxy 8888 + API 8889)

Degradation rules:
- info-privacy unreachable + audit mode: log scan_failed, allow through
- info-privacy unreachable + redact mode: log scan_failed, BLOCK request
- RKLLM unreachable: use original detection results
- MariaDB unreachable: skip logging, continue processing

Tests: 39 unit tests covering state, domains, multipart parsing,
config, scanner utilities, and LLM verdict parsing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 06:25:17 +00:00
qiuruiandClaude Opus 4.6 e6115fbfd7 fix: address review issues in rkllm-server
- Fix concurrent inference protection: use Arc<Semaphore>(1) to hold
  permit through entire rkllm_run duration in spawn_blocking closure
  (previously Mutex was released before rkllm_run, allowing concurrent calls)
- Add missing FFI structs: RKLLMEmbedInput, RKLLMTokenInput,
  RKLLMMultiModelInput (12/12 structs now match Python ctypes)
- Complete RKLLMInputData union with all 4 variants

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 06:03:23 +00:00
qiuruiandClaude Opus 4.6 b0a3c6d099 chore: add rkllm-server Cargo.lock for reproducible builds
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 05:58:39 +00:00
qiuruiandClaude Opus 4.6 81960f0be7 feat: add Rust RKLLM Server (Phase 5.1)
Implement a Rust replacement for the Python RKLLM Server, providing an
OpenAI-compatible API for Qwen3-0.6B on the RK3588 NPU via librkllmrt.so.

Structure:
- rkllm-sys crate: FFI bindings with dynamic loading via libloading
- backend.rs: async generate/stream with tokio mpsc callback bridge
- chat.rs: ChatML formatting + /v1/chat/completions (streaming SSE)
- config.rs: env-var configuration with model name auto-derivation
- main.rs: axum server with clap CLI and graceful shutdown

Key design decisions:
- libloading for runtime .so loading (no compile-time dependency)
- spawn_blocking for the blocking rkllm_run FFI call
- usize-based pointer passing to satisfy Send bounds across threads
- Box<Sender> as C callback userdata with proper lifecycle management

20 tests passing (ChatML formatting, config parsing, serialization).
Release binary: 2.8MB stripped.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 05:58:32 +00:00
qiuruiandClaude Opus 4.6 0a698aca17 fix: address code review issues in usearch-index crate
- Fix clippy field_reassign_with_default warnings (use struct init syntax)
- Fix broken rustdoc link to `reserve` → `Self::reserve`
- Add duplicate-key handling in add_batch() (upsert semantics, matching add())
- Add comments explaining quantization is read from file in load()/view()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 05:45:26 +00:00
qiuruiandClaude Opus 4.6 40e286f26b chore: add embed-db-rs Cargo.lock for reproducible builds
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 05:39:03 +00:00
qiuruiandClaude Opus 4.6 9dececb78a feat: add embed-db-rs workspace with usearch-index crate (Phase 8A)
Create the embed-db-rs Cargo workspace and usearch-index crate that
wraps USearch HNSW as a FAISS replacement for vector indexing.

Key design decisions:
- Inner product (IP) metric matching existing FAISS behavior
- Configurable quantization (F32/F16/I8) per index type
- Upsert semantics: add() removes existing key before inserting
- Native mmap support via view() for memory-efficient loading
- Re-exports ScalarKind/MetricKind so callers avoid usearch dep

API: new(), add(), add_batch(), search(), remove(), save(), load(),
view(), reserve(), reset(), contains(), len(), capacity()

31 unit tests covering CRUD, persistence, batch ops, quantization
variants, edge cases (zero vectors, large keys, dimension mismatches),
and save/load roundtrip correctness.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 05:38:55 +00:00
qiuruiandClaude Opus 4.6 a8fc01ee8b chore: update info-privacy-rs submodule (EXIF orientation fix)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 06:08:27 +00:00
qiuruiandClaude Opus 4.6 2a28466539 fix: EXIF orientation + image resize for OCR/face workers, fix merge field names
Root cause: camera photos have EXIF Orientation tags (e.g., 180° rotation)
that Rust's `image` crate doesn't auto-apply. Text appears upside-down to
PP-OCRv4, causing 0 detections. Also, 4032×3024 photos downsampled to
480×480 lose too much detail.

Fixes:
- ocr_worker.py: add _preprocess_image() — EXIF transpose + resize to
  960px max edge before uploading to NPU Daemon
- face_worker.py: EXIF transpose before uploading to NPU Daemon
- privacy_pipeline.py: fix _merge_findings() field names — info-privacy-rs
  returns "type"/"value", not "entity_type"/"text"; add bbox/security_level

Verified: user's 1.4MB ID card photo (EXIF Orientation=3) now returns
5 entities (id_card + 3 addresses + face) vs 0 before.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 06:08:11 +00:00
qiuruiandClaude Opus 4.6 05435120a8 fix: deployment issues — KVM auto-login, worker scripts, deb deps
- Dashboard: use kvm_service auto-login instead of hardcoded JWT token
  for /api/services and /api/models (fixes OCRv4 showing offline)
- kvm-privacy deb: bundle ocr_worker.py + face_worker.py scripts
  (fixes info-privacy-rs 500 error on analyze)
- kvm-agent deb: move pip-installed deps from Depends to Recommends
  (fixes dpkg configure failure on python3-httpx etc.)
- Dockerfile: unset proxy env before pip install (fixes container build)
- Add missing rkllm_server source files (__init__, backend, server)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 05:37:56 +00:00
qiuruiandClaude Opus 4.6 b15f1592fd fix: deb install issues found during deployment testing
- kvm-npu: remove hard Depends on librknnrt (not deb-managed)
- kvm-privacy: add surnames.txt to configs, create symlinks in
  postinst so info-privacy-rs finds configs/ relative path
- build-debs.sh: copy surnames.txt alongside pii_rules.yaml
- docker-compose.yml: add no_proxy for host.docker.internal
  (prevents httpx routing local traffic through proxy)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 05:12:00 +00:00
qiuruiandClaude Opus 4.6 5e6b7b1c5c chore: update kvm-meta deps, fix docker-compose, update docs
- kvm-meta: add kvm-npu, kvm-rkllm to Recommends
- docker-compose.yml: fix build context (tools/workflow-dashboard),
  correct PRIVACY_RS_URL port (8000->8001), add NPU_DAEMON_URL
- CLAUDE.md: update service table with DEB package names,
  add npu_daemon to directory structure, update build commands

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 04:45:49 +00:00
qiuruiandClaude Opus 4.6 c27aed5c16 feat: extend build script for full-stack deb packaging
Rename build-python-debs.sh to build-debs.sh (symlink preserved).
Now builds all 7 packages in one run:

- Python: kvm-mitm, kvm-agent, kvm-bridge (existing)
- Rust: kvm-npu, kvm-privacy (cargo build + model copy)
- Mixed: kvm-rkllm (Python + librkllmrt.so)
- Meta: kvm-meta

New --skip-rust flag for quick Python-only rebuilds.
Fix pii_rules.yaml path (configs/ not config/).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 04:45:42 +00:00
qiuruiandClaude Opus 4.6 3adb313fa1 feat: add kvm-npu and fill kvm-privacy deb packages for Rust services
kvm-npu (new):
- debian/kvm-npu/ with control, postinst, prerm
- config.yaml with standard model paths (/usr/share/kvm-npu/models/)
- systemd service with security hardening (ProtectSystem, PrivateTmp)
- Build script copies OCR + face models into package

kvm-privacy (filled):
- Was an empty shell (systemd-only, no binary)
- Add binary install path, pii_rules.yaml config
- New systemd service pointing to /usr/local/bin/info-privacy-rs
- Remove hardcoded /home/pi/Desktop paths from info-privacy.service

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 04:45:35 +00:00
qiuruiandClaude Opus 4.6 8000170221 feat: add kvm-rkllm deb package for RKLLM NPU inference server
- Add pyproject.toml with CLI entry point
- Wrap __main__.py module-level code in main() function
- Update config.py default lib_path to /usr/lib/kvm-rkllm/
- Update systemd service to use deb install paths
- Create debian/kvm-rkllm/ with control, postinst, prerm, wrapper

The package bundles librkllmrt.so (closed-source RK3588 NPU runtime)
alongside Python source. LLM model not included (too large).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 04:45:25 +00:00
qiuruiandClaude Opus 4.6 ff6ea73946 refactor: inline log_setup into privacy_gateway, remove kvm_common
kvm_common was a 47-line shared logging library that required
sys.path.insert hacks for importing. Since kvm_agent already
inlined it, this makes privacy_gateway do the same and removes
the shared module entirely.

- Add services/privacy_gateway/log_setup.py (copied from kvm_common)
- Remove sys.path.insert hack from privacy_gateway/__main__.py
- Delete services/kvm_common/
- Update .gitignore for new deb tree build artifacts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 04:45:16 +00:00
qiuruiandClaude Opus 4.6 58ef04bfec chore: remove deprecated doc_processor (port 8891 conflict with rkllm_server)
doc_processor was a thin proxy layer forwarding multipart requests to
info-privacy-rs:8001 with no additional logic. Its port 8891 conflicted
with rkllm_server. Callers should use info-privacy-rs directly.

Removed:
- services/doc_processor/ (entire package)
- deploy/systemd/doc-processor.service
- debian/kvm-mitm doc-processor packaging files
- build-python-debs.sh doc_processor references (6 locations)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 03:56:35 +00:00
qiuruiandClaude Opus 4.6 04e387c7e9 refactor: extract workflow-dashboard to tools/ as standalone testing tool
Move workflow_dashboard from services/ to tools/workflow-dashboard/ to
clearly separate it from production services. Dashboard is a testing-only
tool and should not be co-located with production code.

Changes:
- Move services/workflow_dashboard/ → tools/workflow-dashboard/workflow_dashboard/
- Move Dockerfile and requirements.txt to tools/workflow-dashboard/ root
- Add pyproject.toml with CLI entry point (kvm-dashboard)
- Export main() from __main__.py for entry point support
- Fix api-key.txt path resolution (3→4 parent levels)
- Update Dockerfile for new directory structure
- Update systemd WorkingDirectory
- Remove dead code: ocr_engine.py, llm_router.py
- Update CLAUDE.md directory structure and commands

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 03:56:20 +00:00
qiuruiandClaude Opus 4.6 7138af0b54 feat: Phase 3 — KVM Server OCR backend routes through NPU Daemon
KVM Go server now supports "npu-daemon" OCR backend that sends frames
to the centralized NPU Daemon HTTP API instead of spawning kvm-ocr
exec processes. Configurable via ocr.backend in config.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:45:40 +00:00
qiuruiandClaude Opus 4.6 6bc7aef0f9 feat: Phase 2 — info-privacy-rs uses NPU Daemon + dashboard systemd service
- info-privacy.service: add NPU_DAEMON_URL env var for worker HTTP routing
- workflow-dashboard.service: new systemd unit (port 9099, enabled on boot)
- Dashboard config: fix privacy_rs port 8000→8001, use app object for uvicorn
- deps/info-privacy-rs: update submodule ref (NPU Daemon worker backend)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:31:29 +00:00
qiuruiandClaude Opus 4.6 09a2212543 feat: add workflow dashboard service with NPU integration
New services/workflow_dashboard/ (port 9099):
- Separated service modules: kvm_ocr_service (NPU→KVM fallback),
  npu_service, llm_service, privacy_service
- Agent pipeline with NPU OCR for uploads
- Privacy pipeline using centralized privacy_service
- NPU status/models endpoints
- docker-compose.yml for multi-service orchestration
- workflow_hooks.py for agent event integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:15:44 +00:00
qiuruiandClaude Opus 4.6 fa73fd0b6c feat: integrate NpuClient for AI screenshot privacy redaction
Add PII redaction to cloud LLM path in hybrid_planner:
- NpuClient: async httpx client for NPU Daemon (redact_image, ocr_analyze)
- _redact_for_cloud(): intercepts screenshots before remote LLM calls
- Mixed mode: text_only (no image sent) vs image (redacted JPEG)
- Graceful degradation: NPU unavailable → send original with warning
- Privacy metrics: redactions, findings_total, text_only/image mode counts
- Config: npu_daemon_url, privacy_redact_enabled, privacy_redact_types

12 new tests for NpuClient + HybridPlanner privacy integration.
All 462 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:15:26 +00:00
qiuruiandClaude Opus 4.6 df45cc81e7 feat: add NPU Daemon for centralized RKNN inference (port 8004)
Rust service managing all NPU inference on RK3588:
- PP-OCRv4 OCR (Det→Core0, Rec→Core1): ~200ms per image
- MediaPipe face detection (Core0, serial with det)
- PII regex detection + pixel redaction: ~290ms full pipeline
- Priority scheduler (P0-P3) with tokio semaphore
- Safe RKNN FFI via bindgen + RAII Drop wrapper

API: /api/v1/{ocr/analyze, face/detect, privacy/redact-image, health, status, models}
Binary: 4.9MB stripped, 52MB RAM, <200ms startup
Deployed as systemd service with CPUAffinity A55(4-7)

Hardware-verified on NanoPC-T6 with all 9 integration tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:15:04 +00:00
qiuruiandClaude Opus 4.6 d17031b9cc feat: add PlannerMetrics and /api/v1/agent/metrics endpoint
- PlannerMetrics dataclass tracks template_hits, fingerprint_hits,
  local/remote LLM calls, failures, and estimated tokens saved
- Metrics updated at each routing decision point in HybridPlanner
- New GET /api/v1/agent/metrics API endpoint in AgentAPIServer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:11:24 +00:00
qiuruiandClaude Opus 4.6 bb2e68fd3d feat: add episodic memory system for task execution learning
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:07:40 +00:00
qiuruiandClaude Opus 4.6 0f05b3adf6 feat: add screen fingerprint matching system for zero-token action lookup
Hash OCR text + task into a deterministic fingerprint to cache known-good
action mappings. For repeated tasks on identical screens, the store provides
instant action lookup without LLM calls (0 tokens, ~5ms latency). Includes
JSON file persistence, reliability gating (min 3 successes, >=80%), and
LRU-style eviction. 26 tests covering hashing, store CRUD, persistence,
and eviction behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:07:18 +00:00
qiuruiandClaude Opus 4.6 157d72f65d feat: RKLLM fallback tracking and smart click retry skip
T10: Add _local_fail_count and _remote_call_count to HybridPlanner for
monitoring RKLLM→remote fallback frequency and cost. Counters reset on
local success and on reset(). Add rkllm_fallback_warning config field.

T11: _click_with_retry now tracks whether OCR found the target element.
On the final retry attempt, if OCR still cannot locate the element, the
click is skipped instead of blindly clicking at stale LLM coordinates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:06:49 +00:00
qiuruiandClaude Opus 4.6 302c4aec28 feat: add StateMachineTemplate with conditional branching and fallback states
Extends template system with state machine support:
- TemplateState: action + verify_text + next/fallback transitions
- StateMachineTemplate: non-linear execution with error recovery
- 10 new tests covering state transitions, fallback, JSON roundtrip

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:06:48 +00:00
qiuruiandClaude Opus 4.6 465d9d38cc feat: add retry with exponential backoff to all MemoryClient HTTP methods
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:06:22 +00:00
qiuruiandClaude Opus 4.6 41aafca443 feat: token optimization — OCR dedup, adaptive image, compact scene, history compression
- T4: Merge 3 perceive() calls per step into single OCR call in agent.py
- T5: Reduce max_tokens 512→256, image detail high→auto (configurable)
- T6: Add SceneGraph.to_compact_summary() with task-aware element ranking
- T7: Adaptive image sending — skip screenshot when OCR has ≥5 elements
- T8: History compression with 600-char budget instead of fixed last-3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:02:46 +00:00
qiuruiandClaude Opus 4.6 462bb9eec3 chore: add secrets.env.example template and fix deb control dependencies
- Add secrets.env.example as reference for new deployments
- Add python3-httpx, python3-openai, python3-yaml, python3-aiohttp,
  python3-pymysql to kvm-agent deb Depends

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:51:34 +00:00
qiuruiandClaude Opus 4.6 acf70034da feat: add pyproject.toml and inline log_setup for standalone installation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:50:44 +00:00
qiuruiandClaude Opus 4.6 18cdaa3251 test: mock TaskQueue in runner tests to remove MariaDB dependency
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:45:07 +00:00
qiuruiandClaude Opus 4.6 07d77acc46 chore: update embedding submodule (LFS models added)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:25:21 +00:00
qiuruiandClaude Opus 4.6 eb6e4329d4 feat: v1.0.0 — two-layer privacy, Agent enhancements, model sync, HDMI DRM plan
Architecture:
- Remove Chrome extension (project fully device-side, no target-machine deps)
- Update all docs from "three-layer" to "two-layer" privacy (video redact + network intercept)
- Add comprehensive architecture docs (overview, subsystem designs)

Services:
- kvm_agent: hybrid planner (template/local/cloud), screen state detection,
  mouse-first architecture, app launcher, visual workflow tests
- privacy_gateway: upload scanner, privacy LLM integration, REST API
- doc_processor: new document processing service

Deployment:
- Add kvm-bridge, kvm-meta, kvm-privacy deb package definitions
- New systemd services (doc-processor, kvm-gateway, rkllm-server)
- Network deploy configs, journald forwarding
- Remove secrets.env templates from packages

Plans & Docs:
- HDMI-TX DRM local output + OSD design (drm_output.c, VOP2 multi-plane)
- AI Agent token optimization plan (72% savings via caching/pruning/fingerprint)
- Model sync: all RKNN/ONNX models now in project directory
- Native H.264 adaptive bitrate plan

Submodules updated:
- deps/KVM: WebUI i18n, RBAC, DDNS, Agent API, OCR models (LFS)
- deps/embedding: models synced (LFS), benchmarks, Ollama backend
- deps/info-privacy-rs: regex PII detection, face detection integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:16:58 +00:00
qiuruiandClaude Opus 4.6 6453bf1197 docs: add hotplug stability + process isolation implementation plan
19 tasks across 7 phases: C IPC primitives, kvm-video process,
Go SharedPipeline, HID hot-reconnect, DeviceWatchdog, deployment,
and integration. Build-tag based CGo fallback for migration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:07:55 +00:00
qiuruiandClaude Opus 4.6 9b9121e362 docs: add hotplug stability + video pipeline process isolation design
Comprehensive design for isolating the C video pipeline (V4L2→RGA→MPP)
into a separate process communicating via shared memory ring buffer,
replacing the CGo shared address space that causes full process death
on C-layer crashes.

Key components:
- SPSC lock-free ring buffer for H.264 frames (/dev/shm)
- BGR double buffer for OCR snapshots
- Unix socket control channel
- V4L2 capture thread error recovery
- USB HID hot-reconnect with DeviceLostError
- DeviceWatchdog with systemd WatchdogSec
- Health API endpoint
- udev rule fixes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 00:55:09 +00:00
qiurui 58390e2e82 docs: add WebRTC stability + virtual media implementation plan 2026-03-04 13:37:04 +00:00
qiuruiandClaude Sonnet 4.6 cc1ce4dae5 chore: bump kvm-server to 1.0.0-6 for nav permissions + user card grid
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 09:41:00 +00:00
qiurui 083ef77134 feat: user management card grid layout in settings tab 2026-03-04 09:33:13 +00:00
qiurui 5ca5ffcd41 refactor: merge isPrivileged nav links into single fragment 2026-03-04 09:29:56 +00:00
qiurui 13ec3362aa feat: restrict privacy/audit/agent nav links to privileged roles 2026-03-04 09:27:40 +00:00
qiuruiandClaude Sonnet 4.6 502ae32c9f fix: update test for new set_privacy_mode API format + dynamic deb versioning
- test_set_privacy_mode: verify {mode:audit} request body, not {enabled:true}
- build-python-debs.sh: read version from control file dynamically
  (was hardcoded to 1.0.0-1 in both MODE A and MODE B)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 05:34:58 +00:00
qiuruiandClaude Sonnet 4.6 e34af87d96 fix: kvm_client set/get_privacy_mode uses correct mode API format
set_privacy_mode(bool) now sends {"mode": "audit"|"off"} instead of
{"enabled": bool} which caused 400 Bad Request from Go endpoint.
get_privacy_mode() adds "enabled" key for agent.py backward compat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 05:27:41 +00:00
qiuruiandClaude Sonnet 4.6 2f73e25c4c deploy: deb installation test — Python source packages + audit_logger fix
- audit_logger.py: _ensure_schema() wraps CREATE TABLE in try/except
  so kvm_mitm (INSERT/SELECT only) can still connect and log normally;
  postinst already creates the table
- build-python-debs.sh: add MODE B (source wrapper) as default for
  ARM64 dev machines without mitmproxy 10+; MODE A (PyInstaller) via --pyinstaller
- debian/kvm-mitm/DEBIAN/control: depends python3 (>= 3.9), arch=all
- debian/kvm-agent/DEBIAN/control: same
- debian/*/usr/bin/: shell wrappers exec python3 -m <module>
- .gitignore: exclude debian/*/usr/lib/ (populated by build script)

Installation test results (NanoPC-T6):
  kvm-server 1.0.0-3: active, privacy API native Go verified
  kvm-mitm 1.0.0-1: active on port 8888 (replaces privacy-gateway)
  kvm-agent 1.0.0-1: binary + queue list/add verified against MariaDB

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 04:05:59 +00:00
qiuruiandClaude Sonnet 4.6 619f30fcfe fix: code quality — cert_manager TOCTOU, runner event loop, Go handler errors
- cert_manager.py: write private key with O_CREAT|0o600 mode to avoid
  TOCTOU window between write_bytes() and chmod() calls
- runner.py: get_running_loop() replaces deprecated get_event_loop()
- deps/KVM: PrivacyHandler quality fixes (PIITypes JSON, error checks,
  permissions, test assertion strength)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 03:50:15 +00:00
qiuruiandClaude Sonnet 4.6 633e182a25 fix: readState normalises invalid mode to off + add corrupt-state test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 03:46:32 +00:00
qiuruiandClaude Sonnet 4.6 1c5cf01e4a test: end-to-end deb installation — privacy API native Go, Python suite green
- kvm-server 1.0.0-3 installed (upgraded from 1.0.0-1): native Go privacy
  handlers replace port 8889 proxy; service running on :8080
- MariaDB tables verified: privacy_audit_log + agent_tasks present
- DB users verified: kvm_mitm (SELECT+INSERT on privacy_audit_log),
  kvm_agent (ALL on agent_tasks)
- Privacy API endpoints confirmed: GET/POST /api/v1/privacy/mode (off|audit|redact),
  GET /api/v1/privacy/stats — all return correct JSON via native Go handlers
- Port 8889: privacy-gateway (mitmproxy) still listening as independent service
- kvm-mitm deb install: SKIPPED — PyInstaller binary not available; mitmproxy
  6.0.2 has broken deps on this machine; requires clean Python env with mitmproxy 10+
- kvm-agent deb install: SKIPPED — same reason as above
- Python unit test suite: 349 passed (test_integration skipped — requires
  live KVM hardware with HDMI input connected)
- Go privacy handler tests: 7/7 passed (TestPrivacyHandlerGetMode,
  TestPrivacyHandlerSetMode, TestPrivacyHandlerSetModeInvalid,
  TestPrivacyHandlerGetCertMissing, TestPrivacyHandlerGetStatsNilDB,
  TestPrivacyHandlerGetAuditNilDB, TestPrivacyProxy_ForwardsRequest)
- Added kvm_agent source + tests, chrome-extension, web UI source,
  workflow YAML files, and privacy evaluation docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 03:42:18 +00:00
qiurui 346803ecd9 build: kvm-server 1.0.0-3 with native Go privacy handlers 2026-03-04 03:33:44 +00:00
qiuruiandClaude Sonnet 4.6 8ec50e141d feat: add kvm-mitm and kvm-agent Debian package control trees and build script
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 03:29:59 +00:00
qiuruiandClaude Sonnet 4.6 6338f91f94 feat: harden systemd services with EnvironmentFile and StartLimitInterval
- kvm-agent.service: replace hardcoded KVM_JWT_TOKEN/LLM_API_KEY with
  EnvironmentFile=/etc/kvm-agent/secrets.env; add StartLimitInterval=300,
  StartLimitBurst=5; switch ExecStart to /usr/bin/kvm-agent binary
- kvm-mitm.service: new service for kvm-mitm binary with EnvironmentFile,
  StartLimit, RestartSec=10, MemoryMax=768M, LimitNOFILE=8192
- privacy-gateway.service: add StartLimitInterval=300, StartLimitBurst=5
- mem-bridge-memory.service: add StartLimitInterval=300, StartLimitBurst=5,
  update RestartSec 5→10
- mem-bridge-router.service: add StartLimitInterval=300, StartLimitBurst=5,
  update RestartSec 5→10
- info-privacy.service: add StartLimitInterval=300, StartLimitBurst=5,
  update RestartSec 5→10

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 03:25:16 +00:00
qiurui 1bb44e0315 fix: update KVM submodule with GetStats/GetAudit nil DB guards 2026-03-04 03:23:43 +00:00
qiuruiandClaude Sonnet 4.6 6e0e03b63b feat: add Go PrivacyHandler and wire into router, replace 8889 proxy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 03:19:46 +00:00
qiurui 271b26e094 fix: replace deprecated utcnow() with timezone-aware datetime in cert_manager 2026-03-04 03:15:30 +00:00
qiuruiandClaude Sonnet 4.6 cfe7f9093a feat: non-blocking CA cert generation via background thread
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 03:11:39 +00:00
qiuruiandClaude Sonnet 4.6 6a1684d693 feat: add mitm_launcher.py and explicit scan_failed degradation in addon
- addon.py: split except block into specific httpx network errors
  (ConnectError, TimeoutException, HTTPStatusError) and generic Exception;
  both now log action="scan_failed" instead of "allow", and redact mode
  calls flow.kill() to block the upload when info-privacy is unreachable
- mitm_launcher.py: programmatic DumpMaster entry point for PyInstaller,
  replaces `mitmdump -s addon.py` file-load so all source compiles to binary
- tests/test_addon_degradation.py: 8 parametrized tests covering audit/redact
  degradation with ConnectError, TimeoutException, HTTPStatusError, and
  _audit=None edge cases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 03:06:19 +00:00
qiurui b337f5dc06 fix: no sleep after last retry attempt, mock network in health test 2026-03-04 03:03:11 +00:00
qiuruiandClaude Sonnet 4.6 07785330f1 feat: add exponential-backoff reconnect to MemoryClient
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 03:00:15 +00:00
qiuruiandClaude Sonnet 4.6 baa900b46f fix: add CANCELLED to TaskStatus, fix import path, remove unused imports
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 02:58:20 +00:00
qiuruiandClaude Sonnet 4.6 9e884f68b3 feat: migrate TaskQueue to PyMySQL MariaDB, update config/yaml
Replace SQLite TaskQueue with MariaDB-backed implementation using pymysql.
Update AgentConfig to use db_host/db_name/db_user instead of queue_db_path.
Update agent.yaml, __main__.py daemon/queue commands, and test fixtures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 02:52:25 +00:00
qiurui 03d16a63f9 fix: remove unused os import, use UTC consistently in stats_today 2026-03-04 02:48:02 +00:00
qiuruiandClaude Sonnet 4.6 a75c2696cc feat: migrate audit_logger to PyMySQL MariaDB
Replace SQLite AuditLogger with MariaDB-backed version using PyMySQL.
Keep identical public interface (log, query, count, stats_today).
Constructor accepts pre-existing connection (tests) or env-var credentials (production).
addon.py reads KVM_MITM_DB_HOST/USER/PASS/NAME env vars; guards against init failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 02:42:54 +00:00
qiuruiandClaude Sonnet 4.6 dc09306655 fix: add SQL comments and initialize password vars in postinst
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 02:38:41 +00:00
qiuruiandClaude Sonnet 4.6 735d91cf26 feat: extend postinst with privacy_audit_log, agent_tasks DDL and per-service users
Update deps/KVM submodule to include privacy table DDL and per-service
user grants in kvm-server.postinst.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 02:32:28 +00:00
qiurui 477eb9f570 docs: add deb packaging implementation plan (13 tasks, TDD, MariaDB+PyInstaller) 2026-03-04 02:16:52 +00:00
qiurui 4e4f0798a8 docs: deb packaging design — MariaDB, PyInstaller binaries, service isolation 2026-03-04 02:07:38 +00:00
qiuruiandClaude Sonnet 4.6 9509889763 feat: agent command bar, privacy three-mode gateway, Go proxy, task cancellation
Frontend (deps/KVM submodule - 5 commits):
- Add Go reverse proxy: /api/v1/agent/* → :8890, /api/v1/privacy/* → :8889
- AgentCommandBar at bottom of ConsolePage with submit/cancel/history
- agentStore: Zustand store with poll/cancel, module-scope timer
- PrivacyPage: three-segment mode selector (off/audit/redact)
- i18n keys for agent.* and privacy.mode_*

Backend:
- privacy_gateway/addon.py: three-mode logic (off/audit/redact)
- privacy_gateway/privacy_api.py: REST API with mode enum, stats, audit, cert
- privacy_gateway/audit_logger.py: add client_ip/filename/file_size columns
- kvm_agent/runner.py: asyncio.Task cancellation, TaskQueue.cancel()
- kvm_agent/api_server.py: cancel_current() for running tasks
- deploy/systemd: add kvm-agent.service, privacy-api.service, env/limits

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 01:27:03 +00:00
qiuruiandClaude Sonnet 4.6 b2be690d14 feat: runner task cancellation via asyncio.Task.cancel(), api_server cancel_current support
- TaskQueue.cancel(): mark any task (pending or running) as cancelled in SQLite
- AutonomousRunner._execute_task(): self-registers via asyncio.current_task() so
  cancel_current() works whether called from start() or directly in tests
- AutonomousRunner.cancel_current(task_id): cancels running asyncio.Task by ID,
  returns True/False; re-raises CancelledError after writing DB record
- AgentAPIServer: add autonomous_runner param (self._autonomous_runner) to avoid
  naming conflict with self._runner (web.AppRunner); _handle_cancel_task() now
  handles both pending (queue.cancel) and running (cancel_current) states

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 01:11:28 +00:00
qiuruiandClaude Sonnet 4.6 3b1d2e3c70 feat: addon.py three-mode logic (off/audit/redact), standardize action values to allow/redact
- Add _load_mode() to read mode from /var/lib/kvm-privacy/state.json each request
- off mode: pass through without any logging or processing
- audit mode: scan and log PII detections, action='allow', never modify request
- redact mode: scan, redact files with PII (action='redact'), or allow if clean (action='allow')
- Move _post_to_kvm_audit from static class method to module-level function for testability
- Replace bare imports with relative imports (with mitmproxy bare-script fallback)
- Replace action values 'auto_redact'/'bypass' with 'allow'/'redact' to match frontend PrivacyAuditEntry type
- Add tests/test_addon_modes.py covering all four mode/PII combinations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 01:06:17 +00:00
qiuruiandClaude Sonnet 4.6 2c39f8ec8f fix: stats action mapping handles both old (bypass/auto_redact) and new (allow/redact) DB values
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 01:01:37 +00:00
qiuruiandClaude Sonnet 4.6 f7a7f3bb36 feat: redesign privacy API - mode enum off/audit/redact, fix stats and audit response format
- Replace boolean privacy_enabled with three-mode enum: off/audit/redact
- GET /api/v1/privacy/mode now returns {mode} instead of {enabled}
- POST/PUT /api/v1/privacy/mode validates against allowed set, returns 400 on invalid
- GET /api/v1/privacy/stats now returns rich PrivacyStats matching frontend TS types:
  requests, files, pii_by_type, actions{allow,block,redact}, top_domains[]
- GET /api/v1/privacy/audit now returns {logs, total, page} instead of {entries, total}
  pii_types is parsed from JSON string to dict in response
- Add get_mode() helper for addon.py internal consumption
- Add tests/test_privacy_api_redesign.py with 5 tests covering all changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 00:55:57 +00:00
qiurui 2b4bc9d997 docs: add agent+gateway implementation plan (8 tasks, TDD) 2026-03-03 23:37:55 +00:00
qiurui 7d84e36f5e docs: add AI agent command bar + privacy gateway three-mode design 2026-03-03 23:31:12 +00:00
qiurui 88ce820fe9 fix: WebRTC no video - remove intermediate div wrapper from ConsolePage 2026-03-03 23:17:24 +00:00
qiurui 522d9d8ec5 style: add semicolons to markAction handler 2026-03-03 15:52:45 +00:00
qiuruiandClaude Sonnet 4.6 67c9c14564 fix: record block action in scanHistory for accurate todayBlocked count
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:49:06 +00:00
qiuruiandClaude Sonnet 4.6 ccd28566af fix: B3+B1 - todayBlocked uses action=block, checkServer timeout 800ms
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:45:46 +00:00
qiuruiandClaude Sonnet 4.6 e564db0b69 feat: B2+B3+B4 - Chrome extension popup dashboard (400px, stats+PII chart+recent scans)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:41:37 +00:00
qiurui 224d506f1b fix: B1 - explicit 'allow' action in analyze recordScan call 2026-03-03 15:37:46 +00:00
qiuruiandClaude Sonnet 4.6 4030cf8504 feat: B1 - add todayRedacted counter to Chrome extension stats
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:35:09 +00:00
qiurui 5c56ba48e0 fix: A4 PiiOverlay - clear canvas on disable, immediate first fetch 2026-03-03 15:31:55 +00:00
qiuruiandClaude Sonnet 4.6 d3f838cc76 feat: A4 - PiiOverlay canvas overlay on video stream
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:27:33 +00:00
qiuruiandClaude Sonnet 4.6 a8d29c1190 fix: A3 code quality - i18n audit headers, ToggleCard module level
Update deps/KVM submodule pointer to include:
- i18n keys for AuditTab table headers and pagination text
- ToggleCard moved to module level (was inside CertificateTab body)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:23:03 +00:00
qiuruiandClaude Sonnet 4.6 af2358c344 chore: update deps/KVM submodule to include privacy page fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:15:50 +00:00
qiuruiandClaude Sonnet 4.6 d567ad84c7 chore: update deps/KVM submodule to include PrivacyPage implementation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:13:11 +00:00
qiuruiandClaude Sonnet 4.6 4a652bc38d feat(webui): add /privacy route, nav item, and i18n keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:08:40 +00:00
qiuruiandClaude Sonnet 4.6 703b67a313 chore: update deps/KVM submodule (fix useEffect dep array)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:06:07 +00:00
qiuruiandClaude Sonnet 4.6 ba19a68f59 feat(webui): add privacyMode field to kvmStore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:03:27 +00:00
qiuruiandClaude Sonnet 4.6 d774f7a772 docs: add frontend enhancement implementation plan (2026-03-03)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 14:58:21 +00:00
qiuruiandClaude Sonnet 4.6 3510a1eebb docs: add frontend enhancement design plan (2026-03-03)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 14:49:16 +00:00
339 changed files with 64270 additions and 360 deletions
+30
View File
@@ -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
+148 -15
View File
@@ -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 / recordingsOCR/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 每秒触发 oneshotnative 模式不需要 | 从包中移除 |
## 目标设备
- 硬件: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 TabAuditPage 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
View File
@@ -1,138 +1,181 @@
# DEVELOP
# 开发指南
## 环境要求
- 开发机:Linux x86_64Python 3.10+Go 1.21+
- 目标设备:NanoPC-T6 (RK3588)192.168.123.181user: 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 脱敏 |
+62 -41
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+20
View File
@@ -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
View File
@@ -0,0 +1,3 @@
#!/bin/bash
export PYTHONPATH=/usr/lib/kvm-agent
exec python3 -m kvm_agent "$@"
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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. knowledgelong_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.faissIndexFlatIP)。"""
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.faissmmap=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_termsummary + 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 # 有序 dictPython 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 ServiceOpenAI 兼容代理 + 复杂度路由 + 记忆注入。"""
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:
# 无后端时创建禁用状态的 QueryRewriterenabled=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]
+27
View File
@@ -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
View File
@@ -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()
+11
View File
@@ -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).
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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
Vendored Executable
+13
View File
@@ -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
+20
View File
@@ -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
+21
View File
@@ -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
View File
@@ -0,0 +1,3 @@
#!/bin/bash
export PYTHONPATH=/usr/lib/kvm-mitm
exec python3 -m privacy_gateway.mitm_launcher "$@"
+8
View File
@@ -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
View File
@@ -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
Vendored Executable
+7
View File
@@ -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
View File
@@ -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
+38
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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
+75
View File
@@ -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
View File
@@ -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 APINPU_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)
# ── 后端 CNPU 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
# ── 后端 Amediapipe-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
# ── 后端 Bmediapipe_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 APINPU_DAEMON_URL 环境变量)
2. ppocr_rknn.PPOcrRknnrknnlite,板端友好)
3. ppocr_det + ppocr_recrknn 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
# ── 后端 CNPU 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
# ── 后端 Appocr_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
# ── 后端 Bppocr_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()
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,3 @@
#!/bin/bash
export PYTHONPATH=/usr/lib/kvm-rkllm
exec python3 -m rkllm_server "$@"
+119
View File
@@ -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
+13
View File
@@ -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
+8
View File
@@ -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
+32
View File
@@ -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
+76
View File
@@ -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."
+13 -5
View File
@@ -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
+18
View File
@@ -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
+20
View File
@@ -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
+20
View File
@@ -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
+21
View File
@@ -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
+8 -1
View File
@@ -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
+8 -1
View File
@@ -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
+18
View File
@@ -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
+5 -1
View File
@@ -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
+24
View File
@@ -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
+21
View File
@@ -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
+1 -1
+23
View File
@@ -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 消耗降低 4x114K → 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 tokens30 步任务节省 ~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/)
+147
View File
@@ -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 | 隐私模式状态 |
+144
View File
@@ -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
```
+179
View File
@@ -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
```
+192
View File
@@ -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 请求审计 |
+110
View File
@@ -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 小核
```
+185
View File
@@ -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 组件库
```
+135
View File
@@ -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 证书 |
+183
View File
@@ -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 BChrome 扩展 popup 仪表板化,含统计卡片、PII 分布图、近期扫描列表
---
## Phase AKVM 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 BChrome 扩展 Popup 仪表板化
### B1. 布局变更
- `popup/popup.html`width 320px → 400pxheight 从固定 → 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 APrivacyPage 三 tab|
| Phase 3: KVM 视频 PII 遮罩叠层 | ✅ Phase APiiOverlay 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. 用户管理 TabSettingsPage → 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 1Layout.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 2UsersPage.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 3UserManagementTab.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">&times;</button>
</div>
)}
{message && (
<div className="alert alert-success">
{message}
<button onClick={() => setMessage('')} className="alert-close">&times;</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 timeout3000 → 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_\-.]`,拒绝路径穿越(`../`
- 文件大小: 上限 8GBmultipart 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