2026-09-03 15:35:33 +08:00
|
|
|
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() {
|
2026-09-03 16:40:56 +08:00
|
|
|
let folder_name = clean_title(
|
|
|
|
|
p.file_name()
|
|
|
|
|
.and_then(|value| value.to_str())
|
|
|
|
|
.unwrap_or("未命名分类"),
|
|
|
|
|
)?;
|
|
|
|
|
let child = dst.join(folder_name);
|
|
|
|
|
fs::create_dir_all(&child).map_err(|e| e.to_string())?;
|
|
|
|
|
copy_import(&p, &child, count)?;
|
2026-09-03 15:35:33 +08:00
|
|
|
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)?;
|
2026-09-03 16:40:56 +08:00
|
|
|
let source_name = clean_title(
|
|
|
|
|
src.file_name()
|
|
|
|
|
.and_then(|value| value.to_str())
|
|
|
|
|
.unwrap_or("外部知识库"),
|
|
|
|
|
)?;
|
|
|
|
|
let dst = root.join("docs").join("导入").join(source_name);
|
2026-09-03 15:35:33 +08:00
|
|
|
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")
|
|
|
|
|
}
|
2026-09-03 16:40:56 +08:00
|
|
|
#[test]
|
|
|
|
|
fn folder_import_preserves_classification_tree() {
|
|
|
|
|
let source = tempfile::tempdir().unwrap();
|
|
|
|
|
let destination = tempfile::tempdir().unwrap();
|
|
|
|
|
let category = source.path().join("产品架构");
|
|
|
|
|
fs::create_dir_all(&category).unwrap();
|
|
|
|
|
fs::write(category.join("决定.txt"), "分类内容").unwrap();
|
|
|
|
|
let mut count = 0;
|
|
|
|
|
copy_import(source.path(), destination.path(), &mut count).unwrap();
|
|
|
|
|
assert_eq!(count, 1);
|
|
|
|
|
let imported = fs::read_dir(destination.path().join("产品架构"))
|
|
|
|
|
.unwrap()
|
|
|
|
|
.collect::<Result<Vec<_>, _>>()
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(imported.len(), 1);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
imported[0]
|
|
|
|
|
.path()
|
|
|
|
|
.extension()
|
|
|
|
|
.and_then(|value| value.to_str()),
|
|
|
|
|
Some("md")
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-09-03 15:35:33 +08:00
|
|
|
}
|