feat: 零点原核频道v1+登录双门地基+五湖开场——zero_point裁决链/灯塔查号/code_repo_login/通知卡重设计/浅色主题联动(冰朔20260815夜谕)
This commit is contained in:
parent
ffed065841
commit
07fd4c85ef
16 changed files with 3082 additions and 88 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
pub answers: Vec<String>,
|
||||
pub path_hints: Vec<String>,
|
||||
}
|
||||
|
||||
/// 会话记忆卷=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<PathBuf, String> {
|
||||
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::<Vec<_>>()
|
||||
.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<String>,
|
||||
) -> Result<AgentTaskReceipt, String> {
|
||||
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<String>,
|
||||
) -> Result<Option<AgentSessionMemory>, 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<String> = Vec::new();
|
||||
let mut board_lines: Vec<String> = 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))
|
||||
}
|
||||
|
|
@ -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<Option<String>>,
|
||||
layer: Mutex<String>,
|
||||
}
|
||||
|
||||
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<String, String> {
|
||||
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<String>) -> Result<String, String> {
|
||||
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>), String> {
|
||||
let events: Vec<serde_json::Value> = 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<ParlorAskReceipt, String> {
|
||||
// 砌墙守卫(冰朔砌墙谕):路由不在人格层时,会客厅代码层不可达。
|
||||
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<String, String> {
|
||||
// 砌墙守卫:人格通道整体不可达时,切层也无从谈起。
|
||||
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<String, String> {
|
||||
Ok(state.layer.lock().map(|guard| guard.clone()).unwrap_or_else(|_| "language".to_string()))
|
||||
}
|
||||
|
|
@ -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<std::path::PathBuf, String> {
|
||||
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<String, String> {
|
||||
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<Option<LoginSession>, 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<LoginReceipt, String> {
|
||||
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::<LoginSession>(&raw) {
|
||||
keychain_remove(&session.host, &session.username);
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_file(&path);
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Option<ExportKnowledgeDocumentReceipt>, 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::<Option<PathBuf>>();
|
||||
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<PathBuf, String> {
|
||||
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<bool, String> {
|
||||
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<KnowledgeDeleteReceipt, String> {
|
||||
// 新建空白页:标题撞名自动补序号,落进本机库并进 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<Option<KnowledgeDeleteReceipt>, 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<Option<KnowledgeDeleteReceipt>, 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<PathBuf>), 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<PathBuf, String> {
|
||||
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 == "<aside>" || trimmed == "</aside>" {
|
||||
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("<br/>", "\n")
|
||||
.replace("<br />", "\n")
|
||||
.replace("<br>", "\n");
|
||||
out.push_str(&strip_notion_links(&cleaned));
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Notion 页面链接 → 只留链接文字(跳转关系按谕旨失效)。
|
||||
fn strip_notion_links(line: &str) -> String {
|
||||
let bytes: Vec<char> = 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<ImportDisposition, Strin
|
|||
}
|
||||
fs::create_dir_all(target.parent().unwrap_or_else(|| Path::new(".")))
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?;
|
||||
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(&bytes)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
mod agent_host;
|
||||
mod agent_parlor;
|
||||
mod code_channel;
|
||||
mod code_repo_login;
|
||||
mod glp_envelope;
|
||||
mod direct_local_broker;
|
||||
mod direct_local_session;
|
||||
mod dynamic_capability_routing;
|
||||
|
|
@ -6,11 +10,13 @@ mod home_status;
|
|||
mod knowledge_base;
|
||||
mod local_development_bridge;
|
||||
mod personal_channel;
|
||||
mod persona_butler;
|
||||
mod pncc_receipt_projection;
|
||||
mod pncc_remote_git;
|
||||
mod pncc_repository_binding;
|
||||
mod release_trust;
|
||||
mod release_update;
|
||||
mod zero_point;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
|
|
@ -18,6 +24,13 @@ pub fn run_connector() -> 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!())
|
||||
|
|
|
|||
|
|
@ -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<Vec<ButlerRecord>>,
|
||||
}
|
||||
|
||||
/// 提词器查询口(对兄弟模块公开):按题目查最新答案,未命中返回 None。
|
||||
pub fn prompter_lookup(state: &ButlerState, topic: &str) -> Option<String> {
|
||||
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<PathBuf, String> {
|
||||
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<PathBuf, String> {
|
||||
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<WaterLevelSnapshot, String> {
|
||||
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<String>,
|
||||
pub path_hint: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn query_prompter(
|
||||
state: State<'_, ButlerState>,
|
||||
topic: String,
|
||||
) -> Result<PrompterAnswer, String> {
|
||||
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<ButlerRecord, String> {
|
||||
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<ButlerRecord, String> {
|
||||
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<ButlerRecord, String> {
|
||||
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<usize>,
|
||||
) -> Result<Vec<ButlerRecord>, 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<ButlerRecord> =
|
||||
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)
|
||||
}
|
||||
|
|
@ -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<ZeroPointInner>,
|
||||
}
|
||||
|
||||
#[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<std::sync::MutexGuard<'_, ZeroPointInner>, 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::<ZeroPointProtocol>(&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::<B>(&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<PathBuf, String> {
|
||||
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<ZeroPointSnapshot, String> {
|
||||
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<ZeroPointSnapshot, String> {
|
||||
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<ZeroPointSnapshot, String> {
|
||||
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<ZeroPointSnapshot, String> {
|
||||
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<String, String> {
|
||||
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<serde_json::Value, String> {
|
||||
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::<GenericApiConfig>(&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<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 通用AI直通对话:用户 API 的裸管——无人格章程、不碰原核、无写权限。
|
||||
#[tauri::command]
|
||||
pub async fn generic_layer_chat(state: State<'_, ZeroPointState>, input: GenericChatInput) -> Result<String, String> {
|
||||
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::<GenericApiConfig>(&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"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue