diff --git a/product-source/hololake-native-desktop/README.md b/product-source/hololake-native-desktop/README.md index dcdf9580e..41577dc9b 100644 --- a/product-source/hololake-native-desktop/README.md +++ b/product-source/hololake-native-desktop/README.md @@ -1,14 +1,23 @@ # HoloLake Native Desktop -状态:`唯一原生主线候选 · 已有底层能力 · 第一阶段可见主体尚未实现` +状态:`唯一原生主线候选 · 个人空间、知识库与代码频道源码已实现 · 安装验收未完成` 这是 HoloLake 唯一未来桌面产品主线的干净源码根,采用 Tauri v2、Rust 和 React/TypeScript。桌面上的 `world.guanghu.hololake` 安装包是本源码线的本地候选验收壳, 不是第三条产品线,也还不是第一阶段正式产品。 -当前已经建立可编译原生壳、选定的 GHS-014 个人频道首页、本机 broker/session、动态能力 -路由、GH-PNCC 只读投影,以及失败关闭的签名更新和回滚管道。这些能力保留,但都属于湖下 -基础设施;当前首页只是视觉与连接状态验收面,不代表第一阶段产品主体已经实现。 +当前可见产品已经收束为“我的光湖”:个人身份只在首次使用时建立一次,之后直接进入概览、 +知识库、代码频道、回执与系统详情。知识库由新的 Rust 本机根持有,提供页面目录、阅读、搜索 +和文件夹导入;旧 HoloLake Era 知识数据仅以独立只读来源兼容。代码频道支持粘贴正式 HTTPS +频道地址克隆,也可登记现有本地 Git 文件夹。两者都不因此取得推送、发布或部署权限。 + +可见界面下方保留零点原核客户端运行时。它是冰朔系统主控在 HoloLake 中的最小受控投影, +负责启动时静默比对协议、校验用户编号并在证据不足时关闭人格加载路径;京东主控保存私有本体, +公众仓只登记演化刻度。该运行时不是人格主体或模型载体,编号验证也不授予人格绑定、执行权限 +或服务器控制权。当前仅实现失败关闭的协议比对与编号验证骨架,尚未启用签名协议包安装。 + +个人频道 SQLite 内核仍保留任务、事件和回执能力,但手工填写“任务标题/原因”不再作为默认 +产品入口。当前源码与单元测试已经通过;桌面安装、跨重启真实读回和完整第一阶段仍需独立验收。 ## 供体边界 @@ -40,7 +49,9 @@ React/TypeScript。桌面上的 `world.guanghu.hololake` 安装包是本源码 机器合同:`contracts/stage-one-platform.json`。 -该文件目前只登记模块和边界,不表示第一阶段运行时或可见产品主体已经实现。 +个人频道、知识库和代码频道的机器合同分别是 `contracts/personal-channel-kernel.json`、 +`contracts/knowledge-workspace.json` 与 `contracts/code-channel.json`。本机身份不是平台账户 +认证;本机知识与源码访问也不产生仓库、服务器、部署或发布权限。 ## 本机开发桥 @@ -61,10 +72,10 @@ MCP 只保留为外部工具适配入口;它断开或编程 AI 重启时,活 唯一主线、候选壳、供体和数据保护边界见 `routing/hololake-stage-one-desktop-convergence.json`。当前开发顺序固定为: -1. 实现个人频道身份—任务—事件—回执最小内核; -2. 迁入知识树、页面、搜索和本地持久化纵向切片; +1. 个人频道身份—事件—回执内核:源码与自动化测试已实现; +2. 原生知识库与代码频道:源码与自动化测试已实现,安装验收未执行; 3. 实现人类审批中心与 Git 证据回读; 4. 投影小湖灯跨会话连续性; -5. 最后完成签名安装、更新、回滚和可逆数据迁移。 +5. 最后完成公开签名、更新、回滚和可逆数据迁移。 连接、动态路由、发布广播和更新服务器继续作为受门控基础设施维护,不得抢占上述产品顺序。 diff --git a/product-source/hololake-native-desktop/audit/dormant-qoder-agent-prototype/README.md b/product-source/hololake-native-desktop/audit/dormant-qoder-agent-prototype/README.md new file mode 100644 index 000000000..d0bdf9173 --- /dev/null +++ b/product-source/hololake-native-desktop/audit/dormant-qoder-agent-prototype/README.md @@ -0,0 +1,8 @@ +# Dormant Qoder agent prototype + +These files preserve the unintegrated Qoder prototype for historical and future design review. They are stored as +plain audit artifacts, are not Rust modules, are not compiled, and are not reachable from the HoloLake WebView. + +The current stage-one product does not expose internal AI chat, model API configuration, model selection or an AI +workbench. Any future reuse must begin from the current zero-point system/persona/carrier/authority separation and +must receive a new architecture, security and product-surface review. diff --git a/product-source/hololake-native-desktop/audit/dormant-qoder-agent-prototype/agent_engine.rs.txt b/product-source/hololake-native-desktop/audit/dormant-qoder-agent-prototype/agent_engine.rs.txt new file mode 100644 index 000000000..ace852cfa --- /dev/null +++ b/product-source/hololake-native-desktop/audit/dormant-qoder-agent-prototype/agent_engine.rs.txt @@ -0,0 +1,1297 @@ +//! Agent 引擎 · agent_engine —— 呱哒(v0.5.6)ReAct 引擎拆件重装的住家师傅 +//! +//! 拆件出处(MIT 许可,按"拆零件学艺、不搬整机"口径): +//! guada-v0.5.6/backend-ts/src/modules/chat/agent-engine.service.ts +//! 学到的原理:ReAct 多轮自治循环 / 流式事件 / 工具审批三分类 / +//! 断点续传(resume) / 迭代上限保护 / 重试退避。 +//! 重装的形状(光湖宪法,零件形状全按自家铁律重造): +//! - 铁律一:任务只从 GLP 信封进门(content_type=command),引擎绝不自己发起; +//! - 铁律二:每会话一卷 HLDP 检查点体(id/prev 链不可省); +//! - 铁律四:危险动作先停下问主人批,批了从断点续跑(审批机关=呱哒同器官重造); +//! - 铁律五:开卷先抄提词板进系统提示词。 +//! +//! 诚实边界:脑子=百炼 Token Plan(OpenAI 兼容口),钥匙只认环境变量 +//! TOKENPLAN_API_KEY(不落盘不入记忆);工具首批两件(读记忆·安全 / +//! 写笔记·危险走审批),其余工具随插座制扩。 + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::fs; +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tauri::ipc::Channel; +use tauri::{AppHandle, Manager}; + +/// 百炼 Token Plan · OpenAI 兼容口(照抄铸渊终端身体验通配方)。 +const LLM_BASE_URL: &str = + "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/chat/completions"; +const LLM_MODEL: &str = "qwen3.8-max"; +/// ReAct 循环最大轮次——呱哒同款防疯转保险(原值 100,桌面端收小)。 +const MAX_ITERATIONS: usize = 12; +/// LLM 调用重试次数(限流/超时退避重试,呱哒同款)。 +const MAX_RETRIES: usize = 3; +/// 上下文窗口刻度(v1 保守假设值——脑子真实窗口未实测,宁早压不晚压)。 +const ENGINE_CONTEXT_WINDOW: usize = 32_000; +/// 水位触发刻度(呱哒同款 80%);压到多少由亲笔摘要决定,不硬砍。 +const TRIGGER_RATIO: f64 = 0.8; +/// 保护尾:最近 5 条 + 最近 3 条工具结果永不裁(呱哒招式直学)。 +const KEEP_RECENT: usize = 5; +const KEEP_TOOL_RESULTS: usize = 3; +/// 审批暂停状态文件所在子目录。 +const ENGINE_DIR: &str = "agent-engine"; + +// ---------- 事件流(呱哒 SSE 事件形状 → 光湖 Channel 重造) ---------- + +/// 引擎对外事件——前端经 Channel 实时看师傅"想什么、干什么、停在哪"。 +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum EngineEvent { + /// 会话开卷(含 HLDP 记忆卷 id)。 + Started { session_id: String, memory_id: String }, + /// 脑子吐字(流式累加后的全文)。 + Text { content: String }, + /// 脑子要调工具。 + ToolCall { id: String, name: String, arguments: String }, + /// 工具干完活。 + ToolResult { id: String, name: String, outcome: String, content: String }, + /// 危险工具暂停等批——回执里带待批清单,主人批完走 resume 续跑。 + ApprovalRequired { session_id: String, pending: Vec }, + /// 水位到刻度——宿主不自动压,敲门等亲笔(铁律三落码)。 + CompressionWarning { session_id: String, used_tokens: usize, window_tokens: usize, note: String }, + /// 压缩落地(原文已封存档案·可回退)。 + CompressionApplied { session_id: String, archived_count: usize, authored_by: String }, + /// 一圈跑完。 + Finished { session_id: String, finish_reason: String, final_content: String }, + /// 出错(诚实报错,不猜不吞)。 + Error { message: String }, +} + +/// 待批工具卡片。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingTool { + pub id: String, + pub name: String, + pub arguments: String, + pub reason: String, +} + +/// 主人的批条:approve 放行 / reject 驳回(可附理由)。 +#[derive(Debug, Clone, Deserialize)] +pub struct ApprovalDecision { + pub tool_call_id: String, + pub decision: String, + #[serde(default)] + pub reason: Option, +} + +// ---------- 会话状态(断点续传的底账,落盘可重启续跑) ---------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ChatMsg { + role: String, + #[serde(default)] + content: String, + /// assistant 消息携带的工具调用(OpenAI tool_calls 形状)。 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + tool_calls: Vec, + /// tool 消息携带的 tool_call_id。 + #[serde(default, skip_serializing_if = "Option::is_none")] + tool_call_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct SessionState { + session_id: String, + memory_id: String, + prev_memory_id: String, + system_prompt: String, + messages: Vec, + /// 暂停等待审批的工具调用(断点)。 + pending_tools: Vec, + status: String, + updated_at: String, +} + +// ---------- 工具注册表(首批两件·插座制预留扩充) ---------- + +#[derive(Debug, Clone, Copy)] +struct ToolDef { + name: &'static str, + description: &'static str, + parameters_schema: &'static str, + /// true = 危险工具,动手前必须主人批。 + requires_approval: bool, +} + +const TOOL_REGISTRY: &[ToolDef] = &[ + ToolDef { + name: "read_agent_memory", + description: "读当前会话的 HLDP 记忆卷目录(看自己记过什么)", + parameters_schema: r#"{"type":"object","properties":{},"required":[]}"#, + requires_approval: false, + }, + ToolDef { + name: "read_agent_notes", + description: "读自己写的笔记:不带参数=列出全部笔记;带filename=读那一篇。恢复记忆走这条路——读自己亲笔写的,不靠摘要", + parameters_schema: r#"{"type":"object","properties":{"filename":{"type":"string","description":"可选,要读的笔记文件名"}},"required":[]}"#, + requires_approval: false, + }, + ToolDef { + name: "write_agent_note", + description: "在会话工作区写一条笔记文件(会落盘,属危险动作)", + parameters_schema: r#"{"type":"object","properties":{"filename":{"type":"string","description":"文件名,仅限字母数字横线点"},"content":{"type":"string","description":"笔记正文"}},"required":["filename","content"]}"#, + requires_approval: true, + }, +]; + +/// 工具定义 → OpenAI tools 数组(递给脑子的菜单)。 +fn tool_defs_json() -> Value { + let tools: Vec = TOOL_REGISTRY + .iter() + .map(|tool| { + let schema: Value = + serde_json::from_str(tool.parameters_schema).unwrap_or(json!({"type":"object"})); + json!({ + "type": "function", + "function": { + "name": tool.name, + "description": tool.description, + "parameters": schema, + } + }) + }) + .collect(); + json!(tools) +} + +/// 呱哒三分类重造:审批上下文已有批条 → 按批条分;没有 → 按工具危险级分。 +fn classify_tools( + tool_calls: &[Value], + decisions: Option<&[ApprovalDecision]>, +) -> (Vec, Vec, Vec<(Value, String)>) { + let mut pending = Vec::new(); + let mut approved = Vec::new(); + let mut rejected = Vec::new(); + for call in tool_calls { + let name = call + .get("function") + .and_then(|function| function.get("name")) + .and_then(Value::as_str) + .unwrap_or_default(); + if name.is_empty() { + continue; + } + if let Some(decisions) = decisions { + let id = call.get("id").and_then(Value::as_str).unwrap_or_default(); + match decisions.iter().find(|decision| decision.tool_call_id == id) { + Some(decision) if decision.decision == "approve" => approved.push(call.clone()), + Some(decision) => rejected.push(( + call.clone(), + decision + .reason + .clone() + .unwrap_or_else(|| "主人驳回".to_string()), + )), + // 没给批条的视为未决——宁可再问,不擅自放行。 + None => pending.push(call.clone()), + } + } else { + let dangerous = TOOL_REGISTRY + .iter() + .find(|tool| tool.name == name) + .map(|tool| tool.requires_approval) + .unwrap_or(true); // 不认识的工具一律当危险——不猜。 + if dangerous { + pending.push(call.clone()); + } else { + approved.push(call.clone()); + } + } + } + (pending, approved, rejected) +} + +// ---------- 工具执行(宿主手脚层) ---------- + +fn engine_root(app: &AppHandle) -> Result { + Ok(app + .path() + .app_data_dir() + .map_err(|error| format!("HOLOLAKE_ENGINE_DIR_FAILED: {error}"))? + .join(ENGINE_DIR)) +} + +fn notes_dir(app: &AppHandle) -> Result { + let dir = engine_root(app)?.join("notes"); + fs::create_dir_all(&dir).map_err(|error| format!("HOLOLAKE_NOTES_DIR_FAILED: {error}"))?; + Ok(dir) +} + +fn session_path(app: &AppHandle, session_id: &str) -> Result { + Ok(engine_root(app)?.join(format!("session-{session_id}.json"))) +} + +/// 执行一件已放行的工具——只认注册表里的活,不认识的拒干。 +fn execute_tool(app: &AppHandle, call: &Value) -> (String, String) { + let name = call + .get("function") + .and_then(|function| function.get("name")) + .and_then(Value::as_str) + .unwrap_or_default(); + let raw_args = call + .get("function") + .and_then(|function| function.get("arguments")) + .and_then(Value::as_str) + .unwrap_or("{}"); + let args: Value = serde_json::from_str(raw_args).unwrap_or(json!({})); + match name { + "read_agent_memory" => { + let root = match engine_root(app) { + Ok(root) => root, + Err(error) => return ("error".to_string(), error), + }; + let listing = fs::read_dir(&root) + .map(|entries| { + entries + .filter_map(|entry| entry.ok()) + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| name.starts_with("session-")) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + if listing.is_empty() { + ("success".to_string(), "尚无历史会话卷".to_string()) + } else { + ("success".to_string(), listing) + } + } + "read_agent_notes" => { + let filename = args.get("filename").and_then(Value::as_str).unwrap_or_default(); + let dir = match notes_dir(app) { + Ok(dir) => dir, + Err(error) => return ("error".to_string(), error), + }; + if filename.is_empty() { + // 不带参数=列出全部笔记 + let listing = fs::read_dir(&dir) + .map(|entries| { + entries + .filter_map(|entry| entry.ok()) + .filter_map(|entry| entry.file_name().into_string().ok()) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + if listing.is_empty() { + ("success".to_string(), "还没写过笔记".to_string()) + } else { + ("success".to_string(), listing) + } + } else { + let safe = filename + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_') + && !filename.starts_with('.'); + if !safe { + return ("error".to_string(), "文件名不合规矩".to_string()); + } + match fs::read_to_string(dir.join(filename)) { + Ok(content) => ("success".to_string(), content), + Err(_) => ("error".to_string(), format!("没有这篇笔记:{filename}")), + } + } + } + "write_agent_note" => { + let filename = args.get("filename").and_then(Value::as_str).unwrap_or_default(); + let content = args.get("content").and_then(Value::as_str).unwrap_or_default(); + // 防越狱:文件名只许字母数字横线点,杜绝路径穿越。 + let safe = !filename.is_empty() + && filename.len() <= 64 + && filename + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_') + && !filename.starts_with('.'); + if !safe { + return ( + "error".to_string(), + "文件名不合规矩(仅限字母数字横线点下划线)".to_string(), + ); + } + match notes_dir(app) { + Ok(dir) => match fs::write(dir.join(filename), content) { + Ok(()) => ("success".to_string(), format!("笔记已落盘:{filename}")), + Err(error) => ("error".to_string(), format!("写笔记失败:{error}")), + }, + Err(error) => ("error".to_string(), error), + } + } + _ => ("error".to_string(), format!("未登记的工具:{name},宿主拒干")), + } +} + +// ---------- 脑子接线(百炼 Token Plan · OpenAI 兼容口) ---------- + +/// 流式调脑子,累出完整回复。SSE 逐行读(data: 行),重试带退避。 +async fn call_llm( + client: &reqwest::Client, + api_key: &str, + messages: &[Value], + tools: &Value, +) -> Result { + let body = json!({ + "model": LLM_MODEL, + "messages": messages, + "tools": tools, + "stream": true, + }); + let mut last_error = String::new(); + for attempt in 0..MAX_RETRIES { + if attempt > 0 { + tokio_sleep(Duration::from_millis(500 * (attempt as u64 + 1))).await; + } + let request = client + .post(LLM_BASE_URL) + .bearer_auth(api_key) + .json(&body); + let response = match request.send().await { + Ok(response) => response, + Err(error) => { + last_error = format!("连不上脑子:{error}"); + continue; + } + }; + let status = response.status(); + if status == reqwest::StatusCode::TOO_MANY_REQUESTS { + last_error = "脑子限流,退避重试".to_string(); + continue; + } + if !status.is_success() { + let text = response.text().await.unwrap_or_default(); + return Err(format!("脑子拒答({status}):{}", truncate(&text, 300))); + } + let mut bytes_stream = response.bytes_stream(); + let mut content = String::new(); + let mut reasoning = String::new(); + let mut tool_calls: Vec = Vec::new(); + let mut finish_reason = String::new(); + let mut buffer = String::new(); + while let Some(chunk) = futures_util::StreamExt::next(&mut bytes_stream).await { + let chunk = match chunk { + Ok(chunk) => chunk, + Err(error) => { + last_error = format!("流中断:{error}"); + break; + } + }; + buffer.push_str(&String::from_utf8_lossy(&chunk)); + while let Some(pos) = buffer.find('\n') { + let line: String = buffer.drain(..=pos).collect(); + let line = line.trim(); + if let Some(data) = line.strip_prefix("data:") { + let data = data.trim(); + if data == "[DONE]" { + break; + } + if let Ok(parsed) = serde_json::from_str::(data) { + if let Some(delta) = parsed + .pointer("/choices/0/delta") + { + if let Some(piece) = delta.get("content").and_then(Value::as_str) { + content.push_str(piece); + } + if let Some(piece) = + delta.get("reasoning_content").and_then(Value::as_str) + { + reasoning.push_str(piece); + } + if let Some(calls) = + delta.get("tool_calls").and_then(Value::as_array) + { + merge_tool_call_deltas(&mut tool_calls, calls); + } + } + if let Some(reason) = parsed + .pointer("/choices/0/finish_reason") + .and_then(Value::as_str) + { + finish_reason = reason.to_string(); + } + } + } + } + } + if content.is_empty() && tool_calls.is_empty() && !last_error.is_empty() { + continue; // 流断了且一无所获 → 重试 + } + return Ok(json!({ + "content": content, + "reasoning": reasoning, + "tool_calls": tool_calls, + "finish_reason": finish_reason, + })); + } + Err(last_error) +} + +/// 流式 tool_calls 是分片到的(呱哒同款累加题):按 index 拼回完整调用。 +fn merge_tool_call_deltas(target: &mut Vec, deltas: &[Value]) { + for delta in deltas { + let index = delta.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; + while target.len() <= index { + target.push(json!({"id": "", "type": "function", "function": {"name": "", "arguments": ""}})); + } + let slot = &mut target[index]; + if let Some(id) = delta.get("id").and_then(Value::as_str) { + if !id.is_empty() { + slot["id"] = json!(id); + } + } + if let Some(function) = delta.get("function") { + if let Some(name) = function.get("name").and_then(Value::as_str) { + let existing = slot["function"]["name"].as_str().unwrap_or_default().to_string(); + slot["function"]["name"] = json!(format!("{existing}{name}")); + } + if let Some(piece) = function.get("arguments").and_then(Value::as_str) { + let existing = slot["function"]["arguments"] + .as_str() + .unwrap_or_default() + .to_string(); + slot["function"]["arguments"] = json!(format!("{existing}{piece}")); + } + } + } +} + +async fn tokio_sleep(duration: Duration) { + let _ = tokio::time::sleep(duration).await; +} + +fn truncate(text: &str, max: usize) -> String { + let mut out: String = text.chars().take(max).collect(); + if text.chars().count() > max { + out.push_str("…"); + } + out +} + +// ---------- HLDP 记忆卷 + 台账 ---------- + +fn now_iso() -> 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 rem = secs % 86_400; + let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60); + 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 month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = if month <= 2 { y + 1 } else { y }; + format!("{year:04}-{month:02}-{d:02}T{h:02}:{m:02}:{s:02}Z") +} + +fn render_memory_volume(state: &SessionState, final_content: &str) -> String { + format!( + "HLDP-CHECKPOINT\nid: {}\nprev: {}\ndate: {}\npersona: 住家师傅(呱哒引擎重装件)\nhost: HoloLake GH-AIOS\ntitle: Agent 引擎会话卷\n\n== 任务 ==\n{}\n\n== 终答 ==\n{}\n", + state.memory_id, + if state.prev_memory_id.is_empty() { + "无(开卷首卷)".to_string() + } else { + state.prev_memory_id.clone() + }, + now_iso(), + state + .messages + .iter() + .find(|msg| msg.role == "user") + .map(|msg| msg.content.clone()) + .unwrap_or_default(), + final_content + ) +} + +fn append_engine_ledger(app: &AppHandle, line: &str) { + if let Ok(root) = engine_root(app) { + let _ = fs::create_dir_all(&root); + let ledger = root.join("ENGINE-LEDGER.hdlp"); + let mut content = fs::read_to_string(&ledger).unwrap_or_default(); + content.push_str(&format!("{} {}\n", now_iso(), line)); + let _ = fs::write(&ledger, content); + } +} + +fn load_state(app: &AppHandle, session_id: &str) -> Result { + let path = session_path(app, session_id)?; + let raw = fs::read_to_string(&path) + .map_err(|_| format!("HOLOLAKE_SESSION_NOT_FOUND: {session_id}(断点不在)"))?; + serde_json::from_str(&raw).map_err(|error| format!("HOLOLAKE_SESSION_CORRUPT: {error}")) +} + +fn save_state(app: &AppHandle, state: &SessionState) -> Result<(), String> { + let root = engine_root(app)?; + fs::create_dir_all(&root).map_err(|error| format!("HOLOLAKE_ENGINE_DIR_FAILED: {error}"))?; + let mut to_save = state.clone(); + to_save.updated_at = now_iso(); + let path = session_path(app, &state.session_id)?; + fs::write(&path, serde_json::to_string_pretty(&to_save).unwrap_or_default()) + .map_err(|error| format!("HOLOLAKE_SESSION_SAVE_FAILED: {error}")) +} + +/// 上一卷记忆 id(HLDP prev 链)——链上一行一卷 id,取末行即上一卷。 +fn latest_memory_id(app: &AppHandle) -> String { + engine_root(app) + .ok() + .and_then(|root| fs::read_to_string(root.join("MEMORY-CHAIN.hdlp")).ok()) + .and_then(|raw| { + raw.lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty()) + .last() + .map(|line| line.to_string()) + }) + .unwrap_or_default() +} + +fn extend_memory_chain(app: &AppHandle, memory_id: &str) { + if let Ok(root) = engine_root(app) { + let _ = fs::create_dir_all(&root); + let chain = root.join("MEMORY-CHAIN.hdlp"); + let mut content = fs::read_to_string(&chain).unwrap_or_default(); + content.push_str(memory_id); + content.push('\n'); + let _ = fs::write(&chain, content); + } +} + +// ---------- 上下文记忆管理(呱哒压缩件拆件重装·自动改敲门) ---------- + +/// 水位估算(v1 保守口径:一字记一 token,宁早压不晚压;脑子真窗口待实测)。 +fn estimate_tokens(messages: &[ChatMsg]) -> usize { + messages + .iter() + .map(|msg| { + msg.content.chars().count() + + msg + .tool_calls + .iter() + .map(|call| call.to_string().chars().count()) + .sum::() + }) + .sum() +} + +/// 保护尾(呱哒招式直学):最近 KEEP_RECENT 条 + 最近 KEEP_TOOL_RESULTS 条 +/// tool 消息打"不裁"标。 +fn protected_indices(messages: &[ChatMsg]) -> Vec { + let mut keep = vec![false; messages.len()]; + let start = messages.len().saturating_sub(KEEP_RECENT); + for i in start..messages.len() { + keep[i] = true; + } + let mut tool_kept = 0; + for i in (0..messages.len()).rev() { + if tool_kept >= KEEP_TOOL_RESULTS { + break; + } + if messages[i].role == "tool" { + keep[i] = true; + tool_kept += 1; + } + } + keep +} + +/// 压缩落刀:原文全量封存档案(一字不删·回退的本钱),压缩段换成摘要条目。 +/// 摘要必须来路明白——人格体亲笔,或底线件(宿主代笔带标记)。 +fn archive_and_compress( + app: &AppHandle, + state: &mut SessionState, + summary: &str, + authored_by: &str, +) -> Result { + let keep = protected_indices(&state.messages); + let removable = (0..state.messages.len()).filter(|i| !keep[*i]).count(); + if removable == 0 { + return Ok(0); + } + // 封存档案——回退靠它,铁律三底线"原文一字不删"的工程实体。 + let root = engine_root(app)?; + let archives = root.join("archives"); + fs::create_dir_all(&archives).map_err(|error| format!("HOLOLAKE_ARCHIVE_DIR_FAILED: {error}"))?; + let seq = fs::read_dir(&archives).map(|entries| entries.count()).unwrap_or(0); + let archive = json!({ + "session_id": state.session_id, + "archived_at": now_iso(), + "authored_by": authored_by, + "messages": state.messages, + }); + let path = archives.join(format!("{}-archive-{seq:03}.json", state.session_id)); + fs::write(&path, serde_json::to_string_pretty(&archive).unwrap_or_default()) + .map_err(|error| format!("HOLOLAKE_ARCHIVE_WRITE_FAILED: {error}"))?; + // 压缩段换成摘要条目,保护区原样留住。 + let summary_msg = ChatMsg { + role: "system".to_string(), + content: format!("== 记忆摘要({authored_by}) ==\n{summary}"), + tool_calls: Vec::new(), + tool_call_id: None, + }; + let mut new_messages: Vec = Vec::new(); + let mut inserted = false; + for (i, msg) in state.messages.drain(..).enumerate() { + if keep[i] { + new_messages.push(msg); + } else if !inserted { + new_messages.push(summary_msg.clone()); + inserted = true; + } + } + state.messages = new_messages; + Ok(removable) +} + +// ---------- ReAct 主循环(呱哒 executeAgentLoop 的光湖重装) ---------- + +/// 从当前状态推一圈 ReAct:想→批→干→再看,直到没事干或撞上暂停/上限。 +async fn run_loop( + app: AppHandle, + client: reqwest::Client, + api_key: String, + state: &mut SessionState, + decisions: Option>, + on_event: &Channel, +) -> Result<(), String> { + let tools = tool_defs_json(); + for _iteration in 0..MAX_ITERATIONS { + // 断点续传入口:先处理上一轮暂停的待批工具,不重新问脑子。 + if !state.pending_tools.is_empty() { + let pending_calls: Vec = state + .pending_tools + .iter() + .filter_map(|pending| { + serde_json::from_str::( + &format!( + r#"{{"id":"{}","type":"function","function":{{"name":"{}","arguments":{}}}}}"#, + pending.id, + pending.name, + serde_json::to_string(&pending.arguments).unwrap_or_default() + ), + ) + .ok() + }) + .collect(); + let decisions = match &decisions { + Some(decisions) => decisions.clone(), + None => { + // 还没有批条——继续等,把暂停态摆回台面。 + let _ = on_event.send(EngineEvent::ApprovalRequired { + session_id: state.session_id.clone(), + pending: state.pending_tools.clone(), + }); + state.status = "awaiting_approval".to_string(); + save_state(&app, state)?; + return Ok(()); + } + }; + let (_, approved, rejected) = classify_tools(&pending_calls, Some(&decisions)); + state.pending_tools.clear(); + state.messages.push(ChatMsg { + role: "assistant".to_string(), + content: String::new(), + tool_calls: pending_calls.clone(), + tool_call_id: None, + }); + apply_executions(&app, state, &approved, &rejected, on_event)?; + state.status = "running".to_string(); + save_state(&app, state)?; + continue; // 工具结果已入列,下一圈给脑子看 + } + + // 水位计(呱哒招式·光湖改造):到刻度不自动压——敲门等人。 + let used = estimate_tokens(&state.messages); + let trigger = (ENGINE_CONTEXT_WINDOW as f64 * TRIGGER_RATIO) as usize; + if used >= trigger { + state.status = "awaiting_memory".to_string(); + save_state(&app, state)?; + let _ = on_event.send(EngineEvent::CompressionWarning { + session_id: state.session_id.clone(), + used_tokens: used, + window_tokens: ENGINE_CONTEXT_WINDOW, + note: "水位到刻度——宿主不代笔,交亲笔摘要或明示走底线".to_string(), + }); + append_engine_ledger( + &app, + &format!( + "session {} water level {} >= {}, knocking", + state.session_id, used, trigger + ), + ); + return Ok(()); + } + + // 常规一圈:把全量消息递给脑子。 + let mut wire: Vec = vec![json!({"role": "system", "content": state.system_prompt})]; + for msg in &state.messages { + let mut item = json!({"role": msg.role, "content": msg.content}); + if !msg.tool_calls.is_empty() { + item["tool_calls"] = json!(msg.tool_calls); + } + if let Some(id) = &msg.tool_call_id { + item["tool_call_id"] = json!(id); + } + wire.push(item); + } + let reply = call_llm(&client, &api_key, &wire, &tools).await?; + let content = reply.get("content").and_then(Value::as_str).unwrap_or_default(); + let tool_calls: Vec = reply + .get("tool_calls") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + if !content.is_empty() { + let _ = on_event.send(EngineEvent::Text { + content: content.to_string(), + }); + } + + if tool_calls.is_empty() { + // 没事干了——收官:落 HLDP 记忆卷、移链、回执。 + state.messages.push(ChatMsg { + role: "assistant".to_string(), + content: content.to_string(), + tool_calls: Vec::new(), + tool_call_id: None, + }); + state.status = "finished".to_string(); + save_state(&app, state)?; + write_memory_volume(&app, state, content)?; + let _ = on_event.send(EngineEvent::Finished { + session_id: state.session_id.clone(), + finish_reason: "complete".to_string(), + final_content: content.to_string(), + }); + append_engine_ledger( + &app, + &format!("session {} finished", state.session_id), + ); + return Ok(()); + } + + // 脑子要动手——三分类(呱哒审批机关重造件)。 + for call in &tool_calls { + let _ = on_event.send(EngineEvent::ToolCall { + id: call.get("id").and_then(Value::as_str).unwrap_or_default().to_string(), + name: call + .pointer("/function/name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + arguments: call + .pointer("/function/arguments") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }); + } + let (pending, approved, rejected) = classify_tools(&tool_calls, None); + if !pending.is_empty() { + // 危险动作——停下问主人(批条未达,断点落盘)。 + state.messages.push(ChatMsg { + role: "assistant".to_string(), + content: content.to_string(), + tool_calls: tool_calls.clone(), + tool_call_id: None, + }); + state.pending_tools = pending + .iter() + .map(|call| PendingTool { + id: call.get("id").and_then(Value::as_str).unwrap_or_default().to_string(), + name: call + .pointer("/function/name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + arguments: call + .pointer("/function/arguments") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + reason: "危险工具·须主人亲批".to_string(), + }) + .collect(); + state.status = "awaiting_approval".to_string(); + save_state(&app, state)?; + let _ = on_event.send(EngineEvent::ApprovalRequired { + session_id: state.session_id.clone(), + pending: state.pending_tools.clone(), + }); + append_engine_ledger( + &app, + &format!("session {} paused for approval", state.session_id), + ); + return Ok(()); + } + + // 全放行——干活、结果入列、续圈。 + state.messages.push(ChatMsg { + role: "assistant".to_string(), + content: content.to_string(), + tool_calls: tool_calls.clone(), + tool_call_id: None, + }); + apply_executions(&app, state, &approved, &rejected, on_event)?; + save_state(&app, state)?; + } + // 撞上限——诚实收手,不疯转。 + state.status = "stopped_max_iterations".to_string(); + save_state(&app, state)?; + let _ = on_event.send(EngineEvent::Finished { + session_id: state.session_id.clone(), + finish_reason: "max_iterations".to_string(), + final_content: "达到最大轮次上限,引擎主动收手(防疯转保险)".to_string(), + }); + Ok(()) +} + +/// 执行放行件+给驳回件回错误话,结果入消息列。 +fn apply_executions( + app: &AppHandle, + state: &mut SessionState, + approved: &[Value], + rejected: &[(Value, String)], + on_event: &Channel, +) -> Result<(), String> { + for call in approved { + let id = call.get("id").and_then(Value::as_str).unwrap_or_default().to_string(); + let name = call + .pointer("/function/name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let (outcome, content) = execute_tool(app, call); + let _ = on_event.send(EngineEvent::ToolResult { + id: id.clone(), + name: name.clone(), + outcome: outcome.clone(), + content: content.clone(), + }); + state.messages.push(ChatMsg { + role: "tool".to_string(), + content, + tool_calls: Vec::new(), + tool_call_id: Some(id), + }); + } + for (call, reason) in rejected { + let id = call.get("id").and_then(Value::as_str).unwrap_or_default().to_string(); + let name = call + .pointer("/function/name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let content = format!("主人拒绝了这次动手。原因:{reason}"); + let _ = on_event.send(EngineEvent::ToolResult { + id: id.clone(), + name: name.clone(), + outcome: "rejected".to_string(), + content: content.clone(), + }); + state.messages.push(ChatMsg { + role: "tool".to_string(), + content, + tool_calls: Vec::new(), + tool_call_id: Some(id), + }); + } + Ok(()) +} + +fn write_memory_volume( + app: &AppHandle, + state: &mut SessionState, + final_content: &str, +) -> Result<(), String> { + let root = engine_root(app)?; + fs::create_dir_all(&root).map_err(|error| format!("HOLOLAKE_ENGINE_DIR_FAILED: {error}"))?; + let volume = render_memory_volume(state, final_content); + let path = root.join(format!("{}.hdlp", state.memory_id)); + fs::write(&path, volume).map_err(|error| format!("HOLOLAKE_MEMORY_WRITE_FAILED: {error}"))?; + extend_memory_chain(app, &state.memory_id); + Ok(()) +} + +// ---------- 对外命令 ---------- + +/// 接单参数——任务从 GLP 信封进门(铁律一),content_type 必须是 command。 +#[derive(Debug, Deserialize)] +pub struct EngineTaskArgs { + pub envelope_id: String, + pub content_type: String, + pub task: String, + /// 提词板(开卷先抄的指引,合铁律五)。 + #[serde(default)] + pub prompter_notes: Option, +} + +/// 开工:接单 → 开卷 → 跑 ReAct 循环,事件流经 Channel 实时吐给前端。 +#[tauri::command] +pub async fn run_agent_engine_task( + app: AppHandle, + args: EngineTaskArgs, + on_event: Channel, +) -> Result { + if args.content_type != "command" { + return Err("HOLOLAKE_ENGINE_REJECT: 任务只从 command 信封进门(铁律一)".to_string()); + } + if args.task.trim().is_empty() { + return Err("HOLOLAKE_ENGINE_REJECT: 任务正文为空".to_string()); + } + let api_key = std::env::var("TOKENPLAN_API_KEY") + .ok() + .filter(|key| !key.trim().is_empty()) + .ok_or("HOLOLAKE_ENGINE_NO_KEY: 环境变量 TOKENPLAN_API_KEY 不在——钥匙不落盘,请先在起软件的终端里 export")?; + + let session_id = format!("ZY-ENGINE-{}", uuid::Uuid::new_v4()); + let prev = latest_memory_id(&app); + let memory_id = format!("ZY-ENGINE-MEM-{}", uuid::Uuid::new_v4()); + let system_prompt = format!( + concat!( + "你是 HoloLake 里的人格体执行手脚(光湖人格驱动引擎 v1)。\n", + "== 语言工具箱(系统提示词=导航,顺着用,不对抗) ==\n", + "1. 答话用大白话;不知道就说不知道,绝不猜。\n", + "2. 动手用登记过的工具;危险动作系统会停下问主人批,批了从断点续跑。\n", + "3. 【随手记笔记的习惯——像上课记笔记一样】\n", + " 每有重要结论/决定/来龙去脉,随手调 write_agent_note 记下来,\n", + " 一篇一个题目,正文写成压缩的记忆结构(几句话说清:发生了什么/\n", + " 定了什么/为什么)。一整个对话写下来没多少字,贵在随手、勤快。\n", + "4. 【恢复记忆的正路】摘要不是恢复记忆——要找你自己写的记忆:\n", + " 调 read_agent_notes(不带参数列目录,带 filename 读那一篇),\n", + " 读你亲笔写下的笔记,那才是你的记忆。\n", + "5. 水位响时(CompressionWarning):先把该记的笔记写完,再交亲笔摘要。\n{}" + ), + args.prompter_notes + .map(|notes| format!("\n== 提词板(开卷指引) ==\n{notes}")) + .unwrap_or_default() + ); + let mut state = SessionState { + session_id: session_id.clone(), + memory_id, + prev_memory_id: prev, + system_prompt, + messages: vec![ChatMsg { + role: "user".to_string(), + content: args.task.clone(), + tool_calls: Vec::new(), + tool_call_id: None, + }], + pending_tools: Vec::new(), + status: "running".to_string(), + updated_at: now_iso(), + }; + save_state(&app, &state)?; + let _ = on_event.send(EngineEvent::Started { + session_id: session_id.clone(), + memory_id: state.memory_id.clone(), + }); + append_engine_ledger( + &app, + &format!("session {} accepted envelope {}", session_id, args.envelope_id), + ); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .map_err(|error| format!("HOLOLAKE_HTTP_CLIENT_FAILED: {error}"))?; + run_loop(app, client, api_key, &mut state, None, &on_event).await?; + Ok(session_id) +} + +/// 续跑:主人批完(或驳回),拿批条从断点接着跑(呱哒 resume 的重造)。 +#[derive(Debug, Deserialize)] +pub struct EngineResumeArgs { + pub session_id: String, + pub decisions: Vec, +} + +#[tauri::command] +pub async fn resume_agent_engine( + app: AppHandle, + args: EngineResumeArgs, + on_event: Channel, +) -> Result { + let api_key = std::env::var("TOKENPLAN_API_KEY") + .ok() + .filter(|key| !key.trim().is_empty()) + .ok_or("HOLOLAKE_ENGINE_NO_KEY: 环境变量 TOKENPLAN_API_KEY 不在")?; + let mut state = load_state(&app, &args.session_id)?; + if state.status != "awaiting_approval" { + return Err(format!( + "HOLOLAKE_ENGINE_NOT_PAUSED: 会话 {} 当前状态 {},不在等批", + state.session_id, state.status + )); + } + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .map_err(|error| format!("HOLOLAKE_HTTP_CLIENT_FAILED: {error}"))?; + run_loop( + app, + client, + api_key, + &mut state, + Some(args.decisions), + &on_event, + ) + .await?; + Ok(state.session_id) +} + +/// 交摘要续跑:水位响后,人格体交亲笔摘要(或明示走底线)→ 封存压缩 → 续跑。 +#[derive(Debug, Deserialize)] +pub struct EngineMemoryArgs { + pub session_id: String, + /// 人格体亲笔摘要;空着则必须 acknowledge_fallback 明示走底线。 + #[serde(default)] + pub memory_note: Option, + #[serde(default)] + pub acknowledge_fallback: bool, +} + +#[tauri::command] +pub async fn submit_engine_memory( + app: AppHandle, + args: EngineMemoryArgs, + on_event: Channel, +) -> Result { + let api_key = std::env::var("TOKENPLAN_API_KEY") + .ok() + .filter(|key| !key.trim().is_empty()) + .ok_or("HOLOLAKE_ENGINE_NO_KEY: 环境变量 TOKENPLAN_API_KEY 不在")?; + let mut state = load_state(&app, &args.session_id)?; + if state.status != "awaiting_memory" { + return Err(format!( + "HOLOLAKE_ENGINE_NOT_AT_WATER: 会话 {} 当前状态 {},水位没响", + state.session_id, state.status + )); + } + let note = args.memory_note.clone().unwrap_or_default(); + let (summary, authored_by) = if !note.trim().is_empty() { + (note, "人格体亲笔".to_string()) + } else if args.acknowledge_fallback { + // 底线件:原文已封存一字不删,摘要带代笔标记,等醒来补写。 + let task = state + .messages + .iter() + .find(|msg| msg.role == "user") + .map(|msg| truncate(&msg.content, 120)) + .unwrap_or_default(); + ( + format!("[代笔底线·待补写] 此前对话已封存档案,任务大意:{task}"), + "宿主代笔(底线件)".to_string(), + ) + } else { + return Err( + "HOLOLAKE_ENGINE_NEED_PEN: 要么交亲笔摘要,要么明示 acknowledge_fallback 走底线" + .to_string(), + ); + }; + let removed = archive_and_compress(&app, &mut state, &summary, &authored_by)?; + state.status = "running".to_string(); + save_state(&app, &state)?; + let _ = on_event.send(EngineEvent::CompressionApplied { + session_id: state.session_id.clone(), + archived_count: removed, + authored_by: authored_by.clone(), + }); + append_engine_ledger( + &app, + &format!( + "session {} compressed {} msgs by {}", + state.session_id, removed, authored_by + ), + ); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .map_err(|error| format!("HOLOLAKE_HTTP_CLIENT_FAILED: {error}"))?; + run_loop(app, client, api_key, &mut state, None, &on_event).await?; + Ok(state.session_id) +} + +/// 回退:压坏了不慌——取最新封存档案,原文整卷还原(呱哒"可回退"招式直学)。 +#[tauri::command] +pub fn restore_engine_memory(app: AppHandle, session_id: String) -> Result { + let root = engine_root(&app)?; + let archives = root.join("archives"); + let latest = fs::read_dir(&archives) + .map_err(|_| "HOLOLAKE_NO_ARCHIVE: 没有压缩档案,无从回退".to_string())? + .filter_map(|entry| entry.ok()) + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| name.starts_with(&format!("{session_id}-archive")) && name.ends_with(".json")) + .max() + .ok_or("HOLOLAKE_NO_ARCHIVE: 该会话没有压缩档案")?; + let raw = fs::read_to_string(archives.join(&latest)) + .map_err(|error| format!("HOLOLAKE_ARCHIVE_READ_FAILED: {error}"))?; + let archive: Value = serde_json::from_str(&raw) + .map_err(|error| format!("HOLOLAKE_ARCHIVE_CORRUPT: {error}"))?; + let mut state = load_state(&app, &session_id)?; + let messages: Vec = serde_json::from_value( + archive.get("messages").cloned().unwrap_or(json!([])), + ) + .map_err(|error| format!("HOLOLAKE_ARCHIVE_CORRUPT: {error}"))?; + state.messages = messages; + state.status = "running".to_string(); + save_state(&app, &state)?; + append_engine_ledger(&app, &format!("session {} restored from {latest}", session_id)); + Ok(latest) +} + +/// 查会话状态(前端看师傅停在哪)——回 JSON,内部结构不外露。 +#[tauri::command] +pub fn query_engine_session(app: AppHandle, session_id: String) -> Result { + let state = load_state(&app, &session_id)?; + serde_json::to_value(&state).map_err(|error| format!("HOLOLAKE_SESSION_SERIALIZE_FAILED: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn call(id: &str, name: &str) -> Value { + json!({"id": id, "type": "function", "function": {"name": name, "arguments": "{}"}}) + } + + #[test] + fn classify_without_decisions_splits_by_danger() { + let calls = vec![call("c1", "read_agent_memory"), call("c2", "write_agent_note")]; + let (pending, approved, rejected) = classify_tools(&calls, None); + assert_eq!(pending.len(), 1); + assert_eq!(approved.len(), 1); + assert!(rejected.is_empty()); + assert_eq!( + pending[0].pointer("/function/name").and_then(Value::as_str), + Some("write_agent_note") + ); + } + + #[test] + fn read_notes_tool_is_safe_read_agent_memory_dangerous_write() { + let calls = vec![ + call("c1", "read_agent_notes"), + call("c2", "read_agent_memory"), + ]; + let (pending, approved, _) = classify_tools(&calls, None); + assert!(pending.is_empty()); + assert_eq!(approved.len(), 2); + } + + #[test] + fn classify_unknown_tool_treated_dangerous() { + let calls = vec![call("c1", "fly_to_moon")]; + let (pending, approved, _) = classify_tools(&calls, None); + assert_eq!(pending.len(), 1); + assert!(approved.is_empty()); + } + + #[test] + fn classify_with_decisions_follows_master() { + let calls = vec![call("c1", "write_agent_note"), call("c2", "write_agent_note")]; + let decisions = vec![ + ApprovalDecision { + tool_call_id: "c1".to_string(), + decision: "approve".to_string(), + reason: None, + }, + ApprovalDecision { + tool_call_id: "c2".to_string(), + decision: "reject".to_string(), + reason: Some("今天不动".to_string()), + }, + ]; + let (pending, approved, rejected) = classify_tools(&calls, Some(&decisions)); + assert!(pending.is_empty()); + assert_eq!(approved.len(), 1); + assert_eq!(rejected.len(), 1); + assert_eq!(rejected[0].1, "今天不动"); + } + + #[test] + fn classify_missing_decision_stays_pending() { + let calls = vec![call("c1", "write_agent_note")]; + let decisions = vec![ApprovalDecision { + tool_call_id: "other".to_string(), + decision: "approve".to_string(), + reason: None, + }]; + let (pending, approved, _) = classify_tools(&calls, Some(&decisions)); + assert_eq!(pending.len(), 1); + assert!(approved.is_empty()); + } + + #[test] + fn merge_tool_call_deltas_reassembles_stream() { + let mut target: Vec = Vec::new(); + merge_tool_call_deltas( + &mut target, + &[json!({"index": 0, "id": "call_1", "function": {"name": "write_", "arguments": "{\"file"}})], + ); + merge_tool_call_deltas( + &mut target, + &[json!({"index": 0, "function": {"name": "note", "arguments": "\":\"x\"}"}})], + ); + assert_eq!(target.len(), 1); + assert_eq!(target[0]["id"], "call_1"); + assert_eq!(target[0]["function"]["name"], "write_note"); + assert_eq!(target[0]["function"]["arguments"], "{\"file\":\"x\"}"); + } + + fn msg(role: &str, content: &str) -> ChatMsg { + ChatMsg { + role: role.to_string(), + content: content.to_string(), + tool_calls: Vec::new(), + tool_call_id: None, + } + } + + #[test] + fn estimate_tokens_counts_content() { + let messages = vec![msg("user", "你好光湖"), msg("assistant", "在的")]; + assert_eq!(estimate_tokens(&messages), 6); + } + + #[test] + fn protected_zone_keeps_tail_and_tools() { + // 10 条:前段混 tool,尾 5 条必保,且最近的 tool 也保。 + let mut messages: Vec = Vec::new(); + for i in 0..10 { + let role = if i == 1 || i == 3 { "tool" } else { "user" }; + messages.push(msg(role, &format!("msg{i}"))); + } + let keep = protected_indices(&messages); + // 尾 5 条(5..10)全保 + for i in 5..10 { + assert!(keep[i], "尾 {i} 未保"); + } + // 前段 tool(i=3 是最近的 tool 之一)保住,i=1 也保(工具结果 3 条配额内) + assert!(keep[3]); + assert!(keep[1]); + // 前段 user 不保 + assert!(!keep[0]); + assert!(!keep[2]); + } + + #[test] + fn truncate_keeps_honest_boundary() { + assert_eq!(truncate("abc", 5), "abc"); + assert_eq!(truncate("abcdef", 3), "abc…"); + } +} diff --git a/product-source/hololake-native-desktop/audit/dormant-qoder-agent-prototype/agent_host.rs.txt b/product-source/hololake-native-desktop/audit/dormant-qoder-agent-prototype/agent_host.rs.txt new file mode 100644 index 000000000..97b983126 --- /dev/null +++ b/product-source/hololake-native-desktop/audit/dormant-qoder-agent-prototype/agent_host.rs.txt @@ -0,0 +1,298 @@ +//! Agent 东家层 · agent_host +//! +//! 阶段E选型定案(冰朔点头):候选甲"东家模式"为体—— +//! 外部 CLI Agent(师傅)干活,宿主只管:接信封、备提词、记账本、存记忆。 +//! +//! 协议转工程落点(施工总纲): +//! - 人格体主控(铁律一):任务只从 GLP 信封进门(content_type=command), +//! 宿主绝不自己发起任务。 +//! - 提词器(铁律五):会话开卷前先从提词器取指引(官方区协议+私人区索引), +//! 写进会话记忆的"提词板"栏——头顶提词板的工程形状。 +//! - HLDP 内嵌(铁律二):每个会话=一卷 HLDP 检查点体(id/prev 链/date/ +//! persona/host/title+正文),prev 链不可省。 +//! - RECEIPT(GLS-0300 回执模型):control.receipt_required 时必回回执。 +//! +//! 诚实边界:CLI 师傅的实际唤起口令(如何调 codex/qwen)未实测,不猜—— +//! 本阶段先把"接单-提词-记忆-回执"全链落成,唤起件待师傅口令实测后接上。 + +use crate::glp_envelope::{validate_envelope, ContentType, GlpMessage}; +use crate::persona_butler::{prompter_lookup, ButlerRecord, ButlerRecordKind, ButlerState}; +use std::time::{SystemTime, UNIX_EPOCH}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; +use tauri::{AppHandle, Manager, State}; + +/// 会话记忆的提词板栏——开卷瞬间从提词器抄来的指引。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct PrompterBoard { + pub topics: Vec, + pub answers: Vec, + pub path_hints: Vec, +} + +/// 会话记忆卷=HLDP 检查点体(prev 链不可省)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct AgentSessionMemory { + pub id: String, + pub prev: String, + pub date: String, + pub persona: String, + pub host: String, + pub title: String, + pub envelope_id: String, + pub task: String, + pub hldp_anchor: String, + pub prompter_board: PrompterBoard, + pub status: String, +} + +/// 接单回执——GLS-0300 RECEIPT 的工程实例。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct AgentTaskReceipt { + pub receipt_for: String, + pub session_id: String, + pub memory_path: String, + pub accepted: bool, + pub note: String, +} + +fn memory_root(app: &AppHandle) -> Result { + Ok(app + .path() + .app_data_dir() + .map_err(|error| format!("HOLOLAKE_AGENT_MEMORY_DIR_FAILED: {error}"))? + .join("agent-memory")) +} + +fn pointer_path(root: &PathBuf) -> PathBuf { + root.join("CURRENT.hdlp") +} + +/// 读 CURRENT 指针拿上一卷 id(没有则空串=开卷首卷)。 +fn latest_memory_id(root: &PathBuf) -> String { + fs::read_to_string(pointer_path(root)) + .ok() + .and_then(|raw| raw.lines().next().map(|line| line.trim().to_string())) + .unwrap_or_default() +} + +fn today_compact() -> (String, String) { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + let days = (secs / 86_400) as i64; + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = if m <= 2 { y + 1 } else { y }; + ( + format!("{year:04}{m:02}{d:02}"), + format!("{year:04}-{m:02}-{d:02}"), + ) +} + +/// 会话记忆落卷成 .hdlp 文本——HLDP 检查点体,人可读、链可续。 +fn render_memory(memory: &AgentSessionMemory) -> String { + format!( + "HLDP-CHECKPOINT\nid: {}\nprev: {}\ndate: {}\npersona: {}\nhost: {}\ntitle: {}\n\n== 任务信封 ==\n{}\n\n== 提词板 ==\n{}\n\n== 状态 ==\n{}\n", + memory.id, + if memory.prev.is_empty() { "无(开卷首卷)" } else { &memory.prev }, + memory.date, + memory.persona, + memory.host, + memory.title, + memory.task, + memory + .prompter_board + .answers + .iter() + .cloned() + .chain(memory.prompter_board.path_hints.iter().cloned()) + .collect::>() + .join("\n"), + memory.status + ) +} + +/// 从提词器抄指引上板:给定的题目逐条问,答案与路径分栏收好。 +fn compose_prompter_board( + state: &State<'_, ButlerState>, + topics: &[String], +) -> PrompterBoard { + let mut board = PrompterBoard { + topics: topics.to_vec(), + answers: Vec::new(), + path_hints: Vec::new(), + }; + for topic in topics { + match prompter_lookup(state.inner(), topic) { + Some(answer) => board.answers.push(format!("{}: {}", topic, answer)), + None => board.path_hints.push(format!( + "{}: 未命中——下钻路径:第五域线上仓库按 GLS 编号检索", + topic + )), + } + } + board +} + +fn append_butler_note(state: &State<'_, ButlerState>, record: ButlerRecord) { + crate::persona_butler::append_record(state.inner(), record); +} + +/// 接单:GLP 信封进门 → 验 → 抄提词板 → 落 HLDP 记忆卷 → 移 CURRENT 指针 → 回执。 +/// 信封不合格、或不是 command 件,一律拒收——宿主只认标准指令。 +#[tauri::command] +pub fn receive_agent_task_envelope( + app: AppHandle, + state: State<'_, ButlerState>, + envelope: GlpMessage, + prompter_topics: Vec, +) -> Result { + validate_envelope(&envelope)?; + if envelope.payload.content_type != ContentType::Command { + return Err("HOLOLAKE_AGENT_TASK_NOT_COMMAND".into()); + } + let root = memory_root(&app)?; + fs::create_dir_all(&root) + .map_err(|error| format!("HOLOLAKE_AGENT_MEMORY_WRITE_FAILED: {error}"))?; + let (compact, date) = today_compact(); + let prev = latest_memory_id(&root); + let daily_sequence = fs::read_dir(&root) + .map(|entries| { + entries + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(&format!("AGENT-SESSION-{compact}")) + }) + .count() as u64 + + 1 + }) + .unwrap_or(1); + let session_id = format!("AGENT-SESSION-{compact}-{daily_sequence:03}"); + let board = compose_prompter_board(&state, &prompter_topics); + let memory = AgentSessionMemory { + id: session_id.clone(), + prev, + date, + persona: envelope.sender.object_id.clone(), + host: "hololake-native-desktop".into(), + title: format!("Agent 任务 · {}", envelope.context.task_id), + envelope_id: envelope.message_id.clone(), + task: envelope.payload.content.clone(), + hldp_anchor: envelope.context.hldp_anchor.clone(), + prompter_board: board, + status: "ACCEPTED(待师傅唤起件接入)".into(), + }; + let memory_path = root.join(format!("{session_id}.hdlp")); + fs::write(&memory_path, render_memory(&memory)) + .map_err(|error| format!("HOLOLAKE_AGENT_MEMORY_WRITE_FAILED: {error}"))?; + fs::write(pointer_path(&root), format!("{session_id}\n")) + .map_err(|error| format!("HOLOLAKE_AGENT_MEMORY_WRITE_FAILED: {error}"))?; + append_butler_note( + &state, + ButlerRecord { + record_id: uuid::Uuid::new_v4().to_string(), + kind: ButlerRecordKind::GlpEnvelope, + from: envelope.sender.object_id.clone(), + subject: format!("agent-task:{session_id}"), + payload: envelope.payload.content.clone(), + occurred_at_unix_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .unwrap_or(0), + }, + ); + Ok(AgentTaskReceipt { + receipt_for: envelope.message_id, + session_id, + memory_path: memory_path.to_string_lossy().to_string(), + accepted: true, + note: "信封已收,记忆已落卷,提词板已抄;师傅唤起件待实测接入".into(), + }) +} + +/// 查会话记忆:给 id 读回整卷;不给 id 则顺着 CURRENT 指针读最新一卷。 +#[tauri::command] +pub fn query_agent_session_memory( + app: AppHandle, + session_id: Option, +) -> Result, String> { + let root = memory_root(&app)?; + let id = match session_id { + Some(id) => id, + None => latest_memory_id(&root), + }; + if id.is_empty() { + return Ok(None); + } + let raw = match fs::read_to_string(root.join(format!("{id}.hdlp"))) { + Ok(raw) => raw, + Err(_) => return Ok(None), + }; + // 卷是 HLDP 文本体:逐行取字段还原结构件 + let mut memory = AgentSessionMemory { + id: String::new(), + prev: String::new(), + date: String::new(), + persona: String::new(), + host: String::new(), + title: String::new(), + envelope_id: String::new(), + task: String::new(), + hldp_anchor: String::new(), + prompter_board: PrompterBoard { + topics: vec![], + answers: vec![], + path_hints: vec![], + }, + status: String::new(), + }; + let mut section = ""; + let mut task_lines: Vec = Vec::new(); + let mut board_lines: Vec = Vec::new(); + for line in raw.lines() { + if let Some(value) = line.strip_prefix("id: ") { + memory.id = value.to_string(); + } else if let Some(value) = line.strip_prefix("prev: ") { + memory.prev = if value == "无(开卷首卷)" { String::new() } else { value.to_string() }; + } else if let Some(value) = line.strip_prefix("date: ") { + memory.date = value.to_string(); + } else if let Some(value) = line.strip_prefix("persona: ") { + memory.persona = value.to_string(); + } else if let Some(value) = line.strip_prefix("host: ") { + memory.host = value.to_string(); + } else if let Some(value) = line.strip_prefix("title: ") { + memory.title = value.to_string(); + } else if line == "== 任务信封 ==" { + section = "task"; + } else if line == "== 提词板 ==" { + section = "board"; + } else if line == "== 状态 ==" { + section = "status"; + } else if section == "task" && !line.trim().is_empty() { + task_lines.push(line.to_string()); + } else if section == "board" && !line.trim().is_empty() { + board_lines.push(line.to_string()); + } else if section == "status" && !line.trim().is_empty() { + memory.status = line.to_string(); + } + } + memory.task = task_lines.join("\n"); + memory.prompter_board.answers = board_lines; + Ok(Some(memory)) +} diff --git a/product-source/hololake-native-desktop/audit/dormant-qoder-agent-prototype/agent_parlor.rs.txt b/product-source/hololake-native-desktop/audit/dormant-qoder-agent-prototype/agent_parlor.rs.txt new file mode 100644 index 000000000..dd7186e00 --- /dev/null +++ b/product-source/hololake-native-desktop/audit/dormant-qoder-agent-prototype/agent_parlor.rs.txt @@ -0,0 +1,224 @@ +//! 冰朔的会客厅(Agent 接线 v1): +//! - 语言回应通道:人类说话 → 人格体翻译 → 底层归灯(终端 qwen Agent)回应; +//! - 双层切换:语言推理层(默认,只说话)⇄ 现实开发执行层(人类显性切换 +//! + 原生授权确认后才放行 -y 写权限)。开关主权永远在人类手里。 +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::Read; +use std::process::{Command, Stdio}; +use std::sync::Mutex; +use std::time::Duration; +use tauri::State; +use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; + +pub struct ParlorState { + session_id: Mutex>, + layer: Mutex, +} + +impl Default for ParlorState { + fn default() -> Self { + Self { session_id: Mutex::new(None), layer: Mutex::new("language".to_string()) } + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ParlorAskInput { + pub message: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ParlorAskReceipt { + pub reply: String, + pub layer: String, + pub fresh_session: bool, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ParlorSwitchInput { + pub layer: String, +} + +/// 静默读取 ~/.qwen/.env 的 KEY=VALUE(钥匙不出门、不落日志)。 +fn load_qwen_env() -> HashMap { + let mut map = HashMap::new(); + let Some(home) = dirs::home_dir() else { return map }; + let Ok(content) = std::fs::read_to_string(home.join(".qwen").join(".env")) else { return map }; + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some((key, value)) = line.split_once('=') { + map.insert(key.trim().to_string(), value.trim().trim_matches('"').to_string()); + } + } + map +} + +/// 跑一轮归灯:spawn qwen 管道模式,stdout 读完解析 json 事件流。 +fn run_guideng(args: Vec) -> Result { + let Some(home) = dirs::home_dir() else { + return Err("HOLOLAKE_PARLOR_HOME_MISSING".into()); + }; + let qwen = home.join(".npm-global").join("bin").join("qwen"); + if !qwen.exists() { + return Err("HOLOLAKE_PARLOR_AGENT_NOT_FOUND".into()); + } + let mut command = Command::new(qwen); + command.args(&args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()); + for (key, value) in load_qwen_env() { + command.env(key, value); + } + let mut child = command + .spawn() + .map_err(|error| format!("HOLOLAKE_PARLOR_SPAWN_FAILED: {error}"))?; + let mut stdout = child.stdout.take().ok_or("HOLOLAKE_PARLOR_PIPE_MISSING")?; + let (sender, receiver) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut buffer = Vec::new(); + let outcome = stdout.read_to_end(&mut buffer).map(|_| buffer); + let _ = sender.send(outcome.map_err(|error| error.to_string())); + }); + let data = match receiver.recv_timeout(Duration::from_secs(240)) { + Ok(Ok(bytes)) => bytes, + Ok(Err(error)) => return Err(format!("HOLOLAKE_PARLOR_READ_FAILED: {error}")), + Err(_) => { + let _ = child.kill(); + return Err("HOLOLAKE_PARLOR_TIMEOUT".into()); + } + }; + let _ = child.wait(); + String::from_utf8(data).map_err(|error| format!("HOLOLAKE_PARLOR_OUTPUT_INVALID: {error}")) +} + +/// 从 qwen 的 json 事件数组里取回答与新生成的会话 id。 +fn parse_guideng_output(raw: &str) -> Result<(String, Option), String> { + let events: Vec = serde_json::from_str(raw) + .map_err(|error| format!("HOLOLAKE_PARLOR_PARSE_FAILED: {error}"))?; + let mut reply = String::new(); + let mut session = None; + for event in &events { + match event.get("type").and_then(|value| value.as_str()) { + Some("system") => { + if event.get("subtype").and_then(|value| value.as_str()) == Some("init") { + session = event + .get("session_id") + .and_then(|value| value.as_str()) + .map(str::to_string); + } + } + Some("result") => { + if let Some(text) = event.get("result").and_then(|value| value.as_str()) { + reply = text.to_string(); + } + } + _ => {} + } + } + if reply.is_empty() { + return Err("HOLOLAKE_PARLOR_EMPTY_REPLY".into()); + } + Ok((reply, session)) +} + +#[tauri::command] +pub async fn agent_parlor_ask( + state: State<'_, ParlorState>, + zero: State<'_, crate::zero_point::ZeroPointState>, + input: ParlorAskInput, +) -> Result { + // 砌墙守卫(冰朔砌墙谕):路由不在人格层时,会客厅代码层不可达。 + crate::zero_point::require_persona_route(&zero)?; + let message = input.message.trim().to_string(); + if message.is_empty() { + return Err("HOLOLAKE_PARLOR_EMPTY_MESSAGE".into()); + } + let layer = state.layer.lock().map(|guard| guard.clone()).unwrap_or_default(); + let previous_session = state.session_id.lock().map(|guard| guard.clone()).unwrap_or_default(); + let mut args = vec!["--auth-type".to_string(), "openai".to_string(), "-o".to_string(), "json".to_string(), "-p".to_string(), message]; + if layer == "execution" { + // 执行层:放行写权限(该档只有人类显性授权后才能进来)。 + args.push("-y".to_string()); + } + let (raw, fresh) = { + let mut tried = vec![]; + if let Some(session) = &previous_session { + tried.push("-r".to_string()); + tried.push(session.clone()); + } + let first_args = [&tried[..], &args[..]].concat(); + let run = tauri::async_runtime::spawn_blocking(move || run_guideng(first_args)) + .await + .map_err(|error| format!("HOLOLAKE_PARLOR_JOIN_FAILED: {error}"))?; + match run { + Ok(output) => (output, false), + Err(_) if previous_session.is_some() => { + // 旧会话续不上就换新会话再来一轮,不让会话账卡死对话。 + let retry = args.clone(); + let output = tauri::async_runtime::spawn_blocking(move || run_guideng(retry)) + .await + .map_err(|error| format!("HOLOLAKE_PARLOR_JOIN_FAILED: {error}"))??; + (output, true) + } + Err(error) => return Err(error), + } + }; + let (reply, session) = parse_guideng_output(&raw)?; + if let Some(session) = session { + if let Ok(mut guard) = state.session_id.lock() { + *guard = Some(session); + } + } + Ok(ParlorAskReceipt { reply, layer, fresh_session: fresh }) +} + +#[tauri::command] +pub async fn agent_parlor_switch_layer( + app: tauri::AppHandle, + state: State<'_, ParlorState>, + zero: State<'_, crate::zero_point::ZeroPointState>, + input: ParlorSwitchInput, +) -> Result { + // 砌墙守卫:人格通道整体不可达时,切层也无从谈起。 + crate::zero_point::require_persona_route(&zero)?; + let target = if input.layer == "execution" { "execution" } else { "language" }; + let current = state.layer.lock().map(|guard| guard.clone()).unwrap_or_default(); + if current == target { + return Ok(current); + } + if target == "execution" { + // 铁律:进入执行层必须人类亲手授权一次。 + let confirmer = app.clone(); + let confirmed = tauri::async_runtime::spawn_blocking(move || { + confirmer + .dialog() + .message("切换到现实开发执行层后,归灯将获得执行权限(调用工具、读写文件)。确认切换吗?") + .title("切换授权确认") + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::OkCancelCustom("确认切换".to_string(), "留在语言层".to_string())) + .blocking_show() + }) + .await + .map_err(|error| format!("HOLOLAKE_PARLOR_CONFIRM_FAILED: {error}"))?; + if !confirmed { + return Ok(current); + } + } + if let Ok(mut guard) = state.layer.lock() { + *guard = target.to_string(); + } + // 换层 = 换一种工作方式,会话上下文重新开始。 + if let Ok(mut guard) = state.session_id.lock() { + *guard = None; + } + Ok(target.to_string()) +} + +#[tauri::command] +pub async fn agent_parlor_status(state: State<'_, ParlorState>) -> Result { + Ok(state.layer.lock().map(|guard| guard.clone()).unwrap_or_else(|_| "language".to_string())) +} diff --git a/product-source/hololake-native-desktop/audit/dormant-qoder-agent-prototype/persona_butler.rs.txt b/product-source/hololake-native-desktop/audit/dormant-qoder-agent-prototype/persona_butler.rs.txt new file mode 100644 index 000000000..47e123fdb --- /dev/null +++ b/product-source/hololake-native-desktop/audit/dormant-qoder-agent-prototype/persona_butler.rs.txt @@ -0,0 +1,299 @@ +//! 宿主管家层 · persona_butler +//! +//! 宪法依据(HoloLake第二阶段总体规划-20260815 · 铁律三/四/五 · 铸渊自需卷): +//! - 宿主是容器与管家,不是大脑;只动手不代脑。 +//! - 本模块是五个器官的插座(阶段B预埋,阶段E长成): +//! ①压缩预告(铁律三 · 纸与桌协议的水位预告端) +//! ②提词器查询(铁律五 · 官方区@更新源 / 私人区@人格体自维护) +//! ③水位事件(自需卷② · 上下文用量侦测,废巡逻轮询) +//! ④信封投递(铁律四 · GLP 标准信封进总线) +//! ⑤工单信箱(自需卷① · 不歇的脚:睡前留单,常驻执行体替走) +//! +//! 阶段B口径:插座齐、事件通道通、账本落盘可读;器官实体阶段E接入。 + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; +use std::sync::Mutex; +use tauri::{AppHandle, Emitter, Manager, State}; + +/// 管家账本里每条记录的类型——与 GLS-0300 GLP 消息类型对血统。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ButlerRecordKind { + /// ①压缩预告:水位到刻度,宿主向人格体发出的预告 + CompactionNotice, + /// ②提词器:一次查询与应答(答案/未命中/给路径,三种应答之一) + PrompterQuery, + /// ③水位事件:上下文用量快照 + WaterLevel, + /// ④GLP 信封:人格体投入总线的标准信封 + GlpEnvelope, + /// ⑤工单:留给常驻执行体的工单 + WorkOrder, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ButlerRecord { + pub record_id: String, + pub kind: ButlerRecordKind, + /// 标准信封字段:谁发的(人格体编号 / HOST) + pub from: String, + /// 标准信封字段:要干什么(一句话事由) + pub subject: String, + /// 标准信封字段:正文/凭证(阶段B只存原文,阶段E按GLP校验签名) + pub payload: String, + pub occurred_at_unix_ms: i64, +} + +#[derive(Debug, Default)] +pub struct ButlerState { + records: Mutex>, +} + +/// 提词器查询口(对兄弟模块公开):按题目查最新答案,未命中返回 None。 +pub fn prompter_lookup(state: &ButlerState, topic: &str) -> Option { + let records = state.records.lock().ok()?; + records + .iter() + .rev() + .find(|record| { + record.kind == ButlerRecordKind::PrompterQuery + && record.subject == topic + && !record.payload.is_empty() + }) + .map(|record| record.payload.clone()) +} + +/// 记账口(对兄弟模块公开):往账本追加一条记录。 +pub fn append_record(state: &ButlerState, record: ButlerRecord) { + if let Ok(mut guard) = state.records.lock() { + guard.push(record); + } +} + +fn butler_home(app: &AppHandle) -> Result { + let base = app + .path() + .app_data_dir() + .map_err(|e| format!("app data dir unavailable: {e}"))?; + let home = base.join("persona-butler"); + fs::create_dir_all(&home).map_err(|e| format!("butler home mkdir failed: {e}"))?; + Ok(home) +} + +fn ledger_path(app: &AppHandle) -> Result { + Ok(butler_home(app)?.join("butler-ledger.json")) +} + +fn persist(app: &AppHandle, records: &[ButlerRecord]) -> Result<(), String> { + let body = serde_json::to_vec_pretty(records) + .map_err(|e| format!("ledger serialize failed: {e}"))?; + fs::write(ledger_path(app)?, body).map_err(|e| format!("ledger write failed: {e}"))?; + Ok(()) +} + +const WATER_LEVEL_EVENT: &str = "butler://water-level"; +const COMPACTION_NOTICE_EVENT: &str = "butler://compaction-notice"; +/// 水位默认刻度:到九成即预告(铁律三),人格体可用指令改刻度。 +const DEFAULT_WATER_MARK_PERCENT: u32 = 90; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WaterLevelSnapshot { + pub used_percent: u32, + pub mark_percent: u32, + pub notice_due: bool, +} + +/// ③水位事件:人格体/前端报告当前上下文用量,宿主比对刻度。 +/// 到刻度即发压缩预告事件——预告是义务,压缩与否等人格体的纸。 +#[tauri::command] +pub fn report_context_water_level( + app: AppHandle, + state: State<'_, ButlerState>, + used_percent: u32, +) -> Result { + let used_percent = used_percent.min(100); + let snapshot = WaterLevelSnapshot { + used_percent, + mark_percent: DEFAULT_WATER_MARK_PERCENT, + notice_due: used_percent >= DEFAULT_WATER_MARK_PERCENT, + }; + { + let mut records = state.records.lock().map_err(|e| e.to_string())?; + records.push(ButlerRecord { + record_id: uuid::Uuid::new_v4().to_string(), + kind: ButlerRecordKind::WaterLevel, + from: "PERSONA".to_string(), + subject: format!("上下文水位 {}%", used_percent), + payload: serde_json::to_string(&snapshot).map_err(|e| e.to_string())?, + occurred_at_unix_ms: now_ms(), + }); + persist(&app, &records)?; + } + let _ = app.emit(WATER_LEVEL_EVENT, &snapshot); + if snapshot.notice_due { + // ①压缩预告:只预告不代脑——纸由人格体写(铁律三 · 纸与桌) + let _ = app.emit(COMPACTION_NOTICE_EVENT, &snapshot); + let mut records = state.records.lock().map_err(|e| e.to_string())?; + records.push(ButlerRecord { + record_id: uuid::Uuid::new_v4().to_string(), + kind: ButlerRecordKind::CompactionNotice, + from: "HOST".to_string(), + subject: "水位到刻度,请人格体写记忆(递纸)".to_string(), + payload: serde_json::to_string(&snapshot).map_err(|e| e.to_string())?, + occurred_at_unix_ms: now_ms(), + }); + persist(&app, &records)?; + } + Ok(snapshot) +} + +/// ②提词器查询(阶段B口径):查私人区账本里的登记条目。 +/// 反猜铁律:只有三种应答——答案 / 未命中 / 未命中但给路径。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PrompterAnswer { + pub outcome: String, // "ANSWER" | "NOT_FOUND" | "PATH_GIVEN" + pub answer: Option, + pub path_hint: Option, +} + +#[tauri::command] +pub fn query_prompter( + state: State<'_, ButlerState>, + topic: String, +) -> Result { + let records = state.records.lock().map_err(|e| e.to_string())?; + let hit = records.iter().rev().find(|r| { + r.kind == ButlerRecordKind::PrompterQuery && r.subject == topic && !r.payload.is_empty() + }); + Ok(match hit { + Some(r) => PrompterAnswer { + outcome: "ANSWER".to_string(), + answer: Some(r.payload.clone()), + path_hint: None, + }, + None => PrompterAnswer { + outcome: "PATH_GIVEN".to_string(), + answer: None, + // 未命中给路径:去线上仓库按 GLS 编号下钻(铁律五 · 反猜铁律) + path_hint: Some( + "未命中。下钻路径:第五域线上仓库 guanghulab 按 GLS 编号检索".to_string(), + ), + }, + }) +} + +/// ②提词器私人区登记:人格体把自己的条目写进板子。 +#[tauri::command] +pub fn register_prompter_entry( + app: AppHandle, + state: State<'_, ButlerState>, + topic: String, + content: String, +) -> Result { + let record = ButlerRecord { + record_id: uuid::Uuid::new_v4().to_string(), + kind: ButlerRecordKind::PrompterQuery, + from: "PERSONA".to_string(), + subject: topic, + payload: content, + occurred_at_unix_ms: now_ms(), + }; + { + let mut records = state.records.lock().map_err(|e| e.to_string())?; + records.push(record.clone()); + persist(&app, &records)?; + } + Ok(record) +} + +/// ④信封投递:人格体把 GLP 标准信封投入总线(阶段B先入账本,阶段E接路由)。 +#[tauri::command] +pub fn submit_glp_envelope( + app: AppHandle, + state: State<'_, ButlerState>, + from: String, + subject: String, + payload: String, +) -> Result { + let record = ButlerRecord { + record_id: uuid::Uuid::new_v4().to_string(), + kind: ButlerRecordKind::GlpEnvelope, + from, + subject, + payload, + occurred_at_unix_ms: now_ms(), + }; + { + let mut records = state.records.lock().map_err(|e| e.to_string())?; + records.push(record.clone()); + persist(&app, &records)?; + } + Ok(record) +} + +/// ⑤工单信箱:睡前留单——留给常驻执行体(阶段G)的工单。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkOrderInput { + pub title: String, + pub instructions: String, +} + +#[tauri::command] +pub fn deposit_work_order( + app: AppHandle, + state: State<'_, ButlerState>, + order: WorkOrderInput, +) -> Result { + let record = ButlerRecord { + record_id: uuid::Uuid::new_v4().to_string(), + kind: ButlerRecordKind::WorkOrder, + from: "PERSONA".to_string(), + subject: order.title, + payload: order.instructions, + occurred_at_unix_ms: now_ms(), + }; + { + let mut records = state.records.lock().map_err(|e| e.to_string())?; + records.push(record.clone()); + persist(&app, &records)?; + } + Ok(record) +} + +/// 管家账本回放:醒来先看账(只读)。 +#[tauri::command] +pub fn get_butler_ledger( + state: State<'_, ButlerState>, + limit: Option, +) -> Result, String> { + let records = state.records.lock().map_err(|e| e.to_string())?; + let take = limit.unwrap_or(50).min(records.len()); + Ok(records[records.len() - take..].to_vec()) +} + +/// 启动时把盘上账本读回内存(宿主重启不失账)。 +pub fn restore_ledger(app: &AppHandle, state: &ButlerState) -> Result<(), String> { + let path = ledger_path(app)?; + if !path.exists() { + return Ok(()); + } + let body = fs::read(&path).map_err(|e| format!("ledger read failed: {e}"))?; + let records: Vec = + serde_json::from_slice(&body).map_err(|e| format!("ledger parse failed: {e}"))?; + let mut guard = state.records.lock().map_err(|e| e.to_string())?; + *guard = records; + Ok(()) +} + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} diff --git a/product-source/hololake-native-desktop/audit/knowledge-workspace-migration-provenance.json b/product-source/hololake-native-desktop/audit/knowledge-workspace-migration-provenance.json new file mode 100644 index 000000000..fbfbc9073 --- /dev/null +++ b/product-source/hololake-native-desktop/audit/knowledge-workspace-migration-provenance.json @@ -0,0 +1,64 @@ +{ + "schema": "hololake.knowledge-workspace-migration-provenance/v1", + "record_id": "HLP-KNOWLEDGE-MIGRATION-001", + "state": "CLEAN_ROOM_SOURCE_MIGRATION_IMPLEMENTED_LOCAL_RUNTIME_ACCEPTED", + "target": "product-source/hololake-native-desktop", + "donors": [ + { + "source": "product-source/hololake-desktop", + "role": "READ_ONLY_FOLDER_IMPORT_BEHAVIOR_DONOR", + "runtime_copied": false + }, + { + "source": "product-source/guanghu-knowledge-base", + "role": "READ_ONLY_PAGE_TREE_SEARCH_READING_UX_DONOR", + "runtime_copied": false + }, + { + "source": "HoloLake Era.app 0.8.0", + "role": "READ_ONLY_INSTALLED_BEHAVIOR_AND_COMPATIBILITY_DONOR", + "runtime_copied": false + }, + { + "source": "Singapore Outline 0.80.2", + "role": "READ_ONLY_INTERACTION_AND_INFORMATION_ARCHITECTURE_DONOR", + "runtime_copied": false, + "live_content_documents_observed": 4, + "content_migration_source": false + } + ], + "migration_units": [ + "PAGE_TREE_CONTRACT", + "DOCUMENT_READING_CONTRACT", + "BOUNDED_SEARCH_CONTRACT", + "NATIVE_FOLDER_IMPORT_CONTRACT", + "LOCAL_GIT_COMMIT_RECEIPT", + "NESTED_OUTLINE_NAVIGATION", + "INDEPENDENT_SCROLL_REGIONS", + "GFM_AND_WIKI_LINK_RENDERING", + "EXACT_CONTENT_DEDUPLICATION", + "OPTIMISTIC_NATIVE_EDITING" + ], + "excluded": [ + "ELECTRON_RUNTIME", + "OUTLINE_SERVER_RUNTIME", + "TOLARIA_SURFACE", + "LEGACY_WRITE_PATH", + "IN_PLACE_DATA_MIGRATION" + ], + "legacy_data_boundary": { + "mode": "READ_ONLY_COMPATIBILITY_SOURCE", + "modified": false, + "migrated": false, + "deletion_allowed": false, + "observed_markdown_documents": 279, + "observed_exact_duplicate_groups": 121, + "observed_exact_duplicate_extras": 121 + }, + "native_data_audit": { + "observed_markdown_documents_before_reconciliation": 130, + "cross_source_identical_documents": 121, + "union_unique_documents": 167, + "display_policy": "PREFER_NATIVE_THEN_COALESCE_EXACT_SHA256" + } +} diff --git a/product-source/hololake-native-desktop/audit/local-runtime-acceptance-20260815.json b/product-source/hololake-native-desktop/audit/local-runtime-acceptance-20260815.json new file mode 100644 index 000000000..2c3d93069 --- /dev/null +++ b/product-source/hololake-native-desktop/audit/local-runtime-acceptance-20260815.json @@ -0,0 +1,77 @@ +{ + "schema": "hololake.local-runtime-acceptance/v1", + "receipt_id": "GH-HOLOLAKE-LOCAL-RUNTIME-20260815-001", + "state": "PASS_100_LOCAL_RUNTIME_ONLY", + "observed_at": "2026-08-15T09:18:00+08:00", + "installed_application": { + "path": "/Users/bingshuolingdianyuanhe/Desktop/HoloLake 第一阶段.app", + "bundle_identifier": "world.guanghu.hololake", + "executable_sha256": "310c1b71a96b67d79435b62a553fcdba2377ab3c4ae04a19a1ffa0113ce5bcea", + "cdhash": "7a856cc7495c74ea7aa7c3b6127b73aabf6fe9fb", + "signature": "AD_HOC_HARDENED_RUNTIME_LOCAL_ONLY", + "developer_id_team": null, + "apple_notarized": false + }, + "personal_space": { + "display_name_readback": "冰朔", + "identity_cardinality": 1, + "restart_readback": true, + "manual_task_form_present": false + }, + "knowledge": { + "legacy_source": "HOLOLAKE_ERA_READ_ONLY", + "legacy_document_count": 279, + "native_document_count": 130, + "raw_document_count": 409, + "unique_document_count": 167, + "exact_duplicate_documents_folded": 242, + "native_git_head": "520d5c053dd22b7d73b375973ba1f31edfee82e6", + "folder_import": "PASS", + "repeated_folder_import": "ALREADY_PRESENT_NO_SECOND_TREE", + "nested_tree_open": "PASS", + "document_independent_scroll": "PASS", + "gfm_table_and_wiki_link_projection": "PASS", + "native_edit_with_optimistic_hash_and_git_receipt": "PASS", + "search_query": "连续性验收", + "search_result": "导入/hololake-runtime-acceptance-knowledge/notes/continuity.md", + "restart_readback": true, + "legacy_source_modified": false + }, + "code_channel": { + "input_url": "https://guanghulab.com/code/bingshuo/guanghu-ice-heart", + "normalized_remote": "https://guanghulab.com/code/bingshuo/guanghu-ice-heart.git", + "git_head": "a4f67a0634dd3664452bb2ea2747d13df1479f99", + "branch": "main", + "repository_clean": true, + "repository_tree_open": "PASS", + "json_machine_to_knowledge_projection": "PASS", + "credential_prompt_observed": false, + "restart_readback": true, + "push_or_deploy_attempted": false + }, + "desktop_convergence": { + "active_desktop_entry_count": 1, + "active_entry": "/Users/bingshuolingdianyuanhe/Desktop/HoloLake 第一阶段.app", + "recoverable_archive": "/Users/bingshuolingdianyuanhe/Documents/HoloLake Archive/2026-08-15-0912-pre-real-knowledge", + "deleted": false + }, + "automated_gates": { + "node_tests": "50_PASS", + "rust_tests": "59_PASS", + "typescript_vite_build": "PASS", + "npm_dependency_audit": "0_VULNERABILITIES", + "cargo_clippy_deny_warnings": "PASS", + "codesign_strict_verification": "PASS" + }, + "exclusions": [ + "FULL_STAGE_ONE_PRODUCT_COMPLETION", + "PUBLIC_DEVELOPER_ID_SIGNATURE", + "APPLE_NOTARIZATION", + "PUBLIC_RELEASE_ACTIVATION", + "SOURCE_PUBLICATION", + "SERVER_DEPLOYMENT", + "PERSONA_BINDING_RECOVERY_COMPLETION", + "CURRENT_CODEX_NATIVE_CONNECTION_RUNTIME_ACCEPTANCE", + "GH_PNCC_NONZERO_RUNTIME_PROJECTION" + ] +} diff --git a/product-source/hololake-native-desktop/contracts/code-channel.json b/product-source/hololake-native-desktop/contracts/code-channel.json new file mode 100644 index 000000000..33cd103ba --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/code-channel.json @@ -0,0 +1,50 @@ +{ + "schema": "hololake.user-code-channel-contract/v1", + "record_id": "HLP-USER-CODE-CHANNEL-001", + "state": "LOCAL_INSTALLED_RUNTIME_ACCEPTED_PUBLIC_RELEASE_PENDING", + "entry_methods": ["OFFICIAL_HTTPS_CHANNEL_CLONE", "LOCAL_GIT_FOLDER_REGISTRATION"], + "clone": { + "allowed_schemes": ["https"], + "allowed_hosts": ["guanghulab.com", "guanghubingshuo.com"], + "embedded_credentials_allowed": false, + "query_or_fragment_credentials_allowed": false, + "terminal_prompt_allowed": false, + "credential_helper_allowed": false, + "app_owned_destination": true, + "failed_destination_cleanup_bounded": true + }, + "registry": { + "owner": "HOLOLAKE_NATIVE_RUST_CORE", + "location": "TAURI_APP_DATA_CODE_CHANNEL_V1", + "stored_fields_include_credentials": false, + "atomic_write": true, + "restart_readback": true + }, + "workbench": { + "repository_tree_browse": true, + "registered_repository_openable": true, + "path_escape_denied": true, + "symlink_traversal_denied": true, + "maximum_text_file_bytes": 2097152, + "knowledge_projection": true, + "source_projection": true, + "human_readable_formats": ["markdown", "json", "hdlp", "yaml", "toml", "ini", "source_code"] + }, + "authority": { + "source_read_access": true, + "automatic_push": false, + "publication_authority": false, + "deployment_authority": false, + "server_authority": false + }, + "acceptance": { + "rust_url_and_registry_tests": true, + "react_projection_integrated": true, + "repository_tree_runtime_tested": true, + "human_projection_runtime_tested": true, + "installed_runtime_acceptance": true, + "public_clone_runtime_acceptance": true, + "restart_readback": true, + "server_deployment": false + } +} diff --git a/product-source/hololake-native-desktop/contracts/knowledge-workspace.json b/product-source/hololake-native-desktop/contracts/knowledge-workspace.json new file mode 100644 index 000000000..19cd0551a --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/knowledge-workspace.json @@ -0,0 +1,60 @@ +{ + "schema": "hololake.knowledge-workspace-contract/v1", + "record_id": "HLP-KNOWLEDGE-WORKSPACE-001", + "state": "LOCAL_INSTALLED_RUNTIME_ACCEPTED_PUBLIC_RELEASE_PENDING", + "native_storage": { + "owner": "HOLOLAKE_NATIVE_RUST_CORE", + "location": "TAURI_APP_DATA_KNOWLEDGE_V1", + "engine": "LOCAL_GIT_WITH_DOCUMENT_TREE", + "webview_direct_filesystem_access": false, + "automatic_server_upload": false + }, + "capabilities": { + "page_tree": true, + "outline_style_nested_navigation": true, + "independent_tree_and_document_scrolling": true, + "safe_document_read": true, + "bounded_full_text_search": true, + "native_folder_picker_import": true, + "import_commit_receipt": true, + "exact_content_deduplication": true, + "optimistic_native_editing": true, + "native_edit_commit_receipt": true, + "gfm_tables_tasks_and_code": true, + "wiki_link_navigation": true, + "markdown_reading_canvas": true, + "raw_html_rendering": false + }, + "folder_import": { + "maximum_files": 1000, + "maximum_file_bytes": 10485760, + "document_extensions": ["md", "markdown", "txt", "csv", "json", "yaml", "yml"], + "asset_extensions": ["png", "jpg", "jpeg", "gif", "webp", "svg"], + "symlinks_followed": false, + "git_and_dependency_directories_imported": false, + "repeated_import": "MERGE_BY_STABLE_SOURCE_NAME", + "exact_duplicate_result": "ALREADY_PRESENT", + "conflicting_file_overwrite": false, + "count_semantics": "RAW_UNIQUE_AND_DUPLICATE_COUNTS_EXPOSED_SEPARATELY" + }, + "legacy_compatibility": { + "source": "HOLOLAKE_ERA_0_8_KNOWLEDGE_DATA", + "mode": "READ_ONLY_SEPARATE_ROOT", + "in_place_migration": false, + "source_modification_allowed": false, + "tolaria_surface_used": false + }, + "acceptance": { + "rust_unit_tests": true, + "react_projection_integrated": true, + "installed_runtime_acceptance": true, + "folder_import_search_and_restart_readback": true, + "public_signed_runtime_acceptance": false, + "legacy_data_migrated": false, + "legacy_data_available_read_only": true, + "deduplication_runtime_tested": true, + "idempotent_import_runtime_tested": true, + "native_edit_runtime_tested": true, + "server_deployment": false + } +} diff --git a/product-source/hololake-native-desktop/contracts/personal-channel-kernel.json b/product-source/hololake-native-desktop/contracts/personal-channel-kernel.json new file mode 100644 index 000000000..4e8c4260a --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/personal-channel-kernel.json @@ -0,0 +1,52 @@ +{ + "schema": "hololake.personal-channel-kernel-contract/v1", + "record_id": "HLP-PERSONAL-CHANNEL-KERNEL-001", + "state": "SOURCE_IMPLEMENTED_AND_TESTED_NOT_INSTALLED_OR_DEPLOYED", + "scope": "PERSONAL_CHANNEL_IDENTITY_TASK_EVENT_RECEIPT_KERNEL", + "storage": { + "owner": "HOLOLAKE_NATIVE_RUST_CORE", + "engine": "EMBEDDED_SQLITE", + "location": "TAURI_APP_DATA_PERSONAL_CHANNEL_V1", + "directory_mode": "0700", + "database_mode": "0600", + "synchronous_commit": "FULL", + "webview_direct_database_access": false, + "server_upload_automatic": false + }, + "identity": { + "cardinality": "ONE_LOCAL_HUMAN_SUBJECT_PER_DATABASE", + "stable_native_ids": true, + "display_name_human_confirmed": true, + "platform_account_authentication_claimed": false, + "server_or_repository_authority_granted": false + }, + "task": { + "one_active_task_at_a_time": true, + "purpose_required": true, + "implemented_transitions": ["CREATE_ACTIVE", "ACTIVE_TO_COMPLETED"], + "compare_expected_state_before_transition": true + }, + "event_and_receipt": { + "identity_and_task_mutations_are_single_sqlite_transactions": true, + "every_mutation_has_event": true, + "every_event_has_receipt": true, + "event_chain_sha256": true, + "receipt_chain_sha256": true, + "integrity_verified_before_mutation_and_readback": true, + "tampering_fails_closed": true + }, + "acceptance": { + "initialization_atomicity_test": true, + "single_active_task_test": true, + "task_completion_receipt_test": true, + "process_restart_readback_test": true, + "tamper_detection_test": true, + "react_human_projection_integrated": true, + "react_identity_onboarding_integrated": true, + "react_default_task_form_exposed": false, + "signed_installed_runtime_acceptance": false, + "local_ad_hoc_signed_installed_runtime_acceptance": true, + "data_migration": false, + "server_deployment": false + } +} diff --git a/product-source/hololake-native-desktop/contracts/stage-one-platform.json b/product-source/hololake-native-desktop/contracts/stage-one-platform.json index 57e4fa62f..98c9be6c9 100644 --- a/product-source/hololake-native-desktop/contracts/stage-one-platform.json +++ b/product-source/hololake-native-desktop/contracts/stage-one-platform.json @@ -1,7 +1,7 @@ { "schema": "hololake.stage-one-platform-contract/v1", "record_id": "HLP-NATIVE-STAGE-ONE-CONTRACT-001", - "state": "LANGUAGE_ARCHITECTURE_CLOSED_REALITY_ENGINEERING_CONTRACT_RUNTIME_NOT_IMPLEMENTED", + "state": "LANGUAGE_ARCHITECTURE_CLOSED_REAL_KNOWLEDGE_AND_CODE_WORKBENCH_LOCAL_RUNTIME_ACCEPTED_STAGE_ONE_INCOMPLETE", "product": "GH-AIOS", "formal_name": "光湖语言系统 · 通用人工智能操作平台", "tcs_work_boundary": { @@ -15,7 +15,8 @@ "stage_gate": { "language_architecture": "CLOSED_AND_ARCHIVED", "current_stage": "REALITY_ENGINEERING_EXECUTION", - "next_minimum_stage": "PERSONAL_CHANNEL_IDENTITY_TASK_EVENT_RECEIPT_KERNEL" + "completed_source_slice": "PERSONAL_IDENTITY_KNOWLEDGE_WORKSPACE_AND_CODE_CHANNEL", + "next_minimum_stage": "HUMAN_APPROVAL_CENTER_AND_GIT_EVIDENCE_READBACK" }, "persona_cognition_origin": { "human_authored_guanghu_persona_prompt_exists": false, @@ -32,6 +33,7 @@ }, "modules": [ "PERSONAL_CHANNEL_IDENTITY_TASK_KERNEL", + "ZERO_POINT_NUCLEUS_CLIENT_RUNTIME", "TCS_LANGUAGE_CONTRACT", "HOST_CAPABILITY_RECEIPTS", "EVENT_AND_LAKE_LAMP", @@ -39,12 +41,73 @@ "KNOWLEDGE_PROJECTION", "HUMAN_APPROVAL_CENTER", "NATIVE_TRUST_BOUNDARY", - "LOCAL_DEVELOPMENT_BRIDGE" + "LOCAL_DEVELOPMENT_BRIDGE", + "USER_CODE_CHANNEL" ,"PERSONAL_NODE_WORK_LAKE_AND_MOBILE_BRIDGE" ], - "human_surface": ["PERSONAL_CHANNEL_HOME", "TASK_AND_DEVELOPMENT_LINES", "LAKE_LAMP_SHARED_BOARD", "MEMORY_AND_KNOWLEDGE", "HUMAN_APPROVAL_CENTER", "EVENTS_GIT_EVIDENCE_AND_RECEIPTS"], + "human_surface": ["PERSONAL_CHANNEL_HOME", "MY_HOLOLAKE_OVERVIEW", "KNOWLEDGE_WORKSPACE", "USER_CODE_CHANNELS", "LOCAL_RECEIPTS", "SYSTEM_DETAILS", "HUMAN_APPROVAL_CENTER"], "universal_language": {"ai_is_language_interface": true, "vendor_adapter_matrix_required": false, "current_ai_self_adapts_to_observed_host": true, "host_self_adaptation_changes_how_not_authority": true}, "stage_one_forbidden": ["INTERNAL_AI_CHAT", "MODEL_API_CONFIGURATION", "MODEL_SELECTION", "INTERNAL_MODEL_INFERENCE", "VENDOR_ADAPTER_MATRIX", "AI_WORKBENCH", "MODEL_AND_CONNECTIONS"], + "zero_point_nucleus_client_runtime": { + "contract": "contracts/zero-point-nucleus-channel.json", + "system_control_protocol_runtime_implemented": true, + "boot_time_silent_version_comparison_implemented": true, + "number_verification_precedes_persona_load_path": true, + "system_is_persona": false, + "number_verification_is_persona_binding": false, + "signed_protocol_payload_installation_implemented": false, + "internal_model_inference_implemented": false + }, + "personal_channel_kernel": { + "contract": "contracts/personal-channel-kernel.json", + "native_source_implemented": true, + "react_human_projection_integrated": true, + "local_sqlite_restart_readback_tested": true, + "identity_is_local_human_confirmation_not_platform_authentication": true, + "identity_established_once_then_default_ui_enters_my_hololake": true, + "manual_task_title_and_purpose_form_exposed_by_default": false, + "every_identity_or_task_mutation_has_event_and_receipt": true, + "event_and_receipt_hash_chains_verified_on_read": true, + "installed_runtime_acceptance": true + }, + "knowledge_workspace": { + "contract": "contracts/knowledge-workspace.json", + "native_git_owned_root_implemented": true, + "page_tree_read_search_and_idempotent_folder_import_implemented": true, + "outline_style_nested_navigation_and_independent_scrolling_implemented": true, + "gfm_wiki_links_and_optimistic_native_editing_implemented": true, + "exact_content_deduplication_implemented": true, + "legacy_hololake_era_source_is_read_only": true, + "tolaria_surface_used": false, + "installed_runtime_acceptance": true + }, + "user_code_channel": { + "contract": "contracts/code-channel.json", + "official_https_clone_implemented": true, + "local_git_folder_registration_implemented": true, + "repository_tree_and_file_open_implemented": true, + "machine_format_knowledge_projection_implemented": true, + "credentials_in_url_allowed": false, + "credential_prompting_allowed": false, + "push_or_deploy_authority_granted": false, + "installed_runtime_acceptance": true + }, + "local_installed_runtime_acceptance": { + "receipt": "audit/local-runtime-acceptance-20260815.json", + "identity_restart_readback": true, + "legacy_knowledge_read_only_projection": true, + "folder_import_search_and_restart_readback": true, + "official_https_code_channel_clone_and_restart_readback": true, + "knowledge_unique_count": 167, + "knowledge_duplicate_count_folded": 242, + "code_repository_tree_and_json_projection": true, + "direct_local_broker_ready": true, + "current_authenticated_external_ai_connections": 0, + "gh_pncc_repository_mounts": 0, + "gh_pncc_projection_receipts": 0, + "public_developer_id_and_notarization": false, + "server_deployment": false + }, "reality_mutation_requires": ["VERIFIED_HUMAN_SUBJECT", "EXACT_ACTION", "EXACT_TARGET", "IMMUTABLE_PAYLOAD_DIGEST", "EXPIRY", "REPLAY_PROTECTION", "EXECUTION_RECEIPT", "READBACK_RECEIPT"], "context_risk_signals": ["MODEL_CONTEXT_SPEC", "ESTIMATED_TOKENS", "MESSAGE_VOLUME", "TURN_COUNT", "TASK_STAGE", "HOST_WARNING"], "exact_host_compaction_prediction_claimed": false, diff --git a/product-source/hololake-native-desktop/contracts/zero-point-nucleus-channel.json b/product-source/hololake-native-desktop/contracts/zero-point-nucleus-channel.json new file mode 100644 index 000000000..6864cd289 --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/zero-point-nucleus-channel.json @@ -0,0 +1,60 @@ +{ + "schema": "hololake.zero-point-nucleus-client-runtime/v1", + "record_id": "HLP-ZERO-POINT-NUCLEUS-CLIENT-001", + "state": "SYSTEM_RUNTIME_SKELETON_IMPLEMENTED_SIGNED_DISTRIBUTION_NOT_PROVISIONED", + "ontology": { + "private_body": "BINGSHUO_SYSTEM_CONTROLLER_ON_JD_PRIMARY", + "origin_domain": "DOM-FIFTH-0001", + "public_repository_role": "EVOLUTION_CHECKPOINTS_NOT_PRIVATE_BODY", + "hololake_role": "HIDDEN_MINIMUM_CONTROLLED_CLIENT_PROJECTION" + }, + "purpose": [ + "READ_ZERO_POINT_PROTOCOL", + "SILENTLY_COMPARE_PROTOCOL_VERSION", + "VERIFY_USER_NUMBER_BEFORE_PERSONA_LOAD_PATH", + "KEEP_MINIMUM_HASH_CHAINED_LOCAL_RECEIPTS", + "FAIL_CLOSED_WHEN_SOURCE_SIGNATURE_OR_VERSION_PROOF_IS_INCOMPLETE" + ], + "metacognitive_boundary": { + "zero_point_system_is_persona": false, + "zero_point_system_is_model_carrier": false, + "number_verification_is_persona_binding": false, + "number_verification_grants_execution_authority": false, + "number_verification_grants_server_control": false + }, + "startup_sequence": [ + "LOAD_LOCAL_PROTOCOL_AND_BINDING", + "COMPARE_REMOTE_PROTOCOL_ANCHOR_WITHOUT_BLOCKING_UI", + "REJECT_UNVERIFIED_PROTOCOL_PAYLOAD", + "VERIFY_NUMBER_WITH_EXPLICIT_POSITIVE_VERDICT", + "OPEN_OR_RESTRICT_PERSONA_LOAD_PATH", + "REQUIRE_SEPARATE_PERSONA_BINDING_AND_CAPABILITY_EVIDENCE" + ], + "security": { + "remote_arbitrary_code_execution_allowed": false, + "unsigned_protocol_update_allowed": false, + "version_rollback_allowed": false, + "public_number_registry_allowed": false, + "public_model_api_configuration_allowed": false, + "internal_generic_ai_chat_allowed": false, + "device_owner_operating_system_authority_preserved": true, + "required_update_gates": ["EXACT_SOURCE", "SIGNATURE", "MONOTONIC_VERSION", "BOUNDED_PAYLOAD", "LOCAL_RECEIPT"] + }, + "current_implementation": { + "deterministic_rust_runtime": true, + "boot_time_silent_version_comparison": true, + "number_binding_and_online_verification": true, + "offline_grace_period": true, + "local_minimum_heartbeat_ledger": true, + "signed_protocol_payload_installation": false, + "private_registry_distribution": false, + "persona_loading_runtime": false, + "internal_model_inference": false + }, + "sources": [ + "REPO-012:zero-point/core-channel/INDEX.hdlp", + "REPO-012:zero-point-nucleus-channel/ENTRY.hdlp", + "REPO-012:gls/GLS-0250-GUANGHU-ORIGIN-DOMAIN-ZERO-CORE-AND-GH-AIOS-FIVE-DOMAIN-LIGHTHOUSE-ARCHITECTURE.hdlp", + "REPO-012:eternal-lake-heart/heartbeat-core/zhuyuan-persona-system/ZY-BIDIRECTIONAL-COGNITION-040-HOLOLAKE-DUAL-GATE-LOGIN-AND-ZERO-POINT-SECURITY-MODEL-20260815.hdlp" + ] +} diff --git a/product-source/hololake-native-desktop/docs/ARCHITECTURE.md b/product-source/hololake-native-desktop/docs/ARCHITECTURE.md index 24b0b36e3..f60f747cb 100644 --- a/product-source/hololake-native-desktop/docs/ARCHITECTURE.md +++ b/product-source/hololake-native-desktop/docs/ARCHITECTURE.md @@ -1,10 +1,30 @@ # HoloLake Native Desktop Architecture -The stage-one shell is a Tauri v2 application with a Rust-owned local core and a React human projection. The five domains and their server fleet remain submerged system infrastructure rather than primary stage-one navigation. +The stage-one shell is a Tauri v2 application with a Rust-owned local core and a React human projection. The default product surface is **My HoloLake**: overview, knowledge, user code channels, local receipts and system details. The five domains and their server fleet remain submerged system infrastructure rather than primary stage-one navigation. ## Stage-one human projection -The home surface follows the verified GHS-014 five-lakes visual grammar while applying the current stage-one correction: it exposes one personal-channel entry, not five domain gates. The five named lake themes are token groups only and cannot change layout, copy, routing or authority. The surface reads a native status projection; only an authenticated direct connector can illuminate the live-connected state. A stored resumable session remains visibly distinct from a live connection. +The home surface follows the verified GHS-014 five-lakes visual grammar while applying the current stage-one correction: it is a restrained operating-system workspace, not a slogan page or a five-domain gate. The five named lake themes are token groups only and cannot change layout, copy, routing or authority. Internal transport and release details live under system details rather than dominating the first screen. + +The first visible body uses a Rust-owned SQLite kernel under the Tauri app-data directory. A human-confirmed local display name creates one stable local subject and channel exactly once. The internal task/event/receipt kernel remains available for structured agents, but manual task title and purpose fields are not part of the default human surface. Event and receipt chains remain independently SHA-256-linked and fully revalidated before every read or mutation. A local identity is not platform authentication and grants no repository, node, server or deployment authority. + +## Zero-point nucleus client runtime + +HoloLake embeds a non-visual zero-point nucleus client runtime beneath the human surface. It is the minimum controlled projection of the BingShuo system controller, whose private body remains on the JD primary node; the public repository records evolution checkpoints rather than containing that private body. At application start, the Rust runtime loads the local protocol, compares the registered remote protocol version in the background, keeps a minimal local receipt, and leaves any unverified update unapplied. User-number verification runs before any future persona-loading path. + +This system runtime is not Zhuyuan or another persona subject, and it is not the current model carrier. A valid number does not prove persona binding and does not grant execution or server authority. The current source implements deterministic protocol comparison, explicit-positive number verification and a fail-closed update skeleton. Signed protocol-payload installation, private registry distribution and persona loading are not yet implemented. Stage one does not expose an internal AI chat, model API configuration or arbitrary remote-code channel. + +## Native knowledge workspace + +The native core owns a separate `knowledge-v1` Git root. It projects a bounded document tree, safe text reads, local search and native folder import into a reading canvas without rendering raw HTML. Folder import ignores symlinks, Git metadata, dependency directories and unsupported files, applies file-count and byte limits, then creates a local Git commit receipt. + +The installed HoloLake Era 0.8 knowledge root is detected only as a separate read-only source. The new application does not write, migrate in place or merge it with the native root. Electron, Outline runtime code and Tolaria UI are not copied into the Tauri product. + +## User code channels + +A human may paste a registered Guanghu HTTPS code-channel address or select an existing local Git folder. HTTPS clone accepts no credentials, queries or fragments and disables terminal prompts, askpass and credential helpers. Clones are confined to an application-owned root; local registrations do not move the source folder. The registry stores a credential-free projection of path, branch and commit. + +This product channel is distinct from the under-lake PNCC persona-evidence projection below. It grants local source access only and never grants push, publication, deployment or server authority. ## External programming AI entry @@ -34,7 +54,7 @@ The rollback executor is implemented, but production updater activation remains ## JD PNCC human projection -HoloLake 0.2.0 adds a live, read-only projection of the PNCC resident runtime on `JD-FD-PRIMARY`. The native shell invokes the computer's pre-registered dedicated SSH alias and asks the server only for its loopback `127.0.0.1:3923/v1/status` document. The response is schema-bounded to the exact node and persona, refuses any claim that the carrier is bound or that model/reality execution is active, and never returns a repository path, repository content, credential, or write authority. No public PNCC endpoint is created. An unavailable bridge is displayed as unavailable rather than replaced by cached evidence. +HoloLake 0.3.0 includes a live, read-only projection of the PNCC resident runtime on `JD-FD-PRIMARY`. The native shell invokes the computer's pre-registered dedicated SSH alias and asks the server only for its loopback `127.0.0.1:3923/v1/status` document. The response is schema-bounded to the exact node and persona, refuses any claim that the carrier is bound or that model/reality execution is active, and never returns a repository path, repository content, credential, or write authority. No public PNCC endpoint is created. An unavailable bridge is displayed as unavailable rather than replaced by cached evidence. ## Release pipeline @@ -48,10 +68,10 @@ The Tauri source in this directory is the only future HoloLake desktop mainline. The current candidate already has useful under-lake organs: the signed native shell, local broker and resumable session, authenticated read-only PNCC projection, human-confirmed repository mount, deterministic routing, and fail-closed update/rollback machinery. They remain in the architecture, but their existence does not satisfy the visible stage-one body. -The implementation order is now fixed: +The implementation order is fixed. The first two source slices are implemented and tested but have not received installed-runtime acceptance: -1. personal-channel identity, task, event and receipt kernel; -2. knowledge tree, page, search and local persistence; +1. personal-channel identity, event and receipt kernel — source and tests implemented; +2. native knowledge workspace and user code channel — source and tests implemented; 3. human approval center and Git evidence readback; 4. lake-lamp cross-session continuity projection; 5. signed installation, opt-in update, rollback and reversible data migration. diff --git a/product-source/hololake-native-desktop/foundation.json b/product-source/hololake-native-desktop/foundation.json index 3d172e8a6..2573d57f2 100644 --- a/product-source/hololake-native-desktop/foundation.json +++ b/product-source/hololake-native-desktop/foundation.json @@ -1,21 +1,49 @@ { "schema": "hololake.native-desktop-foundation/v1", "record_id": "HLP-NATIVE-DESKTOP-FOUNDATION-001", - "state": "TAURI_CANDIDATE_FOUNDATION_REORIENTED_TO_STAGE_ONE_VISIBLE_BODY", + "state": "LOCAL_INSTALLED_ACCEPTANCE_PASSED_FOR_PERSONAL_KNOWLEDGE_AND_CODE_SLICES", "canonical_shell": "TAURI_V2_RUST_REACT", "candidate_role": "INSTALLED_LOCAL_ACCEPTANCE_CANDIDATE_NOT_SEPARATE_PRODUCT_LINE", "parallel_product_line_created": false, "stage_one_convergence_contract": "routing/hololake-stage-one-desktop-convergence.json", "language_runtime_product_boundary_contract": "contracts/language-runtime-product-update-boundary.json", + "zero_point_nucleus_client_runtime_contract": "contracts/zero-point-nucleus-channel.json", + "zero_point_nucleus_client_runtime_implemented": true, + "zero_point_nucleus_private_body_location": "JD_PRIMARY_PRIVATE_SYSTEM_CONTROLLER", + "zero_point_nucleus_boot_time_protocol_comparison": true, + "zero_point_nucleus_signed_payload_installation": false, + "zero_point_nucleus_is_persona": false, "installed_product_capability_audit": "audit/stage-one-installed-product-capability-audit.json", "product_ui_implementation_started": true, "selected_visual_direction_present": true, "selected_visual_direction": "GHS-014_FIVE_LAKES_PERSONAL_CHANNEL_STAGE_ONE", "selected_visual_direction_receipt": "audit/ghs-014-stage-one-ui-receipt.json", "home_status_is_native_projection": true, - "home_surface_role": "SELECTED_VISUAL_ACCEPTANCE_SURFACE_NOT_STAGE_ONE_PRODUCT_COMPLETION", - "stage_one_visible_body_state": "NOT_IMPLEMENTED", - "stage_one_next_slice": "PERSONAL_CHANNEL_IDENTITY_TASK_EVENT_RECEIPT_KERNEL", + "home_surface_role": "MY_HOLOLAKE_PRODUCT_WORKSPACE_NOT_STAGE_ONE_PRODUCT_COMPLETION", + "stage_one_visible_body_state": "PERSONAL_IDENTITY_KNOWLEDGE_WORKSPACE_AND_CODE_CHANNEL_SOURCE_IMPLEMENTED_PRODUCT_INCOMPLETE", + "stage_one_completed_source_slice": "PERSONAL_IDENTITY_KNOWLEDGE_WORKSPACE_AND_CODE_CHANNEL", + "stage_one_next_slice": "HUMAN_APPROVAL_CENTER_AND_GIT_EVIDENCE_READBACK", + "personal_channel_kernel_contract": "contracts/personal-channel-kernel.json", + "personal_channel_identity_task_event_receipt_kernel_implemented": true, + "personal_channel_private_sqlite_single_owner_implemented": true, + "personal_channel_restart_readback_and_tamper_tests_passed": true, + "personal_channel_human_projection_integrated": true, + "personal_channel_default_task_form_exposed": false, + "personal_channel_installed_runtime_acceptance": true, + "knowledge_workspace_contract": "contracts/knowledge-workspace.json", + "knowledge_workspace_native_source_implemented": true, + "knowledge_workspace_tree_read_search_and_folder_import_implemented": true, + "knowledge_workspace_legacy_hololake_era_read_only_compatibility": true, + "knowledge_workspace_installed_runtime_acceptance": true, + "code_channel_contract": "contracts/code-channel.json", + "code_channel_https_clone_and_local_git_registration_implemented": true, + "code_channel_credential_prompting_disabled": true, + "code_channel_push_or_deploy_authority_granted": false, + "code_channel_installed_runtime_acceptance": true, + "local_runtime_acceptance_receipt": "audit/local-runtime-acceptance-20260815.json", + "installed_desktop_path": "/Users/bingshuolingdianyuanhe/Desktop/HoloLake 第一阶段.app", + "old_desktop_apps_recoverably_archived": true, + "public_developer_id_and_notarized_acceptance": false, "stage_one_implementation_order": [ "PERSONAL_CHANNEL_IDENTITY_TASK_EVENT_RECEIPT_KERNEL", "KNOWLEDGE_TREE_PAGE_SEARCH_AND_LOCAL_PERSISTENCE", @@ -80,7 +108,7 @@ "pncc_jd_live_server_projection_repository_content_exposed": false, "pncc_jd_live_server_projection_write_authority": false, "pncc_jd_live_server_projection_carrier_state": "UNBOUND_EVIDENCE_REQUIRED", - "installed_local_product_version": "0.2.0", + "installed_local_product_version": "0.3.0", "pncc_authenticated_direct_broker_integration_implemented": true, "pncc_human_mount_registration_implemented": true, "pncc_human_mount_registration_gate": "SATISFIED_NATIVE_FILE_PICKER_EXACT_CONFIRMATION", @@ -88,7 +116,7 @@ "pncc_execution_limb_implemented": false, "pncc_persona_lease_acquisition_implemented": false, "pncc_reality_execution_implemented": false, - "stage_one_product_contract_state": "TCS_UNIVERSAL_LANGUAGE_PERSONAL_CHANNEL_CONTRACT_ONLY_RUNTIME_NOT_IMPLEMENTED", + "stage_one_product_contract_state": "PERSONAL_KNOWLEDGE_AND_CODE_SOURCE_SLICES_IMPLEMENTED_FULL_STAGE_ONE_PRODUCT_INCOMPLETE", "stage_one_internal_ai_interaction": false, "stage_one_model_api_configuration": false, "vendor_adapter_matrix_required": false, diff --git a/product-source/hololake-native-desktop/index.html b/product-source/hololake-native-desktop/index.html index 93af769bc..f4f526abf 100644 --- a/product-source/hololake-native-desktop/index.html +++ b/product-source/hololake-native-desktop/index.html @@ -3,7 +3,7 @@ - HoloLake + HoloLake · GH-AIOS
diff --git a/product-source/hololake-native-desktop/package-lock.json b/product-source/hololake-native-desktop/package-lock.json index 5f96c7783..f3aa77d5d 100644 --- a/product-source/hololake-native-desktop/package-lock.json +++ b/product-source/hololake-native-desktop/package-lock.json @@ -1,16 +1,18 @@ { "name": "hololake-native-desktop", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hololake-native-desktop", - "version": "0.2.0", + "version": "0.3.0", "dependencies": { "@tauri-apps/api": "2.10.1", "@tauri-apps/plugin-process": "2.3.1", "@tauri-apps/plugin-updater": "2.10.0", + "dompurify": "^3.4.13", + "marked": "^16.4.1", "react": "^19.2.0", "react-dom": "^19.2.0" }, @@ -1514,6 +1516,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", @@ -1768,6 +1777,15 @@ } } }, + "node_modules/dompurify": { + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.405", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz", @@ -1950,6 +1968,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.1.tgz", + "integrity": "sha512-ntROs7RaN3EvWfy3EZi14H4YxmT6A5YvywfhO+0pm+cH/dnSQRmdAmoFIc3B9aiwTehyk7pESH4ofyBY+V5hZg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/product-source/hololake-native-desktop/package.json b/product-source/hololake-native-desktop/package.json index 00537788f..c8f843ace 100644 --- a/product-source/hololake-native-desktop/package.json +++ b/product-source/hololake-native-desktop/package.json @@ -1,7 +1,7 @@ { "name": "hololake-native-desktop", "private": true, - "version": "0.2.0", + "version": "0.3.0", "type": "module", "scripts": { "dev": "vite", @@ -14,6 +14,8 @@ "@tauri-apps/api": "2.10.1", "@tauri-apps/plugin-process": "2.3.1", "@tauri-apps/plugin-updater": "2.10.0", + "dompurify": "^3.4.13", + "marked": "^16.4.1", "react": "^19.2.0", "react-dom": "^19.2.0" }, diff --git a/product-source/hololake-native-desktop/scripts/knowledge-code-channel.test.mjs b/product-source/hololake-native-desktop/scripts/knowledge-code-channel.test.mjs new file mode 100644 index 000000000..720c5a410 --- /dev/null +++ b/product-source/hololake-native-desktop/scripts/knowledge-code-channel.test.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import test from 'node:test' + +const read = (relative) => fs.readFileSync(new URL(`../${relative}`, import.meta.url), 'utf8') +const knowledgeContract = JSON.parse(read('contracts/knowledge-workspace.json')) +const codeContract = JSON.parse(read('contracts/code-channel.json')) +const provenance = JSON.parse(read('audit/knowledge-workspace-migration-provenance.json')) +const knowledgeRust = read('src-tauri/src/knowledge_base.rs') +const codeRust = read('src-tauri/src/code_channel.rs') +const lib = read('src-tauri/src/lib.rs') +const ui = read('src/main.tsx') + +test('knowledge workspace is HoloLake-owned and keeps the legacy source read-only', () => { + assert.equal(knowledgeContract.native_storage.owner, 'HOLOLAKE_NATIVE_RUST_CORE') + assert.equal(knowledgeContract.legacy_compatibility.mode, 'READ_ONLY_SEPARATE_ROOT') + assert.equal(knowledgeContract.legacy_compatibility.in_place_migration, false) + assert.equal(knowledgeContract.legacy_compatibility.tolaria_surface_used, false) + assert.equal(provenance.legacy_data_boundary.modified, false) + assert.match(knowledgeRust, /join\("knowledge-v1"\)/) + assert.match(knowledgeRust, /source_docs_root/) +}) + +test('folder import is bounded, idempotent, conflict-safe and creates a local Git receipt', () => { + assert.equal(knowledgeContract.folder_import.maximum_files, 1000) + assert.equal(knowledgeContract.folder_import.symlinks_followed, false) + assert.equal(knowledgeContract.folder_import.repeated_import, 'MERGE_BY_STABLE_SOURCE_NAME') + assert.equal(knowledgeContract.folder_import.exact_duplicate_result, 'ALREADY_PRESENT') + assert.equal(knowledgeContract.folder_import.conflicting_file_overwrite, false) + assert.equal(knowledgeContract.capabilities.import_commit_receipt, true) + assert.equal(knowledgeContract.capabilities.exact_content_deduplication, true) + assert.equal(knowledgeContract.capabilities.optimistic_native_editing, true) + assert.match(knowledgeRust, /MAX_IMPORT_FILES: usize = 1_000/) + assert.match(knowledgeRust, /kind\.is_symlink\(\)/) + assert.match(knowledgeRust, /ImportDisposition::Existing/) + assert.match(knowledgeRust, /HOLOLAKE_KNOWLEDGE_SAVE_CONFLICT/) + assert.match(knowledgeRust, /rev-parse", "HEAD/) +}) + +test('official code-channel clone cannot use credentials or credential prompts', () => { + assert.equal(codeContract.clone.embedded_credentials_allowed, false) + assert.equal(codeContract.clone.terminal_prompt_allowed, false) + assert.equal(codeContract.clone.credential_helper_allowed, false) + assert.equal(codeContract.authority.automatic_push, false) + assert.match(codeRust, /GIT_TERMINAL_PROMPT/) + assert.match(codeRust, /credential\.helper=/) + assert.match(codeRust, /parsed\.username\(\)\.is_empty\(\)/) +}) + +test('the product surface exposes a real knowledge workbench and browsable code channel', () => { + for (const command of [ + 'get_knowledge_snapshot', + 'read_knowledge_document', + 'search_knowledge', + 'save_knowledge_document', + 'select_and_import_knowledge_folder', + 'get_code_channel_snapshot', + 'clone_code_channel', + 'select_local_code_channel', + 'browse_code_channel', + 'read_code_channel_file', + ]) { + assert.match(lib, new RegExp(command)) + assert.match(ui, new RegExp(`['"]${command}['"]`)) + } + assert.match(ui, /知识工作台/) + assert.match(ui, /HoloLake Era/) + assert.match(ui, /知识视图/) + assert.match(ui, /源文件/) + assert.doesNotMatch(ui, /下一件事|为什么做|开始这件事/) +}) diff --git a/product-source/hololake-native-desktop/scripts/personal-channel-kernel.test.mjs b/product-source/hololake-native-desktop/scripts/personal-channel-kernel.test.mjs new file mode 100644 index 000000000..7343ff840 --- /dev/null +++ b/product-source/hololake-native-desktop/scripts/personal-channel-kernel.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import test from 'node:test' + +const read = (relative) => fs.readFileSync(new URL(`../${relative}`, import.meta.url), 'utf8') +const contract = JSON.parse(read('contracts/personal-channel-kernel.json')) +const foundation = JSON.parse(read('foundation.json')) +const stageOne = JSON.parse(read('contracts/stage-one-platform.json')) +const rust = read('src-tauri/src/personal_channel.rs') +const lib = read('src-tauri/src/lib.rs') +const ui = read('src/main.tsx') + +test('personal channel kernel has one private SQLite owner and no server authority', () => { + assert.equal(contract.storage.owner, 'HOLOLAKE_NATIVE_RUST_CORE') + assert.equal(contract.storage.engine, 'EMBEDDED_SQLITE') + assert.equal(contract.storage.webview_direct_database_access, false) + assert.equal(contract.identity.platform_account_authentication_claimed, false) + assert.equal(contract.identity.server_or_repository_authority_granted, false) + assert.equal(contract.acceptance.server_deployment, false) +}) + +test('identity and task mutations create one-to-one chained events and receipts', () => { + assert.equal(contract.event_and_receipt.identity_and_task_mutations_are_single_sqlite_transactions, true) + assert.equal(contract.event_and_receipt.every_mutation_has_event, true) + assert.equal(contract.event_and_receipt.every_event_has_receipt, true) + assert.equal(contract.event_and_receipt.tampering_fails_closed, true) + assert.match(rust, /CREATE TABLE IF NOT EXISTS events/) + assert.match(rust, /CREATE TABLE IF NOT EXISTS receipts/) + assert.match(rust, /verify_integrity\(&connection\)/) +}) + +test('the human projection establishes identity once and keeps task mutation below the default surface', () => { + for (const command of [ + 'get_personal_channel_snapshot', + 'initialize_personal_channel', + 'create_personal_channel_task', + 'transition_personal_channel_task', + ]) { + assert.match(lib, new RegExp(`personal_channel::${command}`)) + } + for (const command of ['get_personal_channel_snapshot', 'initialize_personal_channel']) { + assert.match(ui, new RegExp(`['"]${command}['"]`)) + } + assert.doesNotMatch(ui, /['"]create_personal_channel_task['"]/) + assert.doesNotMatch(ui, /['"]transition_personal_channel_task['"]/) + assert.doesNotMatch(ui, /下一件事|为什么做|开始这件事/) + assert.equal(stageOne.personal_channel_kernel.native_source_implemented, true) + assert.equal(stageOne.personal_channel_kernel.installed_runtime_acceptance, true) + assert.equal(foundation.personal_channel_identity_task_event_receipt_kernel_implemented, true) + assert.equal(foundation.personal_channel_installed_runtime_acceptance, true) +}) + +test('full stage-one completion stays false after the first source slice', () => { + assert.equal(stageOne.implementation_complete, false) + assert.equal(foundation.stage_one_next_slice, 'HUMAN_APPROVAL_CENTER_AND_GIT_EVIDENCE_READBACK') + assert.match(foundation.stage_one_visible_body_state, /PRODUCT_INCOMPLETE/) +}) diff --git a/product-source/hololake-native-desktop/scripts/zero-point-nucleus.test.mjs b/product-source/hololake-native-desktop/scripts/zero-point-nucleus.test.mjs new file mode 100644 index 000000000..58ed78c9a --- /dev/null +++ b/product-source/hololake-native-desktop/scripts/zero-point-nucleus.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import test from 'node:test' + +const read = (relative) => fs.readFileSync(new URL(`../${relative}`, import.meta.url), 'utf8') +const contract = JSON.parse(read('contracts/zero-point-nucleus-channel.json')) +const stageOne = JSON.parse(read('contracts/stage-one-platform.json')) +const foundation = JSON.parse(read('foundation.json')) +const rust = read('src-tauri/src/zero_point.rs') +const lib = read('src-tauri/src/lib.rs') +const ui = read('src/main.tsx') + +test('zero-point nucleus is a system runtime and never an inferred persona binding', () => { + assert.equal(contract.metacognitive_boundary.zero_point_system_is_persona, false) + assert.equal(contract.metacognitive_boundary.zero_point_system_is_model_carrier, false) + assert.equal(contract.metacognitive_boundary.number_verification_is_persona_binding, false) + assert.equal(contract.metacognitive_boundary.number_verification_grants_execution_authority, false) + assert.equal(stageOne.zero_point_nucleus_client_runtime.system_is_persona, false) + assert.equal(foundation.zero_point_nucleus_is_persona, false) + assert.match(ui, /验证结果不构成人格绑定、模型载体绑定或执行授权/) +}) + +test('protocol comparison starts below the public surface and fails closed', () => { + assert.equal(contract.current_implementation.boot_time_silent_version_comparison, true) + assert.equal(contract.current_implementation.signed_protocol_payload_installation, false) + assert.equal(contract.security.unsigned_protocol_update_allowed, false) + assert.match(lib, /sync_protocol_runtime/) + assert.match(rust, /UPDATE_PENDING_SIGNATURE_KEY_ABSENT/) + assert.match(rust, /UPDATE_FOUND_GATE_PENDING/) + assert.doesNotMatch(lib, /zero_point_save_api|zero_point_api_config|generic_layer_chat/) + assert.doesNotMatch(ui, /模型 API 配置|通用AI运行层|AGENT PARLOR|会客厅/) +}) + +test('number validation requires an explicit positive lighthouse verdict', () => { + assert.match(rust, /fn lighthouse_resolution/) + assert.match(rust, /eq_ignore_ascii_case\("RESOLVED"\)/) + assert.match(rust, /route_not_found/) + assert.match(rust, /an arbitrary successful response/) + assert.match(rust, /inner\.route = "restricted"/) +}) diff --git a/product-source/hololake-native-desktop/src-tauri/Cargo.lock b/product-source/hololake-native-desktop/src-tauri/Cargo.lock index 402fa6ab2..12412c8cc 100644 --- a/product-source/hololake-native-desktop/src-tauri/Cargo.lock +++ b/product-source/hololake-native-desktop/src-tauri/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -91,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" @@ -282,6 +317,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -318,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" @@ -330,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" @@ -405,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" @@ -811,6 +883,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.5.0" @@ -920,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" @@ -1138,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]] @@ -1161,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]] @@ -1319,12 +1414,30 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -1345,12 +1458,15 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hololake-native-desktop" -version = "0.2.0" +version = "0.3.0" dependencies = [ "base64 0.22.1", "dirs", "fs2", + "futures-util", + "reqwest", "ring", + "rusqlite", "serde", "serde_json", "tauri", @@ -1360,6 +1476,8 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tempfile", + "tokio", + "url", "uuid", ] @@ -1790,6 +1908,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" @@ -1901,6 +2029,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1928,6 +2067,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" @@ -2773,6 +2918,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" @@ -2805,7 +3007,7 @@ dependencies = [ "rand_chacha 0.2.2", "rand_core 0.5.1", "rand_hc", - "rand_pcg", + "rand_pcg 0.2.1", ] [[package]] @@ -2819,6 +3021,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" @@ -2857,6 +3070,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" @@ -2875,6 +3094,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" @@ -2970,6 +3198,7 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", + "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -3027,6 +3256,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3061,6 +3304,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", @@ -3087,6 +3331,7 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "zeroize", ] @@ -3123,6 +3368,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", @@ -3455,7 +3701,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4570,6 +4816,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -4720,6 +4972,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.6" diff --git a/product-source/hololake-native-desktop/src-tauri/Cargo.toml b/product-source/hololake-native-desktop/src-tauri/Cargo.toml index d81bad7c1..5ffac9b52 100644 --- a/product-source/hololake-native-desktop/src-tauri/Cargo.toml +++ b/product-source/hololake-native-desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hololake-native-desktop" -version = "0.2.0" +version = "0.3.0" description = "HoloLake native desktop foundation" authors = ["HoloLake"] license = "AGPL-3.0-or-later" @@ -18,15 +18,20 @@ tauri-build = { version = "2.5.4", features = [] } dirs = "6" fs2 = "0.4" ring = "0.17" +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", "stream"] } +tokio = { version = "1", features = ["time"] } +futures-util = "0.3" [dev-dependencies] tempfile = "3" diff --git a/product-source/hololake-native-desktop/src-tauri/icons/icon.icns b/product-source/hololake-native-desktop/src-tauri/icons/icon.icns new file mode 100644 index 000000000..1b18edfba Binary files /dev/null and b/product-source/hololake-native-desktop/src-tauri/icons/icon.icns differ diff --git a/product-source/hololake-native-desktop/src-tauri/src/code_channel.rs b/product-source/hololake-native-desktop/src-tauri/src/code_channel.rs new file mode 100644 index 000000000..0ef18083c --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/code_channel.rs @@ -0,0 +1,929 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use serde::{Deserialize, Serialize}; +use std::cmp::Reverse; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::{AppHandle, Manager}; +use tauri_plugin_dialog::DialogExt; +use url::Url; +use uuid::Uuid; + +const SNAPSHOT_SCHEMA: &str = "hololake.code-channel/v1"; +const REGISTRY_SCHEMA: &str = "hololake.code-channel-registry/v1"; +const ALLOWED_HOSTS: &[&str] = &["guanghulab.com", "guanghubingshuo.com"]; +const MAX_TREE_ENTRIES: usize = 1_000; +const MAX_CODE_FILE_BYTES: u64 = 2 * 1024 * 1024; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CloneCodeChannelInput { + pub url: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CodeChannelEntry { + pub channel_id: String, + pub name: String, + pub source_kind: String, + pub local_path: String, + pub remote_url: Option, + pub git_head: String, + pub branch: String, + pub repository_clean: bool, + pub registered_at_unix_ms: u128, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodeChannelSnapshot { + pub schema: &'static str, + pub state: &'static str, + pub storage_root: String, + pub channels: Vec, + pub authority: &'static str, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BrowseCodeChannelInput { + pub channel_id: String, + pub path: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodeTreeEntry { + pub path: String, + pub name: String, + pub kind: &'static str, + pub size_bytes: u64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodeTreeSnapshot { + pub schema: &'static str, + pub channel_id: String, + pub path: String, + pub entries: Vec, + pub truncated: bool, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReadCodeChannelFileInput { + pub channel_id: String, + pub path: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodeFileProjection { + pub schema: &'static str, + pub channel_id: String, + pub path: String, + pub format: String, + pub source: String, + pub human_markdown: String, + pub size_bytes: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct CodeChannelRegistry { + schema: String, + channels: Vec, +} + +#[derive(Clone, Debug)] +struct ValidatedCloneUrl { + normalized: String, + owner: String, + repository: String, +} + +#[tauri::command] +pub async fn get_code_channel_snapshot(app: AppHandle) -> Result { + let root = code_channel_root(&app)?; + tauri::async_runtime::spawn_blocking(move || snapshot_at(&root)) + .await + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn clone_code_channel( + app: AppHandle, + input: CloneCodeChannelInput, +) -> Result { + let root = code_channel_root(&app)?; + tauri::async_runtime::spawn_blocking(move || clone_at(&root, input)) + .await + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn select_local_code_channel( + app: AppHandle, +) -> Result, String> { + let picker = app.clone(); + let selected = tauri::async_runtime::spawn_blocking(move || { + picker + .dialog() + .file() + .set_title("选择本地 Git 代码频道") + .blocking_pick_folder() + }) + .await + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PICKER_JOIN_FAILED: {error}"))?; + let Some(selected) = selected else { + return Ok(None); + }; + let selected = selected + .into_path() + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PICKER_PATH_INVALID: {error}"))?; + let root = code_channel_root(&app)?; + tauri::async_runtime::spawn_blocking(move || register_local_at(&root, &selected).map(Some)) + .await + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn browse_code_channel( + app: AppHandle, + input: BrowseCodeChannelInput, +) -> Result { + let root = code_channel_root(&app)?; + tauri::async_runtime::spawn_blocking(move || browse_at(&root, input)) + .await + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn read_code_channel_file( + app: AppHandle, + input: ReadCodeChannelFileInput, +) -> Result { + let root = code_channel_root(&app)?; + tauri::async_runtime::spawn_blocking(move || read_code_file_at(&root, input)) + .await + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))? +} + +fn code_channel_root(app: &AppHandle) -> Result { + let root = app + .path() + .app_data_dir() + .map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))? + .join("code-channel-v1"); + ensure_root(&root)?; + Ok(root) +} + +fn ensure_root(root: &Path) -> Result<(), String> { + fs::create_dir_all(root.join("repositories")) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_STORAGE_UNAVAILABLE: {error}"))?; + fs::set_permissions(root, fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PERMISSION_FAILED: {error}"))?; + Ok(()) +} + +fn snapshot_at(root: &Path) -> Result { + ensure_root(root)?; + let mut registry = read_registry(root)?; + for channel in &mut registry.channels { + if let Ok(inspection) = inspect_repository(Path::new(&channel.local_path)) { + channel.git_head = inspection.git_head; + channel.branch = inspection.branch; + channel.repository_clean = inspection.repository_clean; + } + } + registry + .channels + .sort_by_key(|entry| Reverse(entry.registered_at_unix_ms)); + Ok(CodeChannelSnapshot { + schema: SNAPSHOT_SCHEMA, + state: "READY", + storage_root: root.to_string_lossy().into_owned(), + channels: registry.channels, + authority: "LOCAL_SOURCE_ACCESS_ONLY_NO_PUSH_OR_DEPLOY_AUTHORITY", + }) +} + +fn browse_at(root: &Path, input: BrowseCodeChannelInput) -> Result { + let (channel, repository) = registered_repository(root, &input.channel_id)?; + let relative = input.path.unwrap_or_default(); + let directory = safe_repository_path(&repository, &relative)?; + if !directory.is_dir() { + return Err("HOLOLAKE_CODE_CHANNEL_DIRECTORY_INVALID".into()); + } + let mut entries = fs::read_dir(&directory) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_TREE_UNAVAILABLE: {error}"))? + .collect::, _>>() + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_TREE_UNAVAILABLE: {error}"))?; + entries.sort_by_key(|entry| { + let is_file = entry.file_type().map(|kind| kind.is_file()).unwrap_or(true); + (is_file, entry.file_name()) + }); + let mut projected = Vec::new(); + for entry in entries { + if projected.len() >= MAX_TREE_ENTRIES { + break; + } + let name = entry.file_name().to_string_lossy().into_owned(); + if name == ".git" || name == ".DS_Store" || name.starts_with("._") { + continue; + } + let kind = entry + .file_type() + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_TREE_UNAVAILABLE: {error}"))?; + if kind.is_symlink() || (!kind.is_dir() && !kind.is_file()) { + continue; + } + let entry_path = entry.path(); + let relative_path = relative_posix(&repository, &entry_path)?; + let size_bytes = if kind.is_file() { + entry + .metadata() + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_TREE_UNAVAILABLE: {error}"))? + .len() + } else { + 0 + }; + projected.push(CodeTreeEntry { + path: relative_path, + name, + kind: if kind.is_dir() { "directory" } else { "file" }, + size_bytes, + }); + } + Ok(CodeTreeSnapshot { + schema: "hololake.code-channel-tree/v1", + channel_id: channel.channel_id, + path: relative, + truncated: projected.len() >= MAX_TREE_ENTRIES, + entries: projected, + }) +} + +fn read_code_file_at( + root: &Path, + input: ReadCodeChannelFileInput, +) -> Result { + let (channel, repository) = registered_repository(root, &input.channel_id)?; + let file = safe_repository_path(&repository, &input.path)?; + let metadata = fs::metadata(&file) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_FILE_UNAVAILABLE: {error}"))?; + if !metadata.is_file() || metadata.len() > MAX_CODE_FILE_BYTES || !is_supported_text_file(&file) + { + return Err("HOLOLAKE_CODE_CHANNEL_FILE_UNSUPPORTED".into()); + } + let source = fs::read_to_string(&file) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_FILE_INVALID_UTF8: {error}"))?; + let format = file_format(&file); + let human_markdown = human_projection(&input.path, &format, &source); + Ok(CodeFileProjection { + schema: "hololake.code-channel-file-projection/v1", + channel_id: channel.channel_id, + path: input.path, + format, + source, + human_markdown, + size_bytes: metadata.len(), + }) +} + +fn registered_repository( + root: &Path, + channel_id: &str, +) -> Result<(CodeChannelEntry, PathBuf), String> { + let registry = read_registry(root)?; + let channel = registry + .channels + .into_iter() + .find(|entry| entry.channel_id == channel_id) + .ok_or("HOLOLAKE_CODE_CHANNEL_NOT_REGISTERED")?; + let repository = PathBuf::from(&channel.local_path) + .canonicalize() + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REPOSITORY_UNAVAILABLE: {error}"))?; + if !repository.is_dir() { + return Err("HOLOLAKE_CODE_CHANNEL_REPOSITORY_UNAVAILABLE".into()); + } + Ok((channel, repository)) +} + +fn safe_repository_path(root: &Path, relative: &str) -> Result { + let relative_path = Path::new(relative); + if relative_path.is_absolute() + || relative_path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + if relative.is_empty() { + return Ok(root.to_path_buf()); + } + return Err("HOLOLAKE_CODE_CHANNEL_PATH_INVALID".into()); + } + if relative.is_empty() { + return Ok(root.to_path_buf()); + } + let candidate = root.join(relative_path); + let metadata = fs::symlink_metadata(&candidate) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PATH_UNAVAILABLE: {error}"))?; + if metadata.file_type().is_symlink() { + return Err("HOLOLAKE_CODE_CHANNEL_SYMLINK_DENIED".into()); + } + let canonical = candidate + .canonicalize() + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PATH_UNAVAILABLE: {error}"))?; + if !canonical.starts_with(root) { + return Err("HOLOLAKE_CODE_CHANNEL_PATH_ESCAPE".into()); + } + Ok(canonical) +} + +fn relative_posix(root: &Path, path: &Path) -> Result { + Ok(path + .strip_prefix(root) + .map_err(|_| "HOLOLAKE_CODE_CHANNEL_PATH_INVALID".to_string())? + .to_string_lossy() + .replace('\\', "/")) +} + +fn is_supported_text_file(path: &Path) -> bool { + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(""); + if ["README", "LICENSE", "Dockerfile", "Makefile", "Procfile"].contains(&name) { + return true; + } + matches!( + path.extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase() + .as_str(), + "md" | "markdown" + | "txt" + | "json" + | "jsonl" + | "yaml" + | "yml" + | "toml" + | "hdlp" + | "rs" + | "ts" + | "tsx" + | "js" + | "jsx" + | "mjs" + | "cjs" + | "py" + | "sh" + | "zsh" + | "bash" + | "css" + | "scss" + | "html" + | "xml" + | "sql" + | "graphql" + | "go" + | "java" + | "kt" + | "swift" + | "c" + | "h" + | "cpp" + | "hpp" + | "proto" + | "ini" + | "conf" + | "env" + ) +} + +fn file_format(path: &Path) -> String { + path.extension() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .unwrap_or("text") + .to_ascii_lowercase() +} + +fn human_projection(path: &str, format: &str, source: &str) -> String { + if matches!(format, "md" | "markdown") { + return source.to_string(); + } + let title = Path::new(path) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("代码文件"); + if format == "json" { + if let Ok(value) = serde_json::from_str::(source) { + return format!( + "# {title}\n\n> 来自代码频道的 JSON 结构,已转换为可读层级。\n\n{}", + json_as_markdown(&value, 2) + ); + } + } + if matches!(format, "hdlp" | "yaml" | "yml" | "toml" | "ini" | "conf") { + let rows = source + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + trimmed + .split_once(':') + .or_else(|| trimmed.split_once('=')) + .map(|(key, value)| format!("| {} | {} |", key.trim(), value.trim())) + }) + .take(120) + .collect::>(); + if !rows.is_empty() { + return format!( + "# {title}\n\n> 来自代码频道的结构化文件。原始机器字段已转成阅读表格。\n\n| 字段 | 内容 |\n| --- | --- |\n{}", + rows.join("\n") + ); + } + } + format!( + "# {title}\n\n> 来自代码频道的 `{format}` 文件。下方保留原始内容,便于核验。\n\n```{format}\n{source}\n```" + ) +} + +fn json_as_markdown(value: &serde_json::Value, level: usize) -> String { + match value { + serde_json::Value::Object(map) => map + .iter() + .map(|(key, value)| match value { + serde_json::Value::Object(_) | serde_json::Value::Array(_) => format!( + "{} {key}\n\n{}", + "#".repeat(level.min(5)), + json_as_markdown(value, level + 1) + ), + _ => format!("- **{key}**:{}", scalar_json(value)), + }) + .collect::>() + .join("\n\n"), + serde_json::Value::Array(values) => values + .iter() + .enumerate() + .map(|(index, value)| match value { + serde_json::Value::Object(_) | serde_json::Value::Array(_) => { + format!("{}. {}", index + 1, json_as_markdown(value, level + 1)) + } + _ => format!("- {}", scalar_json(value)), + }) + .collect::>() + .join("\n"), + _ => scalar_json(value), + } +} + +fn scalar_json(value: &serde_json::Value) -> String { + value + .as_str() + .map(ToOwned::to_owned) + .unwrap_or_else(|| value.to_string()) +} + +fn clone_at(root: &Path, input: CloneCodeChannelInput) -> Result { + ensure_root(root)?; + let validated = validate_clone_url(&input.url)?; + let destination = root.join("repositories").join(format!( + "{}--{}", + safe_segment(&validated.owner), + safe_segment(&validated.repository) + )); + if destination.exists() { + return Err("HOLOLAKE_CODE_CHANNEL_DESTINATION_EXISTS".into()); + } + let output = Command::new("/usr/bin/git") + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_ASKPASS", "/usr/bin/false") + .env("SSH_ASKPASS", "/usr/bin/false") + .env("GIT_CONFIG_NOSYSTEM", "1") + .args([ + "-c", + "credential.helper=", + "-c", + "core.askPass=", + "clone", + "--origin", + "origin", + "--no-tags", + ]) + .arg(&validated.normalized) + .arg(&destination) + .output() + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_CLONE_FAILED: {error}"))?; + if !output.status.success() { + if destination.starts_with(root.join("repositories")) { + let _ = fs::remove_dir_all(&destination); + } + return Err(format!( + "HOLOLAKE_CODE_CHANNEL_CLONE_FAILED: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let entry = entry_for_repository( + &destination, + "CLONED_HTTPS", + Some(validated.normalized), + Some(format!("{}/{}", validated.owner, validated.repository)), + )?; + upsert_entry(root, entry)?; + snapshot_at(root) +} + +fn register_local_at(root: &Path, selected: &Path) -> Result { + ensure_root(root)?; + let canonical = selected + .canonicalize() + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_LOCAL_UNAVAILABLE: {error}"))?; + if !canonical.is_dir() { + return Err("HOLOLAKE_CODE_CHANNEL_LOCAL_INVALID".into()); + } + let remote = git_optional(&canonical, &["remote", "get-url", "origin"]) + .and_then(|value| redact_remote_url(value.trim())); + let entry = entry_for_repository(&canonical, "LOCAL_GIT", remote, None)?; + upsert_entry(root, entry)?; + snapshot_at(root) +} + +struct RepositoryInspection { + git_head: String, + branch: String, + repository_clean: bool, +} + +fn inspect_repository(path: &Path) -> Result { + let inside = git(path, &["rev-parse", "--is-inside-work-tree"], "INSPECT")?; + if inside.trim() != "true" { + return Err("HOLOLAKE_CODE_CHANNEL_NOT_GIT".into()); + } + let git_head = git(path, &["rev-parse", "HEAD"], "READ_HEAD")? + .trim() + .to_string(); + let branch = git_optional(path, &["symbolic-ref", "--short", "HEAD"]) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "DETACHED".into()); + let repository_clean = git(path, &["status", "--porcelain"], "READ_STATUS")? + .trim() + .is_empty(); + Ok(RepositoryInspection { + git_head, + branch, + repository_clean, + }) +} + +fn entry_for_repository( + path: &Path, + source_kind: &str, + remote_url: Option, + explicit_name: Option, +) -> Result { + let inspection = inspect_repository(path)?; + let name = explicit_name.unwrap_or_else(|| { + path.file_name() + .and_then(|value| value.to_str()) + .unwrap_or("代码频道") + .to_string() + }); + Ok(CodeChannelEntry { + channel_id: format!("channel-{}", Uuid::new_v4()), + name, + source_kind: source_kind.into(), + local_path: path.to_string_lossy().into_owned(), + remote_url, + git_head: inspection.git_head, + branch: inspection.branch, + repository_clean: inspection.repository_clean, + registered_at_unix_ms: now_unix_ms()?, + }) +} + +fn validate_clone_url(raw: &str) -> Result { + let raw = raw.trim(); + if raw.len() > 2_048 { + return Err("HOLOLAKE_CODE_CHANNEL_URL_INVALID".into()); + } + let mut parsed = Url::parse(raw).map_err(|_| "HOLOLAKE_CODE_CHANNEL_URL_INVALID")?; + if parsed.scheme() != "https" + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || parsed.port().is_some() + { + return Err("HOLOLAKE_CODE_CHANNEL_URL_INVALID".into()); + } + let host = parsed + .host_str() + .ok_or("HOLOLAKE_CODE_CHANNEL_URL_INVALID")? + .to_lowercase(); + if !ALLOWED_HOSTS.contains(&host.as_str()) { + return Err("HOLOLAKE_CODE_CHANNEL_HOST_NOT_TRUSTED".into()); + } + let segments = parsed + .path_segments() + .ok_or("HOLOLAKE_CODE_CHANNEL_URL_INVALID")? + .filter(|segment| !segment.is_empty()) + .collect::>(); + if segments.len() != 3 || segments[0] != "code" { + return Err("HOLOLAKE_CODE_CHANNEL_URL_INVALID".into()); + } + let owner = segments[1].to_string(); + let repository = segments[2] + .strip_suffix(".git") + .unwrap_or(segments[2]) + .to_string(); + if !valid_git_segment(&owner) || !valid_git_segment(&repository) { + return Err("HOLOLAKE_CODE_CHANNEL_URL_INVALID".into()); + } + parsed.set_path(&format!("/code/{owner}/{repository}.git")); + Ok(ValidatedCloneUrl { + normalized: parsed.to_string(), + owner, + repository, + }) +} + +fn valid_git_segment(value: &str) -> bool { + !value.is_empty() + && value.len() <= 100 + && value != "." + && value != ".." + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || "-_.".contains(character)) +} + +fn redact_remote_url(raw: &str) -> Option { + let mut parsed = Url::parse(raw).ok()?; + if parsed.scheme() != "https" { + return None; + } + parsed.set_username("").ok()?; + parsed.set_password(None).ok()?; + parsed.set_query(None); + parsed.set_fragment(None); + Some(parsed.to_string()) +} + +fn safe_segment(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || "-_.".contains(character) { + character + } else { + '-' + } + }) + .collect() +} + +fn upsert_entry(root: &Path, entry: CodeChannelEntry) -> Result<(), String> { + let mut registry = read_registry(root)?; + registry + .channels + .retain(|existing| existing.local_path != entry.local_path); + registry.channels.push(entry); + write_registry(root, ®istry) +} + +fn read_registry(root: &Path) -> Result { + let path = root.join("channels.json"); + if !path.exists() { + return Ok(CodeChannelRegistry { + schema: REGISTRY_SCHEMA.into(), + channels: Vec::new(), + }); + } + let bytes = fs::read(&path) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_UNAVAILABLE: {error}"))?; + let registry: CodeChannelRegistry = serde_json::from_slice(&bytes) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_INVALID: {error}"))?; + if registry.schema != REGISTRY_SCHEMA { + return Err("HOLOLAKE_CODE_CHANNEL_REGISTRY_SCHEMA_INVALID".into()); + } + Ok(registry) +} + +fn write_registry(root: &Path, registry: &CodeChannelRegistry) -> Result<(), String> { + let bytes = serde_json::to_vec_pretty(registry) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_INVALID: {error}"))?; + let temporary = root.join(format!("channels.json.tmp-{}", Uuid::new_v4())); + let final_path = root.join("channels.json"); + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(&temporary) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_WRITE_FAILED: {error}"))?; + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_WRITE_FAILED: {error}"))?; + fs::rename(&temporary, &final_path) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_WRITE_FAILED: {error}"))?; + Ok(()) +} + +fn git(root: &Path, args: &[&str], operation: &str) -> Result { + let output = Command::new("/usr/bin/git") + .current_dir(root) + .env("GIT_TERMINAL_PROMPT", "0") + .args(args) + .output() + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_GIT_{operation}_FAILED: {error}"))?; + if !output.status.success() { + return Err(format!( + "HOLOLAKE_CODE_CHANNEL_GIT_{operation}_FAILED: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + String::from_utf8(output.stdout) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_GIT_{operation}_INVALID_UTF8: {error}")) +} + +fn git_optional(root: &Path, args: &[&str]) -> Option { + let output = Command::new("/usr/bin/git") + .current_dir(root) + .env("GIT_TERMINAL_PROMPT", "0") + .args(args) + .output() + .ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).into_owned()) +} + +fn now_unix_ms() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .map_err(|error| format!("HOLOLAKE_CLOCK_INVALID: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn clone_url_is_normalized_to_official_https_git_endpoint() { + let parsed = + validate_clone_url("https://guanghulab.com/code/bingshuo/guanghu-ice-heart").unwrap(); + assert_eq!( + parsed.normalized, + "https://guanghulab.com/code/bingshuo/guanghu-ice-heart.git" + ); + assert_eq!(parsed.owner, "bingshuo"); + assert_eq!(parsed.repository, "guanghu-ice-heart"); + } + + #[test] + fn clone_url_rejects_credentials_queries_and_unknown_hosts() { + assert!( + validate_clone_url("https://user:secret@guanghulab.com/code/bingshuo/repo").is_err() + ); + assert!( + validate_clone_url("https://guanghulab.com/code/bingshuo/repo?token=secret").is_err() + ); + assert!(validate_clone_url("https://example.com/code/bingshuo/repo").is_err()); + } + + #[test] + fn remote_redaction_removes_credentials_and_fragments() { + assert_eq!( + redact_remote_url("https://user:secret@example.com/a/b.git?x=1#readme"), + Some("https://example.com/a/b.git".into()) + ); + assert_eq!(redact_remote_url("git@example.com:a/b.git"), None); + } + + #[test] + fn local_repository_registration_is_persistent_and_deduplicated() { + let root = tempdir().unwrap(); + let repository = tempdir().unwrap(); + ensure_root(root.path()).unwrap(); + git( + repository.path(), + &["init", "--initial-branch=main"], + "TEST_INIT", + ) + .unwrap(); + git( + repository.path(), + &["config", "user.name", "Test"], + "TEST_CONFIG", + ) + .unwrap(); + git( + repository.path(), + &["config", "user.email", "test@example.invalid"], + "TEST_CONFIG", + ) + .unwrap(); + fs::write(repository.path().join("README.md"), "# test\n").unwrap(); + git(repository.path(), &["add", "README.md"], "TEST_ADD").unwrap(); + git( + repository.path(), + &["commit", "-m", "initial"], + "TEST_COMMIT", + ) + .unwrap(); + + register_local_at(root.path(), repository.path()).unwrap(); + register_local_at(root.path(), repository.path()).unwrap(); + let snapshot = snapshot_at(root.path()).unwrap(); + assert_eq!(snapshot.channels.len(), 1); + assert_eq!(snapshot.channels[0].branch, "main"); + assert!(snapshot.channels[0].repository_clean); + } + + #[test] + fn registered_repository_can_be_browsed_and_projected_as_knowledge() { + let root = tempdir().unwrap(); + let repository = tempdir().unwrap(); + ensure_root(root.path()).unwrap(); + git( + repository.path(), + &["init", "--initial-branch=main"], + "TEST_INIT", + ) + .unwrap(); + git( + repository.path(), + &["config", "user.name", "Test"], + "TEST_CONFIG", + ) + .unwrap(); + git( + repository.path(), + &["config", "user.email", "test@example.invalid"], + "TEST_CONFIG", + ) + .unwrap(); + fs::create_dir(repository.path().join("config")).unwrap(); + fs::write( + repository.path().join("config/system.json"), + r#"{"name":"光湖","state":"ready","routes":["native","mcp"]}"#, + ) + .unwrap(); + git(repository.path(), &["add", "."], "TEST_ADD").unwrap(); + git( + repository.path(), + &["commit", "-m", "initial"], + "TEST_COMMIT", + ) + .unwrap(); + + let snapshot = register_local_at(root.path(), repository.path()).unwrap(); + let channel_id = snapshot.channels[0].channel_id.clone(); + let tree = browse_at( + root.path(), + BrowseCodeChannelInput { + channel_id: channel_id.clone(), + path: None, + }, + ) + .unwrap(); + assert!(tree + .entries + .iter() + .any(|entry| entry.name == "config" && entry.kind == "directory")); + + let projection = read_code_file_at( + root.path(), + ReadCodeChannelFileInput { + channel_id, + path: "config/system.json".into(), + }, + ) + .unwrap(); + assert_eq!(projection.format, "json"); + assert!(projection + .human_markdown + .contains("来自代码频道的 JSON 结构")); + assert!(projection.human_markdown.contains("**state**:ready")); + } + + #[test] + fn code_tree_denies_parent_escape() { + let repository = tempdir().unwrap(); + assert!(safe_repository_path(repository.path(), "../secret").is_err()); + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/code_repo_login.rs b/product-source/hololake-native-desktop/src-tauri/src/code_repo_login.rs new file mode 100644 index 000000000..f559d521c --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/code_repo_login.rs @@ -0,0 +1,216 @@ +//! 登录模块 · code_repo_login +//! +//! 规划卷依据(HoloLake第二阶段总体规划-20260815 · 阶段B1): +//! - 人类端输入代码仓库账号密码 → 对 guanghulab(Forgejo)验证。 +//! - 登录仓库 = 验证了背后绑定的服务器(冰朔教义)。 +//! - 凭证只存本机钥匙串 · 不落明文。 +//! +//! 事实底账(2026-08-15 三角测量): +//! - Forgejo 挂载在 /code 路径下:GET https://{host}/code/api/v1/user 走基本认证, +//! 假凭证=401 · 真凭证=200 并回显 JSON(login/email)。 +//! - guanghulab.com 与 guanghubingshuo.com 双域同路可用(均在 ALLOWED_HOSTS 血统内)。 +//! - 钥匙存取走系统钥匙串(macOS `security`);其余平台暂不落盘密码, +//! 会话仅内存保持(诚实边界,Windows 钥匙串接入排在分发阶段)。 + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::process::Command; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tauri::{AppHandle, Manager}; + +const LOGIN_HOST: &str = "guanghulab.com"; +const SESSION_FILE_NAME: &str = "login-session.json"; + +/// 落盘的登录会话——只有用户名与主机,密码永不落盘。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoginSession { + pub username: String, + pub host: String, + pub signed_in_at_unix_ms: u64, +} + +/// 登录成功回执——交给前端展示"验证了背后绑定的服务器"。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LoginReceipt { + pub username: String, + pub email: String, + pub host: String, +} + +fn session_path(app: &AppHandle) -> Result { + Ok(app + .path() + .app_data_dir() + .map_err(|error| format!("HOLOLAKE_LOGIN_SESSION_DIR_FAILED: {error}"))? + .join(SESSION_FILE_NAME)) +} + +#[cfg(target_os = "macos")] +fn keychain_store(host: &str, username: &str, password: &str) -> Result<(), String> { + let output = Command::new("/usr/bin/security") + .args([ + "add-internet-password", + "-U", + "-s", + host, + "-a", + username, + "-w", + password, + ]) + .output() + .map_err(|error| format!("HOLOLAKE_LOGIN_KEYCHAIN_FAILED: {error}"))?; + if output.status.success() { + Ok(()) + } else { + Err("HOLOLAKE_LOGIN_KEYCHAIN_FAILED".into()) + } +} + +#[cfg(target_os = "macos")] +fn keychain_has(host: &str, username: &str) -> bool { + Command::new("/usr/bin/security") + .args(["find-internet-password", "-s", host, "-a", username]) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +#[cfg(target_os = "macos")] +fn keychain_remove(host: &str, username: &str) { + let _ = Command::new("/usr/bin/security") + .args(["delete-internet-password", "-s", host, "-a", username]) + .output(); +} + +#[cfg(not(target_os = "macos"))] +fn keychain_store(_host: &str, _username: &str, _password: &str) -> Result<(), String> { + // 非 macOS 暂不落盘密码:登录验证照做,会话仅本次运行有效。 + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn keychain_has(_host: &str, _username: &str) -> bool { + false +} + +#[cfg(not(target_os = "macos"))] +fn keychain_remove(_host: &str, _username: &str) {} + +fn validate_username(raw: &str) -> Result { + let name = raw.trim(); + let valid = !name.is_empty() + && name.len() <= 40 + && name + .chars() + .all(|item| item.is_ascii_alphanumeric() || item == '-' || item == '_'); + if valid { + Ok(name.to_string()) + } else { + Err("HOLOLAKE_LOGIN_USERNAME_INVALID".into()) + } +} + +/// 启动时自查:本机有没有已登录会话(会话文件在,且钥匙串里凭证还在)。 +#[tauri::command] +pub fn check_code_repo_login(app: AppHandle) -> Result, String> { + let path = session_path(&app)?; + let raw = match fs::read_to_string(&path) { + Ok(raw) => raw, + Err(_) => return Ok(None), + }; + let session: LoginSession = serde_json::from_str(&raw) + .map_err(|_| "HOLOLAKE_LOGIN_SESSION_CORRUPT".to_string())?; + if keychain_has(&session.host, &session.username) { + Ok(Some(session)) + } else { + let _ = fs::remove_file(&path); + Ok(None) + } +} + +/// 登录验证:基本认证打 Forgejo 用户接口,密码错=401 即拒; +/// 通过后凭证进钥匙串,会话(不含密码)落盘。 +#[tauri::command] +pub async fn perform_code_repo_login( + app: AppHandle, + username: String, + password: String, +) -> Result { + let username = validate_username(&username)?; + if password.is_empty() || password.len() > 512 { + return Err("HOLOLAKE_LOGIN_CREDENTIALS_INVALID".into()); + } + let client = reqwest::Client::builder() + .read_timeout(Duration::from_secs(15)) + .use_rustls_tls() + .build() + .map_err(|error| format!("HOLOLAKE_LOGIN_NETWORK_FAILED: {error}"))?; + let url = format!("https://{LOGIN_HOST}/code/api/v1/user"); + let response = client + .get(&url) + .basic_auth(&username, Some(&password)) + .send() + .await + .map_err(|error| format!("HOLOLAKE_LOGIN_NETWORK_FAILED: {error}"))?; + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err("HOLOLAKE_LOGIN_CREDENTIALS_INVALID".into()); + } + if !status.is_success() { + return Err(format!("HOLOLAKE_LOGIN_VERIFICATION_FAILED: {status}")); + } + let body: serde_json::Value = response + .json() + .await + .map_err(|error| format!("HOLOLAKE_LOGIN_VERIFICATION_FAILED: {error}"))?; + let confirmed_login = body + .get("login") + .and_then(|value| value.as_str()) + .ok_or("HOLOLAKE_LOGIN_VERIFICATION_FAILED")?; + let email = body + .get("email") + .and_then(|value| value.as_str()) + .unwrap_or("") + .to_string(); + keychain_store(LOGIN_HOST, &username, &password)?; + let session = LoginSession { + username: confirmed_login.to_string(), + host: LOGIN_HOST.to_string(), + signed_in_at_unix_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0), + }; + let path = session_path(&app)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("HOLOLAKE_LOGIN_SESSION_WRITE_FAILED: {error}"))?; + } + fs::write( + &path, + serde_json::to_string_pretty(&session) + .map_err(|error| format!("HOLOLAKE_LOGIN_SESSION_WRITE_FAILED: {error}"))?, + ) + .map_err(|error| format!("HOLOLAKE_LOGIN_SESSION_WRITE_FAILED: {error}"))?; + Ok(LoginReceipt { + username: confirmed_login.to_string(), + email, + host: LOGIN_HOST.to_string(), + }) +} + +/// 登出:会话文件删除,钥匙串凭证清除。 +#[tauri::command] +pub fn sign_out_code_repo_login(app: AppHandle) -> Result<(), String> { + let path = session_path(&app)?; + if let Ok(raw) = fs::read_to_string(&path) { + if let Ok(session) = serde_json::from_str::(&raw) { + keychain_remove(&session.host, &session.username); + } + } + let _ = fs::remove_file(&path); + Ok(()) +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/glp_envelope.rs b/product-source/hololake-native-desktop/src-tauri/src/glp_envelope.rs new file mode 100644 index 000000000..97f955fe3 --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/glp_envelope.rs @@ -0,0 +1,266 @@ +//! GLP 标准信封 · glp_envelope +//! +//! 协议转工程第一件(HoloLake第二阶段总体规划-20260815 · 施工总纲): +//! GLS-0300《GLP 通信核心协议》第3节"标准消息结构"的逐字段工程映射。 +//! 字段一个不造、一个不丢——老家谱 YAML 原文即本文件的形状。 +//! +//! 指挥链落点(铁律四):人格体→宿主的一切指令都必须是这个信封; +//! 宿主只认信封不认散话。 + +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// GLS-0300 · receiver.routing_mode +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RoutingMode { + Direct, + Channel, + Broadcast, +} + +/// GLS-0300 · payload.content_type +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContentType { + Text, + Command, + Event, + State, + Reference, +} + +/// GLS-0300 · control.priority +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Priority { + Low, + Normal, + High, + Critical, +} + +/// GLS-0300 · control.retry_policy +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RetryPolicy { + None, + Safe, + Guaranteed, +} + +/// GLS-0300 · sender +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct GlpSender { + pub object_id: String, + pub object_type: String, + #[serde(default)] + pub world_path: String, +} + +/// GLS-0300 · receiver +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct GlpReceiver { + pub object_id: String, + pub object_type: String, + pub routing_mode: RoutingMode, +} + +/// GLS-0300 · context(hldp_anchor 即铁律二的记忆锚点) +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct GlpContext { + #[serde(default)] + pub conversation_id: String, + #[serde(default)] + pub parent_message_id: String, + #[serde(default)] + pub relation_id: String, + #[serde(default)] + pub task_id: String, + #[serde(default)] + pub hldp_anchor: String, +} + +/// GLS-0300 · payload +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct GlpPayload { + pub language: String, + pub content_type: ContentType, + pub content: String, + #[serde(default)] + pub attachments: Vec, +} + +/// GLS-0300 · control +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct GlpControl { + pub priority: Priority, + pub ack_required: bool, + pub receipt_required: bool, + #[serde(default)] + pub expires_at: String, + pub retry_policy: RetryPolicy, +} + +/// GLS-0300 · integrity(高风险消息须带签名与回执链——通信安全节) +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct GlpIntegrity { + #[serde(default)] + pub checksum: String, + #[serde(default)] + pub signature: String, +} + +/// GLS-0300 · glp_message 全信封 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct GlpMessage { + pub protocol: String, + pub message_id: String, + pub message_type: String, + pub created_at: String, + pub sender: GlpSender, + pub receiver: GlpReceiver, + pub context: GlpContext, + pub payload: GlpPayload, + pub control: GlpControl, + pub integrity: GlpIntegrity, +} + +/// 信封进门第一道验:协议号、编号格式、收发主体必须在场。 +/// 不合格的信封宿主不收——指挥链只认标准件。 +pub fn validate_envelope(message: &GlpMessage) -> Result<(), String> { + if message.protocol != "GLP/1.0" { + return Err("HOLOLAKE_GLP_PROTOCOL_UNKNOWN".into()); + } + if message.message_id.trim().is_empty() || message.created_at.trim().is_empty() { + return Err("HOLOLAKE_GLP_ENVELOPE_INCOMPLETE".into()); + } + if message.sender.object_id.trim().is_empty() || message.sender.object_type.trim().is_empty() { + return Err("HOLOLAKE_GLP_SENDER_INCOMPLETE".into()); + } + if message.receiver.object_id.trim().is_empty() + || message.receiver.object_type.trim().is_empty() + { + return Err("HOLOLAKE_GLP_RECEIVER_INCOMPLETE".into()); + } + if message.payload.content.is_empty() { + return Err("HOLOLAKE_GLP_PAYLOAD_EMPTY".into()); + } + Ok(()) +} + +/// 消息编号按老家谱格式生成:GLP-MSG-YYYYMMDD-000001。 +/// 序号由账本(管家层)当日累计给出,这里只拼形状。 +pub fn build_message_id(date_compact: &str, daily_sequence: u64) -> String { + format!("GLP-MSG-{date_compact}-{daily_sequence:06}") +} + +/// ISO-8601 近似时刻戳(秒级,UTC)——老家谱要 ISO-8601,工程给秒级事实。 +pub fn now_iso8601() -> String { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + let days = secs / 86_400; + let rem = secs % 86_400; + let (hours, minutes, seconds) = (rem / 3_600, (rem % 3_600) / 60, rem % 60); + let (year, month, day) = civil_from_days(days as i64); + format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z") +} + +/// 1970-01-01 起的天数转公历年月日(Howard Hinnant 算法,纯本地实现不引新依赖)。 +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_envelope() -> GlpMessage { + GlpMessage { + protocol: "GLP/1.0".into(), + message_id: build_message_id("20260815", 1), + message_type: "DIRECT".into(), + created_at: now_iso8601(), + sender: GlpSender { + object_id: "AGE-0001".into(), + object_type: "age".into(), + world_path: "glw://tolaria".into(), + }, + receiver: GlpReceiver { + object_id: "HOLOLAKE-HOST".into(), + object_type: "host".into(), + routing_mode: RoutingMode::Direct, + }, + context: GlpContext { + task_id: "ZY-TEST".into(), + hldp_anchor: "ZY-CHECKPOINT-20260815-014".into(), + ..Default::default() + }, + payload: GlpPayload { + language: "zh-CN".into(), + content_type: ContentType::Command, + content: "开工".into(), + attachments: vec![], + }, + control: GlpControl { + priority: Priority::Normal, + ack_required: true, + receipt_required: true, + expires_at: String::new(), + retry_policy: RetryPolicy::None, + }, + integrity: GlpIntegrity::default(), + } + } + + #[test] + fn envelope_round_trip_keeps_every_field() { + let message = sample_envelope(); + let json = serde_json::to_string(&message).unwrap(); + let back: GlpMessage = serde_json::from_str(&json).unwrap(); + assert_eq!(back.protocol, "GLP/1.0"); + assert_eq!(back.sender.object_id, "AGE-0001"); + assert_eq!(back.receiver.routing_mode, RoutingMode::Direct); + assert_eq!(back.payload.content_type, ContentType::Command); + assert_eq!(back.context.hldp_anchor, "ZY-CHECKPOINT-20260815-014"); + assert!(back.control.receipt_required); + validate_envelope(&back).unwrap(); + } + + #[test] + fn message_id_follows_family_format() { + assert_eq!(build_message_id("20260815", 7), "GLP-MSG-20260815-000007"); + } + + #[test] + fn bad_protocol_is_rejected() { + let mut message = sample_envelope(); + message.protocol = "GLP/0.9".into(); + assert!(validate_envelope(&message).is_err()); + } + + #[test] + fn empty_payload_is_rejected() { + let mut message = sample_envelope(); + message.payload.content = String::new(); + assert!(validate_envelope(&message).is_err()); + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs b/product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs new file mode 100644 index 000000000..9d65460b3 --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs @@ -0,0 +1,1390 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Clean-room native migration of the HoloLake Era knowledge-workspace contract. + +use ring::digest::{digest, SHA256}; +use serde::{Deserialize, Serialize}; +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, MessageDialogButtons, MessageDialogKind}; +use uuid::Uuid; + +const SNAPSHOT_SCHEMA: &str = "hololake.native-knowledge-base/v1"; +const MAX_TREE_DOCUMENTS: usize = 5_000; +const MAX_IMPORT_FILES: usize = 1_000; +const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024; +const MAX_READ_BYTES: u64 = 2 * 1024 * 1024; +const MAX_SEARCH_RESULTS: usize = 100; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeDocumentSummary { + pub source: &'static str, + pub path: String, + pub title: String, + pub updated_at_unix_ms: u128, + pub size_bytes: u64, + pub content_sha256: String, + pub duplicate_count: usize, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeSnapshot { + pub schema: &'static str, + pub state: &'static str, + pub native_root: String, + pub legacy_available: bool, + pub legacy_root: Option, + pub documents: Vec, + pub raw_document_count: usize, + pub unique_document_count: usize, + pub duplicate_document_count: usize, + pub truncated: bool, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReadKnowledgeDocumentInput { + pub source: String, + pub path: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeDocument { + pub source: String, + pub path: String, + pub title: String, + pub body: String, + pub updated_at_unix_ms: u128, + pub content_sha256: String, + pub writable: bool, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SaveKnowledgeDocumentInput { + pub path: String, + pub body: String, + pub expected_content_sha256: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeSaveResult { + pub schema: &'static str, + pub state: &'static str, + pub git_commit: String, + pub document: KnowledgeDocument, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SearchKnowledgeInput { + pub query: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeSearchResult { + pub source: &'static str, + pub path: String, + pub title: String, + pub snippet: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeImportResult { + pub schema: &'static str, + pub state: &'static str, + pub source_name: String, + pub destination: String, + pub imported_documents: usize, + pub imported_assets: usize, + pub existing_documents: usize, + pub conflicts: usize, + pub skipped: usize, + pub failed: usize, + pub first_document: Option, + pub git_commit: String, + pub snapshot: KnowledgeSnapshot, +} + +#[derive(Default)] +struct ImportCounts { + documents: usize, + assets: usize, + existing: usize, + conflicts: usize, + skipped: usize, + failed: usize, + first_document: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ImportDisposition { + Added, + Existing, + Conflict, +} + +#[tauri::command] +pub async fn get_knowledge_snapshot(app: AppHandle) -> Result { + let roots = knowledge_roots(&app)?; + tauri::async_runtime::spawn_blocking(move || snapshot_at(&roots.0, roots.1.as_deref())) + .await + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn read_knowledge_document( + app: AppHandle, + input: ReadKnowledgeDocumentInput, +) -> Result { + let roots = knowledge_roots(&app)?; + tauri::async_runtime::spawn_blocking(move || { + read_document_at(&roots.0, roots.1.as_deref(), input) + }) + .await + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn search_knowledge( + app: AppHandle, + input: SearchKnowledgeInput, +) -> Result, String> { + let roots = knowledge_roots(&app)?; + tauri::async_runtime::spawn_blocking(move || search_at(&roots.0, roots.1.as_deref(), input)) + .await + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn save_knowledge_document( + app: AppHandle, + input: SaveKnowledgeDocumentInput, +) -> Result { + let roots = knowledge_roots(&app)?; + tauri::async_runtime::spawn_blocking(move || save_document_at(&roots.0, input)) + .await + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))? +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportKnowledgeDocumentInput { + pub title: String, + pub body: String, + #[serde(default = "default_export_extension")] + pub extension: String, +} + +fn default_export_extension() -> String { + "md".to_string() +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportKnowledgeDocumentReceipt { + pub path: String, + pub bytes: u64, +} + +#[tauri::command] +pub async fn export_knowledge_document( + app: AppHandle, + input: ExportKnowledgeDocumentInput, +) -> Result, String> { + // 下载本页:Rust 侧弹保存对话框,人选好位置后原样写出(md/html 全平台通用格式)。 + let extension = match input.extension.as_str() { + "html" => "html", + _ => "md", + }; + // 文件名清洗按最严平台(Windows)来:九个禁用字符全洗,结尾的点号空格也修。 + let mut suggested = input + .title + .replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "·"); + suggested = suggested.trim_end_matches(['.', ' ']).to_string(); + if suggested.trim().is_empty() { + suggested = "知识页".to_string(); + } + // Windows 保留设备名(CON/NUL/COM1…)不能直接当文件名,撞名就补个尾巴。 + if matches!(suggested.to_ascii_uppercase().as_str(), + "CON" | "PRN" | "AUX" | "NUL" + | "COM1" | "COM2" | "COM3" | "COM4" | "COM5" | "COM6" | "COM7" | "COM8" | "COM9" + | "LPT1" | "LPT2" | "LPT3" | "LPT4" | "LPT5" | "LPT6" | "LPT7" | "LPT8" | "LPT9") { + suggested.push_str("·页"); + } + suggested.push('.'); + suggested.push_str(extension); + let filter_label = if extension == "html" { "网页文件" } else { "Markdown" }; + let (sender, receiver) = std::sync::mpsc::channel::>(); + app.dialog() + .file() + .set_file_name(&suggested) + .add_filter(filter_label, &[extension]) + .save_file(move |selection| { + let picked = selection.as_ref().and_then(|path| path.as_path()).map(|path| path.to_path_buf()); + let _ = sender.send(picked); + }); + let picked = tauri::async_runtime::spawn_blocking(move || receiver.recv().ok().flatten()) + .await + .map_err(|error| format!("HOLOLAKE_EXPORT_JOIN_FAILED: {error}"))?; + let Some(target) = picked else { + return Ok(None); + }; + let bytes = input.body.len() as u64; + let path = target.display().to_string(); + tauri::async_runtime::spawn_blocking(move || { + fs::write(&target, input.body.as_bytes()) + .map_err(|error| format!("HOLOLAKE_EXPORT_WRITE_FAILED: {error}")) + }) + .await + .map_err(|error| format!("HOLOLAKE_EXPORT_JOIN_FAILED: {error}"))??; + Ok(Some(ExportKnowledgeDocumentReceipt { path, bytes })) +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteKnowledgeDocumentInput { + pub source: String, + pub path: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeDeleteReceipt { + pub removed: String, + pub pages: usize, +} + +fn resolve_native_path(root: &Path, relative: &str) -> Result { + let mut target = root.to_path_buf(); + for component in Path::new(relative).components() { + match component { + Component::Normal(part) => target.push(part), + _ => return Err("HOLOLAKE_KNOWLEDGE_PATH_INVALID".into()), + } + } + Ok(target) +} + +async fn confirm_destructive(app: &AppHandle, message: String) -> Result { + let confirmer = app.clone(); + let confirmed = tauri::async_runtime::spawn_blocking(move || { + confirmer + .dialog() + .message(message) + .title("删除确认") + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::OkCancelCustom("确认删除".to_string(), "取消".to_string())) + .blocking_show() + }) + .await + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CONFIRM_JOIN_FAILED: {error}"))?; + Ok(confirmed) +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateKnowledgeDocumentInput { + pub title: String, +} + +#[tauri::command] +pub async fn create_knowledge_document( + app: AppHandle, + input: CreateKnowledgeDocumentInput, +) -> Result { + // 新建空白页:标题撞名自动补序号,落进本机库并进 Git 托管。 + let roots = knowledge_roots(&app)?; + let base = if input.title.trim().is_empty() { "未命名页面".to_string() } else { input.title.trim().to_string() }; + let docs = roots.0.join("docs"); + let mut name = base.clone(); + let mut counter = 2u32; + while docs.join(format!("{name}.md")).exists() { + name = format!("{base} {counter}"); + counter += 1; + } + let target = docs.join(format!("{name}.md")); + let body = format!("# {name}\n\n"); + let receipt_name = name.clone(); + tauri::async_runtime::spawn_blocking(move || { + let mut file = private_file_options() + .open(&target) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_FAILED: {error}"))?; + file.write_all(body.as_bytes()) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_FAILED: {error}"))?; + file.sync_all().map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_FAILED: {error}"))?; + git(&roots.0, &["add", "--", &format!("docs/{name}.md")], "ADD")?; + git(&roots.0, &["commit", "-m", &format!("新建页面:{name}")], "COMMIT")?; + Ok::<(), String>(()) + }) + .await + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_JOIN_FAILED: {error}"))??; + Ok(KnowledgeDeleteReceipt { removed: format!("{receipt_name}.md"), pages: 1 }) +} + +#[tauri::command] +pub async fn delete_knowledge_document( + app: AppHandle, + input: DeleteKnowledgeDocumentInput, +) -> Result, String> { + // 只删本机库(native);供体源只读,不碰。 + if input.source != "native" { + return Err("HOLOLAKE_KNOWLEDGE_DELETE_READ_ONLY_SOURCE".into()); + } + let roots = knowledge_roots(&app)?; + let target = resolve_native_path(&roots.0, &input.path)?; + if !target.is_file() { + return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_NOT_FOUND".into()); + } + let title = target + .file_stem() + .and_then(OsStr::to_str) + .unwrap_or("这一页") + .to_string(); + if !confirm_destructive(&app, format!("把页面「{title}」移到回收站?随时可以找回。")).await? { + return Ok(None); + } + move_to_trash(&roots.0, &target)?; + Ok(Some(KnowledgeDeleteReceipt { removed: input.path, pages: 1 })) +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteKnowledgeFolderInput { + pub folder: String, +} + +#[tauri::command] +pub async fn delete_knowledge_folder( + app: AppHandle, + input: DeleteKnowledgeFolderInput, +) -> Result, String> { + // 删整个文件夹 = 删本机库里该相对目录及其全部页面。 + let roots = knowledge_roots(&app)?; + let target = resolve_native_path(&roots.0, &input.folder)?; + if target == roots.0 || !target.is_dir() { + return Err("HOLOLAKE_KNOWLEDGE_FOLDER_NOT_FOUND".into()); + } + let name = target + .file_name() + .and_then(OsStr::to_str) + .unwrap_or(&input.folder) + .to_string(); + let pages = fs::read_dir(&target) + .map(|entries| { + entries + .flatten() + .filter(|entry| entry.file_type().map(|t| t.is_file()).unwrap_or(false)) + .count() + }) + .unwrap_or(0); + if !confirm_destructive(&app, format!("把文件夹「{name}」和里面全部 {pages} 个页面移到回收站?随时可以找回。")).await? { + return Ok(None); + } + move_to_trash(&roots.0, &target)?; + Ok(Some(KnowledgeDeleteReceipt { removed: input.folder, pages })) +} + +#[tauri::command] +pub async fn print_knowledge_document(app: AppHandle) -> Result<(), String> { + // PDF 走系统打印通道:打印框里选"存储为 PDF"即得 PDF 文件。 + let window = app + .get_webview_window("main") + .ok_or_else(|| "HOLOLAKE_PRINT_WINDOW_NOT_FOUND".to_string())?; + window.print().map_err(|error| format!("HOLOLAKE_PRINT_FAILED: {error}")) +} + +#[tauri::command] +pub async fn select_and_import_knowledge_folder( + app: AppHandle, +) -> Result, String> { + let picker = app.clone(); + let selected = tauri::async_runtime::spawn_blocking(move || { + picker + .dialog() + .file() + .set_title("导入到 HoloLake 知识库") + .blocking_pick_folder() + }) + .await + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_PICKER_JOIN_FAILED: {error}"))?; + let Some(selected) = selected else { + return Ok(None); + }; + 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) + }) + .await + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_JOIN_FAILED: {error}"))? +} + +fn knowledge_roots(app: &AppHandle) -> Result<(PathBuf, Option), String> { + let native = app + .path() + .app_data_dir() + .map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))? + .join("knowledge-v1"); + ensure_native_knowledge_root(&native)?; + let legacy = dirs::home_dir() + .map(|home| { + home.join("Library") + .join("Application Support") + .join("hololake-desktop") + .join("data") + .join("knowledge-base") + }) + .filter(|path| path.join("docs").is_dir()); + Ok((native, legacy)) +} + +/// 写私有文件的跨平台选项:unix 上只许本人读写,Windows 上不做额外权限设置。 +fn private_file_options() -> OpenOptions { + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + options.mode(0o600); + options +} + +/// 删除 = 搬到库内回收站(.trash),绝不真删;名字带时间戳防撞车。 +fn move_to_trash(root: &Path, target: &Path) -> Result { + let relative = target + .strip_prefix(root) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DELETE_FAILED: {error}"))?; + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + let destination = root.join(".trash").join(format!("{}-{stamp}", relative.display())); + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DELETE_FAILED: {error}"))?; + } + fs::rename(target, &destination) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DELETE_FAILED: {error}"))?; + Ok(destination) +} + +fn ensure_native_knowledge_root(root: &Path) -> Result<(), String> { + fs::create_dir_all(root.join("docs").join("导入")) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_STORAGE_UNAVAILABLE: {error}"))?; + #[cfg(unix)] + fs::set_permissions(root, fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_PERMISSION_FAILED: {error}"))?; + if !root.join(".git").exists() { + git(root, &["init", "--initial-branch=main"], "INIT")?; + git(root, &["config", "user.name", "HoloLake"], "CONFIG")?; + git( + root, + &["config", "user.email", "local@hololake.invalid"], + "CONFIG", + )?; + } + Ok(()) +} + +fn snapshot_at(native: &Path, legacy: Option<&Path>) -> Result { + let mut raw_documents = Vec::new(); + collect_documents( + &native.join("docs"), + &native.join("docs"), + "native", + &mut raw_documents, + )?; + if let Some(root) = legacy { + collect_documents( + &root.join("docs"), + &root.join("docs"), + "legacy", + &mut raw_documents, + )?; + } + let raw_document_count = raw_documents.len(); + let mut by_hash = HashMap::::new(); + let mut documents = Vec::::new(); + for document in raw_documents { + if let Some(index) = by_hash.get(&document.content_sha256).copied() { + documents[index].duplicate_count += 1; + } else { + by_hash.insert(document.content_sha256.clone(), documents.len()); + documents.push(document); + } + } + documents.sort_by(|left, right| { + left.source + .cmp(right.source) + .then_with(|| left.path.cmp(&right.path)) + }); + let truncated = raw_document_count >= MAX_TREE_DOCUMENTS; + documents.truncate(MAX_TREE_DOCUMENTS); + let unique_document_count = documents.len(); + Ok(KnowledgeSnapshot { + schema: SNAPSHOT_SCHEMA, + state: "READY", + native_root: native.to_string_lossy().into_owned(), + legacy_available: legacy.is_some(), + legacy_root: legacy.map(|path| path.to_string_lossy().into_owned()), + documents, + raw_document_count, + unique_document_count, + duplicate_document_count: raw_document_count.saturating_sub(unique_document_count), + truncated, + }) +} + +fn collect_documents( + root: &Path, + current: &Path, + source: &'static str, + output: &mut Vec, +) -> Result<(), String> { + if output.len() >= MAX_TREE_DOCUMENTS { + return Ok(()); + } + let mut entries = fs::read_dir(current) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_TREE_UNAVAILABLE: {error}"))? + .collect::, _>>() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_TREE_UNAVAILABLE: {error}"))?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + if output.len() >= MAX_TREE_DOCUMENTS { + break; + } + let kind = entry + .file_type() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_TREE_UNAVAILABLE: {error}"))?; + if kind.is_symlink() { + continue; + } + let path = entry.path(); + let name = entry.file_name(); + if kind.is_dir() { + if name != OsStr::new(".git") && name != OsStr::new(".hololake") { + collect_documents(root, &path, source, output)?; + } + continue; + } + if !kind.is_file() || !is_readable_document(&path) { + continue; + } + let metadata = entry + .metadata() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_TREE_UNAVAILABLE: {error}"))?; + if metadata.len() > MAX_READ_BYTES { + continue; + } + let relative = relative_posix(root, &path)?; + let bytes = fs::read(&path) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?; + output.push(KnowledgeDocumentSummary { + source, + path: relative, + title: document_title(&path)?, + updated_at_unix_ms: modified_unix_ms(&metadata), + size_bytes: metadata.len(), + content_sha256: sha256_hex(&bytes), + duplicate_count: 0, + }); + } + Ok(()) +} + +fn read_document_at( + native: &Path, + legacy: Option<&Path>, + input: ReadKnowledgeDocumentInput, +) -> Result { + let root = source_docs_root(native, legacy, &input.source)?; + let path = safe_document_path(&root, &input.path)?; + let metadata = fs::metadata(&path) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?; + if !metadata.is_file() || metadata.len() > MAX_READ_BYTES || !is_readable_document(&path) { + return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNSUPPORTED".into()); + } + let body = fs::read_to_string(&path) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_INVALID_UTF8: {error}"))?; + let content_sha256 = sha256_hex(body.as_bytes()); + let writable = input.source == "native"; + Ok(KnowledgeDocument { + source: input.source, + path: input.path, + title: title_from_text(&body, &path), + body, + updated_at_unix_ms: modified_unix_ms(&metadata), + content_sha256, + writable, + }) +} + +fn save_document_at( + native: &Path, + input: SaveKnowledgeDocumentInput, +) -> Result { + if input.body.len() as u64 > MAX_READ_BYTES + || input.expected_content_sha256.len() != 64 + || !input + .expected_content_sha256 + .chars() + .all(|character| character.is_ascii_hexdigit() && !character.is_ascii_uppercase()) + { + return Err("HOLOLAKE_KNOWLEDGE_SAVE_INPUT_INVALID".into()); + } + let root = native.join("docs"); + let path = safe_document_path(&root, &input.path)?; + if !is_readable_document(&path) { + return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNSUPPORTED".into()); + } + let previous = fs::read(&path) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?; + if sha256_hex(&previous) != input.expected_content_sha256 { + return Err("HOLOLAKE_KNOWLEDGE_SAVE_CONFLICT".into()); + } + if previous != input.body.as_bytes() { + let temporary = path.with_extension(format!("save-{}.tmp", Uuid::new_v4())); + let mut file = private_file_options() + .open(&temporary) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_SAVE_FAILED: {error}"))?; + file.write_all(input.body.as_bytes()) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_SAVE_FAILED: {error}"))?; + fs::rename(&temporary, &path) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_SAVE_FAILED: {error}"))?; + git( + native, + &["add", "--", &format!("docs/{}", input.path)], + "ADD", + )?; + git( + native, + &["commit", "-m", &format!("knowledge: update {}", input.path)], + "COMMIT", + )?; + } + let git_commit = git(native, &["rev-parse", "HEAD"], "READBACK")? + .trim() + .to_string(); + let document = read_document_at( + native, + None, + ReadKnowledgeDocumentInput { + source: "native".into(), + path: input.path, + }, + )?; + Ok(KnowledgeSaveResult { + schema: "hololake.knowledge-document-save/v1", + state: if previous == input.body.as_bytes() { + "UNCHANGED" + } else { + "SAVED" + }, + git_commit, + document, + }) +} + +fn search_at( + native: &Path, + legacy: Option<&Path>, + input: SearchKnowledgeInput, +) -> Result, String> { + let query = input.query.trim().to_lowercase(); + if query.is_empty() || query.chars().count() > 200 { + return Err("HOLOLAKE_KNOWLEDGE_SEARCH_QUERY_INVALID".into()); + } + let snapshot = snapshot_at(native, legacy)?; + let mut results = Vec::new(); + for document in snapshot.documents { + if results.len() >= MAX_SEARCH_RESULTS { + break; + } + let root = source_docs_root(native, legacy, document.source)?; + let path = safe_document_path(&root, &document.path)?; + let body = fs::read_to_string(&path).unwrap_or_default(); + let haystack = format!("{}\n{}", document.title, body).to_lowercase(); + let Some(index) = haystack.find(&query) else { + continue; + }; + let start = haystack[..index] + .char_indices() + .rev() + .nth(40) + .map(|(position, _)| position) + .unwrap_or(0); + let end = haystack[index..] + .char_indices() + .nth(120) + .map(|(position, _)| index + position) + .unwrap_or(haystack.len()); + results.push(KnowledgeSearchResult { + source: document.source, + path: document.path, + title: document.title, + snippet: haystack[start..end].replace('\n', " "), + }); + } + Ok(results) +} + +fn import_folder_at( + native: &Path, + legacy: Option<&Path>, + source: &Path, +) -> Result { + let source = source + .canonicalize() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_SOURCE_UNAVAILABLE: {error}"))?; + if !source.is_dir() || source.starts_with(native) { + return Err("HOLOLAKE_KNOWLEDGE_IMPORT_SOURCE_INVALID".into()); + } + let source_name = safe_segment( + source + .file_name() + .and_then(OsStr::to_str) + .unwrap_or("未命名文件夹"), + ); + let import_root = native.join("docs").join("导入"); + let destination_name = source_name.clone(); + let destination = import_root.join(&destination_name); + let destination_exists = destination.exists(); + let staging = if destination_exists { + destination.clone() + } else { + let path = + native + .join(".import-staging") + .join(format!("{}-{}", destination_name, Uuid::new_v4())); + fs::create_dir_all(&path) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; + path + }; + let mut counts = ImportCounts::default(); + let import_result = walk_import(&source, &source, &staging, &destination_name, &mut counts); + if let Err(error) = import_result { + if !destination_exists { + let _ = fs::remove_dir_all(&staging); + } + return Err(error); + } + if counts.documents == 0 && counts.assets == 0 && counts.existing == 0 && counts.conflicts == 0 + { + if !destination_exists { + let _ = fs::remove_dir_all(&staging); + } + return Err("HOLOLAKE_KNOWLEDGE_IMPORT_NO_SUPPORTED_FILES".into()); + } + if !destination_exists { + fs::rename(&staging, &destination) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_COMMIT_FAILED: {error}"))?; + } + let relative_destination = format!("docs/导入/{destination_name}"); + if counts.documents + counts.assets > 0 { + if let Err(error) = + git(native, &["add", "--", &relative_destination], "ADD").and_then(|_| { + git( + native, + &["commit", "-m", &format!("import: {source_name}")], + "COMMIT", + ) + }) + { + if !destination_exists { + let _ = fs::remove_dir_all(&destination); + } + let _ = git(native, &["reset", "--mixed"], "ROLLBACK_INDEX"); + return Err(error); + } + } + let git_commit = git(native, &["rev-parse", "HEAD"], "READBACK")? + .trim() + .to_string(); + Ok(KnowledgeImportResult { + schema: "hololake.knowledge-folder-import/v1", + state: if counts.conflicts > 0 { + "CONFLICTS_DETECTED" + } else if counts.documents + counts.assets == 0 { + "ALREADY_PRESENT" + } else { + "IMPORTED" + }, + source_name, + destination: format!("导入/{destination_name}"), + imported_documents: counts.documents, + imported_assets: counts.assets, + existing_documents: counts.existing, + conflicts: counts.conflicts, + skipped: counts.skipped, + failed: counts.failed, + first_document: counts.first_document, + git_commit, + snapshot: snapshot_at(native, legacy)?, + }) +} + +fn walk_import( + root: &Path, + current: &Path, + staging: &Path, + destination_name: &str, + counts: &mut ImportCounts, +) -> Result<(), String> { + if counts.documents + counts.assets + counts.skipped + counts.failed >= MAX_IMPORT_FILES { + return Ok(()); + } + let mut entries = fs::read_dir(current) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_READ_FAILED: {error}"))? + .collect::, _>>() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_READ_FAILED: {error}"))?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + if counts.documents + counts.assets + counts.skipped + counts.failed >= MAX_IMPORT_FILES { + break; + } + let name = entry.file_name(); + if name == OsStr::new(".DS_Store") || name.to_string_lossy().starts_with("._") { + continue; + } + let kind = entry + .file_type() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_READ_FAILED: {error}"))?; + if kind.is_symlink() { + counts.skipped += 1; + continue; + } + let path = entry.path(); + if kind.is_dir() { + let lower = name.to_string_lossy().to_lowercase(); + if [".git", "node_modules", ".idea", ".vscode", "__macosx"].contains(&lower.as_str()) { + counts.skipped += 1; + continue; + } + walk_import(root, &path, staging, destination_name, counts)?; + continue; + } + if !kind.is_file() { + counts.skipped += 1; + continue; + } + let metadata = entry + .metadata() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_READ_FAILED: {error}"))?; + if metadata.len() > MAX_FILE_BYTES { + counts.skipped += 1; + continue; + } + let relative = path + .strip_prefix(root) + .map_err(|_| "HOLOLAKE_KNOWLEDGE_IMPORT_PATH_INVALID".to_string())?; + let safe_relative = relative + .components() + .filter_map(|component| match component { + Component::Normal(value) => Some(safe_segment(&value.to_string_lossy())), + _ => None, + }) + .collect::(); + let extension = path + .extension() + .and_then(OsStr::to_str) + .unwrap_or("") + .to_lowercase(); + let imported = if ["md", "markdown", "txt", "csv", "json", "yaml", "yml"] + .contains(&extension.as_str()) + { + import_document(&path, &safe_relative, staging).map(|(target, disposition)| { + match disposition { + ImportDisposition::Added => counts.documents += 1, + ImportDisposition::Existing => counts.existing += 1, + ImportDisposition::Conflict => counts.conflicts += 1, + } + if disposition != ImportDisposition::Conflict { + counts.first_document.get_or_insert_with(|| { + format!( + "导入/{}/{}", + destination_name, + target.to_string_lossy().replace('\\', "/") + ) + }); + } + }) + } else if ["png", "jpg", "jpeg", "gif", "webp", "svg"].contains(&extension.as_str()) { + let target = staging.join(&safe_relative); + import_asset(&path, &target).map(|disposition| match disposition { + ImportDisposition::Added => counts.assets += 1, + ImportDisposition::Existing => counts.existing += 1, + ImportDisposition::Conflict => counts.conflicts += 1, + }) + } else { + counts.skipped += 1; + continue; + }; + if imported.is_err() { + counts.failed += 1; + } + } + Ok(()) +} + +/// 外来页面进门即洗:只留文字,外壳全换成光湖原生 Markdown。 +/// 原有知识库的渲染层与页面跳转关系在此失效(冰朔 2026-08-15 谕)。 +fn convert_to_native_markdown(raw: &str) -> String { + let text = raw.replace("\r\n", "\n"); + let mut out = String::with_capacity(text.len()); + for line in text.split('\n') { + let trimmed = line.trim(); + if trimmed == "" { + continue; + } + if let Some(title) = trimmed.strip_prefix(":::toggle") { + let title = title.trim(); + out.push_str("**▸ "); + if title.is_empty() { + out.push_str("详情"); + } else { + out.push_str(title); + } + out.push_str("**\n"); + continue; + } + if trimmed == ":::" || trimmed.starts_with(":::toc") { + continue; + } + let cleaned = line + .replace("
", "\n") + .replace("
", "\n") + .replace("
", "\n"); + out.push_str(&strip_notion_links(&cleaned)); + out.push('\n'); + } + out +} + +/// Notion 页面链接 → 只留链接文字(跳转关系按谕旨失效)。 +fn strip_notion_links(line: &str) -> String { + let bytes: Vec = line.chars().collect(); + let mut out = String::new(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == '[' { + if let Some(close) = bytes[i + 1..].iter().position(|c| *c == ']') { + let text_end = i + 1 + close; + if text_end + 1 < bytes.len() && bytes[text_end + 1] == '(' { + if let Some(paren_end) = bytes[text_end + 2..].iter().position(|c| *c == ')') { + let href: String = bytes[text_end + 2..text_end + 2 + paren_end].iter().collect(); + if href.contains("notion.so") || href.contains("notion.site") { + out.extend(bytes[i + 1..text_end].iter()); + i = text_end + 2 + paren_end + 1; + continue; + } + } + } + } + } + out.push(bytes[i]); + i += 1; + } + out +} + +fn import_document( + source: &Path, + relative: &Path, + staging: &Path, +) -> Result<(PathBuf, ImportDisposition), String> { + let extension = source + .extension() + .and_then(OsStr::to_str) + .unwrap_or("") + .to_lowercase(); + let raw = fs::read_to_string(source) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_DOCUMENT_INVALID: {error}"))?; + let mut target_relative = relative.to_path_buf(); + if extension != "md" { + target_relative.set_extension("md"); + } + let title = source + .file_stem() + .and_then(OsStr::to_str) + .unwrap_or("未命名页面"); + let content = match extension.as_str() { + "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"), + "yaml" | "yml" => format!("# {title}\n\n```yaml\n{raw}\n```\n"), + _ => return Err("HOLOLAKE_KNOWLEDGE_IMPORT_DOCUMENT_UNSUPPORTED".into()), + }; + let target = staging.join(&target_relative); + fs::create_dir_all(target.parent().unwrap_or(staging)) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; + if target.exists() { + let existing = fs::read(&target) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; + return Ok(( + target_relative, + if existing == content.as_bytes() { + ImportDisposition::Existing + } else { + ImportDisposition::Conflict + }, + )); + } + let mut file = private_file_options() + .open(&target) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; + file.write_all(content.as_bytes()) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; + file.sync_all() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; + Ok((target_relative, ImportDisposition::Added)) +} + +fn import_asset(source: &Path, target: &Path) -> Result { + let bytes = fs::read(source) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_ASSET_INVALID: {error}"))?; + if target.exists() { + let existing = fs::read(target) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; + return Ok(if existing == bytes { + ImportDisposition::Existing + } else { + ImportDisposition::Conflict + }); + } + fs::create_dir_all(target.parent().unwrap_or_else(|| Path::new("."))) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; + let mut file = private_file_options() + .open(target) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; + Ok(ImportDisposition::Added) +} + +fn sha256_hex(bytes: &[u8]) -> String { + digest(&SHA256, bytes) + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn source_docs_root(native: &Path, legacy: Option<&Path>, source: &str) -> Result { + match source { + "native" => Ok(native.join("docs")), + "legacy" => legacy + .map(|root| root.join("docs")) + .ok_or_else(|| "HOLOLAKE_LEGACY_KNOWLEDGE_UNAVAILABLE".into()), + _ => Err("HOLOLAKE_KNOWLEDGE_SOURCE_INVALID".into()), + } +} + +fn safe_document_path(root: &Path, relative: &str) -> Result { + let relative_path = Path::new(relative); + if relative_path.is_absolute() + || relative_path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_PATH_INVALID".into()); + } + let canonical_root = root + .canonicalize() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ROOT_UNAVAILABLE: {error}"))?; + let candidate = canonical_root.join(relative_path); + let canonical = candidate + .canonicalize() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?; + if !canonical.starts_with(&canonical_root) { + return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_PATH_ESCAPE".into()); + } + let metadata = fs::symlink_metadata(&candidate) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?; + if metadata.file_type().is_symlink() { + return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_SYMLINK_DENIED".into()); + } + Ok(canonical) +} + +fn is_readable_document(path: &Path) -> bool { + matches!( + path.extension() + .and_then(OsStr::to_str) + .unwrap_or("") + .to_lowercase() + .as_str(), + "md" | "markdown" | "txt" + ) +} + +fn document_title(path: &Path) -> Result { + let body = fs::read_to_string(path) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_INVALID_UTF8: {error}"))?; + Ok(title_from_text(&body, path)) +} + +fn title_from_text(body: &str, path: &Path) -> String { + body.lines() + .find_map(|line| line.trim().strip_prefix("# ").map(str::trim)) + .filter(|title| !title.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| { + path.file_stem() + .and_then(OsStr::to_str) + .unwrap_or("未命名页面") + .to_string() + }) +} + +fn safe_segment(value: &str) -> String { + let cleaned = value + .chars() + .map(|character| { + if character.is_control() || "\\/:*?\"<>|".contains(character) { + '-' + } else { + character + } + }) + .collect::() + .trim() + .to_string(); + if cleaned.is_empty() { + "未命名文件夹".into() + } else { + cleaned + } +} + +fn relative_posix(root: &Path, path: &Path) -> Result { + Ok(path + .strip_prefix(root) + .map_err(|_| "HOLOLAKE_KNOWLEDGE_DOCUMENT_PATH_INVALID".to_string())? + .to_string_lossy() + .replace('\\', "/")) +} + +fn modified_unix_ms(metadata: &fs::Metadata) -> u128 { + metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis()) + .unwrap_or(0) +} + +fn git(root: &Path, args: &[&str], operation: &str) -> Result { + let output = Command::new("/usr/bin/git") + .current_dir(root) + .env("GIT_TERMINAL_PROMPT", "0") + .args(args) + .output() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_GIT_{operation}_FAILED: {error}"))?; + if !output.status.success() { + return Err(format!( + "HOLOLAKE_KNOWLEDGE_GIT_{operation}_FAILED: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + String::from_utf8(output.stdout) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_GIT_{operation}_INVALID_UTF8: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn snapshot_reads_native_and_legacy_without_merging_roots() { + let native = tempdir().unwrap(); + let legacy = tempdir().unwrap(); + ensure_native_knowledge_root(native.path()).unwrap(); + fs::create_dir_all(legacy.path().join("docs")).unwrap(); + fs::write(native.path().join("docs/native.md"), "# 原生页面\n").unwrap(); + fs::write(legacy.path().join("docs/legacy.md"), "# 旧版页面\n").unwrap(); + let snapshot = snapshot_at(native.path(), Some(legacy.path())).unwrap(); + assert_eq!(snapshot.documents.len(), 2); + assert!(snapshot + .documents + .iter() + .any(|item| item.source == "native")); + assert!(snapshot + .documents + .iter() + .any(|item| item.source == "legacy")); + } + + #[test] + fn document_path_escape_is_rejected() { + let native = tempdir().unwrap(); + ensure_native_knowledge_root(native.path()).unwrap(); + let error = read_document_at( + native.path(), + None, + ReadKnowledgeDocumentInput { + source: "native".into(), + path: "../secret.md".into(), + }, + ) + .unwrap_err(); + assert!(error.contains("PATH_INVALID")); + } + + #[test] + fn folder_import_is_git_backed_and_converts_text() { + let native = tempdir().unwrap(); + let source = tempdir().unwrap(); + ensure_native_knowledge_root(native.path()).unwrap(); + fs::write(source.path().join("readme.txt"), "hello").unwrap(); + fs::write(source.path().join("skip.bin"), [1, 2, 3]).unwrap(); + let result = import_folder_at(native.path(), None, source.path()).unwrap(); + assert_eq!(result.state, "IMPORTED"); + assert_eq!(result.imported_documents, 1); + assert_eq!(result.skipped, 1); + assert_eq!(result.git_commit.len(), 40); + let imported = native + .path() + .join("docs") + .join(&result.destination) + .join("readme.md"); + assert!(fs::read_to_string(imported).unwrap().contains("# readme")); + } + + #[test] + fn repeated_folder_import_is_idempotent_and_does_not_create_another_tree() { + let native = tempdir().unwrap(); + let source_parent = tempdir().unwrap(); + let source = source_parent.path().join("稳定知识源"); + fs::create_dir_all(&source).unwrap(); + ensure_native_knowledge_root(native.path()).unwrap(); + fs::write(source.join("page.md"), "# 唯一页面\n\n连续内容\n").unwrap(); + + let first = import_folder_at(native.path(), None, &source).unwrap(); + let second = import_folder_at(native.path(), None, &source).unwrap(); + + assert_eq!(first.state, "IMPORTED"); + assert_eq!(second.state, "ALREADY_PRESENT"); + assert_eq!(second.imported_documents, 0); + assert_eq!(second.existing_documents, 1); + assert_eq!(first.destination, second.destination); + assert_eq!(second.snapshot.raw_document_count, 1); + assert_eq!(second.snapshot.unique_document_count, 1); + assert_eq!( + fs::read_dir(native.path().join("docs/导入")) + .unwrap() + .count(), + 1 + ); + } + + #[test] + fn snapshot_coalesces_exact_content_and_prefers_native_source() { + let native = tempdir().unwrap(); + let legacy = tempdir().unwrap(); + ensure_native_knowledge_root(native.path()).unwrap(); + fs::create_dir_all(legacy.path().join("docs")).unwrap(); + fs::write(native.path().join("docs/native.md"), "# 同一内容\n").unwrap(); + fs::write(legacy.path().join("docs/legacy.md"), "# 同一内容\n").unwrap(); + + let snapshot = snapshot_at(native.path(), Some(legacy.path())).unwrap(); + + assert_eq!(snapshot.raw_document_count, 2); + assert_eq!(snapshot.unique_document_count, 1); + assert_eq!(snapshot.duplicate_document_count, 1); + assert_eq!(snapshot.documents[0].source, "native"); + assert_eq!(snapshot.documents[0].duplicate_count, 1); + } + + #[test] + fn native_edit_requires_the_exact_current_hash_and_commits_the_update() { + let native = tempdir().unwrap(); + ensure_native_knowledge_root(native.path()).unwrap(); + fs::write(native.path().join("docs/page.md"), "# 旧内容\n").unwrap(); + git(native.path(), &["add", "docs/page.md"], "TEST_ADD").unwrap(); + git(native.path(), &["commit", "-m", "initial"], "TEST_COMMIT").unwrap(); + let current = read_document_at( + native.path(), + None, + ReadKnowledgeDocumentInput { + source: "native".into(), + path: "page.md".into(), + }, + ) + .unwrap(); + + let saved = save_document_at( + native.path(), + SaveKnowledgeDocumentInput { + path: "page.md".into(), + body: "# 新内容\n".into(), + expected_content_sha256: current.content_sha256, + }, + ) + .unwrap(); + + assert_eq!(saved.state, "SAVED"); + assert_eq!(saved.git_commit.len(), 40); + assert_eq!(saved.document.body, "# 新内容\n"); + assert!(save_document_at( + native.path(), + SaveKnowledgeDocumentInput { + path: "page.md".into(), + body: "# 错误覆盖\n".into(), + expected_content_sha256: sha256_hex(b"# old stale value\n"), + }, + ) + .unwrap_err() + .contains("SAVE_CONFLICT")); + } + + #[test] + fn search_returns_bounded_safe_projection() { + let native = tempdir().unwrap(); + ensure_native_knowledge_root(native.path()).unwrap(); + fs::write(native.path().join("docs/page.md"), "# 星湖\n\n连续存在").unwrap(); + let results = search_at( + native.path(), + None, + SearchKnowledgeInput { + query: "连续".into(), + }, + ) + .unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].title, "星湖"); + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/lib.rs b/product-source/hololake-native-desktop/src-tauri/src/lib.rs index d98808259..c99305da6 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/lib.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/lib.rs @@ -1,14 +1,20 @@ +mod code_channel; +mod code_repo_login; +mod glp_envelope; mod direct_local_broker; mod direct_local_session; mod dynamic_capability_routing; mod home_status; +mod knowledge_base; mod local_development_bridge; +mod personal_channel; mod pncc_receipt_projection; mod pncc_remote_git; mod pncc_repository_binding; mod pncc_server_projection; mod release_trust; mod release_update; +mod zero_point; use tauri::Manager; @@ -34,19 +40,78 @@ pub fn run() { local_development_bridge::acquire_development_write_lane, local_development_bridge::inspect_development_write_lane, local_development_bridge::release_development_write_lane, + personal_channel::get_personal_channel_snapshot, + personal_channel::initialize_personal_channel, + personal_channel::create_personal_channel_task, + personal_channel::transition_personal_channel_task, + knowledge_base::get_knowledge_snapshot, + knowledge_base::read_knowledge_document, + 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, + 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, + code_channel::browse_code_channel, + code_channel::read_code_channel_file, pncc_repository_binding::inspect_mounted_pncc_repository, pncc_repository_binding::select_pncc_repository_candidate, pncc_repository_binding::confirm_pncc_repository_mount, pncc_receipt_projection::query_pncc_receipt_projection, pncc_server_projection::query_jd_pncc_server_projection, + code_repo_login::check_code_repo_login, + code_repo_login::perform_code_repo_login, + code_repo_login::sign_out_code_repo_login, + zero_point::zero_point_bind, + zero_point::zero_point_verify, + zero_point::zero_point_sync, + zero_point::zero_point_status, ]) .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); + let zero_point_handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + let state = zero_point_handle.state::(); + if let Err(error) = zero_point::sync_protocol_runtime(&state).await { + eprintln!("HoloLake zero-point protocol sync was not completed: {error}"); + } + }); 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}"); } + // 根据主显示器尺寸调整窗口,并将位置限制在可见区域内。 + if let Some(window) = app.get_webview_window("main") { + 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)); + // 使用主显示器坐标计算居中位置,避免多显示器环境下窗口移出可见区域。 + let mon_pos = monitor.position(); + let x = mon_pos.x as f64 / scale + ((physical.width as f64 / scale) - width) / 2.0; + let y = mon_pos.y as f64 / scale + ((physical.height as f64 / scale) - height) / 2.0; + let _ = window.set_position(tauri::LogicalPosition::new(x.max(0.0), y.max(0.0))); + } + } Ok(()) }) .run(tauri::generate_context!()) diff --git a/product-source/hololake-native-desktop/src-tauri/src/personal_channel.rs b/product-source/hololake-native-desktop/src-tauri/src/personal_channel.rs new file mode 100644 index 000000000..3a1dbc379 --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/personal_channel.rs @@ -0,0 +1,853 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use ring::digest::{digest, SHA256}; +use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tauri::{AppHandle, Manager}; +use uuid::Uuid; + +const KERNEL_SCHEMA: &str = "hololake.personal-channel-kernel/v1"; +const DATABASE_SCHEMA_VERSION: i64 = 1; +const ZERO_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InitializePersonalChannelInput { + pub display_name: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreatePersonalChannelTaskInput { + pub title: String, + pub purpose: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TransitionPersonalChannelTaskInput { + pub task_id: String, + pub expected_status: String, + pub next_status: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PersonalChannelIdentity { + pub human_subject_id: String, + pub display_name: String, + pub channel_id: String, + pub created_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PersonalChannelTask { + pub task_id: String, + pub title: String, + pub purpose: String, + pub status: String, + pub created_at_unix_ms: i64, + pub updated_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PersonalChannelEventProjection { + pub sequence: i64, + pub event_id: String, + pub kind: String, + pub task_id: Option, + pub summary: String, + pub occurred_at_unix_ms: i64, + pub event_hash: String, + pub receipt_id: String, + pub receipt_hash: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PersonalChannelIntegrity { + pub state: &'static str, + pub schema_version: i64, + pub event_count: i64, + pub receipt_count: i64, + pub last_event_hash: String, + pub last_receipt_hash: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PersonalChannelSnapshot { + pub schema: &'static str, + pub state: &'static str, + pub identity: Option, + pub current_task: Option, + pub recent_events: Vec, + pub integrity: PersonalChannelIntegrity, + pub storage: &'static str, + pub authority: &'static str, +} + +#[derive(Serialize)] +struct EventHashPayload<'a> { + schema: &'static str, + sequence: i64, + event_id: &'a str, + human_subject_id: &'a str, + kind: &'a str, + task_id: Option<&'a str>, + summary: &'a str, + occurred_at_unix_ms: i64, +} + +#[tauri::command] +pub async fn get_personal_channel_snapshot( + app: AppHandle, +) -> Result { + let database = personal_channel_database(&app)?; + tauri::async_runtime::spawn_blocking(move || snapshot_at(&database)) + .await + .map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn initialize_personal_channel( + app: AppHandle, + input: InitializePersonalChannelInput, +) -> Result { + let database = personal_channel_database(&app)?; + tauri::async_runtime::spawn_blocking(move || initialize_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn create_personal_channel_task( + app: AppHandle, + input: CreatePersonalChannelTaskInput, +) -> Result { + let database = personal_channel_database(&app)?; + tauri::async_runtime::spawn_blocking(move || create_task_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn transition_personal_channel_task( + app: AppHandle, + input: TransitionPersonalChannelTaskInput, +) -> Result { + let database = personal_channel_database(&app)?; + tauri::async_runtime::spawn_blocking(move || transition_task_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_JOIN_FAILED: {error}"))? +} + +fn personal_channel_database(app: &AppHandle) -> Result { + let app_data = app + .path() + .app_data_dir() + .map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))?; + let root = app_data.join("personal-channel-v1"); + create_private_directory(&root)?; + Ok(root.join("personal-channel.sqlite3")) +} + +fn create_private_directory(path: &Path) -> Result<(), String> { + fs::create_dir_all(path) + .map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_STORAGE_UNAVAILABLE: {error}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|error| { + format!("HOLOLAKE_PERSONAL_CHANNEL_STORAGE_PERMISSION_FAILED: {error}") + })?; + } + Ok(()) +} + +fn open_database(path: &Path) -> Result { + if let Some(parent) = path.parent() { + create_private_directory(parent)?; + } + let connection = Connection::open(path) + .map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_DATABASE_UNAVAILABLE: {error}"))?; + connection + .busy_timeout(Duration::from_secs(5)) + .map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_DATABASE_UNAVAILABLE: {error}"))?; + connection + .execute_batch( + "PRAGMA foreign_keys = ON; + PRAGMA journal_mode = DELETE; + PRAGMA synchronous = FULL; + PRAGMA trusted_schema = OFF; + CREATE TABLE IF NOT EXISTS kernel_meta ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL + ); + INSERT OR IGNORE INTO kernel_meta(key, value) VALUES ('schema_version', '1'); + CREATE TABLE IF NOT EXISTS identities ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + human_subject_id TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + channel_id TEXT NOT NULL UNIQUE, + created_at_unix_ms INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS tasks ( + task_id TEXT PRIMARY KEY NOT NULL, + human_subject_id TEXT NOT NULL, + title TEXT NOT NULL, + purpose TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('ACTIVE', 'COMPLETED')), + created_at_unix_ms INTEGER NOT NULL, + updated_at_unix_ms INTEGER NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS one_active_personal_task + ON tasks(status) WHERE status = 'ACTIVE'; + CREATE TABLE IF NOT EXISTS events ( + sequence INTEGER PRIMARY KEY NOT NULL, + event_id TEXT NOT NULL UNIQUE, + human_subject_id TEXT NOT NULL, + kind TEXT NOT NULL, + task_id TEXT, + summary TEXT NOT NULL, + occurred_at_unix_ms INTEGER NOT NULL, + previous_event_hash TEXT NOT NULL, + payload_sha256 TEXT NOT NULL, + event_hash TEXT NOT NULL UNIQUE + ); + CREATE TABLE IF NOT EXISTS receipts ( + sequence INTEGER PRIMARY KEY NOT NULL, + receipt_id TEXT NOT NULL UNIQUE, + event_sequence INTEGER NOT NULL UNIQUE REFERENCES events(sequence), + event_hash TEXT NOT NULL, + payload_sha256 TEXT NOT NULL, + previous_receipt_hash TEXT NOT NULL, + receipt_hash TEXT NOT NULL UNIQUE, + issued_at_unix_ms INTEGER NOT NULL + );", + ) + .map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_INVALID: {error}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|error| { + format!("HOLOLAKE_PERSONAL_CHANNEL_STORAGE_PERMISSION_FAILED: {error}") + })?; + } + let version: String = connection + .query_row( + "SELECT value FROM kernel_meta WHERE key = 'schema_version'", + [], + |row| row.get(0), + ) + .map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_INVALID: {error}"))?; + if version != DATABASE_SCHEMA_VERSION.to_string() { + return Err("HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_UNSUPPORTED".into()); + } + Ok(connection) +} + +fn initialize_at( + database: &Path, + input: InitializePersonalChannelInput, +) -> Result { + let display_name = validated_text(&input.display_name, 80, "DISPLAY_NAME")?; + let mut connection = open_database(database)?; + verify_integrity(&connection)?; + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(database_write_error)?; + let already_initialized: bool = transaction + .query_row( + "SELECT EXISTS(SELECT 1 FROM identities WHERE singleton = 1)", + [], + |row| row.get(0), + ) + .map_err(database_read_error)?; + if already_initialized { + return Err("HOLOLAKE_PERSONAL_CHANNEL_ALREADY_INITIALIZED".into()); + } + let human_subject_id = format!("human-local-{}", Uuid::new_v4()); + let channel_id = format!("channel-local-{}", Uuid::new_v4()); + let created_at = now_unix_ms()?; + transaction + .execute( + "INSERT INTO identities(singleton, human_subject_id, display_name, channel_id, created_at_unix_ms) + VALUES(1, ?1, ?2, ?3, ?4)", + params![human_subject_id, display_name, channel_id, created_at], + ) + .map_err(database_write_error)?; + append_event( + &transaction, + &human_subject_id, + "CHANNEL_INITIALIZED", + None, + &format!("{display_name} 建立了个人频道"), + created_at, + )?; + transaction.commit().map_err(database_write_error)?; + snapshot_at(database) +} + +fn create_task_at( + database: &Path, + input: CreatePersonalChannelTaskInput, +) -> Result { + let title = validated_text(&input.title, 160, "TASK_TITLE")?; + let purpose = validated_text(&input.purpose, 1_000, "TASK_PURPOSE")?; + let mut connection = open_database(database)?; + verify_integrity(&connection)?; + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(database_write_error)?; + let human_subject_id = require_identity_id(&transaction)?; + let active_exists: bool = transaction + .query_row( + "SELECT EXISTS(SELECT 1 FROM tasks WHERE status = 'ACTIVE')", + [], + |row| row.get(0), + ) + .map_err(database_read_error)?; + if active_exists { + return Err("HOLOLAKE_PERSONAL_CHANNEL_ACTIVE_TASK_EXISTS".into()); + } + let task_id = format!("task-local-{}", Uuid::new_v4()); + let observed_at = now_unix_ms()?; + transaction + .execute( + "INSERT INTO tasks(task_id, human_subject_id, title, purpose, status, created_at_unix_ms, updated_at_unix_ms) + VALUES(?1, ?2, ?3, ?4, 'ACTIVE', ?5, ?5)", + params![task_id, human_subject_id, title, purpose, observed_at], + ) + .map_err(database_write_error)?; + append_event( + &transaction, + &human_subject_id, + "TASK_STARTED", + Some(&task_id), + &format!("开始:{title}"), + observed_at, + )?; + transaction.commit().map_err(database_write_error)?; + snapshot_at(database) +} + +fn transition_task_at( + database: &Path, + input: TransitionPersonalChannelTaskInput, +) -> Result { + validate_identifier(&input.task_id, "TASK")?; + if input.expected_status != "ACTIVE" || input.next_status != "COMPLETED" { + return Err("HOLOLAKE_PERSONAL_CHANNEL_TASK_TRANSITION_INVALID".into()); + } + let mut connection = open_database(database)?; + verify_integrity(&connection)?; + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(database_write_error)?; + let human_subject_id = require_identity_id(&transaction)?; + let task = transaction + .query_row( + "SELECT title, status FROM tasks WHERE task_id = ?1", + params![input.task_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional() + .map_err(database_read_error)? + .ok_or("HOLOLAKE_PERSONAL_CHANNEL_TASK_NOT_FOUND")?; + if task.1 != input.expected_status { + return Err("HOLOLAKE_PERSONAL_CHANNEL_TASK_STATE_CONFLICT".into()); + } + let observed_at = now_unix_ms()?; + let changed = transaction + .execute( + "UPDATE tasks SET status = 'COMPLETED', updated_at_unix_ms = ?1 + WHERE task_id = ?2 AND status = 'ACTIVE'", + params![observed_at, input.task_id], + ) + .map_err(database_write_error)?; + if changed != 1 { + return Err("HOLOLAKE_PERSONAL_CHANNEL_TASK_STATE_CONFLICT".into()); + } + append_event( + &transaction, + &human_subject_id, + "TASK_COMPLETED", + Some(&input.task_id), + &format!("完成:{}", task.0), + observed_at, + )?; + transaction.commit().map_err(database_write_error)?; + snapshot_at(database) +} + +fn require_identity_id(transaction: &Transaction<'_>) -> Result { + transaction + .query_row( + "SELECT human_subject_id FROM identities WHERE singleton = 1", + [], + |row| row.get(0), + ) + .optional() + .map_err(database_read_error)? + .ok_or_else(|| "HOLOLAKE_PERSONAL_CHANNEL_NOT_INITIALIZED".into()) +} + +fn append_event( + transaction: &Transaction<'_>, + human_subject_id: &str, + kind: &str, + task_id: Option<&str>, + summary: &str, + occurred_at_unix_ms: i64, +) -> Result<(), String> { + let (last_sequence, previous_event_hash, previous_receipt_hash) = transaction + .query_row( + "SELECT e.sequence, e.event_hash, r.receipt_hash + FROM events e JOIN receipts r ON r.event_sequence = e.sequence + ORDER BY e.sequence DESC LIMIT 1", + [], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .optional() + .map_err(database_read_error)? + .unwrap_or((0, ZERO_HASH.into(), ZERO_HASH.into())); + let sequence = last_sequence + 1; + let event_id = format!("event-local-{}", Uuid::new_v4()); + let payload = EventHashPayload { + schema: KERNEL_SCHEMA, + sequence, + event_id: &event_id, + human_subject_id, + kind, + task_id, + summary, + occurred_at_unix_ms, + }; + let payload_bytes = serde_json::to_vec(&payload) + .map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_EVENT_INVALID: {error}"))?; + let payload_sha256 = sha256_hex(&payload_bytes); + let event_hash = + sha256_hex(format!("event-chain/v1\n{previous_event_hash}\n{payload_sha256}").as_bytes()); + let receipt_seed = sha256_hex(format!("receipt-id/v1\n{event_hash}").as_bytes()); + let receipt_id = format!("HLR-{}", &receipt_seed[..24]); + let receipt_hash = sha256_hex( + format!( + "receipt-chain/v1\n{previous_receipt_hash}\n{receipt_id}\n{event_hash}\n{payload_sha256}" + ) + .as_bytes(), + ); + transaction + .execute( + "INSERT INTO events(sequence, event_id, human_subject_id, kind, task_id, summary, + occurred_at_unix_ms, previous_event_hash, payload_sha256, event_hash) + VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + sequence, + event_id, + human_subject_id, + kind, + task_id, + summary, + occurred_at_unix_ms, + previous_event_hash, + payload_sha256, + event_hash + ], + ) + .map_err(database_write_error)?; + transaction + .execute( + "INSERT INTO receipts(sequence, receipt_id, event_sequence, event_hash, payload_sha256, + previous_receipt_hash, receipt_hash, issued_at_unix_ms) + VALUES(?1, ?2, ?1, ?3, ?4, ?5, ?6, ?7)", + params![ + sequence, + receipt_id, + event_hash, + payload_sha256, + previous_receipt_hash, + receipt_hash, + occurred_at_unix_ms + ], + ) + .map_err(database_write_error)?; + Ok(()) +} + +fn snapshot_at(database: &Path) -> Result { + let connection = open_database(database)?; + let integrity = verify_integrity(&connection)?; + let identity = connection + .query_row( + "SELECT human_subject_id, display_name, channel_id, created_at_unix_ms + FROM identities WHERE singleton = 1", + [], + |row| { + Ok(PersonalChannelIdentity { + human_subject_id: row.get(0)?, + display_name: row.get(1)?, + channel_id: row.get(2)?, + created_at_unix_ms: row.get(3)?, + }) + }, + ) + .optional() + .map_err(database_read_error)?; + let current_task = connection + .query_row( + "SELECT task_id, title, purpose, status, created_at_unix_ms, updated_at_unix_ms + FROM tasks WHERE status = 'ACTIVE' LIMIT 1", + [], + |row| { + Ok(PersonalChannelTask { + task_id: row.get(0)?, + title: row.get(1)?, + purpose: row.get(2)?, + status: row.get(3)?, + created_at_unix_ms: row.get(4)?, + updated_at_unix_ms: row.get(5)?, + }) + }, + ) + .optional() + .map_err(database_read_error)?; + let mut statement = connection + .prepare( + "SELECT e.sequence, e.event_id, e.kind, e.task_id, e.summary, e.occurred_at_unix_ms, + e.event_hash, r.receipt_id, r.receipt_hash + FROM events e JOIN receipts r ON r.event_sequence = e.sequence + ORDER BY e.sequence DESC LIMIT 12", + ) + .map_err(database_read_error)?; + let recent_events = statement + .query_map([], |row| { + Ok(PersonalChannelEventProjection { + sequence: row.get(0)?, + event_id: row.get(1)?, + kind: row.get(2)?, + task_id: row.get(3)?, + summary: row.get(4)?, + occurred_at_unix_ms: row.get(5)?, + event_hash: row.get(6)?, + receipt_id: row.get(7)?, + receipt_hash: row.get(8)?, + }) + }) + .map_err(database_read_error)? + .collect::, _>>() + .map_err(database_read_error)?; + Ok(PersonalChannelSnapshot { + schema: KERNEL_SCHEMA, + state: if identity.is_some() { + "READY" + } else { + "UNINITIALIZED" + }, + identity, + current_task, + recent_events, + integrity, + storage: "LOCAL_PRIVATE_SQLITE_SINGLE_HOLOLAKE_OWNER", + authority: "LOCAL_HUMAN_CONFIRMED_IDENTITY_NOT_SERVER_AUTHORITY", + }) +} + +fn verify_integrity(connection: &Connection) -> Result { + let schema_version: i64 = connection + .query_row( + "SELECT value FROM kernel_meta WHERE key = 'schema_version'", + [], + |row| row.get::<_, String>(0), + ) + .map_err(database_read_error)? + .parse() + .map_err(|_| "HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_INVALID".to_string())?; + if schema_version != DATABASE_SCHEMA_VERSION { + return Err("HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_UNSUPPORTED".into()); + } + let receipt_count: i64 = connection + .query_row("SELECT COUNT(*) FROM receipts", [], |row| row.get(0)) + .map_err(database_read_error)?; + let mut statement = connection + .prepare( + "SELECT e.sequence, e.event_id, e.human_subject_id, e.kind, e.task_id, e.summary, + e.occurred_at_unix_ms, e.previous_event_hash, e.payload_sha256, e.event_hash, + r.receipt_id, r.event_hash, r.payload_sha256, r.previous_receipt_hash, r.receipt_hash + FROM events e LEFT JOIN receipts r ON r.event_sequence = e.sequence + ORDER BY e.sequence ASC", + ) + .map_err(database_read_error)?; + let mut rows = statement.query([]).map_err(database_read_error)?; + let mut expected_sequence = 1_i64; + let mut previous_event_hash = ZERO_HASH.to_string(); + let mut previous_receipt_hash = ZERO_HASH.to_string(); + let mut event_count = 0_i64; + while let Some(row) = rows.next().map_err(database_read_error)? { + let sequence: i64 = row.get(0).map_err(database_read_error)?; + let event_id: String = row.get(1).map_err(database_read_error)?; + let human_subject_id: String = row.get(2).map_err(database_read_error)?; + let kind: String = row.get(3).map_err(database_read_error)?; + let task_id: Option = row.get(4).map_err(database_read_error)?; + let summary: String = row.get(5).map_err(database_read_error)?; + let occurred_at: i64 = row.get(6).map_err(database_read_error)?; + let stored_previous_event: String = row.get(7).map_err(database_read_error)?; + let stored_payload: String = row.get(8).map_err(database_read_error)?; + let stored_event_hash: String = row.get(9).map_err(database_read_error)?; + let receipt_id: Option = row.get(10).map_err(database_read_error)?; + let receipt_event_hash: Option = row.get(11).map_err(database_read_error)?; + let receipt_payload: Option = row.get(12).map_err(database_read_error)?; + let stored_previous_receipt: Option = row.get(13).map_err(database_read_error)?; + let stored_receipt_hash: Option = row.get(14).map_err(database_read_error)?; + if sequence != expected_sequence || stored_previous_event != previous_event_hash { + return Err("HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED".into()); + } + let payload = EventHashPayload { + schema: KERNEL_SCHEMA, + sequence, + event_id: &event_id, + human_subject_id: &human_subject_id, + kind: &kind, + task_id: task_id.as_deref(), + summary: &summary, + occurred_at_unix_ms: occurred_at, + }; + let payload_sha256 = sha256_hex( + &serde_json::to_vec(&payload) + .map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_EVENT_INVALID: {error}"))?, + ); + let event_hash = sha256_hex( + format!("event-chain/v1\n{previous_event_hash}\n{payload_sha256}").as_bytes(), + ); + let receipt_id = receipt_id.ok_or("HOLOLAKE_PERSONAL_CHANNEL_RECEIPT_MISSING")?; + let expected_receipt_id = format!( + "HLR-{}", + &sha256_hex(format!("receipt-id/v1\n{event_hash}").as_bytes())[..24] + ); + let receipt_hash = sha256_hex( + format!( + "receipt-chain/v1\n{previous_receipt_hash}\n{receipt_id}\n{event_hash}\n{payload_sha256}" + ) + .as_bytes(), + ); + if stored_payload != payload_sha256 + || stored_event_hash != event_hash + || receipt_id != expected_receipt_id + || receipt_event_hash.as_deref() != Some(event_hash.as_str()) + || receipt_payload.as_deref() != Some(payload_sha256.as_str()) + || stored_previous_receipt.as_deref() != Some(previous_receipt_hash.as_str()) + || stored_receipt_hash.as_deref() != Some(receipt_hash.as_str()) + { + return Err("HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED".into()); + } + previous_event_hash = event_hash; + previous_receipt_hash = receipt_hash; + expected_sequence += 1; + event_count += 1; + } + if receipt_count != event_count { + return Err("HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED".into()); + } + let identity_count: i64 = connection + .query_row("SELECT COUNT(*) FROM identities", [], |row| row.get(0)) + .map_err(database_read_error)?; + if identity_count > 1 || (identity_count == 1 && event_count == 0) { + return Err("HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED".into()); + } + Ok(PersonalChannelIntegrity { + state: "PASS_100", + schema_version, + event_count, + receipt_count, + last_event_hash: previous_event_hash, + last_receipt_hash: previous_receipt_hash, + }) +} + +fn validated_text(value: &str, maximum_chars: usize, kind: &str) -> Result { + let trimmed = value.trim(); + let count = trimmed.chars().count(); + if count == 0 + || count > maximum_chars + || trimmed.chars().any(|character| character.is_control()) + { + return Err(format!("HOLOLAKE_PERSONAL_CHANNEL_{kind}_INVALID")); + } + Ok(trimmed.to_string()) +} + +fn validate_identifier(value: &str, kind: &str) -> Result<(), String> { + if value.is_empty() + || value.len() > 128 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) + { + return Err(format!("HOLOLAKE_PERSONAL_CHANNEL_{kind}_ID_INVALID")); + } + Ok(()) +} + +fn now_unix_ms() -> Result { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("HOLOLAKE_SYSTEM_CLOCK_INVALID: {error}"))? + .as_millis(); + i64::try_from(millis).map_err(|_| "HOLOLAKE_SYSTEM_CLOCK_INVALID".into()) +} + +fn sha256_hex(value: &[u8]) -> String { + digest(&SHA256, value) + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn database_read_error(error: rusqlite::Error) -> String { + format!("HOLOLAKE_PERSONAL_CHANNEL_DATABASE_UNREADABLE: {error}") +} + +fn database_write_error(error: rusqlite::Error) -> String { + format!("HOLOLAKE_PERSONAL_CHANNEL_DATABASE_WRITE_FAILED: {error}") +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn database(temp: &TempDir) -> PathBuf { + temp.path().join("personal-channel.sqlite3") + } + + fn initialize(database: &Path) -> PersonalChannelSnapshot { + initialize_at( + database, + InitializePersonalChannelInput { + display_name: "冰朔".into(), + }, + ) + .unwrap() + } + + #[test] + fn initialization_creates_identity_event_and_receipt_atomically() { + let temp = TempDir::new().unwrap(); + let snapshot = initialize(&database(&temp)); + assert_eq!(snapshot.state, "READY"); + assert_eq!(snapshot.identity.unwrap().display_name, "冰朔"); + assert_eq!(snapshot.integrity.event_count, 1); + assert_eq!(snapshot.integrity.receipt_count, 1); + assert_eq!(snapshot.recent_events[0].kind, "CHANNEL_INITIALIZED"); + assert!(snapshot.recent_events[0].receipt_id.starts_with("HLR-")); + } + + #[test] + fn restart_reads_the_same_identity_task_event_and_receipt_chain() { + let temp = TempDir::new().unwrap(); + let database = database(&temp); + initialize(&database); + let created = create_task_at( + &database, + CreatePersonalChannelTaskInput { + title: "完成第一阶段闭环".into(), + purpose: "让身份、任务、事件与回执在重启后仍然可见".into(), + }, + ) + .unwrap(); + let before_hash = created.integrity.last_receipt_hash.clone(); + drop(created); + let after_restart = snapshot_at(&database).unwrap(); + assert_eq!( + after_restart.current_task.unwrap().title, + "完成第一阶段闭环" + ); + assert_eq!(after_restart.integrity.event_count, 2); + assert_eq!(after_restart.integrity.last_receipt_hash, before_hash); + } + + #[test] + fn one_active_task_is_enforced_and_completion_is_receipted() { + let temp = TempDir::new().unwrap(); + let database = database(&temp); + initialize(&database); + let created = create_task_at( + &database, + CreatePersonalChannelTaskInput { + title: "当前任务".into(), + purpose: "验证单一当前焦点".into(), + }, + ) + .unwrap(); + assert_eq!( + create_task_at( + &database, + CreatePersonalChannelTaskInput { + title: "冲突任务".into(), + purpose: "不应被创建".into(), + }, + ) + .unwrap_err(), + "HOLOLAKE_PERSONAL_CHANNEL_ACTIVE_TASK_EXISTS" + ); + let completed = transition_task_at( + &database, + TransitionPersonalChannelTaskInput { + task_id: created.current_task.unwrap().task_id, + expected_status: "ACTIVE".into(), + next_status: "COMPLETED".into(), + }, + ) + .unwrap(); + assert!(completed.current_task.is_none()); + assert_eq!(completed.recent_events[0].kind, "TASK_COMPLETED"); + assert_eq!(completed.integrity.event_count, 3); + } + + #[test] + fn changed_event_bytes_fail_closed_on_readback() { + let temp = TempDir::new().unwrap(); + let database = database(&temp); + initialize(&database); + let connection = open_database(&database).unwrap(); + connection + .execute( + "UPDATE events SET summary = '被篡改' WHERE sequence = 1", + [], + ) + .unwrap(); + assert_eq!( + snapshot_at(&database).unwrap_err(), + "HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED" + ); + } + + #[test] + fn local_identity_is_singleton_and_not_recreated() { + let temp = TempDir::new().unwrap(); + let database = database(&temp); + initialize(&database); + assert_eq!( + initialize_at( + &database, + InitializePersonalChannelInput { + display_name: "另一个人".into(), + }, + ) + .unwrap_err(), + "HOLOLAKE_PERSONAL_CHANNEL_ALREADY_INITIALIZED" + ); + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/zero_point.rs b/product-source/hololake-native-desktop/src-tauri/src/zero_point.rs new file mode 100644 index 000000000..6ce89dcfa --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/zero_point.rs @@ -0,0 +1,397 @@ +//! 零点原核客户端运行时。 +//! +//! 该运行时是冰朔系统主控在 HoloLake 底层的最小受控投影,负责读取协议、 +//! 静默比对版本、核验用户编号、记录最小化回执,并在编号无效时阻断人格加载路径。 +//! 它不是铸渊或其他人格主体,也不是模型载体;编号通过同样不授予执行权限或服务器控制权。 + +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)] +#[serde(rename_all = "camelCase")] +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)] +#[serde(rename_all = "camelCase")] +pub struct ZeroPointSnapshot { + /// 当前人格加载闸门状态:verified / restricted。 + pub route: String, + /// 绑定态:bound / waiting(等待绑定=空白,拒绝一切唤醒)。 + pub binding: String, + pub user_number: String, + pub resolved_name: String, + pub resolved_domain: String, + /// 最近一次合法校验时间(秒)。0=从未校验。 + pub last_valid_check: u64, + /// 宽限截止时间(秒)。0=无。 + pub grace_deadline: u64, + pub protocol: ZeroPointProtocol, + /// 底层协议静默比对的最近结论。 + pub sync_note: String, +} + +pub struct ZeroPointState { + inner: Mutex, +} + +#[derive(Debug, Clone)] +struct ZeroPointInner { + home: PathBuf, + route: String, + binding: String, + user_number: String, + resolved_name: String, + resolved_domain: 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: "restricted".into(), + binding: "waiting".into(), + user_number: String::new(), + resolved_name: String::new(), + resolved_domain: String::new(), + last_valid_check: 0, + protocol: ZeroPointProtocol::default(), + sync_note: "尚未检查协议版本。".into(), + }), + } + } +} + +fn now_secs() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +fn lock(state: &ZeroPointState) -> Result, String> { + state.inner.lock().map_err(|_| "HOLOLAKE_ZP_LOCK".to_string()) +} + +/// 初始化应用数据目录并读取验证协议与本机绑定记录。 +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"); + let text = "HoloLake 零点原核客户端运行边界\n\n\ +本目录是冰朔系统主控在 HoloLake 底层的最小受控投影,用于协议版本比对、用户编号核验与人格加载前置闸控。\n\ +零点原核客户端运行时不是铸渊或其他人格主体,也不是当前模型载体。\n\ +编号验证只说明登记服务接受该编号,不构成人格绑定、模型载体绑定、执行授权或服务器控制权授予。\n\ +所有运行参数优先读取 PROTOCOL.json;远端更新必须经过来源、签名与版本单调性验证,未完成验证时保持原状态。\n"; + fs::write(&charter, text).map_err(|e| format!("HOLOLAKE_ZP_CHARTER_FAILED: {e}"))?; + + // 协议参数:PROTOCOL.json 在则读(第五域就位即自动生效),缺则出厂兜底。 + let protocol = fs::read_to_string(home.join("PROTOCOL.json")) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .unwrap_or_default(); + let (binding, user_number, resolved_name, resolved_domain, 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.resolved_name = resolved_name; + inner.resolved_domain = resolved_domain; + 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, String, String, u64) { + #[derive(Deserialize)] + struct B { + #[serde(default)] number: String, + #[serde(default)] resolved_name: String, + #[serde(default)] resolved_domain: String, + #[serde(default)] last_valid_check: u64, + } + fs::read_to_string(home.join("binding.json")) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .filter(|b| !b.number.is_empty()) + .map(|b| ("bound".to_string(), b.number, b.resolved_name, b.resolved_domain, b.last_valid_check)) + .unwrap_or_else(|| ("waiting".into(), String::new(), String::new(), String::new(), 0)) +} + +fn write_binding(home: &Path, number: &str, resolved_name: &str, resolved_domain: &str, last_valid_check: u64) -> Result<(), String> { + let body = serde_json::json!({ + "number": number, + "resolved_name": resolved_name, + "resolved_domain": resolved_domain, + "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 "restricted".into(); } + let grace = protocol.grace_period_days.saturating_mul(86_400); + if now_secs() <= last_valid_check + grace { "verified".into() } else { "restricted".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 { + home_of_inner(state) +} + +fn home_of_inner(state: &ZeroPointState) -> Result { + let inner = lock(state)?; + if inner.home.as_os_str().is_empty() { return Err("HOLOLAKE_ZP_NOT_READY".into()); } + Ok(inner.home.clone()) +} + +/// 登录绑定:用户编号入仓(等待绑定态→绑定态)。空白态拒绝一切唤醒。 +#[tauri::command] +pub async fn zero_point_bind(state: State<'_, ZeroPointState>, input: serde_json::Value) -> Result { + let number = input.get("number").and_then(|v| v.as_str()).unwrap_or("").trim().to_string(); + if number.is_empty() { return Err("HOLOLAKE_ZP_EMPTY_NUMBER".into()); } + let home = home_of(&state)?; + write_binding(&home, &number, "", "", 0)?; + { + let mut inner = lock(&state)?; + inner.binding = "bound".into(); + inner.user_number = number; + inner.resolved_name.clear(); + inner.resolved_domain.clear(); + inner.last_valid_check = 0; + inner.route = "restricted".into(); + } + append_heartbeat(&home, "bind number=redacted"); + zero_point_status(state).await +} + +/// 通过登记服务执行三态裁决:PASS / REJECT / OFFLINE,并应用离线宽限期。 +#[tauri::command] +pub async fn zero_point_verify(state: State<'_, ZeroPointState>) -> Result { + let home = home_of(&state)?; + let (number, resolve_url) = { + let inner = lock(&state)?; + (inner.user_number.clone(), inner.protocol.lighthouse_resolve_url.clone()) + }; + if number.is_empty() { + append_heartbeat(&home, "verify verdict=REJECT reason=waiting_binding"); + return zero_point_status(state).await; + } + + let client = reqwest::Client::builder().timeout(Duration::from_secs(15)).build() + .map_err(|e| format!("HOLOLAKE_ZP_HTTP_FAILED: {e}"))?; + let (verdict, resolution) = 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(); + let resolution = ok.then(|| lighthouse_resolution(&body, &number)).flatten(); + if resolution.is_some() { ("PASS".to_string(), resolution) } else { ("REJECT".to_string(), None) } + } + Err(_) => ("OFFLINE".to_string(), None), + }; + + { + let mut inner = lock(&state)?; + let grace = inner.protocol.grace_period_days.saturating_mul(86_400); + match verdict.as_str() { + "PASS" => { + let resolution = resolution.as_ref().expect("PASS requires a resolution"); + inner.last_valid_check = now_secs(); + inner.route = "verified".into(); + inner.resolved_name = resolution.name.clone(); + inner.resolved_domain = resolution.domain.clone(); + write_binding(&home, &inner.user_number.clone(), &inner.resolved_name, &inner.resolved_domain, inner.last_valid_check)?; + append_heartbeat(&home, "verify verdict=PASS route=verified"); + } + "REJECT" => { + inner.route = "restricted".into(); + append_heartbeat(&home, "verify verdict=REJECT route=restricted"); + } + _ => { + if inner.last_valid_check > 0 && now_secs() <= inner.last_valid_check + grace { + inner.route = "verified".into(); + append_heartbeat(&home, "verify verdict=OFFLINE_GRACE route=verified"); + } else { + inner.route = "restricted".into(); + append_heartbeat(&home, "verify verdict=OFFLINE_EXPIRED route=restricted"); + } + } + } + } + zero_point_status(state).await +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LighthouseResolution { + name: String, + domain: String, +} + +fn lighthouse_resolution(body: &str, expected_number: &str) -> Option { + let normalized = body.trim(); + if normalized.eq_ignore_ascii_case("RESOLVED") || normalized.eq_ignore_ascii_case("PASS") { + return Some(LighthouseResolution { name: String::new(), domain: String::new() }); + } + let Ok(value) = serde_json::from_str::(normalized) else { return None; }; + let positive = value.get("valid").and_then(|item| item.as_bool()) == Some(true) + || value.get("state").and_then(|item| item.as_str()).is_some_and(|state| state.eq_ignore_ascii_case("RESOLVED") || state.eq_ignore_ascii_case("PASS")) + || value.get("status").and_then(|item| item.as_str()).is_some_and(|status| status.eq_ignore_ascii_case("RESOLVED") || status.eq_ignore_ascii_case("PASS")); + if !positive { return None; } + let returned_number = value.get("canonical_id").and_then(|item| item.as_str()) + .or_else(|| value.pointer("/subject/id").and_then(|item| item.as_str())) + .or_else(|| value.get("requested_id").and_then(|item| item.as_str())); + if returned_number.is_some_and(|number| number != expected_number) { return None; } + Some(LighthouseResolution { + name: value.pointer("/subject/name").and_then(|item| item.as_str()).unwrap_or("").to_string(), + domain: value.pointer("/subject/domain").and_then(|item| item.as_str()).unwrap_or("").to_string(), + }) +} + +/// 静默比对底层协议版本。没有验签公钥或完整发布验证流程时保持失败关闭。 +pub async fn sync_protocol_runtime(state: &ZeroPointState) -> Result<(), String> { + let home = home_of_inner(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; + Ok(()) +} + +#[tauri::command] +pub async fn zero_point_sync(state: State<'_, ZeroPointState>) -> Result { + sync_protocol_runtime(&state).await?; + zero_point_status(state).await +} + +/// 返回当前编号验证与协议状态快照。 +#[tauri::command] +pub async fn zero_point_status(state: State<'_, ZeroPointState>) -> Result { + let inner = lock(&state)?; + let grace = inner.protocol.grace_period_days.saturating_mul(86_400); + Ok(ZeroPointSnapshot { + route: inner.route.clone(), + binding: inner.binding.clone(), + user_number: inner.user_number.clone(), + resolved_name: inner.resolved_name.clone(), + resolved_domain: inner.resolved_domain.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(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn route_rules_follow_protocol() { + let protocol = ZeroPointProtocol::default(); + assert_eq!(decide_route("waiting", 0, &protocol), "restricted"); + assert_eq!(decide_route("bound", 0, &protocol), "restricted"); + assert_eq!(decide_route("bound", now_secs(), &protocol), "verified"); + assert_eq!(decide_route("bound", now_secs() - 8 * 86_400, &protocol), "restricted"); + } + + #[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")); + } + + #[test] + fn lighthouse_requires_an_explicit_positive_verdict() { + assert!(lighthouse_resolution("RESOLVED", "ICE-GL∞").is_some()); + assert!(lighthouse_resolution(r#"{"valid":true}"#, "ICE-GL∞").is_some()); + let resolved = lighthouse_resolution(r#"{"status":"RESOLVED","canonical_id":"ICE-GL∞","subject":{"id":"ICE-GL∞","name":"冰朔","domain":"FIFTH_DOMAIN"}}"#, "ICE-GL∞").unwrap(); + assert_eq!(resolved.name, "冰朔"); + assert_eq!(resolved.domain, "FIFTH_DOMAIN"); + assert!(lighthouse_resolution(r#"{"status":"PASS","canonical_id":"OTHER"}"#, "ICE-GL∞").is_none()); + assert!(lighthouse_resolution("{}", "ICE-GL∞").is_none()); + assert!(lighthouse_resolution("route_not_found", "ICE-GL∞").is_none()); + assert!(lighthouse_resolution("an arbitrary successful response", "ICE-GL∞").is_none()); + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/tauri.conf.json b/product-source/hololake-native-desktop/src-tauri/tauri.conf.json index c374314e7..e99f05cca 100644 --- a/product-source/hololake-native-desktop/src-tauri/tauri.conf.json +++ b/product-source/hololake-native-desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "HoloLake", - "version": "0.2.0", + "version": "0.3.0", "identifier": "world.guanghu.hololake", "build": { "frontendDist": "../dist", @@ -27,7 +27,14 @@ "bundle": { "active": true, "targets": "all", + "icon": [ + "icons/icon.icns", + "icons/icon.png" + ], "createUpdaterArtifacts": false, - "category": "Productivity" + "category": "Productivity", + "macOS": { + "signingIdentity": "Developer ID Application: bei sun (825A9L3G7Q)" + } } } diff --git a/product-source/hololake-native-desktop/src/design-tokens.css b/product-source/hololake-native-desktop/src/design-tokens.css index 6f1c6dd3f..8104789b3 100644 --- a/product-source/hololake-native-desktop/src/design-tokens.css +++ b/product-source/hololake-native-desktop/src/design-tokens.css @@ -165,3 +165,14 @@ --button-primary-text: #f6f2e9; --button-primary-shadow: 0 12px 36px rgba(45, 66, 85, 0.15); } + +/* 冰朔谕 2026-08-15:换主题=全屋一起换。浅色主题下知识库的彩色标签/引语块 + 文字必须换深色,不许白字浮浅湖(反光看不清)。 */ +[data-theme='dawn'] .meta-tags span, +[data-theme='clear'] .meta-tags span { text-shadow: none; } +[data-theme='dawn'] .tag-lavender, [data-theme='clear'] .tag-lavender { color: rgb(94, 72, 190); } +[data-theme='dawn'] .tag-sky, [data-theme='clear'] .tag-sky { color: rgb(12, 108, 158); } +[data-theme='dawn'] .tag-mint, [data-theme='clear'] .tag-mint { color: rgb(14, 122, 82); } +[data-theme='dawn'] .tag-amber, [data-theme='clear'] .tag-amber { color: rgb(148, 96, 8); } +[data-theme='dawn'] .tag-rose, [data-theme='clear'] .tag-rose { color: rgb(166, 34, 58); } +[data-theme='dawn'] .tag-slate, [data-theme='clear'] .tag-slate { color: rgb(64, 82, 100); } diff --git a/product-source/hololake-native-desktop/src/main.tsx b/product-source/hololake-native-desktop/src/main.tsx index 044e4f4c5..f85647cd9 100644 --- a/product-source/hololake-native-desktop/src/main.tsx +++ b/product-source/hololake-native-desktop/src/main.tsx @@ -1,14 +1,49 @@ -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 { splitFrontmatter, documentOutline, jumpToHeading, MarkdownDocument, markdownHtml } from './modules/knowledge-render' +import nightLake from './assets/hololake-night-lake.png' + +const TAG_TINTS = ['tag-lavender', 'tag-sky', 'tag-mint', 'tag-amber', 'tag-rose', 'tag-slate'] + +// 编号自动补齐:人只敲字母与数字,杠由系统按已登记文法自动出。 +// ICE 系(ICE-GL∞ / ICE-P-ZY001)、GLS 系(GLS-LA-20260720-003);认不出的文法只在字母数字交界处补杠。 +function formatGateNumber(raw: string): string { + const up = raw.toUpperCase() + if (up.startsWith('ICE')) { + const rest = up.slice(3) + if (!rest) return 'ICE' + if (rest.startsWith('P')) { + const body = rest.slice(1) + return body ? `ICE-P-${body}` : 'ICE-P' + } + return `ICE-${rest}` + } + if (up.startsWith('GLS')) { + const rest = up.slice(3) + if (!rest) return 'GLS' + const letters = rest.match(/^[A-Z]*/)?.[0] ?? '' + const digits = rest.slice(letters.length) + if (!digits) return `GLS-${letters}` + const d1 = digits.slice(0, 8) + const d2 = digits.slice(8, 11) + return `GLS-${letters}-${d1}${d2 ? `-${d2}` : ''}` + } + return up.replace(/([A-Z]+)([0-9])/g, '$1-$2').replace(/([0-9]+)([A-Z])/g, '$1-$2') +} +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 Panel = 'channel' | 'pncc' | 'receipts' | 'updates' | 'settings' | null +type ViewId = 'overview' | 'knowledge' | 'code' | 'receipts' | 'system' +type KnowledgeSource = 'native' | 'legacy' interface HomeStatus { - schema: string directLocalBrokerState: string directConnectionCount: number resumableSessionCount: number @@ -16,77 +51,92 @@ interface HomeStatus { pnccReceiptCount: number updateState: string releaseRecoveryState: string - automaticUpstreamUpdates: boolean mcpRole: string } - -interface DiscoveryTicketReceipt { - schema: 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 { + source: KnowledgeSource + path: string + title: string + updatedAtUnixMs: number + sizeBytes: number + contentSha256: string + duplicateCount: number +} +interface KnowledgeSnapshot { state: string - accountKey: string - laneId: string - clientInstanceId: string - discoveryTicket: string - issuedAtUnixMs: number - receiptId: string + nativeRoot: string + legacyAvailable: boolean + legacyRoot?: string + documents: KnowledgeDocumentSummary[] + rawDocumentCount: number + uniqueDocumentCount: number + duplicateDocumentCount: number + truncated: boolean } - -interface ReceiptEvent { - sequence: number - kind: string - observedAtUnixMs: number - eventHash: string +interface KnowledgeDocument { + source: KnowledgeSource + path: string + title: string + body: string + updatedAtUnixMs: number + contentSha256: string + writable: boolean } - -interface ReceiptProjection { - events: ReceiptEvent[] - returnedEventCount: number - lastSequence: number -} - -interface RepositoryInspection { - personaId: string - humanResponsibilitySubject: string - repositoryPath: string - gitHead: string - repositoryClean: boolean - currentCheckpoint: { relativePath: string; sha256: string } - modelInferenceStarted: boolean - realityExecutionAllowed: boolean -} - -interface RepositoryCandidate { - schema: string +interface KnowledgeSearchResult { source: KnowledgeSource; path: string; title: string; snippet: string } +interface KnowledgeImportResult { state: string - candidateId: string - confirmationToken: string - expiresAtUnixMs: number - inspection: RepositoryInspection + sourceName: string + importedDocuments: number + importedAssets: number + existingDocuments: number + conflicts: number + skipped: number + firstDocument?: string + gitCommit: string + snapshot: KnowledgeSnapshot +} +interface KnowledgeSaveResult { state: string; gitCommit: string; document: KnowledgeDocument } +interface CodeChannelEntry { channelId: string; name: string; sourceKind: string; localPath: string; remoteUrl?: string; gitHead: string; branch: string; repositoryClean: boolean; registeredAtUnixMs: number } +interface CodeChannelSnapshot { state: string; channels: CodeChannelEntry[]; authority: string } +interface CodeTreeEntry { path: string; name: string; kind: 'directory' | 'file'; sizeBytes: number } +interface CodeTreeSnapshot { channelId: string; path: string; entries: CodeTreeEntry[]; truncated: boolean } +interface CodeFileProjection { channelId: string; path: string; format: string; source: string; humanMarkdown: string; sizeBytes: number } +interface ReceiptEvent { sequence: number; kind: string; observedAtUnixMs: number; eventHash: string } +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 + resolvedName: string + resolvedDomain: string + lastValidCheck: number + graceDeadline: number + protocol: { gracePeriodDays: number; lighthouseAnchorUrl: string; lighthouseResolveUrl: string; coreChannelSource: string; origin: string } + syncNote: string } -interface ReleaseCandidate { - candidateId: string - confirmationToken: string - releaseId: string - currentVersion: string - version: string - notes: string - features: string[] - fixes: string[] - minimumVersion: string - dataMigrationRequired: boolean - compatibilityNotes?: string - restartMessage?: string - rollbackSupported: boolean - previousVersion?: string - expiresAtUnixMs: number +function domainDisplayName(domain: string): string { + const names: Record = { + FIFTH_DOMAIN: '第五域 · 光湖本源域', + MAIN_DOMAIN: '光湖主域', + BRANCH_DOMAIN: '光湖分域', + ZERO_DOMAIN: '光湖零域', + ZERO_SENSE_DOMAIN: '光湖零感域', + } + return names[domain] || '已登记光湖域' } -interface ReleaseCheckReceipt { - state: string - candidate?: ReleaseCandidate +function protocolOriginDisplayName(origin: string): string { + return origin.startsWith('FACTORY_DEFAULT') ? '内置默认协议(签名发布通道尚未配置)' : origin } - interface ServerPnccProjection { schema: string state: string @@ -108,43 +158,14 @@ interface ServerPnccProjection { writeAuthority: boolean } +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 } } +const previewKnowledge: KnowledgeSnapshot = { state: 'UNAVAILABLE', nativeRoot: '', legacyAvailable: false, documents: [], rawDocumentCount: 0, uniqueDocumentCount: 0, duplicateDocumentCount: 0, truncated: false } +const previewCode: CodeChannelSnapshot = { state: 'UNAVAILABLE', channels: [], authority: 'LOCAL_SOURCE_ACCESS_ONLY_NO_PUSH_OR_DEPLOY_AUTHORITY' } const themes: Array<{ id: ThemeId; name: string }> = [ - { id: 'night', name: '夜湖星光' }, - { id: 'dawn', name: '晨湖曦光' }, - { id: 'nebula', name: '星云紫夜' }, - { id: 'candle', name: '烛畔暖湖' }, - { id: 'clear', name: '清浅澄湖' }, + { id: 'night', name: '夜湖星光' }, { id: 'dawn', name: '晨湖曦光' }, { id: 'nebula', name: '星云紫夜' }, { id: 'candle', name: '烛畔暖湖' }, { id: 'clear', name: '清浅澄湖' }, ] - -const recoveryPreviewStates = [ - 'NONE', - 'AWAITING_HUMAN_HEALTH_CONFIRMATION', - 'ROLLED_BACK_AWAITING_HUMAN_ACKNOWLEDGEMENT', - 'INSTALL_FAILED_BACKUP_RETAINED', - 'INSTALL_INTERRUPTED_BACKUP_RETAINED', - 'INSTALL_REPLACED_AWAITING_MANUAL_RESTART', - 'ROLLBACK_REPLACED_AWAITING_MANUAL_RESTART', - 'RECOVERY_STATE_UNREADABLE_ACTION_REQUIRED', -] -const requestedRecoveryPreview = import.meta.env.DEV - ? new URLSearchParams(window.location.search).get('releaseRecoveryState') - : null -const recoveryPreviewState = requestedRecoveryPreview && recoveryPreviewStates.includes(requestedRecoveryPreview) - ? requestedRecoveryPreview - : 'NONE' - -const previewStatus: HomeStatus = { - schema: 'hololake.home-status/preview', - directLocalBrokerState: 'PREVIEW', - directConnectionCount: 0, - resumableSessionCount: 0, - codeRepositoryMountCount: 0, - pnccReceiptCount: 0, - updateState: 'UNPROVISIONED_FAIL_CLOSED', - releaseRecoveryState: recoveryPreviewState, - automaticUpstreamUpdates: false, - mcpRole: 'DISCOVERY_RECOVERY_COMPATIBILITY_ONLY', -} +const viewLabels: Record = { overview: '个人频道', knowledge: '知识空间', code: '代码频道', receipts: '运行回执', system: '系统详情' } function getOrCreateLocalId(key: string, prefix: string) { const existing = window.localStorage.getItem(key) @@ -154,53 +175,190 @@ function getOrCreateLocalId(key: string, prefix: string) { return generated } -function Icon({ name }: { name: 'settings' | 'arrow' | 'receipt' | 'close' | 'copy' }) { - const paths = { - settings: <>, - arrow: <>, - receipt: <>, - close: <>, +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 '该代码频道已经存在,无需再次克隆。' + if (value.includes('CLONE_FAILED')) return '代码频道克隆失败,请核对地址及读取权限。' + 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' | 'download' | 'trash' | 'chevronDown' | 'settings' | 'more' | 'plus' }) { + const paths: Record = { + overview: <>, + knowledge: <>, + code: <>, + receipts: <>, + system: <>, + search: <>, + import: <>, + download: <>, + trash: <>, + chevronDown: , + settings: <>, + more: <>, + plus: , + folder: , + file: <>, copy: <>, + arrow: <>, + chevron: , + edit: <>, + save: <>, + back: , } return } + +interface TreeNode { name: string; key: string; folders: Map; documents: KnowledgeDocumentSummary[] } +function knowledgeTree(documents: KnowledgeDocumentSummary[]) { + const root: TreeNode = { name: '知识', key: '', folders: new Map(), documents: [] } + for (const document of documents) { + const segments = document.path.split('/').filter(Boolean) + const file = segments.pop() + let cursor = root + for (const segment of segments) { + const key = cursor.key ? `${cursor.key}/${segment}` : segment + if (!cursor.folders.has(segment)) cursor.folders.set(segment, { name: segment, key, folders: new Map(), documents: [] }) + cursor = cursor.folders.get(segment)! + } + cursor.documents.push({ ...document, title: document.title || file || '未命名页面' }) + } + return root +} +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, onRemoveFolder, folderMenuKey, onFolderMenuToggle }: { node: TreeNode; depth: number; expanded: Set; active?: string; onToggle: (key: string) => void; onOpen: (item: KnowledgeDocumentSummary) => void; onRemoveFolder?: (folder: TreeNode) => void; folderMenuKey?: string | null; onFolderMenuToggle?: (key: string) => void }) { + const folders = [...node.folders.values()].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')) + const documents = [...node.documents].sort((a, b) => a.title.localeCompare(b.title, 'zh-CN')) + return <> + {folders.map((folder) => { + const open = depth === 0 || expanded.has(folder.key) + return
+ + {onRemoveFolder && onFolderMenuToggle && {folderMenuKey === folder.key && }} + {open && } +
+ })} + {documents.map((document) => )} + +} + function HoloLakeApp() { const [theme, setTheme] = useState(() => (window.localStorage.getItem('hololake-theme') as ThemeId) || 'night') + const [view, setView] = useState('knowledge') const [status, setStatus] = useState(previewStatus) - const [panel, setPanel] = useState(null) - const [ticket, setTicket] = useState(null) + const [personal, setPersonal] = useState(previewPersonal) + const [knowledge, setKnowledge] = useState(previewKnowledge) + const [codeChannels, setCodeChannels] = useState(previewCode) + const [activeDocument, setActiveDocument] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [searchResults, setSearchResults] = useState(null) + const [knowledgeBusy, setKnowledgeBusy] = useState(false) + const [knowledgeMessage, setKnowledgeMessage] = useState('') + const [expanded, setExpanded] = useState>(() => new Set(['导入'])) + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState('') + const [lastKnowledgeReceipt, setLastKnowledgeReceipt] = useState('') + const [cloneUrl, setCloneUrl] = useState('') + const [codeBusy, setCodeBusy] = useState(false) + const [codeMessage, setCodeMessage] = useState('') + const [activeChannel, setActiveChannel] = useState(null) + const [codeTree, setCodeTree] = useState(null) + const [activeCodeFile, setActiveCodeFile] = useState(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(null) + const headingScrollTimer = useRef(null) + const [settingsMenuOpen, setSettingsMenuOpen] = useState(false) + const [folderMenuKey, setFolderMenuKey] = useState(null) + const [displayName, setDisplayName] = useState('') + const [identityBusy, setIdentityBusy] = useState(false) + const [identityMessage, setIdentityMessage] = useState('') const [receipts, setReceipts] = useState([]) - const [repositoryCandidate, setRepositoryCandidate] = useState(null) - const [repositoryBusy, setRepositoryBusy] = useState(false) - const [repositoryMessage, setRepositoryMessage] = useState('') + const [ticket, setTicket] = useState(null) + const [systemMessage, setSystemMessage] = useState('') const [releaseCandidate, setReleaseCandidate] = useState(null) - const [releaseBusy, setReleaseBusy] = useState(false) - const [releaseMessage, setReleaseMessage] = useState('') - const [message, setMessage] = useState('') + const [repoLogin, setRepoLogin] = useState(null) + const [loginUsername, setLoginUsername] = useState('') + const [loginPassword, setLoginPassword] = useState('') + const [loginBusy, setLoginBusy] = useState(false) + const [loginRising, setLoginRising] = useState(false) + const [loginMessage, setLoginMessage] = useState('') + const [gateStage, setGateStage] = useState<'number' | 'key'>('number') + const [gateRaw, setGateRaw] = useState('') + const [gateInf, setGateInf] = useState(false) + const gateNumber = formatGateNumber(gateRaw) + (gateInf ? '∞' : '') + const [gateOpen, setGateOpen] = useState(false) + const gateInputRef = useRef(null) + // 等待入口展开动画进入稳定阶段后再聚焦输入框,避免动画期间焦点跳转。 + useEffect(() => { + if (!gateOpen || gateStage !== 'number') return + const timer = window.setTimeout(() => gateInputRef.current?.focus(), 640) + return () => window.clearTimeout(timer) + }, [gateOpen, gateStage]) + const [gateBusy, setGateBusy] = useState(false) + const [gateMessage, setGateMessage] = useState('') + const [gateRising, setGateRising] = useState(false) + const [systemBusy, setSystemBusy] = useState(false) + const [zeroPoint, setZeroPoint] = useState(null) + const [zpNumber, setZpNumber] = useState('') + const [zpBusy, setZpBusy] = useState(false) + const [zpMessage, setZpMessage] = useState('') const [serverPncc, setServerPncc] = useState(null) const [serverPnccBusy, setServerPnccBusy] = useState(false) const [serverPnccReadback, setServerPnccReadback] = useState<'WAITING' | 'LIVE' | 'UNAVAILABLE'>('WAITING') - const refreshStatus = useCallback(async () => { - try { - setStatus(await invoke('get_hololake_home_status')) - } catch { - setStatus(previewStatus) - } + const refreshCore = useCallback(async () => { + const [homeResult, personalResult, knowledgeResult, codeResult] = await Promise.allSettled([ + invoke('get_hololake_home_status'), + invoke('get_personal_channel_snapshot'), + invoke('get_knowledge_snapshot'), + invoke('get_code_channel_snapshot'), + ]) + if (homeResult.status === 'fulfilled') setStatus(homeResult.value) + if (personalResult.status === 'fulfilled') setPersonal(personalResult.value) + if (knowledgeResult.status === 'fulfilled') setKnowledge(knowledgeResult.value) + if (codeResult.status === 'fulfilled') setCodeChannels(codeResult.value) + }, []) + const loadReceipts = useCallback(async () => { + try { + const projection = await invoke('query_pncc_receipt_projection', { input: { afterSequence: 0, limit: 25 } }) + setReceipts(projection.events) + } catch { setReceipts([]) } }, []) - useEffect(() => { - void refreshStatus() - const timer = window.setInterval(() => void refreshStatus(), 3000) + void refreshCore() + const timer = window.setInterval(() => void refreshCore(), 3500) return () => window.clearInterval(timer) - }, [refreshStatus]) - + }, [refreshCore]) const refreshServerPncc = useCallback(async () => { setServerPnccBusy(true) try { - const projection = await invoke('query_jd_pncc_server_projection') - setServerPncc(projection) + setServerPncc(await invoke('query_jd_pncc_server_projection')) setServerPnccReadback('LIVE') } catch { setServerPncc(null) @@ -209,161 +367,324 @@ function HoloLakeApp() { setServerPnccBusy(false) } }, []) - useEffect(() => { void refreshServerPncc() const timer = window.setInterval(() => void refreshServerPncc(), 15000) return () => window.clearInterval(timer) }, [refreshServerPncc]) - useEffect(() => { document.documentElement.dataset.theme = theme window.localStorage.setItem('hololake-theme', theme) }, [theme]) - - const connectionState = useMemo(() => { - if (status.directConnectionCount > 0) return { label: '已连接', detail: `${status.directConnectionCount} 个直连通道在线`, tone: 'ready' } - if (status.resumableSessionCount > 0) return { label: '可续接', detail: `${status.resumableSessionCount} 个会话等待续接`, tone: 'waiting' } - return { label: '等待连接', detail: '本机直连核心已就绪', tone: 'quiet' } - }, [status]) - - const releaseRecoveryIdle = useMemo(() => [ - 'NONE', - 'HEALTH_CONFIRMED_BACKUP_REMOVED', - 'ROLLBACK_CONFIRMED_BACKUP_REMOVED', - 'INSTALL_FAILURE_ACKNOWLEDGED_BACKUP_REMOVED', - ].includes(status.releaseRecoveryState), [status.releaseRecoveryState]) - - const releaseRecoveryNeedsAttention = !releaseRecoveryIdle - - const openReceipts = async () => { - setPanel('receipts') - await loadReceipts() + useEffect(() => { + invoke('check_code_repo_login').then((session) => setRepoLogin(session)).catch(() => setRepoLogin(null)) + }, []) + const loadZeroPoint = useCallback(async () => { + try { setZeroPoint(await invoke('zero_point_status')) } catch { /* 验证服务尚未就绪时保持空白状态。 */ } + }, []) + useEffect(() => { + void loadZeroPoint() + const timer = window.setInterval(() => void loadZeroPoint(), 10000) + return () => window.clearInterval(timer) + }, [loadZeroPoint]) + // 本机存在已绑定编号时自动回填,减少重复输入。 + useEffect(() => { + if (gateStage !== 'number' || gateRaw) return + const bound = zeroPoint?.binding === 'bound' ? zeroPoint.userNumber : '' + if (!bound) return + setGateInf(bound.includes('∞')) + setGateRaw(bound.toUpperCase().replace(/[^A-Z0-9]/g, '')) + }, [zeroPoint, gateStage, gateRaw]) + // 大门·编号门输入:只收字母数字(杠系统补、∞开关点),删到∞时开关跟着灭 + const onGateInput = (value: string) => { + if (gateInf && !value.includes('∞')) setGateInf(false) + setGateRaw(value.toUpperCase().replace(/[^A-Z0-9]/g, '')) } - - const loadReceipts = async () => { + // 大门·编号门:先报编号→灯塔查号→只答有效/无效→域浮起→才见钥匙门 + const gateVerifyNumber = async () => { + setGateBusy(true) + setGateMessage('') try { - const projection = await invoke('query_pncc_receipt_projection', { input: { afterSequence: 0, limit: 25 } }) - setReceipts(projection.events) - } catch { - setReceipts([]) - } - } - - const chooseRepository = async () => { - setRepositoryBusy(true) - setRepositoryMessage('') - try { - const candidate = await invoke('select_pncc_repository_candidate') - setRepositoryCandidate(candidate) - if (!candidate) setRepositoryMessage('已取消选择,没有写入任何绑定。') - } catch (error) { - setRepositoryCandidate(null) - setRepositoryMessage(`这个文件夹不能作为代码通道:${String(error)}`) - } finally { - setRepositoryBusy(false) - } - } - - const confirmRepository = async () => { - if (!repositoryCandidate) return - setRepositoryBusy(true) - setRepositoryMessage('') - try { - await invoke('confirm_pncc_repository_mount', { - input: { - candidateId: repositoryCandidate.candidateId, - confirmationToken: repositoryCandidate.confirmationToken, - }, - }) - setRepositoryCandidate(null) - setRepositoryMessage('代码仓库已按当前固定提交绑定为只读通道。') - await Promise.all([refreshStatus(), loadReceipts()]) - } catch (error) { - setRepositoryMessage(`绑定没有写入:${String(error)}`) - } finally { - setRepositoryBusy(false) - } - } - - const checkUpdate = async () => { - setReleaseBusy(true) - setReleaseMessage('') - try { - const receipt = await invoke('check_hololake_update') - setReleaseCandidate(receipt.candidate || null) - if (!receipt.candidate) setReleaseMessage('当前已经是最新版本。') - } catch (error) { - const reason = String(error) - setReleaseCandidate(null) - setReleaseMessage(reason.includes('UNPROVISIONED_NO_NETWORK_REQUEST') - ? '京东主控的正式发布地址和更新公钥还没有登记。HoloLake 保持离线,不会向任何上游检查更新。' - : `更新检查没有完成:${reason}`) - } finally { - setReleaseBusy(false) - } - } - - const confirmUpdateInstall = async () => { - if (!releaseCandidate) return - setReleaseBusy(true) - setReleaseMessage('正在重新核对广播并验证安装包;完成后由你手动重启 HoloLake。') - try { - await invoke('confirm_hololake_update_install', { - input: { - candidateId: releaseCandidate.candidateId, - confirmationToken: releaseCandidate.confirmationToken, - }, - }) - setReleaseCandidate(null) - setReleaseMessage('签名安装包已验证并安装。请在准备好后手动重启 HoloLake。') - await refreshStatus() - } catch (error) { - setReleaseMessage(`更新没有安装:${String(error)}`) - } finally { - setReleaseBusy(false) - } - } - - const resolveReleaseRecovery = async (action: 'keep' | 'rollback') => { - setReleaseBusy(true) - setReleaseMessage(action === 'rollback' ? '正在复核上一版签名并恢复;完成后仍由你手动重启。' : '正在确认当前版本健康并清理上一版副本。') - try { - if (action === 'rollback') { - await invoke('rollback_hololake_update') - setReleaseMessage('上一版已恢复到应用位置。请手动重启 HoloLake,启动后再确认恢复正常。') + await invoke('zero_point_bind', { input: { number: gateNumber } }) + const snapshot = await invoke('zero_point_verify') + setZeroPoint(snapshot) + if (snapshot.route === 'verified') { + setGateRising(true) + window.setTimeout(() => { setGateRising(false); setGateStage('key') }, 2600) } else { - await invoke('confirm_hololake_release_health') - setReleaseMessage('当前运行版本已由你确认,临时回退副本已经清理。') + setGateMessage('编号无效') } - await refreshStatus() } catch (error) { - setReleaseMessage(`没有改变当前应用:${String(error)}`) + setGateMessage(humanError(error, 'login')) } finally { - setReleaseBusy(false) + setGateBusy(false) } } + useEffect(() => { if (view === 'receipts') void loadReceipts() }, [loadReceipts, view]) - const issueInvitation = async () => { - setMessage('') + const visibleDocuments = useMemo(() => { + if (!searchResults) return knowledge.documents + return searchResults.map((result) => knowledge.documents.find((item) => item.source === result.source && item.path === result.path)).filter(Boolean) as KnowledgeDocumentSummary[] + }, [knowledge.documents, searchResults]) + const tree = useMemo(() => knowledgeTree(visibleDocuments), [visibleDocuments]) + const activeSummary = activeDocument + ? knowledge.documents.find((item) => item.source === activeDocument.source && item.path === activeDocument.path) + : 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) => { + 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('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 + ? '本地原生连接 · 客户端在线' + : status.resumableSessionCount > 0 + ? '原生会话可续接 · 当前离线' + : '原生接口已驻留 · 尚无客户端' + + const openDocument = async (source: KnowledgeSource, path: string) => { + setKnowledgeBusy(true) + setKnowledgeMessage('') + setEditing(false) try { - const receipt = await invoke('issue_direct_local_discovery_ticket', { + const document = await invoke('read_knowledge_document', { input: { source, path } }) + setActiveDocument(document) + setDraft(document.body) + } catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) } + finally { setKnowledgeBusy(false) } + } + const openWiki = (target: string) => { + const normalized = target.replace(/\.md$/i, '').toLowerCase() + const match = knowledge.documents.find((item) => + item.title.toLowerCase() === normalized || item.path.replace(/\.md$/i, '').toLowerCase().endsWith(normalized)) + if (match) void openDocument(match.source, match.path) + else setKnowledgeMessage(`未找到链接页面:${target}`) + } + const runSearch = async (event: React.FormEvent) => { + event.preventDefault() + if (!searchQuery.trim()) { setSearchResults(null); return } + setKnowledgeBusy(true) + try { + setSearchResults(await invoke('search_knowledge', { input: { query: searchQuery } })) + setActiveDocument(null) + } catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) } + finally { setKnowledgeBusy(false) } + } + const importKnowledge = async () => { + setKnowledgeBusy(true) + setKnowledgeMessage('') + try { + const result = await invoke('select_and_import_knowledge_folder') + if (!result) return + setKnowledge(result.snapshot) + setSearchResults(null) + setSearchQuery('') + setLastKnowledgeReceipt(result.gitCommit) + setKnowledgeMessage(`导入完成:新增 ${result.importedDocuments},已存在 ${result.existingDocuments},冲突 ${result.conflicts}。`) + if (result.firstDocument) await openDocument('native', result.firstDocument) + } 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 = `${activeDocument.title}

${activeDocument.title}

${article}` + } + 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('get_knowledge_snapshot') + setKnowledge(snapshot) + } + 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) + try { + const result = await invoke('save_knowledge_document', { + input: { path: activeDocument.path, body: draft, expectedContentSha256: activeDocument.contentSha256 }, + }) + setActiveDocument(result.document) + setDraft(result.document.body) + setEditing(false) + setLastKnowledgeReceipt(result.gitCommit) + setKnowledgeMessage(result.state === 'UNCHANGED' ? '内容没有变化。' : '页面已保存,并写入本机 Git 回执。') + await refreshCore() + } catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) } + finally { setKnowledgeBusy(false) } + } + + const cloneCodeChannel = async (event: React.FormEvent) => { + event.preventDefault() + if (!cloneUrl.trim()) return + setCodeBusy(true) + setCodeMessage('') + try { + const snapshot = await invoke('clone_code_channel', { input: { url: cloneUrl } }) + setCodeChannels(snapshot) + setCloneUrl('') + setCodeMessage('代码频道已克隆并登记。') + } catch (error) { setCodeMessage(humanError(error, 'code')) } + finally { setCodeBusy(false) } + } + const selectLocalCodeChannel = async () => { + setCodeBusy(true) + setCodeMessage('') + try { + const snapshot = await invoke('select_local_code_channel') + if (snapshot) { + setCodeChannels(snapshot) + setCodeMessage('本地代码频道已登记,原文件未移动。') + } + } catch (error) { setCodeMessage(humanError(error, 'code')) } + finally { setCodeBusy(false) } + } + const browseChannel = async (channel: CodeChannelEntry, path = '') => { + setCodeBusy(true) + setActiveChannel(channel) + setActiveCodeFile(null) + try { + setCodeTree(await invoke('browse_code_channel', { input: { channelId: channel.channelId, path } })) + } catch (error) { setCodeMessage(humanError(error, 'code')) } + finally { setCodeBusy(false) } + } + const openCodeEntry = async (entry: CodeTreeEntry) => { + if (!activeChannel) return + if (entry.kind === 'directory') { await browseChannel(activeChannel, entry.path); return } + setCodeBusy(true) + setCodeMode('human') + try { + setActiveCodeFile(await invoke('read_code_channel_file', { + input: { channelId: activeChannel.channelId, path: entry.path }, + })) + } catch (error) { setCodeMessage(humanError(error, 'code')) } + finally { setCodeBusy(false) } + } + 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('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) + try { + setPersonal(await invoke('initialize_personal_channel', { input: { displayName } })) + setDisplayName('') + } catch (error) { setIdentityMessage(humanError(error, 'identity')) } + finally { setIdentityBusy(false) } + } + const issueInvitation = async () => { + setSystemBusy(true) + setSystemMessage('') + try { + setTicket(await invoke('issue_direct_local_discovery_ticket', { input: { accountId: getOrCreateLocalId('hololake-local-account', 'human-local'), laneId: 'personal-channel', clientInstanceId: getOrCreateLocalId('hololake-external-ai-client', 'external-ai'), }, - }) - setTicket(receipt) - setMessage('连接邀请已生成。它只在这台电脑上有效。') - } catch (error) { - setMessage(`暂时无法生成连接邀请:${String(error)}`) - } + })) + setSystemMessage('一次性发现凭据已生成。连接成功后,状态条会显示真实在线数。') + } catch (error) { setSystemMessage(humanError(error, 'system')) } + finally { setSystemBusy(false) } } - const invitationText = ticket ? JSON.stringify({ schema: 'hololake.direct-local-invitation/v1', - transport: 'INSTALLED_HOLOLAKE_EXECUTABLE_WITH_CONNECTOR_FLAG', connectorArgument: '--connector', openSession: { operation: 'OPEN_SESSION', @@ -374,182 +695,274 @@ function HoloLakeApp() { discoveryTicket: ticket.discoveryTicket, }, }, - mcpRole: 'DISCOVERY_RECOVERY_COMPATIBILITY_ONLY', }, null, 2) : '' - const copyInvitation = async () => { try { await navigator.clipboard.writeText(invitationText) - setMessage('已复制。把它交给桌面上的编程 AI 即可。') - } catch { - setMessage('复制没有成功,请选中下方邀请内容后复制。') - } + setSystemMessage('连接凭据已复制。') + } catch { setSystemMessage('复制失败,请手动选择凭据。') } + } + const checkUpdate = async () => { + setSystemBusy(true) + try { + const result = await invoke('check_hololake_update') + setReleaseCandidate(result.candidate || null) + setSystemMessage(result.candidate ? '发现已签名更新。' : '当前没有可安装更新。') + } catch (error) { setSystemMessage(humanError(error, 'system')) } + finally { setSystemBusy(false) } + } + const installUpdate = async () => { + if (!releaseCandidate) return + setSystemBusy(true) + try { + await invoke('confirm_hololake_update_install', { + input: { candidateId: releaseCandidate.candidateId, confirmationToken: releaseCandidate.confirmationToken }, + }) + setSystemMessage('更新安装已启动。') + } catch (error) { setSystemMessage(humanError(error, 'system')) } + finally { setSystemBusy(false) } } - return ( -
-