feat(hololake): complete public runtime loop
This commit is contained in:
parent
71cece74b0
commit
92cce27973
26 changed files with 2626 additions and 79 deletions
|
|
@ -642,7 +642,7 @@ dependencies = [
|
|||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -801,7 +801,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1328,8 +1328,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
|||
|
||||
[[package]]
|
||||
name = "hololake-clean-desktop"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
|
@ -3061,7 +3062,7 @@ dependencies = [
|
|||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3117,7 +3118,7 @@ dependencies = [
|
|||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3527,7 +3528,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4113,10 +4114,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4876,7 +4877,7 @@ version = "0.1.11"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "hololake-clean-desktop"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
description = "HoloLake clean personal language operating system shell"
|
||||
authors = ["HoloLake"]
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
|
@ -25,6 +25,7 @@ serde = { version = "1", features = ["derive"] }
|
|||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
regex = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,414 @@
|
|||
use crate::{model::SourceKind, storage};
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::{collections::BTreeMap, fs, path::Path};
|
||||
use tauri::AppHandle;
|
||||
|
||||
const REQUIRED_SECTIONS: &[&str] = &[
|
||||
"header",
|
||||
"source",
|
||||
"subject",
|
||||
"target",
|
||||
"inputs",
|
||||
"outputs",
|
||||
"conditions",
|
||||
"actions",
|
||||
"authority",
|
||||
"resources",
|
||||
"failure",
|
||||
"stop",
|
||||
"cleanup",
|
||||
"rollback",
|
||||
"receipt",
|
||||
];
|
||||
const REGISTERED_OPERATIONS: &[&str] = &[
|
||||
"KNOWLEDGE.CREATE",
|
||||
"KNOWLEDGE.UPDATE",
|
||||
"KNOWLEDGE.DELETE",
|
||||
"KNOWLEDGE.READ",
|
||||
"KNOWLEDGE.LIST",
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct TcsCompileRequest {
|
||||
pub source: String,
|
||||
#[serde(default)]
|
||||
pub inputs: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct GirAction {
|
||||
pub action_id: String,
|
||||
pub operation: String,
|
||||
pub input: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct GirProgram {
|
||||
pub schema: String,
|
||||
pub program_id: String,
|
||||
pub channel_id: String,
|
||||
pub source_sha256: String,
|
||||
pub compiler_id: String,
|
||||
pub unresolved_natural_language: bool,
|
||||
pub actions: Vec<GirAction>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AgentProposal {
|
||||
pub proposal_id: String,
|
||||
pub client_id: String,
|
||||
pub persona_id: Option<String>,
|
||||
pub state: String,
|
||||
pub created_at: String,
|
||||
pub gir: GirProgram,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentReceipt {
|
||||
pub schema: String,
|
||||
pub receipt_id: String,
|
||||
pub proposal_id: String,
|
||||
pub state: String,
|
||||
pub action_results: Vec<Value>,
|
||||
pub git_commit: Option<String>,
|
||||
pub target_readback_sha256: String,
|
||||
}
|
||||
|
||||
fn assignments(block: &str) -> Result<Vec<String>, String> {
|
||||
let string_re = Regex::new(r#"\"((?:\\.|[^\"])*)\""#).unwrap();
|
||||
string_re
|
||||
.captures_iter(block)
|
||||
.map(|capture| {
|
||||
serde_json::from_str::<String>(&format!("\"{}\"", &capture[1]))
|
||||
.map_err(|_| "TCS-E1001 INVALID_STRING".to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn compile(
|
||||
source: &str,
|
||||
inputs: &BTreeMap<String, Value>,
|
||||
channel_id: &str,
|
||||
) -> Result<GirProgram, String> {
|
||||
if source.len() > 256 * 1024 || !source.trim_start().starts_with("TCS 0.1;") {
|
||||
return Err("TCS-E1001 INVALID_PROLOGUE".into());
|
||||
}
|
||||
for section in REQUIRED_SECTIONS {
|
||||
let re = Regex::new(&format!(r"(?m)\b{}\s*\{{", regex::escape(section))).unwrap();
|
||||
if !re.is_match(source) {
|
||||
return Err(format!("TCS-E1004 REQUIRED_SECTION_MISSING:{section}"));
|
||||
}
|
||||
}
|
||||
let program_re = Regex::new(r"(?m)\bPROGRAM\s+([\p{L}_][\p{L}\p{N}_.:/@-]*)\s*\{").unwrap();
|
||||
let program_id = program_re
|
||||
.captures(source)
|
||||
.and_then(|capture| capture.get(1))
|
||||
.map(|m| m.as_str().to_owned())
|
||||
.ok_or_else(|| "TCS-E1001 PROGRAM_DECLARATION_REQUIRED".to_string())?;
|
||||
let action_re = Regex::new(r#"(?s)([\p{L}_][\p{L}\p{N}_.:/@-]*)\s*\{[^{}]*?operation\s*=\s*\"([^\"]+)\"\s*;[^{}]*?input_refs\s*=\s*\[([^\]]*)\]"#).unwrap();
|
||||
let mut actions = Vec::new();
|
||||
for capture in action_re.captures_iter(source) {
|
||||
let operation = capture[2].to_string();
|
||||
if !REGISTERED_OPERATIONS.contains(&operation.as_str()) {
|
||||
return Err(format!("TCS-E2101 UNREGISTERED_OPERATION:{operation}"));
|
||||
}
|
||||
let mut input = BTreeMap::new();
|
||||
for reference in assignments(&capture[3])? {
|
||||
let value = inputs
|
||||
.get(&reference)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("TCS-E2002 REFERENCE_NOT_DECLARED:{reference}"))?;
|
||||
input.insert(reference, value);
|
||||
}
|
||||
actions.push(GirAction {
|
||||
action_id: capture[1].to_string(),
|
||||
operation,
|
||||
input,
|
||||
});
|
||||
}
|
||||
if actions.is_empty() {
|
||||
return Err("TCS-E1004 ACTION_REQUIRED".into());
|
||||
}
|
||||
Ok(GirProgram {
|
||||
schema: "hololake.gir-program/v1".into(),
|
||||
program_id,
|
||||
channel_id: channel_id.into(),
|
||||
source_sha256: storage::sha(source.as_bytes()),
|
||||
compiler_id: "HLP-TCS-PUBLIC-STAGE0-0001".into(),
|
||||
unresolved_natural_language: false,
|
||||
actions,
|
||||
})
|
||||
}
|
||||
|
||||
fn proposals_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
Ok(storage::root(app)?.join("agent-proposals.json"))
|
||||
}
|
||||
fn load(app: &AppHandle) -> Result<Vec<AgentProposal>, String> {
|
||||
let path = proposals_path(app)?;
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
storage::read_json(&path)
|
||||
}
|
||||
fn save(app: &AppHandle, proposals: &[AgentProposal]) -> Result<(), String> {
|
||||
storage::write_json(&proposals_path(app)?, proposals)
|
||||
}
|
||||
|
||||
pub fn list(app: &AppHandle) -> Result<Vec<AgentProposal>, String> {
|
||||
load(app)
|
||||
}
|
||||
|
||||
pub fn queue(
|
||||
app: &AppHandle,
|
||||
request: TcsCompileRequest,
|
||||
client_id: String,
|
||||
persona_id: Option<String>,
|
||||
) -> Result<AgentProposal, String> {
|
||||
let channel = storage::channel(app)?.ok_or_else(|| "CHANNEL_NOT_INITIALIZED".to_string())?;
|
||||
let gir = compile(&request.source, &request.inputs, &channel.channel_id)?;
|
||||
let proposal = AgentProposal {
|
||||
proposal_id: storage::id("HL-PROP"),
|
||||
client_id,
|
||||
persona_id,
|
||||
state: "PENDING_HUMAN_APPROVAL".into(),
|
||||
created_at: storage::now(),
|
||||
gir,
|
||||
};
|
||||
let mut proposals = load(app)?;
|
||||
proposals.push(proposal.clone());
|
||||
save(app, &proposals)?;
|
||||
storage::append_event(
|
||||
app,
|
||||
&storage::event(
|
||||
SourceKind::ProtocolEvent,
|
||||
"TCS 已编译为 GIR",
|
||||
format!("{} 等待人类审批。", proposal.proposal_id),
|
||||
"WAITING",
|
||||
),
|
||||
)?;
|
||||
Ok(proposal)
|
||||
}
|
||||
|
||||
fn value<'a>(input: &'a BTreeMap<String, Value>, name: &str) -> Result<&'a Value, String> {
|
||||
input
|
||||
.get(name)
|
||||
.or_else(|| input.get(&name.to_lowercase()))
|
||||
.ok_or_else(|| format!("TCS-E2002 INPUT_REQUIRED:{name}"))
|
||||
}
|
||||
fn text(input: &BTreeMap<String, Value>, name: &str) -> Result<String, String> {
|
||||
value(input, name)?
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| format!("TCS-E2001 TEXT_REQUIRED:{name}"))
|
||||
}
|
||||
fn safe_title(title: &str) -> Result<String, String> {
|
||||
let title = title.trim();
|
||||
if title.is_empty() || title.chars().count() > 120 {
|
||||
return Err("KNOWLEDGE_TITLE_INVALID".into());
|
||||
}
|
||||
Ok(title
|
||||
.chars()
|
||||
.map(|c| if "\\/:*?\"<>|".contains(c) { '-' } else { c })
|
||||
.collect())
|
||||
}
|
||||
fn move_to_trash(root: &Path, relative: &Path) -> Result<(), String> {
|
||||
let source = root.join("docs").join(relative);
|
||||
if !source.exists() {
|
||||
return Err("KNOWLEDGE_DOCUMENT_NOT_FOUND".into());
|
||||
}
|
||||
let target = root.join(".trash").join(format!(
|
||||
"{}-{}",
|
||||
storage::id("agent"),
|
||||
relative
|
||||
.file_name()
|
||||
.and_then(|v| v.to_str())
|
||||
.unwrap_or("document.md")
|
||||
));
|
||||
fs::create_dir_all(target.parent().unwrap()).map_err(|e| e.to_string())?;
|
||||
fs::rename(source, target).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn approve(app: &AppHandle, proposal_id: &str) -> Result<AgentReceipt, String> {
|
||||
let mut proposals = load(app)?;
|
||||
let index = proposals
|
||||
.iter()
|
||||
.position(|proposal| proposal.proposal_id == proposal_id)
|
||||
.ok_or_else(|| "AGENT_PROPOSAL_NOT_FOUND".to_string())?;
|
||||
if proposals[index].state != "PENDING_HUMAN_APPROVAL" {
|
||||
return Err("AGENT_PROPOSAL_NOT_PENDING".into());
|
||||
}
|
||||
let proposal = proposals[index].clone();
|
||||
let root = storage::knowledge_root(app)?;
|
||||
let mut results = Vec::new();
|
||||
for action in &proposal.gir.actions {
|
||||
let result = match action.operation.as_str() {
|
||||
"KNOWLEDGE.CREATE" => {
|
||||
let title = safe_title(&text(&action.input, "TITLE")?)?;
|
||||
let body = action
|
||||
.input
|
||||
.get("BODY")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let relative = format!("{}.md", title);
|
||||
let path = root.join("docs").join(&relative);
|
||||
if path.exists() {
|
||||
return Err("KNOWLEDGE_DOCUMENT_EXISTS".into());
|
||||
}
|
||||
fs::write(
|
||||
&path,
|
||||
if body.trim().is_empty() {
|
||||
format!("# {title}\n\n")
|
||||
} else {
|
||||
body.to_owned()
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::json!({"actionId":action.action_id,"path":relative,"sha256":storage::sha(&fs::read(path).map_err(|e|e.to_string())?)})
|
||||
}
|
||||
"KNOWLEDGE.UPDATE" => {
|
||||
let relative = storage::safe_rel(&text(&action.input, "PATH")?)?;
|
||||
let path = root.join("docs").join(&relative);
|
||||
let before =
|
||||
fs::read(&path).map_err(|_| "KNOWLEDGE_DOCUMENT_NOT_FOUND".to_string())?;
|
||||
if let Some(expected) = action.input.get("EXPECTED_SHA256").and_then(Value::as_str)
|
||||
{
|
||||
if storage::sha(&before) != expected {
|
||||
return Err("TCS-E6001 TARGET_STATE_MISMATCH".into());
|
||||
}
|
||||
}
|
||||
fs::write(&path, text(&action.input, "BODY")?).map_err(|e| e.to_string())?;
|
||||
serde_json::json!({"actionId":action.action_id,"path":relative,"sha256":storage::sha(&fs::read(path).map_err(|e|e.to_string())?)})
|
||||
}
|
||||
"KNOWLEDGE.DELETE" => {
|
||||
let relative = storage::safe_rel(&text(&action.input, "PATH")?)?;
|
||||
move_to_trash(&root, &relative)?;
|
||||
serde_json::json!({"actionId":action.action_id,"path":relative,"state":"MOVED_TO_TRASH"})
|
||||
}
|
||||
"KNOWLEDGE.READ" => {
|
||||
let path = text(&action.input, "PATH")?;
|
||||
let document = storage::read_doc(app, &path)?;
|
||||
serde_json::to_value(document).map_err(|e| e.to_string())?
|
||||
}
|
||||
"KNOWLEDGE.LIST" => {
|
||||
serde_json::to_value(storage::list_docs(app)?).map_err(|e| e.to_string())?
|
||||
}
|
||||
operation => return Err(format!("TCS-E2101 UNREGISTERED_OPERATION:{operation}")),
|
||||
};
|
||||
results.push(result);
|
||||
}
|
||||
let git_commit = storage::git_commit(
|
||||
&root,
|
||||
&format!("agent({}): execute approved GIR", proposal.gir.program_id),
|
||||
)?;
|
||||
let readback = storage::list_docs(app)?;
|
||||
let readback_bytes = serde_json::to_vec(&readback).map_err(|e| e.to_string())?;
|
||||
let receipt = AgentReceipt {
|
||||
schema: "hololake.agent-execution-receipt/v1".into(),
|
||||
receipt_id: storage::id("HL-AGENT-RCP"),
|
||||
proposal_id: proposal.proposal_id.clone(),
|
||||
state: "EXECUTED_TARGET_READBACK_VERIFIED".into(),
|
||||
action_results: results,
|
||||
git_commit,
|
||||
target_readback_sha256: storage::sha(&readback_bytes),
|
||||
};
|
||||
proposals[index].state = "EXECUTED".into();
|
||||
save(app, &proposals)?;
|
||||
storage::append_event(
|
||||
app,
|
||||
&storage::event(
|
||||
SourceKind::AgentAction,
|
||||
"Agent 已执行批准的 GIR",
|
||||
proposal.gir.program_id,
|
||||
"SUCCEEDED",
|
||||
),
|
||||
)?;
|
||||
storage::append_event(
|
||||
app,
|
||||
&storage::event(
|
||||
SourceKind::ToolResult,
|
||||
"目标读回",
|
||||
receipt.target_readback_sha256.clone(),
|
||||
"SUCCEEDED",
|
||||
),
|
||||
)?;
|
||||
storage::append_event(
|
||||
app,
|
||||
&storage::event(
|
||||
SourceKind::SystemReceipt,
|
||||
"系统回执",
|
||||
format!("{} 已完成并读回。", receipt.receipt_id),
|
||||
"SUCCEEDED",
|
||||
),
|
||||
)?;
|
||||
let receipt_path = storage::root(app)?
|
||||
.join("agent-receipts")
|
||||
.join(format!("{}.json", receipt.receipt_id));
|
||||
storage::write_json(&receipt_path, &receipt)?;
|
||||
Ok(receipt)
|
||||
}
|
||||
|
||||
pub fn reject(app: &AppHandle, proposal_id: &str) -> Result<AgentProposal, String> {
|
||||
let mut proposals = load(app)?;
|
||||
let proposal = proposals
|
||||
.iter_mut()
|
||||
.find(|proposal| proposal.proposal_id == proposal_id)
|
||||
.ok_or_else(|| "AGENT_PROPOSAL_NOT_FOUND".to_string())?;
|
||||
if proposal.state != "PENDING_HUMAN_APPROVAL" {
|
||||
return Err("AGENT_PROPOSAL_NOT_PENDING".into());
|
||||
}
|
||||
proposal.state = "REJECTED_BY_HUMAN".into();
|
||||
let result = proposal.clone();
|
||||
save(app, &proposals)?;
|
||||
storage::append_event(
|
||||
app,
|
||||
&storage::event(
|
||||
SourceKind::SystemReceipt,
|
||||
"执行已驳回",
|
||||
proposal_id,
|
||||
"SUCCEEDED",
|
||||
),
|
||||
)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn base_program(operation: &str) -> String {
|
||||
format!(
|
||||
r#"TCS 0.1;
|
||||
PROGRAM TEST-PROGRAM-0001 {{
|
||||
header {{ schema = "tcs.program/v1"; }} source {{ source_id = "S"; }} subject {{ subject_id = "P"; }} target {{ target_id = "K"; }}
|
||||
inputs {{ TITLE {{ type = "Text"; source = "EVENT"; required = true; }} }} outputs {{ RESULT {{ type = "Text"; destination = "R"; integrity = "SHA256"; }} }}
|
||||
conditions {{ C1 {{ predicate = "APPROVED"; on_false = "FAIL_CLOSED"; }} }} actions {{ A1 {{ operation = "{operation}"; input_refs = ["TITLE"]; output_refs = ["RESULT"]; on_success = "COMPLETE"; on_failure = "FAIL_CLOSED"; }} }}
|
||||
authority {{ issuer = "H"; }} resources {{ runway = "LOCAL"; }} failure {{ errors = []; }} stop {{ signals = []; }} cleanup {{ targets = []; }} rollback {{ preconditions = []; }} receipt {{ protocol = "GLP"; }}
|
||||
}}"#
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn public_tcs_compiles_registered_operation() {
|
||||
let mut inputs = BTreeMap::new();
|
||||
inputs.insert("TITLE".into(), Value::String("测试".into()));
|
||||
let gir = compile(&base_program("KNOWLEDGE.CREATE"), &inputs, "HL-CH-TEST").unwrap();
|
||||
assert_eq!(gir.actions[0].operation, "KNOWLEDGE.CREATE");
|
||||
}
|
||||
#[test]
|
||||
fn public_tcs_rejects_unregistered_operation() {
|
||||
let mut inputs = BTreeMap::new();
|
||||
inputs.insert("TITLE".into(), Value::String("测试".into()));
|
||||
assert!(compile(&base_program("ABSORB"), &inputs, "HL-CH-TEST")
|
||||
.unwrap_err()
|
||||
.contains("TCS-E2101"));
|
||||
}
|
||||
#[test]
|
||||
fn public_tcs_requires_all_program_sections() {
|
||||
assert!(compile("TCS 0.1; PROGRAM X { actions { A1 { operation = \"KNOWLEDGE.LIST\"; input_refs = []; } } }", &BTreeMap::new(), "HL-CH-TEST").unwrap_err().contains("TCS-E1004"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
mod agent_executor;
|
||||
mod model;
|
||||
mod persona_runtime;
|
||||
mod realtime_bridge;
|
||||
mod storage;
|
||||
|
||||
use model::*;
|
||||
use serde::Serialize;
|
||||
use std::{fs, path::Path};
|
||||
use tauri::AppHandle;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
const UPDATE_ENDPOINT: &str = "https://guanghulab.com/hololake/releases/latest.json";
|
||||
|
|
@ -76,7 +80,11 @@ fn create_channel(app: AppHandle, name: String) -> Result<Channel, String> {
|
|||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn submit_user_message(app: AppHandle, content: String) -> Result<MutationReceipt, String> {
|
||||
fn submit_user_message(
|
||||
app: AppHandle,
|
||||
realtime: State<'_, realtime_bridge::RealtimeBridgeState>,
|
||||
content: String,
|
||||
) -> Result<MutationReceipt, String> {
|
||||
let content = content.trim();
|
||||
if content.is_empty() {
|
||||
return Err("EMPTY_USER_MESSAGE".into());
|
||||
|
|
@ -86,16 +94,17 @@ fn submit_user_message(app: AppHandle, content: String) -> Result<MutationReceip
|
|||
}
|
||||
let e = storage::event(SourceKind::UserMessage, "用户语言", content, "RECORDED");
|
||||
storage::append_event(&app, &e)?;
|
||||
realtime.broadcast_value(serde_json::json!({"type":"user_message","event":e}));
|
||||
let waiting = storage::event(
|
||||
SourceKind::SystemReceipt,
|
||||
"系统回执",
|
||||
"用户语言已按 USER_MESSAGE 保存;当前未绑定人格模型,不伪造人格回应。",
|
||||
"用户语言已按 USER_MESSAGE 保存并实时发送到已连接的外部 AI;系统不伪造人格回应。",
|
||||
"WAITING",
|
||||
);
|
||||
receipt(
|
||||
&app,
|
||||
"WAITING_PERSONA",
|
||||
"用户语言已记录,等待人格体接入。",
|
||||
"用户语言已记录并投递到实时桥。",
|
||||
None,
|
||||
waiting,
|
||||
)
|
||||
|
|
@ -328,7 +337,7 @@ fn register_external_ai_bridge(
|
|||
display_name: name.into(),
|
||||
inbox_path: inbox.to_string_lossy().into_owned(),
|
||||
outbox_path: outbox.to_string_lossy().into_owned(),
|
||||
state: "LOCAL_EXPRESSION_ONLY_NO_EXECUTION_AUTHORITY".into(),
|
||||
state: "GLP_REALTIME_REGISTERED_NO_EXECUTION_AUTHORITY".into(),
|
||||
};
|
||||
let mut all = storage::bridges(&app)?;
|
||||
all.push(bridge.clone());
|
||||
|
|
@ -346,12 +355,166 @@ fn register_external_ai_bridge(
|
|||
Ok(bridge)
|
||||
}
|
||||
|
||||
fn ensure_default_external_ai_bridge(app: &AppHandle) -> Result<(), String> {
|
||||
let mut bridges = storage::bridges(app)?;
|
||||
if bridges
|
||||
.iter()
|
||||
.any(|bridge| bridge.bridge_id == "HLP-BRIDGE-LOCAL-DEFAULT")
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let root = storage::root(app)?
|
||||
.join("external-ai")
|
||||
.join("HLP-BRIDGE-LOCAL-DEFAULT");
|
||||
let inbox = root.join("inbox");
|
||||
let outbox = root.join("outbox");
|
||||
fs::create_dir_all(&inbox).map_err(|e| e.to_string())?;
|
||||
fs::create_dir_all(&outbox).map_err(|e| e.to_string())?;
|
||||
bridges.push(ExternalAiBridge {
|
||||
bridge_id: "HLP-BRIDGE-LOCAL-DEFAULT".into(),
|
||||
display_name: "本机编程 AI".into(),
|
||||
inbox_path: inbox.to_string_lossy().into_owned(),
|
||||
outbox_path: outbox.to_string_lossy().into_owned(),
|
||||
state: "GLP_REALTIME_REGISTERED_NO_EXECUTION_AUTHORITY".into(),
|
||||
});
|
||||
storage::save_bridges(app, &bridges)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RuntimeOverview {
|
||||
realtime: realtime_bridge::RealtimeBridgeStatus,
|
||||
persona: persona_runtime::PersonaRuntimeSnapshot,
|
||||
proposals: Vec<agent_executor::AgentProposal>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_runtime_overview(
|
||||
app: AppHandle,
|
||||
realtime: State<'_, realtime_bridge::RealtimeBridgeState>,
|
||||
) -> Result<RuntimeOverview, String> {
|
||||
Ok(RuntimeOverview {
|
||||
realtime: realtime.status(),
|
||||
persona: persona_runtime::snapshot(&app)?,
|
||||
proposals: agent_executor::list(&app)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_realtime_invitation(
|
||||
app: AppHandle,
|
||||
realtime: State<'_, realtime_bridge::RealtimeBridgeState>,
|
||||
) -> Result<realtime_bridge::RealtimeInvitation, String> {
|
||||
realtime_bridge::invitation(&app, &realtime)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn register_public_persona(
|
||||
app: AppHandle,
|
||||
display_name: String,
|
||||
) -> Result<persona_runtime::PublicPersona, String> {
|
||||
persona_runtime::register(&app, display_name)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_trial_persona(
|
||||
app: AppHandle,
|
||||
persona_id: String,
|
||||
exact_confirmation: String,
|
||||
) -> Result<persona_runtime::PersonaRuntimeSnapshot, String> {
|
||||
persona_runtime::delete_trial(&app, &persona_id, &exact_confirmation)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn compile_tcs_agent_proposal(
|
||||
app: AppHandle,
|
||||
request: agent_executor::TcsCompileRequest,
|
||||
) -> Result<agent_executor::AgentProposal, String> {
|
||||
agent_executor::queue(&app, request, "HOLOLAKE_LOCAL_UI".into(), None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn approve_agent_proposal(
|
||||
app: AppHandle,
|
||||
realtime: State<'_, realtime_bridge::RealtimeBridgeState>,
|
||||
proposal_id: String,
|
||||
) -> Result<agent_executor::AgentReceipt, String> {
|
||||
let receipt = agent_executor::approve(&app, &proposal_id)?;
|
||||
realtime.broadcast_value(serde_json::json!({"type":"agent_receipt","receipt":receipt}));
|
||||
Ok(receipt)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn reject_agent_proposal(
|
||||
app: AppHandle,
|
||||
realtime: State<'_, realtime_bridge::RealtimeBridgeState>,
|
||||
proposal_id: String,
|
||||
) -> Result<agent_executor::AgentProposal, String> {
|
||||
let proposal = agent_executor::reject(&app, &proposal_id)?;
|
||||
realtime
|
||||
.broadcast_value(serde_json::json!({"type":"proposal_rejected","proposalId":proposal_id}));
|
||||
Ok(proposal)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.setup(|app| {
|
||||
let diagnostic = storage::root(app.handle())
|
||||
.map_err(std::io::Error::other)?
|
||||
.join("startup-runtime-diagnostic.json");
|
||||
storage::write_json(
|
||||
&diagnostic,
|
||||
&serde_json::json!({"state":"SETUP_ENTERED","recordedAt":storage::now()}),
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
if std::env::var_os("HOLOLAKE_RUNTIME_TEST_ROOT").is_some() {
|
||||
if let Ok(channel_name) = std::env::var("HOLOLAKE_TEST_CHANNEL_NAME") {
|
||||
create_channel(app.handle().clone(), channel_name)
|
||||
.map_err(std::io::Error::other)?;
|
||||
}
|
||||
if let Ok(display_name) = std::env::var("HOLOLAKE_TEST_PERSONA_NAME") {
|
||||
persona_runtime::register(app.handle(), display_name)
|
||||
.map_err(std::io::Error::other)?;
|
||||
}
|
||||
}
|
||||
ensure_default_external_ai_bridge(app.handle()).map_err(std::io::Error::other)?;
|
||||
let state = match realtime_bridge::start(app.handle().clone()) {
|
||||
Ok(state) => state,
|
||||
Err(error) => {
|
||||
let _ = storage::write_json(
|
||||
&diagnostic,
|
||||
&serde_json::json!({"state":"LISTENER_FAILED","error":error.clone(),"recordedAt":storage::now()}),
|
||||
);
|
||||
return Err(std::io::Error::other(error).into());
|
||||
}
|
||||
};
|
||||
storage::write_json(
|
||||
&diagnostic,
|
||||
&serde_json::json!({"state":"LISTENER_BOUND","status":state.status(),"recordedAt":storage::now()}),
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
let invitation =
|
||||
realtime_bridge::invitation(app.handle(), &state).map_err(std::io::Error::other)?;
|
||||
storage::write_json(
|
||||
&storage::root(app.handle())
|
||||
.map_err(std::io::Error::other)?
|
||||
.join("startup-runtime-receipt.json"),
|
||||
&serde_json::json!({
|
||||
"schema":"hololake.startup-runtime-receipt/v1",
|
||||
"state":"GLP_LISTENING",
|
||||
"protocol":invitation.protocol,
|
||||
"endpoint":invitation.endpoint,
|
||||
"recordedAt":storage::now()
|
||||
}),
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
app.manage(state);
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
system_snapshot,
|
||||
create_channel,
|
||||
|
|
@ -362,7 +525,14 @@ pub fn run() {
|
|||
delete_knowledge_document,
|
||||
import_knowledge_folder,
|
||||
export_knowledge_document,
|
||||
register_external_ai_bridge
|
||||
register_external_ai_bridge,
|
||||
get_runtime_overview,
|
||||
get_realtime_invitation,
|
||||
register_public_persona,
|
||||
delete_trial_persona,
|
||||
compile_tcs_agent_proposal,
|
||||
approve_agent_proposal,
|
||||
reject_agent_proposal
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("HoloLake runtime failed")
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ pub struct Channel {
|
|||
pub enum SourceKind {
|
||||
UserMessage,
|
||||
PersonaResponse,
|
||||
ExternalAiMessage,
|
||||
SystemContext,
|
||||
ProtocolEvent,
|
||||
AgentAction,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
use crate::{model::SourceKind, storage};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use tauri::AppHandle;
|
||||
|
||||
const PERSONA_STATE: &str = "LOCAL_TRIAL_UNVERIFIED_EXTERNAL_HOST_REQUIRED";
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PublicPersona {
|
||||
pub persona_id: String,
|
||||
pub display_name: String,
|
||||
pub state: String,
|
||||
pub created_at: String,
|
||||
pub reversible_until_unix_ms: u128,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersonaRuntimeSnapshot {
|
||||
pub schema: &'static str,
|
||||
pub state: String,
|
||||
pub active_persona_id: Option<String>,
|
||||
pub personas: Vec<PublicPersona>,
|
||||
pub verified_existing_persona_count: usize,
|
||||
}
|
||||
|
||||
fn path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
Ok(storage::root(app)?.join("public-persona-runtime.json"))
|
||||
}
|
||||
|
||||
fn load(app: &AppHandle) -> Result<Vec<PublicPersona>, String> {
|
||||
let p = path(app)?;
|
||||
if !p.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
serde_json::from_slice(&fs::read(p).map_err(|e| e.to_string())?).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn save(app: &AppHandle, personas: &[PublicPersona]) -> Result<(), String> {
|
||||
storage::write_json(&path(app)?, personas)
|
||||
}
|
||||
|
||||
pub fn exists(app: &AppHandle, persona_id: &str) -> Result<bool, String> {
|
||||
Ok(load(app)?
|
||||
.iter()
|
||||
.any(|persona| persona.persona_id == persona_id))
|
||||
}
|
||||
|
||||
pub fn snapshot(app: &AppHandle) -> Result<PersonaRuntimeSnapshot, String> {
|
||||
let personas = load(app)?;
|
||||
Ok(PersonaRuntimeSnapshot {
|
||||
schema: "hololake.public-persona-runtime/v1",
|
||||
state: if personas.is_empty() {
|
||||
"READY_NO_PERSONA"
|
||||
} else {
|
||||
"TRIAL_PERSONA_READY"
|
||||
}
|
||||
.into(),
|
||||
active_persona_id: personas.first().map(|persona| persona.persona_id.clone()),
|
||||
personas,
|
||||
verified_existing_persona_count: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn register(app: &AppHandle, display_name: String) -> Result<PublicPersona, String> {
|
||||
let display_name = display_name.trim();
|
||||
if display_name.is_empty() || display_name.chars().count() > 64 {
|
||||
return Err("PERSONA_DISPLAY_NAME_INVALID".into());
|
||||
}
|
||||
let mut personas = load(app)?;
|
||||
if personas
|
||||
.iter()
|
||||
.any(|persona| persona.display_name == display_name)
|
||||
{
|
||||
return Err("PERSONA_DISPLAY_NAME_EXISTS".into());
|
||||
}
|
||||
let now_ms = storage::now_unix_ms();
|
||||
let persona = PublicPersona {
|
||||
persona_id: format!(
|
||||
"HL-PERSONA-{}",
|
||||
uuid::Uuid::new_v4().simple().to_string()[..10].to_uppercase()
|
||||
),
|
||||
display_name: display_name.into(),
|
||||
state: PERSONA_STATE.into(),
|
||||
created_at: storage::now(),
|
||||
reversible_until_unix_ms: now_ms + 30 * 24 * 60 * 60 * 1_000,
|
||||
};
|
||||
personas.push(persona.clone());
|
||||
save(app, &personas)?;
|
||||
let event = storage::event(
|
||||
SourceKind::SystemReceipt,
|
||||
"公众人格试用运行时已建立",
|
||||
format!(
|
||||
"{} 已登记为本频道可逆试用人格;尚未验证为任何既有历史人格。",
|
||||
persona.persona_id
|
||||
),
|
||||
"SUCCEEDED",
|
||||
);
|
||||
storage::append_event(app, &event)?;
|
||||
Ok(persona)
|
||||
}
|
||||
|
||||
pub fn delete_trial(
|
||||
app: &AppHandle,
|
||||
persona_id: &str,
|
||||
exact_confirmation: &str,
|
||||
) -> Result<PersonaRuntimeSnapshot, String> {
|
||||
if exact_confirmation != format!("删除试用人格 {persona_id}") {
|
||||
return Err("PERSONA_DELETE_EXACT_CONFIRMATION_REQUIRED".into());
|
||||
}
|
||||
let mut personas = load(app)?;
|
||||
let index = personas
|
||||
.iter()
|
||||
.position(|persona| persona.persona_id == persona_id)
|
||||
.ok_or_else(|| "PERSONA_NOT_FOUND".to_string())?;
|
||||
if personas[index].state != PERSONA_STATE {
|
||||
return Err("ONLY_UNVERIFIED_TRIAL_PERSONA_IS_REVERSIBLE".into());
|
||||
}
|
||||
let removed = personas.remove(index);
|
||||
save(app, &personas)?;
|
||||
storage::append_event(
|
||||
app,
|
||||
&storage::event(
|
||||
SourceKind::SystemReceipt,
|
||||
"试用人格已移除",
|
||||
format!(
|
||||
"{} 的可逆试用记录已移除;历史事件仍保留来源类型。",
|
||||
removed.persona_id
|
||||
),
|
||||
"SUCCEEDED",
|
||||
),
|
||||
)?;
|
||||
snapshot(app)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn public_persona_state_never_claims_verified_existing_identity() {
|
||||
assert!(super::PERSONA_STATE.contains("UNVERIFIED"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,425 @@
|
|||
use crate::{agent_executor, model::SourceKind, persona_runtime, storage};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufRead, BufReader, Write},
|
||||
net::{Ipv4Addr, SocketAddrV4, TcpListener, TcpStream},
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
mpsc, Arc, Mutex,
|
||||
},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
const PROTOCOL: &str = "GLP_LOCAL_REALTIME/1";
|
||||
const MAX_LINE_BYTES: usize = 256 * 1024;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RealtimeBridgeState {
|
||||
port: u16,
|
||||
token: Arc<String>,
|
||||
_listener: Arc<TcpListener>,
|
||||
clients: Arc<Mutex<Vec<mpsc::Sender<String>>>>,
|
||||
connected: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RealtimeBridgeStatus {
|
||||
pub schema: &'static str,
|
||||
pub state: &'static str,
|
||||
pub protocol: &'static str,
|
||||
pub endpoint: String,
|
||||
pub connected_clients: usize,
|
||||
pub loopback_only: bool,
|
||||
pub transport_grants_authority: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RealtimeInvitation {
|
||||
pub schema: &'static str,
|
||||
pub protocol: &'static str,
|
||||
pub endpoint: String,
|
||||
pub token: String,
|
||||
pub descriptor_path: String,
|
||||
pub connector_command: String,
|
||||
pub warning: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct Hello {
|
||||
message_type: String,
|
||||
protocol: String,
|
||||
token: String,
|
||||
bridge_id: String,
|
||||
persona_id: Option<String>,
|
||||
}
|
||||
|
||||
fn token_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
Ok(storage::root(app)?.join("realtime-bridge-token"))
|
||||
}
|
||||
|
||||
fn load_or_create_token(app: &AppHandle) -> Result<String, String> {
|
||||
let path = token_path(app)?;
|
||||
if path.exists() {
|
||||
let value = fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||
if value.trim().len() >= 32 {
|
||||
return Ok(value.trim().into());
|
||||
}
|
||||
return Err("GLP_TOKEN_FILE_INVALID".into());
|
||||
}
|
||||
let token = format!(
|
||||
"{}{}",
|
||||
uuid::Uuid::new_v4().simple(),
|
||||
uuid::Uuid::new_v4().simple()
|
||||
);
|
||||
fs::write(&path, &token).map_err(|e| e.to_string())?;
|
||||
#[cfg(unix)]
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).map_err(|e| e.to_string())?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
fn bind_listener() -> Result<(TcpListener, u16), String> {
|
||||
for port in 39281..39291 {
|
||||
if let Ok(listener) = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, port)) {
|
||||
return Ok((listener, port));
|
||||
}
|
||||
}
|
||||
Err("GLP_LOOPBACK_PORT_UNAVAILABLE".into())
|
||||
}
|
||||
|
||||
pub fn start(app: AppHandle) -> Result<RealtimeBridgeState, String> {
|
||||
let token = Arc::new(load_or_create_token(&app)?);
|
||||
let (listener, port) = bind_listener()?;
|
||||
let listener = Arc::new(listener);
|
||||
let state = RealtimeBridgeState {
|
||||
port,
|
||||
token,
|
||||
_listener: listener.clone(),
|
||||
clients: Arc::new(Mutex::new(Vec::new())),
|
||||
connected: Arc::new(AtomicUsize::new(0)),
|
||||
};
|
||||
let runtime_state = state.clone();
|
||||
thread::Builder::new()
|
||||
.name("hololake-glp-listener".into())
|
||||
.spawn(move || loop {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => {
|
||||
let connection_state = runtime_state.clone();
|
||||
let connection_app = app.clone();
|
||||
let _ = thread::Builder::new()
|
||||
.name("hololake-glp-client".into())
|
||||
.spawn(move || {
|
||||
if let Err(error) =
|
||||
handle_connection(connection_app.clone(), connection_state, stream)
|
||||
{
|
||||
let _ = connection_app.emit(
|
||||
"hololake-runtime-event",
|
||||
json!({"kind":"CLIENT_ERROR","error":error}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = app.emit(
|
||||
"hololake-runtime-event",
|
||||
json!({"kind":"LISTENER_ERROR","error":error.to_string()}),
|
||||
);
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
impl RealtimeBridgeState {
|
||||
pub fn status(&self) -> RealtimeBridgeStatus {
|
||||
RealtimeBridgeStatus {
|
||||
schema: "hololake.glp-local-realtime-status/v1",
|
||||
state: "LISTENING",
|
||||
protocol: PROTOCOL,
|
||||
endpoint: format!("tcp://127.0.0.1:{}", self.port),
|
||||
connected_clients: self.connected.load(Ordering::SeqCst),
|
||||
loopback_only: true,
|
||||
transport_grants_authority: false,
|
||||
}
|
||||
}
|
||||
pub fn broadcast_value(&self, value: Value) {
|
||||
if let Ok(line) = serde_json::to_string(&value) {
|
||||
if let Ok(mut clients) = self.clients.lock() {
|
||||
clients.retain(|client| client.send(line.clone()).is_ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invitation(
|
||||
app: &AppHandle,
|
||||
state: &RealtimeBridgeState,
|
||||
) -> Result<RealtimeInvitation, String> {
|
||||
let descriptor = storage::root(app)?.join("realtime-bridge-descriptor.json");
|
||||
let endpoint = format!("tcp://127.0.0.1:{}", state.port);
|
||||
storage::write_json(
|
||||
&descriptor,
|
||||
&json!({"schema":"hololake.glp-local-realtime-descriptor/v1","protocol":PROTOCOL,"endpoint":endpoint,"token":state.token.as_str()}),
|
||||
)?;
|
||||
#[cfg(unix)]
|
||||
fs::set_permissions(&descriptor, fs::Permissions::from_mode(0o600))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let connector = connector_path(app)?;
|
||||
Ok(RealtimeInvitation {
|
||||
schema: "hololake.glp-local-realtime-invitation/v1",
|
||||
protocol: PROTOCOL,
|
||||
endpoint,
|
||||
token: state.token.as_str().to_string(),
|
||||
descriptor_path: descriptor.to_string_lossy().into_owned(),
|
||||
connector_command: format!(
|
||||
"python3 \"{}\" --descriptor \"{}\"",
|
||||
connector.display(),
|
||||
descriptor.display()
|
||||
),
|
||||
warning: "令牌只认证本机连接,不证明人格身份,也不授予执行权限。",
|
||||
})
|
||||
}
|
||||
|
||||
fn connector_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
if std::env::var_os("HOLOLAKE_RUNTIME_TEST_ROOT").is_some() {
|
||||
if let Some(raw_path) = std::env::var_os("HOLOLAKE_CONNECTOR_TEST_PATH") {
|
||||
let path = std::path::PathBuf::from(raw_path);
|
||||
if !path.is_absolute()
|
||||
|| path.extension().and_then(|value| value.to_str()) != Some("py")
|
||||
{
|
||||
return Err("GLP_TEST_CONNECTOR_PATH_INVALID".into());
|
||||
}
|
||||
let metadata = fs::metadata(&path).map_err(|_| "GLP_TEST_CONNECTOR_NOT_READABLE")?;
|
||||
if !metadata.is_file() {
|
||||
return Err("GLP_TEST_CONNECTOR_NOT_A_FILE".into());
|
||||
}
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
let path = app
|
||||
.path()
|
||||
.resource_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("connectors/hololake-glp-client.py");
|
||||
if !path.is_file() {
|
||||
return Err("GLP_BUNDLED_CONNECTOR_MISSING".into());
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn send(writer: &mut TcpStream, value: Value) -> Result<(), String> {
|
||||
serde_json::to_writer(&mut *writer, &value).map_err(|e| e.to_string())?;
|
||||
writer.write_all(b"\n").map_err(|e| e.to_string())?;
|
||||
writer.flush().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn handle_connection(
|
||||
app: AppHandle,
|
||||
state: RealtimeBridgeState,
|
||||
stream: TcpStream,
|
||||
) -> Result<(), String> {
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(10)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let reader_stream = stream.try_clone().map_err(|e| e.to_string())?;
|
||||
let mut writer = stream;
|
||||
let mut reader = BufReader::new(reader_stream);
|
||||
let mut first = String::new();
|
||||
reader.read_line(&mut first).map_err(|e| e.to_string())?;
|
||||
if first.len() > MAX_LINE_BYTES {
|
||||
return Err("GLP_MESSAGE_TOO_LARGE".into());
|
||||
}
|
||||
let hello: Hello = serde_json::from_str(&first).map_err(|_| "GLP_HELLO_INVALID".to_string())?;
|
||||
if hello.message_type != "hello"
|
||||
|| hello.protocol != PROTOCOL
|
||||
|| hello.token != state.token.as_str()
|
||||
{
|
||||
let _ = send(
|
||||
&mut writer,
|
||||
json!({"type":"error","code":"GLP_TOKEN_OR_PROTOCOL_REJECTED"}),
|
||||
);
|
||||
return Err("GLP_TOKEN_OR_PROTOCOL_REJECTED".into());
|
||||
}
|
||||
if !storage::bridges(&app)?
|
||||
.iter()
|
||||
.any(|bridge| bridge.bridge_id == hello.bridge_id)
|
||||
{
|
||||
send(
|
||||
&mut writer,
|
||||
json!({"type":"error","code":"GLP_BRIDGE_NOT_REGISTERED"}),
|
||||
)?;
|
||||
return Err("GLP_BRIDGE_NOT_REGISTERED".into());
|
||||
}
|
||||
if let Some(persona_id) = &hello.persona_id {
|
||||
if !persona_runtime::exists(&app, persona_id)? {
|
||||
send(
|
||||
&mut writer,
|
||||
json!({"type":"error","code":"PERSONA_IDENTITY_UNVERIFIED"}),
|
||||
)?;
|
||||
return Err("PERSONA_IDENTITY_UNVERIFIED".into());
|
||||
}
|
||||
}
|
||||
writer.set_read_timeout(None).map_err(|e| e.to_string())?;
|
||||
reader
|
||||
.get_ref()
|
||||
.set_read_timeout(None)
|
||||
.map_err(|e| e.to_string())?;
|
||||
state.connected.fetch_add(1, Ordering::SeqCst);
|
||||
let _guard = ConnectionGuard(state.connected.clone());
|
||||
send(
|
||||
&mut writer,
|
||||
json!({"type":"welcome","protocol":PROTOCOL,"bridgeId":hello.bridge_id,"personaId":hello.persona_id,"personaState":if hello.persona_id.is_some(){"LOCAL_TRIAL_UNVERIFIED_HOST_CONNECTED"}else{"EXTERNAL_AI_CONNECTED_NO_PERSONA"},"executionAuthority":false}),
|
||||
)?;
|
||||
let (tx, rx) = mpsc::channel::<String>();
|
||||
state
|
||||
.clients
|
||||
.lock()
|
||||
.map_err(|_| "GLP_CLIENT_REGISTRY_POISONED".to_string())?
|
||||
.push(tx);
|
||||
let mut outbound_writer = writer.try_clone().map_err(|e| e.to_string())?;
|
||||
let _ = thread::Builder::new()
|
||||
.name("hololake-glp-outbound".into())
|
||||
.spawn(move || {
|
||||
while let Ok(line) = rx.recv() {
|
||||
if outbound_writer.write_all(line.as_bytes()).is_err()
|
||||
|| outbound_writer.write_all(b"\n").is_err()
|
||||
|| outbound_writer.flush().is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let _ = app.emit("hololake-runtime-event", json!({"kind":"CLIENT_CONNECTED"}));
|
||||
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).map_err(|e| e.to_string())? == 0 {
|
||||
break;
|
||||
}
|
||||
if line.len() > MAX_LINE_BYTES {
|
||||
send(
|
||||
&mut writer,
|
||||
json!({"type":"error","code":"GLP_MESSAGE_TOO_LARGE"}),
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
let value: Value = match serde_json::from_str(&line) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
send(
|
||||
&mut writer,
|
||||
json!({"type":"error","code":"GLP_MESSAGE_INVALID"}),
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match value.get("type").and_then(Value::as_str).unwrap_or("") {
|
||||
"persona_response" if hello.persona_id.is_some() => {
|
||||
let content = value
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if content.is_empty() {
|
||||
send(
|
||||
&mut writer,
|
||||
json!({"type":"error","code":"EMPTY_PERSONA_RESPONSE"}),
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
let event =
|
||||
storage::event(SourceKind::PersonaResponse, "人格回应", content, "RECORDED");
|
||||
storage::append_event(&app, &event)?;
|
||||
let _ = app.emit("hololake-runtime-event", &event);
|
||||
send(&mut writer, json!({"type":"accepted","event":event}))?;
|
||||
}
|
||||
"external_ai_message" => {
|
||||
let content = value
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if content.is_empty() {
|
||||
send(
|
||||
&mut writer,
|
||||
json!({"type":"error","code":"EMPTY_EXTERNAL_AI_MESSAGE"}),
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
let event = storage::event(
|
||||
SourceKind::ExternalAiMessage,
|
||||
"外部 AI 语言",
|
||||
content,
|
||||
"RECORDED",
|
||||
);
|
||||
storage::append_event(&app, &event)?;
|
||||
let _ = app.emit("hololake-runtime-event", &event);
|
||||
send(&mut writer, json!({"type":"accepted","event":event}))?;
|
||||
}
|
||||
"tcs_proposal" => {
|
||||
let request: agent_executor::TcsCompileRequest = serde_json::from_value(
|
||||
value
|
||||
.get("request")
|
||||
.cloned()
|
||||
.ok_or_else(|| "TCS_REQUEST_REQUIRED".to_string())?,
|
||||
)
|
||||
.map_err(|e| format!("TCS_REQUEST_INVALID:{e}"))?;
|
||||
match agent_executor::queue(
|
||||
&app,
|
||||
request,
|
||||
hello.bridge_id.clone(),
|
||||
hello.persona_id.clone(),
|
||||
) {
|
||||
Ok(proposal) => {
|
||||
let _ = app.emit(
|
||||
"hololake-runtime-event",
|
||||
json!({"kind":"PROPOSAL_PENDING","proposalId":proposal.proposal_id}),
|
||||
);
|
||||
send(
|
||||
&mut writer,
|
||||
json!({"type":"proposal_pending","proposal":proposal}),
|
||||
)?;
|
||||
if isolated_approval_simulation_enabled() {
|
||||
let receipt = agent_executor::approve(&app, &proposal.proposal_id)?;
|
||||
state.broadcast_value(json!({
|
||||
"type":"agent_receipt",
|
||||
"receipt":receipt,
|
||||
"testApprovalSimulation":true
|
||||
}));
|
||||
}
|
||||
}
|
||||
Err(error) => send(&mut writer, json!({"type":"error","code":error}))?,
|
||||
}
|
||||
}
|
||||
"ping" => send(&mut writer, json!({"type":"pong","at":storage::now()}))?,
|
||||
_ => send(
|
||||
&mut writer,
|
||||
json!({"type":"error","code":"GLP_MESSAGE_TYPE_REJECTED"}),
|
||||
)?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn isolated_approval_simulation_enabled() -> bool {
|
||||
std::env::var_os("HOLOLAKE_RUNTIME_TEST_ROOT").is_some()
|
||||
&& std::env::var("HOLOLAKE_TEST_APPROVAL_SIMULATION").as_deref()
|
||||
== Ok("SIMULATE_HUMAN_APPROVAL")
|
||||
}
|
||||
|
||||
struct ConnectionGuard(Arc<AtomicUsize>);
|
||||
impl Drop for ConnectionGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,12 @@ pub fn now() -> String {
|
|||
.map(|d| format!("{}.{:03}Z", d.as_secs(), d.subsec_millis()))
|
||||
.unwrap_or_else(|_| "0Z".into())
|
||||
}
|
||||
pub fn now_unix_ms() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
pub fn id(prefix: &str) -> String {
|
||||
format!("{}-{}", prefix, UuidPart::new())
|
||||
}
|
||||
|
|
@ -35,6 +41,14 @@ pub fn sha(bytes: &[u8]) -> String {
|
|||
}
|
||||
|
||||
pub fn root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
if let Some(value) = std::env::var_os("HOLOLAKE_RUNTIME_TEST_ROOT") {
|
||||
let path = PathBuf::from(value);
|
||||
if !path.is_absolute() || !path.starts_with(std::env::temp_dir()) {
|
||||
return Err("HOLOLAKE_RUNTIME_TEST_ROOT_OUT_OF_SCOPE".into());
|
||||
}
|
||||
fs::create_dir_all(&path).map_err(|e| e.to_string())?;
|
||||
return Ok(path);
|
||||
}
|
||||
let p = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
|
|
@ -43,10 +57,10 @@ pub fn root(app: &AppHandle) -> Result<PathBuf, String> {
|
|||
fs::create_dir_all(&p).map_err(|e| e.to_string())?;
|
||||
Ok(p)
|
||||
}
|
||||
fn read_json<T: serde::de::DeserializeOwned>(p: &Path) -> Result<T, String> {
|
||||
pub fn read_json<T: serde::de::DeserializeOwned>(p: &Path) -> Result<T, String> {
|
||||
serde_json::from_slice(&fs::read(p).map_err(|e| e.to_string())?).map_err(|e| e.to_string())
|
||||
}
|
||||
fn write_json<T: serde::Serialize>(p: &Path, v: &T) -> Result<(), String> {
|
||||
pub fn write_json<T: serde::Serialize + ?Sized>(p: &Path, v: &T) -> Result<(), String> {
|
||||
if let Some(x) = p.parent() {
|
||||
fs::create_dir_all(x).map_err(|e| e.to_string())?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "HoloLake",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"identifier": "world.guanghu.hololake",
|
||||
"build": { "frontendDist": "../dist", "devUrl": "http://127.0.0.1:5211", "beforeDevCommand": "npm run dev", "beforeBuildCommand": "npm run build" },
|
||||
"app": {
|
||||
|
|
@ -9,5 +9,5 @@
|
|||
"security": { "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost https://guanghulab.com; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" }
|
||||
},
|
||||
"plugins": { "updater": { "endpoints": ["https://guanghulab.com/hololake/releases/latest.json"], "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDEzQkYzRTQ5QTE2MEFDMzEKUldReHJHQ2hTVDYvRTZNNDVqZDUxLzRZMmxuT1pSM2Q5RTRZbzRCZUZ0d0FxVVNidGJ0dUQyMnoK" } },
|
||||
"bundle": { "active": true, "targets": ["app", "dmg"], "createUpdaterArtifacts": true, "category": "Productivity", "icon": ["icons/icon.icns", "icons/icon.png", "icons/icon.ico"], "macOS": { "signingIdentity": "Developer ID Application: bei sun (825A9L3G7Q)" } }
|
||||
"bundle": { "active": true, "targets": ["app", "dmg"], "createUpdaterArtifacts": true, "category": "Productivity", "icon": ["icons/icon.icns", "icons/icon.png", "icons/icon.ico"], "resources": {"../connectors/hololake-glp-client.py":"connectors/hololake-glp-client.py"}, "macOS": { "signingIdentity": "Developer ID Application: bei sun (825A9L3G7Q)" } }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue