feat: 零点原核频道v1+登录双门地基+五湖开场——zero_point裁决链/灯塔查号/code_repo_login/通知卡重设计/浅色主题联动(冰朔20260815夜谕)

This commit is contained in:
冰朔 2026-08-16 00:02:05 +08:00
commit 07fd4c85ef
16 changed files with 3082 additions and 88 deletions

View file

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HoloLake</title>
<title>HoloLake · GH-AIOS</title>
</head>
<body>
<div id="root"></div>

View file

@ -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"

View file

@ -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"

View file

@ -0,0 +1,298 @@
//! Agent 东家层 · agent_host
//!
//! 阶段E选型定案冰朔点头候选甲"东家模式"为体——
//! 外部 CLI Agent师傅干活宿主只管接信封、备提词、记账本、存记忆。
//!
//! 协议转工程落点(施工总纲):
//! - 人格体主控(铁律一):任务只从 GLP 信封进门content_type=command
//! 宿主绝不自己发起任务。
//! - 提词器(铁律五):会话开卷前先从提词器取指引(官方区协议+私人区索引),
//! 写进会话记忆的"提词板"栏——头顶提词板的工程形状。
//! - HLDP 内嵌(铁律二):每个会话=一卷 HLDP 检查点体id/prev 链/date/
//! persona/host/title+正文prev 链不可省。
//! - RECEIPTGLS-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))
}

View file

@ -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()))
}

View file

@ -0,0 +1,216 @@
//! 登录模块 · code_repo_login
//!
//! 规划卷依据HoloLake第二阶段总体规划-20260815 · 阶段B1
//! - 人类端输入代码仓库账号密码 → 对 guanghulabForgejo验证。
//! - 登录仓库 = 验证了背后绑定的服务器(冰朔教义)。
//! - 凭证只存本机钥匙串 · 不落明文。
//!
//! 事实底账2026-08-15 三角测量):
//! - Forgejo 挂载在 /code 路径下GET https://{host}/code/api/v1/user 走基本认证,
//! 假凭证=401 · 真凭证=200 并回显 JSONlogin/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(())
}

View file

@ -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 · contexthldp_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());
}
}

View file

@ -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)

View file

@ -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=6901center 把窗口弹出屏外,
// 冰朔验收两次只见黑屏。现改为 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-15webview 曾停在 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!())

View file

@ -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)
}

View file

@ -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"));
}
}

View file

@ -165,3 +165,14 @@
--button-primary-text: #f6f2e9;
--button-primary-shadow: 0 12px 36px rgba(45, 66, 85, 0.15);
}
/* 冰朔谕 2026-08-15换主题=全屋一起换浅色主题下知识库的彩色标签/引语块
文字必须换深色不许白字浮浅湖反光看不清 */
[data-theme='dawn'] .meta-tags span,
[data-theme='clear'] .meta-tags span { text-shadow: none; }
[data-theme='dawn'] .tag-lavender, [data-theme='clear'] .tag-lavender { color: rgb(94, 72, 190); }
[data-theme='dawn'] .tag-sky, [data-theme='clear'] .tag-sky { color: rgb(12, 108, 158); }
[data-theme='dawn'] .tag-mint, [data-theme='clear'] .tag-mint { color: rgb(14, 122, 82); }
[data-theme='dawn'] .tag-amber, [data-theme='clear'] .tag-amber { color: rgb(148, 96, 8); }
[data-theme='dawn'] .tag-rose, [data-theme='clear'] .tag-rose { color: rgb(166, 34, 58); }
[data-theme='dawn'] .tag-slate, [data-theme='clear'] .tag-slate { color: rgb(64, 82, 100); }

View file

@ -1,13 +1,19 @@
import { StrictMode, useCallback, useEffect, useMemo, useState } from 'react'
import { StrictMode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { invoke } from '@tauri-apps/api/core'
import DOMPurify from 'dompurify'
import { marked } from 'marked'
import { splitFrontmatter, documentOutline, jumpToHeading, MarkdownDocument, markdownHtml } from './modules/knowledge-render'
const TAG_TINTS = ['tag-lavender', 'tag-sky', 'tag-mint', 'tag-amber', 'tag-rose', 'tag-slate']
function tagTint(tag: string) {
let hash = 0
for (const char of tag) hash = (hash * 31 + (char.codePointAt(0) || 0)) % 997
return TAG_TINTS[hash % TAG_TINTS.length]
}
import './design-tokens.css'
import './styles.css'
type ThemeId = 'night' | 'dawn' | 'nebula' | 'candle' | 'clear'
type ViewId = 'overview' | 'knowledge' | 'code' | 'receipts' | 'system'
type ViewId = 'overview' | 'knowledge' | 'code' | 'receipts' | 'system' | 'parlor' | 'generic'
type KnowledgeSource = 'native' | 'legacy'
interface HomeStatus {
@ -21,6 +27,8 @@ interface HomeStatus {
mcpRole: string
}
interface PersonalChannelIdentity { humanSubjectId: string; displayName: string; channelId: string; createdAtUnixMs: number }
interface LoginSession { username: string; host: string; signedInAtUnixMs: number }
interface LoginReceipt { username: string; email: string; host: string }
interface PersonalChannelEvent { sequence: number; eventId: string; kind: string; summary: string; occurredAtUnixMs: number; receiptId: string; receiptHash: string }
interface PersonalChannelSnapshot { state: string; identity?: PersonalChannelIdentity; recentEvents: PersonalChannelEvent[]; integrity: { state: string; eventCount: number; receiptCount: number } }
interface KnowledgeDocumentSummary {
@ -76,6 +84,15 @@ interface ReceiptProjection { events: ReceiptEvent[] }
interface DiscoveryTicketReceipt { laneId: string; clientInstanceId: string; discoveryTicket: string }
interface ReleaseCandidate { candidateId: string; confirmationToken: string; currentVersion: string; version: string; notes: string; features: string[]; fixes: string[]; dataMigrationRequired: boolean }
interface ReleaseCheckReceipt { candidate?: ReleaseCandidate }
interface ZeroPointSnapshot {
route: string
binding: string
userNumber: string
lastValidCheck: number
graceDeadline: number
protocol: { gracePeriodDays: number; lighthouseAnchorUrl: string; lighthouseResolveUrl: string; coreChannelSource: string; origin: string }
syncNote: string
}
const previewStatus: HomeStatus = { directLocalBrokerState: 'UNVERIFIED', directConnectionCount: 0, resumableSessionCount: 0, codeRepositoryMountCount: 0, pnccReceiptCount: 0, updateState: 'UNPROVISIONED_FAIL_CLOSED', releaseRecoveryState: 'NONE', mcpRole: 'DISCOVERY_RECOVERY_COMPATIBILITY_ONLY' }
const previewPersonal: PersonalChannelSnapshot = { state: 'UNAVAILABLE', recentEvents: [], integrity: { state: 'UNKNOWN', eventCount: 0, receiptCount: 0 } }
@ -84,7 +101,7 @@ const previewCode: CodeChannelSnapshot = { state: 'UNAVAILABLE', channels: [], a
const themes: Array<{ id: ThemeId; name: string }> = [
{ id: 'night', name: '夜湖星光' }, { id: 'dawn', name: '晨湖曦光' }, { id: 'nebula', name: '星云紫夜' }, { id: 'candle', name: '烛畔暖湖' }, { id: 'clear', name: '清浅澄湖' },
]
const viewLabels: Record<ViewId, string> = { overview: '总览', knowledge: '知识工作台', code: '代码频道', receipts: '回执', system: '系统详情' }
const viewLabels: Record<ViewId, string> = { overview: '总览', knowledge: '知识工作台', code: '代码频道', receipts: '回执', system: '系统详情', parlor: '会客厅', generic: '通用AI' }
function getOrCreateLocalId(key: string, prefix: string) {
const existing = window.localStorage.getItem(key)
@ -94,8 +111,14 @@ function getOrCreateLocalId(key: string, prefix: string) {
return generated
}
function humanError(error: unknown, context: 'knowledge' | 'code' | 'identity' | 'system') {
function humanError(error: unknown, context: 'knowledge' | 'code' | 'identity' | 'system' | 'login') {
const value = String(error)
if (value.includes('LOGIN_CREDENTIALS_INVALID')) return '账号或密码未被仓库接受。'
if (value.includes('LOGIN_USERNAME_INVALID')) return '账号格式不合法(仅限字母、数字、连字符、下划线)。'
if (value.includes('LOGIN_NETWORK_FAILED')) return '未能连通光湖代码频道服务器。'
if (value.includes('LOGIN_KEYCHAIN_FAILED')) return '本机钥匙串写入失败,凭证未能安全保存。'
if (value.includes('LOGIN_SESSION_CORRUPT')) return '登录会话已损坏,请重新登录。'
if (value.includes('LOGIN_VERIFICATION_FAILED')) return '仓库验证未通过,请稍后再试。'
if (value.includes('HOST_NOT_TRUSTED')) return '该地址不属于已登记的光湖代码频道。'
if (value.includes('URL_INVALID')) return '频道地址无效,请粘贴完整的 HTTPS 克隆地址。'
if (value.includes('DESTINATION_EXISTS')) return '该代码频道已经存在,无需再次克隆。'
@ -103,13 +126,14 @@ function humanError(error: unknown, context: 'knowledge' | 'code' | 'identity' |
if (value.includes('NO_SUPPORTED_FILES')) return '文件夹中没有可导入的知识文件。'
if (value.includes('SAVE_CONFLICT')) return '页面已在别处更新,请重新打开后再编辑。'
if (value.includes('FILE_UNSUPPORTED')) return '该文件不是当前可阅读的文本格式。'
if (context === 'login') return '登录未完成。'
if (context === 'identity') return '个人空间未能建立。'
if (context === 'knowledge') return '知识操作未完成,原文件未被覆盖。'
if (context === 'code') return '代码频道操作未完成。'
return '系统操作未完成。'
}
function Icon({ name }: { name: ViewId | 'search' | 'import' | 'folder' | 'copy' | 'arrow' | 'chevron' | 'file' | 'edit' | 'save' | 'back' }) {
function Icon({ name }: { name: ViewId | 'search' | 'import' | 'folder' | 'copy' | 'arrow' | 'chevron' | 'file' | 'edit' | 'save' | 'back' | 'download' | 'trash' | 'chevronDown' | 'settings' | 'more' | 'plus' }) {
const paths: Record<string, React.ReactNode> = {
overview: <><rect x="4" y="4" width="6" height="6" rx="1.5"/><rect x="14" y="4" width="6" height="6" rx="1.5"/><rect x="4" y="14" width="6" height="6" rx="1.5"/><rect x="14" y="14" width="6" height="6" rx="1.5"/></>,
knowledge: <><path d="M5 4.5h10a3 3 0 0 1 3 3V20H8a3 3 0 0 1-3-3Z"/><path d="M8 4.5v12.8A2.7 2.7 0 0 1 10.7 20"/></>,
@ -118,6 +142,14 @@ function Icon({ name }: { name: ViewId | 'search' | 'import' | 'folder' | 'copy'
system: <><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.4-2.4 1a7 7 0 0 0-2-1.2L14.2 3h-4.1l-.3 2.7a7 7 0 0 0-2 1.2l-2.5-1-2 3.4 2.1 1.5a7 7 0 0 0 0 2.4l-2 1.5 2 3.4 2.4-1a7 7 0 0 0 2 1.2l.3 2.7h4.1l.3-2.7a7 7 0 0 0 2-1.2l2.5 1 2-3.4-2.1-1.5A7 7 0 0 0 19 12Z"/></>,
search: <><circle cx="11" cy="11" r="6"/><path d="m16 16 4 4"/></>,
import: <><path d="M12 4v11M8 11l4 4 4-4"/><path d="M5 19h14"/></>,
download: <><path d="M12 4v12M7 11l5 5 5-5"/><path d="M4 17v3h16v-3"/></>,
trash: <><path d="M4 7h16M10 4h4M7 7l1 13h8l1-13M10 11v5M14 11v5"/></>,
chevronDown: <path d="M6 9l6 6 6-6"/>,
settings: <><circle cx="12" cy="12" r="3"/><path d="M12 3v2.5M12 18.5V21M4.6 6.8l1.8 1.8M17.6 15.4l1.8 1.8M3 12h2.5M18.5 12H21M4.6 17.2l1.8-1.8M17.6 8.6l1.8-1.8"/></>,
more: <><circle cx="12" cy="5" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="12" cy="19" r="1.4"/></>,
plus: <path d="M12 5v14M5 12h14"/>,
parlor: <><path d="M4 5.5h16v11H9l-5 3.5Z"/><path d="M8 9.5h8M8 12.5h5"/></>,
generic: <><path d="M13 3 5 13.5h5L11 21l8-10.5h-5Z"/></>,
folder: <path d="M3.5 7.5h6l2-2h9v13h-17Z"/>,
file: <><path d="M6 3.5h8l4 4v13H6Z"/><path d="M14 3.5v4h4"/></>,
copy: <><rect x="8" y="8" width="10" height="11" rx="2"/><path d="M15 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2"/></>,
@ -130,45 +162,6 @@ function Icon({ name }: { name: ViewId | 'search' | 'import' | 'folder' | 'copy'
return <svg viewBox="0 0 24 24" aria-hidden="true">{paths[name]}</svg>
}
function escapeHtml(value: string) {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
}
function splitFrontmatter(body: string) {
const normalized = body.replace(/\r\n/g, '\n')
if (!normalized.startsWith('---\n')) return { content: normalized, metadata: {} as Record<string, string> }
const closing = normalized.indexOf('\n---\n', 4)
if (closing < 0) return { content: normalized, metadata: {} as Record<string, string> }
const metadata: Record<string, string> = {}
for (const line of normalized.slice(4, closing).split('\n')) {
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line)
if (match) metadata[match[1]] = match[2].replace(/^['"]|['"]$/g, '')
}
return { content: normalized.slice(closing + 5), metadata }
}
function markdownHtml(body: string) {
const withWikiLinks = body.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_whole, target: string, label?: string) =>
`<button class="wiki-link" type="button" data-wiki="${escapeHtml(target.trim())}">${escapeHtml((label || target).trim())}</button>`)
const html = marked.parse(withWikiLinks, { async: false, gfm: true, breaks: false }) as string
return DOMPurify.sanitize(html, { ADD_ATTR: ['data-wiki'], ADD_TAGS: ['button'] })
}
function documentOutline(body: string) {
return splitFrontmatter(body).content.split('\n').flatMap((line, index) => {
const match = /^(#{1,4})\s+(.+)$/.exec(line.trim())
return match ? [{ id: `outline-${index}`, level: match[1].length, title: match[2].replace(/[*_`]/g, '') }] : []
}).slice(0, 24)
}
function MarkdownDocument({ body, onWiki }: { body: string; onWiki?: (target: string) => void }) {
const parsed = useMemo(() => splitFrontmatter(body), [body])
const html = useMemo(() => markdownHtml(parsed.content), [parsed.content])
return <article className="markdown-document" onClick={(event) => {
const element = (event.target as HTMLElement).closest<HTMLElement>('[data-wiki]')
if (element?.dataset.wiki && onWiki) onWiki(element.dataset.wiki)
}} dangerouslySetInnerHTML={{ __html: html }} />
}
interface TreeNode { name: string; key: string; folders: Map<string, TreeNode>; documents: KnowledgeDocumentSummary[] }
function knowledgeTree(documents: KnowledgeDocumentSummary[]) {
@ -189,7 +182,7 @@ function knowledgeTree(documents: KnowledgeDocumentSummary[]) {
function countTree(node: TreeNode): number {
return node.documents.length + [...node.folders.values()].reduce((sum, child) => sum + countTree(child), 0)
}
function KnowledgeTree({ node, depth, expanded, active, onToggle, onOpen }: { node: TreeNode; depth: number; expanded: Set<string>; active?: string; onToggle: (key: string) => void; onOpen: (item: KnowledgeDocumentSummary) => void }) {
function KnowledgeTree({ node, depth, expanded, active, onToggle, onOpen, onRemoveFolder, folderMenuKey, onFolderMenuToggle }: { node: TreeNode; depth: number; expanded: Set<string>; active?: string; onToggle: (key: string) => void; onOpen: (item: KnowledgeDocumentSummary) => void; onRemoveFolder?: (folder: TreeNode) => void; folderMenuKey?: string | null; onFolderMenuToggle?: (key: string) => void }) {
const folders = [...node.folders.values()].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
const documents = [...node.documents].sort((a, b) => a.title.localeCompare(b.title, 'zh-CN'))
return <>
@ -199,7 +192,8 @@ function KnowledgeTree({ node, depth, expanded, active, onToggle, onOpen }: { no
<button className="tree-folder" style={{ paddingLeft: 12 + depth * 14 }} type="button" onClick={() => onToggle(folder.key)}>
<span className={open ? 'tree-chevron open' : 'tree-chevron'}><Icon name="chevron"/></span><Icon name="folder"/><span>{folder.name}</span><em>{countTree(folder)}</em>
</button>
{open && <KnowledgeTree node={folder} depth={depth + 1} expanded={expanded} active={active} onToggle={onToggle} onOpen={onOpen}/>}
{onRemoveFolder && onFolderMenuToggle && <span className="tree-folder-tools"><button className="tree-folder-menu-button" type="button" title="文件夹操作" onClick={(event) => { event.stopPropagation(); onFolderMenuToggle(folder.key) }}><Icon name="more"/></button>{folderMenuKey === folder.key && <span className="tree-folder-menu"><button type="button" onClick={(event) => { event.stopPropagation(); onFolderMenuToggle(folder.key); onRemoveFolder(folder) }}><Icon name="trash"/></button></span>}</span>}
{open && <KnowledgeTree node={folder} depth={depth + 1} expanded={expanded} active={active} onToggle={onToggle} onOpen={onOpen} onRemoveFolder={onRemoveFolder} folderMenuKey={folderMenuKey} onFolderMenuToggle={onFolderMenuToggle}/>}
</div>
})}
{documents.map((document) => <button key={`${document.source}:${document.path}`} className={active === `${document.source}:${document.path}` ? 'tree-document active' : 'tree-document'} style={{ paddingLeft: 31 + depth * 14 }} type="button" onClick={() => onOpen(document)}>
@ -231,6 +225,25 @@ function HoloLakeApp() {
const [codeTree, setCodeTree] = useState<CodeTreeSnapshot | null>(null)
const [activeCodeFile, setActiveCodeFile] = useState<CodeFileProjection | null>(null)
const [codeMode, setCodeMode] = useState<'human' | 'source'>('human')
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => window.localStorage.getItem('hololake-sidebar-collapsed') === '1')
const [inspectorOpen, setInspectorOpen] = useState(() => window.localStorage.getItem('hololake-inspector-open') !== '0')
const toggleSidebar = () => setSidebarCollapsed((current) => { window.localStorage.setItem('hololake-sidebar-collapsed', current ? '0' : '1'); return !current })
const toggleInspector = () => setInspectorOpen((current) => { window.localStorage.setItem('hololake-inspector-open', current ? '0' : '1'); return !current })
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null)
const headingScrollTimer = useRef<number | null>(null)
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false)
const [folderMenuKey, setFolderMenuKey] = useState<string | null>(null)
const [parlorMessages, setParlorMessages] = useState<{ role: 'user' | 'agent' | 'note'; text: string }[]>([])
const [parlorInput, setParlorInput] = useState('')
const [parlorBusy, setParlorBusy] = useState(false)
const [parlorLayer, setParlorLayer] = useState<'language' | 'execution'>('language')
const parlorScrollRef = useRef<HTMLDivElement>(null)
useEffect(() => { parlorScrollRef.current?.scrollTo({ top: parlorScrollRef.current.scrollHeight }) }, [parlorMessages, parlorBusy])
const [genericMessages, setGenericMessages] = useState<Array<{ role: string; text: string }>>([])
const [genericInput, setGenericInput] = useState('')
const [genericBusy, setGenericBusy] = useState(false)
const genericScrollRef = useRef<HTMLDivElement>(null)
useEffect(() => { genericScrollRef.current?.scrollTo({ top: genericScrollRef.current.scrollHeight }) }, [genericMessages, genericBusy])
const [displayName, setDisplayName] = useState('')
const [identityBusy, setIdentityBusy] = useState(false)
const [identityMessage, setIdentityMessage] = useState('')
@ -238,7 +251,19 @@ function HoloLakeApp() {
const [ticket, setTicket] = useState<DiscoveryTicketReceipt | null>(null)
const [systemMessage, setSystemMessage] = useState('')
const [releaseCandidate, setReleaseCandidate] = useState<ReleaseCandidate | null>(null)
const [repoLogin, setRepoLogin] = useState<LoginSession | null>(null)
const [loginUsername, setLoginUsername] = useState('')
const [loginPassword, setLoginPassword] = useState('')
const [loginBusy, setLoginBusy] = useState(false)
const [loginRising, setLoginRising] = useState(false)
const [loginMessage, setLoginMessage] = useState('')
const [systemBusy, setSystemBusy] = useState(false)
const [zeroPoint, setZeroPoint] = useState<ZeroPointSnapshot | null>(null)
const [zpNumber, setZpNumber] = useState('')
const [zpBusy, setZpBusy] = useState(false)
const [zpMessage, setZpMessage] = useState('')
const [apiForm, setApiForm] = useState({ baseUrl: '', apiKey: '', model: '' })
const [apiReady, setApiReady] = useState(false)
const refreshCore = useCallback(async () => {
const [homeResult, personalResult, knowledgeResult, codeResult] = await Promise.allSettled([
@ -267,6 +292,27 @@ function HoloLakeApp() {
document.documentElement.dataset.theme = theme
window.localStorage.setItem('hololake-theme', theme)
}, [theme])
useEffect(() => {
invoke<LoginSession | null>('check_code_repo_login').then((session) => setRepoLogin(session)).catch(() => setRepoLogin(null))
}, [])
const loadZeroPoint = useCallback(async () => {
try { setZeroPoint(await invoke<ZeroPointSnapshot>('zero_point_status')) } catch { /* 执行手脚启动中,下轮补 */ }
}, [])
useEffect(() => {
void loadZeroPoint()
const timer = window.setInterval(() => void loadZeroPoint(), 10000)
return () => window.clearInterval(timer)
}, [loadZeroPoint])
useEffect(() => {
invoke<{ baseUrl: string; model: string; hasKey: boolean }>('zero_point_api_config')
.then((cfg) => { setApiForm((prev) => ({ ...prev, baseUrl: cfg.baseUrl, model: cfg.model })); setApiReady(cfg.hasKey && cfg.baseUrl !== '') })
.catch(() => { /* 钥匙库未落,等用户配置 */ })
}, [])
useEffect(() => {
if (!zeroPoint) return
if (zeroPoint.route !== 'persona' && view === 'parlor') setView('generic')
if (zeroPoint.route === 'persona' && view === 'generic') setView('knowledge')
}, [zeroPoint, view])
useEffect(() => { if (view === 'receipts') void loadReceipts() }, [loadReceipts, view])
const visibleDocuments = useMemo(() => {
@ -279,6 +325,30 @@ function HoloLakeApp() {
: undefined
const parsedDocument = useMemo(() => activeDocument ? splitFrontmatter(activeDocument.body) : null, [activeDocument])
const outline = useMemo(() => activeDocument ? documentOutline(activeDocument.body) : [], [activeDocument])
const docStats = useMemo(() => {
if (!activeDocument) return null
const chars = activeDocument.body.replace(/\s+/g, '').length
return { chars, minutes: Math.max(1, Math.ceil(chars / 400)) }
}, [activeDocument])
useEffect(() => { setActiveHeadingId(null) }, [activeDocument?.path])
useEffect(() => {
if (!activeHeadingId) return
document.querySelector('.outline-list button.active')?.scrollIntoView({ block: 'nearest' })
}, [activeHeadingId])
const handleReaderScroll = (event: React.UIEvent<HTMLDivElement>) => {
const container = event.currentTarget
if (headingScrollTimer.current !== null) return
headingScrollTimer.current = window.setTimeout(() => {
headingScrollTimer.current = null
const containerTop = container.getBoundingClientRect().top
let current: string | null = null
for (const heading of Array.from(container.querySelectorAll<HTMLElement>('h1[id], h2[id], h3[id], h4[id]'))) {
if (heading.getBoundingClientRect().top - containerTop <= 28) current = heading.id
else break
}
setActiveHeadingId(current)
}, 100)
}
const connectionOnline = status.directConnectionCount > 0
const connectionLabel = connectionOnline
? '本地原生连接 · 客户端在线'
@ -329,6 +399,95 @@ function HoloLakeApp() {
} catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) }
finally { setKnowledgeBusy(false) }
}
const downloadDocument = async (format: 'md' | 'html') => {
if (!activeDocument) return
setKnowledgeBusy(true)
setKnowledgeMessage('')
try {
let body = activeDocument.body
if (format === 'html') {
const parsed = splitFrontmatter(body)
const article = markdownHtml(parsed.content)
body = `<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8"><title>${activeDocument.title}</title><style>body{max-width:820px;margin:40px auto;padding:0 24px;font:15px/1.8 -apple-system,'Segoe UI','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif;color:#1f2330;background:#fff}h1,h2,h3{line-height:1.4}blockquote{margin:20px 0;padding:12px 16px;border-left:3px solid #a78bfa;background:#f3f0ff;border-radius:8px}table{border-collapse:collapse;width:100%}td,th{border:1px solid #ddd;padding:7px 10px}pre{background:#f4f5f8;padding:14px;border-radius:8px;overflow:auto}img{max-width:100%}</style></head><body><h1>${activeDocument.title}</h1>${article}</body></html>`
}
const receipt = await invoke<{ path: string; bytes: number } | null>('export_knowledge_document', { input: { title: activeDocument.title, extension: format, body } })
setKnowledgeMessage(receipt ? `已下载到 ${receipt.path}` : '已取消下载。')
} catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) }
finally { setKnowledgeBusy(false) }
}
const printDocument = async () => {
setKnowledgeMessage('')
try {
await invoke('print_knowledge_document')
setKnowledgeMessage('打印框里选「存储为 PDF」即可得到 PDF 文件。')
} catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) }
}
const reloadKnowledge = async () => {
const snapshot = await invoke<KnowledgeSnapshot>('get_knowledge_snapshot')
setKnowledge(snapshot)
}
const askParlor = async () => {
const message = parlorInput.trim()
if (!message || parlorBusy) return
setParlorInput('')
setParlorMessages((current) => [...current, { role: 'user', text: message }])
setParlorBusy(true)
try {
const receipt = await invoke<{ reply: string; layer: string; freshSession: boolean }>('agent_parlor_ask', { input: { message } })
setParlorMessages((current) => [...current, { role: 'agent', text: receipt.reply }])
} catch (error) { setParlorMessages((current) => [...current, { role: 'note', text: humanError(error, 'knowledge') }]) }
finally { setParlorBusy(false) }
}
const switchParlorLayer = async () => {
try {
const target = parlorLayer === 'execution' ? 'language' : 'execution'
const actual = await invoke<string>('agent_parlor_switch_layer', { input: { layer: target } })
setParlorLayer(actual === 'execution' ? 'execution' : 'language')
setParlorMessages((current) => [...current, { role: 'note', text: actual === 'execution' ? '已切换到现实开发执行层:归灯获得执行权限,语言即现实。' : '已回到语言推理层:只对话不动手。' }])
} catch (error) { setParlorMessages((current) => [...current, { role: 'note', text: humanError(error, 'knowledge') }]) }
}
const createDocument = async () => {
setKnowledgeBusy(true)
setKnowledgeMessage('')
try {
const receipt = await invoke<{ removed: string; pages: number }>('create_knowledge_document', { input: { title: '未命名页面' } })
setKnowledgeMessage(`已新建空白页「${receipt.removed.replace(/\.md$/, '')}」,正在编辑。`)
setSearchResults(null)
await reloadKnowledge()
await openDocument('native', receipt.removed)
setEditing(true)
} catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) }
finally { setKnowledgeBusy(false) }
}
const removeDocument = async () => {
if (!activeDocument) return
setKnowledgeBusy(true)
setKnowledgeMessage('')
try {
const receipt = await invoke<{ removed: string; pages: number } | null>('delete_knowledge_document', { input: { source: activeDocument.source, path: activeDocument.path } })
if (receipt) {
setKnowledgeMessage(`已移入回收站:${receipt.removed}(可找回)`)
setActiveDocument(null)
setSearchResults(null)
await reloadKnowledge()
} else setKnowledgeMessage('已取消删除。')
} catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) }
finally { setKnowledgeBusy(false) }
}
const removeFolder = async (folder: TreeNode) => {
setKnowledgeBusy(true)
setKnowledgeMessage('')
try {
const receipt = await invoke<{ removed: string; pages: number } | null>('delete_knowledge_folder', { input: { folder: folder.key } })
if (receipt) {
setKnowledgeMessage(`文件夹「${folder.name}」(${receipt.pages} 页)已移入回收站(可找回)`)
setActiveDocument(null)
setSearchResults(null)
await reloadKnowledge()
} else setKnowledgeMessage('已取消删除。')
} catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) }
finally { setKnowledgeBusy(false) }
}
const saveDocument = async () => {
if (!activeDocument?.writable) return
setKnowledgeBusy(true)
@ -394,6 +553,25 @@ function HoloLakeApp() {
}
const codeParent = () => codeTree?.path.split('/').filter(Boolean).slice(0, -1).join('/') || ''
const performRepoLogin = async (event: React.FormEvent) => {
event.preventDefault()
setLoginBusy(true)
setLoginMessage('')
try {
const receipt = await invoke<LoginReceipt>('perform_code_repo_login', { username: loginUsername.trim(), password: loginPassword })
// 五湖开场:校验通过=第五域从湖面浮起,镜头沉入湖中再进场。
setLoginRising(true)
await new Promise((resolve) => setTimeout(resolve, 1300))
setRepoLogin({ username: receipt.username, host: receipt.host, signedInAtUnixMs: Date.now() })
setLoginRising(false)
setLoginPassword('')
} catch (error) { setLoginMessage(humanError(error, 'login')) }
finally { setLoginBusy(false) }
}
const signOutRepo = async () => {
try { await invoke('sign_out_code_repo_login') } catch { /* 登出以本机清场为准 */ }
setRepoLogin(null)
}
const initializeIdentity = async (event: React.FormEvent) => {
event.preventDefault()
setIdentityBusy(true)
@ -458,7 +636,78 @@ function HoloLakeApp() {
finally { setSystemBusy(false) }
}
const renderOverview = () => (
const bindZeroPoint = async () => {
if (!zpNumber.trim()) return
setZpBusy(true); setZpMessage('')
try {
const bound = await invoke<ZeroPointSnapshot>('zero_point_bind', { input: { number: zpNumber.trim() } })
setZeroPoint(bound)
const verified = await invoke<ZeroPointSnapshot>('zero_point_verify')
setZeroPoint(verified)
setZpMessage(verified.route === 'persona' ? '编号合法,人格层路由已开' : '人格系统未授权已切换至通用AI运行层')
} catch (error) { setZpMessage(String(error)) } finally { setZpBusy(false) }
}
const verifyZeroPoint = async () => {
setZpBusy(true); setZpMessage('')
try {
const snapshot = await invoke<ZeroPointSnapshot>('zero_point_verify')
setZeroPoint(snapshot)
setZpMessage(snapshot.route === 'persona' ? '校验通过,人格层路由已开' : '人格系统未授权已切换至通用AI运行层')
} catch (error) { setZpMessage(String(error)) } finally { setZpBusy(false) }
}
const syncZeroPoint = async () => {
setZpBusy(true); setZpMessage('')
try { setZeroPoint(await invoke<ZeroPointSnapshot>('zero_point_sync')) } catch (error) { setZpMessage(String(error)) } finally { setZpBusy(false) }
}
const saveApiConfig = async () => {
try {
await invoke('zero_point_save_api', { input: { baseUrl: apiForm.baseUrl, apiKey: apiForm.apiKey, model: apiForm.model } })
setApiReady(true)
setGenericMessages((rows) => [...rows, { role: 'note', text: 'API 已收进底层钥匙库通用AI运行层就绪' }])
} catch (error) { setGenericMessages((rows) => [...rows, { role: 'note', text: `保存失败:${String(error)}` }]) }
}
const askGeneric = async () => {
const message = genericInput.trim()
if (!message || genericBusy) return
const history = genericMessages.filter((row) => row.role !== 'note').map((row) => ({ role: row.role === 'user' ? 'user' : 'assistant', content: row.text }))
setGenericMessages((rows) => [...rows, { role: 'user', text: message }])
setGenericInput(''); setGenericBusy(true)
try {
const reply = await invoke<string>('generic_layer_chat', { input: { messages: [...history, { role: 'user', content: message }] } })
setGenericMessages((rows) => [...rows, { role: 'agent', text: reply }])
} catch (error) { setGenericMessages((rows) => [...rows, { role: 'note', text: `通用层回应失败:${String(error)}` }]) } finally { setGenericBusy(false) }
}
const renderGeneric = () => (
<section className="content-page parlor-page">
<header className="page-title"><div><span className="kicker">GENERIC AI LAYER</span><h1>AI运行层</h1><p> API · </p></div><span className="status-chip"></span></header>
<section className="plain-panel zp-api-panel">
<header><div><h2> API </h2><p>{apiReady ? ' · 已就绪' : ''}</p></div></header>
<div className="zp-api-row">
<input value={apiForm.baseUrl} placeholder="API 端点,如 https://dashscope.aliyuncs.com/compatible-mode/v1" onChange={(event) => setApiForm((prev) => ({ ...prev, baseUrl: event.target.value }))}/>
<input type="password" value={apiForm.apiKey} placeholder="API 钥匙" onChange={(event) => setApiForm((prev) => ({ ...prev, apiKey: event.target.value }))}/>
<input value={apiForm.model} placeholder="模型名,如 qwen3-max" onChange={(event) => setApiForm((prev) => ({ ...prev, model: event.target.value }))}/>
<button className="primary-button" type="button" onClick={() => void saveApiConfig()}></button>
</div>
</section>
<div className="parlor-scroll" ref={genericScrollRef}>
{genericMessages.length === 0 && <p className="parlor-note"> API API </p>}
{genericMessages.map((item, index) => (
<div key={index} className={`parlor-row ${item.role}`}>
{item.role === 'user' ? <span className="parlor-who"></span> : item.role === 'agent' ? <span className="parlor-who">AI</span> : null}
<div className={item.role === 'note' ? 'parlor-note' : 'parlor-bubble'}>{item.text}</div>
</div>
))}
{genericBusy && <div className="parlor-row agent"><span className="parlor-who">AI</span><div className="parlor-bubble thinking"></div></div>}
</div>
<form className="parlor-input-row" onSubmit={(event) => { event.preventDefault(); void askGeneric() }}>
<input maxLength={2000} value={genericInput} placeholder={apiReady ? '跟通用AI说话…' : '先把模型 API 收进钥匙库'} onChange={(event) => setGenericInput(event.target.value)}/>
<button className="primary-button" disabled={genericBusy || !genericInput.trim() || !apiReady}>{genericBusy ? '等回应…' : '说话'}</button>
</form>
</section>
)
const renderOverview = () => (
<section className="content-page overview-page">
<header className="page-title">
<div><span className="kicker">PERSONAL HOLOLAKE</span><h1></h1><p></p></div>
@ -499,7 +748,10 @@ function HoloLakeApp() {
{visibleDocuments.length
? <KnowledgeTree node={tree} depth={0} expanded={expanded} active={activeDocument ? `${activeDocument.source}:${activeDocument.path}` : undefined}
onToggle={(key) => setExpanded((current) => { const next = new Set(current); if (next.has(key)) next.delete(key); else next.add(key); return next })}
onOpen={(item) => void openDocument(item.source, item.path)}/>
onOpen={(item) => void openDocument(item.source, item.path)}
onRemoveFolder={(folder) => void removeFolder(folder)}
folderMenuKey={folderMenuKey}
onFolderMenuToggle={(key) => setFolderMenuKey((current) => (current === key ? null : key))}/>
: <div className="empty-state"></div>}
</div>
<footer>{knowledgeMessage || (lastKnowledgeReceipt ? `Git ${lastKnowledgeReceipt.slice(0, 10)}` : '导入会自动检查相同内容')}</footer>
@ -508,26 +760,26 @@ function HoloLakeApp() {
{activeDocument ? <>
<header className="document-toolbar">
<div className="breadcrumbs"><span>{activeDocument.source === 'legacy' ? 'HoloLake Era · 只读源' : '我的知识库'}</span><b>/</b><span>{activeDocument.path}</span></div>
<div>{editing
<div className="toolbar-actions"><button className="toolbar-button" type="button" title={inspectorOpen ? '收起右侧大纲与证据' : '展开右侧大纲与证据'} onClick={toggleInspector}><Icon name="chevron"/>{inspectorOpen ? '收起大纲' : '展开大纲'}</button>{editing
? <><button className="toolbar-button" type="button" onClick={() => { setEditing(false); setDraft(activeDocument.body) }}></button><button className="toolbar-button primary" type="button" disabled={knowledgeBusy} onClick={() => void saveDocument()}><Icon name="save"/></button></>
: activeDocument.writable && <button className="toolbar-button" type="button" onClick={() => { setDraft(activeDocument.body); setEditing(true) }}><Icon name="edit"/></button>}
: <><div className="toolbar-menu"><button className="toolbar-button" type="button" title="页面操作与常用功能" disabled={knowledgeBusy} onClick={() => setSettingsMenuOpen((current) => !current)}><Icon name="settings"/> <Icon name="chevronDown"/></button>{settingsMenuOpen && <div className="toolbar-menu-pop"><button type="button" disabled={knowledgeBusy} onClick={() => { setSettingsMenuOpen(false); void createDocument() }}><Icon name="plus"/></button><button type="button" disabled={knowledgeBusy} onClick={() => { setSettingsMenuOpen(false); void importKnowledge() }}><Icon name="import"/></button>{activeDocument.writable && <button type="button" onClick={() => { setSettingsMenuOpen(false); setDraft(activeDocument.body); setEditing(true) }}><Icon name="edit"/></button>}{activeDocument.writable && <button type="button" disabled={knowledgeBusy} onClick={() => { setSettingsMenuOpen(false); void removeDocument() }}><Icon name="trash"/></button>}<span className="toolbar-menu-sep"/><button type="button" disabled={knowledgeBusy} onClick={() => { setSettingsMenuOpen(false); void downloadDocument('md') }}><Icon name="download"/> Markdown</button><button type="button" disabled={knowledgeBusy} onClick={() => { setSettingsMenuOpen(false); void downloadDocument('html') }}><Icon name="download"/>.html</button><button type="button" disabled={knowledgeBusy} onClick={() => { setSettingsMenuOpen(false); void printDocument() }}><Icon name="download"/> PDF</button></div>}</div></>}
</div>
</header>
<div className="document-scroll">
<div className="document-scroll" onScroll={handleReaderScroll}>
{editing
? <textarea className="document-editor" aria-label="Markdown 编辑器" value={draft} onChange={(event) => setDraft(event.target.value)}/>
: <><header className="reader-heading"><h1>{activeDocument.title}</h1><div>{Object.entries(parsedDocument?.metadata || {}).slice(0, 5).map(([key, value]) => <span key={key}>{key} · {value}</span>)}</div></header><MarkdownDocument body={activeDocument.body} onWiki={openWiki}/></>}
: <><header className="reader-heading"><h1>{activeDocument.title}</h1>{docStats && <div className="meta-stats">{docStats.chars.toLocaleString()} · {docStats.minutes} {parsedDocument?.metadata?.created ? ` · 创建于 ${String(parsedDocument.metadata.created).slice(0, 10)}` : ''}{parsedDocument?.metadata?.updated ? ` · 更新于 ${String(parsedDocument.metadata.updated).slice(0, 10)}` : ''}</div>}{(parsedDocument?.tags?.length ?? 0) > 0 && <div className="meta-tags">{parsedDocument!.tags.map((tag) => <span key={tag} className={tagTint(tag)}>{tag}</span>)}</div>}<div>{Object.entries(parsedDocument?.metadata || {}).filter(([key]) => key !== 'tags').slice(0, 5).map(([key, value]) => <span key={key}>{key} · {value}</span>)}</div></header><MarkdownDocument body={activeDocument.body} knownTitles={knowledge.documents.map((doc) => doc.title)} onWiki={openWiki}/></>}
</div>
</> : <div className="workbench-empty"><span></span><h2></h2><p></p><button className="primary-button" type="button" onClick={() => void importKnowledge()}></button></div>}
</main>
<aside className="document-inspector">
{inspectorOpen && <aside className="document-inspector">
<div className="inspector-tabs"><span className="active"></span><span></span><span></span></div>
{activeDocument ? <div className="inspector-scroll">
<section><h2></h2>{outline.length ? <nav className="outline-list">{outline.map((item) => <span key={item.id} style={{ paddingLeft: (item.level - 1) * 11 }}>{item.title}</span>)}</nav> : <p></p>}</section>
<section><h2></h2>{outline.length ? <nav className="outline-list">{outline.map((item) => <button key={item.id} type="button" title="跳到该章节" className={activeHeadingId === item.id ? 'active' : ''} style={{ paddingLeft: 8 + (item.level - 1) * 11 }} onClick={() => jumpToHeading(item.id)}>{item.title}</button>)}</nav> : <p></p>}</section>
<section><h2></h2><dl><div><dt></dt><dd>{activeDocument.source === 'native' ? 'HoloLake 本机 Git' : 'HoloLake Era 只读供体'}</dd></div><div><dt></dt><dd>{activeDocument.path}</dd></div><div><dt></dt><dd>{activeDocument.contentSha256.slice(0, 16)}</dd></div><div><dt></dt><dd>{activeSummary?.duplicateCount || 0} </dd></div></dl></section>
<section><h2></h2><p>{activeDocument.writable ? '可编辑;保存时写入本机 Git。' : '供体原件只读;不会被修改。'}</p>{lastKnowledgeReceipt && <code>{lastKnowledgeReceipt}</code>}</section>
</div> : <div className="empty-state"></div>}
</aside>
</aside>}
</section>
)
@ -575,6 +827,22 @@ function HoloLakeApp() {
{ticket ? <><button className="secondary-button" type="button" onClick={() => void copyInvitation()}><Icon name="copy"/></button><pre className="invitation-data">{invitationText}</pre></> : <button className="primary-button" type="button" disabled={systemBusy} onClick={() => void issueInvitation()}></button>}
</section>
<section className="plain-panel"><header><div><h2>GH-PNCC </h2><p></p></div></header><dl className="evidence-list"><div><dt></dt><dd>{status.codeRepositoryMountCount}</dd></div><div><dt></dt><dd>{status.pnccReceiptCount}</dd></div><div><dt></dt><dd>{status.codeRepositoryMountCount > 0 && status.pnccReceiptCount > 0 ? '已有运行证据' : '架构已接入,尚无完整运行投影'}</dd></div></dl></section>
<section className="plain-panel">
<header><div><h2> · </h2><p></p></div><span className={zeroPoint?.route === 'persona' ? 'status-chip online' : 'status-chip'}>{zeroPoint ? (zeroPoint.route === 'persona' ? '人格层' : '通用AI层') : '启动中'}</span></header>
<dl className="evidence-list">
<div><dt></dt><dd>{zeroPoint ? (zeroPoint.binding === 'bound' ? `已绑定(${zeroPoint.userNumber}` : '等待绑定(空白态)') : '—'}</dd></div>
<div><dt></dt><dd>{zeroPoint?.protocol.origin ?? '—'}</dd></div>
<div><dt>线</dt><dd>{zeroPoint ? `${zeroPoint.protocol.gracePeriodDays} 天(协议值)` : '—'}</dd></div>
<div><dt></dt><dd>{zeroPoint?.syncNote ?? '—'}</dd></div>
</dl>
<div className="zp-api-row">
<input value={zpNumber} placeholder="输入用户编号,如 ICE-GL∞" onChange={(event) => setZpNumber(event.target.value)}/>
<button className="secondary-button" type="button" disabled={zpBusy || !zpNumber.trim()} onClick={() => void bindZeroPoint()}></button>
<button className="secondary-button" type="button" disabled={zpBusy || zeroPoint?.binding !== 'bound'} onClick={() => void verifyZeroPoint()}></button>
<button className="secondary-button" type="button" disabled={zpBusy} onClick={() => void syncZeroPoint()}></button>
</div>
{zpMessage && <p className="global-message">{zpMessage}</p>}
</section>
<section className="plain-panel"><header><div><h2></h2><p></p></div></header><div className="theme-options">{themes.map((choice) => <button className={theme === choice.id ? 'active' : ''} key={choice.id} type="button" onClick={() => setTheme(choice.id)}><i className={choice.id}/><span>{choice.name}</span></button>)}</div></section>
<section className="plain-panel"><header><div><h2></h2><p></p></div></header>{releaseCandidate ? <div className="release-summary"><b>HoloLake {releaseCandidate.version}</b><p>{releaseCandidate.notes}</p><button className="primary-button" onClick={() => void installUpdate()}></button></div> : <button className="secondary-button" disabled={systemBusy} onClick={() => void checkUpdate()}></button>}</section>
</div>
@ -582,15 +850,46 @@ function HoloLakeApp() {
</section>
)
return <div className="app-shell">
const renderParlor = () => (
<section className="content-page parlor-page">
<header className="page-title"><div><span className="kicker">AGENT PARLOR</span><h1></h1><p></p></div>
<div className="parlor-layer">
<span className={parlorLayer === 'execution' ? 'status-chip online' : 'status-chip'}>{parlorLayer === 'execution' ? '现实开发执行层' : '语言推理层'}</span>
<button className="secondary-button" type="button" onClick={() => void switchParlorLayer()}>{parlorLayer === 'execution' ? '回到语言推理层' : '切换到现实开发执行层'}</button>
</div>
</header>
<div className="parlor-scroll" ref={parlorScrollRef}>
<p className="parlor-note"></p>
{parlorMessages.map((item, index) => (
<div key={index} className={`parlor-row ${item.role}`}>
{item.role === 'user' ? <span className="parlor-who"></span> : item.role === 'agent' ? <span className="parlor-who"></span> : null}
<div className={item.role === 'note' ? 'parlor-note' : 'parlor-bubble'}>{item.text}</div>
</div>
))}
{parlorBusy && <div className="parlor-row agent"><span className="parlor-who"></span><div className="parlor-bubble thinking"></div></div>}
</div>
<form className="parlor-input-row" onSubmit={(event) => { event.preventDefault(); void askParlor() }}>
<input maxLength={2000} value={parlorInput} placeholder={parlorLayer === 'execution' ? '执行层:语言即现实,说要做什么' : '跟人格体说话…'} onChange={(event) => setParlorInput(event.target.value)}/>
<button className="primary-button" disabled={parlorBusy || !parlorInput.trim()}>{parlorBusy ? '等回话…' : '说话'}</button>
</form>
</section>
)
if (!repoLogin) {
return <div className="app-shell">
<div className={`onboarding-backdrop lake-scene${loginRising ? ' sinking' : ''}`}><div className="lake-bays" aria-hidden="true">{([['main', '主域'], ['sub', '分域'], ['zero', '零域'], ['sense', '零感域'], ['fifth', '第五域']] as const).map(([bayId, bayName]) => <span key={bayId} className={`lake-bay ${bayId}${loginRising && bayId === 'fifth' ? ' rising' : ''}`}><i/><b>{bayName}</b></span>)}</div><section className={`onboarding-card${loginRising ? ' fade-out' : ''}`} role="dialog"><span className="onboarding-mark"></span><span className="kicker">CODE REPOSITORY ENTRY</span><h1></h1><p>使guanghulab.com</p><form onSubmit={(event) => void performRepoLogin(event)}><label htmlFor="repo-login-username"></label><input id="repo-login-username" autoFocus maxLength={40} value={loginUsername} placeholder="代码仓库用户名" onChange={(event) => setLoginUsername(event.target.value)}/><label htmlFor="repo-login-password"></label><input id="repo-login-password" type="password" maxLength={512} value={loginPassword} placeholder="代码仓库密码" onChange={(event) => setLoginPassword(event.target.value)}/><button className="primary-button" disabled={loginBusy || !loginUsername.trim() || !loginPassword}>{loginBusy ? '正在验证…' : '进入 HoloLake'}</button></form>{loginMessage && <p>{loginMessage}</p>}</section></div>
</div>
}
return <div className={`app-shell${sidebarCollapsed ? ' sidebar-collapsed' : ''}${inspectorOpen ? '' : ' inspector-closed'}`}>
<aside className="sidebar">
<div className="brand"><span className="brand-mark"></span><div><b>HoloLake</b><small>GH-AIOS</small></div></div>
<nav>{(Object.keys(viewLabels) as ViewId[]).map((item) => <button className={view === item ? 'active' : ''} key={item} type="button" onClick={() => setView(item)}><Icon name={item}/><span>{viewLabels[item]}</span>{item === 'knowledge' && <em>{knowledge.uniqueDocumentCount}</em>}</button>)}</nav>
<div className="sidebar-foot"><span className="identity-dot"/><div><b>{personal.identity?.displayName || '本机空间'}</b><small>{personal.identity ? '个人频道已建立' : '等待首次设置'}</small></div></div>
<div className="brand"><span className="brand-mark brand-lamp" title={sidebarCollapsed ? '展开导航' : '光湖 · HoloLake'} onClick={sidebarCollapsed ? toggleSidebar : undefined}><i className="lamp-glow"/><i className="lamp-core"/><i className="lake-shimmer"/></span><div className="brand-text"><b>HoloLake</b><small className="brand-sub"> · </small><small>GH-AIOS · V1.0.0</small></div><button className="icon-button sidebar-toggle" type="button" title={sidebarCollapsed ? '展开导航' : '收起导航'} onClick={toggleSidebar}><Icon name="chevron"/></button></div>
<nav>{(Object.keys(viewLabels) as ViewId[]).filter((item) => item === 'parlor' ? !zeroPoint || zeroPoint.route === 'persona' : item === 'generic' ? !!zeroPoint && zeroPoint.route !== 'persona' : true).map((item) => <button className={view === item ? 'active' : ''} key={item} type="button" onClick={() => setView(item)}><Icon name={item}/><span>{viewLabels[item]}</span>{item === 'knowledge' && <em>{knowledge.uniqueDocumentCount}</em>}</button>)}</nav>
<div className="sidebar-foot"><span className="identity-dot"/><div><b>{personal.identity?.displayName || '本机空间'}</b><small>{repoLogin ? `${repoLogin.username}@${repoLogin.host}` : (personal.identity ? '个人频道已建立' : '等待首次设置')}</small></div><button className="sign-out" type="button" title="退出代码仓库登录" onClick={() => void signOutRepo()}>退</button></div>
</aside>
<section className="app-main">
<header className="connection-strip"><button type="button" onClick={() => setView('system')}><i className={connectionOnline ? 'online' : ''}/><span>{connectionLabel}</span></button><span className="mcp-backup">MCP </span><button type="button" onClick={() => setView('system')}> <Icon name="arrow"/></button></header>
<main className="workspace">{view === 'overview' ? renderOverview() : view === 'knowledge' ? renderKnowledge() : view === 'code' ? renderCode() : view === 'receipts' ? renderReceipts() : renderSystem()}</main>
<main className="workspace">{zeroPoint && zeroPoint.route !== 'persona' && <div className="zp-downgrade-wrap"><div className="zp-downgrade-card"><i className="zp-dot"/><div className="zp-copy"><b></b><span>AI层 API </span></div><button type="button" onClick={() => setView('system')}> <Icon name="arrow"/></button></div></div>}{view === 'overview' ? renderOverview() : view === 'knowledge' ? renderKnowledge() : view === 'code' ? renderCode() : view === 'receipts' ? renderReceipts() : view === 'parlor' ? renderParlor() : view === 'generic' ? renderGeneric() : renderSystem()}</main>
</section>
{personal.state !== 'UNAVAILABLE' && !personal.identity && <div className="onboarding-backdrop"><section className="onboarding-card" role="dialog" aria-modal="true"><span className="onboarding-mark"></span><span className="kicker">FIRST LOCAL ENTRY</span><h1></h1><p></p><form onSubmit={(event) => void initializeIdentity(event)}><label htmlFor="display-name"></label><input id="display-name" autoFocus maxLength={80} value={displayName} placeholder="你的名字" onChange={(event) => setDisplayName(event.target.value)}/><button className="primary-button" disabled={identityBusy || !displayName.trim()}> HoloLake</button></form>{identityMessage && <p>{identityMessage}</p>}</section></div>}
</div>

View file

@ -0,0 +1,146 @@
//! 知识渲染件 · 渲染入口(常驻模块 · 插座口 knowledge-render
//!
//! 职责:把仓库里的 Markdown 文件变成可读的页面——frontmatter 解析、
//! Notion/Outline 导出兼容、标题锚点、大纲同源、跳转。
import { useMemo } from 'react'
import DOMPurify from 'dompurify'
import { marked, Parser, type Tokens } from 'marked'
export { knowledgeRenderManifest } from './manifest'
function escapeHtml(value: string) {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
}
export function splitFrontmatter(body: string) {
const normalized = body.replace(/\r\n/g, '\n')
if (!normalized.startsWith('---\n')) return { content: normalized, metadata: {} as Record<string, string>, tags: [] as string[] }
const closing = normalized.indexOf('\n---\n', 4)
if (closing < 0) return { content: normalized, metadata: {} as Record<string, string>, tags: [] as string[] }
const metadata: Record<string, string> = {}
const tags: string[] = []
let pendingListKey = ''
for (const line of normalized.slice(4, closing).split('\n')) {
const listItem = /^\s+-\s+(.+)$/.exec(line)
if (listItem && pendingListKey) {
if (pendingListKey === 'tags') tags.push(listItem[1].trim().replace(/^['"]|['"]$/g, ''))
continue
}
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line)
if (!match) continue
pendingListKey = match[2] === '' ? match[1] : ''
const value = match[2].replace(/^['"]|['"]$/g, '')
metadata[match[1]] = value
if (match[1] === 'tags') {
const inline = /^\[(.*)\]$/.exec(match[2].trim())
const source = inline ? inline[1] : value
source.split(',').map((part) => part.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean).forEach((tag) => tags.push(tag))
}
}
return { content: normalized.slice(closing + 5), metadata, tags }
}
function stripNotionLinks(line: string) {
// Notion 页面链接 → 只留链接文字(跳转关系按谕旨失效)
return line.replace(/\[([^\]]*)\]\(([^)]*)\)/g, (whole, text: string, href: string) =>
(/notion\.(?:so|site)/.test(href) ? text : whole))
}
export function notionCompat(content: string) {
// 存量旧库文件尚未洗净:渲染时按"进门即洗"同规矩兜底过滤一遍。
// 内容一字不改,只把外来知识库的外壳换成光湖原生 Markdown冰朔 2026-08-15 谕:不兼容,进门就转)。
const result: string[] = []
for (const line of content.replace(/\r\n/g, '\n').split('\n')) {
const trimmed = line.trim()
if (trimmed === '<aside>' || trimmed === '</aside>') continue
if (trimmed.startsWith(':::toggle')) {
const title = trimmed.slice(':::toggle'.length).trim()
result.push(title ? `**▸ ${title}**` : '**▸ 详情**')
continue
}
if (trimmed === ':::' || trimmed.startsWith(':::toc')) continue
result.push(stripNotionLinks(line.replace(/<br\s*\/?>/g, '\n')))
}
// Notion 粗体写法(**文字:**紧接正文)不合 CommonMark给收尾星号补一口气
return result.join('\n').replace(/(\*\*[^*\n]+\*\*)(?=[^\s.,;:!?,。;:!?、)\]"'])/g, '$1 ')
}
const CALLOUT_TINTS: [RegExp, string][] = [
[/📌|🌌|🧭|💜|🗂|🏷|📚/, 'callout-lavender'],
[/💡|⚡|🌟|☀|✨|🔑/, 'callout-amber'],
[/⚠|🔥|❗|🚨|❌/, 'callout-rose'],
[/✅|🌿|🍀|💚|✔/, 'callout-mint'],
[/🌊|💧|🔵|❄|🧊/, 'callout-sky'],
]
function normalizeTitle(value: string) {
return value.replace(/[\s·•・\-_|*`]/g, '').toLowerCase()
}
function calloutTint(text: string) {
for (const [pattern, tint] of CALLOUT_TINTS) if (pattern.test(text)) return tint
return 'callout-slate'
}
export function markdownHtml(body: string, knownTitles: readonly string[] = []) {
const compat = notionCompat(body)
const titleMap = new Map<string, string>()
for (const title of knownTitles) titleMap.set(normalizeTitle(title), title)
const withWikiLinks = compat.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_whole, target: string, label?: string) =>
`<button class="wiki-link" type="button" data-wiki="${escapeHtml(target.trim())}">${escapeHtml((label || target).trim())}</button>`)
let headingIndex = 0
const renderer = new marked.Renderer()
renderer.heading = (token) => {
const id = `heading-${headingIndex}`
headingIndex += 1
const inlineHtml = token.tokens.length ? Parser.parseInline(token.tokens) : escapeHtml(token.text)
return `<h${token.depth} id="${id}">${inlineHtml}</h${token.depth}>\n`
}
renderer.blockquote = (token) => {
const inner = Parser.parse(token.tokens) as string
return `<blockquote class="callout ${calloutTint(token.raw)}">${inner}</blockquote>\n`
}
renderer.link = (token) => {
const inner = token.tokens.length ? Parser.parseInline(token.tokens) : escapeHtml(token.text)
const href = token.href || ''
if (/notion\.(?:so|site)/i.test(href)) {
// Notion 残留链接:认得出是自家页面就转成内部跳转,认不出标"外来页面"。
const hit = titleMap.get(normalizeTitle(token.text))
if (hit) return `<button class="wiki-link" type="button" data-wiki="${escapeHtml(hit)}">${inner}</button>`
return `<a class="external-link" href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${inner}<span class="external-mark">外来页面</span></a>`
}
if (/^https?:\/\//i.test(href)) return `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${inner}</a>`
return `<a href="${escapeHtml(href)}">${inner}</a>`
}
const html = marked.parse(withWikiLinks, { async: false, gfm: true, breaks: false, renderer }) as string
return DOMPurify.sanitize(html, { ADD_ATTR: ['data-wiki', 'id', 'target', 'rel'], ADD_TAGS: ['button'] })
}
export function documentOutline(body: string) {
const headings: { id: string; level: number; title: string }[] = []
const plain = (tokens: unknown): string => (tokens as { type: string; text?: string; tokens?: unknown[] }[])
.map((token) => (token.tokens && token.tokens.length ? plain(token.tokens) : token.text || '')).join('')
marked.lexer(splitFrontmatter(body).content, { gfm: true })
.filter((token): token is Tokens.Heading => token.type === 'heading')
.forEach((token, index) => {
if (index >= 24) return
headings.push({ id: `heading-${index}`, level: token.depth, title: plain(token.tokens).trim() })
})
return headings
}
export function jumpToHeading(id: string) {
const element = document.querySelector<HTMLElement>(`.document-scroll [id="${id}"]`)
if (!element) return
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
element.classList.remove('heading-flash')
void element.offsetWidth
element.classList.add('heading-flash')
}
export function MarkdownDocument({ body, knownTitles, onWiki }: { body: string; knownTitles?: readonly string[]; onWiki?: (target: string) => void }) {
const parsed = useMemo(() => splitFrontmatter(body), [body])
const html = useMemo(() => markdownHtml(parsed.content, knownTitles), [parsed.content, knownTitles])
return <article className="markdown-document" onClick={(event) => {
const element = (event.target as HTMLElement).closest<HTMLElement>('[data-wiki]')
if (element?.dataset.wiki && onWiki) onWiki(element.dataset.wiki)
}} dangerouslySetInnerHTML={{ __html: html }} />
}

View file

@ -0,0 +1,22 @@
//! 模块形状声明 · 插座制(冰朔 2026-08-15 架构谕)
//!
//! 软件只认这一个插座形状:每个模块 = manifest我是谁/插哪个口/从哪来)
//! + index渲染入口导出。常驻模块住在频道里将来 origin='repo' 的
//! 模块躺代码仓库,人格体按需拉取部署。
export const knowledgeRenderManifest = {
moduleId: 'hololake.knowledge-render',
name: '知识渲染件',
version: '0.1.0',
slot: 'knowledge-render',
origin: 'resident',
exports: ['splitFrontmatter', 'documentOutline', 'jumpToHeading', 'MarkdownDocument'],
} as const
export type ModuleManifest = {
moduleId: string
name: string
version: string
slot: string
origin: 'resident' | 'repo'
exports: readonly string[]
}

View file

@ -20,7 +20,7 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
.app-shell {
width: 100%; height: 100%;
display: grid; grid-template-columns: 226px minmax(0, 1fr);
display: grid; grid-template-columns: 226px minmax(0, 1fr); transition: grid-template-columns .18s ease;
overflow: hidden;
background:
radial-gradient(circle at 82% -12%, var(--primitive-nebula-a), transparent 31%),
@ -41,6 +41,9 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
}
.brand b { display: block; color: var(--content-primary); font-size: 18px; font-weight: 650; letter-spacing: .01em; }
.brand small { display: block; margin-top: 4px; color: var(--content-muted); font-size: 11px; font-weight: 520; letter-spacing: .22em; }
.brand small.brand-sub { margin-top: 3px; font-size: 10.5px; font-weight: 500; letter-spacing: .04em; opacity: .82; }
.sign-out { flex: none; padding: 5px 10px; border: 1px solid var(--panel-edge); border-radius: 8px; background: transparent; color: var(--content-muted); font-size: 11.5px; cursor: pointer; }
.sign-out:hover { color: var(--content-secondary); border-color: var(--accent-light); }
.sidebar nav { display: grid; gap: 7px; margin-top: 54px; }
.sidebar nav button {
min-height: 50px; display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: center; gap: 13px;
@ -71,8 +74,10 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
.connection-strip i { background: var(--content-faint); box-shadow: none; }
.connection-strip i.online { background: var(--state-ready); box-shadow: 0 0 12px color-mix(in srgb, var(--state-ready) 52%, transparent); }
.mcp-backup { padding: 4px 8px; border: 1px solid var(--panel-edge); border-radius: 7px; color: var(--content-muted); font-size: 11px; font-weight: 560; }
.workspace { min-width: 0; min-height: 0; overflow: hidden; }
.content-page { height: 100%; overflow: auto; padding: 48px clamp(28px, 4vw, 64px) 64px; }
.workspace { min-width: 0; min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
.content-page { flex: 1; min-height: 0; overflow: auto; padding: 34px clamp(28px, 4vw, 64px) 64px; }
.zp-downgrade-wrap { flex: none; }
.zp-downgrade-wrap + .content-page { padding-top: 26px; }
.page-title { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 34px; }
.page-title h1 { margin: 8px 0 0; color: var(--content-primary); font-size: clamp(34px, 4vw, 48px); font-weight: 620; letter-spacing: -.035em; line-height: 1.08; }
.page-title p { margin: 10px 0 0; color: var(--content-muted); font-size: 15px; }
@ -124,7 +129,8 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
.system-proof dd, .evidence-list dd, .document-inspector dd { margin: 0; color: var(--content-secondary); font-weight: 580; text-align: right; }
.full-workbench { height: 100%; min-width: 0; min-height: 0; display: grid; background: color-mix(in srgb, var(--surface-depth) 70%, transparent); }
.knowledge-page { grid-template-columns: 310px minmax(430px, 1fr) 278px; }
.knowledge-page { grid-template-columns: 336px minmax(0, 1fr) 288px; }
.knowledge-page.inspector-closed { grid-template-columns: 336px minmax(0, 1fr); }
.knowledge-browser, .document-inspector, .code-channels, .repository-tree {
min-width: 0; min-height: 0; display: flex; flex-direction: column;
border-right: 1px solid var(--panel-edge);
@ -164,7 +170,9 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
}
.breadcrumbs { min-width: 0; display: flex; align-items: center; gap: 8px; color: var(--content-muted); font-size: 12px; }
.breadcrumbs span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.document-toolbar > div:last-child { display: flex; gap: 8px; }
.document-toolbar > div:last-child { display: flex; flex: none; gap: 8px; }
.document-toolbar .breadcrumbs { flex: 1 1 auto; }
.toolbar-actions .toolbar-button { white-space: nowrap; flex: none; }
.toolbar-button { min-height: 33px; padding: 0 12px; font-size: 12.5px; }
.toolbar-button svg { width: 16px; height: 16px; }
.document-scroll, .code-reader-scroll { min-width: 0; min-height: 0; overflow: auto; scroll-behavior: smooth; }
@ -172,20 +180,71 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
.reader-heading h1 { margin: 0; color: var(--content-primary); font-size: clamp(32px, 3vw, 42px); font-weight: 680; letter-spacing: -.035em; line-height: 1.18; }
.reader-heading div { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 20px; }
.reader-heading span { padding: 5px 8px; border-radius: 6px; color: var(--content-muted); background: var(--primitive-glass); font-size: 12px; }
.reader-heading .meta-stats { margin-top: 15px; color: var(--content-muted); font-size: 12.5px; letter-spacing: .03em; }
.knowledge-tree .tree-branch { position: relative; }
.tree-folder-tools { position: absolute; top: 4px; right: 6px; z-index: 5; }
.tree-folder-menu-button { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border: 0; border-radius: 6px; background: transparent; color: var(--content-muted); cursor: pointer; opacity: .55; }
.tree-branch:hover > .tree-folder-tools .tree-folder-menu-button { opacity: 1; }
.tree-folder-menu-button:hover { background: var(--primitive-glass-hover); color: var(--content-strong); }
.tree-folder-menu { position: absolute; top: 24px; right: 0; z-index: 70; display: flex; flex-direction: column; min-width: 132px; padding: 5px; border: 1px solid var(--panel-edge); border-radius: 9px; background: var(--panel-bg); box-shadow: 0 12px 30px rgba(0, 0, 0, .35); }
.tree-folder-menu button { display: flex; gap: 7px; align-items: center; padding: 6px 8px; border: 0; border-radius: 6px; background: transparent; color: var(--content-strong); font-size: 12.5px; text-align: left; cursor: pointer; white-space: nowrap; }
.tree-folder-menu button:hover { background: var(--primitive-glass-hover); color: rgb(251, 113, 133); }
.tree-folder-menu button svg { width: 13px; height: 13px; }
.toolbar-menu { position: relative; display: inline-flex; }
.toolbar-menu .toolbar-button svg:last-child { width: 12px; height: 12px; opacity: .7; }
.toolbar-menu-pop { position: absolute; top: calc(100% + 6px); right: 0; z-index: 60; display: flex; flex-direction: column; min-width: 196px; padding: 6px; border: 1px solid var(--panel-edge); border-radius: 10px; background: var(--panel-bg); box-shadow: 0 12px 30px rgba(0, 0, 0, .35); }
.toolbar-menu-pop button { display: flex; gap: 8px; align-items: center; white-space: nowrap; }
.toolbar-menu-pop button svg { width: 13px; height: 13px; flex: none; }
.toolbar-menu-sep { display: block; height: 1px; margin: 5px 4px; background: var(--panel-edge); }
.toolbar-menu-pop button { padding: 8px 10px; border: 0; border-radius: 7px; background: transparent; color: var(--content-secondary); text-align: left; font-size: 13px; cursor: pointer; }
.toolbar-menu-pop button:hover { background: var(--primitive-glass-hover); color: var(--content-primary); }
@media print {
.app-sidebar, .knowledge-tree, .document-toolbar, .document-inspector, .sidebar-toggle, .outline-panel { display: none !important; }
.app-shell, .full-workbench, .knowledge-page, .document-workspace { display: block !important; grid-template-columns: none !important; height: auto !important; overflow: visible !important; background: #fff !important; }
.document-scroll { height: auto !important; overflow: visible !important; padding: 0 !important; }
.reader-heading .meta-tags span, .meta-stats { color: #444 !important; }
body, .markdown-document, .reader-heading h1 { background: #fff !important; color: #111 !important; }
.markdown-document blockquote.callout { background: #f5f3ff !important; border-left-color: #a78bfa !important; color: #333 !important; }
.markdown-document pre, .markdown-document code { background: #f2f3f5 !important; color: #222 !important; }
}
.markdown-document .external-link { text-decoration: underline dashed; text-underline-offset: 3px; }
.markdown-document .external-mark { display: inline-block; margin-left: 6px; padding: 1px 8px; border: 1px solid var(--panel-edge); border-radius: 999px; background: var(--primitive-glass); color: var(--content-muted); font-size: 10.5px; letter-spacing: .05em; vertical-align: 1px; }
.markdown-document details.toggle-block { margin: 22px 0; border: 1px solid var(--panel-edge); border-radius: 10px; background: var(--primitive-glass); }
.markdown-document details.toggle-block summary { padding: 12px 16px; border-radius: 10px; color: var(--content-primary); font-weight: 620; cursor: pointer; list-style: none; }
.markdown-document details.toggle-block summary::before { content: '▸ '; color: var(--accent-light); }
.markdown-document details.toggle-block[open] summary::before { content: '▾ '; }
.markdown-document details.toggle-block .toggle-body { padding: 2px 16px 14px; border-top: 1px dashed var(--panel-edge); }
.reader-heading .meta-tags span.tag-lavender { border-color: rgba(167, 139, 250, .5); background: rgba(167, 139, 250, .16); color: rgb(200, 183, 255); }
.reader-heading .meta-tags span.tag-sky { border-color: rgba(56, 189, 248, .45); background: rgba(56, 189, 248, .14); color: rgb(151, 219, 252); }
.reader-heading .meta-tags span.tag-mint { border-color: rgba(52, 211, 153, .45); background: rgba(52, 211, 153, .14); color: rgb(148, 232, 195); }
.reader-heading .meta-tags span.tag-amber { border-color: rgba(251, 191, 36, .45); background: rgba(251, 191, 36, .13); color: rgb(252, 220, 145); }
.reader-heading .meta-tags span.tag-rose { border-color: rgba(251, 113, 133, .45); background: rgba(251, 113, 133, .13); color: rgb(253, 186, 196); }
.reader-heading .meta-tags span.tag-slate { border-color: rgba(148, 163, 184, .4); background: rgba(148, 163, 184, .12); color: rgb(203, 213, 225); }
.reader-heading .meta-tags span { padding: 4px 12px; border: 1px solid color-mix(in srgb, var(--accent-light) 32%, transparent); border-radius: 999px; color: var(--accent-light); background: color-mix(in srgb, var(--accent-light) 9%, transparent); font-weight: 600; }
.markdown-document { max-width: 870px; margin: 0 auto; padding: 20px 54px 100px; color: var(--content-secondary); font-size: 17px; font-weight: 430; line-height: 1.78; overflow-wrap: anywhere; }
.markdown-document h1, .markdown-document h2, .markdown-document h3, .markdown-document h4 { color: var(--content-primary); font-weight: 680; line-height: 1.34; letter-spacing: -.02em; }
.markdown-document h1 { margin: 48px 0 20px; font-size: 34px; }
.markdown-document h2 { margin: 42px 0 17px; font-size: 27px; }
.markdown-document h2 { margin: 42px 0 17px; padding-bottom: 9px; font-size: 27px; background: linear-gradient(90deg, var(--accent-light), transparent 72%) left bottom / 100% 1px no-repeat; }
.markdown-document hr { height: 1px; margin: 36px 0; border: 0; background: linear-gradient(90deg, transparent, var(--accent-light), transparent); opacity: .45; }
.markdown-document h3 { margin: 34px 0 14px; font-size: 22px; }
.markdown-document h4 { margin: 28px 0 12px; font-size: 18px; }
.markdown-document p { margin: 0 0 20px; }
.markdown-document ul, .markdown-document ol { margin: 0 0 22px; padding-left: 27px; }
.markdown-document li { margin: 6px 0; }
.markdown-document a { color: color-mix(in srgb, var(--accent-light) 78%, var(--content-primary)); text-decoration-thickness: 1px; text-underline-offset: 3px; }
.markdown-document blockquote { margin: 25px 0; padding: 14px 19px; border-left: 3px solid var(--accent-light); color: var(--content-muted); background: var(--primitive-glass); }
.markdown-document blockquote.callout { margin: 25px 0; padding: 15px 19px; border: 0; border-left: 3px solid; border-radius: 9px; color: var(--content-secondary); font-size: 14.5px; }
.markdown-document blockquote.callout p { margin: 6px 0; }
.callout-slate { border-left-color: rgba(148, 163, 184, .65); background: rgba(148, 163, 184, .10); }
.callout-lavender { border-left-color: rgba(167, 139, 250, .7); background: rgba(167, 139, 250, .13); }
.callout-sky { border-left-color: rgba(56, 189, 248, .6); background: rgba(56, 189, 248, .11); }
.callout-mint { border-left-color: rgba(52, 211, 153, .6); background: rgba(52, 211, 153, .11); }
.callout-amber { border-left-color: rgba(251, 191, 36, .65); background: rgba(251, 191, 36, .11); }
.callout-rose { border-left-color: rgba(251, 113, 133, .6); background: rgba(251, 113, 133, .11); }
.markdown-document pre, .source-code { overflow: auto; padding: 19px; border: 1px solid var(--panel-edge); border-radius: 10px; color: var(--content-secondary); background: rgba(0, 0, 0, .25); font: 13.5px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; tab-size: 2; }
.markdown-document code:not(pre code) { padding: 2px 5px; border-radius: 5px; background: var(--primitive-glass-hover); font: .88em ui-monospace, SFMono-Regular, Menlo, monospace; }
.markdown-document table { width: 100%; display: table; margin: 25px 0; border-collapse: collapse; font-size: 14px; line-height: 1.5; }
.markdown-document table { width: 100%; display: table; margin: 25px 0; border-collapse: collapse; border-radius: 10px; font-size: 14px; line-height: 1.5; }
.markdown-document table th { background: var(--primitive-glass-hover); color: var(--content-primary); }
.markdown-document table tbody tr:nth-child(even) { background: var(--primitive-glass); }
.markdown-document th, .markdown-document td { padding: 10px 12px; border: 1px solid var(--panel-edge); text-align: left; vertical-align: top; }
.markdown-document th { color: var(--content-primary); background: var(--primitive-glass-hover); font-weight: 650; }
.markdown-document img { max-width: 100%; border-radius: 10px; }
@ -206,6 +265,9 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
.inspector-scroll code { display: block; overflow-wrap: anywhere; color: var(--content-muted); font-size: 11px; }
.outline-list { display: grid; gap: 10px; }
.outline-list span { overflow: hidden; color: var(--content-muted); font-size: 12.5px; text-overflow: ellipsis; white-space: nowrap; }
.outline-list button { display: block; width: 100%; overflow: hidden; padding: 5px 8px; border: 0; border-radius: 7px; color: var(--content-muted); background: transparent; font-size: 12.5px; text-align: left; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.outline-list button.active { color: var(--content-primary); background: var(--primitive-glass-hover); box-shadow: inset 2px 0 0 var(--accent-light); }
.outline-list button:hover { color: var(--content-primary); background: var(--primitive-glass); }
.workbench-empty, .empty-state { display: grid; place-content: center; justify-items: center; color: var(--content-muted); text-align: center; }
.workbench-empty { min-height: 0; padding: 40px; }
.workbench-empty > span { color: var(--accent-light); font-family: "Songti SC", serif; font-size: 36px; }
@ -293,3 +355,84 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; }
}
/* ===== 布局收展(学 Tolaria侧栏可收正文与知识库拿回宽度 ===== */
.app-shell.sidebar-collapsed { grid-template-columns: 80px minmax(0, 1fr); }
.sidebar-toggle { width: 30px; height: 30px; margin-left: auto; border-radius: 8px; }
.sidebar-toggle svg { transition: transform .18s ease; }
.app-shell.sidebar-collapsed .brand { flex-direction: column; gap: 10px; padding: 0 0 4px; }
.app-shell.sidebar-collapsed .brand-text,
.app-shell.sidebar-collapsed .sidebar nav button span,
.app-shell.sidebar-collapsed .sidebar nav button em,
.app-shell.sidebar-collapsed .sidebar-foot div { display: none; }
.app-shell.sidebar-collapsed .sidebar-toggle svg { transform: rotate(180deg); }
.app-shell.sidebar-collapsed .sidebar nav button { grid-template-columns: 24px; justify-content: center; padding: 0; }
.app-shell.sidebar-collapsed .brand-mark { cursor: pointer; }
.app-shell.sidebar-collapsed .sidebar-foot { grid-template-columns: 1fr; justify-items: center; gap: 8px; padding: 14px 0 0; }
.app-shell.sidebar-collapsed .sign-out { padding: 4px 8px; font-size: 11px; }
.toolbar-actions { display: flex; align-items: center; gap: 10px; }
.toolbar-actions .toolbar-button svg { width: 15px; height: 15px; }
.toolbar-actions .toolbar-button[title^="收起"] svg { transform: rotate(90deg); }
.toolbar-actions .toolbar-button[title^="展开"] svg { transform: rotate(-90deg); }
.markdown-document :is(h1, h2, h3, h4) { scroll-margin-top: 16px; }
.heading-flash { animation: heading-flash 1.3s ease; }
@keyframes heading-flash { 0% { background: color-mix(in srgb, var(--accent-light) 24%, transparent); } 100% { background: transparent; } }
/* ===== 光湖灯:品牌徽的灯亮着,一闪一闪,湖面有微光 ===== */
.brand-lamp { position: relative; overflow: hidden; }
.brand-lamp .lamp-glow { position: absolute; inset: -7px; border-radius: inherit; background: radial-gradient(circle at 50% 32%, color-mix(in srgb, var(--accent-light) 36%, transparent), transparent 62%); animation: lamp-breathe 5s ease-in-out infinite; }
.brand-lamp .lamp-core { position: absolute; top: 8px; left: 50%; width: 9px; height: 12px; transform: translateX(-50%); border-radius: 50% 50% 46% 46% / 58% 58% 42% 42%; background: var(--accent-light); box-shadow: 0 0 8px color-mix(in srgb, var(--accent-light) 70%, transparent); animation: lamp-flicker 5.2s infinite; }
.brand-lamp .lake-shimmer { position: absolute; right: 7px; bottom: 8px; left: 7px; height: 2px; border-radius: 2px; opacity: .8; background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent-light) 72%, transparent), transparent); background-size: 200% 100%; animation: lake-shimmer 3.6s linear infinite; }
@keyframes lamp-flicker { 0%, 100% { opacity: 1; } 42% { opacity: 1; } 44% { opacity: .3; } 46% { opacity: 1; } 71% { opacity: .94; } 73% { opacity: .45; } 75% { opacity: 1; } }
@keyframes lamp-breathe { 0%, 100% { opacity: .68; } 50% { opacity: 1; } }
@keyframes lake-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
/* ===== 会客厅Agent 接线 v1===== */
.parlor-page { display: flex; flex-direction: column; }
.parlor-layer { display: flex; align-items: center; gap: 10px; flex: none; }
.parlor-scroll { flex: 1; min-height: 200px; overflow: auto; display: flex; flex-direction: column; gap: 12px; padding: 18px; border: 1px solid var(--panel-edge); border-radius: 12px; background: var(--primitive-glass); }
.parlor-row { display: flex; flex-direction: column; gap: 4px; max-width: 760px; }
.parlor-row.user { align-self: flex-end; align-items: flex-end; }
.parlor-row.agent { align-self: flex-start; align-items: flex-start; }
.parlor-row.note { align-self: center; }
.parlor-who { font-size: 11px; font-weight: 650; letter-spacing: .08em; color: var(--content-muted); }
.parlor-bubble { padding: 10px 14px; border-radius: 12px; border: 1px solid var(--panel-edge); color: var(--content-primary); font-size: 14px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; background: color-mix(in srgb, var(--accent-light) 7%, transparent); }
.parlor-row.user .parlor-bubble { background: color-mix(in srgb, var(--accent-light) 16%, transparent); border-color: color-mix(in srgb, var(--accent-light) 34%, transparent); }
.parlor-bubble.thinking { opacity: .6; animation: lamp-breathe 1.6s infinite; }
.parlor-note { margin: 0; padding: 7px 13px; border-radius: 999px; border: 1px dashed color-mix(in srgb, var(--accent-light) 30%, transparent); color: var(--content-muted); font-size: 12px; line-height: 1.6; align-self: center; max-width: 760px; text-align: center; }
.parlor-input-row { display: flex; gap: 10px; margin-top: 14px; flex: none; }
.parlor-input-row input { flex: 1; padding: 12px 15px; border: 1px solid var(--panel-edge); border-radius: 10px; background: var(--primitive-glass); color: var(--content-primary); font-size: 14px; }
.parlor-input-row input:focus { outline: none; border-color: color-mix(in srgb, var(--accent-light) 55%, transparent); }
/* 零点原核频道·双层路由导航(施工图 v1.4 件4/4b */
.zp-downgrade-wrap { padding: 26px clamp(28px, 4vw, 64px) 0; }
.zp-downgrade-card { display: flex; align-items: center; gap: 16px; padding: 16px 20px; border: 1px solid color-mix(in srgb, var(--accent-light) 30%, var(--panel-edge)); border-radius: 14px; background: color-mix(in srgb, var(--panel-bg) 88%, transparent); }
.zp-downgrade-card .zp-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--accent-light); box-shadow: 0 0 14px color-mix(in srgb, var(--accent-light) 55%, transparent); }
.zp-downgrade-card .zp-copy { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.zp-downgrade-card .zp-copy b { color: var(--content-primary); font-size: 13.5px; font-weight: 650; }
.zp-downgrade-card .zp-copy span { color: var(--content-muted); font-size: 12.5px; line-height: 1.5; }
.zp-downgrade-card button { margin-left: auto; flex: none; display: inline-flex; align-items: center; gap: 6px; padding: 9px 15px; border: 1px solid color-mix(in srgb, var(--accent-light) 34%, transparent); border-radius: 999px; color: var(--accent-light); background: color-mix(in srgb, var(--accent-light) 9%, transparent); font-size: 12.5px; font-weight: 650; cursor: pointer; }
.zp-downgrade-card button:hover { border-color: var(--accent-light); background: color-mix(in srgb, var(--accent-light) 16%, transparent); }
.zp-api-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.zp-api-row input { flex: 1 1 180px; min-width: 140px; }
.zp-api-panel { margin-bottom: 14px; }
/* 五湖登录开场2026-08-15 冰朔谕五域悬浮湖面登录通过=第五域浮起镜头沉入
审美纪律流光不用线条EXP-106未开域幽静待建BRAIN 认知一 */
.lake-scene { overflow: hidden; transition: opacity .85s ease; }
.lake-scene.sinking { opacity: 0; }
.lake-bays { position: absolute; left: 0; right: 0; bottom: 8%; display: flex; align-items: flex-end; justify-content: center; gap: clamp(28px, 6vw, 104px); pointer-events: none; }
.lake-bay { position: relative; display: flex; flex-direction: column; align-items: center; gap: 16px; animation: bay-breathe 6s ease-in-out infinite; }
.lake-bay:nth-child(2) { animation-delay: 1.3s; }
.lake-bay:nth-child(3) { animation-delay: 2.6s; }
.lake-bay:nth-child(4) { animation-delay: .8s; }
.lake-bay:nth-child(5) { animation-delay: 1.9s; }
.lake-bay i { width: 64px; height: 64px; border-radius: 50%; filter: blur(2px); opacity: .26; background: radial-gradient(circle at 50% 36%, color-mix(in srgb, var(--primitive-cool-glow) 72%, transparent), color-mix(in srgb, var(--primitive-cool-glow) 26%, transparent) 48%, transparent 72%); }
.lake-bay b { color: var(--content-faint); font-size: 12px; letter-spacing: .34em; font-weight: 500; text-indent: .34em; }
.lake-bay.fifth i { width: 92px; height: 92px; opacity: .72; background: radial-gradient(circle at 50% 36%, var(--primitive-warm-glow), color-mix(in srgb, var(--primitive-warm-glow) 34%, transparent) 50%, transparent 74%); box-shadow: 0 0 58px color-mix(in srgb, var(--primitive-warm-glow) 42%, transparent); }
.lake-bay.fifth b { color: var(--accent-light); }
.lake-bay.rising { animation: bay-rise 1.25s cubic-bezier(.32, .72, .36, 1) forwards; z-index: 2; }
.lake-bay.rising i { opacity: 1; box-shadow: 0 0 120px color-mix(in srgb, var(--primitive-warm-glow) 65%, transparent); }
@keyframes bay-breathe { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
@keyframes bay-rise { 0% { transform: translateY(0) scale(1); } 55% { transform: translateY(-52px) scale(1.2); } 100% { transform: translateY(-150px) scale(1.9); } }
.onboarding-card { position: relative; z-index: 1; transition: opacity .7s ease, transform .7s ease; }
.onboarding-card.fade-out { opacity: 0; transform: translateY(16px) scale(.985); }