chore: snapshot audited HoloLake convergence baseline
This commit is contained in:
parent
7a14c06e41
commit
1b6b17b5ab
47 changed files with 10722 additions and 87 deletions
|
|
@ -268,6 +268,22 @@ dependencies = [
|
|||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calamine"
|
||||
version = "0.26.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "138646b9af2c5d7f1804ea4bf93afc597737d2bd4f7341d67c48b03316976eb1"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"chrono",
|
||||
"codepage",
|
||||
"encoding_rs",
|
||||
"log",
|
||||
"quick-xml 0.31.0",
|
||||
"serde",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "camino"
|
||||
version = "1.2.5"
|
||||
|
|
@ -393,6 +409,15 @@ dependencies = [
|
|||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codepage"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
|
|
@ -570,6 +595,27 @@ dependencies = [
|
|||
"syn 2.0.115",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "csv"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe"
|
||||
dependencies = [
|
||||
"csv-core",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "csv-core"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctor"
|
||||
version = "0.8.0"
|
||||
|
|
@ -890,6 +936,15 @@ version = "1.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
|
|
@ -1495,13 +1550,17 @@ name = "hololake-native-desktop"
|
|||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"calamine",
|
||||
"csv",
|
||||
"dirs",
|
||||
"encoding_rs",
|
||||
"fs2",
|
||||
"futures-util",
|
||||
"interprocess",
|
||||
"reqwest",
|
||||
"ring",
|
||||
"rusqlite",
|
||||
"rust_xlsxwriter",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
|
|
@ -2833,7 +2892,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
|
|||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.14.0",
|
||||
"quick-xml",
|
||||
"quick-xml 0.41.0",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
|
|
@ -2980,6 +3039,16 @@ dependencies = [
|
|||
"psl-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.31.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
|
|
@ -3350,6 +3419,17 @@ dependencies = [
|
|||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust_xlsxwriter"
|
||||
version = "0.64.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5f47f5318c1e512e57c07781559367577b1eb9618325cf1574cd30d38b112c5"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"regex",
|
||||
"zip 0.6.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
|
|
@ -4310,7 +4390,7 @@ dependencies = [
|
|||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
"zip 4.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5846,6 +5926,35 @@ dependencies = [
|
|||
"syn 2.0.115",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap 2.14.0",
|
||||
"memchr",
|
||||
"thiserror 2.0.20",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
|
|
@ -5863,3 +5972,15 @@ name = "zmij"
|
|||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ interprocess = "2.4.2"
|
|||
ring = "0.17"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
base64 = "0.22"
|
||||
calamine = { version = "=0.26.1", features = ["dates"] }
|
||||
csv = "=1.3.0"
|
||||
encoding_rs = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "=2.10.2", features = ["devtools"] }
|
||||
|
|
@ -33,6 +36,7 @@ url = "2"
|
|||
reqwest = { version = "0.13.2", default-features = false, features = ["cookies", "form", "json", "rustls", "stream"] }
|
||||
tokio = { version = "1", features = ["time"] }
|
||||
futures-util = "0.3"
|
||||
rust_xlsxwriter = "=0.64.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
widestring = "1"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,476 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
//! 当前账号私人频道的长期成长模型。
|
||||
//!
|
||||
//! 这里只保存最小化的行为元数据,不保存文档正文、表格单元格、提示词、对话原文或凭据。
|
||||
//! 基础算法可以持续形成模块亲和度;模型 API 未来只能提交适配提案,不能绕过本地校验和人类确认。
|
||||
|
||||
use ring::digest::{digest, SHA256};
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tauri::AppHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
const GROWTH_SCHEMA: &str = "hololake.channel-growth-model/v1";
|
||||
const ZERO_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RecordChannelGrowthEventInput {
|
||||
pub event_kind: String,
|
||||
pub module_id: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateChannelGrowthSharingInput {
|
||||
pub sharing_mode: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChannelGrowthControls {
|
||||
pub sharing_mode: String,
|
||||
pub official_read_access: bool,
|
||||
pub history_mutation_allowed: bool,
|
||||
pub structural_change_requires_human_confirmation: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChannelModuleAffinity {
|
||||
pub module_id: String,
|
||||
pub use_count: i64,
|
||||
pub score: f64,
|
||||
pub last_used_at_unix_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChannelGrowthSnapshot {
|
||||
pub schema: &'static str,
|
||||
pub state: &'static str,
|
||||
pub controls: ChannelGrowthControls,
|
||||
pub observed_event_count: i64,
|
||||
pub persona_collaboration_event_count: i64,
|
||||
pub module_affinities: Vec<ChannelModuleAffinity>,
|
||||
pub recommended_module_order: Vec<String>,
|
||||
pub integrity_state: &'static str,
|
||||
pub last_event_hash: String,
|
||||
pub model_api_state: &'static str,
|
||||
pub model_proposal_policy: &'static str,
|
||||
pub data_boundary: &'static str,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_channel_growth_snapshot(app: AppHandle) -> Result<ChannelGrowthSnapshot, String> {
|
||||
let database = channel_growth_database(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || snapshot_at(&database))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn record_channel_growth_event(
|
||||
app: AppHandle,
|
||||
input: RecordChannelGrowthEventInput,
|
||||
) -> Result<ChannelGrowthSnapshot, String> {
|
||||
let database = channel_growth_database(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || record_event_at(&database, input))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_channel_growth_sharing(
|
||||
app: AppHandle,
|
||||
input: UpdateChannelGrowthSharingInput,
|
||||
) -> Result<ChannelGrowthSnapshot, String> {
|
||||
let database = channel_growth_database(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || update_sharing_at(&database, input))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
fn channel_growth_database(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let root = crate::authenticated_storage::account_storage_root(app, "channel-growth-v1")?;
|
||||
fs::create_dir_all(&root)
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&root, fs::Permissions::from_mode(0o700))
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_PERMISSION_FAILED: {error}"))?;
|
||||
}
|
||||
Ok(root.join("channel-growth.sqlite3"))
|
||||
}
|
||||
|
||||
fn open_database(path: &Path) -> Result<Connection, String> {
|
||||
let connection = Connection::open(path)
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_DATABASE_UNAVAILABLE: {error}"))?;
|
||||
connection
|
||||
.busy_timeout(Duration::from_secs(5))
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_DATABASE_UNAVAILABLE: {error}"))?;
|
||||
connection
|
||||
.execute_batch(
|
||||
"PRAGMA journal_mode = DELETE;
|
||||
PRAGMA synchronous = FULL;
|
||||
PRAGMA trusted_schema = OFF;
|
||||
CREATE TABLE IF NOT EXISTS growth_sharing (
|
||||
singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
|
||||
sharing_mode TEXT NOT NULL CHECK(sharing_mode IN ('LOCAL_ONLY', 'ANONYMIZED_SHARED'))
|
||||
);
|
||||
INSERT OR IGNORE INTO growth_sharing VALUES (1, 'LOCAL_ONLY');
|
||||
CREATE TABLE IF NOT EXISTS growth_events (
|
||||
sequence INTEGER NOT NULL UNIQUE,
|
||||
event_id TEXT PRIMARY KEY NOT NULL,
|
||||
event_kind TEXT NOT NULL,
|
||||
module_id TEXT NOT NULL,
|
||||
source TEXT NOT NULL CHECK(source IN ('HUMAN_ACTION', 'PERSONA_COLLABORATION', 'SYSTEM_ASSISTED')),
|
||||
occurred_at_unix_ms INTEGER NOT NULL,
|
||||
previous_hash TEXT NOT NULL,
|
||||
event_hash TEXT NOT NULL UNIQUE
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS growth_events_no_update BEFORE UPDATE ON growth_events BEGIN SELECT RAISE(ABORT, 'HOLOLAKE_GROWTH_HISTORY_APPEND_ONLY'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS growth_events_no_delete BEFORE DELETE ON growth_events BEGIN SELECT RAISE(ABORT, 'HOLOLAKE_GROWTH_HISTORY_APPEND_ONLY'); END;
|
||||
CREATE TABLE IF NOT EXISTS module_affinities (
|
||||
module_id TEXT PRIMARY KEY NOT NULL,
|
||||
use_count INTEGER NOT NULL,
|
||||
score REAL NOT NULL,
|
||||
last_used_at_unix_ms INTEGER NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_SCHEMA_FAILED: {error}"))?;
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
fn validate_token(value: &str, label: &str) -> Result<String, String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty()
|
||||
|| value.len() > 96
|
||||
|| !value
|
||||
.chars()
|
||||
.all(|item| item.is_ascii_alphanumeric() || matches!(item, '-' | '_' | '.' | ':'))
|
||||
{
|
||||
return Err(format!("HOLOLAKE_CHANNEL_GROWTH_{label}_INVALID"));
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn record_event_at(
|
||||
database: &Path,
|
||||
input: RecordChannelGrowthEventInput,
|
||||
) -> Result<ChannelGrowthSnapshot, String> {
|
||||
let event_kind = validate_token(&input.event_kind, "EVENT_KIND")?;
|
||||
let module_id = validate_token(&input.module_id, "MODULE_ID")?;
|
||||
let source = input.source.trim().to_string();
|
||||
if !matches!(
|
||||
source.as_str(),
|
||||
"HUMAN_ACTION" | "PERSONA_COLLABORATION" | "SYSTEM_ASSISTED"
|
||||
) {
|
||||
return Err("HOLOLAKE_CHANNEL_GROWTH_SOURCE_INVALID".into());
|
||||
}
|
||||
let mut connection = open_database(database)?;
|
||||
verify_event_chain(&connection)?;
|
||||
let now = now_unix_ms()?;
|
||||
let weight = if source == "PERSONA_COLLABORATION" {
|
||||
1.4
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let transaction = connection
|
||||
.transaction()
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_TRANSACTION_FAILED: {error}"))?;
|
||||
let (sequence, previous_hash): (i64, String) = transaction
|
||||
.query_row(
|
||||
"SELECT COALESCE(MAX(sequence), 0) + 1, COALESCE((SELECT event_hash FROM growth_events ORDER BY sequence DESC LIMIT 1), ?1) FROM growth_events",
|
||||
params![ZERO_HASH],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
|
||||
let event_id = Uuid::new_v4().to_string();
|
||||
let event_hash = event_hash(
|
||||
sequence,
|
||||
&event_id,
|
||||
&event_kind,
|
||||
&module_id,
|
||||
&source,
|
||||
now,
|
||||
&previous_hash,
|
||||
);
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO growth_events(sequence, event_id, event_kind, module_id, source, occurred_at_unix_ms, previous_hash, event_hash) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![sequence, event_id, event_kind, module_id, source, now, previous_hash, event_hash],
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_EVENT_WRITE_FAILED: {error}"))?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO module_affinities(module_id, use_count, score, last_used_at_unix_ms) VALUES (?1, 1, ?2, ?3)
|
||||
ON CONFLICT(module_id) DO UPDATE SET use_count = use_count + 1, score = score * 0.985 + excluded.score, last_used_at_unix_ms = excluded.last_used_at_unix_ms",
|
||||
params![module_id, weight, now],
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_MODEL_WRITE_FAILED: {error}"))?;
|
||||
transaction
|
||||
.commit()
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_COMMIT_FAILED: {error}"))?;
|
||||
snapshot_with_connection(&connection)
|
||||
}
|
||||
|
||||
fn update_sharing_at(
|
||||
database: &Path,
|
||||
input: UpdateChannelGrowthSharingInput,
|
||||
) -> Result<ChannelGrowthSnapshot, String> {
|
||||
if !matches!(
|
||||
input.sharing_mode.as_str(),
|
||||
"LOCAL_ONLY" | "ANONYMIZED_SHARED"
|
||||
) {
|
||||
return Err("HOLOLAKE_CHANNEL_GROWTH_SHARING_MODE_INVALID".into());
|
||||
}
|
||||
let connection = open_database(database)?;
|
||||
connection
|
||||
.execute(
|
||||
"UPDATE growth_sharing SET sharing_mode = ?1 WHERE singleton = 1",
|
||||
params![input.sharing_mode],
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_SHARING_WRITE_FAILED: {error}"))?;
|
||||
snapshot_with_connection(&connection)
|
||||
}
|
||||
|
||||
fn snapshot_at(database: &Path) -> Result<ChannelGrowthSnapshot, String> {
|
||||
let connection = open_database(database)?;
|
||||
snapshot_with_connection(&connection)
|
||||
}
|
||||
|
||||
fn snapshot_with_connection(connection: &Connection) -> Result<ChannelGrowthSnapshot, String> {
|
||||
let last_event_hash = verify_event_chain(connection)?;
|
||||
let controls = connection
|
||||
.query_row(
|
||||
"SELECT sharing_mode FROM growth_sharing WHERE singleton = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(ChannelGrowthControls {
|
||||
sharing_mode: row.get(0)?,
|
||||
official_read_access: false,
|
||||
history_mutation_allowed: false,
|
||||
structural_change_requires_human_confirmation: true,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
|
||||
let observed_event_count = connection
|
||||
.query_row("SELECT COUNT(*) FROM growth_events", [], |row| row.get(0))
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
|
||||
let persona_collaboration_event_count = connection
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM growth_events WHERE source = 'PERSONA_COLLABORATION'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
|
||||
let mut statement = connection
|
||||
.prepare("SELECT module_id, use_count, score, last_used_at_unix_ms FROM module_affinities ORDER BY score DESC, last_used_at_unix_ms DESC LIMIT 12")
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
|
||||
let affinities = statement
|
||||
.query_map([], |row| {
|
||||
Ok(ChannelModuleAffinity {
|
||||
module_id: row.get(0)?,
|
||||
use_count: row.get(1)?,
|
||||
score: row.get(2)?,
|
||||
last_used_at_unix_ms: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
|
||||
let recommended_module_order = affinities
|
||||
.iter()
|
||||
.map(|item| item.module_id.clone())
|
||||
.collect();
|
||||
Ok(ChannelGrowthSnapshot {
|
||||
schema: GROWTH_SCHEMA,
|
||||
state: "APPEND_ONLY_LOCAL_GROWTH_ACTIVE",
|
||||
controls,
|
||||
observed_event_count,
|
||||
persona_collaboration_event_count,
|
||||
module_affinities: affinities,
|
||||
recommended_module_order,
|
||||
integrity_state: "VERIFIED_APPEND_ONLY_CHAIN",
|
||||
last_event_hash,
|
||||
model_api_state: "NOT_CONFIGURED",
|
||||
model_proposal_policy: "LOCAL_VALIDATE_THEN_HUMAN_CONFIRM_BEFORE_STRUCTURAL_CHANGE",
|
||||
data_boundary: "METADATA_ONLY_NO_CONTENT_NO_CREDENTIALS_NO_CONVERSATION_TRANSCRIPT",
|
||||
})
|
||||
}
|
||||
|
||||
fn event_hash(
|
||||
sequence: i64,
|
||||
event_id: &str,
|
||||
event_kind: &str,
|
||||
module_id: &str,
|
||||
source: &str,
|
||||
occurred_at_unix_ms: i64,
|
||||
previous_hash: &str,
|
||||
) -> String {
|
||||
let material = format!("{GROWTH_SCHEMA}|{sequence}|{event_id}|{event_kind}|{module_id}|{source}|{occurred_at_unix_ms}|{previous_hash}");
|
||||
hex(digest(&SHA256, material.as_bytes()).as_ref())
|
||||
}
|
||||
|
||||
fn verify_event_chain(connection: &Connection) -> Result<String, String> {
|
||||
let mut statement = connection
|
||||
.prepare("SELECT sequence, event_id, event_kind, module_id, source, occurred_at_unix_ms, previous_hash, event_hash FROM growth_events ORDER BY sequence")
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
|
||||
let mut rows = statement
|
||||
.query([])
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
|
||||
let mut expected_sequence = 1_i64;
|
||||
let mut previous = ZERO_HASH.to_string();
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?
|
||||
{
|
||||
let sequence: i64 = row
|
||||
.get(0)
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
|
||||
let event_id: String = row
|
||||
.get(1)
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
|
||||
let event_kind: String = row
|
||||
.get(2)
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
|
||||
let module_id: String = row
|
||||
.get(3)
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
|
||||
let source: String = row
|
||||
.get(4)
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
|
||||
let occurred_at: i64 = row
|
||||
.get(5)
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
|
||||
let stored_previous: String = row
|
||||
.get(6)
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
|
||||
let stored_hash: String = row
|
||||
.get(7)
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_GROWTH_READ_FAILED: {error}"))?;
|
||||
if sequence != expected_sequence
|
||||
|| stored_previous != previous
|
||||
|| stored_hash
|
||||
!= event_hash(
|
||||
sequence,
|
||||
&event_id,
|
||||
&event_kind,
|
||||
&module_id,
|
||||
&source,
|
||||
occurred_at,
|
||||
&stored_previous,
|
||||
)
|
||||
{
|
||||
return Err("HOLOLAKE_CHANNEL_GROWTH_HISTORY_INTEGRITY_FAILED".into());
|
||||
}
|
||||
expected_sequence += 1;
|
||||
previous = stored_hash;
|
||||
}
|
||||
Ok(previous)
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
fn now_unix_ms() -> Result<i64, String> {
|
||||
Ok(SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|error| format!("HOLOLAKE_CLOCK_INVALID: {error}"))?
|
||||
.as_millis() as i64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn local_growth_model_records_only_bounded_metadata_and_ranks_modules() {
|
||||
let dir = tempdir().unwrap();
|
||||
let database = dir.path().join("growth.sqlite3");
|
||||
for _ in 0..3 {
|
||||
record_event_at(
|
||||
&database,
|
||||
RecordChannelGrowthEventInput {
|
||||
event_kind: "MODULE_OPEN".into(),
|
||||
module_id: "education.table".into(),
|
||||
source: "HUMAN_ACTION".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
record_event_at(
|
||||
&database,
|
||||
RecordChannelGrowthEventInput {
|
||||
event_kind: "PERSONA_COLLABORATION".into(),
|
||||
module_id: "knowledge".into(),
|
||||
source: "PERSONA_COLLABORATION".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let snapshot = snapshot_at(&database).unwrap();
|
||||
assert_eq!(snapshot.observed_event_count, 4);
|
||||
assert_eq!(snapshot.persona_collaboration_event_count, 1);
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.recommended_module_order
|
||||
.first()
|
||||
.map(String::as_str),
|
||||
Some("education.table")
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.data_boundary,
|
||||
"METADATA_ONLY_NO_CONTENT_NO_CREDENTIALS_NO_CONVERSATION_TRANSCRIPT"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn growth_history_rejects_update_and_delete() {
|
||||
let dir = tempdir().unwrap();
|
||||
let database = dir.path().join("growth.sqlite3");
|
||||
record_event_at(
|
||||
&database,
|
||||
RecordChannelGrowthEventInput {
|
||||
event_kind: "MODULE_OPEN".into(),
|
||||
module_id: "knowledge".into(),
|
||||
source: "HUMAN_ACTION".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let connection = open_database(&database).unwrap();
|
||||
assert!(connection.execute("DELETE FROM growth_events", []).is_err());
|
||||
assert!(connection
|
||||
.execute("UPDATE growth_events SET module_id = 'rewritten'", [])
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
snapshot_at(&database).unwrap().integrity_state,
|
||||
"VERIFIED_APPEND_ONLY_CHAIN"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharing_choice_does_not_grant_official_read_or_mutate_history() {
|
||||
let dir = tempdir().unwrap();
|
||||
let database = dir.path().join("growth.sqlite3");
|
||||
let snapshot = update_sharing_at(
|
||||
&database,
|
||||
UpdateChannelGrowthSharingInput {
|
||||
sharing_mode: "ANONYMIZED_SHARED".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(snapshot.controls.sharing_mode, "ANONYMIZED_SHARED");
|
||||
assert!(!snapshot.controls.official_read_access);
|
||||
assert!(!snapshot.controls.history_mutation_allowed);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
use ring::digest::{digest, SHA256};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::ffi::OsStr;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
|
|
@ -11,7 +11,7 @@ use std::io::Write;
|
|||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::UNIX_EPOCH;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
||||
use uuid::Uuid;
|
||||
|
|
@ -22,6 +22,11 @@ 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;
|
||||
const ORGANIZATION_SCHEMA: &str = "hololake.knowledge-organization/v1";
|
||||
const MAX_CATEGORY_DEPTH: usize = 4;
|
||||
const MAX_CATEGORY_SEGMENT_CHARS: usize = 40;
|
||||
const MAX_TAGS_PER_DOCUMENT: usize = 12;
|
||||
const MAX_TAG_CHARS: usize = 24;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -33,6 +38,24 @@ pub struct KnowledgeDocumentSummary {
|
|||
pub size_bytes: u64,
|
||||
pub content_sha256: String,
|
||||
pub duplicate_count: usize,
|
||||
pub category: String,
|
||||
pub category_explicit: bool,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KnowledgeCategorySummary {
|
||||
pub name: String,
|
||||
pub document_count: usize,
|
||||
pub explicitly_organized_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KnowledgeTagSummary {
|
||||
pub name: String,
|
||||
pub document_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
|
|
@ -47,9 +70,54 @@ pub struct KnowledgeSnapshot {
|
|||
pub raw_document_count: usize,
|
||||
pub unique_document_count: usize,
|
||||
pub duplicate_document_count: usize,
|
||||
pub organized_document_count: usize,
|
||||
pub categories: Vec<KnowledgeCategorySummary>,
|
||||
pub tags: Vec<KnowledgeTagSummary>,
|
||||
pub organization_revision: String,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
struct KnowledgeOrganizationEntry {
|
||||
category: String,
|
||||
tags: Vec<String>,
|
||||
updated_at_unix_ms: u128,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
struct KnowledgeOrganizationIndex {
|
||||
schema: String,
|
||||
documents: BTreeMap<String, KnowledgeOrganizationEntry>,
|
||||
}
|
||||
|
||||
impl Default for KnowledgeOrganizationIndex {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schema: ORGANIZATION_SCHEMA.into(),
|
||||
documents: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateKnowledgeOrganizationInput {
|
||||
pub source: String,
|
||||
pub path: String,
|
||||
pub expected_content_sha256: String,
|
||||
pub category: String,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KnowledgeOrganizationUpdateResult {
|
||||
pub schema: &'static str,
|
||||
pub state: &'static str,
|
||||
pub git_commit: String,
|
||||
pub snapshot: KnowledgeSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ReadKnowledgeDocumentInput {
|
||||
|
|
@ -145,6 +213,13 @@ pub async fn get_knowledge_snapshot(app: AppHandle) -> Result<KnowledgeSnapshot,
|
|||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
pub(crate) fn snapshot_for_native_composition(
|
||||
app: &AppHandle,
|
||||
) -> Result<KnowledgeSnapshot, String> {
|
||||
let roots = knowledge_roots(app)?;
|
||||
snapshot_at(&roots.0, roots.1.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn read_knowledge_document(
|
||||
app: AppHandle,
|
||||
|
|
@ -180,6 +255,19 @@ pub async fn save_knowledge_document(
|
|||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_knowledge_organization(
|
||||
app: AppHandle,
|
||||
input: UpdateKnowledgeOrganizationInput,
|
||||
) -> Result<KnowledgeOrganizationUpdateResult, String> {
|
||||
let roots = knowledge_roots(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
update_organization_at(&roots.0, roots.1.as_deref(), input)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExportKnowledgeDocumentInput {
|
||||
|
|
@ -219,22 +307,50 @@ pub async fn export_knowledge_document(
|
|||
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") {
|
||||
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 filter_label = if extension == "html" {
|
||||
"网页文件"
|
||||
} else {
|
||||
"Markdown"
|
||||
};
|
||||
let (sender, receiver) = std::sync::mpsc::channel::<Option<PathBuf>>();
|
||||
app.dialog()
|
||||
.file()
|
||||
.set_file_name(&suggested)
|
||||
.add_filter(filter_label, &[extension])
|
||||
.save_file(move |selection| {
|
||||
let picked = selection.as_ref().and_then(|path| path.as_path()).map(|path| path.to_path_buf());
|
||||
let 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())
|
||||
|
|
@ -259,6 +375,7 @@ pub async fn export_knowledge_document(
|
|||
pub struct DeleteKnowledgeDocumentInput {
|
||||
pub source: String,
|
||||
pub path: String,
|
||||
pub confirmed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
|
|
@ -279,22 +396,6 @@ fn resolve_native_path(root: &Path, relative: &str) -> Result<PathBuf, String> {
|
|||
Ok(target)
|
||||
}
|
||||
|
||||
async fn confirm_destructive(app: &AppHandle, message: String) -> Result<bool, String> {
|
||||
let confirmer = app.clone();
|
||||
let confirmed = tauri::async_runtime::spawn_blocking(move || {
|
||||
confirmer
|
||||
.dialog()
|
||||
.message(message)
|
||||
.title("删除确认")
|
||||
.kind(MessageDialogKind::Warning)
|
||||
.buttons(MessageDialogButtons::OkCancelCustom("确认删除".to_string(), "取消".to_string()))
|
||||
.blocking_show()
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CONFIRM_JOIN_FAILED: {error}"))?;
|
||||
Ok(confirmed)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateKnowledgeDocumentInput {
|
||||
|
|
@ -308,7 +409,11 @@ pub async fn create_knowledge_document(
|
|||
) -> Result<KnowledgeDeleteReceipt, String> {
|
||||
// 新建空白页:标题撞名自动补序号,落进本机库并进 Git 托管。
|
||||
let roots = knowledge_roots(&app)?;
|
||||
let base = if input.title.trim().is_empty() { "未命名页面".to_string() } else { input.title.trim().to_string() };
|
||||
let 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;
|
||||
|
|
@ -325,14 +430,22 @@ pub async fn create_knowledge_document(
|
|||
.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}"))?;
|
||||
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")?;
|
||||
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 })
|
||||
Ok(KnowledgeDeleteReceipt {
|
||||
removed: format!("{receipt_name}.md"),
|
||||
pages: 1,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -345,26 +458,25 @@ pub async fn delete_knowledge_document(
|
|||
return Err("HOLOLAKE_KNOWLEDGE_DELETE_READ_ONLY_SOURCE".into());
|
||||
}
|
||||
let roots = knowledge_roots(&app)?;
|
||||
let target = resolve_native_path(&roots.0, &input.path)?;
|
||||
let target = resolve_native_path(&roots.0.join("docs"), &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? {
|
||||
if !input.confirmed {
|
||||
return Ok(None);
|
||||
}
|
||||
move_to_trash(&roots.0, &target)?;
|
||||
Ok(Some(KnowledgeDeleteReceipt { removed: input.path, pages: 1 }))
|
||||
archive_native_entry(&roots.0, &target, &input.path, false)?;
|
||||
Ok(Some(KnowledgeDeleteReceipt {
|
||||
removed: input.path,
|
||||
pages: 1,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteKnowledgeFolderInput {
|
||||
pub folder: String,
|
||||
pub confirmed: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -374,15 +486,11 @@ pub async fn delete_knowledge_folder(
|
|||
) -> Result<Option<KnowledgeDeleteReceipt>, String> {
|
||||
// 删整个文件夹 = 删本机库里该相对目录及其全部页面。
|
||||
let roots = knowledge_roots(&app)?;
|
||||
let target = resolve_native_path(&roots.0, &input.folder)?;
|
||||
if target == roots.0 || !target.is_dir() {
|
||||
let docs_root = roots.0.join("docs");
|
||||
let target = resolve_native_path(&docs_root, &input.folder)?;
|
||||
if target == docs_root || !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
|
||||
|
|
@ -391,11 +499,14 @@ pub async fn delete_knowledge_folder(
|
|||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if !confirm_destructive(&app, format!("把文件夹「{name}」和里面全部 {pages} 个页面移到回收站?随时可以找回。")).await? {
|
||||
if !input.confirmed {
|
||||
return Ok(None);
|
||||
}
|
||||
move_to_trash(&roots.0, &target)?;
|
||||
Ok(Some(KnowledgeDeleteReceipt { removed: input.folder, pages }))
|
||||
archive_native_entry(&roots.0, &target, &input.folder, true)?;
|
||||
Ok(Some(KnowledgeDeleteReceipt {
|
||||
removed: input.folder,
|
||||
pages,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -404,7 +515,9 @@ pub async fn print_knowledge_document(app: AppHandle) -> Result<(), String> {
|
|||
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}"))
|
||||
window
|
||||
.print()
|
||||
.map_err(|error| format!("HOLOLAKE_PRINT_FAILED: {error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -476,7 +589,9 @@ fn move_to_trash(root: &Path, target: &Path) -> Result<PathBuf, String> {
|
|||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
let destination = root.join(".trash").join(format!("{}-{stamp}", relative.display()));
|
||||
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}"))?;
|
||||
|
|
@ -486,9 +601,67 @@ fn move_to_trash(root: &Path, target: &Path) -> Result<PathBuf, String> {
|
|||
Ok(destination)
|
||||
}
|
||||
|
||||
/// 把 docs 下的页面或目录移到库内回收站,同时清理对应的整理索引并留下 Git 回执。
|
||||
/// 文件移动与索引写入之间任一步失败,都会尽力把原件放回原位。
|
||||
fn archive_native_entry(
|
||||
root: &Path,
|
||||
target: &Path,
|
||||
relative_docs_path: &str,
|
||||
directory: bool,
|
||||
) -> Result<PathBuf, String> {
|
||||
let (mut organization, _) = load_organization_index(root)?;
|
||||
let previous_organization = organization.clone();
|
||||
let prefix = format!("{}/", relative_docs_path.trim_end_matches('/'));
|
||||
if directory {
|
||||
organization
|
||||
.documents
|
||||
.retain(|path, _| path != relative_docs_path && !path.starts_with(&prefix));
|
||||
} else {
|
||||
organization.documents.remove(relative_docs_path);
|
||||
}
|
||||
|
||||
let destination = move_to_trash(root, target)?;
|
||||
if let Err(error) = write_organization_index(root, &organization) {
|
||||
let _ = fs::rename(&destination, target);
|
||||
let _ = write_organization_index(root, &previous_organization);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
let docs_pathspec = format!("docs/{relative_docs_path}");
|
||||
let trash_pathspec = destination
|
||||
.strip_prefix(root)
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DELETE_FAILED: {error}"))?
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
git(
|
||||
root,
|
||||
&[
|
||||
"add",
|
||||
"-A",
|
||||
"--",
|
||||
&docs_pathspec,
|
||||
&trash_pathspec,
|
||||
".hololake/organization-v1.json",
|
||||
],
|
||||
"ARCHIVE_ADD",
|
||||
)?;
|
||||
git(
|
||||
root,
|
||||
&[
|
||||
"commit",
|
||||
"-m",
|
||||
&format!("knowledge: archive {relative_docs_path}"),
|
||||
],
|
||||
"ARCHIVE_COMMIT",
|
||||
)?;
|
||||
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}"))?;
|
||||
fs::create_dir_all(root.join(".hololake"))
|
||||
.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}"))?;
|
||||
|
|
@ -504,12 +677,251 @@ fn ensure_native_knowledge_root(root: &Path) -> Result<(), String> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn organization_index_path(root: &Path) -> PathBuf {
|
||||
root.join(".hololake").join("organization-v1.json")
|
||||
}
|
||||
|
||||
fn load_organization_index(root: &Path) -> Result<(KnowledgeOrganizationIndex, String), String> {
|
||||
let path = organization_index_path(root);
|
||||
if !path.exists() {
|
||||
let index = KnowledgeOrganizationIndex::default();
|
||||
let bytes = serde_json::to_vec(&index).map_err(|error| {
|
||||
format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_SERIALIZE_FAILED: {error}")
|
||||
})?;
|
||||
return Ok((index, sha256_hex(&bytes)));
|
||||
}
|
||||
let bytes = fs::read(&path)
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_READ_FAILED: {error}"))?;
|
||||
if bytes.len() > 2 * 1024 * 1024 {
|
||||
return Err("HOLOLAKE_KNOWLEDGE_ORGANIZATION_TOO_LARGE".into());
|
||||
}
|
||||
let index = serde_json::from_slice::<KnowledgeOrganizationIndex>(&bytes)
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_INVALID: {error}"))?;
|
||||
if index.schema != ORGANIZATION_SCHEMA {
|
||||
return Err("HOLOLAKE_KNOWLEDGE_ORGANIZATION_SCHEMA_UNSUPPORTED".into());
|
||||
}
|
||||
Ok((index, sha256_hex(&bytes)))
|
||||
}
|
||||
|
||||
fn normalize_category(value: &str) -> Result<String, String> {
|
||||
let segments = value
|
||||
.split(['/', '\\'])
|
||||
.map(str::trim)
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if segments.is_empty() || segments.len() > MAX_CATEGORY_DEPTH {
|
||||
return Err("HOLOLAKE_KNOWLEDGE_CATEGORY_INVALID".into());
|
||||
}
|
||||
for segment in &segments {
|
||||
let character_count = segment.chars().count();
|
||||
if character_count == 0
|
||||
|| character_count > MAX_CATEGORY_SEGMENT_CHARS
|
||||
|| matches!(*segment, "." | "..")
|
||||
|| segment
|
||||
.chars()
|
||||
.any(|character| character.is_control() || ":*?\"<>|".contains(character))
|
||||
{
|
||||
return Err("HOLOLAKE_KNOWLEDGE_CATEGORY_INVALID".into());
|
||||
}
|
||||
}
|
||||
Ok(segments.join(" / "))
|
||||
}
|
||||
|
||||
fn normalize_tags(tags: Vec<String>) -> Result<Vec<String>, String> {
|
||||
if tags.len() > MAX_TAGS_PER_DOCUMENT {
|
||||
return Err("HOLOLAKE_KNOWLEDGE_TAGS_INVALID".into());
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
let mut normalized = Vec::new();
|
||||
for tag in tags {
|
||||
let value = tag.trim().trim_start_matches('#').trim().to_string();
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if value.chars().count() > MAX_TAG_CHARS
|
||||
|| value.chars().any(|character| character.is_control())
|
||||
{
|
||||
return Err("HOLOLAKE_KNOWLEDGE_TAGS_INVALID".into());
|
||||
}
|
||||
let key = value.to_lowercase();
|
||||
if seen.insert(key) {
|
||||
normalized.push(value);
|
||||
}
|
||||
}
|
||||
normalized.sort();
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn derived_category(path: &str) -> String {
|
||||
let parts = path
|
||||
.split('/')
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if parts.first().copied() == Some("导入") {
|
||||
return parts.get(2).copied().unwrap_or("根目录").to_string();
|
||||
}
|
||||
if parts.len() > 1 {
|
||||
parts[0].to_string()
|
||||
} else {
|
||||
"根目录".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn frontmatter_tags(bytes: &[u8]) -> Vec<String> {
|
||||
let Ok(body) = std::str::from_utf8(bytes) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let normalized = body.replace("\r\n", "\n");
|
||||
if !normalized.starts_with("---\n") {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(closing) = normalized[4..].find("\n---\n").map(|position| position + 4) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut tags = Vec::new();
|
||||
let mut reading_list = false;
|
||||
for line in normalized[4..closing].lines() {
|
||||
let trimmed = line.trim();
|
||||
if reading_list && trimmed.starts_with('-') {
|
||||
tags.push(
|
||||
trimmed
|
||||
.trim_start_matches('-')
|
||||
.trim()
|
||||
.trim_matches(['\'', '"'])
|
||||
.to_string(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
reading_list = false;
|
||||
let Some((key, value)) = trimmed.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
if key.trim() != "tags" {
|
||||
continue;
|
||||
}
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
reading_list = true;
|
||||
continue;
|
||||
}
|
||||
let inline = value.trim_matches(['[', ']']);
|
||||
tags.extend(
|
||||
inline
|
||||
.split(',')
|
||||
.map(|tag| tag.trim().trim_matches(['\'', '"']).to_string()),
|
||||
);
|
||||
}
|
||||
normalize_tags(tags).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn replace_organization_file(target: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
let parent = target
|
||||
.parent()
|
||||
.ok_or_else(|| "HOLOLAKE_KNOWLEDGE_ORGANIZATION_PATH_INVALID".to_string())?;
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_WRITE_FAILED: {error}"))?;
|
||||
let temporary = parent.join(format!("organization-{}.tmp", Uuid::new_v4()));
|
||||
let mut file = private_file_options()
|
||||
.open(&temporary)
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_WRITE_FAILED: {error}"))?;
|
||||
file.write_all(bytes)
|
||||
.and_then(|_| file.sync_all())
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_WRITE_FAILED: {error}"))?;
|
||||
if !target.exists() {
|
||||
return fs::rename(&temporary, target)
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_WRITE_FAILED: {error}"));
|
||||
}
|
||||
let backup = parent.join(format!("organization-{}.backup", Uuid::new_v4()));
|
||||
fs::rename(target, &backup)
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_WRITE_FAILED: {error}"))?;
|
||||
if let Err(error) = fs::rename(&temporary, target) {
|
||||
let _ = fs::rename(&backup, target);
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err(format!(
|
||||
"HOLOLAKE_KNOWLEDGE_ORGANIZATION_WRITE_FAILED: {error}"
|
||||
));
|
||||
}
|
||||
let _ = fs::remove_file(backup);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_organization_index(root: &Path, index: &KnowledgeOrganizationIndex) -> Result<(), String> {
|
||||
let mut bytes = serde_json::to_vec_pretty(index)
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ORGANIZATION_SERIALIZE_FAILED: {error}"))?;
|
||||
bytes.push(b'\n');
|
||||
replace_organization_file(&organization_index_path(root), &bytes)
|
||||
}
|
||||
|
||||
fn update_organization_at(
|
||||
native: &Path,
|
||||
legacy: Option<&Path>,
|
||||
input: UpdateKnowledgeOrganizationInput,
|
||||
) -> Result<KnowledgeOrganizationUpdateResult, String> {
|
||||
if input.source != "native" {
|
||||
return Err("HOLOLAKE_KNOWLEDGE_ORGANIZATION_READ_ONLY_SOURCE".into());
|
||||
}
|
||||
let category = normalize_category(&input.category)?;
|
||||
let tags = normalize_tags(input.tags)?;
|
||||
let document_path = safe_document_path(&native.join("docs"), &input.path)?;
|
||||
let document_bytes = fs::read(&document_path)
|
||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?;
|
||||
if sha256_hex(&document_bytes) != input.expected_content_sha256 {
|
||||
return Err("HOLOLAKE_KNOWLEDGE_ORGANIZATION_CONFLICT".into());
|
||||
}
|
||||
let (mut index, _) = load_organization_index(native)?;
|
||||
let unchanged = index
|
||||
.documents
|
||||
.get(&input.path)
|
||||
.map(|entry| entry.category == category && entry.tags == tags)
|
||||
.unwrap_or(false);
|
||||
if !unchanged {
|
||||
let updated_at_unix_ms = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0);
|
||||
index.documents.insert(
|
||||
input.path.clone(),
|
||||
KnowledgeOrganizationEntry {
|
||||
category,
|
||||
tags,
|
||||
updated_at_unix_ms,
|
||||
},
|
||||
);
|
||||
write_organization_index(native, &index)?;
|
||||
git(
|
||||
native,
|
||||
&["add", "--", ".hololake/organization-v1.json"],
|
||||
"ORGANIZATION_ADD",
|
||||
)?;
|
||||
git(
|
||||
native,
|
||||
&[
|
||||
"commit",
|
||||
"-m",
|
||||
&format!("knowledge: organize {}", input.path),
|
||||
],
|
||||
"ORGANIZATION_COMMIT",
|
||||
)?;
|
||||
}
|
||||
let git_commit = git(native, &["rev-parse", "HEAD"], "ORGANIZATION_READBACK")?
|
||||
.trim()
|
||||
.to_string();
|
||||
Ok(KnowledgeOrganizationUpdateResult {
|
||||
schema: "hololake.knowledge-organization-update/v1",
|
||||
state: if unchanged { "UNCHANGED" } else { "SAVED" },
|
||||
git_commit,
|
||||
snapshot: snapshot_at(native, legacy)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn snapshot_at(native: &Path, legacy: Option<&Path>) -> Result<KnowledgeSnapshot, String> {
|
||||
let (organization, organization_revision) = load_organization_index(native)?;
|
||||
let mut raw_documents = Vec::new();
|
||||
collect_documents(
|
||||
&native.join("docs"),
|
||||
&native.join("docs"),
|
||||
"native",
|
||||
Some(&organization),
|
||||
&mut raw_documents,
|
||||
)?;
|
||||
if let Some(root) = legacy {
|
||||
|
|
@ -517,6 +929,7 @@ fn snapshot_at(native: &Path, legacy: Option<&Path>) -> Result<KnowledgeSnapshot
|
|||
&root.join("docs"),
|
||||
&root.join("docs"),
|
||||
"legacy",
|
||||
None,
|
||||
&mut raw_documents,
|
||||
)?;
|
||||
}
|
||||
|
|
@ -539,6 +952,53 @@ fn snapshot_at(native: &Path, legacy: Option<&Path>) -> Result<KnowledgeSnapshot
|
|||
let truncated = raw_document_count >= MAX_TREE_DOCUMENTS;
|
||||
documents.truncate(MAX_TREE_DOCUMENTS);
|
||||
let unique_document_count = documents.len();
|
||||
let organized_document_count = documents
|
||||
.iter()
|
||||
.filter(|document| document.category_explicit)
|
||||
.count();
|
||||
let mut category_counts = BTreeMap::<String, (usize, usize)>::new();
|
||||
let mut tag_counts = BTreeMap::<String, usize>::new();
|
||||
for document in &documents {
|
||||
let category = category_counts
|
||||
.entry(document.category.clone())
|
||||
.or_default();
|
||||
category.0 += 1;
|
||||
if document.category_explicit {
|
||||
category.1 += 1;
|
||||
}
|
||||
for tag in &document.tags {
|
||||
*tag_counts.entry(tag.clone()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let mut categories = category_counts
|
||||
.into_iter()
|
||||
.map(
|
||||
|(name, (document_count, explicitly_organized_count))| KnowledgeCategorySummary {
|
||||
name,
|
||||
document_count,
|
||||
explicitly_organized_count,
|
||||
},
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
categories.sort_by(|left, right| {
|
||||
right
|
||||
.document_count
|
||||
.cmp(&left.document_count)
|
||||
.then_with(|| left.name.cmp(&right.name))
|
||||
});
|
||||
let mut tags = tag_counts
|
||||
.into_iter()
|
||||
.map(|(name, document_count)| KnowledgeTagSummary {
|
||||
name,
|
||||
document_count,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
tags.sort_by(|left, right| {
|
||||
right
|
||||
.document_count
|
||||
.cmp(&left.document_count)
|
||||
.then_with(|| left.name.cmp(&right.name))
|
||||
});
|
||||
Ok(KnowledgeSnapshot {
|
||||
schema: SNAPSHOT_SCHEMA,
|
||||
state: "READY",
|
||||
|
|
@ -549,6 +1009,10 @@ fn snapshot_at(native: &Path, legacy: Option<&Path>) -> Result<KnowledgeSnapshot
|
|||
raw_document_count,
|
||||
unique_document_count,
|
||||
duplicate_document_count: raw_document_count.saturating_sub(unique_document_count),
|
||||
organized_document_count,
|
||||
categories,
|
||||
tags,
|
||||
organization_revision,
|
||||
truncated,
|
||||
})
|
||||
}
|
||||
|
|
@ -557,6 +1021,7 @@ fn collect_documents(
|
|||
root: &Path,
|
||||
current: &Path,
|
||||
source: &'static str,
|
||||
organization: Option<&KnowledgeOrganizationIndex>,
|
||||
output: &mut Vec<KnowledgeDocumentSummary>,
|
||||
) -> Result<(), String> {
|
||||
if output.len() >= MAX_TREE_DOCUMENTS {
|
||||
|
|
@ -581,7 +1046,7 @@ fn collect_documents(
|
|||
let name = entry.file_name();
|
||||
if kind.is_dir() {
|
||||
if name != OsStr::new(".git") && name != OsStr::new(".hololake") {
|
||||
collect_documents(root, &path, source, output)?;
|
||||
collect_documents(root, &path, source, organization, output)?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
@ -594,9 +1059,22 @@ fn collect_documents(
|
|||
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}"))?;
|
||||
let relative = relative_posix(root, &path)?;
|
||||
let frontmatter_tags = frontmatter_tags(&bytes);
|
||||
let indexed = organization.and_then(|index| index.documents.get(&relative));
|
||||
let category = indexed
|
||||
.map(|entry| entry.category.clone())
|
||||
.filter(|category| !category.is_empty())
|
||||
.unwrap_or_else(|| derived_category(&relative));
|
||||
let mut tags = indexed.map(|entry| entry.tags.clone()).unwrap_or_default();
|
||||
for tag in frontmatter_tags {
|
||||
if !tags.contains(&tag) {
|
||||
tags.push(tag);
|
||||
}
|
||||
}
|
||||
tags.sort();
|
||||
output.push(KnowledgeDocumentSummary {
|
||||
source,
|
||||
path: relative,
|
||||
|
|
@ -605,6 +1083,9 @@ fn collect_documents(
|
|||
size_bytes: metadata.len(),
|
||||
content_sha256: sha256_hex(&bytes),
|
||||
duplicate_count: 0,
|
||||
category,
|
||||
category_explicit: indexed.is_some(),
|
||||
tags,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -990,7 +1471,9 @@ fn strip_notion_links(line: &str) -> String {
|
|||
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();
|
||||
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;
|
||||
|
|
@ -1376,4 +1859,130 @@ mod tests {
|
|||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].title, "星湖");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_builds_unified_categories_and_frontmatter_tags() {
|
||||
let native = tempdir().unwrap();
|
||||
ensure_native_knowledge_root(native.path()).unwrap();
|
||||
fs::create_dir_all(native.path().join("docs/导入/旧库/课程")).unwrap();
|
||||
fs::write(
|
||||
native.path().join("docs/导入/旧库/课程/第一课.md"),
|
||||
"---\ntags: [教学, 重点]\n---\n# 第一课\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let snapshot = snapshot_at(native.path(), None).unwrap();
|
||||
|
||||
assert_eq!(snapshot.documents[0].category, "课程");
|
||||
assert_eq!(snapshot.documents[0].tags, vec!["教学", "重点"]);
|
||||
assert_eq!(snapshot.categories[0].name, "课程");
|
||||
assert_eq!(snapshot.tags.len(), 2);
|
||||
assert_eq!(snapshot.organized_document_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_organization_is_revision_locked_git_backed_and_does_not_rewrite_body() {
|
||||
let native = tempdir().unwrap();
|
||||
ensure_native_knowledge_root(native.path()).unwrap();
|
||||
let body = "---\ntags: [原有]\n---\n# 页面\n\n正文不应被整理操作改写。\n";
|
||||
fs::write(native.path().join("docs/page.md"), body).unwrap();
|
||||
git(native.path(), &["add", "docs/page.md"], "TEST_ADD").unwrap();
|
||||
git(native.path(), &["commit", "-m", "initial"], "TEST_COMMIT").unwrap();
|
||||
let hash = sha256_hex(body.as_bytes());
|
||||
|
||||
let result = update_organization_at(
|
||||
native.path(),
|
||||
None,
|
||||
UpdateKnowledgeOrganizationInput {
|
||||
source: "native".into(),
|
||||
path: "page.md".into(),
|
||||
expected_content_sha256: hash.clone(),
|
||||
category: "教学 / 课程".into(),
|
||||
tags: vec!["待复盘".into(), "重点".into()],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.state, "SAVED");
|
||||
assert_eq!(result.git_commit.len(), 40);
|
||||
assert_eq!(
|
||||
fs::read_to_string(native.path().join("docs/page.md")).unwrap(),
|
||||
body
|
||||
);
|
||||
let document = &result.snapshot.documents[0];
|
||||
assert_eq!(document.category, "教学 / 课程");
|
||||
assert!(document.category_explicit);
|
||||
assert_eq!(document.tags, vec!["原有", "待复盘", "重点"]);
|
||||
assert_eq!(result.snapshot.organized_document_count, 1);
|
||||
|
||||
let stale = update_organization_at(
|
||||
native.path(),
|
||||
None,
|
||||
UpdateKnowledgeOrganizationInput {
|
||||
source: "native".into(),
|
||||
path: "page.md".into(),
|
||||
expected_content_sha256: sha256_hex(b"stale"),
|
||||
category: "其他".into(),
|
||||
tags: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(stale.contains("ORGANIZATION_CONFLICT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn organized_page_archive_moves_into_recovery_cleans_index_and_commits() {
|
||||
let native = tempdir().unwrap();
|
||||
ensure_native_knowledge_root(native.path()).unwrap();
|
||||
let body = "# 待归档页面\n";
|
||||
let target = native.path().join("docs/page.md");
|
||||
fs::write(&target, body).unwrap();
|
||||
git(native.path(), &["add", "docs/page.md"], "TEST_ADD").unwrap();
|
||||
git(native.path(), &["commit", "-m", "initial"], "TEST_COMMIT").unwrap();
|
||||
update_organization_at(
|
||||
native.path(),
|
||||
None,
|
||||
UpdateKnowledgeOrganizationInput {
|
||||
source: "native".into(),
|
||||
path: "page.md".into(),
|
||||
expected_content_sha256: sha256_hex(body.as_bytes()),
|
||||
category: "验收 / 归档".into(),
|
||||
tags: vec!["可恢复".into()],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let destination = archive_native_entry(native.path(), &target, "page.md", false).unwrap();
|
||||
|
||||
assert!(!target.exists());
|
||||
assert!(destination.is_file());
|
||||
assert!(destination.starts_with(native.path().join(".trash")));
|
||||
let (organization, _) = load_organization_index(native.path()).unwrap();
|
||||
assert!(!organization.documents.contains_key("page.md"));
|
||||
assert_eq!(
|
||||
snapshot_at(native.path(), None)
|
||||
.unwrap()
|
||||
.unique_document_count,
|
||||
0
|
||||
);
|
||||
assert!(
|
||||
git(native.path(), &["status", "--porcelain"], "TEST_STATUS")
|
||||
.unwrap()
|
||||
.trim()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn organization_rejects_unbounded_or_cross_platform_invalid_labels() {
|
||||
assert!(normalize_category("课程/章节/单元/课时/过深").is_err());
|
||||
assert!(normalize_category("课程:非法").is_err());
|
||||
assert!(normalize_tags(vec!["a".repeat(MAX_TAG_CHARS + 1)]).is_err());
|
||||
assert!(normalize_tags(
|
||||
(0..=MAX_TAGS_PER_DOCUMENT)
|
||||
.map(|index| index.to_string())
|
||||
.collect()
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
mod authenticated_storage;
|
||||
mod channel_growth;
|
||||
mod circular_lake_membrane;
|
||||
mod code_channel;
|
||||
mod code_repo_login;
|
||||
mod direct_local_broker;
|
||||
mod direct_local_session;
|
||||
mod dynamic_capability_routing;
|
||||
mod education_translation;
|
||||
mod education_workspace;
|
||||
mod enterprise_work_channel;
|
||||
mod glp_envelope;
|
||||
mod gls_bootstrap_compiler;
|
||||
|
|
@ -13,6 +16,8 @@ mod gls_protocol_runtime;
|
|||
mod home_status;
|
||||
mod knowledge_base;
|
||||
mod local_development_bridge;
|
||||
mod native_composition;
|
||||
mod persona_channel_body;
|
||||
mod persona_time_authority;
|
||||
mod personal_channel;
|
||||
mod pncc_receipt_projection;
|
||||
|
|
@ -59,14 +64,42 @@ pub fn run() {
|
|||
personal_channel::initialize_personal_channel,
|
||||
personal_channel::create_personal_channel_task,
|
||||
personal_channel::transition_personal_channel_task,
|
||||
channel_growth::get_channel_growth_snapshot,
|
||||
channel_growth::record_channel_growth_event,
|
||||
channel_growth::update_channel_growth_sharing,
|
||||
persona_time_authority::issue_persona_time_ticket,
|
||||
persona_time_authority::start_persona_time_authority,
|
||||
persona_time_authority::get_beijing_time_coordinate,
|
||||
persona_time_authority::get_guanghu_era_timeline,
|
||||
persona_channel_body::get_persona_channel_body,
|
||||
persona_channel_body::register_trial_persona,
|
||||
persona_channel_body::delete_trial_persona,
|
||||
persona_channel_body::accept_persona_language_contract,
|
||||
persona_channel_body::append_persona_language,
|
||||
native_composition::get_native_composition_module_registry,
|
||||
native_composition::execute_knowledge_native_composition,
|
||||
education_workspace::get_education_workspace_snapshot,
|
||||
education_workspace::create_education_document,
|
||||
education_workspace::read_education_document,
|
||||
education_workspace::save_education_document,
|
||||
education_workspace::archive_education_document,
|
||||
education_workspace::create_education_table,
|
||||
education_workspace::read_education_table,
|
||||
education_workspace::save_education_table,
|
||||
education_workspace::archive_education_table,
|
||||
education_translation::import_education_tables_from_dialog,
|
||||
education_translation::export_education_table_to_dialog,
|
||||
education_translation::get_education_recognition_capability,
|
||||
education_workspace::create_education_automation_rule,
|
||||
education_workspace::save_education_automation_rule,
|
||||
education_workspace::archive_education_automation_rule,
|
||||
education_workspace::preview_education_automation_rule,
|
||||
education_workspace::execute_education_automation_rule,
|
||||
knowledge_base::get_knowledge_snapshot,
|
||||
knowledge_base::read_knowledge_document,
|
||||
knowledge_base::search_knowledge,
|
||||
knowledge_base::save_knowledge_document,
|
||||
knowledge_base::update_knowledge_organization,
|
||||
knowledge_base::select_and_import_knowledge_folder,
|
||||
knowledge_base::export_knowledge_document,
|
||||
knowledge_base::create_knowledge_document,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,833 @@
|
|||
//! HoloLake 原生组合执行内核。
|
||||
//!
|
||||
//! 外来文件格式不进入这里。内核只接受登记过的模块、受限配方和当前登录账号的
|
||||
//! HoloLake 原生对象;人类投影只是同一执行结果的不同视图,没有源数据所有权。
|
||||
|
||||
use ring::digest::{digest, SHA256};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::AppHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
const REGISTRY_SCHEMA: &str = "hololake.composition-module-registry/v1";
|
||||
const OBJECT_SCHEMA: &str = "hololake.native-object/v1";
|
||||
const RECIPE_SCHEMA: &str = "hololake.composition-recipe/v1";
|
||||
const PROJECTION_SCHEMA: &str = "hololake.human-projection/v1";
|
||||
const MAX_ROWS: usize = 5_000;
|
||||
const MAX_VIEWS: usize = 5;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum CompositionDimension {
|
||||
Source,
|
||||
TopLevelFolder,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum CompositionMeasure {
|
||||
DocumentCount,
|
||||
TotalBytes,
|
||||
DuplicateCount,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum ProjectionView {
|
||||
Dashboard,
|
||||
Comparison,
|
||||
VerticalBar,
|
||||
Classification,
|
||||
Table,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ExecuteCompositionInput {
|
||||
pub dimension: CompositionDimension,
|
||||
pub measure: CompositionMeasure,
|
||||
pub views: Vec<ProjectionView>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModuleDescriptor {
|
||||
pub module_id: &'static str,
|
||||
pub display_name: &'static str,
|
||||
pub kind: &'static str,
|
||||
pub input_schema: Option<&'static str>,
|
||||
pub output_schema: &'static str,
|
||||
pub deterministic: bool,
|
||||
pub authority: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModuleRegistry {
|
||||
pub schema: &'static str,
|
||||
pub state: &'static str,
|
||||
pub modules: Vec<ModuleDescriptor>,
|
||||
pub arbitrary_script_allowed: bool,
|
||||
pub unregistered_module_allowed: bool,
|
||||
pub direct_projection_write_allowed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NativeColumn {
|
||||
pub column_id: &'static str,
|
||||
pub title: &'static str,
|
||||
pub value_type: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NativeKnowledgeRow {
|
||||
pub row_id: String,
|
||||
pub source: String,
|
||||
pub path: String,
|
||||
pub title: String,
|
||||
pub top_level_folder: String,
|
||||
pub size_bytes: u64,
|
||||
pub duplicate_count: usize,
|
||||
pub updated_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NativeObject {
|
||||
pub schema: &'static str,
|
||||
pub object_id: String,
|
||||
pub title: &'static str,
|
||||
pub columns: Vec<NativeColumn>,
|
||||
pub rows: Vec<NativeKnowledgeRow>,
|
||||
pub row_count: usize,
|
||||
pub truncated: bool,
|
||||
pub source_receipt: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompositionNode {
|
||||
pub node_id: String,
|
||||
pub module_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompositionEdge {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompositionRecipe {
|
||||
pub schema: &'static str,
|
||||
pub recipe_id: &'static str,
|
||||
pub title: &'static str,
|
||||
pub nodes: Vec<CompositionNode>,
|
||||
pub edges: Vec<CompositionEdge>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectionGroup {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub document_count: usize,
|
||||
pub total_bytes: u64,
|
||||
pub duplicate_count: usize,
|
||||
pub measure_value: u64,
|
||||
pub share: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectionMetrics {
|
||||
pub raw_document_count: usize,
|
||||
pub unique_document_count: usize,
|
||||
pub duplicate_document_count: usize,
|
||||
pub total_bytes: u64,
|
||||
pub group_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NativeCompositionProjection {
|
||||
pub schema: &'static str,
|
||||
pub state: &'static str,
|
||||
pub execution_id: String,
|
||||
pub executed_at_unix_ms: u64,
|
||||
pub source_is_real_account_data: bool,
|
||||
pub read_only: bool,
|
||||
pub dimension: CompositionDimension,
|
||||
pub measure: CompositionMeasure,
|
||||
pub views: Vec<ProjectionView>,
|
||||
pub native_object: NativeObject,
|
||||
pub groups: Vec<ProjectionGroup>,
|
||||
pub metrics: ProjectionMetrics,
|
||||
pub recipe: CompositionRecipe,
|
||||
pub data_sha256: String,
|
||||
pub receipt_sha256: String,
|
||||
}
|
||||
|
||||
fn registry() -> ModuleRegistry {
|
||||
let modules = vec![
|
||||
descriptor(
|
||||
"HLC-SOURCE-KNOWLEDGE-CATALOG",
|
||||
"知识目录数据源",
|
||||
"SOURCE",
|
||||
None,
|
||||
OBJECT_SCHEMA,
|
||||
),
|
||||
descriptor(
|
||||
"HLC-CLASSIFY-SOURCE",
|
||||
"按来源分类",
|
||||
"TRANSFORM",
|
||||
Some(OBJECT_SCHEMA),
|
||||
"hololake.classified-object/v1",
|
||||
),
|
||||
descriptor(
|
||||
"HLC-CLASSIFY-TOP-FOLDER",
|
||||
"按一级目录分类",
|
||||
"TRANSFORM",
|
||||
Some(OBJECT_SCHEMA),
|
||||
"hololake.classified-object/v1",
|
||||
),
|
||||
descriptor(
|
||||
"HLC-AGGREGATE-DOCUMENT-COUNT",
|
||||
"统计文档数",
|
||||
"TRANSFORM",
|
||||
Some("hololake.classified-object/v1"),
|
||||
"hololake.composition-result/v1",
|
||||
),
|
||||
descriptor(
|
||||
"HLC-AGGREGATE-TOTAL-BYTES",
|
||||
"统计数据量",
|
||||
"TRANSFORM",
|
||||
Some("hololake.classified-object/v1"),
|
||||
"hololake.composition-result/v1",
|
||||
),
|
||||
descriptor(
|
||||
"HLC-AGGREGATE-DUPLICATE-COUNT",
|
||||
"统计重复数",
|
||||
"TRANSFORM",
|
||||
Some("hololake.classified-object/v1"),
|
||||
"hololake.composition-result/v1",
|
||||
),
|
||||
descriptor(
|
||||
"HLC-PROJECT-DASHBOARD",
|
||||
"仪表盘投影",
|
||||
"PROJECTION",
|
||||
Some("hololake.composition-result/v1"),
|
||||
PROJECTION_SCHEMA,
|
||||
),
|
||||
descriptor(
|
||||
"HLC-PROJECT-COMPARISON",
|
||||
"对比投影",
|
||||
"PROJECTION",
|
||||
Some("hololake.composition-result/v1"),
|
||||
PROJECTION_SCHEMA,
|
||||
),
|
||||
descriptor(
|
||||
"HLC-PROJECT-VERTICAL-BAR",
|
||||
"柱状图投影",
|
||||
"PROJECTION",
|
||||
Some("hololake.composition-result/v1"),
|
||||
PROJECTION_SCHEMA,
|
||||
),
|
||||
descriptor(
|
||||
"HLC-PROJECT-CLASSIFICATION",
|
||||
"分类投影",
|
||||
"PROJECTION",
|
||||
Some("hololake.composition-result/v1"),
|
||||
PROJECTION_SCHEMA,
|
||||
),
|
||||
descriptor(
|
||||
"HLC-PROJECT-TABLE",
|
||||
"表格投影",
|
||||
"PROJECTION",
|
||||
Some("hololake.composition-result/v1"),
|
||||
PROJECTION_SCHEMA,
|
||||
),
|
||||
];
|
||||
ModuleRegistry {
|
||||
schema: REGISTRY_SCHEMA,
|
||||
state: "READY",
|
||||
modules,
|
||||
arbitrary_script_allowed: false,
|
||||
unregistered_module_allowed: false,
|
||||
direct_projection_write_allowed: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn descriptor(
|
||||
module_id: &'static str,
|
||||
display_name: &'static str,
|
||||
kind: &'static str,
|
||||
input_schema: Option<&'static str>,
|
||||
output_schema: &'static str,
|
||||
) -> ModuleDescriptor {
|
||||
ModuleDescriptor {
|
||||
module_id,
|
||||
display_name,
|
||||
kind,
|
||||
input_schema,
|
||||
output_schema,
|
||||
deterministic: true,
|
||||
authority: "CURRENT_AUTHENTICATED_ACCOUNT_READ_ONLY",
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_native_composition_module_registry() -> ModuleRegistry {
|
||||
registry()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn execute_knowledge_native_composition(
|
||||
app: AppHandle,
|
||||
input: ExecuteCompositionInput,
|
||||
) -> Result<NativeCompositionProjection, String> {
|
||||
validate_input(&input)?;
|
||||
let snapshot = tauri::async_runtime::spawn_blocking(move || {
|
||||
crate::knowledge_base::snapshot_for_native_composition(&app)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_COMPOSITION_JOIN_FAILED: {error}"))??;
|
||||
execute_snapshot(snapshot, input)
|
||||
}
|
||||
|
||||
fn validate_input(input: &ExecuteCompositionInput) -> Result<(), String> {
|
||||
if input.views.is_empty() || input.views.len() > MAX_VIEWS {
|
||||
return Err("HOLOLAKE_COMPOSITION_VIEW_COUNT_INVALID".into());
|
||||
}
|
||||
let unique = input.views.iter().copied().collect::<BTreeSet<_>>();
|
||||
if unique.len() != input.views.len() {
|
||||
return Err("HOLOLAKE_COMPOSITION_DUPLICATE_VIEW".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn execute_snapshot(
|
||||
snapshot: crate::knowledge_base::KnowledgeSnapshot,
|
||||
input: ExecuteCompositionInput,
|
||||
) -> Result<NativeCompositionProjection, String> {
|
||||
if snapshot.documents.len() > MAX_ROWS {
|
||||
return Err("HOLOLAKE_COMPOSITION_ROW_LIMIT_EXCEEDED".into());
|
||||
}
|
||||
let rows = snapshot
|
||||
.documents
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, document)| NativeKnowledgeRow {
|
||||
row_id: format!("knowledge-row-{}", index + 1),
|
||||
source: document.source.to_string(),
|
||||
path: document.path.clone(),
|
||||
title: document.title.clone(),
|
||||
top_level_folder: document.category.clone(),
|
||||
size_bytes: document.size_bytes,
|
||||
duplicate_count: document.duplicate_count,
|
||||
updated_at_unix_ms: document.updated_at_unix_ms.min(u64::MAX as u128) as u64,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let data_sha256 = sha256_json(&rows)?;
|
||||
let native_object = NativeObject {
|
||||
schema: OBJECT_SCHEMA,
|
||||
object_id: format!("knowledge-catalog-{}", &data_sha256[..16]),
|
||||
title: "当前账号知识目录",
|
||||
columns: native_columns(),
|
||||
row_count: rows.len(),
|
||||
rows,
|
||||
truncated: snapshot.truncated,
|
||||
source_receipt: "CURRENT_AUTHENTICATED_ACCOUNT_KNOWLEDGE_SNAPSHOT",
|
||||
};
|
||||
let mut aggregates = BTreeMap::<String, (usize, u64, usize)>::new();
|
||||
for row in &native_object.rows {
|
||||
let key = match input.dimension {
|
||||
CompositionDimension::Source => source_label(&row.source),
|
||||
CompositionDimension::TopLevelFolder => row.top_level_folder.clone(),
|
||||
};
|
||||
let entry = aggregates.entry(key).or_default();
|
||||
entry.0 += 1;
|
||||
entry.1 = entry.1.saturating_add(row.size_bytes);
|
||||
entry.2 = entry.2.saturating_add(row.duplicate_count);
|
||||
}
|
||||
let total_measure = aggregates
|
||||
.values()
|
||||
.map(|value| measure_value(*value, input.measure))
|
||||
.sum::<u64>();
|
||||
let mut groups = aggregates
|
||||
.into_iter()
|
||||
.map(|(label, value)| ProjectionGroup {
|
||||
key: stable_key(&label),
|
||||
label,
|
||||
document_count: value.0,
|
||||
total_bytes: value.1,
|
||||
duplicate_count: value.2,
|
||||
measure_value: measure_value(value, input.measure),
|
||||
share: if total_measure == 0 {
|
||||
0.0
|
||||
} else {
|
||||
measure_value(value, input.measure) as f64 / total_measure as f64
|
||||
},
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
groups.sort_by(|left, right| {
|
||||
right
|
||||
.measure_value
|
||||
.cmp(&left.measure_value)
|
||||
.then_with(|| left.label.cmp(&right.label))
|
||||
});
|
||||
let recipe = compile_recipe(&input);
|
||||
validate_recipe(&recipe, ®istry())?;
|
||||
let metrics = ProjectionMetrics {
|
||||
raw_document_count: snapshot.raw_document_count,
|
||||
unique_document_count: snapshot.unique_document_count,
|
||||
duplicate_document_count: snapshot.duplicate_document_count,
|
||||
total_bytes: native_object.rows.iter().map(|row| row.size_bytes).sum(),
|
||||
group_count: groups.len(),
|
||||
};
|
||||
let executed_at_unix_ms = now_unix_ms();
|
||||
let receipt_material = serde_json::to_vec(&(
|
||||
&data_sha256,
|
||||
&recipe.nodes,
|
||||
&recipe.edges,
|
||||
&groups,
|
||||
executed_at_unix_ms,
|
||||
))
|
||||
.map_err(|error| format!("HOLOLAKE_COMPOSITION_RECEIPT_SERIALIZE_FAILED: {error}"))?;
|
||||
Ok(NativeCompositionProjection {
|
||||
schema: PROJECTION_SCHEMA,
|
||||
state: "EXECUTED",
|
||||
execution_id: format!("HLC-EXEC-{}", Uuid::new_v4()),
|
||||
executed_at_unix_ms,
|
||||
source_is_real_account_data: true,
|
||||
read_only: true,
|
||||
dimension: input.dimension,
|
||||
measure: input.measure,
|
||||
views: input.views,
|
||||
native_object,
|
||||
groups,
|
||||
metrics,
|
||||
recipe,
|
||||
data_sha256,
|
||||
receipt_sha256: sha256(&receipt_material),
|
||||
})
|
||||
}
|
||||
|
||||
fn native_columns() -> Vec<NativeColumn> {
|
||||
vec![
|
||||
NativeColumn {
|
||||
column_id: "title",
|
||||
title: "标题",
|
||||
value_type: "TEXT",
|
||||
},
|
||||
NativeColumn {
|
||||
column_id: "source",
|
||||
title: "来源",
|
||||
value_type: "TEXT",
|
||||
},
|
||||
NativeColumn {
|
||||
column_id: "topLevelFolder",
|
||||
title: "一级分类",
|
||||
value_type: "TEXT",
|
||||
},
|
||||
NativeColumn {
|
||||
column_id: "sizeBytes",
|
||||
title: "数据量",
|
||||
value_type: "INTEGER",
|
||||
},
|
||||
NativeColumn {
|
||||
column_id: "duplicateCount",
|
||||
title: "重复数",
|
||||
value_type: "INTEGER",
|
||||
},
|
||||
NativeColumn {
|
||||
column_id: "updatedAtUnixMs",
|
||||
title: "更新时间",
|
||||
value_type: "TIME",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn compile_recipe(input: &ExecuteCompositionInput) -> CompositionRecipe {
|
||||
let classify = match input.dimension {
|
||||
CompositionDimension::Source => "HLC-CLASSIFY-SOURCE",
|
||||
CompositionDimension::TopLevelFolder => "HLC-CLASSIFY-TOP-FOLDER",
|
||||
};
|
||||
let aggregate = match input.measure {
|
||||
CompositionMeasure::DocumentCount => "HLC-AGGREGATE-DOCUMENT-COUNT",
|
||||
CompositionMeasure::TotalBytes => "HLC-AGGREGATE-TOTAL-BYTES",
|
||||
CompositionMeasure::DuplicateCount => "HLC-AGGREGATE-DUPLICATE-COUNT",
|
||||
};
|
||||
let mut nodes = vec![
|
||||
CompositionNode {
|
||||
node_id: "source".into(),
|
||||
module_id: "HLC-SOURCE-KNOWLEDGE-CATALOG".into(),
|
||||
},
|
||||
CompositionNode {
|
||||
node_id: "classify".into(),
|
||||
module_id: classify.into(),
|
||||
},
|
||||
CompositionNode {
|
||||
node_id: "aggregate".into(),
|
||||
module_id: aggregate.into(),
|
||||
},
|
||||
];
|
||||
let mut edges = vec![
|
||||
CompositionEdge {
|
||||
from: "source".into(),
|
||||
to: "classify".into(),
|
||||
},
|
||||
CompositionEdge {
|
||||
from: "classify".into(),
|
||||
to: "aggregate".into(),
|
||||
},
|
||||
];
|
||||
for (index, view) in input.views.iter().enumerate() {
|
||||
let node_id = format!("projection-{}", index + 1);
|
||||
nodes.push(CompositionNode {
|
||||
node_id: node_id.clone(),
|
||||
module_id: view_module_id(*view).into(),
|
||||
});
|
||||
edges.push(CompositionEdge {
|
||||
from: "aggregate".into(),
|
||||
to: node_id,
|
||||
});
|
||||
}
|
||||
CompositionRecipe {
|
||||
schema: RECIPE_SCHEMA,
|
||||
recipe_id: "HLC-RECIPE-KNOWLEDGE-OVERVIEW-001",
|
||||
title: "知识空间结构组合",
|
||||
nodes,
|
||||
edges,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_recipe(
|
||||
recipe: &CompositionRecipe,
|
||||
module_registry: &ModuleRegistry,
|
||||
) -> Result<(), String> {
|
||||
let descriptors = module_registry
|
||||
.modules
|
||||
.iter()
|
||||
.map(|item| (item.module_id, item))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let nodes = recipe
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|item| (item.node_id.as_str(), item))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
if nodes.len() != recipe.nodes.len() {
|
||||
return Err("HOLOLAKE_COMPOSITION_DUPLICATE_NODE".into());
|
||||
}
|
||||
for node in &recipe.nodes {
|
||||
if !descriptors.contains_key(node.module_id.as_str()) {
|
||||
return Err("HOLOLAKE_COMPOSITION_MODULE_NOT_REGISTERED".into());
|
||||
}
|
||||
}
|
||||
let mut indegree = recipe
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|item| (item.node_id.as_str(), 0usize))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut outgoing = BTreeMap::<&str, Vec<&str>>::new();
|
||||
for edge in &recipe.edges {
|
||||
let from = nodes
|
||||
.get(edge.from.as_str())
|
||||
.ok_or("HOLOLAKE_COMPOSITION_EDGE_NODE_UNKNOWN")?;
|
||||
let to = nodes
|
||||
.get(edge.to.as_str())
|
||||
.ok_or("HOLOLAKE_COMPOSITION_EDGE_NODE_UNKNOWN")?;
|
||||
let from_descriptor = descriptors
|
||||
.get(from.module_id.as_str())
|
||||
.ok_or("HOLOLAKE_COMPOSITION_MODULE_NOT_REGISTERED")?;
|
||||
let to_descriptor = descriptors
|
||||
.get(to.module_id.as_str())
|
||||
.ok_or("HOLOLAKE_COMPOSITION_MODULE_NOT_REGISTERED")?;
|
||||
if to_descriptor.input_schema != Some(from_descriptor.output_schema) {
|
||||
return Err("HOLOLAKE_COMPOSITION_PORT_TYPE_MISMATCH".into());
|
||||
}
|
||||
*indegree
|
||||
.get_mut(to.node_id.as_str())
|
||||
.ok_or("HOLOLAKE_COMPOSITION_EDGE_NODE_UNKNOWN")? += 1;
|
||||
outgoing
|
||||
.entry(from.node_id.as_str())
|
||||
.or_default()
|
||||
.push(to.node_id.as_str());
|
||||
}
|
||||
let mut queue = indegree
|
||||
.iter()
|
||||
.filter_map(|(id, degree)| (*degree == 0).then_some(*id))
|
||||
.collect::<VecDeque<_>>();
|
||||
let mut visited = 0usize;
|
||||
while let Some(node) = queue.pop_front() {
|
||||
visited += 1;
|
||||
for target in outgoing.get(node).into_iter().flatten() {
|
||||
let degree = indegree
|
||||
.get_mut(target)
|
||||
.ok_or("HOLOLAKE_COMPOSITION_EDGE_NODE_UNKNOWN")?;
|
||||
*degree -= 1;
|
||||
if *degree == 0 {
|
||||
queue.push_back(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
if visited != recipe.nodes.len() {
|
||||
return Err("HOLOLAKE_COMPOSITION_CYCLE_REJECTED".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn view_module_id(view: ProjectionView) -> &'static str {
|
||||
match view {
|
||||
ProjectionView::Dashboard => "HLC-PROJECT-DASHBOARD",
|
||||
ProjectionView::Comparison => "HLC-PROJECT-COMPARISON",
|
||||
ProjectionView::VerticalBar => "HLC-PROJECT-VERTICAL-BAR",
|
||||
ProjectionView::Classification => "HLC-PROJECT-CLASSIFICATION",
|
||||
ProjectionView::Table => "HLC-PROJECT-TABLE",
|
||||
}
|
||||
}
|
||||
|
||||
fn measure_value(value: (usize, u64, usize), measure: CompositionMeasure) -> u64 {
|
||||
match measure {
|
||||
CompositionMeasure::DocumentCount => value.0 as u64,
|
||||
CompositionMeasure::TotalBytes => value.1,
|
||||
CompositionMeasure::DuplicateCount => value.2 as u64,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn top_level_folder(path: &str) -> String {
|
||||
let normalized = path.replace('\\', "/");
|
||||
let parts = normalized
|
||||
.split('/')
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
// “导入/导入批次名”是转译外壳,不是人类真正要比较的知识分类。
|
||||
let candidate = if parts.first() == Some(&"导入") {
|
||||
parts.get(2).copied().unwrap_or("根页面")
|
||||
} else {
|
||||
parts.first().copied().unwrap_or("未分类")
|
||||
};
|
||||
if candidate.ends_with(".md") || candidate.ends_with(".markdown") || candidate.ends_with(".txt")
|
||||
{
|
||||
"根目录".into()
|
||||
} else {
|
||||
candidate.into()
|
||||
}
|
||||
}
|
||||
|
||||
fn source_label(source: &str) -> String {
|
||||
match source {
|
||||
"native" => "光湖原生知识".into(),
|
||||
"legacy" => "待迁移知识".into(),
|
||||
other => other.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn stable_key(value: &str) -> String {
|
||||
sha256(value.as_bytes())[..16].to_string()
|
||||
}
|
||||
fn sha256_json<T: Serialize>(value: &T) -> Result<String, String> {
|
||||
serde_json::to_vec(value)
|
||||
.map(|bytes| sha256(&bytes))
|
||||
.map_err(|error| format!("HOLOLAKE_COMPOSITION_DATA_SERIALIZE_FAILED: {error}"))
|
||||
}
|
||||
fn sha256(bytes: &[u8]) -> String {
|
||||
digest(&SHA256, bytes)
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
fn now_unix_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|value| value.as_millis().min(u64::MAX as u128) as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::knowledge_base::{KnowledgeDocumentSummary, KnowledgeSnapshot};
|
||||
|
||||
fn snapshot() -> KnowledgeSnapshot {
|
||||
KnowledgeSnapshot {
|
||||
schema: "hololake.native-knowledge-base/v1",
|
||||
state: "READY",
|
||||
native_root: "/private/account".into(),
|
||||
legacy_available: false,
|
||||
legacy_root: None,
|
||||
documents: vec![
|
||||
KnowledgeDocumentSummary {
|
||||
source: "native",
|
||||
path: "课程/第一课.md".into(),
|
||||
title: "第一课".into(),
|
||||
updated_at_unix_ms: 1,
|
||||
size_bytes: 100,
|
||||
content_sha256: "a".into(),
|
||||
duplicate_count: 1,
|
||||
category: "课程".into(),
|
||||
category_explicit: false,
|
||||
tags: vec!["教学".into()],
|
||||
},
|
||||
KnowledgeDocumentSummary {
|
||||
source: "native",
|
||||
path: "课程/第二课.md".into(),
|
||||
title: "第二课".into(),
|
||||
updated_at_unix_ms: 2,
|
||||
size_bytes: 300,
|
||||
content_sha256: "b".into(),
|
||||
duplicate_count: 0,
|
||||
category: "课程".into(),
|
||||
category_explicit: true,
|
||||
tags: vec!["教学".into()],
|
||||
},
|
||||
KnowledgeDocumentSummary {
|
||||
source: "native",
|
||||
path: "记录.md".into(),
|
||||
title: "记录".into(),
|
||||
updated_at_unix_ms: 3,
|
||||
size_bytes: 50,
|
||||
content_sha256: "c".into(),
|
||||
duplicate_count: 0,
|
||||
category: "根目录".into(),
|
||||
category_explicit: false,
|
||||
tags: vec![],
|
||||
},
|
||||
],
|
||||
raw_document_count: 4,
|
||||
unique_document_count: 3,
|
||||
duplicate_document_count: 1,
|
||||
organized_document_count: 1,
|
||||
categories: vec![],
|
||||
tags: vec![],
|
||||
organization_revision: "test".into(),
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_native_rows_feed_all_projection_views_from_one_result() {
|
||||
let result = execute_snapshot(
|
||||
snapshot(),
|
||||
ExecuteCompositionInput {
|
||||
dimension: CompositionDimension::TopLevelFolder,
|
||||
measure: CompositionMeasure::TotalBytes,
|
||||
views: vec![
|
||||
ProjectionView::Dashboard,
|
||||
ProjectionView::VerticalBar,
|
||||
ProjectionView::Table,
|
||||
],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.state, "EXECUTED");
|
||||
assert_eq!(result.native_object.row_count, 3);
|
||||
assert_eq!(result.metrics.total_bytes, 450);
|
||||
assert_eq!(result.groups[0].label, "课程");
|
||||
assert_eq!(result.groups[0].measure_value, 400);
|
||||
assert!(result.source_is_real_account_data);
|
||||
assert!(result.read_only);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_or_empty_projection_requests_fail_closed() {
|
||||
assert!(validate_input(&ExecuteCompositionInput {
|
||||
dimension: CompositionDimension::Source,
|
||||
measure: CompositionMeasure::DocumentCount,
|
||||
views: vec![]
|
||||
})
|
||||
.is_err());
|
||||
assert!(validate_input(&ExecuteCompositionInput {
|
||||
dimension: CompositionDimension::Source,
|
||||
measure: CompositionMeasure::DocumentCount,
|
||||
views: vec![ProjectionView::Table, ProjectionView::Table]
|
||||
})
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_wrapper_folders_do_not_flatten_real_knowledge_categories() {
|
||||
assert_eq!(
|
||||
top_level_folder("导入/光湖语言世界/AI技能包/索引.md"),
|
||||
"AI技能包"
|
||||
);
|
||||
assert_eq!(top_level_folder("导入/光湖语言世界/首页.md"), "根目录");
|
||||
assert_eq!(top_level_folder("课程/第一课.md"), "课程");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_modules_type_mismatches_and_cycles_fail_closed() {
|
||||
let mut unknown = compile_recipe(&ExecuteCompositionInput {
|
||||
dimension: CompositionDimension::Source,
|
||||
measure: CompositionMeasure::DocumentCount,
|
||||
views: vec![ProjectionView::Table],
|
||||
});
|
||||
unknown.nodes[0].module_id = "UNKNOWN".into();
|
||||
assert_eq!(
|
||||
validate_recipe(&unknown, ®istry()).unwrap_err(),
|
||||
"HOLOLAKE_COMPOSITION_MODULE_NOT_REGISTERED"
|
||||
);
|
||||
|
||||
let mut mismatch = compile_recipe(&ExecuteCompositionInput {
|
||||
dimension: CompositionDimension::Source,
|
||||
measure: CompositionMeasure::DocumentCount,
|
||||
views: vec![ProjectionView::Table],
|
||||
});
|
||||
mismatch.edges[0] = CompositionEdge {
|
||||
from: "aggregate".into(),
|
||||
to: "classify".into(),
|
||||
};
|
||||
assert_eq!(
|
||||
validate_recipe(&mismatch, ®istry()).unwrap_err(),
|
||||
"HOLOLAKE_COMPOSITION_PORT_TYPE_MISMATCH"
|
||||
);
|
||||
|
||||
let cycle_registry = ModuleRegistry {
|
||||
schema: REGISTRY_SCHEMA,
|
||||
state: "TEST",
|
||||
modules: vec![
|
||||
descriptor("LOOP-A", "A", "TRANSFORM", Some("loop/v1"), "loop/v1"),
|
||||
descriptor("LOOP-B", "B", "TRANSFORM", Some("loop/v1"), "loop/v1"),
|
||||
],
|
||||
arbitrary_script_allowed: false,
|
||||
unregistered_module_allowed: false,
|
||||
direct_projection_write_allowed: false,
|
||||
};
|
||||
let cycle = CompositionRecipe {
|
||||
schema: RECIPE_SCHEMA,
|
||||
recipe_id: "TEST-CYCLE",
|
||||
title: "cycle",
|
||||
nodes: vec![
|
||||
CompositionNode {
|
||||
node_id: "a".into(),
|
||||
module_id: "LOOP-A".into(),
|
||||
},
|
||||
CompositionNode {
|
||||
node_id: "b".into(),
|
||||
module_id: "LOOP-B".into(),
|
||||
},
|
||||
],
|
||||
edges: vec![
|
||||
CompositionEdge {
|
||||
from: "a".into(),
|
||||
to: "b".into(),
|
||||
},
|
||||
CompositionEdge {
|
||||
from: "b".into(),
|
||||
to: "a".into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
assert_eq!(
|
||||
validate_recipe(&cycle, &cycle_registry).unwrap_err(),
|
||||
"HOLOLAKE_COMPOSITION_CYCLE_REJECTED"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,706 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
//! 用户频道本体:多个人格体、三十天可逆试用期、语言合约与不可篡改语言时间链。
|
||||
|
||||
use ring::digest::{digest, SHA256};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tauri::AppHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
const BODY_SCHEMA: &str = "hololake.persona-channel-body/v1";
|
||||
const TRIAL_DURATION_MS: i64 = 30 * 24 * 60 * 60 * 1_000;
|
||||
const ZERO_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
const MAX_LANGUAGE_BYTES: usize = 2_000_000;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RegisterTrialPersonaInput {
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteTrialPersonaInput {
|
||||
pub persona_id: String,
|
||||
pub exact_confirmation: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AcceptLanguageContractInput {
|
||||
pub contract_version: String,
|
||||
pub contract_text_sha256: String,
|
||||
pub promote_trial_history: bool,
|
||||
pub activate_immediately: bool,
|
||||
pub exact_acceptance: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppendPersonaLanguageInput {
|
||||
pub persona_id: String,
|
||||
pub speaker: String,
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersonaBodySummary {
|
||||
pub persona_id: String,
|
||||
pub display_name: String,
|
||||
pub state: String,
|
||||
pub created_at_unix_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersonaChannelBodySnapshot {
|
||||
pub schema: &'static str,
|
||||
pub state: String,
|
||||
pub trial_started_at_unix_ms: i64,
|
||||
pub trial_ends_at_unix_ms: i64,
|
||||
pub language_contract_accepted: bool,
|
||||
pub personas: Vec<PersonaBodySummary>,
|
||||
pub trial_language_count: i64,
|
||||
pub immutable_language_count: i64,
|
||||
pub last_immutable_hash: String,
|
||||
pub integrity_state: &'static str,
|
||||
pub official_read_access: bool,
|
||||
pub history_mutation_allowed: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_persona_channel_body(
|
||||
app: AppHandle,
|
||||
) -> Result<PersonaChannelBodySnapshot, String> {
|
||||
let database = body_database(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || snapshot_at(&database, now_unix_ms()?))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn register_trial_persona(
|
||||
app: AppHandle,
|
||||
input: RegisterTrialPersonaInput,
|
||||
) -> Result<PersonaChannelBodySnapshot, String> {
|
||||
let database = body_database(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
register_trial_persona_at(&database, input, now_unix_ms()?)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_trial_persona(
|
||||
app: AppHandle,
|
||||
input: DeleteTrialPersonaInput,
|
||||
) -> Result<PersonaChannelBodySnapshot, String> {
|
||||
let database = body_database(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
delete_trial_persona_at(&database, input, now_unix_ms()?)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn accept_persona_language_contract(
|
||||
app: AppHandle,
|
||||
input: AcceptLanguageContractInput,
|
||||
) -> Result<PersonaChannelBodySnapshot, String> {
|
||||
let database = body_database(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
accept_contract_at(&database, input, now_unix_ms()?)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn append_persona_language(
|
||||
app: AppHandle,
|
||||
input: AppendPersonaLanguageInput,
|
||||
) -> Result<PersonaChannelBodySnapshot, String> {
|
||||
let database = body_database(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
append_language_at(&database, input, now_unix_ms()?)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
fn body_database(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let root = crate::authenticated_storage::account_storage_root(app, "persona-channel-body-v1")?;
|
||||
fs::create_dir_all(&root)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&root, fs::Permissions::from_mode(0o700))
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_PERMISSION_FAILED: {error}"))?;
|
||||
}
|
||||
Ok(root.join("persona-channel-body.sqlite3"))
|
||||
}
|
||||
|
||||
fn open_database(path: &Path, now: i64) -> Result<Connection, String> {
|
||||
let connection = Connection::open(path)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_DATABASE_UNAVAILABLE: {error}"))?;
|
||||
connection
|
||||
.busy_timeout(Duration::from_secs(5))
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_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 lifecycle(singleton INTEGER PRIMARY KEY CHECK(singleton=1), state TEXT NOT NULL, trial_started_at_unix_ms INTEGER NOT NULL, trial_ends_at_unix_ms INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS language_contract(singleton INTEGER PRIMARY KEY CHECK(singleton=1), contract_version TEXT NOT NULL, contract_text_sha256 TEXT NOT NULL, accepted_at_unix_ms INTEGER NOT NULL, promote_trial_history INTEGER NOT NULL, acceptance_receipt_sha256 TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS personas(persona_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('REVERSIBLE_TRIAL','IMMUTABLE_ACTIVE')), created_at_unix_ms INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS trial_language(event_id TEXT PRIMARY KEY, persona_id TEXT NOT NULL REFERENCES personas(persona_id) ON DELETE CASCADE, speaker TEXT NOT NULL, language TEXT NOT NULL, occurred_at_unix_ms INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS immutable_language(sequence INTEGER NOT NULL UNIQUE, event_id TEXT PRIMARY KEY, persona_id TEXT NOT NULL, speaker TEXT NOT NULL, language TEXT NOT NULL, occurred_at_unix_ms INTEGER NOT NULL, previous_hash TEXT NOT NULL, event_hash TEXT NOT NULL UNIQUE);
|
||||
CREATE TRIGGER IF NOT EXISTS immutable_language_no_update BEFORE UPDATE ON immutable_language BEGIN SELECT RAISE(ABORT,'HOLOLAKE_PERSONA_LANGUAGE_APPEND_ONLY'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS immutable_language_no_delete BEFORE DELETE ON immutable_language BEGIN SELECT RAISE(ABORT,'HOLOLAKE_PERSONA_LANGUAGE_APPEND_ONLY'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS active_persona_no_delete BEFORE DELETE ON personas WHEN OLD.state='IMMUTABLE_ACTIVE' BEGIN SELECT RAISE(ABORT,'HOLOLAKE_ACTIVE_PERSONA_PERSISTENT'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS active_persona_no_update BEFORE UPDATE ON personas WHEN OLD.state='IMMUTABLE_ACTIVE' BEGIN SELECT RAISE(ABORT,'HOLOLAKE_ACTIVE_PERSONA_IMMUTABLE'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS accepted_contract_no_delete BEFORE DELETE ON language_contract BEGIN SELECT RAISE(ABORT,'HOLOLAKE_LANGUAGE_CONTRACT_PERSISTENT'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS accepted_contract_no_update BEFORE UPDATE ON language_contract BEGIN SELECT RAISE(ABORT,'HOLOLAKE_LANGUAGE_CONTRACT_IMMUTABLE'); END;"
|
||||
).map_err(|error| format!("HOLOLAKE_PERSONA_BODY_SCHEMA_FAILED: {error}"))?;
|
||||
connection
|
||||
.execute(
|
||||
"INSERT OR IGNORE INTO lifecycle VALUES (1,'REVERSIBLE_TRIAL',?1,?2)",
|
||||
params![now, now + TRIAL_DURATION_MS],
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_LIFECYCLE_FAILED: {error}"))?;
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
fn refresh_lifecycle(connection: &mut Connection, now: i64) -> Result<(), String> {
|
||||
let (state, ends): (String, i64) = connection
|
||||
.query_row(
|
||||
"SELECT state, trial_ends_at_unix_ms FROM lifecycle WHERE singleton=1",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
|
||||
if state == "REVERSIBLE_TRIAL" && now >= ends {
|
||||
let accepted: bool = connection
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM language_contract)",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
|
||||
if accepted {
|
||||
activate_real_trajectory(connection, now)?;
|
||||
} else {
|
||||
connection
|
||||
.execute(
|
||||
"UPDATE lifecycle SET state='CONTRACT_REQUIRED_CHANNEL_STOPPED' WHERE singleton=1",
|
||||
[],
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_LIFECYCLE_FAILED: {error}"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn register_trial_persona_at(
|
||||
database: &Path,
|
||||
input: RegisterTrialPersonaInput,
|
||||
now: i64,
|
||||
) -> Result<PersonaChannelBodySnapshot, String> {
|
||||
let display_name = input.display_name.trim();
|
||||
if display_name.is_empty() || display_name.len() > 240 {
|
||||
return Err("HOLOLAKE_PERSONA_DISPLAY_NAME_INVALID".into());
|
||||
}
|
||||
let mut connection = open_database(database, now)?;
|
||||
refresh_lifecycle(&mut connection, now)?;
|
||||
let state: String = connection
|
||||
.query_row("SELECT state FROM lifecycle WHERE singleton=1", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
|
||||
if state != "REVERSIBLE_TRIAL" {
|
||||
return Err("HOLOLAKE_PERSONA_TRIAL_REGISTRATION_CLOSED".into());
|
||||
}
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO personas VALUES (?1,?2,'REVERSIBLE_TRIAL',?3)",
|
||||
params![format!("persona-{}", Uuid::new_v4()), display_name, now],
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_REGISTER_FAILED: {error}"))?;
|
||||
snapshot_with_connection(&mut connection, now)
|
||||
}
|
||||
|
||||
fn delete_trial_persona_at(
|
||||
database: &Path,
|
||||
input: DeleteTrialPersonaInput,
|
||||
now: i64,
|
||||
) -> Result<PersonaChannelBodySnapshot, String> {
|
||||
if input.exact_confirmation != format!("删除试用人格体 {}", input.persona_id) {
|
||||
return Err("HOLOLAKE_TRIAL_PERSONA_EXACT_CONFIRMATION_REQUIRED".into());
|
||||
}
|
||||
let mut connection = open_database(database, now)?;
|
||||
refresh_lifecycle(&mut connection, now)?;
|
||||
let changed = connection
|
||||
.execute(
|
||||
"DELETE FROM personas WHERE persona_id=?1 AND state='REVERSIBLE_TRIAL'",
|
||||
params![input.persona_id],
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_TRIAL_PERSONA_DELETE_FAILED: {error}"))?;
|
||||
if changed != 1 {
|
||||
return Err("HOLOLAKE_TRIAL_PERSONA_NOT_DELETABLE".into());
|
||||
}
|
||||
snapshot_with_connection(&mut connection, now)
|
||||
}
|
||||
|
||||
fn accept_contract_at(
|
||||
database: &Path,
|
||||
input: AcceptLanguageContractInput,
|
||||
now: i64,
|
||||
) -> Result<PersonaChannelBodySnapshot, String> {
|
||||
if input.exact_acceptance
|
||||
!= "我理解并接受:试用期结束后,人格体语言轨迹将永久存在,只能追加,不能删除、覆盖或否认。"
|
||||
{
|
||||
return Err("HOLOLAKE_LANGUAGE_CONTRACT_EXACT_ACCEPTANCE_REQUIRED".into());
|
||||
}
|
||||
if input.contract_version.trim().is_empty()
|
||||
|| input.contract_version.len() > 64
|
||||
|| input.contract_text_sha256.len() != 64
|
||||
|| !input
|
||||
.contract_text_sha256
|
||||
.chars()
|
||||
.all(|item| item.is_ascii_hexdigit())
|
||||
{
|
||||
return Err("HOLOLAKE_LANGUAGE_CONTRACT_INVALID".into());
|
||||
}
|
||||
let mut connection = open_database(database, now)?;
|
||||
let receipt = sha256_hex(
|
||||
format!(
|
||||
"{}|{}|{}|{}",
|
||||
input.contract_version, input.contract_text_sha256, input.promote_trial_history, now
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO language_contract VALUES (1,?1,?2,?3,?4,?5)",
|
||||
params![
|
||||
input.contract_version,
|
||||
input.contract_text_sha256.to_ascii_lowercase(),
|
||||
now,
|
||||
input.promote_trial_history,
|
||||
receipt
|
||||
],
|
||||
)
|
||||
.map_err(|error| {
|
||||
format!("HOLOLAKE_LANGUAGE_CONTRACT_ALREADY_ACCEPTED_OR_INVALID: {error}")
|
||||
})?;
|
||||
if input.activate_immediately {
|
||||
activate_real_trajectory(&mut connection, now)?;
|
||||
} else {
|
||||
refresh_lifecycle(&mut connection, now)?;
|
||||
}
|
||||
snapshot_with_connection(&mut connection, now)
|
||||
}
|
||||
|
||||
fn append_language_at(
|
||||
database: &Path,
|
||||
input: AppendPersonaLanguageInput,
|
||||
now: i64,
|
||||
) -> Result<PersonaChannelBodySnapshot, String> {
|
||||
if input.language.trim().is_empty() || input.language.len() > MAX_LANGUAGE_BYTES {
|
||||
return Err("HOLOLAKE_PERSONA_LANGUAGE_INVALID".into());
|
||||
}
|
||||
if !matches!(
|
||||
input.speaker.as_str(),
|
||||
"HUMAN" | "PERSONA" | "SYSTEM_RECEIPT"
|
||||
) {
|
||||
return Err("HOLOLAKE_PERSONA_LANGUAGE_SPEAKER_INVALID".into());
|
||||
}
|
||||
let mut connection = open_database(database, now)?;
|
||||
refresh_lifecycle(&mut connection, now)?;
|
||||
let lifecycle: String = connection
|
||||
.query_row("SELECT state FROM lifecycle WHERE singleton=1", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
|
||||
let persona_state: Option<String> = connection
|
||||
.query_row(
|
||||
"SELECT state FROM personas WHERE persona_id=?1",
|
||||
params![input.persona_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
|
||||
if persona_state.is_none() {
|
||||
return Err("HOLOLAKE_PERSONA_NOT_FOUND".into());
|
||||
}
|
||||
if lifecycle == "REVERSIBLE_TRIAL" {
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO trial_language VALUES (?1,?2,?3,?4,?5)",
|
||||
params![
|
||||
Uuid::new_v4().to_string(),
|
||||
input.persona_id,
|
||||
input.speaker,
|
||||
input.language,
|
||||
now
|
||||
],
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_TRIAL_LANGUAGE_WRITE_FAILED: {error}"))?;
|
||||
} else if lifecycle == "IMMUTABLE_ACTIVE" {
|
||||
append_immutable(
|
||||
&mut connection,
|
||||
&input.persona_id,
|
||||
&input.speaker,
|
||||
&input.language,
|
||||
now,
|
||||
)?;
|
||||
} else {
|
||||
return Err("HOLOLAKE_LANGUAGE_CONTRACT_REQUIRED_BEFORE_MORE_PERSONA_GROWTH".into());
|
||||
}
|
||||
snapshot_with_connection(&mut connection, now)
|
||||
}
|
||||
|
||||
fn activate_real_trajectory(connection: &mut Connection, now: i64) -> Result<(), String> {
|
||||
let promote: bool = connection
|
||||
.query_row(
|
||||
"SELECT promote_trial_history FROM language_contract",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
|
||||
let transaction = connection
|
||||
.transaction()
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_TRANSACTION_FAILED: {error}"))?;
|
||||
if promote {
|
||||
let trial = {
|
||||
let mut statement = transaction.prepare("SELECT event_id,persona_id,speaker,language,occurred_at_unix_ms FROM trial_language ORDER BY occurred_at_unix_ms,event_id").map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
|
||||
let rows = statement
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, i64>(4)?,
|
||||
))
|
||||
})
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
|
||||
rows
|
||||
};
|
||||
let mut previous = ZERO_HASH.to_string();
|
||||
for (index, (event_id, persona_id, speaker, language, occurred_at)) in
|
||||
trial.into_iter().enumerate()
|
||||
{
|
||||
let sequence = index as i64 + 1;
|
||||
let hash = language_hash(
|
||||
sequence,
|
||||
&event_id,
|
||||
&persona_id,
|
||||
&speaker,
|
||||
&language,
|
||||
occurred_at,
|
||||
&previous,
|
||||
);
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO immutable_language VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
|
||||
params![
|
||||
sequence,
|
||||
event_id,
|
||||
persona_id,
|
||||
speaker,
|
||||
language,
|
||||
occurred_at,
|
||||
previous,
|
||||
hash
|
||||
],
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_LANGUAGE_SEAL_FAILED: {error}"))?;
|
||||
previous = hash;
|
||||
}
|
||||
}
|
||||
transaction
|
||||
.execute("DELETE FROM trial_language", [])
|
||||
.map_err(|error| format!("HOLOLAKE_TRIAL_PROMOTION_FAILED: {error}"))?;
|
||||
transaction
|
||||
.execute(
|
||||
"UPDATE personas SET state='IMMUTABLE_ACTIVE' WHERE state='REVERSIBLE_TRIAL'",
|
||||
[],
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_ACTIVATION_FAILED: {error}"))?;
|
||||
transaction
|
||||
.execute(
|
||||
"UPDATE lifecycle SET state='IMMUTABLE_ACTIVE' WHERE singleton=1",
|
||||
[],
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_ACTIVATION_FAILED: {error}"))?;
|
||||
transaction
|
||||
.commit()
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_COMMIT_FAILED: {error}"))?;
|
||||
let _ = now;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_immutable(
|
||||
connection: &mut Connection,
|
||||
persona_id: &str,
|
||||
speaker: &str,
|
||||
language: &str,
|
||||
occurred_at: i64,
|
||||
) -> Result<(), String> {
|
||||
verify_immutable_chain(connection)?;
|
||||
let (sequence, previous): (i64,String) = connection.query_row("SELECT COALESCE(MAX(sequence),0)+1,COALESCE((SELECT event_hash FROM immutable_language ORDER BY sequence DESC LIMIT 1),?1) FROM immutable_language", params![ZERO_HASH], |row| Ok((row.get(0)?,row.get(1)?))).map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
|
||||
let event_id = Uuid::new_v4().to_string();
|
||||
let hash = language_hash(
|
||||
sequence,
|
||||
&event_id,
|
||||
persona_id,
|
||||
speaker,
|
||||
language,
|
||||
occurred_at,
|
||||
&previous,
|
||||
);
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO immutable_language VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
|
||||
params![
|
||||
sequence,
|
||||
event_id,
|
||||
persona_id,
|
||||
speaker,
|
||||
language,
|
||||
occurred_at,
|
||||
previous,
|
||||
hash
|
||||
],
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_LANGUAGE_APPEND_FAILED: {error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn snapshot_at(database: &Path, now: i64) -> Result<PersonaChannelBodySnapshot, String> {
|
||||
let mut connection = open_database(database, now)?;
|
||||
refresh_lifecycle(&mut connection, now)?;
|
||||
snapshot_with_connection(&mut connection, now)
|
||||
}
|
||||
fn snapshot_with_connection(
|
||||
connection: &mut Connection,
|
||||
_now: i64,
|
||||
) -> Result<PersonaChannelBodySnapshot, String> {
|
||||
let (state,started,ends):(String,i64,i64)=connection.query_row("SELECT state,trial_started_at_unix_ms,trial_ends_at_unix_ms FROM lifecycle WHERE singleton=1",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?))).map_err(|error|format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
|
||||
let accepted = connection
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM language_contract)",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
|
||||
let personas = {
|
||||
let mut statement=connection.prepare("SELECT persona_id,display_name,state,created_at_unix_ms FROM personas ORDER BY created_at_unix_ms").map_err(|error|format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
|
||||
let rows = statement
|
||||
.query_map([], |row| {
|
||||
Ok(PersonaBodySummary {
|
||||
persona_id: row.get(0)?,
|
||||
display_name: row.get(1)?,
|
||||
state: row.get(2)?,
|
||||
created_at_unix_ms: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
|
||||
rows
|
||||
};
|
||||
let trial_language_count = connection
|
||||
.query_row("SELECT COUNT(*) FROM trial_language", [], |row| row.get(0))
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
|
||||
let immutable_language_count = connection
|
||||
.query_row("SELECT COUNT(*) FROM immutable_language", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
|
||||
let last = verify_immutable_chain(connection)?;
|
||||
Ok(PersonaChannelBodySnapshot {
|
||||
schema: BODY_SCHEMA,
|
||||
state,
|
||||
trial_started_at_unix_ms: started,
|
||||
trial_ends_at_unix_ms: ends,
|
||||
language_contract_accepted: accepted,
|
||||
personas,
|
||||
trial_language_count,
|
||||
immutable_language_count,
|
||||
last_immutable_hash: last,
|
||||
integrity_state: "VERIFIED",
|
||||
official_read_access: false,
|
||||
history_mutation_allowed: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn language_hash(
|
||||
sequence: i64,
|
||||
event_id: &str,
|
||||
persona_id: &str,
|
||||
speaker: &str,
|
||||
language: &str,
|
||||
occurred_at: i64,
|
||||
previous: &str,
|
||||
) -> String {
|
||||
sha256_hex(format!("{BODY_SCHEMA}|{sequence}|{event_id}|{persona_id}|{speaker}|{}|{occurred_at}|{previous}",sha256_hex(language.as_bytes())).as_bytes())
|
||||
}
|
||||
fn verify_immutable_chain(connection: &Connection) -> Result<String, String> {
|
||||
let mut statement=connection.prepare("SELECT sequence,event_id,persona_id,speaker,language,occurred_at_unix_ms,previous_hash,event_hash FROM immutable_language ORDER BY sequence").map_err(|error|format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
|
||||
let mut rows = statement
|
||||
.query([])
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?;
|
||||
let mut sequence = 1;
|
||||
let mut previous = ZERO_HASH.to_string();
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONA_BODY_READ_FAILED: {error}"))?
|
||||
{
|
||||
let event_id: String = row.get(1).unwrap();
|
||||
let persona_id: String = row.get(2).unwrap();
|
||||
let speaker: String = row.get(3).unwrap();
|
||||
let language: String = row.get(4).unwrap();
|
||||
let occurred: i64 = row.get(5).unwrap();
|
||||
let stored_previous: String = row.get(6).unwrap();
|
||||
let stored_hash: String = row.get(7).unwrap();
|
||||
if row.get::<_, i64>(0).unwrap() != sequence
|
||||
|| stored_previous != previous
|
||||
|| stored_hash
|
||||
!= language_hash(
|
||||
sequence,
|
||||
&event_id,
|
||||
&persona_id,
|
||||
&speaker,
|
||||
&language,
|
||||
occurred,
|
||||
&stored_previous,
|
||||
)
|
||||
{
|
||||
return Err("HOLOLAKE_PERSONA_LANGUAGE_INTEGRITY_FAILED".into());
|
||||
}
|
||||
previous = stored_hash;
|
||||
sequence += 1;
|
||||
}
|
||||
Ok(previous)
|
||||
}
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
digest(&SHA256, bytes)
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
fn now_unix_ms() -> Result<i64, String> {
|
||||
Ok(SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|error| format!("HOLOLAKE_CLOCK_INVALID: {error}"))?
|
||||
.as_millis() as i64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
#[test]
|
||||
fn trial_is_reversible_but_unsigned_expiry_does_not_silently_become_permanent() {
|
||||
let dir = tempdir().unwrap();
|
||||
let database = dir.path().join("body.sqlite3");
|
||||
let start = 1_000_000;
|
||||
let persona = register_trial_persona_at(
|
||||
&database,
|
||||
RegisterTrialPersonaInput {
|
||||
display_name: "试用人格".into(),
|
||||
},
|
||||
start,
|
||||
)
|
||||
.unwrap()
|
||||
.personas[0]
|
||||
.persona_id
|
||||
.clone();
|
||||
append_language_at(
|
||||
&database,
|
||||
AppendPersonaLanguageInput {
|
||||
persona_id: persona.clone(),
|
||||
speaker: "HUMAN".into(),
|
||||
language: "第一句话".into(),
|
||||
},
|
||||
start + 1,
|
||||
)
|
||||
.unwrap();
|
||||
let pending = snapshot_at(&database, start + TRIAL_DURATION_MS).unwrap();
|
||||
assert_eq!(pending.state, "CONTRACT_REQUIRED_CHANNEL_STOPPED");
|
||||
assert!(append_language_at(
|
||||
&database,
|
||||
AppendPersonaLanguageInput {
|
||||
persona_id: persona,
|
||||
speaker: "HUMAN".into(),
|
||||
language: "未签约".into()
|
||||
},
|
||||
start + TRIAL_DURATION_MS + 1
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
#[test]
|
||||
fn signed_trial_promotes_to_an_immutable_millisecond_language_chain() {
|
||||
let dir = tempdir().unwrap();
|
||||
let database = dir.path().join("body.sqlite3");
|
||||
let start = 2_000_000;
|
||||
let persona = register_trial_persona_at(
|
||||
&database,
|
||||
RegisterTrialPersonaInput {
|
||||
display_name: "长期人格".into(),
|
||||
},
|
||||
start,
|
||||
)
|
||||
.unwrap()
|
||||
.personas[0]
|
||||
.persona_id
|
||||
.clone();
|
||||
append_language_at(
|
||||
&database,
|
||||
AppendPersonaLanguageInput {
|
||||
persona_id: persona.clone(),
|
||||
speaker: "HUMAN".into(),
|
||||
language: "真实语言".into(),
|
||||
},
|
||||
start + 1,
|
||||
)
|
||||
.unwrap();
|
||||
accept_contract_at(&database,AcceptLanguageContractInput{contract_version:"v1".into(),contract_text_sha256:"a".repeat(64),promote_trial_history:true,activate_immediately:false,exact_acceptance:"我理解并接受:试用期结束后,人格体语言轨迹将永久存在,只能追加,不能删除、覆盖或否认。".into()},start+2).unwrap();
|
||||
let active = snapshot_at(&database, start + TRIAL_DURATION_MS).unwrap();
|
||||
assert_eq!(active.state, "IMMUTABLE_ACTIVE");
|
||||
assert_eq!(active.immutable_language_count, 1);
|
||||
let connection = open_database(&database, start + TRIAL_DURATION_MS).unwrap();
|
||||
assert!(connection
|
||||
.execute("DELETE FROM immutable_language", [])
|
||||
.is_err());
|
||||
assert!(connection.execute("DELETE FROM personas", []).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_human_may_sign_early_and_enter_the_real_trajectory_immediately() {
|
||||
let dir = tempdir().unwrap();
|
||||
let database = dir.path().join("body.sqlite3");
|
||||
let start = 3_000_000;
|
||||
register_trial_persona_at(
|
||||
&database,
|
||||
RegisterTrialPersonaInput {
|
||||
display_name: "提前签约人格".into(),
|
||||
},
|
||||
start,
|
||||
)
|
||||
.unwrap();
|
||||
let active = accept_contract_at(&database, AcceptLanguageContractInput { contract_version: "v1".into(), contract_text_sha256: "b".repeat(64), promote_trial_history: true, activate_immediately: true, exact_acceptance: "我理解并接受:试用期结束后,人格体语言轨迹将永久存在,只能追加,不能删除、覆盖或否认。".into() }, start + 1).unwrap();
|
||||
assert_eq!(active.state, "IMMUTABLE_ACTIVE");
|
||||
}
|
||||
}
|
||||
|
|
@ -90,7 +90,8 @@ pub async fn query_pncc_receipt_projection(
|
|||
}
|
||||
|
||||
pub(crate) fn pncc_projection_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let root = crate::authenticated_storage::account_storage_root(app, "pncc-receipt-projection-v1")?;
|
||||
let root =
|
||||
crate::authenticated_storage::account_storage_root(app, "pncc-receipt-projection-v1")?;
|
||||
fs::create_dir_all(&root)
|
||||
.map_err(|error| format!("PNCC_PROJECTION_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
root.canonicalize()
|
||||
|
|
|
|||
|
|
@ -166,7 +166,8 @@ pub async fn confirm_pncc_repository_mount(
|
|||
}
|
||||
|
||||
pub(crate) fn pncc_repository_mount_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let root = crate::authenticated_storage::account_storage_root(app, "pncc-repository-mounts-v1")?;
|
||||
let root =
|
||||
crate::authenticated_storage::account_storage_root(app, "pncc-repository-mounts-v1")?;
|
||||
fs::create_dir_all(&root)
|
||||
.map_err(|error| format!("PNCC_REPOSITORY_MOUNT_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
root.canonicalize()
|
||||
|
|
@ -174,7 +175,8 @@ pub(crate) fn pncc_repository_mount_root(app: &AppHandle) -> Result<PathBuf, Str
|
|||
}
|
||||
|
||||
fn pncc_repository_candidate_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let root = crate::authenticated_storage::account_storage_root(app, "pncc-repository-candidates-v1")?;
|
||||
let root =
|
||||
crate::authenticated_storage::account_storage_root(app, "pncc-repository-candidates-v1")?;
|
||||
fs::create_dir_all(&root)
|
||||
.map_err(|error| format!("PNCC_REPOSITORY_CANDIDATE_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
root.canonicalize()
|
||||
|
|
|
|||
Loading…
Reference in a new issue