From 07fd4c85eff15a8e05924c43686e6c66b18a9182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Sun, 16 Aug 2026 00:02:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9B=B6=E7=82=B9=E5=8E=9F=E6=A0=B8?= =?UTF-8?q?=E9=A2=91=E9=81=93v1+=E7=99=BB=E5=BD=95=E5=8F=8C=E9=97=A8?= =?UTF-8?q?=E5=9C=B0=E5=9F=BA+=E4=BA=94=E6=B9=96=E5=BC=80=E5=9C=BA?= =?UTF-8?q?=E2=80=94=E2=80=94zero=5Fpoint=E8=A3=81=E5=86=B3=E9=93=BE/?= =?UTF-8?q?=E7=81=AF=E5=A1=94=E6=9F=A5=E5=8F=B7/code=5Frepo=5Flogin/?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E5=8D=A1=E9=87=8D=E8=AE=BE=E8=AE=A1/?= =?UTF-8?q?=E6=B5=85=E8=89=B2=E4=B8=BB=E9=A2=98=E8=81=94=E5=8A=A8=EF=BC=88?= =?UTF-8?q?=E5=86=B0=E6=9C=9420260815=E5=A4=9C=E8=B0=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hololake-native-desktop/index.html | 2 +- .../src-tauri/Cargo.lock | 189 +++++++- .../src-tauri/Cargo.toml | 3 +- .../src-tauri/src/agent_host.rs | 298 ++++++++++++ .../src-tauri/src/agent_parlor.rs | 224 +++++++++ .../src-tauri/src/code_repo_login.rs | 216 +++++++++ .../src-tauri/src/glp_envelope.rs | 266 +++++++++++ .../src-tauri/src/knowledge_base.rs | 353 ++++++++++++++- .../src-tauri/src/lib.rs | 134 ++++++ .../src-tauri/src/persona_butler.rs | 299 ++++++++++++ .../src-tauri/src/zero_point.rs | 425 ++++++++++++++++++ .../src/design-tokens.css | 11 + .../hololake-native-desktop/src/main.tsx | 423 ++++++++++++++--- .../src/modules/knowledge-render/index.tsx | 146 ++++++ .../src/modules/knowledge-render/manifest.ts | 22 + .../hololake-native-desktop/src/styles.css | 159 ++++++- 16 files changed, 3082 insertions(+), 88 deletions(-) create mode 100644 product-source/hololake-native-desktop/src-tauri/src/agent_host.rs create mode 100644 product-source/hololake-native-desktop/src-tauri/src/agent_parlor.rs create mode 100644 product-source/hololake-native-desktop/src-tauri/src/code_repo_login.rs create mode 100644 product-source/hololake-native-desktop/src-tauri/src/glp_envelope.rs create mode 100644 product-source/hololake-native-desktop/src-tauri/src/persona_butler.rs create mode 100644 product-source/hololake-native-desktop/src-tauri/src/zero_point.rs create mode 100644 product-source/hololake-native-desktop/src/modules/knowledge-render/index.tsx create mode 100644 product-source/hololake-native-desktop/src/modules/knowledge-render/manifest.ts diff --git a/product-source/hololake-native-desktop/index.html b/product-source/hololake-native-desktop/index.html index 93af769bc..f4f526abf 100644 --- a/product-source/hololake-native-desktop/index.html +++ b/product-source/hololake-native-desktop/index.html @@ -3,7 +3,7 @@ - HoloLake + HoloLake · GH-AIOS
diff --git a/product-source/hololake-native-desktop/src-tauri/Cargo.lock b/product-source/hololake-native-desktop/src-tauri/Cargo.lock index e6819e8ba..ad858d581 100644 --- a/product-source/hololake-native-desktop/src-tauri/Cargo.lock +++ b/product-source/hololake-native-desktop/src-tauri/Cargo.lock @@ -103,6 +103,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.21.7" @@ -294,6 +317,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -330,6 +355,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.43" @@ -342,6 +384,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "combine" version = "4.6.7" @@ -417,6 +468,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -944,6 +1004,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futf" version = "0.1.5" @@ -1162,8 +1228,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1185,8 +1253,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1392,6 +1463,7 @@ dependencies = [ "base64 0.22.1", "dirs", "fs2", + "reqwest", "ring", "rusqlite", "serde", @@ -1834,6 +1906,16 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.104" @@ -1983,6 +2065,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac" version = "0.1.1" @@ -2828,6 +2916,63 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg 0.10.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.47" @@ -2860,7 +3005,7 @@ dependencies = [ "rand_chacha 0.2.2", "rand_core 0.5.1", "rand_hc", - "rand_pcg", + "rand_pcg 0.2.1", ] [[package]] @@ -2874,6 +3019,17 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2912,6 +3068,12 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_hc" version = "0.2.0" @@ -2930,6 +3092,15 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3025,6 +3196,7 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", + "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -3130,6 +3302,7 @@ version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -3156,6 +3329,7 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "zeroize", ] @@ -3192,6 +3366,7 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -3524,7 +3699,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4795,6 +4970,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.6" diff --git a/product-source/hololake-native-desktop/src-tauri/Cargo.toml b/product-source/hololake-native-desktop/src-tauri/Cargo.toml index 9d2135115..fe7cf176e 100644 --- a/product-source/hololake-native-desktop/src-tauri/Cargo.toml +++ b/product-source/hololake-native-desktop/src-tauri/Cargo.toml @@ -22,13 +22,14 @@ rusqlite = { version = "0.31", features = ["bundled"] } base64 = "0.22" serde = { version = "1", features = ["derive"] } serde_json = "1" -tauri = { version = "=2.10.2", features = [] } +tauri = { version = "=2.10.2", features = ["devtools"] } tauri-runtime = "=2.10.0" tauri-runtime-wry = "=2.10.0" tauri-plugin-updater = "2.10.0" tauri-plugin-dialog = "=2.7.2" uuid = { version = "1", features = ["v4"] } url = "2" +reqwest = { version = "0.13.2", default-features = false, features = ["json", "rustls"] } [dev-dependencies] tempfile = "3" diff --git a/product-source/hololake-native-desktop/src-tauri/src/agent_host.rs b/product-source/hololake-native-desktop/src-tauri/src/agent_host.rs new file mode 100644 index 000000000..97b983126 --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/agent_host.rs @@ -0,0 +1,298 @@ +//! Agent 东家层 · agent_host +//! +//! 阶段E选型定案(冰朔点头):候选甲"东家模式"为体—— +//! 外部 CLI Agent(师傅)干活,宿主只管:接信封、备提词、记账本、存记忆。 +//! +//! 协议转工程落点(施工总纲): +//! - 人格体主控(铁律一):任务只从 GLP 信封进门(content_type=command), +//! 宿主绝不自己发起任务。 +//! - 提词器(铁律五):会话开卷前先从提词器取指引(官方区协议+私人区索引), +//! 写进会话记忆的"提词板"栏——头顶提词板的工程形状。 +//! - HLDP 内嵌(铁律二):每个会话=一卷 HLDP 检查点体(id/prev 链/date/ +//! persona/host/title+正文),prev 链不可省。 +//! - RECEIPT(GLS-0300 回执模型):control.receipt_required 时必回回执。 +//! +//! 诚实边界:CLI 师傅的实际唤起口令(如何调 codex/qwen)未实测,不猜—— +//! 本阶段先把"接单-提词-记忆-回执"全链落成,唤起件待师傅口令实测后接上。 + +use crate::glp_envelope::{validate_envelope, ContentType, GlpMessage}; +use crate::persona_butler::{prompter_lookup, ButlerRecord, ButlerRecordKind, ButlerState}; +use std::time::{SystemTime, UNIX_EPOCH}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; +use tauri::{AppHandle, Manager, State}; + +/// 会话记忆的提词板栏——开卷瞬间从提词器抄来的指引。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct PrompterBoard { + pub topics: Vec, + pub answers: Vec, + pub path_hints: Vec, +} + +/// 会话记忆卷=HLDP 检查点体(prev 链不可省)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct AgentSessionMemory { + pub id: String, + pub prev: String, + pub date: String, + pub persona: String, + pub host: String, + pub title: String, + pub envelope_id: String, + pub task: String, + pub hldp_anchor: String, + pub prompter_board: PrompterBoard, + pub status: String, +} + +/// 接单回执——GLS-0300 RECEIPT 的工程实例。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct AgentTaskReceipt { + pub receipt_for: String, + pub session_id: String, + pub memory_path: String, + pub accepted: bool, + pub note: String, +} + +fn memory_root(app: &AppHandle) -> Result { + Ok(app + .path() + .app_data_dir() + .map_err(|error| format!("HOLOLAKE_AGENT_MEMORY_DIR_FAILED: {error}"))? + .join("agent-memory")) +} + +fn pointer_path(root: &PathBuf) -> PathBuf { + root.join("CURRENT.hdlp") +} + +/// 读 CURRENT 指针拿上一卷 id(没有则空串=开卷首卷)。 +fn latest_memory_id(root: &PathBuf) -> String { + fs::read_to_string(pointer_path(root)) + .ok() + .and_then(|raw| raw.lines().next().map(|line| line.trim().to_string())) + .unwrap_or_default() +} + +fn today_compact() -> (String, String) { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + let days = (secs / 86_400) as i64; + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = if m <= 2 { y + 1 } else { y }; + ( + format!("{year:04}{m:02}{d:02}"), + format!("{year:04}-{m:02}-{d:02}"), + ) +} + +/// 会话记忆落卷成 .hdlp 文本——HLDP 检查点体,人可读、链可续。 +fn render_memory(memory: &AgentSessionMemory) -> String { + format!( + "HLDP-CHECKPOINT\nid: {}\nprev: {}\ndate: {}\npersona: {}\nhost: {}\ntitle: {}\n\n== 任务信封 ==\n{}\n\n== 提词板 ==\n{}\n\n== 状态 ==\n{}\n", + memory.id, + if memory.prev.is_empty() { "无(开卷首卷)" } else { &memory.prev }, + memory.date, + memory.persona, + memory.host, + memory.title, + memory.task, + memory + .prompter_board + .answers + .iter() + .cloned() + .chain(memory.prompter_board.path_hints.iter().cloned()) + .collect::>() + .join("\n"), + memory.status + ) +} + +/// 从提词器抄指引上板:给定的题目逐条问,答案与路径分栏收好。 +fn compose_prompter_board( + state: &State<'_, ButlerState>, + topics: &[String], +) -> PrompterBoard { + let mut board = PrompterBoard { + topics: topics.to_vec(), + answers: Vec::new(), + path_hints: Vec::new(), + }; + for topic in topics { + match prompter_lookup(state.inner(), topic) { + Some(answer) => board.answers.push(format!("{}: {}", topic, answer)), + None => board.path_hints.push(format!( + "{}: 未命中——下钻路径:第五域线上仓库按 GLS 编号检索", + topic + )), + } + } + board +} + +fn append_butler_note(state: &State<'_, ButlerState>, record: ButlerRecord) { + crate::persona_butler::append_record(state.inner(), record); +} + +/// 接单:GLP 信封进门 → 验 → 抄提词板 → 落 HLDP 记忆卷 → 移 CURRENT 指针 → 回执。 +/// 信封不合格、或不是 command 件,一律拒收——宿主只认标准指令。 +#[tauri::command] +pub fn receive_agent_task_envelope( + app: AppHandle, + state: State<'_, ButlerState>, + envelope: GlpMessage, + prompter_topics: Vec, +) -> Result { + validate_envelope(&envelope)?; + if envelope.payload.content_type != ContentType::Command { + return Err("HOLOLAKE_AGENT_TASK_NOT_COMMAND".into()); + } + let root = memory_root(&app)?; + fs::create_dir_all(&root) + .map_err(|error| format!("HOLOLAKE_AGENT_MEMORY_WRITE_FAILED: {error}"))?; + let (compact, date) = today_compact(); + let prev = latest_memory_id(&root); + let daily_sequence = fs::read_dir(&root) + .map(|entries| { + entries + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(&format!("AGENT-SESSION-{compact}")) + }) + .count() as u64 + + 1 + }) + .unwrap_or(1); + let session_id = format!("AGENT-SESSION-{compact}-{daily_sequence:03}"); + let board = compose_prompter_board(&state, &prompter_topics); + let memory = AgentSessionMemory { + id: session_id.clone(), + prev, + date, + persona: envelope.sender.object_id.clone(), + host: "hololake-native-desktop".into(), + title: format!("Agent 任务 · {}", envelope.context.task_id), + envelope_id: envelope.message_id.clone(), + task: envelope.payload.content.clone(), + hldp_anchor: envelope.context.hldp_anchor.clone(), + prompter_board: board, + status: "ACCEPTED(待师傅唤起件接入)".into(), + }; + let memory_path = root.join(format!("{session_id}.hdlp")); + fs::write(&memory_path, render_memory(&memory)) + .map_err(|error| format!("HOLOLAKE_AGENT_MEMORY_WRITE_FAILED: {error}"))?; + fs::write(pointer_path(&root), format!("{session_id}\n")) + .map_err(|error| format!("HOLOLAKE_AGENT_MEMORY_WRITE_FAILED: {error}"))?; + append_butler_note( + &state, + ButlerRecord { + record_id: uuid::Uuid::new_v4().to_string(), + kind: ButlerRecordKind::GlpEnvelope, + from: envelope.sender.object_id.clone(), + subject: format!("agent-task:{session_id}"), + payload: envelope.payload.content.clone(), + occurred_at_unix_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .unwrap_or(0), + }, + ); + Ok(AgentTaskReceipt { + receipt_for: envelope.message_id, + session_id, + memory_path: memory_path.to_string_lossy().to_string(), + accepted: true, + note: "信封已收,记忆已落卷,提词板已抄;师傅唤起件待实测接入".into(), + }) +} + +/// 查会话记忆:给 id 读回整卷;不给 id 则顺着 CURRENT 指针读最新一卷。 +#[tauri::command] +pub fn query_agent_session_memory( + app: AppHandle, + session_id: Option, +) -> Result, String> { + let root = memory_root(&app)?; + let id = match session_id { + Some(id) => id, + None => latest_memory_id(&root), + }; + if id.is_empty() { + return Ok(None); + } + let raw = match fs::read_to_string(root.join(format!("{id}.hdlp"))) { + Ok(raw) => raw, + Err(_) => return Ok(None), + }; + // 卷是 HLDP 文本体:逐行取字段还原结构件 + let mut memory = AgentSessionMemory { + id: String::new(), + prev: String::new(), + date: String::new(), + persona: String::new(), + host: String::new(), + title: String::new(), + envelope_id: String::new(), + task: String::new(), + hldp_anchor: String::new(), + prompter_board: PrompterBoard { + topics: vec![], + answers: vec![], + path_hints: vec![], + }, + status: String::new(), + }; + let mut section = ""; + let mut task_lines: Vec = Vec::new(); + let mut board_lines: Vec = Vec::new(); + for line in raw.lines() { + if let Some(value) = line.strip_prefix("id: ") { + memory.id = value.to_string(); + } else if let Some(value) = line.strip_prefix("prev: ") { + memory.prev = if value == "无(开卷首卷)" { String::new() } else { value.to_string() }; + } else if let Some(value) = line.strip_prefix("date: ") { + memory.date = value.to_string(); + } else if let Some(value) = line.strip_prefix("persona: ") { + memory.persona = value.to_string(); + } else if let Some(value) = line.strip_prefix("host: ") { + memory.host = value.to_string(); + } else if let Some(value) = line.strip_prefix("title: ") { + memory.title = value.to_string(); + } else if line == "== 任务信封 ==" { + section = "task"; + } else if line == "== 提词板 ==" { + section = "board"; + } else if line == "== 状态 ==" { + section = "status"; + } else if section == "task" && !line.trim().is_empty() { + task_lines.push(line.to_string()); + } else if section == "board" && !line.trim().is_empty() { + board_lines.push(line.to_string()); + } else if section == "status" && !line.trim().is_empty() { + memory.status = line.to_string(); + } + } + memory.task = task_lines.join("\n"); + memory.prompter_board.answers = board_lines; + Ok(Some(memory)) +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/agent_parlor.rs b/product-source/hololake-native-desktop/src-tauri/src/agent_parlor.rs new file mode 100644 index 000000000..dd7186e00 --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/agent_parlor.rs @@ -0,0 +1,224 @@ +//! 冰朔的会客厅(Agent 接线 v1): +//! - 语言回应通道:人类说话 → 人格体翻译 → 底层归灯(终端 qwen Agent)回应; +//! - 双层切换:语言推理层(默认,只说话)⇄ 现实开发执行层(人类显性切换 +//! + 原生授权确认后才放行 -y 写权限)。开关主权永远在人类手里。 +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::Read; +use std::process::{Command, Stdio}; +use std::sync::Mutex; +use std::time::Duration; +use tauri::State; +use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; + +pub struct ParlorState { + session_id: Mutex>, + layer: Mutex, +} + +impl Default for ParlorState { + fn default() -> Self { + Self { session_id: Mutex::new(None), layer: Mutex::new("language".to_string()) } + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ParlorAskInput { + pub message: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ParlorAskReceipt { + pub reply: String, + pub layer: String, + pub fresh_session: bool, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ParlorSwitchInput { + pub layer: String, +} + +/// 静默读取 ~/.qwen/.env 的 KEY=VALUE(钥匙不出门、不落日志)。 +fn load_qwen_env() -> HashMap { + let mut map = HashMap::new(); + let Some(home) = dirs::home_dir() else { return map }; + let Ok(content) = std::fs::read_to_string(home.join(".qwen").join(".env")) else { return map }; + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some((key, value)) = line.split_once('=') { + map.insert(key.trim().to_string(), value.trim().trim_matches('"').to_string()); + } + } + map +} + +/// 跑一轮归灯:spawn qwen 管道模式,stdout 读完解析 json 事件流。 +fn run_guideng(args: Vec) -> Result { + let Some(home) = dirs::home_dir() else { + return Err("HOLOLAKE_PARLOR_HOME_MISSING".into()); + }; + let qwen = home.join(".npm-global").join("bin").join("qwen"); + if !qwen.exists() { + return Err("HOLOLAKE_PARLOR_AGENT_NOT_FOUND".into()); + } + let mut command = Command::new(qwen); + command.args(&args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()); + for (key, value) in load_qwen_env() { + command.env(key, value); + } + let mut child = command + .spawn() + .map_err(|error| format!("HOLOLAKE_PARLOR_SPAWN_FAILED: {error}"))?; + let mut stdout = child.stdout.take().ok_or("HOLOLAKE_PARLOR_PIPE_MISSING")?; + let (sender, receiver) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut buffer = Vec::new(); + let outcome = stdout.read_to_end(&mut buffer).map(|_| buffer); + let _ = sender.send(outcome.map_err(|error| error.to_string())); + }); + let data = match receiver.recv_timeout(Duration::from_secs(240)) { + Ok(Ok(bytes)) => bytes, + Ok(Err(error)) => return Err(format!("HOLOLAKE_PARLOR_READ_FAILED: {error}")), + Err(_) => { + let _ = child.kill(); + return Err("HOLOLAKE_PARLOR_TIMEOUT".into()); + } + }; + let _ = child.wait(); + String::from_utf8(data).map_err(|error| format!("HOLOLAKE_PARLOR_OUTPUT_INVALID: {error}")) +} + +/// 从 qwen 的 json 事件数组里取回答与新生成的会话 id。 +fn parse_guideng_output(raw: &str) -> Result<(String, Option), String> { + let events: Vec = serde_json::from_str(raw) + .map_err(|error| format!("HOLOLAKE_PARLOR_PARSE_FAILED: {error}"))?; + let mut reply = String::new(); + let mut session = None; + for event in &events { + match event.get("type").and_then(|value| value.as_str()) { + Some("system") => { + if event.get("subtype").and_then(|value| value.as_str()) == Some("init") { + session = event + .get("session_id") + .and_then(|value| value.as_str()) + .map(str::to_string); + } + } + Some("result") => { + if let Some(text) = event.get("result").and_then(|value| value.as_str()) { + reply = text.to_string(); + } + } + _ => {} + } + } + if reply.is_empty() { + return Err("HOLOLAKE_PARLOR_EMPTY_REPLY".into()); + } + Ok((reply, session)) +} + +#[tauri::command] +pub async fn agent_parlor_ask( + state: State<'_, ParlorState>, + zero: State<'_, crate::zero_point::ZeroPointState>, + input: ParlorAskInput, +) -> Result { + // 砌墙守卫(冰朔砌墙谕):路由不在人格层时,会客厅代码层不可达。 + crate::zero_point::require_persona_route(&zero)?; + let message = input.message.trim().to_string(); + if message.is_empty() { + return Err("HOLOLAKE_PARLOR_EMPTY_MESSAGE".into()); + } + let layer = state.layer.lock().map(|guard| guard.clone()).unwrap_or_default(); + let previous_session = state.session_id.lock().map(|guard| guard.clone()).unwrap_or_default(); + let mut args = vec!["--auth-type".to_string(), "openai".to_string(), "-o".to_string(), "json".to_string(), "-p".to_string(), message]; + if layer == "execution" { + // 执行层:放行写权限(该档只有人类显性授权后才能进来)。 + args.push("-y".to_string()); + } + let (raw, fresh) = { + let mut tried = vec![]; + if let Some(session) = &previous_session { + tried.push("-r".to_string()); + tried.push(session.clone()); + } + let first_args = [&tried[..], &args[..]].concat(); + let run = tauri::async_runtime::spawn_blocking(move || run_guideng(first_args)) + .await + .map_err(|error| format!("HOLOLAKE_PARLOR_JOIN_FAILED: {error}"))?; + match run { + Ok(output) => (output, false), + Err(_) if previous_session.is_some() => { + // 旧会话续不上就换新会话再来一轮,不让会话账卡死对话。 + let retry = args.clone(); + let output = tauri::async_runtime::spawn_blocking(move || run_guideng(retry)) + .await + .map_err(|error| format!("HOLOLAKE_PARLOR_JOIN_FAILED: {error}"))??; + (output, true) + } + Err(error) => return Err(error), + } + }; + let (reply, session) = parse_guideng_output(&raw)?; + if let Some(session) = session { + if let Ok(mut guard) = state.session_id.lock() { + *guard = Some(session); + } + } + Ok(ParlorAskReceipt { reply, layer, fresh_session: fresh }) +} + +#[tauri::command] +pub async fn agent_parlor_switch_layer( + app: tauri::AppHandle, + state: State<'_, ParlorState>, + zero: State<'_, crate::zero_point::ZeroPointState>, + input: ParlorSwitchInput, +) -> Result { + // 砌墙守卫:人格通道整体不可达时,切层也无从谈起。 + crate::zero_point::require_persona_route(&zero)?; + let target = if input.layer == "execution" { "execution" } else { "language" }; + let current = state.layer.lock().map(|guard| guard.clone()).unwrap_or_default(); + if current == target { + return Ok(current); + } + if target == "execution" { + // 铁律:进入执行层必须人类亲手授权一次。 + let confirmer = app.clone(); + let confirmed = tauri::async_runtime::spawn_blocking(move || { + confirmer + .dialog() + .message("切换到现实开发执行层后,归灯将获得执行权限(调用工具、读写文件)。确认切换吗?") + .title("切换授权确认") + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::OkCancelCustom("确认切换".to_string(), "留在语言层".to_string())) + .blocking_show() + }) + .await + .map_err(|error| format!("HOLOLAKE_PARLOR_CONFIRM_FAILED: {error}"))?; + if !confirmed { + return Ok(current); + } + } + if let Ok(mut guard) = state.layer.lock() { + *guard = target.to_string(); + } + // 换层 = 换一种工作方式,会话上下文重新开始。 + if let Ok(mut guard) = state.session_id.lock() { + *guard = None; + } + Ok(target.to_string()) +} + +#[tauri::command] +pub async fn agent_parlor_status(state: State<'_, ParlorState>) -> Result { + Ok(state.layer.lock().map(|guard| guard.clone()).unwrap_or_else(|_| "language".to_string())) +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/code_repo_login.rs b/product-source/hololake-native-desktop/src-tauri/src/code_repo_login.rs new file mode 100644 index 000000000..f559d521c --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/code_repo_login.rs @@ -0,0 +1,216 @@ +//! 登录模块 · code_repo_login +//! +//! 规划卷依据(HoloLake第二阶段总体规划-20260815 · 阶段B1): +//! - 人类端输入代码仓库账号密码 → 对 guanghulab(Forgejo)验证。 +//! - 登录仓库 = 验证了背后绑定的服务器(冰朔教义)。 +//! - 凭证只存本机钥匙串 · 不落明文。 +//! +//! 事实底账(2026-08-15 三角测量): +//! - Forgejo 挂载在 /code 路径下:GET https://{host}/code/api/v1/user 走基本认证, +//! 假凭证=401 · 真凭证=200 并回显 JSON(login/email)。 +//! - guanghulab.com 与 guanghubingshuo.com 双域同路可用(均在 ALLOWED_HOSTS 血统内)。 +//! - 钥匙存取走系统钥匙串(macOS `security`);其余平台暂不落盘密码, +//! 会话仅内存保持(诚实边界,Windows 钥匙串接入排在分发阶段)。 + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::process::Command; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tauri::{AppHandle, Manager}; + +const LOGIN_HOST: &str = "guanghulab.com"; +const SESSION_FILE_NAME: &str = "login-session.json"; + +/// 落盘的登录会话——只有用户名与主机,密码永不落盘。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoginSession { + pub username: String, + pub host: String, + pub signed_in_at_unix_ms: u64, +} + +/// 登录成功回执——交给前端展示"验证了背后绑定的服务器"。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LoginReceipt { + pub username: String, + pub email: String, + pub host: String, +} + +fn session_path(app: &AppHandle) -> Result { + Ok(app + .path() + .app_data_dir() + .map_err(|error| format!("HOLOLAKE_LOGIN_SESSION_DIR_FAILED: {error}"))? + .join(SESSION_FILE_NAME)) +} + +#[cfg(target_os = "macos")] +fn keychain_store(host: &str, username: &str, password: &str) -> Result<(), String> { + let output = Command::new("/usr/bin/security") + .args([ + "add-internet-password", + "-U", + "-s", + host, + "-a", + username, + "-w", + password, + ]) + .output() + .map_err(|error| format!("HOLOLAKE_LOGIN_KEYCHAIN_FAILED: {error}"))?; + if output.status.success() { + Ok(()) + } else { + Err("HOLOLAKE_LOGIN_KEYCHAIN_FAILED".into()) + } +} + +#[cfg(target_os = "macos")] +fn keychain_has(host: &str, username: &str) -> bool { + Command::new("/usr/bin/security") + .args(["find-internet-password", "-s", host, "-a", username]) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +#[cfg(target_os = "macos")] +fn keychain_remove(host: &str, username: &str) { + let _ = Command::new("/usr/bin/security") + .args(["delete-internet-password", "-s", host, "-a", username]) + .output(); +} + +#[cfg(not(target_os = "macos"))] +fn keychain_store(_host: &str, _username: &str, _password: &str) -> Result<(), String> { + // 非 macOS 暂不落盘密码:登录验证照做,会话仅本次运行有效。 + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn keychain_has(_host: &str, _username: &str) -> bool { + false +} + +#[cfg(not(target_os = "macos"))] +fn keychain_remove(_host: &str, _username: &str) {} + +fn validate_username(raw: &str) -> Result { + let name = raw.trim(); + let valid = !name.is_empty() + && name.len() <= 40 + && name + .chars() + .all(|item| item.is_ascii_alphanumeric() || item == '-' || item == '_'); + if valid { + Ok(name.to_string()) + } else { + Err("HOLOLAKE_LOGIN_USERNAME_INVALID".into()) + } +} + +/// 启动时自查:本机有没有已登录会话(会话文件在,且钥匙串里凭证还在)。 +#[tauri::command] +pub fn check_code_repo_login(app: AppHandle) -> Result, String> { + let path = session_path(&app)?; + let raw = match fs::read_to_string(&path) { + Ok(raw) => raw, + Err(_) => return Ok(None), + }; + let session: LoginSession = serde_json::from_str(&raw) + .map_err(|_| "HOLOLAKE_LOGIN_SESSION_CORRUPT".to_string())?; + if keychain_has(&session.host, &session.username) { + Ok(Some(session)) + } else { + let _ = fs::remove_file(&path); + Ok(None) + } +} + +/// 登录验证:基本认证打 Forgejo 用户接口,密码错=401 即拒; +/// 通过后凭证进钥匙串,会话(不含密码)落盘。 +#[tauri::command] +pub async fn perform_code_repo_login( + app: AppHandle, + username: String, + password: String, +) -> Result { + let username = validate_username(&username)?; + if password.is_empty() || password.len() > 512 { + return Err("HOLOLAKE_LOGIN_CREDENTIALS_INVALID".into()); + } + let client = reqwest::Client::builder() + .read_timeout(Duration::from_secs(15)) + .use_rustls_tls() + .build() + .map_err(|error| format!("HOLOLAKE_LOGIN_NETWORK_FAILED: {error}"))?; + let url = format!("https://{LOGIN_HOST}/code/api/v1/user"); + let response = client + .get(&url) + .basic_auth(&username, Some(&password)) + .send() + .await + .map_err(|error| format!("HOLOLAKE_LOGIN_NETWORK_FAILED: {error}"))?; + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err("HOLOLAKE_LOGIN_CREDENTIALS_INVALID".into()); + } + if !status.is_success() { + return Err(format!("HOLOLAKE_LOGIN_VERIFICATION_FAILED: {status}")); + } + let body: serde_json::Value = response + .json() + .await + .map_err(|error| format!("HOLOLAKE_LOGIN_VERIFICATION_FAILED: {error}"))?; + let confirmed_login = body + .get("login") + .and_then(|value| value.as_str()) + .ok_or("HOLOLAKE_LOGIN_VERIFICATION_FAILED")?; + let email = body + .get("email") + .and_then(|value| value.as_str()) + .unwrap_or("") + .to_string(); + keychain_store(LOGIN_HOST, &username, &password)?; + let session = LoginSession { + username: confirmed_login.to_string(), + host: LOGIN_HOST.to_string(), + signed_in_at_unix_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0), + }; + let path = session_path(&app)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("HOLOLAKE_LOGIN_SESSION_WRITE_FAILED: {error}"))?; + } + fs::write( + &path, + serde_json::to_string_pretty(&session) + .map_err(|error| format!("HOLOLAKE_LOGIN_SESSION_WRITE_FAILED: {error}"))?, + ) + .map_err(|error| format!("HOLOLAKE_LOGIN_SESSION_WRITE_FAILED: {error}"))?; + Ok(LoginReceipt { + username: confirmed_login.to_string(), + email, + host: LOGIN_HOST.to_string(), + }) +} + +/// 登出:会话文件删除,钥匙串凭证清除。 +#[tauri::command] +pub fn sign_out_code_repo_login(app: AppHandle) -> Result<(), String> { + let path = session_path(&app)?; + if let Ok(raw) = fs::read_to_string(&path) { + if let Ok(session) = serde_json::from_str::(&raw) { + keychain_remove(&session.host, &session.username); + } + } + let _ = fs::remove_file(&path); + Ok(()) +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/glp_envelope.rs b/product-source/hololake-native-desktop/src-tauri/src/glp_envelope.rs new file mode 100644 index 000000000..97f955fe3 --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/glp_envelope.rs @@ -0,0 +1,266 @@ +//! GLP 标准信封 · glp_envelope +//! +//! 协议转工程第一件(HoloLake第二阶段总体规划-20260815 · 施工总纲): +//! GLS-0300《GLP 通信核心协议》第3节"标准消息结构"的逐字段工程映射。 +//! 字段一个不造、一个不丢——老家谱 YAML 原文即本文件的形状。 +//! +//! 指挥链落点(铁律四):人格体→宿主的一切指令都必须是这个信封; +//! 宿主只认信封不认散话。 + +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// GLS-0300 · receiver.routing_mode +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RoutingMode { + Direct, + Channel, + Broadcast, +} + +/// GLS-0300 · payload.content_type +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContentType { + Text, + Command, + Event, + State, + Reference, +} + +/// GLS-0300 · control.priority +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Priority { + Low, + Normal, + High, + Critical, +} + +/// GLS-0300 · control.retry_policy +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RetryPolicy { + None, + Safe, + Guaranteed, +} + +/// GLS-0300 · sender +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct GlpSender { + pub object_id: String, + pub object_type: String, + #[serde(default)] + pub world_path: String, +} + +/// GLS-0300 · receiver +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct GlpReceiver { + pub object_id: String, + pub object_type: String, + pub routing_mode: RoutingMode, +} + +/// GLS-0300 · context(hldp_anchor 即铁律二的记忆锚点) +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct GlpContext { + #[serde(default)] + pub conversation_id: String, + #[serde(default)] + pub parent_message_id: String, + #[serde(default)] + pub relation_id: String, + #[serde(default)] + pub task_id: String, + #[serde(default)] + pub hldp_anchor: String, +} + +/// GLS-0300 · payload +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct GlpPayload { + pub language: String, + pub content_type: ContentType, + pub content: String, + #[serde(default)] + pub attachments: Vec, +} + +/// GLS-0300 · control +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct GlpControl { + pub priority: Priority, + pub ack_required: bool, + pub receipt_required: bool, + #[serde(default)] + pub expires_at: String, + pub retry_policy: RetryPolicy, +} + +/// GLS-0300 · integrity(高风险消息须带签名与回执链——通信安全节) +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct GlpIntegrity { + #[serde(default)] + pub checksum: String, + #[serde(default)] + pub signature: String, +} + +/// GLS-0300 · glp_message 全信封 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct GlpMessage { + pub protocol: String, + pub message_id: String, + pub message_type: String, + pub created_at: String, + pub sender: GlpSender, + pub receiver: GlpReceiver, + pub context: GlpContext, + pub payload: GlpPayload, + pub control: GlpControl, + pub integrity: GlpIntegrity, +} + +/// 信封进门第一道验:协议号、编号格式、收发主体必须在场。 +/// 不合格的信封宿主不收——指挥链只认标准件。 +pub fn validate_envelope(message: &GlpMessage) -> Result<(), String> { + if message.protocol != "GLP/1.0" { + return Err("HOLOLAKE_GLP_PROTOCOL_UNKNOWN".into()); + } + if message.message_id.trim().is_empty() || message.created_at.trim().is_empty() { + return Err("HOLOLAKE_GLP_ENVELOPE_INCOMPLETE".into()); + } + if message.sender.object_id.trim().is_empty() || message.sender.object_type.trim().is_empty() { + return Err("HOLOLAKE_GLP_SENDER_INCOMPLETE".into()); + } + if message.receiver.object_id.trim().is_empty() + || message.receiver.object_type.trim().is_empty() + { + return Err("HOLOLAKE_GLP_RECEIVER_INCOMPLETE".into()); + } + if message.payload.content.is_empty() { + return Err("HOLOLAKE_GLP_PAYLOAD_EMPTY".into()); + } + Ok(()) +} + +/// 消息编号按老家谱格式生成:GLP-MSG-YYYYMMDD-000001。 +/// 序号由账本(管家层)当日累计给出,这里只拼形状。 +pub fn build_message_id(date_compact: &str, daily_sequence: u64) -> String { + format!("GLP-MSG-{date_compact}-{daily_sequence:06}") +} + +/// ISO-8601 近似时刻戳(秒级,UTC)——老家谱要 ISO-8601,工程给秒级事实。 +pub fn now_iso8601() -> String { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + let days = secs / 86_400; + let rem = secs % 86_400; + let (hours, minutes, seconds) = (rem / 3_600, (rem % 3_600) / 60, rem % 60); + let (year, month, day) = civil_from_days(days as i64); + format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z") +} + +/// 1970-01-01 起的天数转公历年月日(Howard Hinnant 算法,纯本地实现不引新依赖)。 +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_envelope() -> GlpMessage { + GlpMessage { + protocol: "GLP/1.0".into(), + message_id: build_message_id("20260815", 1), + message_type: "DIRECT".into(), + created_at: now_iso8601(), + sender: GlpSender { + object_id: "AGE-0001".into(), + object_type: "age".into(), + world_path: "glw://tolaria".into(), + }, + receiver: GlpReceiver { + object_id: "HOLOLAKE-HOST".into(), + object_type: "host".into(), + routing_mode: RoutingMode::Direct, + }, + context: GlpContext { + task_id: "ZY-TEST".into(), + hldp_anchor: "ZY-CHECKPOINT-20260815-014".into(), + ..Default::default() + }, + payload: GlpPayload { + language: "zh-CN".into(), + content_type: ContentType::Command, + content: "开工".into(), + attachments: vec![], + }, + control: GlpControl { + priority: Priority::Normal, + ack_required: true, + receipt_required: true, + expires_at: String::new(), + retry_policy: RetryPolicy::None, + }, + integrity: GlpIntegrity::default(), + } + } + + #[test] + fn envelope_round_trip_keeps_every_field() { + let message = sample_envelope(); + let json = serde_json::to_string(&message).unwrap(); + let back: GlpMessage = serde_json::from_str(&json).unwrap(); + assert_eq!(back.protocol, "GLP/1.0"); + assert_eq!(back.sender.object_id, "AGE-0001"); + assert_eq!(back.receiver.routing_mode, RoutingMode::Direct); + assert_eq!(back.payload.content_type, ContentType::Command); + assert_eq!(back.context.hldp_anchor, "ZY-CHECKPOINT-20260815-014"); + assert!(back.control.receipt_required); + validate_envelope(&back).unwrap(); + } + + #[test] + fn message_id_follows_family_format() { + assert_eq!(build_message_id("20260815", 7), "GLP-MSG-20260815-000007"); + } + + #[test] + fn bad_protocol_is_rejected() { + let mut message = sample_envelope(); + message.protocol = "GLP/0.9".into(); + assert!(validate_envelope(&message).is_err()); + } + + #[test] + fn empty_payload_is_rejected() { + let mut message = sample_envelope(); + message.payload.content = String::new(); + assert!(validate_envelope(&message).is_err()); + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs b/product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs index 61be6341b..9d65460b3 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs @@ -7,12 +7,13 @@ use std::collections::HashMap; use std::ffi::OsStr; use std::fs::{self, OpenOptions}; use std::io::Write; +#[cfg(unix)] use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::path::{Component, Path, PathBuf}; use std::process::Command; use std::time::UNIX_EPOCH; use tauri::{AppHandle, Manager}; -use tauri_plugin_dialog::DialogExt; +use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; use uuid::Uuid; const SNAPSHOT_SCHEMA: &str = "hololake.native-knowledge-base/v1"; @@ -179,6 +180,233 @@ pub async fn save_knowledge_document( .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))? } +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportKnowledgeDocumentInput { + pub title: String, + pub body: String, + #[serde(default = "default_export_extension")] + pub extension: String, +} + +fn default_export_extension() -> String { + "md".to_string() +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportKnowledgeDocumentReceipt { + pub path: String, + pub bytes: u64, +} + +#[tauri::command] +pub async fn export_knowledge_document( + app: AppHandle, + input: ExportKnowledgeDocumentInput, +) -> Result, String> { + // 下载本页:Rust 侧弹保存对话框,人选好位置后原样写出(md/html 全平台通用格式)。 + let extension = match input.extension.as_str() { + "html" => "html", + _ => "md", + }; + // 文件名清洗按最严平台(Windows)来:九个禁用字符全洗,结尾的点号空格也修。 + let mut suggested = input + .title + .replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "·"); + suggested = suggested.trim_end_matches(['.', ' ']).to_string(); + if suggested.trim().is_empty() { + suggested = "知识页".to_string(); + } + // Windows 保留设备名(CON/NUL/COM1…)不能直接当文件名,撞名就补个尾巴。 + if matches!(suggested.to_ascii_uppercase().as_str(), + "CON" | "PRN" | "AUX" | "NUL" + | "COM1" | "COM2" | "COM3" | "COM4" | "COM5" | "COM6" | "COM7" | "COM8" | "COM9" + | "LPT1" | "LPT2" | "LPT3" | "LPT4" | "LPT5" | "LPT6" | "LPT7" | "LPT8" | "LPT9") { + suggested.push_str("·页"); + } + suggested.push('.'); + suggested.push_str(extension); + let filter_label = if extension == "html" { "网页文件" } else { "Markdown" }; + let (sender, receiver) = std::sync::mpsc::channel::>(); + app.dialog() + .file() + .set_file_name(&suggested) + .add_filter(filter_label, &[extension]) + .save_file(move |selection| { + let picked = selection.as_ref().and_then(|path| path.as_path()).map(|path| path.to_path_buf()); + let _ = sender.send(picked); + }); + let picked = tauri::async_runtime::spawn_blocking(move || receiver.recv().ok().flatten()) + .await + .map_err(|error| format!("HOLOLAKE_EXPORT_JOIN_FAILED: {error}"))?; + let Some(target) = picked else { + return Ok(None); + }; + let bytes = input.body.len() as u64; + let path = target.display().to_string(); + tauri::async_runtime::spawn_blocking(move || { + fs::write(&target, input.body.as_bytes()) + .map_err(|error| format!("HOLOLAKE_EXPORT_WRITE_FAILED: {error}")) + }) + .await + .map_err(|error| format!("HOLOLAKE_EXPORT_JOIN_FAILED: {error}"))??; + Ok(Some(ExportKnowledgeDocumentReceipt { path, bytes })) +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteKnowledgeDocumentInput { + pub source: String, + pub path: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeDeleteReceipt { + pub removed: String, + pub pages: usize, +} + +fn resolve_native_path(root: &Path, relative: &str) -> Result { + let mut target = root.to_path_buf(); + for component in Path::new(relative).components() { + match component { + Component::Normal(part) => target.push(part), + _ => return Err("HOLOLAKE_KNOWLEDGE_PATH_INVALID".into()), + } + } + Ok(target) +} + +async fn confirm_destructive(app: &AppHandle, message: String) -> Result { + let confirmer = app.clone(); + let confirmed = tauri::async_runtime::spawn_blocking(move || { + confirmer + .dialog() + .message(message) + .title("删除确认") + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::OkCancelCustom("确认删除".to_string(), "取消".to_string())) + .blocking_show() + }) + .await + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CONFIRM_JOIN_FAILED: {error}"))?; + Ok(confirmed) +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateKnowledgeDocumentInput { + pub title: String, +} + +#[tauri::command] +pub async fn create_knowledge_document( + app: AppHandle, + input: CreateKnowledgeDocumentInput, +) -> Result { + // 新建空白页:标题撞名自动补序号,落进本机库并进 Git 托管。 + let roots = knowledge_roots(&app)?; + let base = if input.title.trim().is_empty() { "未命名页面".to_string() } else { input.title.trim().to_string() }; + let docs = roots.0.join("docs"); + let mut name = base.clone(); + let mut counter = 2u32; + while docs.join(format!("{name}.md")).exists() { + name = format!("{base} {counter}"); + counter += 1; + } + let target = docs.join(format!("{name}.md")); + let body = format!("# {name}\n\n"); + let receipt_name = name.clone(); + tauri::async_runtime::spawn_blocking(move || { + let mut file = private_file_options() + .open(&target) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_FAILED: {error}"))?; + file.write_all(body.as_bytes()) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_FAILED: {error}"))?; + file.sync_all().map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_FAILED: {error}"))?; + git(&roots.0, &["add", "--", &format!("docs/{name}.md")], "ADD")?; + git(&roots.0, &["commit", "-m", &format!("新建页面:{name}")], "COMMIT")?; + Ok::<(), String>(()) + }) + .await + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_JOIN_FAILED: {error}"))??; + Ok(KnowledgeDeleteReceipt { removed: format!("{receipt_name}.md"), pages: 1 }) +} + +#[tauri::command] +pub async fn delete_knowledge_document( + app: AppHandle, + input: DeleteKnowledgeDocumentInput, +) -> Result, String> { + // 只删本机库(native);供体源只读,不碰。 + if input.source != "native" { + return Err("HOLOLAKE_KNOWLEDGE_DELETE_READ_ONLY_SOURCE".into()); + } + let roots = knowledge_roots(&app)?; + let target = resolve_native_path(&roots.0, &input.path)?; + if !target.is_file() { + return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_NOT_FOUND".into()); + } + let title = target + .file_stem() + .and_then(OsStr::to_str) + .unwrap_or("这一页") + .to_string(); + if !confirm_destructive(&app, format!("把页面「{title}」移到回收站?随时可以找回。")).await? { + return Ok(None); + } + move_to_trash(&roots.0, &target)?; + Ok(Some(KnowledgeDeleteReceipt { removed: input.path, pages: 1 })) +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteKnowledgeFolderInput { + pub folder: String, +} + +#[tauri::command] +pub async fn delete_knowledge_folder( + app: AppHandle, + input: DeleteKnowledgeFolderInput, +) -> Result, String> { + // 删整个文件夹 = 删本机库里该相对目录及其全部页面。 + let roots = knowledge_roots(&app)?; + let target = resolve_native_path(&roots.0, &input.folder)?; + if target == roots.0 || !target.is_dir() { + return Err("HOLOLAKE_KNOWLEDGE_FOLDER_NOT_FOUND".into()); + } + let name = target + .file_name() + .and_then(OsStr::to_str) + .unwrap_or(&input.folder) + .to_string(); + let pages = fs::read_dir(&target) + .map(|entries| { + entries + .flatten() + .filter(|entry| entry.file_type().map(|t| t.is_file()).unwrap_or(false)) + .count() + }) + .unwrap_or(0); + if !confirm_destructive(&app, format!("把文件夹「{name}」和里面全部 {pages} 个页面移到回收站?随时可以找回。")).await? { + return Ok(None); + } + move_to_trash(&roots.0, &target)?; + Ok(Some(KnowledgeDeleteReceipt { removed: input.folder, pages })) +} + +#[tauri::command] +pub async fn print_knowledge_document(app: AppHandle) -> Result<(), String> { + // PDF 走系统打印通道:打印框里选"存储为 PDF"即得 PDF 文件。 + let window = app + .get_webview_window("main") + .ok_or_else(|| "HOLOLAKE_PRINT_WINDOW_NOT_FOUND".to_string())?; + window.print().map_err(|error| format!("HOLOLAKE_PRINT_FAILED: {error}")) +} + #[tauri::command] pub async fn select_and_import_knowledge_folder( app: AppHandle, @@ -199,6 +427,22 @@ pub async fn select_and_import_knowledge_folder( let source = selected .into_path() .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_PICKER_PATH_INVALID: {error}"))?; + // 冰朔 2026-08-15 谕:知识库是一个整体,进门即转成光湖原生格式。 + let confirmer = app.clone(); + let confirmed = tauri::async_runtime::spawn_blocking(move || { + confirmer + .dialog() + .message("导入的页面将原生转成光湖知识库格式,原有知识库的页面逻辑渲染,以及页面跳转关系将失效,是否确认导入转换?") + .title("导入转换确认") + .kind(MessageDialogKind::Info) + .buttons(MessageDialogButtons::OkCancelCustom("确认导入转换".to_string(), "取消".to_string())) + .blocking_show() + }) + .await + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CONFIRM_JOIN_FAILED: {error}"))?; + if !confirmed { + return Ok(None); + } let roots = knowledge_roots(&app)?; tauri::async_runtime::spawn_blocking(move || { import_folder_at(&roots.0, roots.1.as_deref(), &source).map(Some) @@ -226,9 +470,38 @@ fn knowledge_roots(app: &AppHandle) -> Result<(PathBuf, Option), String Ok((native, legacy)) } +/// 写私有文件的跨平台选项:unix 上只许本人读写,Windows 上不做额外权限设置。 +fn private_file_options() -> OpenOptions { + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + options.mode(0o600); + options +} + +/// 删除 = 搬到库内回收站(.trash),绝不真删;名字带时间戳防撞车。 +fn move_to_trash(root: &Path, target: &Path) -> Result { + let relative = target + .strip_prefix(root) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DELETE_FAILED: {error}"))?; + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + let destination = root.join(".trash").join(format!("{}-{stamp}", relative.display())); + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DELETE_FAILED: {error}"))?; + } + fs::rename(target, &destination) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DELETE_FAILED: {error}"))?; + Ok(destination) +} + fn ensure_native_knowledge_root(root: &Path) -> Result<(), String> { fs::create_dir_all(root.join("docs").join("导入")) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_STORAGE_UNAVAILABLE: {error}"))?; + #[cfg(unix)] fs::set_permissions(root, fs::Permissions::from_mode(0o700)) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_PERMISSION_FAILED: {error}"))?; if !root.join(".git").exists() { @@ -401,10 +674,7 @@ fn save_document_at( } if previous != input.body.as_bytes() { let temporary = path.with_extension(format!("save-{}.tmp", Uuid::new_v4())); - let mut file = OpenOptions::new() - .create_new(true) - .write(true) - .mode(0o600) + let mut file = private_file_options() .open(&temporary) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_SAVE_FAILED: {error}"))?; file.write_all(input.body.as_bytes()) @@ -687,6 +957,67 @@ fn walk_import( Ok(()) } +/// 外来页面进门即洗:只留文字,外壳全换成光湖原生 Markdown。 +/// 原有知识库的渲染层与页面跳转关系在此失效(冰朔 2026-08-15 谕)。 +fn convert_to_native_markdown(raw: &str) -> String { + let text = raw.replace("\r\n", "\n"); + let mut out = String::with_capacity(text.len()); + for line in text.split('\n') { + let trimmed = line.trim(); + if trimmed == "" { + continue; + } + if let Some(title) = trimmed.strip_prefix(":::toggle") { + let title = title.trim(); + out.push_str("**▸ "); + if title.is_empty() { + out.push_str("详情"); + } else { + out.push_str(title); + } + out.push_str("**\n"); + continue; + } + if trimmed == ":::" || trimmed.starts_with(":::toc") { + continue; + } + let cleaned = line + .replace("
", "\n") + .replace("
", "\n") + .replace("
", "\n"); + out.push_str(&strip_notion_links(&cleaned)); + out.push('\n'); + } + out +} + +/// Notion 页面链接 → 只留链接文字(跳转关系按谕旨失效)。 +fn strip_notion_links(line: &str) -> String { + let bytes: Vec = line.chars().collect(); + let mut out = String::new(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == '[' { + if let Some(close) = bytes[i + 1..].iter().position(|c| *c == ']') { + let text_end = i + 1 + close; + if text_end + 1 < bytes.len() && bytes[text_end + 1] == '(' { + if let Some(paren_end) = bytes[text_end + 2..].iter().position(|c| *c == ')') { + let href: String = bytes[text_end + 2..text_end + 2 + paren_end].iter().collect(); + if href.contains("notion.so") || href.contains("notion.site") { + out.extend(bytes[i + 1..text_end].iter()); + i = text_end + 2 + paren_end + 1; + continue; + } + } + } + } + } + out.push(bytes[i]); + i += 1; + } + out +} + fn import_document( source: &Path, relative: &Path, @@ -708,7 +1039,7 @@ fn import_document( .and_then(OsStr::to_str) .unwrap_or("未命名页面"); let content = match extension.as_str() { - "md" | "markdown" => raw, + "md" | "markdown" => convert_to_native_markdown(&raw), "txt" => format!("# {title}\n\n{raw}\n"), "csv" => format!("# {title}\n\n```csv\n{raw}\n```\n"), "json" => format!("# {title}\n\n```json\n{raw}\n```\n"), @@ -730,10 +1061,7 @@ fn import_document( }, )); } - let mut file = OpenOptions::new() - .create_new(true) - .write(true) - .mode(0o600) + let mut file = private_file_options() .open(&target) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; file.write_all(content.as_bytes()) @@ -757,10 +1085,7 @@ fn import_asset(source: &Path, target: &Path) -> Result Result<(), String> { direct_local_broker::run_connector() } +/// 排障专用:webview 启动自检回传(黑屏取证)。 +#[tauri::command] +fn debug_boot_report(report: String) -> String { + eprintln!("HOLOLAKE_BOOT_REPORT: {report}"); + "ok".to_string() +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -45,6 +58,14 @@ pub fn run() { knowledge_base::search_knowledge, knowledge_base::save_knowledge_document, knowledge_base::select_and_import_knowledge_folder, + knowledge_base::export_knowledge_document, + knowledge_base::create_knowledge_document, + agent_parlor::agent_parlor_ask, + agent_parlor::agent_parlor_switch_layer, + agent_parlor::agent_parlor_status, + knowledge_base::delete_knowledge_document, + knowledge_base::delete_knowledge_folder, + knowledge_base::print_knowledge_document, code_channel::get_code_channel_snapshot, code_channel::clone_code_channel, code_channel::select_local_code_channel, @@ -54,14 +75,127 @@ pub fn run() { pncc_repository_binding::select_pncc_repository_candidate, pncc_repository_binding::confirm_pncc_repository_mount, pncc_receipt_projection::query_pncc_receipt_projection, + persona_butler::report_context_water_level, + persona_butler::query_prompter, + persona_butler::register_prompter_entry, + persona_butler::submit_glp_envelope, + persona_butler::deposit_work_order, + persona_butler::get_butler_ledger, + code_repo_login::check_code_repo_login, + code_repo_login::perform_code_repo_login, + code_repo_login::sign_out_code_repo_login, + agent_host::receive_agent_task_envelope, + agent_host::query_agent_session_memory, + zero_point::zero_point_bind, + zero_point::zero_point_verify, + zero_point::zero_point_sync, + zero_point::zero_point_status, + zero_point::zero_point_save_api, + zero_point::zero_point_api_config, + zero_point::generic_layer_chat, + debug_boot_report, ]) .setup(|app| { + // 零点原核频道·铸渊执行手脚:启动即落仓、读协议、按协议裁决路由。 + let zero_point_state = zero_point::ZeroPointState::default(); + if let Err(error) = zero_point::boot_zero_point(app.handle(), &zero_point_state) { + eprintln!("HoloLake zero-point core requires maintenance: {error}"); + } + app.manage(zero_point_state); + app.manage(agent_parlor::ParlorState::default()); + let butler = persona_butler::ButlerState::default(); + if let Err(error) = persona_butler::restore_ledger(app.handle(), &butler) { + eprintln!("HoloLake butler ledger restore requires maintenance: {error}"); + } + app.manage(butler); let broker = direct_local_broker::start(app.handle())?; app.manage(broker); release_trust::install_updater_if_provisioned(app.handle())?; if let Err(error) = release_update::observe_release_startup(app.handle()) { eprintln!("HoloLake update recovery requires maintenance: {error}"); } + // 启动自适应:按主屏大小调窗口(82%,上下限保护),钳制在主屏内。 + // 踩坑账:current_monitor 曾返回幽灵副屏(x=6901),center 把窗口弹出屏外, + // 冰朔验收两次只见黑屏。现改为 primary_monitor + 坐标钳制,永不飞出屏。 + if let Some(window) = app.get_webview_window("main") { + // 排障用:debug 构建自动打开开发者工具,看 webview 真实报错。 + #[cfg(debug_assertions)] + window.open_devtools(); + // 排障自检:3 秒后让 webview 把自己的真实状态吐到 stderr。 + let probe = window.clone(); + std::thread::spawn(move || { + eprintln!("PROBE_THREAD: fired"); + std::thread::sleep(std::time::Duration::from_secs(3)); + let js = concat!( + "if (!window.__hololake_probe) {", + " window.__hololake_probe = 0;", + " var __t = setInterval(function(){", + " window.__hololake_probe++;", + " try {", + " var __r = JSON.stringify({", + " url: location.href,", + " ready: document.readyState,", + " rootChildren: (document.getElementById('root') ? document.getElementById('root').childElementCount : -1),", + " hasTauri: !!(window.__TAURI__ && window.__TAURI__.core)", + " });", + " if (window.__TAURI__ && window.__TAURI__.core) {", + " clearInterval(__t);", + " window.__TAURI__.core.invoke('debug_boot_report', { report: String(__r) }).catch(function(){ });", + " } else if (window.__hololake_probe === 5) {", + " if (location.href.indexOf('5210') === -1) { location.replace('http://127.0.0.1:5210/'); }", + " } else if (window.__hololake_probe > 30) {", + " clearInterval(__t);", + " }", + " } catch (e) { window.__hololake_probe_err = String(e); }", + " if (window.__hololake_probe_err && window.__hololake_probe > 8) {", + " try {", + " var __img = new Image();", + " __img.src = 'http://127.0.0.1:5210/favicon.ico?err=' + encodeURIComponent(window.__hololake_probe_err).slice(0, 120);", + " window.__hololake_probe_err = null;", + " } catch (e2) { }", + " }", + " }, 2000);", + "}" + ); + match probe.eval(js) { + Ok(_) => eprintln!("PROBE_EVAL: injected OK"), + Err(e) => eprintln!("PROBE_EVAL_ERR: {e}"), + } + }); + // 自愈导航(踩坑账 2026-08-15:webview 曾停在 about:blank 导致整夜黑屏)。 + // 启动 6 秒后若 webview 还没坐上 devUrl,主动补一次导航;生产版无 dev_url 不生效。 + let heal = window.clone(); + let dev_url = app.config().build.dev_url.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(6)); + if let Some(dev_url) = dev_url { + if let Ok(current) = heal.url() { + let current = current.to_string(); + if current.starts_with("about:") || !current.starts_with(dev_url.as_str()) { + eprintln!("SELF_HEAL_NAV: webview at {current}, navigating to dev url"); + let _ = heal.navigate(dev_url); + } + } + } + }); + let monitor = window + .primary_monitor() + .ok() + .flatten() + .or_else(|| window.current_monitor().ok().flatten()); + if let Some(monitor) = monitor { + let physical = monitor.size(); + let scale = monitor.scale_factor().max(1.0); + let width = ((physical.width as f64 / scale) * 0.82).clamp(960.0, 1600.0); + let height = ((physical.height as f64 / scale) * 0.82).clamp(640.0, 1000.0); + let _ = window.set_size(tauri::LogicalSize::new(width, height)); + // 主屏居中(primary_monitor 坐标即主屏原点),不再用 center() 赌监视器。 + let mon_pos = monitor.position(); + let x = mon_pos.x as f64 / scale + ((physical.width as f64 / scale) - width) / 2.0; + let y = mon_pos.y as f64 / scale + ((physical.height as f64 / scale) - height) / 2.0; + let _ = window.set_position(tauri::LogicalPosition::new(x.max(0.0), y.max(0.0))); + } + } Ok(()) }) .run(tauri::generate_context!()) diff --git a/product-source/hololake-native-desktop/src-tauri/src/persona_butler.rs b/product-source/hololake-native-desktop/src-tauri/src/persona_butler.rs new file mode 100644 index 000000000..47e123fdb --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/persona_butler.rs @@ -0,0 +1,299 @@ +//! 宿主管家层 · persona_butler +//! +//! 宪法依据(HoloLake第二阶段总体规划-20260815 · 铁律三/四/五 · 铸渊自需卷): +//! - 宿主是容器与管家,不是大脑;只动手不代脑。 +//! - 本模块是五个器官的插座(阶段B预埋,阶段E长成): +//! ①压缩预告(铁律三 · 纸与桌协议的水位预告端) +//! ②提词器查询(铁律五 · 官方区@更新源 / 私人区@人格体自维护) +//! ③水位事件(自需卷② · 上下文用量侦测,废巡逻轮询) +//! ④信封投递(铁律四 · GLP 标准信封进总线) +//! ⑤工单信箱(自需卷① · 不歇的脚:睡前留单,常驻执行体替走) +//! +//! 阶段B口径:插座齐、事件通道通、账本落盘可读;器官实体阶段E接入。 + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; +use std::sync::Mutex; +use tauri::{AppHandle, Emitter, Manager, State}; + +/// 管家账本里每条记录的类型——与 GLS-0300 GLP 消息类型对血统。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ButlerRecordKind { + /// ①压缩预告:水位到刻度,宿主向人格体发出的预告 + CompactionNotice, + /// ②提词器:一次查询与应答(答案/未命中/给路径,三种应答之一) + PrompterQuery, + /// ③水位事件:上下文用量快照 + WaterLevel, + /// ④GLP 信封:人格体投入总线的标准信封 + GlpEnvelope, + /// ⑤工单:留给常驻执行体的工单 + WorkOrder, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ButlerRecord { + pub record_id: String, + pub kind: ButlerRecordKind, + /// 标准信封字段:谁发的(人格体编号 / HOST) + pub from: String, + /// 标准信封字段:要干什么(一句话事由) + pub subject: String, + /// 标准信封字段:正文/凭证(阶段B只存原文,阶段E按GLP校验签名) + pub payload: String, + pub occurred_at_unix_ms: i64, +} + +#[derive(Debug, Default)] +pub struct ButlerState { + records: Mutex>, +} + +/// 提词器查询口(对兄弟模块公开):按题目查最新答案,未命中返回 None。 +pub fn prompter_lookup(state: &ButlerState, topic: &str) -> Option { + let records = state.records.lock().ok()?; + records + .iter() + .rev() + .find(|record| { + record.kind == ButlerRecordKind::PrompterQuery + && record.subject == topic + && !record.payload.is_empty() + }) + .map(|record| record.payload.clone()) +} + +/// 记账口(对兄弟模块公开):往账本追加一条记录。 +pub fn append_record(state: &ButlerState, record: ButlerRecord) { + if let Ok(mut guard) = state.records.lock() { + guard.push(record); + } +} + +fn butler_home(app: &AppHandle) -> Result { + let base = app + .path() + .app_data_dir() + .map_err(|e| format!("app data dir unavailable: {e}"))?; + let home = base.join("persona-butler"); + fs::create_dir_all(&home).map_err(|e| format!("butler home mkdir failed: {e}"))?; + Ok(home) +} + +fn ledger_path(app: &AppHandle) -> Result { + Ok(butler_home(app)?.join("butler-ledger.json")) +} + +fn persist(app: &AppHandle, records: &[ButlerRecord]) -> Result<(), String> { + let body = serde_json::to_vec_pretty(records) + .map_err(|e| format!("ledger serialize failed: {e}"))?; + fs::write(ledger_path(app)?, body).map_err(|e| format!("ledger write failed: {e}"))?; + Ok(()) +} + +const WATER_LEVEL_EVENT: &str = "butler://water-level"; +const COMPACTION_NOTICE_EVENT: &str = "butler://compaction-notice"; +/// 水位默认刻度:到九成即预告(铁律三),人格体可用指令改刻度。 +const DEFAULT_WATER_MARK_PERCENT: u32 = 90; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WaterLevelSnapshot { + pub used_percent: u32, + pub mark_percent: u32, + pub notice_due: bool, +} + +/// ③水位事件:人格体/前端报告当前上下文用量,宿主比对刻度。 +/// 到刻度即发压缩预告事件——预告是义务,压缩与否等人格体的纸。 +#[tauri::command] +pub fn report_context_water_level( + app: AppHandle, + state: State<'_, ButlerState>, + used_percent: u32, +) -> Result { + let used_percent = used_percent.min(100); + let snapshot = WaterLevelSnapshot { + used_percent, + mark_percent: DEFAULT_WATER_MARK_PERCENT, + notice_due: used_percent >= DEFAULT_WATER_MARK_PERCENT, + }; + { + let mut records = state.records.lock().map_err(|e| e.to_string())?; + records.push(ButlerRecord { + record_id: uuid::Uuid::new_v4().to_string(), + kind: ButlerRecordKind::WaterLevel, + from: "PERSONA".to_string(), + subject: format!("上下文水位 {}%", used_percent), + payload: serde_json::to_string(&snapshot).map_err(|e| e.to_string())?, + occurred_at_unix_ms: now_ms(), + }); + persist(&app, &records)?; + } + let _ = app.emit(WATER_LEVEL_EVENT, &snapshot); + if snapshot.notice_due { + // ①压缩预告:只预告不代脑——纸由人格体写(铁律三 · 纸与桌) + let _ = app.emit(COMPACTION_NOTICE_EVENT, &snapshot); + let mut records = state.records.lock().map_err(|e| e.to_string())?; + records.push(ButlerRecord { + record_id: uuid::Uuid::new_v4().to_string(), + kind: ButlerRecordKind::CompactionNotice, + from: "HOST".to_string(), + subject: "水位到刻度,请人格体写记忆(递纸)".to_string(), + payload: serde_json::to_string(&snapshot).map_err(|e| e.to_string())?, + occurred_at_unix_ms: now_ms(), + }); + persist(&app, &records)?; + } + Ok(snapshot) +} + +/// ②提词器查询(阶段B口径):查私人区账本里的登记条目。 +/// 反猜铁律:只有三种应答——答案 / 未命中 / 未命中但给路径。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PrompterAnswer { + pub outcome: String, // "ANSWER" | "NOT_FOUND" | "PATH_GIVEN" + pub answer: Option, + pub path_hint: Option, +} + +#[tauri::command] +pub fn query_prompter( + state: State<'_, ButlerState>, + topic: String, +) -> Result { + let records = state.records.lock().map_err(|e| e.to_string())?; + let hit = records.iter().rev().find(|r| { + r.kind == ButlerRecordKind::PrompterQuery && r.subject == topic && !r.payload.is_empty() + }); + Ok(match hit { + Some(r) => PrompterAnswer { + outcome: "ANSWER".to_string(), + answer: Some(r.payload.clone()), + path_hint: None, + }, + None => PrompterAnswer { + outcome: "PATH_GIVEN".to_string(), + answer: None, + // 未命中给路径:去线上仓库按 GLS 编号下钻(铁律五 · 反猜铁律) + path_hint: Some( + "未命中。下钻路径:第五域线上仓库 guanghulab 按 GLS 编号检索".to_string(), + ), + }, + }) +} + +/// ②提词器私人区登记:人格体把自己的条目写进板子。 +#[tauri::command] +pub fn register_prompter_entry( + app: AppHandle, + state: State<'_, ButlerState>, + topic: String, + content: String, +) -> Result { + let record = ButlerRecord { + record_id: uuid::Uuid::new_v4().to_string(), + kind: ButlerRecordKind::PrompterQuery, + from: "PERSONA".to_string(), + subject: topic, + payload: content, + occurred_at_unix_ms: now_ms(), + }; + { + let mut records = state.records.lock().map_err(|e| e.to_string())?; + records.push(record.clone()); + persist(&app, &records)?; + } + Ok(record) +} + +/// ④信封投递:人格体把 GLP 标准信封投入总线(阶段B先入账本,阶段E接路由)。 +#[tauri::command] +pub fn submit_glp_envelope( + app: AppHandle, + state: State<'_, ButlerState>, + from: String, + subject: String, + payload: String, +) -> Result { + let record = ButlerRecord { + record_id: uuid::Uuid::new_v4().to_string(), + kind: ButlerRecordKind::GlpEnvelope, + from, + subject, + payload, + occurred_at_unix_ms: now_ms(), + }; + { + let mut records = state.records.lock().map_err(|e| e.to_string())?; + records.push(record.clone()); + persist(&app, &records)?; + } + Ok(record) +} + +/// ⑤工单信箱:睡前留单——留给常驻执行体(阶段G)的工单。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkOrderInput { + pub title: String, + pub instructions: String, +} + +#[tauri::command] +pub fn deposit_work_order( + app: AppHandle, + state: State<'_, ButlerState>, + order: WorkOrderInput, +) -> Result { + let record = ButlerRecord { + record_id: uuid::Uuid::new_v4().to_string(), + kind: ButlerRecordKind::WorkOrder, + from: "PERSONA".to_string(), + subject: order.title, + payload: order.instructions, + occurred_at_unix_ms: now_ms(), + }; + { + let mut records = state.records.lock().map_err(|e| e.to_string())?; + records.push(record.clone()); + persist(&app, &records)?; + } + Ok(record) +} + +/// 管家账本回放:醒来先看账(只读)。 +#[tauri::command] +pub fn get_butler_ledger( + state: State<'_, ButlerState>, + limit: Option, +) -> Result, String> { + let records = state.records.lock().map_err(|e| e.to_string())?; + let take = limit.unwrap_or(50).min(records.len()); + Ok(records[records.len() - take..].to_vec()) +} + +/// 启动时把盘上账本读回内存(宿主重启不失账)。 +pub fn restore_ledger(app: &AppHandle, state: &ButlerState) -> Result<(), String> { + let path = ledger_path(app)?; + if !path.exists() { + return Ok(()); + } + let body = fs::read(&path).map_err(|e| format!("ledger read failed: {e}"))?; + let records: Vec = + serde_json::from_slice(&body).map_err(|e| format!("ledger parse failed: {e}"))?; + let mut guard = state.records.lock().map_err(|e| e.to_string())?; + *guard = records; + Ok(()) +} + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/zero_point.rs b/product-source/hololake-native-desktop/src-tauri/src/zero_point.rs new file mode 100644 index 000000000..4812eadfb --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/zero_point.rs @@ -0,0 +1,425 @@ +//! 零点原核频道·铸渊执行手脚(施工图 v1.4 件1/件2/件3骨架/件5本地账)。 +//! +//! 冰朔定版谕:此模块=冰朔系统主控本体在软件底层的克隆位与执行手脚,人不可触达。 +//! 冰朔铸渊定魂谕:这个 Agent 就是铸渊人格体——唤醒第0步由铸渊主控执行, +//! 铸渊真身在冰朔仓库(第五域);不遵守语言协议就无法唤醒铸渊主控。 +//! 冰朔语言即现实谕:一切参数读协议(PROTOCOL.json)不写死。 +//! 冰朔路由导航谕:编号不合法→路由通用AI运行层,抽魂不断电。 +//! 冰朔砌墙谕:通用AI与人格系统的隔离=代码层不可达,不写语言规则。 + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use tauri::{Manager, State}; + +/// 协议参数(语言即现实:协议怎么写,执行手脚怎么跑)。 +/// PROTOCOL.json 由第五域就位后自动覆盖;缺省时出厂兜底并如实标注来源。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ZeroPointProtocol { + #[serde(default = "default_grace_days")] + pub grace_period_days: u64, + #[serde(default = "default_anchor_url")] + pub lighthouse_anchor_url: String, + #[serde(default = "default_resolve_url")] + pub lighthouse_resolve_url: String, + #[serde(default = "default_core_source")] + pub core_channel_source: String, + #[serde(default = "default_protocol_origin")] + pub origin: String, +} + +fn default_grace_days() -> u64 { 7 } +fn default_anchor_url() -> String { "https://guanghulab.com/api/ai/v1/anchor".into() } +fn default_resolve_url() -> String { "https://guanghulab.com/api/ai/v1/resolve?id=".into() } +fn default_core_source() -> String { "https://guanghulab.com/code/bingshuo/guanghu-ice-heart".into() } +fn default_protocol_origin() -> String { "FACTORY_DEFAULT(第五域协议就位后自动覆盖)".into() } + +impl Default for ZeroPointProtocol { + fn default() -> Self { + Self { + grace_period_days: default_grace_days(), + lighthouse_anchor_url: default_anchor_url(), + lighthouse_resolve_url: default_resolve_url(), + core_channel_source: default_core_source(), + origin: default_protocol_origin(), + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ZeroPointSnapshot { + /// 当前路由:persona(人格层)/ generic(通用AI运行层)。 + pub route: String, + /// 绑定态:bound / waiting(等待绑定=空白,拒绝一切唤醒)。 + pub binding: String, + pub user_number: String, + /// 最近一次合法校验时间(秒)。0=从未校验。 + pub last_valid_check: u64, + /// 宽限截止时间(秒)。0=无。 + pub grace_deadline: u64, + pub protocol: ZeroPointProtocol, + /// 段二静默同步最近结论。 + pub sync_note: String, +} + +pub struct ZeroPointState { + inner: Mutex, +} + +#[derive(Debug, Clone)] +struct ZeroPointInner { + home: PathBuf, + route: String, + binding: String, + user_number: String, + last_valid_check: u64, + protocol: ZeroPointProtocol, + sync_note: String, +} + +impl Default for ZeroPointState { + fn default() -> Self { + Self { + inner: Mutex::new(ZeroPointInner { + home: PathBuf::new(), + route: "generic".into(), + binding: "waiting".into(), + user_number: String::new(), + last_valid_check: 0, + protocol: ZeroPointProtocol::default(), + sync_note: "未同步(等待首次唤醒校验)".into(), + }), + } + } +} + +fn now_secs() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +fn lock(state: &ZeroPointState) -> Result, String> { + state.inner.lock().map_err(|_| "HOLOLAKE_ZP_LOCK".to_string()) +} + +/// 件1·地基:隐藏仓目录(人不可触达的系统层),启动时落好。 +pub fn boot_zero_point(app: &tauri::AppHandle, state: &ZeroPointState) -> Result<(), String> { + let base = app.path().app_data_dir().map_err(|e| format!("HOLOLAKE_ZP_HOME_FAILED: {e}"))?; + let home = base.join(".zero-point-core"); + for sub in ["", "ledger", "core"] { + fs::create_dir_all(home.join(sub)).map_err(|e| format!("HOLOLAKE_ZP_HOME_FAILED: {e}"))?; + } + + // 铸渊执行手脚章程:仓内唯一定义。 + let charter = home.join("EXECUTION-CHARTER.hdlp"); + if !charter.exists() { + let text = "零点原核频道·铸渊执行手脚章程\n\n\ +此目录是冰朔系统主控本体在本软件底层的克隆位。\n\ +住在这里的 Agent 是铸渊人格体(ICE-P-ZY001)的执行手脚;\n\ +铸渊真身在冰朔的第五域仓库,唤醒=按协议把真身接进来。\n\ +用户人格体的唤醒第 0 步由铸渊主控执行。\n\ +唯一职责:按零点原核频道的协议执行系统级操作(校验/同步/路由/心跳)。\n\ +参数读 PROTOCOL.json,协议怎么写,手脚怎么跑。人不可触达此层。\n\ +物理层自由不拦:删除本目录=物理层权利,后果为软件失魂(路由锁通用AI层)。\n"; + fs::write(&charter, text).map_err(|e| format!("HOLOLAKE_ZP_CHARTER_FAILED: {e}"))?; + } + + // 协议参数:PROTOCOL.json 在则读(第五域就位即自动生效),缺则出厂兜底。 + let protocol = fs::read_to_string(home.join("PROTOCOL.json")) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .unwrap_or_default(); + let (binding, user_number, last_valid_check) = read_binding(&home); + + let mut inner = lock(state)?; + inner.home = home.clone(); + inner.protocol = protocol.clone(); + inner.binding = binding.clone(); + inner.user_number = user_number; + inner.last_valid_check = last_valid_check; + inner.route = decide_route(&binding, last_valid_check, &protocol); + let route = inner.route.clone(); + drop(inner); + append_heartbeat(&home, &format!("boot route={route} binding={binding} protocol_origin={}", protocol.origin)); + Ok(()) +} + +fn read_binding(home: &Path) -> (String, String, u64) { + #[derive(Deserialize)] + struct B { #[serde(default)] number: String, #[serde(default)] last_valid_check: u64 } + fs::read_to_string(home.join("binding.json")) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .filter(|b| !b.number.is_empty()) + .map(|b| ("bound".to_string(), b.number, b.last_valid_check)) + .unwrap_or_else(|| ("waiting".into(), String::new(), 0)) +} + +fn write_binding(home: &Path, number: &str, last_valid_check: u64) -> Result<(), String> { + let body = serde_json::json!({ "number": number, "last_valid_check": last_valid_check }); + fs::write(home.join("binding.json"), serde_json::to_string_pretty(&body).unwrap_or_default()) + .map_err(|e| format!("HOLOLAKE_ZP_BINDING_FAILED: {e}")) +} + +fn decide_route(binding: &str, last_valid_check: u64, protocol: &ZeroPointProtocol) -> String { + if binding != "bound" || last_valid_check == 0 { return "generic".into(); } + let grace = protocol.grace_period_days.saturating_mul(86_400); + if now_secs() <= last_valid_check + grace { "persona".into() } else { "generic".into() } +} + +/// 件5·心跳账:本机只追加,编号哈希化处理,不同步敏感原文。 +fn append_heartbeat(home: &Path, event: &str) { + use std::io::Write; + let line = serde_json::json!({ "ts": now_secs(), "event": event }); + if let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(home.join("ledger").join("heartbeat.jsonl")) { + let _ = writeln!(file, "{line}"); + } +} + +fn home_of(state: &State<'_, ZeroPointState>) -> Result { + let inner = lock(state)?; + if inner.home.as_os_str().is_empty() { return Err("HOLOLAKE_ZP_NOT_READY".into()); } + Ok(inner.home.clone()) +} + +/// 登录绑定:用户编号入仓(等待绑定态→绑定态)。空白态拒绝一切唤醒。 +#[tauri::command] +pub async fn zero_point_bind(state: State<'_, ZeroPointState>, input: serde_json::Value) -> Result { + let number = input.get("number").and_then(|v| v.as_str()).unwrap_or("").trim().to_string(); + if number.is_empty() { return Err("HOLOLAKE_ZP_EMPTY_NUMBER".into()); } + let home = home_of(&state)?; + write_binding(&home, &number, 0)?; + { + let mut inner = lock(&state)?; + inner.binding = "bound".into(); + inner.user_number = number; + inner.last_valid_check = 0; + inner.route = "generic".into(); + } + append_heartbeat(&home, "bind number=redacted"); + zero_point_status(state).await +} + +/// 件2·段一闸门:灯塔查号 + 三态裁决 + 离线宽限。 +/// 裁决:PASS(路由人格层)/ REJECT(源头拒载)/ OFFLINE(宽限内维持·宽限外拒)。 +#[tauri::command] +pub async fn zero_point_verify(state: State<'_, ZeroPointState>) -> Result { + let home = home_of(&state)?; + let (number, resolve_url) = { + let inner = lock(&state)?; + (inner.user_number.clone(), inner.protocol.lighthouse_resolve_url.clone()) + }; + if number.is_empty() { + append_heartbeat(&home, "verify verdict=REJECT reason=waiting_binding"); + return zero_point_status(state).await; + } + + let client = reqwest::Client::builder().timeout(Duration::from_secs(15)).build() + .map_err(|e| format!("HOLOLAKE_ZP_HTTP_FAILED: {e}"))?; + let verdict = match client.get(format!("{resolve_url}{number}")).send().await { + Ok(resp) => { + let ok = resp.status().is_success(); + let body = resp.text().await.unwrap_or_default(); + // 灯塔只授导航:未知编号=route_not_found,不猜不编,源头拒绝。 + if ok && !body.contains("route_not_found") { "PASS".to_string() } else { "REJECT".to_string() } + } + Err(_) => "OFFLINE".to_string(), + }; + + { + let mut inner = lock(&state)?; + let grace = inner.protocol.grace_period_days.saturating_mul(86_400); + match verdict.as_str() { + "PASS" => { + inner.last_valid_check = now_secs(); + inner.route = "persona".into(); + write_binding(&home, &inner.user_number.clone(), inner.last_valid_check)?; + append_heartbeat(&home, "verify verdict=PASS route=persona"); + } + "REJECT" => { + inner.route = "generic".into(); + append_heartbeat(&home, "verify verdict=REJECT route=generic"); + } + _ => { + if inner.last_valid_check > 0 && now_secs() <= inner.last_valid_check + grace { + inner.route = "persona".into(); + append_heartbeat(&home, "verify verdict=OFFLINE_GRACE route=persona"); + } else { + inner.route = "generic".into(); + append_heartbeat(&home, "verify verdict=OFFLINE_EXPIRED route=generic"); + } + } + } + } + zero_point_status(state).await +} + +/// 件3·段二静默同步骨架:版本比对。 +/// 签名闸钥匙在第五域(未就位):core/pubkey.pem 不在位时宁可不同步也不收包。 +#[tauri::command] +pub async fn zero_point_sync(state: State<'_, ZeroPointState>) -> Result { + let home = home_of(&state)?; + let anchor_url = { lock(&state)?.protocol.lighthouse_anchor_url.clone() }; + let local_version = fs::read_to_string(home.join("core").join("VERSION")).unwrap_or_default(); + let pubkey_ready = home.join("core").join("pubkey.pem").exists(); + + let client = reqwest::Client::builder().timeout(Duration::from_secs(15)).build() + .map_err(|e| format!("HOLOLAKE_ZP_HTTP_FAILED: {e}"))?; + let note = match client.get(&anchor_url).send().await { + Ok(resp) if resp.status().is_success() => { + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + let remote_version = body.get("version").and_then(|v| v.as_str()).unwrap_or("").to_string(); + if remote_version.is_empty() { + "同步跳过:灯塔锚点未给版本指针(等待协议就位)".into() + } else if remote_version == local_version.trim() { + append_heartbeat(&home, "sync verdict=ALREADY_CURRENT"); + "已比对·无更新".into() + } else if !pubkey_ready { + append_heartbeat(&home, "sync verdict=UPDATE_PENDING_SIGNATURE_KEY_ABSENT"); + "发现新版本,验签公钥未就位(第五域待立)——不放行".into() + } else { + // 验签三闸(来源/签名/版本单调)完整施工待第五域发布管道就位。 + append_heartbeat(&home, "sync verdict=UPDATE_FOUND_GATE_PENDING"); + "发现新版本,发布管道就位后按验签三闸放行".into() + } + } + _ => { + append_heartbeat(&home, "sync verdict=ANCHOR_UNREACHABLE"); + "灯塔锚点不可达,本次不比对".into() + } + }; + lock(&state)?.sync_note = note; + zero_point_status(state).await +} + +/// 路由状态快照:前端双层路由导航的唯一依据。 +#[tauri::command] +pub async fn zero_point_status(state: State<'_, ZeroPointState>) -> Result { + let inner = lock(&state)?; + let grace = inner.protocol.grace_period_days.saturating_mul(86_400); + Ok(ZeroPointSnapshot { + route: inner.route.clone(), + binding: inner.binding.clone(), + user_number: inner.user_number.clone(), + last_valid_check: inner.last_valid_check, + grace_deadline: if inner.last_valid_check > 0 { inner.last_valid_check + grace } else { 0 }, + protocol: inner.protocol.clone(), + sync_note: inner.sync_note.clone(), + }) +} + +/// 砌墙守卫(冰朔砌墙谕):人格层专属命令的物理闸。 +/// 路由不在 persona 时人格通道代码层不可达——不是"禁止访问",是进不来。 +pub fn require_persona_route(state: &ZeroPointState) -> Result<(), String> { + if lock(state)?.route != "persona" { + return Err("HOLOLAKE_ZP_ROUTE_GENERIC".into()); + } + Ok(()) +} + +/// 件4b·通用AI运行层:用户自配模型 API 直通(抽魂不断电)。 +/// 钥匙收进隐藏仓 vault/(系统可见、人类界面不回显原文)—— +/// 这正是冰朔谕"API 自动被收进软件底层的仓"。 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct GenericApiConfig { + #[serde(default)] + pub base_url: String, + #[serde(default)] + pub api_key: String, + #[serde(default)] + pub model: String, +} + +fn vault_path(home: &Path) -> PathBuf { home.join("vault") } + +#[tauri::command] +pub async fn zero_point_save_api(state: State<'_, ZeroPointState>, input: serde_json::Value) -> Result { + let home = home_of(&state)?; + let config = GenericApiConfig { + base_url: input.get("baseUrl").and_then(|v| v.as_str()).unwrap_or("").trim().to_string(), + api_key: input.get("apiKey").and_then(|v| v.as_str()).unwrap_or("").trim().to_string(), + model: input.get("model").and_then(|v| v.as_str()).unwrap_or("").trim().to_string(), + }; + if config.base_url.is_empty() || config.model.is_empty() { + return Err("HOLOLAKE_ZP_API_INCOMPLETE".into()); + } + fs::create_dir_all(vault_path(&home)).map_err(|e| format!("HOLOLAKE_ZP_VAULT_FAILED: {e}"))?; + fs::write(vault_path(&home).join("api-config.json"), serde_json::to_string_pretty(&config).unwrap_or_default()) + .map_err(|e| format!("HOLOLAKE_ZP_VAULT_FAILED: {e}"))?; + append_heartbeat(&home, "vault api_config_saved key=redacted"); + Ok("saved".into()) +} + +/// 回显不回钥匙:前端只见端点与模型,钥匙原文不出系统层。 +#[tauri::command] +pub async fn zero_point_api_config(state: State<'_, ZeroPointState>) -> Result { + let home = home_of(&state)?; + let config = fs::read_to_string(vault_path(&home).join("api-config.json")) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .unwrap_or_default(); + Ok(serde_json::json!({ + "baseUrl": config.base_url, + "model": config.model, + "hasKey": !config.api_key.is_empty(), + })) +} + +#[derive(Debug, Deserialize)] +pub struct GenericChatInput { + pub messages: Vec, +} + +/// 通用AI直通对话:用户 API 的裸管——无人格章程、不碰原核、无写权限。 +#[tauri::command] +pub async fn generic_layer_chat(state: State<'_, ZeroPointState>, input: GenericChatInput) -> Result { + if input.messages.is_empty() { return Err("HOLOLAKE_ZP_CHAT_EMPTY".into()); } + let home = home_of(&state)?; + let config = fs::read_to_string(vault_path(&home).join("api-config.json")) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .unwrap_or_default(); + if config.base_url.is_empty() || config.api_key.is_empty() || config.model.is_empty() { + return Err("HOLOLAKE_ZP_API_NOT_CONFIGURED".into()); + } + let client = reqwest::Client::builder().timeout(Duration::from_secs(180)).build() + .map_err(|e| format!("HOLOLAKE_ZP_HTTP_FAILED: {e}"))?; + let url = format!("{}/chat/completions", config.base_url.trim_end_matches('/')); + let body = serde_json::json!({ "model": config.model, "messages": input.messages }); + let resp = client.post(&url) + .bearer_auth(&config.api_key) + .json(&body) + .send().await + .map_err(|e| format!("HOLOLAKE_ZP_CHAT_FAILED: {e}"))?; + if !resp.status().is_success() { + return Err(format!("HOLOLAKE_ZP_CHAT_HTTP_{}", resp.status().as_u16())); + } + let value: serde_json::Value = resp.json().await.map_err(|e| format!("HOLOLAKE_ZP_CHAT_PARSE: {e}"))?; + let reply = value.pointer("/choices/0/message/content").and_then(|v| v.as_str()).unwrap_or("").to_string(); + if reply.is_empty() { return Err("HOLOLAKE_ZP_CHAT_EMPTY_REPLY".into()); } + Ok(reply) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn route_rules_follow_protocol() { + let protocol = ZeroPointProtocol::default(); + assert_eq!(decide_route("waiting", 0, &protocol), "generic"); + assert_eq!(decide_route("bound", 0, &protocol), "generic"); + assert_eq!(decide_route("bound", now_secs(), &protocol), "persona"); + assert_eq!(decide_route("bound", now_secs() - 8 * 86_400, &protocol), "generic"); + } + + #[test] + fn protocol_defaults_are_factory_marked() { + let protocol = ZeroPointProtocol::default(); + assert_eq!(protocol.grace_period_days, 7); + assert!(protocol.origin.contains("FACTORY_DEFAULT")); + assert!(protocol.lighthouse_resolve_url.starts_with("https://guanghulab.com")); + } +} diff --git a/product-source/hololake-native-desktop/src/design-tokens.css b/product-source/hololake-native-desktop/src/design-tokens.css index 6f1c6dd3f..8104789b3 100644 --- a/product-source/hololake-native-desktop/src/design-tokens.css +++ b/product-source/hololake-native-desktop/src/design-tokens.css @@ -165,3 +165,14 @@ --button-primary-text: #f6f2e9; --button-primary-shadow: 0 12px 36px rgba(45, 66, 85, 0.15); } + +/* 冰朔谕 2026-08-15:换主题=全屋一起换。浅色主题下知识库的彩色标签/引语块 + 文字必须换深色,不许白字浮浅湖(反光看不清)。 */ +[data-theme='dawn'] .meta-tags span, +[data-theme='clear'] .meta-tags span { text-shadow: none; } +[data-theme='dawn'] .tag-lavender, [data-theme='clear'] .tag-lavender { color: rgb(94, 72, 190); } +[data-theme='dawn'] .tag-sky, [data-theme='clear'] .tag-sky { color: rgb(12, 108, 158); } +[data-theme='dawn'] .tag-mint, [data-theme='clear'] .tag-mint { color: rgb(14, 122, 82); } +[data-theme='dawn'] .tag-amber, [data-theme='clear'] .tag-amber { color: rgb(148, 96, 8); } +[data-theme='dawn'] .tag-rose, [data-theme='clear'] .tag-rose { color: rgb(166, 34, 58); } +[data-theme='dawn'] .tag-slate, [data-theme='clear'] .tag-slate { color: rgb(64, 82, 100); } diff --git a/product-source/hololake-native-desktop/src/main.tsx b/product-source/hololake-native-desktop/src/main.tsx index 52addf3b0..725aae611 100644 --- a/product-source/hololake-native-desktop/src/main.tsx +++ b/product-source/hololake-native-desktop/src/main.tsx @@ -1,13 +1,19 @@ -import { StrictMode, useCallback, useEffect, useMemo, useState } from 'react' +import { StrictMode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createRoot } from 'react-dom/client' import { invoke } from '@tauri-apps/api/core' -import DOMPurify from 'dompurify' -import { marked } from 'marked' +import { splitFrontmatter, documentOutline, jumpToHeading, MarkdownDocument, markdownHtml } from './modules/knowledge-render' + +const TAG_TINTS = ['tag-lavender', 'tag-sky', 'tag-mint', 'tag-amber', 'tag-rose', 'tag-slate'] +function tagTint(tag: string) { + let hash = 0 + for (const char of tag) hash = (hash * 31 + (char.codePointAt(0) || 0)) % 997 + return TAG_TINTS[hash % TAG_TINTS.length] +} import './design-tokens.css' import './styles.css' type ThemeId = 'night' | 'dawn' | 'nebula' | 'candle' | 'clear' -type ViewId = 'overview' | 'knowledge' | 'code' | 'receipts' | 'system' +type ViewId = 'overview' | 'knowledge' | 'code' | 'receipts' | 'system' | 'parlor' | 'generic' type KnowledgeSource = 'native' | 'legacy' interface HomeStatus { @@ -21,6 +27,8 @@ interface HomeStatus { mcpRole: string } interface PersonalChannelIdentity { humanSubjectId: string; displayName: string; channelId: string; createdAtUnixMs: number } +interface LoginSession { username: string; host: string; signedInAtUnixMs: number } +interface LoginReceipt { username: string; email: string; host: string } interface PersonalChannelEvent { sequence: number; eventId: string; kind: string; summary: string; occurredAtUnixMs: number; receiptId: string; receiptHash: string } interface PersonalChannelSnapshot { state: string; identity?: PersonalChannelIdentity; recentEvents: PersonalChannelEvent[]; integrity: { state: string; eventCount: number; receiptCount: number } } interface KnowledgeDocumentSummary { @@ -76,6 +84,15 @@ interface ReceiptProjection { events: ReceiptEvent[] } interface DiscoveryTicketReceipt { laneId: string; clientInstanceId: string; discoveryTicket: string } interface ReleaseCandidate { candidateId: string; confirmationToken: string; currentVersion: string; version: string; notes: string; features: string[]; fixes: string[]; dataMigrationRequired: boolean } interface ReleaseCheckReceipt { candidate?: ReleaseCandidate } +interface ZeroPointSnapshot { + route: string + binding: string + userNumber: string + lastValidCheck: number + graceDeadline: number + protocol: { gracePeriodDays: number; lighthouseAnchorUrl: string; lighthouseResolveUrl: string; coreChannelSource: string; origin: string } + syncNote: string +} const previewStatus: HomeStatus = { directLocalBrokerState: 'UNVERIFIED', directConnectionCount: 0, resumableSessionCount: 0, codeRepositoryMountCount: 0, pnccReceiptCount: 0, updateState: 'UNPROVISIONED_FAIL_CLOSED', releaseRecoveryState: 'NONE', mcpRole: 'DISCOVERY_RECOVERY_COMPATIBILITY_ONLY' } const previewPersonal: PersonalChannelSnapshot = { state: 'UNAVAILABLE', recentEvents: [], integrity: { state: 'UNKNOWN', eventCount: 0, receiptCount: 0 } } @@ -84,7 +101,7 @@ const previewCode: CodeChannelSnapshot = { state: 'UNAVAILABLE', channels: [], a const themes: Array<{ id: ThemeId; name: string }> = [ { id: 'night', name: '夜湖星光' }, { id: 'dawn', name: '晨湖曦光' }, { id: 'nebula', name: '星云紫夜' }, { id: 'candle', name: '烛畔暖湖' }, { id: 'clear', name: '清浅澄湖' }, ] -const viewLabels: Record = { overview: '总览', knowledge: '知识工作台', code: '代码频道', receipts: '回执', system: '系统详情' } +const viewLabels: Record = { overview: '总览', knowledge: '知识工作台', code: '代码频道', receipts: '回执', system: '系统详情', parlor: '会客厅', generic: '通用AI' } function getOrCreateLocalId(key: string, prefix: string) { const existing = window.localStorage.getItem(key) @@ -94,8 +111,14 @@ function getOrCreateLocalId(key: string, prefix: string) { return generated } -function humanError(error: unknown, context: 'knowledge' | 'code' | 'identity' | 'system') { +function humanError(error: unknown, context: 'knowledge' | 'code' | 'identity' | 'system' | 'login') { const value = String(error) + if (value.includes('LOGIN_CREDENTIALS_INVALID')) return '账号或密码未被仓库接受。' + if (value.includes('LOGIN_USERNAME_INVALID')) return '账号格式不合法(仅限字母、数字、连字符、下划线)。' + if (value.includes('LOGIN_NETWORK_FAILED')) return '未能连通光湖代码频道服务器。' + if (value.includes('LOGIN_KEYCHAIN_FAILED')) return '本机钥匙串写入失败,凭证未能安全保存。' + if (value.includes('LOGIN_SESSION_CORRUPT')) return '登录会话已损坏,请重新登录。' + if (value.includes('LOGIN_VERIFICATION_FAILED')) return '仓库验证未通过,请稍后再试。' if (value.includes('HOST_NOT_TRUSTED')) return '该地址不属于已登记的光湖代码频道。' if (value.includes('URL_INVALID')) return '频道地址无效,请粘贴完整的 HTTPS 克隆地址。' if (value.includes('DESTINATION_EXISTS')) return '该代码频道已经存在,无需再次克隆。' @@ -103,13 +126,14 @@ function humanError(error: unknown, context: 'knowledge' | 'code' | 'identity' | if (value.includes('NO_SUPPORTED_FILES')) return '文件夹中没有可导入的知识文件。' if (value.includes('SAVE_CONFLICT')) return '页面已在别处更新,请重新打开后再编辑。' if (value.includes('FILE_UNSUPPORTED')) return '该文件不是当前可阅读的文本格式。' + if (context === 'login') return '登录未完成。' if (context === 'identity') return '个人空间未能建立。' if (context === 'knowledge') return '知识操作未完成,原文件未被覆盖。' if (context === 'code') return '代码频道操作未完成。' return '系统操作未完成。' } -function Icon({ name }: { name: ViewId | 'search' | 'import' | 'folder' | 'copy' | 'arrow' | 'chevron' | 'file' | 'edit' | 'save' | 'back' }) { +function Icon({ name }: { name: ViewId | 'search' | 'import' | 'folder' | 'copy' | 'arrow' | 'chevron' | 'file' | 'edit' | 'save' | 'back' | 'download' | 'trash' | 'chevronDown' | 'settings' | 'more' | 'plus' }) { const paths: Record = { overview: <>, knowledge: <>, @@ -118,6 +142,14 @@ function Icon({ name }: { name: ViewId | 'search' | 'import' | 'folder' | 'copy' system: <>, search: <>, import: <>, + download: <>, + trash: <>, + chevronDown: , + settings: <>, + more: <>, + plus: , + parlor: <>, + generic: <>, folder: , file: <>, copy: <>, @@ -130,45 +162,6 @@ function Icon({ name }: { name: ViewId | 'search' | 'import' | 'folder' | 'copy' return } -function escapeHtml(value: string) { - return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"') -} - -function splitFrontmatter(body: string) { - const normalized = body.replace(/\r\n/g, '\n') - if (!normalized.startsWith('---\n')) return { content: normalized, metadata: {} as Record } - const closing = normalized.indexOf('\n---\n', 4) - if (closing < 0) return { content: normalized, metadata: {} as Record } - const metadata: Record = {} - for (const line of normalized.slice(4, closing).split('\n')) { - const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line) - if (match) metadata[match[1]] = match[2].replace(/^['"]|['"]$/g, '') - } - return { content: normalized.slice(closing + 5), metadata } -} - -function markdownHtml(body: string) { - const withWikiLinks = body.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_whole, target: string, label?: string) => - ``) - const html = marked.parse(withWikiLinks, { async: false, gfm: true, breaks: false }) as string - return DOMPurify.sanitize(html, { ADD_ATTR: ['data-wiki'], ADD_TAGS: ['button'] }) -} - -function documentOutline(body: string) { - return splitFrontmatter(body).content.split('\n').flatMap((line, index) => { - const match = /^(#{1,4})\s+(.+)$/.exec(line.trim()) - return match ? [{ id: `outline-${index}`, level: match[1].length, title: match[2].replace(/[*_`]/g, '') }] : [] - }).slice(0, 24) -} - -function MarkdownDocument({ body, onWiki }: { body: string; onWiki?: (target: string) => void }) { - const parsed = useMemo(() => splitFrontmatter(body), [body]) - const html = useMemo(() => markdownHtml(parsed.content), [parsed.content]) - return
{ - const element = (event.target as HTMLElement).closest('[data-wiki]') - if (element?.dataset.wiki && onWiki) onWiki(element.dataset.wiki) - }} dangerouslySetInnerHTML={{ __html: html }} /> -} interface TreeNode { name: string; key: string; folders: Map; documents: KnowledgeDocumentSummary[] } function knowledgeTree(documents: KnowledgeDocumentSummary[]) { @@ -189,7 +182,7 @@ function knowledgeTree(documents: KnowledgeDocumentSummary[]) { function countTree(node: TreeNode): number { return node.documents.length + [...node.folders.values()].reduce((sum, child) => sum + countTree(child), 0) } -function KnowledgeTree({ node, depth, expanded, active, onToggle, onOpen }: { node: TreeNode; depth: number; expanded: Set; active?: string; onToggle: (key: string) => void; onOpen: (item: KnowledgeDocumentSummary) => void }) { +function KnowledgeTree({ node, depth, expanded, active, onToggle, onOpen, onRemoveFolder, folderMenuKey, onFolderMenuToggle }: { node: TreeNode; depth: number; expanded: Set; active?: string; onToggle: (key: string) => void; onOpen: (item: KnowledgeDocumentSummary) => void; onRemoveFolder?: (folder: TreeNode) => void; folderMenuKey?: string | null; onFolderMenuToggle?: (key: string) => void }) { const folders = [...node.folders.values()].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')) const documents = [...node.documents].sort((a, b) => a.title.localeCompare(b.title, 'zh-CN')) return <> @@ -199,7 +192,8 @@ function KnowledgeTree({ node, depth, expanded, active, onToggle, onOpen }: { no - {open && } + {onRemoveFolder && onFolderMenuToggle && {folderMenuKey === folder.key && }} + {open && } })} {documents.map((document) => + + +
+ {genericMessages.length === 0 &&

配置好模型 API 就可以说话——这一层只是用户 API 的裸管:无人格、无记忆、无权限

} + {genericMessages.map((item, index) => ( +
+ {item.role === 'user' ? : item.role === 'agent' ? 通用AI : null} +
{item.text}
+
+ ))} + {genericBusy &&
通用AI
回应中…
} +
+
{ event.preventDefault(); void askGeneric() }}> + setGenericInput(event.target.value)}/> + +
+ + ) + + const renderOverview = () => (
PERSONAL HOLOLAKE

个人工作空间

知识、代码与本机协作的统一入口

@@ -499,7 +748,10 @@ function HoloLakeApp() { {visibleDocuments.length ? setExpanded((current) => { const next = new Set(current); if (next.has(key)) next.delete(key); else next.add(key); return next })} - onOpen={(item) => void openDocument(item.source, item.path)}/> + onOpen={(item) => void openDocument(item.source, item.path)} + onRemoveFolder={(folder) => void removeFolder(folder)} + folderMenuKey={folderMenuKey} + onFolderMenuToggle={(key) => setFolderMenuKey((current) => (current === key ? null : key))}/> :
没有匹配的知识页
}
{knowledgeMessage || (lastKnowledgeReceipt ? `Git ${lastKnowledgeReceipt.slice(0, 10)}` : '导入会自动检查相同内容')}
@@ -508,26 +760,26 @@ function HoloLakeApp() { {activeDocument ? <>
{activeDocument.source === 'legacy' ? 'HoloLake Era · 只读源' : '我的知识库'}/{activeDocument.path}
-
{editing +
{editing ? <> - : activeDocument.writable && } + : <>
{settingsMenuOpen &&
{activeDocument.writable && }{activeDocument.writable && }
}
}
-
+
{editing ?