feat(hololake): establish clean v1 personal language shell
This commit is contained in:
parent
9e04b0354c
commit
c4f662e541
51 changed files with 16233 additions and 1 deletions
370
product-source/hololake-clean-desktop/src-tauri/src/lib.rs
Normal file
370
product-source/hololake-clean-desktop/src-tauri/src/lib.rs
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
mod model;
|
||||
mod storage;
|
||||
|
||||
use model::*;
|
||||
use std::{fs, path::Path};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
const UPDATE_ENDPOINT: &str = "https://guanghulab.com/hololake/releases/latest.json";
|
||||
const PUBLIC_STATE: &str = "SIGNED_UPDATE_FEED_READY_NO_RELEASE";
|
||||
|
||||
fn receipt(
|
||||
app: &AppHandle,
|
||||
state: &str,
|
||||
message: &str,
|
||||
git: Option<String>,
|
||||
event: TimelineEvent,
|
||||
) -> Result<MutationReceipt, String> {
|
||||
storage::append_event(app, &event)?;
|
||||
Ok(MutationReceipt {
|
||||
receipt_id: storage::id("HL-RCP"),
|
||||
state: state.into(),
|
||||
message: message.into(),
|
||||
git_commit: git,
|
||||
event,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn system_snapshot(app: AppHandle) -> Result<SystemSnapshot, String> {
|
||||
Ok(SystemSnapshot {
|
||||
app_version: env!("CARGO_PKG_VERSION").into(),
|
||||
channel: storage::channel(&app)?,
|
||||
timeline: storage::events(&app)?,
|
||||
documents: storage::list_docs(&app)?,
|
||||
module_registry_version: "1".into(),
|
||||
public_update_endpoint: UPDATE_ENDPOINT.into(),
|
||||
public_distribution_state: PUBLIC_STATE.into(),
|
||||
external_ai_bridges: storage::bridges(&app)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_channel(app: AppHandle, name: String) -> Result<Channel, String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() || name.chars().count() > 48 {
|
||||
return Err("CHANNEL_NAME_INVALID".into());
|
||||
}
|
||||
if storage::channel(&app)?.is_some() {
|
||||
return Err("CHANNEL_ALREADY_EXISTS".into());
|
||||
}
|
||||
let repo = storage::private_repo_root(&app)?;
|
||||
let c = Channel {
|
||||
channel_id: format!(
|
||||
"HL-CH-{}",
|
||||
uuid::Uuid::new_v4().simple().to_string()[..8].to_uppercase()
|
||||
),
|
||||
name: name.into(),
|
||||
created_at: storage::now(),
|
||||
private_git_path: repo.to_string_lossy().into_owned(),
|
||||
public_distribution_state: PUBLIC_STATE.into(),
|
||||
};
|
||||
storage::save_channel(&app, &c)?;
|
||||
let e = storage::event(
|
||||
SourceKind::SystemReceipt,
|
||||
"频道已建立",
|
||||
format!("{} 已登记;私人 Git 已初始化。", c.channel_id),
|
||||
"SUCCEEDED",
|
||||
);
|
||||
storage::append_event(&app, &e)?;
|
||||
storage::git_commit(
|
||||
&repo,
|
||||
"chore(channel): initialize personal HoloLake channel",
|
||||
)?;
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn submit_user_message(app: AppHandle, content: String) -> Result<MutationReceipt, String> {
|
||||
let content = content.trim();
|
||||
if content.is_empty() {
|
||||
return Err("EMPTY_USER_MESSAGE".into());
|
||||
}
|
||||
if storage::channel(&app)?.is_none() {
|
||||
return Err("CHANNEL_NOT_INITIALIZED".into());
|
||||
}
|
||||
let e = storage::event(SourceKind::UserMessage, "用户语言", content, "RECORDED");
|
||||
storage::append_event(&app, &e)?;
|
||||
let waiting = storage::event(
|
||||
SourceKind::SystemReceipt,
|
||||
"系统回执",
|
||||
"用户语言已按 USER_MESSAGE 保存;当前未绑定人格模型,不伪造人格回应。",
|
||||
"WAITING",
|
||||
);
|
||||
receipt(
|
||||
&app,
|
||||
"WAITING_PERSONA",
|
||||
"用户语言已记录,等待人格体接入。",
|
||||
None,
|
||||
waiting,
|
||||
)
|
||||
}
|
||||
|
||||
fn clean_title(title: &str) -> Result<String, String> {
|
||||
let t = title.trim();
|
||||
if t.is_empty() || t.chars().count() > 120 {
|
||||
return Err("KNOWLEDGE_TITLE_INVALID".into());
|
||||
}
|
||||
Ok(t.chars()
|
||||
.map(|c| if "\\/:*?\"<>|".contains(c) { '-' } else { c })
|
||||
.collect())
|
||||
}
|
||||
#[tauri::command]
|
||||
fn read_knowledge_document(app: AppHandle, path: String) -> Result<KnowledgeDocument, String> {
|
||||
storage::read_doc(&app, &path)
|
||||
}
|
||||
#[tauri::command]
|
||||
fn create_knowledge_document(app: AppHandle, title: String) -> Result<MutationReceipt, String> {
|
||||
let title = clean_title(&title)?;
|
||||
let root = storage::knowledge_root(&app)?;
|
||||
let rel = format!("{}.md", title);
|
||||
let p = root.join("docs").join(&rel);
|
||||
if p.exists() {
|
||||
return Err("KNOWLEDGE_DOCUMENT_EXISTS".into());
|
||||
}
|
||||
fs::write(&p, format!("# {}\n\n", title)).map_err(|e| e.to_string())?;
|
||||
let git = storage::git_commit(&root, "feat(knowledge): create document")?;
|
||||
let e = storage::event(
|
||||
SourceKind::AgentAction,
|
||||
"Agent 执行",
|
||||
format!("已创建知识页 {rel}"),
|
||||
"SUCCEEDED",
|
||||
);
|
||||
receipt(&app, "SUCCEEDED", "知识页已创建。", git, e)
|
||||
}
|
||||
#[tauri::command]
|
||||
fn save_knowledge_document(
|
||||
app: AppHandle,
|
||||
path: String,
|
||||
body: String,
|
||||
) -> Result<MutationReceipt, String> {
|
||||
let rel = storage::safe_rel(&path)?;
|
||||
if rel.extension().and_then(|x| x.to_str()) != Some("md") {
|
||||
return Err("KNOWLEDGE_DOCUMENT_TYPE_REJECTED".into());
|
||||
}
|
||||
let root = storage::knowledge_root(&app)?;
|
||||
let p = root.join("docs").join(rel);
|
||||
if !p.exists() {
|
||||
return Err("KNOWLEDGE_DOCUMENT_NOT_FOUND".into());
|
||||
}
|
||||
fs::write(&p, body).map_err(|e| e.to_string())?;
|
||||
let git = storage::git_commit(&root, "docs(knowledge): save document")?;
|
||||
let e = storage::event(
|
||||
SourceKind::SystemReceipt,
|
||||
"系统回执",
|
||||
format!("{path} 已写入私人 Git。"),
|
||||
"SUCCEEDED",
|
||||
);
|
||||
receipt(&app, "SUCCEEDED", "保存完成。", git, e)
|
||||
}
|
||||
#[tauri::command]
|
||||
fn delete_knowledge_document(app: AppHandle, path: String) -> Result<MutationReceipt, String> {
|
||||
let rel = storage::safe_rel(&path)?;
|
||||
let root = storage::knowledge_root(&app)?;
|
||||
let from = root.join("docs").join(&rel);
|
||||
if !from.exists() {
|
||||
return Err("KNOWLEDGE_DOCUMENT_NOT_FOUND".into());
|
||||
}
|
||||
let trash = root.join(".trash").join(format!(
|
||||
"{}-{}",
|
||||
storage::id("deleted"),
|
||||
rel.file_name()
|
||||
.and_then(|x| x.to_str())
|
||||
.unwrap_or("document.md")
|
||||
));
|
||||
fs::create_dir_all(trash.parent().unwrap()).map_err(|e| e.to_string())?;
|
||||
fs::rename(from, &trash).map_err(|e| e.to_string())?;
|
||||
let git = storage::git_commit(&root, "chore(knowledge): move document to trash")?;
|
||||
let e = storage::event(
|
||||
SourceKind::SystemReceipt,
|
||||
"系统回执",
|
||||
format!("{path} 已移入可恢复回收站。"),
|
||||
"SUCCEEDED",
|
||||
);
|
||||
receipt(&app, "SUCCEEDED", "已移入回收站。", git, e)
|
||||
}
|
||||
|
||||
fn copy_import(src: &Path, dst: &Path, count: &mut usize) -> Result<(), String> {
|
||||
if *count >= 1000 {
|
||||
return Err("IMPORT_FILE_LIMIT_EXCEEDED".into());
|
||||
}
|
||||
for entry in fs::read_dir(src).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let p = entry.path();
|
||||
if p.file_name()
|
||||
.and_then(|x| x.to_str())
|
||||
.map(|x| x.starts_with('.'))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if p.is_dir() {
|
||||
copy_import(&p, dst, count)?;
|
||||
continue;
|
||||
}
|
||||
let ext = p
|
||||
.extension()
|
||||
.and_then(|x| x.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
if !["md", "txt", "markdown"].contains(&ext.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let bytes = fs::read(&p).map_err(|e| e.to_string())?;
|
||||
if bytes.len() > 10 * 1024 * 1024 {
|
||||
continue;
|
||||
}
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
let title = p.file_stem().and_then(|x| x.to_str()).unwrap_or("导入页面");
|
||||
let name = format!("{}-{}.md", clean_title(title)?, &storage::sha(&bytes)[..8]);
|
||||
fs::write(
|
||||
dst.join(name),
|
||||
if ext == "md" || ext == "markdown" {
|
||||
body.into_owned()
|
||||
} else {
|
||||
format!("# {}\n\n{}", title, body)
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
*count += 1
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[tauri::command]
|
||||
async fn import_knowledge_folder(app: AppHandle) -> Result<Option<MutationReceipt>, String> {
|
||||
let Some(folder) = app
|
||||
.dialog()
|
||||
.file()
|
||||
.set_title("导入到光湖知识库")
|
||||
.blocking_pick_folder()
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let src = folder.into_path().map_err(|e| e.to_string())?;
|
||||
let root = storage::knowledge_root(&app)?;
|
||||
let dst = root.join("docs").join("导入");
|
||||
fs::create_dir_all(&dst).map_err(|e| e.to_string())?;
|
||||
let mut count = 0;
|
||||
copy_import(&src, &dst, &mut count)?;
|
||||
let git = storage::git_commit(&root, "feat(knowledge): import external folder")?;
|
||||
let protocol = storage::event(
|
||||
SourceKind::ProtocolEvent,
|
||||
"协议运行",
|
||||
format!("导入边界校验完成;接受 Markdown/纯文本,共 {count} 页。原目录未改写。"),
|
||||
"SUCCEEDED",
|
||||
);
|
||||
storage::append_event(&app, &protocol)?;
|
||||
let e = storage::event(
|
||||
SourceKind::SystemReceipt,
|
||||
"系统回执",
|
||||
format!("外部文件夹已转换为 {count} 个光湖知识页。"),
|
||||
"SUCCEEDED",
|
||||
);
|
||||
Ok(Some(receipt(
|
||||
&app,
|
||||
"SUCCEEDED",
|
||||
"文件夹导入完成。",
|
||||
git,
|
||||
e,
|
||||
)?))
|
||||
}
|
||||
#[tauri::command]
|
||||
async fn export_knowledge_document(app: AppHandle, path: String) -> Result<Option<String>, String> {
|
||||
let doc = storage::read_doc(&app, &path)?;
|
||||
let Some(target) = app
|
||||
.dialog()
|
||||
.file()
|
||||
.set_title("下载知识页")
|
||||
.set_file_name(&path)
|
||||
.blocking_save_file()
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let p = target.into_path().map_err(|e| e.to_string())?;
|
||||
fs::write(&p, doc.body).map_err(|e| e.to_string())?;
|
||||
let e = storage::event(
|
||||
SourceKind::ToolResult,
|
||||
"工具结果",
|
||||
format!("知识页已下载到 {}", p.display()),
|
||||
"SUCCEEDED",
|
||||
);
|
||||
storage::append_event(&app, &e)?;
|
||||
Ok(Some(p.to_string_lossy().into_owned()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn register_external_ai_bridge(
|
||||
app: AppHandle,
|
||||
display_name: String,
|
||||
) -> Result<ExternalAiBridge, String> {
|
||||
let name = display_name.trim();
|
||||
if name.is_empty() || name.chars().count() > 64 {
|
||||
return Err("EXTERNAL_AI_NAME_INVALID".into());
|
||||
}
|
||||
let bridge_id = format!(
|
||||
"HL-AI-{}",
|
||||
uuid::Uuid::new_v4().simple().to_string()[..8].to_uppercase()
|
||||
);
|
||||
let root = storage::root(&app)?.join("external-ai").join(&bridge_id);
|
||||
let inbox = root.join("inbox");
|
||||
let outbox = root.join("outbox");
|
||||
fs::create_dir_all(&inbox).map_err(|e| e.to_string())?;
|
||||
fs::create_dir_all(&outbox).map_err(|e| e.to_string())?;
|
||||
let bridge = ExternalAiBridge {
|
||||
bridge_id,
|
||||
display_name: name.into(),
|
||||
inbox_path: inbox.to_string_lossy().into_owned(),
|
||||
outbox_path: outbox.to_string_lossy().into_owned(),
|
||||
state: "LOCAL_EXPRESSION_ONLY_NO_EXECUTION_AUTHORITY".into(),
|
||||
};
|
||||
let mut all = storage::bridges(&app)?;
|
||||
all.push(bridge.clone());
|
||||
storage::save_bridges(&app, &all)?;
|
||||
let e = storage::event(
|
||||
SourceKind::SystemReceipt,
|
||||
"外部 AI 入口已建立",
|
||||
format!(
|
||||
"{} 仅获得来源明确的语言收发目录,不获得人格与执行权限。",
|
||||
bridge.bridge_id
|
||||
),
|
||||
"SUCCEEDED",
|
||||
);
|
||||
storage::append_event(&app, &e)?;
|
||||
Ok(bridge)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
system_snapshot,
|
||||
create_channel,
|
||||
submit_user_message,
|
||||
read_knowledge_document,
|
||||
create_knowledge_document,
|
||||
save_knowledge_document,
|
||||
delete_knowledge_document,
|
||||
import_knowledge_folder,
|
||||
export_knowledge_document,
|
||||
register_external_ai_bridge
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("HoloLake runtime failed")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn title_rejects_empty() {
|
||||
assert!(clean_title(" ").is_err())
|
||||
}
|
||||
#[test]
|
||||
fn title_sanitizes_paths() {
|
||||
assert_eq!(clean_title("a/b").unwrap(), "a-b")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
fn main() {
|
||||
hololake_clean_desktop_lib::run();
|
||||
}
|
||||
86
product-source/hololake-clean-desktop/src-tauri/src/model.rs
Normal file
86
product-source/hololake-clean-desktop/src-tauri/src/model.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Channel {
|
||||
pub channel_id: String,
|
||||
pub name: String,
|
||||
pub created_at: String,
|
||||
pub private_git_path: String,
|
||||
pub public_distribution_state: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum SourceKind {
|
||||
UserMessage,
|
||||
PersonaResponse,
|
||||
SystemContext,
|
||||
ProtocolEvent,
|
||||
AgentAction,
|
||||
ToolResult,
|
||||
SystemReceipt,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TimelineEvent {
|
||||
pub event_id: String,
|
||||
pub source_kind: SourceKind,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub occurred_at: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KnowledgeSummary {
|
||||
pub path: String,
|
||||
pub title: String,
|
||||
pub updated_at: String,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KnowledgeDocument {
|
||||
pub path: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub updated_at: String,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalAiBridge {
|
||||
pub bridge_id: String,
|
||||
pub display_name: String,
|
||||
pub inbox_path: String,
|
||||
pub outbox_path: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SystemSnapshot {
|
||||
pub app_version: String,
|
||||
pub channel: Option<Channel>,
|
||||
pub timeline: Vec<TimelineEvent>,
|
||||
pub documents: Vec<KnowledgeSummary>,
|
||||
pub module_registry_version: String,
|
||||
pub public_update_endpoint: String,
|
||||
pub public_distribution_state: String,
|
||||
pub external_ai_bridges: Vec<ExternalAiBridge>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MutationReceipt {
|
||||
pub receipt_id: String,
|
||||
pub state: String,
|
||||
pub message: String,
|
||||
pub git_commit: Option<String>,
|
||||
pub event: TimelineEvent,
|
||||
}
|
||||
244
product-source/hololake-clean-desktop/src-tauri/src/storage.rs
Normal file
244
product-source/hololake-clean-desktop/src-tauri/src/storage.rs
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
use crate::model::{
|
||||
Channel, ExternalAiBridge, KnowledgeDocument, KnowledgeSummary, SourceKind, TimelineEvent,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Component, Path, PathBuf},
|
||||
process::Command,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
pub fn now() -> String {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| format!("{}.{:03}Z", d.as_secs(), d.subsec_millis()))
|
||||
.unwrap_or_else(|_| "0Z".into())
|
||||
}
|
||||
pub fn id(prefix: &str) -> String {
|
||||
format!("{}-{}", prefix, UuidPart::new())
|
||||
}
|
||||
struct UuidPart(String);
|
||||
impl UuidPart {
|
||||
fn new() -> Self {
|
||||
Self(uuid::Uuid::new_v4().simple().to_string()[..12].to_uppercase())
|
||||
}
|
||||
}
|
||||
impl std::fmt::Display for UuidPart {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
pub fn sha(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
pub fn root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let p = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("runtime-v1");
|
||||
fs::create_dir_all(&p).map_err(|e| e.to_string())?;
|
||||
Ok(p)
|
||||
}
|
||||
fn read_json<T: serde::de::DeserializeOwned>(p: &Path) -> Result<T, String> {
|
||||
serde_json::from_slice(&fs::read(p).map_err(|e| e.to_string())?).map_err(|e| e.to_string())
|
||||
}
|
||||
fn write_json<T: serde::Serialize>(p: &Path, v: &T) -> Result<(), String> {
|
||||
if let Some(x) = p.parent() {
|
||||
fs::create_dir_all(x).map_err(|e| e.to_string())?
|
||||
}
|
||||
let tmp = p.with_extension("tmp");
|
||||
fs::write(
|
||||
&tmp,
|
||||
serde_json::to_vec_pretty(v).map_err(|e| e.to_string())?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
fs::rename(tmp, p).map_err(|e| e.to_string())
|
||||
}
|
||||
pub fn channel(app: &AppHandle) -> Result<Option<Channel>, String> {
|
||||
let p = root(app)?.join("channel.json");
|
||||
if p.exists() {
|
||||
read_json(&p).map(Some)
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
pub fn save_channel(app: &AppHandle, c: &Channel) -> Result<(), String> {
|
||||
write_json(&root(app)?.join("channel.json"), c)
|
||||
}
|
||||
pub fn event(
|
||||
kind: SourceKind,
|
||||
title: impl Into<String>,
|
||||
content: impl Into<String>,
|
||||
state: &str,
|
||||
) -> TimelineEvent {
|
||||
TimelineEvent {
|
||||
event_id: id("HL-EVT"),
|
||||
source_kind: kind,
|
||||
title: title.into(),
|
||||
content: content.into(),
|
||||
occurred_at: now(),
|
||||
state: state.into(),
|
||||
}
|
||||
}
|
||||
pub fn append_event(app: &AppHandle, e: &TimelineEvent) -> Result<(), String> {
|
||||
let p = root(app)?.join("events.jsonl");
|
||||
let mut s = if p.exists() {
|
||||
fs::read_to_string(&p).map_err(|e| e.to_string())?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
s.push_str(&serde_json::to_string(e).map_err(|e| e.to_string())?);
|
||||
s.push('\n');
|
||||
fs::write(p, s).map_err(|e| e.to_string())
|
||||
}
|
||||
pub fn events(app: &AppHandle) -> Result<Vec<TimelineEvent>, String> {
|
||||
let p = root(app)?.join("events.jsonl");
|
||||
if !p.exists() {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
let mut v = fs::read_to_string(p)
|
||||
.map_err(|e| e.to_string())?
|
||||
.lines()
|
||||
.filter_map(|l| serde_json::from_str(l).ok())
|
||||
.collect::<Vec<_>>();
|
||||
v.reverse();
|
||||
Ok(v)
|
||||
}
|
||||
pub fn knowledge_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let p = root(app)?.join("private-repository").join("knowledge");
|
||||
fs::create_dir_all(p.join("docs")).map_err(|e| e.to_string())?;
|
||||
ensure_git(&p)?;
|
||||
Ok(p)
|
||||
}
|
||||
pub fn private_repo_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let p = root(app)?.join("private-repository");
|
||||
fs::create_dir_all(&p).map_err(|e| e.to_string())?;
|
||||
ensure_git(&p)?;
|
||||
Ok(p)
|
||||
}
|
||||
pub fn ensure_git(p: &Path) -> Result<(), String> {
|
||||
if !p.join(".git").exists() {
|
||||
run_git(p, &["init", "--initial-branch=main"])?;
|
||||
run_git(p, &["config", "user.name", "HoloLake Local"])?;
|
||||
run_git(p, &["config", "user.email", "local@hololake.invalid"])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn git_commit(p: &Path, msg: &str) -> Result<Option<String>, String> {
|
||||
run_git(p, &["add", "."])?;
|
||||
let status = run_git(p, &["status", "--porcelain"])?;
|
||||
if status.trim().is_empty() {
|
||||
return Ok(None);
|
||||
};
|
||||
run_git(p, &["commit", "-m", msg])?;
|
||||
Ok(Some(run_git(p, &["rev-parse", "HEAD"])?.trim().into()))
|
||||
}
|
||||
fn run_git(p: &Path, args: &[&str]) -> Result<String, String> {
|
||||
let o = Command::new("git")
|
||||
.current_dir(p)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|e| format!("GIT_UNAVAILABLE: {e}"))?;
|
||||
if !o.status.success() {
|
||||
return Err(format!(
|
||||
"GIT_FAILED: {}",
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
));
|
||||
};
|
||||
Ok(String::from_utf8_lossy(&o.stdout).into_owned())
|
||||
}
|
||||
pub fn safe_rel(value: &str) -> Result<PathBuf, String> {
|
||||
let p = Path::new(value);
|
||||
if p.is_absolute()
|
||||
|| p.components().any(|c| {
|
||||
matches!(
|
||||
c,
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_)
|
||||
)
|
||||
})
|
||||
{
|
||||
return Err("PATH_OUT_OF_SCOPE".into());
|
||||
}
|
||||
Ok(p.to_path_buf())
|
||||
}
|
||||
pub fn list_docs(app: &AppHandle) -> Result<Vec<KnowledgeSummary>, String> {
|
||||
let root = knowledge_root(app)?.join("docs");
|
||||
let mut out = vec![];
|
||||
collect(&root, &root, &mut out)?;
|
||||
out.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
|
||||
Ok(out)
|
||||
}
|
||||
fn collect(root: &Path, p: &Path, out: &mut Vec<KnowledgeSummary>) -> Result<(), String> {
|
||||
for e in fs::read_dir(p).map_err(|e| e.to_string())? {
|
||||
let e = e.map_err(|e| e.to_string())?;
|
||||
let path = e.path();
|
||||
if path.is_dir() {
|
||||
collect(root, &path, out)?
|
||||
} else if path.extension().and_then(|x| x.to_str()) == Some("md") {
|
||||
let b = fs::read(&path).map_err(|e| e.to_string())?;
|
||||
let rel = path
|
||||
.strip_prefix(root)
|
||||
.map_err(|e| e.to_string())?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let body = String::from_utf8_lossy(&b);
|
||||
let title = body
|
||||
.lines()
|
||||
.find_map(|x| x.strip_prefix("# "))
|
||||
.unwrap_or_else(|| {
|
||||
path.file_stem()
|
||||
.and_then(|x| x.to_str())
|
||||
.unwrap_or("未命名")
|
||||
})
|
||||
.into();
|
||||
let updated = e
|
||||
.metadata()
|
||||
.map_err(|e| e.to_string())?
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs().to_string())
|
||||
.unwrap_or_else(|| "0".into());
|
||||
out.push(KnowledgeSummary {
|
||||
path: rel,
|
||||
title,
|
||||
updated_at: updated,
|
||||
sha256: sha(&b),
|
||||
})
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn read_doc(app: &AppHandle, path: &str) -> Result<KnowledgeDocument, String> {
|
||||
let rel = safe_rel(path)?;
|
||||
let p = knowledge_root(app)?.join("docs").join(rel);
|
||||
let b = fs::read(&p).map_err(|e| e.to_string())?;
|
||||
let body = String::from_utf8(b.clone()).map_err(|_| "DOCUMENT_NOT_UTF8".to_string())?;
|
||||
let title = body
|
||||
.lines()
|
||||
.find_map(|x| x.strip_prefix("# "))
|
||||
.unwrap_or_else(|| p.file_stem().and_then(|x| x.to_str()).unwrap_or("未命名"))
|
||||
.into();
|
||||
Ok(KnowledgeDocument {
|
||||
path: path.into(),
|
||||
title,
|
||||
body,
|
||||
updated_at: now(),
|
||||
sha256: sha(&b),
|
||||
})
|
||||
}
|
||||
pub fn bridges(app: &AppHandle) -> Result<Vec<ExternalAiBridge>, String> {
|
||||
let p = root(app)?.join("external-ai-bridges.json");
|
||||
if p.exists() {
|
||||
read_json(&p)
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
pub fn save_bridges(app: &AppHandle, v: &[ExternalAiBridge]) -> Result<(), String> {
|
||||
write_json(&root(app)?.join("external-ai-bridges.json"), &v)
|
||||
}
|
||||
Loading…
Reference in a new issue