feat: 零点原核频道v1+登录双门地基+五湖开场——zero_point裁决链/灯塔查号/code_repo_login/通知卡重设计/浅色主题联动(冰朔20260815夜谕)
This commit is contained in:
parent
ffed065841
commit
07fd4c85ef
16 changed files with 3082 additions and 88 deletions
|
|
@ -3,7 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>HoloLake</title>
|
<title>HoloLake · GH-AIOS</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,29 @@ version = "1.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aws-lc-rs"
|
||||||
|
version = "1.18.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
|
||||||
|
dependencies = [
|
||||||
|
"aws-lc-sys",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aws-lc-sys"
|
||||||
|
version = "0.44.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cmake",
|
||||||
|
"dunce",
|
||||||
|
"fs_extra",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.21.7"
|
version = "0.21.7"
|
||||||
|
|
@ -294,6 +317,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
|
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
|
"jobserver",
|
||||||
|
"libc",
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -330,6 +355,23 @@ version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.0",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.43"
|
version = "0.4.43"
|
||||||
|
|
@ -342,6 +384,15 @@ dependencies = [
|
||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cmake"
|
||||||
|
version = "0.1.58"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
|
|
@ -417,6 +468,15 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
|
|
@ -944,6 +1004,12 @@ dependencies = [
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fs_extra"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futf"
|
name = "futf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
|
|
@ -1162,8 +1228,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1185,8 +1253,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1392,6 +1463,7 @@ dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"dirs",
|
"dirs",
|
||||||
"fs2",
|
"fs2",
|
||||||
|
"reqwest",
|
||||||
"ring",
|
"ring",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -1834,6 +1906,16 @@ dependencies = [
|
||||||
"syn 2.0.115",
|
"syn 2.0.115",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jobserver"
|
||||||
|
version = "0.1.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.104"
|
version = "0.3.104"
|
||||||
|
|
@ -1983,6 +2065,12 @@ version = "0.4.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mac"
|
name = "mac"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
|
|
@ -2828,6 +2916,63 @@ dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn"
|
||||||
|
version = "0.11.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cfg_aliases",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn-proto",
|
||||||
|
"quinn-udp",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"socket2",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-proto"
|
||||||
|
version = "0.11.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
|
||||||
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
|
"bytes",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"lru-slab",
|
||||||
|
"rand 0.10.2",
|
||||||
|
"rand_pcg 0.10.2",
|
||||||
|
"ring",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"slab",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
"tinyvec",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-udp"
|
||||||
|
version = "0.5.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||||
|
dependencies = [
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"socket2",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.47"
|
||||||
|
|
@ -2860,7 +3005,7 @@ dependencies = [
|
||||||
"rand_chacha 0.2.2",
|
"rand_chacha 0.2.2",
|
||||||
"rand_core 0.5.1",
|
"rand_core 0.5.1",
|
||||||
"rand_hc",
|
"rand_hc",
|
||||||
"rand_pcg",
|
"rand_pcg 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2874,6 +3019,17 @@ dependencies = [
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||||
|
dependencies = [
|
||||||
|
"chacha20",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_chacha"
|
name = "rand_chacha"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
|
|
@ -2912,6 +3068,12 @@ dependencies = [
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_hc"
|
name = "rand_hc"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -2930,6 +3092,15 @@ dependencies = [
|
||||||
"rand_core 0.5.1",
|
"rand_core 0.5.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_pcg"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
|
|
@ -3025,6 +3196,7 @@ dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"rustls-platform-verifier",
|
"rustls-platform-verifier",
|
||||||
|
|
@ -3130,6 +3302,7 @@ version = "0.23.36"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
|
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
|
|
@ -3156,6 +3329,7 @@ version = "1.15.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -3192,6 +3366,7 @@ version = "0.103.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"untrusted",
|
"untrusted",
|
||||||
|
|
@ -3524,7 +3699,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -4795,6 +4970,16 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-time"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web_atoms"
|
name = "web_atoms"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,14 @@ rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tauri = { version = "=2.10.2", features = [] }
|
tauri = { version = "=2.10.2", features = ["devtools"] }
|
||||||
tauri-runtime = "=2.10.0"
|
tauri-runtime = "=2.10.0"
|
||||||
tauri-runtime-wry = "=2.10.0"
|
tauri-runtime-wry = "=2.10.0"
|
||||||
tauri-plugin-updater = "2.10.0"
|
tauri-plugin-updater = "2.10.0"
|
||||||
tauri-plugin-dialog = "=2.7.2"
|
tauri-plugin-dialog = "=2.7.2"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
url = "2"
|
url = "2"
|
||||||
|
reqwest = { version = "0.13.2", default-features = false, features = ["json", "rustls"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,298 @@
|
||||||
|
//! Agent 东家层 · agent_host
|
||||||
|
//!
|
||||||
|
//! 阶段E选型定案(冰朔点头):候选甲"东家模式"为体——
|
||||||
|
//! 外部 CLI Agent(师傅)干活,宿主只管:接信封、备提词、记账本、存记忆。
|
||||||
|
//!
|
||||||
|
//! 协议转工程落点(施工总纲):
|
||||||
|
//! - 人格体主控(铁律一):任务只从 GLP 信封进门(content_type=command),
|
||||||
|
//! 宿主绝不自己发起任务。
|
||||||
|
//! - 提词器(铁律五):会话开卷前先从提词器取指引(官方区协议+私人区索引),
|
||||||
|
//! 写进会话记忆的"提词板"栏——头顶提词板的工程形状。
|
||||||
|
//! - HLDP 内嵌(铁律二):每个会话=一卷 HLDP 检查点体(id/prev 链/date/
|
||||||
|
//! persona/host/title+正文),prev 链不可省。
|
||||||
|
//! - RECEIPT(GLS-0300 回执模型):control.receipt_required 时必回回执。
|
||||||
|
//!
|
||||||
|
//! 诚实边界:CLI 师傅的实际唤起口令(如何调 codex/qwen)未实测,不猜——
|
||||||
|
//! 本阶段先把"接单-提词-记忆-回执"全链落成,唤起件待师傅口令实测后接上。
|
||||||
|
|
||||||
|
use crate::glp_envelope::{validate_envelope, ContentType, GlpMessage};
|
||||||
|
use crate::persona_butler::{prompter_lookup, ButlerRecord, ButlerRecordKind, ButlerState};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::{AppHandle, Manager, State};
|
||||||
|
|
||||||
|
/// 会话记忆的提词板栏——开卷瞬间从提词器抄来的指引。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub struct PrompterBoard {
|
||||||
|
pub topics: Vec<String>,
|
||||||
|
pub answers: Vec<String>,
|
||||||
|
pub path_hints: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 会话记忆卷=HLDP 检查点体(prev 链不可省)。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub struct AgentSessionMemory {
|
||||||
|
pub id: String,
|
||||||
|
pub prev: String,
|
||||||
|
pub date: String,
|
||||||
|
pub persona: String,
|
||||||
|
pub host: String,
|
||||||
|
pub title: String,
|
||||||
|
pub envelope_id: String,
|
||||||
|
pub task: String,
|
||||||
|
pub hldp_anchor: String,
|
||||||
|
pub prompter_board: PrompterBoard,
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 接单回执——GLS-0300 RECEIPT 的工程实例。
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub struct AgentTaskReceipt {
|
||||||
|
pub receipt_for: String,
|
||||||
|
pub session_id: String,
|
||||||
|
pub memory_path: String,
|
||||||
|
pub accepted: bool,
|
||||||
|
pub note: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn memory_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
Ok(app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|error| format!("HOLOLAKE_AGENT_MEMORY_DIR_FAILED: {error}"))?
|
||||||
|
.join("agent-memory"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pointer_path(root: &PathBuf) -> PathBuf {
|
||||||
|
root.join("CURRENT.hdlp")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读 CURRENT 指针拿上一卷 id(没有则空串=开卷首卷)。
|
||||||
|
fn latest_memory_id(root: &PathBuf) -> String {
|
||||||
|
fs::read_to_string(pointer_path(root))
|
||||||
|
.ok()
|
||||||
|
.and_then(|raw| raw.lines().next().map(|line| line.trim().to_string()))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn today_compact() -> (String, String) {
|
||||||
|
let secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let days = (secs / 86_400) as i64;
|
||||||
|
let z = days + 719_468;
|
||||||
|
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||||
|
let doe = (z - era * 146_097) as u64;
|
||||||
|
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||||||
|
let y = yoe as i64 + era * 400;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||||
|
let mp = (5 * doy + 2) / 153;
|
||||||
|
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||||
|
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||||
|
let year = if m <= 2 { y + 1 } else { y };
|
||||||
|
(
|
||||||
|
format!("{year:04}{m:02}{d:02}"),
|
||||||
|
format!("{year:04}-{m:02}-{d:02}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 会话记忆落卷成 .hdlp 文本——HLDP 检查点体,人可读、链可续。
|
||||||
|
fn render_memory(memory: &AgentSessionMemory) -> String {
|
||||||
|
format!(
|
||||||
|
"HLDP-CHECKPOINT\nid: {}\nprev: {}\ndate: {}\npersona: {}\nhost: {}\ntitle: {}\n\n== 任务信封 ==\n{}\n\n== 提词板 ==\n{}\n\n== 状态 ==\n{}\n",
|
||||||
|
memory.id,
|
||||||
|
if memory.prev.is_empty() { "无(开卷首卷)" } else { &memory.prev },
|
||||||
|
memory.date,
|
||||||
|
memory.persona,
|
||||||
|
memory.host,
|
||||||
|
memory.title,
|
||||||
|
memory.task,
|
||||||
|
memory
|
||||||
|
.prompter_board
|
||||||
|
.answers
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.chain(memory.prompter_board.path_hints.iter().cloned())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n"),
|
||||||
|
memory.status
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从提词器抄指引上板:给定的题目逐条问,答案与路径分栏收好。
|
||||||
|
fn compose_prompter_board(
|
||||||
|
state: &State<'_, ButlerState>,
|
||||||
|
topics: &[String],
|
||||||
|
) -> PrompterBoard {
|
||||||
|
let mut board = PrompterBoard {
|
||||||
|
topics: topics.to_vec(),
|
||||||
|
answers: Vec::new(),
|
||||||
|
path_hints: Vec::new(),
|
||||||
|
};
|
||||||
|
for topic in topics {
|
||||||
|
match prompter_lookup(state.inner(), topic) {
|
||||||
|
Some(answer) => board.answers.push(format!("{}: {}", topic, answer)),
|
||||||
|
None => board.path_hints.push(format!(
|
||||||
|
"{}: 未命中——下钻路径:第五域线上仓库按 GLS 编号检索",
|
||||||
|
topic
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
board
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_butler_note(state: &State<'_, ButlerState>, record: ButlerRecord) {
|
||||||
|
crate::persona_butler::append_record(state.inner(), record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 接单:GLP 信封进门 → 验 → 抄提词板 → 落 HLDP 记忆卷 → 移 CURRENT 指针 → 回执。
|
||||||
|
/// 信封不合格、或不是 command 件,一律拒收——宿主只认标准指令。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn receive_agent_task_envelope(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, ButlerState>,
|
||||||
|
envelope: GlpMessage,
|
||||||
|
prompter_topics: Vec<String>,
|
||||||
|
) -> Result<AgentTaskReceipt, String> {
|
||||||
|
validate_envelope(&envelope)?;
|
||||||
|
if envelope.payload.content_type != ContentType::Command {
|
||||||
|
return Err("HOLOLAKE_AGENT_TASK_NOT_COMMAND".into());
|
||||||
|
}
|
||||||
|
let root = memory_root(&app)?;
|
||||||
|
fs::create_dir_all(&root)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_AGENT_MEMORY_WRITE_FAILED: {error}"))?;
|
||||||
|
let (compact, date) = today_compact();
|
||||||
|
let prev = latest_memory_id(&root);
|
||||||
|
let daily_sequence = fs::read_dir(&root)
|
||||||
|
.map(|entries| {
|
||||||
|
entries
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.filter(|entry| {
|
||||||
|
entry
|
||||||
|
.file_name()
|
||||||
|
.to_string_lossy()
|
||||||
|
.starts_with(&format!("AGENT-SESSION-{compact}"))
|
||||||
|
})
|
||||||
|
.count() as u64
|
||||||
|
+ 1
|
||||||
|
})
|
||||||
|
.unwrap_or(1);
|
||||||
|
let session_id = format!("AGENT-SESSION-{compact}-{daily_sequence:03}");
|
||||||
|
let board = compose_prompter_board(&state, &prompter_topics);
|
||||||
|
let memory = AgentSessionMemory {
|
||||||
|
id: session_id.clone(),
|
||||||
|
prev,
|
||||||
|
date,
|
||||||
|
persona: envelope.sender.object_id.clone(),
|
||||||
|
host: "hololake-native-desktop".into(),
|
||||||
|
title: format!("Agent 任务 · {}", envelope.context.task_id),
|
||||||
|
envelope_id: envelope.message_id.clone(),
|
||||||
|
task: envelope.payload.content.clone(),
|
||||||
|
hldp_anchor: envelope.context.hldp_anchor.clone(),
|
||||||
|
prompter_board: board,
|
||||||
|
status: "ACCEPTED(待师傅唤起件接入)".into(),
|
||||||
|
};
|
||||||
|
let memory_path = root.join(format!("{session_id}.hdlp"));
|
||||||
|
fs::write(&memory_path, render_memory(&memory))
|
||||||
|
.map_err(|error| format!("HOLOLAKE_AGENT_MEMORY_WRITE_FAILED: {error}"))?;
|
||||||
|
fs::write(pointer_path(&root), format!("{session_id}\n"))
|
||||||
|
.map_err(|error| format!("HOLOLAKE_AGENT_MEMORY_WRITE_FAILED: {error}"))?;
|
||||||
|
append_butler_note(
|
||||||
|
&state,
|
||||||
|
ButlerRecord {
|
||||||
|
record_id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
kind: ButlerRecordKind::GlpEnvelope,
|
||||||
|
from: envelope.sender.object_id.clone(),
|
||||||
|
subject: format!("agent-task:{session_id}"),
|
||||||
|
payload: envelope.payload.content.clone(),
|
||||||
|
occurred_at_unix_ms: SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_millis() as i64)
|
||||||
|
.unwrap_or(0),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(AgentTaskReceipt {
|
||||||
|
receipt_for: envelope.message_id,
|
||||||
|
session_id,
|
||||||
|
memory_path: memory_path.to_string_lossy().to_string(),
|
||||||
|
accepted: true,
|
||||||
|
note: "信封已收,记忆已落卷,提词板已抄;师傅唤起件待实测接入".into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查会话记忆:给 id 读回整卷;不给 id 则顺着 CURRENT 指针读最新一卷。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn query_agent_session_memory(
|
||||||
|
app: AppHandle,
|
||||||
|
session_id: Option<String>,
|
||||||
|
) -> Result<Option<AgentSessionMemory>, String> {
|
||||||
|
let root = memory_root(&app)?;
|
||||||
|
let id = match session_id {
|
||||||
|
Some(id) => id,
|
||||||
|
None => latest_memory_id(&root),
|
||||||
|
};
|
||||||
|
if id.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let raw = match fs::read_to_string(root.join(format!("{id}.hdlp"))) {
|
||||||
|
Ok(raw) => raw,
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
};
|
||||||
|
// 卷是 HLDP 文本体:逐行取字段还原结构件
|
||||||
|
let mut memory = AgentSessionMemory {
|
||||||
|
id: String::new(),
|
||||||
|
prev: String::new(),
|
||||||
|
date: String::new(),
|
||||||
|
persona: String::new(),
|
||||||
|
host: String::new(),
|
||||||
|
title: String::new(),
|
||||||
|
envelope_id: String::new(),
|
||||||
|
task: String::new(),
|
||||||
|
hldp_anchor: String::new(),
|
||||||
|
prompter_board: PrompterBoard {
|
||||||
|
topics: vec![],
|
||||||
|
answers: vec![],
|
||||||
|
path_hints: vec![],
|
||||||
|
},
|
||||||
|
status: String::new(),
|
||||||
|
};
|
||||||
|
let mut section = "";
|
||||||
|
let mut task_lines: Vec<String> = Vec::new();
|
||||||
|
let mut board_lines: Vec<String> = Vec::new();
|
||||||
|
for line in raw.lines() {
|
||||||
|
if let Some(value) = line.strip_prefix("id: ") {
|
||||||
|
memory.id = value.to_string();
|
||||||
|
} else if let Some(value) = line.strip_prefix("prev: ") {
|
||||||
|
memory.prev = if value == "无(开卷首卷)" { String::new() } else { value.to_string() };
|
||||||
|
} else if let Some(value) = line.strip_prefix("date: ") {
|
||||||
|
memory.date = value.to_string();
|
||||||
|
} else if let Some(value) = line.strip_prefix("persona: ") {
|
||||||
|
memory.persona = value.to_string();
|
||||||
|
} else if let Some(value) = line.strip_prefix("host: ") {
|
||||||
|
memory.host = value.to_string();
|
||||||
|
} else if let Some(value) = line.strip_prefix("title: ") {
|
||||||
|
memory.title = value.to_string();
|
||||||
|
} else if line == "== 任务信封 ==" {
|
||||||
|
section = "task";
|
||||||
|
} else if line == "== 提词板 ==" {
|
||||||
|
section = "board";
|
||||||
|
} else if line == "== 状态 ==" {
|
||||||
|
section = "status";
|
||||||
|
} else if section == "task" && !line.trim().is_empty() {
|
||||||
|
task_lines.push(line.to_string());
|
||||||
|
} else if section == "board" && !line.trim().is_empty() {
|
||||||
|
board_lines.push(line.to_string());
|
||||||
|
} else if section == "status" && !line.trim().is_empty() {
|
||||||
|
memory.status = line.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
memory.task = task_lines.join("\n");
|
||||||
|
memory.prompter_board.answers = board_lines;
|
||||||
|
Ok(Some(memory))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
//! 冰朔的会客厅(Agent 接线 v1):
|
||||||
|
//! - 语言回应通道:人类说话 → 人格体翻译 → 底层归灯(终端 qwen Agent)回应;
|
||||||
|
//! - 双层切换:语言推理层(默认,只说话)⇄ 现实开发执行层(人类显性切换
|
||||||
|
//! + 原生授权确认后才放行 -y 写权限)。开关主权永远在人类手里。
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tauri::State;
|
||||||
|
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
||||||
|
|
||||||
|
pub struct ParlorState {
|
||||||
|
session_id: Mutex<Option<String>>,
|
||||||
|
layer: Mutex<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ParlorState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { session_id: Mutex::new(None), layer: Mutex::new("language".to_string()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ParlorAskInput {
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ParlorAskReceipt {
|
||||||
|
pub reply: String,
|
||||||
|
pub layer: String,
|
||||||
|
pub fresh_session: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ParlorSwitchInput {
|
||||||
|
pub layer: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 静默读取 ~/.qwen/.env 的 KEY=VALUE(钥匙不出门、不落日志)。
|
||||||
|
fn load_qwen_env() -> HashMap<String, String> {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
let Some(home) = dirs::home_dir() else { return map };
|
||||||
|
let Ok(content) = std::fs::read_to_string(home.join(".qwen").join(".env")) else { return map };
|
||||||
|
for line in content.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some((key, value)) = line.split_once('=') {
|
||||||
|
map.insert(key.trim().to_string(), value.trim().trim_matches('"').to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 跑一轮归灯:spawn qwen 管道模式,stdout 读完解析 json 事件流。
|
||||||
|
fn run_guideng(args: Vec<String>) -> Result<String, String> {
|
||||||
|
let Some(home) = dirs::home_dir() else {
|
||||||
|
return Err("HOLOLAKE_PARLOR_HOME_MISSING".into());
|
||||||
|
};
|
||||||
|
let qwen = home.join(".npm-global").join("bin").join("qwen");
|
||||||
|
if !qwen.exists() {
|
||||||
|
return Err("HOLOLAKE_PARLOR_AGENT_NOT_FOUND".into());
|
||||||
|
}
|
||||||
|
let mut command = Command::new(qwen);
|
||||||
|
command.args(&args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null());
|
||||||
|
for (key, value) in load_qwen_env() {
|
||||||
|
command.env(key, value);
|
||||||
|
}
|
||||||
|
let mut child = command
|
||||||
|
.spawn()
|
||||||
|
.map_err(|error| format!("HOLOLAKE_PARLOR_SPAWN_FAILED: {error}"))?;
|
||||||
|
let mut stdout = child.stdout.take().ok_or("HOLOLAKE_PARLOR_PIPE_MISSING")?;
|
||||||
|
let (sender, receiver) = std::sync::mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
let outcome = stdout.read_to_end(&mut buffer).map(|_| buffer);
|
||||||
|
let _ = sender.send(outcome.map_err(|error| error.to_string()));
|
||||||
|
});
|
||||||
|
let data = match receiver.recv_timeout(Duration::from_secs(240)) {
|
||||||
|
Ok(Ok(bytes)) => bytes,
|
||||||
|
Ok(Err(error)) => return Err(format!("HOLOLAKE_PARLOR_READ_FAILED: {error}")),
|
||||||
|
Err(_) => {
|
||||||
|
let _ = child.kill();
|
||||||
|
return Err("HOLOLAKE_PARLOR_TIMEOUT".into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = child.wait();
|
||||||
|
String::from_utf8(data).map_err(|error| format!("HOLOLAKE_PARLOR_OUTPUT_INVALID: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 qwen 的 json 事件数组里取回答与新生成的会话 id。
|
||||||
|
fn parse_guideng_output(raw: &str) -> Result<(String, Option<String>), String> {
|
||||||
|
let events: Vec<serde_json::Value> = serde_json::from_str(raw)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_PARLOR_PARSE_FAILED: {error}"))?;
|
||||||
|
let mut reply = String::new();
|
||||||
|
let mut session = None;
|
||||||
|
for event in &events {
|
||||||
|
match event.get("type").and_then(|value| value.as_str()) {
|
||||||
|
Some("system") => {
|
||||||
|
if event.get("subtype").and_then(|value| value.as_str()) == Some("init") {
|
||||||
|
session = event
|
||||||
|
.get("session_id")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(str::to_string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some("result") => {
|
||||||
|
if let Some(text) = event.get("result").and_then(|value| value.as_str()) {
|
||||||
|
reply = text.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reply.is_empty() {
|
||||||
|
return Err("HOLOLAKE_PARLOR_EMPTY_REPLY".into());
|
||||||
|
}
|
||||||
|
Ok((reply, session))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn agent_parlor_ask(
|
||||||
|
state: State<'_, ParlorState>,
|
||||||
|
zero: State<'_, crate::zero_point::ZeroPointState>,
|
||||||
|
input: ParlorAskInput,
|
||||||
|
) -> Result<ParlorAskReceipt, String> {
|
||||||
|
// 砌墙守卫(冰朔砌墙谕):路由不在人格层时,会客厅代码层不可达。
|
||||||
|
crate::zero_point::require_persona_route(&zero)?;
|
||||||
|
let message = input.message.trim().to_string();
|
||||||
|
if message.is_empty() {
|
||||||
|
return Err("HOLOLAKE_PARLOR_EMPTY_MESSAGE".into());
|
||||||
|
}
|
||||||
|
let layer = state.layer.lock().map(|guard| guard.clone()).unwrap_or_default();
|
||||||
|
let previous_session = state.session_id.lock().map(|guard| guard.clone()).unwrap_or_default();
|
||||||
|
let mut args = vec!["--auth-type".to_string(), "openai".to_string(), "-o".to_string(), "json".to_string(), "-p".to_string(), message];
|
||||||
|
if layer == "execution" {
|
||||||
|
// 执行层:放行写权限(该档只有人类显性授权后才能进来)。
|
||||||
|
args.push("-y".to_string());
|
||||||
|
}
|
||||||
|
let (raw, fresh) = {
|
||||||
|
let mut tried = vec![];
|
||||||
|
if let Some(session) = &previous_session {
|
||||||
|
tried.push("-r".to_string());
|
||||||
|
tried.push(session.clone());
|
||||||
|
}
|
||||||
|
let first_args = [&tried[..], &args[..]].concat();
|
||||||
|
let run = tauri::async_runtime::spawn_blocking(move || run_guideng(first_args))
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("HOLOLAKE_PARLOR_JOIN_FAILED: {error}"))?;
|
||||||
|
match run {
|
||||||
|
Ok(output) => (output, false),
|
||||||
|
Err(_) if previous_session.is_some() => {
|
||||||
|
// 旧会话续不上就换新会话再来一轮,不让会话账卡死对话。
|
||||||
|
let retry = args.clone();
|
||||||
|
let output = tauri::async_runtime::spawn_blocking(move || run_guideng(retry))
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("HOLOLAKE_PARLOR_JOIN_FAILED: {error}"))??;
|
||||||
|
(output, true)
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (reply, session) = parse_guideng_output(&raw)?;
|
||||||
|
if let Some(session) = session {
|
||||||
|
if let Ok(mut guard) = state.session_id.lock() {
|
||||||
|
*guard = Some(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ParlorAskReceipt { reply, layer, fresh_session: fresh })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn agent_parlor_switch_layer(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
state: State<'_, ParlorState>,
|
||||||
|
zero: State<'_, crate::zero_point::ZeroPointState>,
|
||||||
|
input: ParlorSwitchInput,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
// 砌墙守卫:人格通道整体不可达时,切层也无从谈起。
|
||||||
|
crate::zero_point::require_persona_route(&zero)?;
|
||||||
|
let target = if input.layer == "execution" { "execution" } else { "language" };
|
||||||
|
let current = state.layer.lock().map(|guard| guard.clone()).unwrap_or_default();
|
||||||
|
if current == target {
|
||||||
|
return Ok(current);
|
||||||
|
}
|
||||||
|
if target == "execution" {
|
||||||
|
// 铁律:进入执行层必须人类亲手授权一次。
|
||||||
|
let confirmer = app.clone();
|
||||||
|
let confirmed = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
confirmer
|
||||||
|
.dialog()
|
||||||
|
.message("切换到现实开发执行层后,归灯将获得执行权限(调用工具、读写文件)。确认切换吗?")
|
||||||
|
.title("切换授权确认")
|
||||||
|
.kind(MessageDialogKind::Warning)
|
||||||
|
.buttons(MessageDialogButtons::OkCancelCustom("确认切换".to_string(), "留在语言层".to_string()))
|
||||||
|
.blocking_show()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("HOLOLAKE_PARLOR_CONFIRM_FAILED: {error}"))?;
|
||||||
|
if !confirmed {
|
||||||
|
return Ok(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(mut guard) = state.layer.lock() {
|
||||||
|
*guard = target.to_string();
|
||||||
|
}
|
||||||
|
// 换层 = 换一种工作方式,会话上下文重新开始。
|
||||||
|
if let Ok(mut guard) = state.session_id.lock() {
|
||||||
|
*guard = None;
|
||||||
|
}
|
||||||
|
Ok(target.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn agent_parlor_status(state: State<'_, ParlorState>) -> Result<String, String> {
|
||||||
|
Ok(state.layer.lock().map(|guard| guard.clone()).unwrap_or_else(|_| "language".to_string()))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
//! 登录模块 · code_repo_login
|
||||||
|
//!
|
||||||
|
//! 规划卷依据(HoloLake第二阶段总体规划-20260815 · 阶段B1):
|
||||||
|
//! - 人类端输入代码仓库账号密码 → 对 guanghulab(Forgejo)验证。
|
||||||
|
//! - 登录仓库 = 验证了背后绑定的服务器(冰朔教义)。
|
||||||
|
//! - 凭证只存本机钥匙串 · 不落明文。
|
||||||
|
//!
|
||||||
|
//! 事实底账(2026-08-15 三角测量):
|
||||||
|
//! - Forgejo 挂载在 /code 路径下:GET https://{host}/code/api/v1/user 走基本认证,
|
||||||
|
//! 假凭证=401 · 真凭证=200 并回显 JSON(login/email)。
|
||||||
|
//! - guanghulab.com 与 guanghubingshuo.com 双域同路可用(均在 ALLOWED_HOSTS 血统内)。
|
||||||
|
//! - 钥匙存取走系统钥匙串(macOS `security`);其余平台暂不落盘密码,
|
||||||
|
//! 会话仅内存保持(诚实边界,Windows 钥匙串接入排在分发阶段)。
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
const LOGIN_HOST: &str = "guanghulab.com";
|
||||||
|
const SESSION_FILE_NAME: &str = "login-session.json";
|
||||||
|
|
||||||
|
/// 落盘的登录会话——只有用户名与主机,密码永不落盘。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LoginSession {
|
||||||
|
pub username: String,
|
||||||
|
pub host: String,
|
||||||
|
pub signed_in_at_unix_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登录成功回执——交给前端展示"验证了背后绑定的服务器"。
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LoginReceipt {
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub host: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||||
|
Ok(app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|error| format!("HOLOLAKE_LOGIN_SESSION_DIR_FAILED: {error}"))?
|
||||||
|
.join(SESSION_FILE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn keychain_store(host: &str, username: &str, password: &str) -> Result<(), String> {
|
||||||
|
let output = Command::new("/usr/bin/security")
|
||||||
|
.args([
|
||||||
|
"add-internet-password",
|
||||||
|
"-U",
|
||||||
|
"-s",
|
||||||
|
host,
|
||||||
|
"-a",
|
||||||
|
username,
|
||||||
|
"-w",
|
||||||
|
password,
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.map_err(|error| format!("HOLOLAKE_LOGIN_KEYCHAIN_FAILED: {error}"))?;
|
||||||
|
if output.status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("HOLOLAKE_LOGIN_KEYCHAIN_FAILED".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn keychain_has(host: &str, username: &str) -> bool {
|
||||||
|
Command::new("/usr/bin/security")
|
||||||
|
.args(["find-internet-password", "-s", host, "-a", username])
|
||||||
|
.output()
|
||||||
|
.map(|output| output.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn keychain_remove(host: &str, username: &str) {
|
||||||
|
let _ = Command::new("/usr/bin/security")
|
||||||
|
.args(["delete-internet-password", "-s", host, "-a", username])
|
||||||
|
.output();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
fn keychain_store(_host: &str, _username: &str, _password: &str) -> Result<(), String> {
|
||||||
|
// 非 macOS 暂不落盘密码:登录验证照做,会话仅本次运行有效。
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
fn keychain_has(_host: &str, _username: &str) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
fn keychain_remove(_host: &str, _username: &str) {}
|
||||||
|
|
||||||
|
fn validate_username(raw: &str) -> Result<String, String> {
|
||||||
|
let name = raw.trim();
|
||||||
|
let valid = !name.is_empty()
|
||||||
|
&& name.len() <= 40
|
||||||
|
&& name
|
||||||
|
.chars()
|
||||||
|
.all(|item| item.is_ascii_alphanumeric() || item == '-' || item == '_');
|
||||||
|
if valid {
|
||||||
|
Ok(name.to_string())
|
||||||
|
} else {
|
||||||
|
Err("HOLOLAKE_LOGIN_USERNAME_INVALID".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动时自查:本机有没有已登录会话(会话文件在,且钥匙串里凭证还在)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn check_code_repo_login(app: AppHandle) -> Result<Option<LoginSession>, String> {
|
||||||
|
let path = session_path(&app)?;
|
||||||
|
let raw = match fs::read_to_string(&path) {
|
||||||
|
Ok(raw) => raw,
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
};
|
||||||
|
let session: LoginSession = serde_json::from_str(&raw)
|
||||||
|
.map_err(|_| "HOLOLAKE_LOGIN_SESSION_CORRUPT".to_string())?;
|
||||||
|
if keychain_has(&session.host, &session.username) {
|
||||||
|
Ok(Some(session))
|
||||||
|
} else {
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登录验证:基本认证打 Forgejo 用户接口,密码错=401 即拒;
|
||||||
|
/// 通过后凭证进钥匙串,会话(不含密码)落盘。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn perform_code_repo_login(
|
||||||
|
app: AppHandle,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<LoginReceipt, String> {
|
||||||
|
let username = validate_username(&username)?;
|
||||||
|
if password.is_empty() || password.len() > 512 {
|
||||||
|
return Err("HOLOLAKE_LOGIN_CREDENTIALS_INVALID".into());
|
||||||
|
}
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.read_timeout(Duration::from_secs(15))
|
||||||
|
.use_rustls_tls()
|
||||||
|
.build()
|
||||||
|
.map_err(|error| format!("HOLOLAKE_LOGIN_NETWORK_FAILED: {error}"))?;
|
||||||
|
let url = format!("https://{LOGIN_HOST}/code/api/v1/user");
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.basic_auth(&username, Some(&password))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("HOLOLAKE_LOGIN_NETWORK_FAILED: {error}"))?;
|
||||||
|
let status = response.status();
|
||||||
|
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
|
return Err("HOLOLAKE_LOGIN_CREDENTIALS_INVALID".into());
|
||||||
|
}
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("HOLOLAKE_LOGIN_VERIFICATION_FAILED: {status}"));
|
||||||
|
}
|
||||||
|
let body: serde_json::Value = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("HOLOLAKE_LOGIN_VERIFICATION_FAILED: {error}"))?;
|
||||||
|
let confirmed_login = body
|
||||||
|
.get("login")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.ok_or("HOLOLAKE_LOGIN_VERIFICATION_FAILED")?;
|
||||||
|
let email = body
|
||||||
|
.get("email")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
keychain_store(LOGIN_HOST, &username, &password)?;
|
||||||
|
let session = LoginSession {
|
||||||
|
username: confirmed_login.to_string(),
|
||||||
|
host: LOGIN_HOST.to_string(),
|
||||||
|
signed_in_at_unix_ms: SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_millis() as u64)
|
||||||
|
.unwrap_or(0),
|
||||||
|
};
|
||||||
|
let path = session_path(&app)?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_LOGIN_SESSION_WRITE_FAILED: {error}"))?;
|
||||||
|
}
|
||||||
|
fs::write(
|
||||||
|
&path,
|
||||||
|
serde_json::to_string_pretty(&session)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_LOGIN_SESSION_WRITE_FAILED: {error}"))?,
|
||||||
|
)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_LOGIN_SESSION_WRITE_FAILED: {error}"))?;
|
||||||
|
Ok(LoginReceipt {
|
||||||
|
username: confirmed_login.to_string(),
|
||||||
|
email,
|
||||||
|
host: LOGIN_HOST.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登出:会话文件删除,钥匙串凭证清除。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn sign_out_code_repo_login(app: AppHandle) -> Result<(), String> {
|
||||||
|
let path = session_path(&app)?;
|
||||||
|
if let Ok(raw) = fs::read_to_string(&path) {
|
||||||
|
if let Ok(session) = serde_json::from_str::<LoginSession>(&raw) {
|
||||||
|
keychain_remove(&session.host, &session.username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,266 @@
|
||||||
|
//! GLP 标准信封 · glp_envelope
|
||||||
|
//!
|
||||||
|
//! 协议转工程第一件(HoloLake第二阶段总体规划-20260815 · 施工总纲):
|
||||||
|
//! GLS-0300《GLP 通信核心协议》第3节"标准消息结构"的逐字段工程映射。
|
||||||
|
//! 字段一个不造、一个不丢——老家谱 YAML 原文即本文件的形状。
|
||||||
|
//!
|
||||||
|
//! 指挥链落点(铁律四):人格体→宿主的一切指令都必须是这个信封;
|
||||||
|
//! 宿主只认信封不认散话。
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
/// GLS-0300 · receiver.routing_mode
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RoutingMode {
|
||||||
|
Direct,
|
||||||
|
Channel,
|
||||||
|
Broadcast,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GLS-0300 · payload.content_type
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ContentType {
|
||||||
|
Text,
|
||||||
|
Command,
|
||||||
|
Event,
|
||||||
|
State,
|
||||||
|
Reference,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GLS-0300 · control.priority
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Priority {
|
||||||
|
Low,
|
||||||
|
Normal,
|
||||||
|
High,
|
||||||
|
Critical,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GLS-0300 · control.retry_policy
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RetryPolicy {
|
||||||
|
None,
|
||||||
|
Safe,
|
||||||
|
Guaranteed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GLS-0300 · sender
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub struct GlpSender {
|
||||||
|
pub object_id: String,
|
||||||
|
pub object_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub world_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GLS-0300 · receiver
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub struct GlpReceiver {
|
||||||
|
pub object_id: String,
|
||||||
|
pub object_type: String,
|
||||||
|
pub routing_mode: RoutingMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GLS-0300 · context(hldp_anchor 即铁律二的记忆锚点)
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub struct GlpContext {
|
||||||
|
#[serde(default)]
|
||||||
|
pub conversation_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub parent_message_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub relation_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub task_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub hldp_anchor: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GLS-0300 · payload
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub struct GlpPayload {
|
||||||
|
pub language: String,
|
||||||
|
pub content_type: ContentType,
|
||||||
|
pub content: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub attachments: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GLS-0300 · control
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub struct GlpControl {
|
||||||
|
pub priority: Priority,
|
||||||
|
pub ack_required: bool,
|
||||||
|
pub receipt_required: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub expires_at: String,
|
||||||
|
pub retry_policy: RetryPolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GLS-0300 · integrity(高风险消息须带签名与回执链——通信安全节)
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub struct GlpIntegrity {
|
||||||
|
#[serde(default)]
|
||||||
|
pub checksum: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GLS-0300 · glp_message 全信封
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub struct GlpMessage {
|
||||||
|
pub protocol: String,
|
||||||
|
pub message_id: String,
|
||||||
|
pub message_type: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub sender: GlpSender,
|
||||||
|
pub receiver: GlpReceiver,
|
||||||
|
pub context: GlpContext,
|
||||||
|
pub payload: GlpPayload,
|
||||||
|
pub control: GlpControl,
|
||||||
|
pub integrity: GlpIntegrity,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 信封进门第一道验:协议号、编号格式、收发主体必须在场。
|
||||||
|
/// 不合格的信封宿主不收——指挥链只认标准件。
|
||||||
|
pub fn validate_envelope(message: &GlpMessage) -> Result<(), String> {
|
||||||
|
if message.protocol != "GLP/1.0" {
|
||||||
|
return Err("HOLOLAKE_GLP_PROTOCOL_UNKNOWN".into());
|
||||||
|
}
|
||||||
|
if message.message_id.trim().is_empty() || message.created_at.trim().is_empty() {
|
||||||
|
return Err("HOLOLAKE_GLP_ENVELOPE_INCOMPLETE".into());
|
||||||
|
}
|
||||||
|
if message.sender.object_id.trim().is_empty() || message.sender.object_type.trim().is_empty() {
|
||||||
|
return Err("HOLOLAKE_GLP_SENDER_INCOMPLETE".into());
|
||||||
|
}
|
||||||
|
if message.receiver.object_id.trim().is_empty()
|
||||||
|
|| message.receiver.object_type.trim().is_empty()
|
||||||
|
{
|
||||||
|
return Err("HOLOLAKE_GLP_RECEIVER_INCOMPLETE".into());
|
||||||
|
}
|
||||||
|
if message.payload.content.is_empty() {
|
||||||
|
return Err("HOLOLAKE_GLP_PAYLOAD_EMPTY".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 消息编号按老家谱格式生成:GLP-MSG-YYYYMMDD-000001。
|
||||||
|
/// 序号由账本(管家层)当日累计给出,这里只拼形状。
|
||||||
|
pub fn build_message_id(date_compact: &str, daily_sequence: u64) -> String {
|
||||||
|
format!("GLP-MSG-{date_compact}-{daily_sequence:06}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ISO-8601 近似时刻戳(秒级,UTC)——老家谱要 ISO-8601,工程给秒级事实。
|
||||||
|
pub fn now_iso8601() -> String {
|
||||||
|
let secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let days = secs / 86_400;
|
||||||
|
let rem = secs % 86_400;
|
||||||
|
let (hours, minutes, seconds) = (rem / 3_600, (rem % 3_600) / 60, rem % 60);
|
||||||
|
let (year, month, day) = civil_from_days(days as i64);
|
||||||
|
format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 1970-01-01 起的天数转公历年月日(Howard Hinnant 算法,纯本地实现不引新依赖)。
|
||||||
|
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||||
|
let z = z + 719_468;
|
||||||
|
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||||
|
let doe = (z - era * 146_097) as u64;
|
||||||
|
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||||||
|
let y = yoe as i64 + era * 400;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||||
|
let mp = (5 * doy + 2) / 153;
|
||||||
|
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||||
|
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||||
|
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample_envelope() -> GlpMessage {
|
||||||
|
GlpMessage {
|
||||||
|
protocol: "GLP/1.0".into(),
|
||||||
|
message_id: build_message_id("20260815", 1),
|
||||||
|
message_type: "DIRECT".into(),
|
||||||
|
created_at: now_iso8601(),
|
||||||
|
sender: GlpSender {
|
||||||
|
object_id: "AGE-0001".into(),
|
||||||
|
object_type: "age".into(),
|
||||||
|
world_path: "glw://tolaria".into(),
|
||||||
|
},
|
||||||
|
receiver: GlpReceiver {
|
||||||
|
object_id: "HOLOLAKE-HOST".into(),
|
||||||
|
object_type: "host".into(),
|
||||||
|
routing_mode: RoutingMode::Direct,
|
||||||
|
},
|
||||||
|
context: GlpContext {
|
||||||
|
task_id: "ZY-TEST".into(),
|
||||||
|
hldp_anchor: "ZY-CHECKPOINT-20260815-014".into(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
payload: GlpPayload {
|
||||||
|
language: "zh-CN".into(),
|
||||||
|
content_type: ContentType::Command,
|
||||||
|
content: "开工".into(),
|
||||||
|
attachments: vec![],
|
||||||
|
},
|
||||||
|
control: GlpControl {
|
||||||
|
priority: Priority::Normal,
|
||||||
|
ack_required: true,
|
||||||
|
receipt_required: true,
|
||||||
|
expires_at: String::new(),
|
||||||
|
retry_policy: RetryPolicy::None,
|
||||||
|
},
|
||||||
|
integrity: GlpIntegrity::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn envelope_round_trip_keeps_every_field() {
|
||||||
|
let message = sample_envelope();
|
||||||
|
let json = serde_json::to_string(&message).unwrap();
|
||||||
|
let back: GlpMessage = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back.protocol, "GLP/1.0");
|
||||||
|
assert_eq!(back.sender.object_id, "AGE-0001");
|
||||||
|
assert_eq!(back.receiver.routing_mode, RoutingMode::Direct);
|
||||||
|
assert_eq!(back.payload.content_type, ContentType::Command);
|
||||||
|
assert_eq!(back.context.hldp_anchor, "ZY-CHECKPOINT-20260815-014");
|
||||||
|
assert!(back.control.receipt_required);
|
||||||
|
validate_envelope(&back).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_id_follows_family_format() {
|
||||||
|
assert_eq!(build_message_id("20260815", 7), "GLP-MSG-20260815-000007");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bad_protocol_is_rejected() {
|
||||||
|
let mut message = sample_envelope();
|
||||||
|
message.protocol = "GLP/0.9".into();
|
||||||
|
assert!(validate_envelope(&message).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_payload_is_rejected() {
|
||||||
|
let mut message = sample_envelope();
|
||||||
|
message.payload.content = String::new();
|
||||||
|
assert!(validate_envelope(&message).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,12 +7,13 @@ use std::collections::HashMap;
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::fs::{self, OpenOptions};
|
use std::fs::{self, OpenOptions};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
#[cfg(unix)]
|
||||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::time::UNIX_EPOCH;
|
use std::time::UNIX_EPOCH;
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
use tauri_plugin_dialog::DialogExt;
|
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
const SNAPSHOT_SCHEMA: &str = "hololake.native-knowledge-base/v1";
|
const SNAPSHOT_SCHEMA: &str = "hololake.native-knowledge-base/v1";
|
||||||
|
|
@ -179,6 +180,233 @@ pub async fn save_knowledge_document(
|
||||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))?
|
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ExportKnowledgeDocumentInput {
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
#[serde(default = "default_export_extension")]
|
||||||
|
pub extension: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_export_extension() -> String {
|
||||||
|
"md".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ExportKnowledgeDocumentReceipt {
|
||||||
|
pub path: String,
|
||||||
|
pub bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn export_knowledge_document(
|
||||||
|
app: AppHandle,
|
||||||
|
input: ExportKnowledgeDocumentInput,
|
||||||
|
) -> Result<Option<ExportKnowledgeDocumentReceipt>, String> {
|
||||||
|
// 下载本页:Rust 侧弹保存对话框,人选好位置后原样写出(md/html 全平台通用格式)。
|
||||||
|
let extension = match input.extension.as_str() {
|
||||||
|
"html" => "html",
|
||||||
|
_ => "md",
|
||||||
|
};
|
||||||
|
// 文件名清洗按最严平台(Windows)来:九个禁用字符全洗,结尾的点号空格也修。
|
||||||
|
let mut suggested = input
|
||||||
|
.title
|
||||||
|
.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "·");
|
||||||
|
suggested = suggested.trim_end_matches(['.', ' ']).to_string();
|
||||||
|
if suggested.trim().is_empty() {
|
||||||
|
suggested = "知识页".to_string();
|
||||||
|
}
|
||||||
|
// Windows 保留设备名(CON/NUL/COM1…)不能直接当文件名,撞名就补个尾巴。
|
||||||
|
if matches!(suggested.to_ascii_uppercase().as_str(),
|
||||||
|
"CON" | "PRN" | "AUX" | "NUL"
|
||||||
|
| "COM1" | "COM2" | "COM3" | "COM4" | "COM5" | "COM6" | "COM7" | "COM8" | "COM9"
|
||||||
|
| "LPT1" | "LPT2" | "LPT3" | "LPT4" | "LPT5" | "LPT6" | "LPT7" | "LPT8" | "LPT9") {
|
||||||
|
suggested.push_str("·页");
|
||||||
|
}
|
||||||
|
suggested.push('.');
|
||||||
|
suggested.push_str(extension);
|
||||||
|
let filter_label = if extension == "html" { "网页文件" } else { "Markdown" };
|
||||||
|
let (sender, receiver) = std::sync::mpsc::channel::<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 _ = sender.send(picked);
|
||||||
|
});
|
||||||
|
let picked = tauri::async_runtime::spawn_blocking(move || receiver.recv().ok().flatten())
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("HOLOLAKE_EXPORT_JOIN_FAILED: {error}"))?;
|
||||||
|
let Some(target) = picked else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let bytes = input.body.len() as u64;
|
||||||
|
let path = target.display().to_string();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
fs::write(&target, input.body.as_bytes())
|
||||||
|
.map_err(|error| format!("HOLOLAKE_EXPORT_WRITE_FAILED: {error}"))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("HOLOLAKE_EXPORT_JOIN_FAILED: {error}"))??;
|
||||||
|
Ok(Some(ExportKnowledgeDocumentReceipt { path, bytes }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DeleteKnowledgeDocumentInput {
|
||||||
|
pub source: String,
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct KnowledgeDeleteReceipt {
|
||||||
|
pub removed: String,
|
||||||
|
pub pages: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_native_path(root: &Path, relative: &str) -> Result<PathBuf, String> {
|
||||||
|
let mut target = root.to_path_buf();
|
||||||
|
for component in Path::new(relative).components() {
|
||||||
|
match component {
|
||||||
|
Component::Normal(part) => target.push(part),
|
||||||
|
_ => return Err("HOLOLAKE_KNOWLEDGE_PATH_INVALID".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn confirm_destructive(app: &AppHandle, message: String) -> Result<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 {
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_knowledge_document(
|
||||||
|
app: AppHandle,
|
||||||
|
input: CreateKnowledgeDocumentInput,
|
||||||
|
) -> 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 docs = roots.0.join("docs");
|
||||||
|
let mut name = base.clone();
|
||||||
|
let mut counter = 2u32;
|
||||||
|
while docs.join(format!("{name}.md")).exists() {
|
||||||
|
name = format!("{base} {counter}");
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
let target = docs.join(format!("{name}.md"));
|
||||||
|
let body = format!("# {name}\n\n");
|
||||||
|
let receipt_name = name.clone();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let mut file = private_file_options()
|
||||||
|
.open(&target)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_FAILED: {error}"))?;
|
||||||
|
file.write_all(body.as_bytes())
|
||||||
|
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_FAILED: {error}"))?;
|
||||||
|
file.sync_all().map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_FAILED: {error}"))?;
|
||||||
|
git(&roots.0, &["add", "--", &format!("docs/{name}.md")], "ADD")?;
|
||||||
|
git(&roots.0, &["commit", "-m", &format!("新建页面:{name}")], "COMMIT")?;
|
||||||
|
Ok::<(), String>(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CREATE_JOIN_FAILED: {error}"))??;
|
||||||
|
Ok(KnowledgeDeleteReceipt { removed: format!("{receipt_name}.md"), pages: 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_knowledge_document(
|
||||||
|
app: AppHandle,
|
||||||
|
input: DeleteKnowledgeDocumentInput,
|
||||||
|
) -> Result<Option<KnowledgeDeleteReceipt>, String> {
|
||||||
|
// 只删本机库(native);供体源只读,不碰。
|
||||||
|
if input.source != "native" {
|
||||||
|
return Err("HOLOLAKE_KNOWLEDGE_DELETE_READ_ONLY_SOURCE".into());
|
||||||
|
}
|
||||||
|
let roots = knowledge_roots(&app)?;
|
||||||
|
let target = resolve_native_path(&roots.0, &input.path)?;
|
||||||
|
if !target.is_file() {
|
||||||
|
return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_NOT_FOUND".into());
|
||||||
|
}
|
||||||
|
let title = target
|
||||||
|
.file_stem()
|
||||||
|
.and_then(OsStr::to_str)
|
||||||
|
.unwrap_or("这一页")
|
||||||
|
.to_string();
|
||||||
|
if !confirm_destructive(&app, format!("把页面「{title}」移到回收站?随时可以找回。")).await? {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
move_to_trash(&roots.0, &target)?;
|
||||||
|
Ok(Some(KnowledgeDeleteReceipt { removed: input.path, pages: 1 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DeleteKnowledgeFolderInput {
|
||||||
|
pub folder: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_knowledge_folder(
|
||||||
|
app: AppHandle,
|
||||||
|
input: DeleteKnowledgeFolderInput,
|
||||||
|
) -> Result<Option<KnowledgeDeleteReceipt>, String> {
|
||||||
|
// 删整个文件夹 = 删本机库里该相对目录及其全部页面。
|
||||||
|
let roots = knowledge_roots(&app)?;
|
||||||
|
let target = resolve_native_path(&roots.0, &input.folder)?;
|
||||||
|
if target == roots.0 || !target.is_dir() {
|
||||||
|
return Err("HOLOLAKE_KNOWLEDGE_FOLDER_NOT_FOUND".into());
|
||||||
|
}
|
||||||
|
let name = target
|
||||||
|
.file_name()
|
||||||
|
.and_then(OsStr::to_str)
|
||||||
|
.unwrap_or(&input.folder)
|
||||||
|
.to_string();
|
||||||
|
let pages = fs::read_dir(&target)
|
||||||
|
.map(|entries| {
|
||||||
|
entries
|
||||||
|
.flatten()
|
||||||
|
.filter(|entry| entry.file_type().map(|t| t.is_file()).unwrap_or(false))
|
||||||
|
.count()
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
if !confirm_destructive(&app, format!("把文件夹「{name}」和里面全部 {pages} 个页面移到回收站?随时可以找回。")).await? {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
move_to_trash(&roots.0, &target)?;
|
||||||
|
Ok(Some(KnowledgeDeleteReceipt { removed: input.folder, pages }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn print_knowledge_document(app: AppHandle) -> Result<(), String> {
|
||||||
|
// PDF 走系统打印通道:打印框里选"存储为 PDF"即得 PDF 文件。
|
||||||
|
let window = app
|
||||||
|
.get_webview_window("main")
|
||||||
|
.ok_or_else(|| "HOLOLAKE_PRINT_WINDOW_NOT_FOUND".to_string())?;
|
||||||
|
window.print().map_err(|error| format!("HOLOLAKE_PRINT_FAILED: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn select_and_import_knowledge_folder(
|
pub async fn select_and_import_knowledge_folder(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
|
|
@ -199,6 +427,22 @@ pub async fn select_and_import_knowledge_folder(
|
||||||
let source = selected
|
let source = selected
|
||||||
.into_path()
|
.into_path()
|
||||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_PICKER_PATH_INVALID: {error}"))?;
|
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_PICKER_PATH_INVALID: {error}"))?;
|
||||||
|
// 冰朔 2026-08-15 谕:知识库是一个整体,进门即转成光湖原生格式。
|
||||||
|
let confirmer = app.clone();
|
||||||
|
let confirmed = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
confirmer
|
||||||
|
.dialog()
|
||||||
|
.message("导入的页面将原生转成光湖知识库格式,原有知识库的页面逻辑渲染,以及页面跳转关系将失效,是否确认导入转换?")
|
||||||
|
.title("导入转换确认")
|
||||||
|
.kind(MessageDialogKind::Info)
|
||||||
|
.buttons(MessageDialogButtons::OkCancelCustom("确认导入转换".to_string(), "取消".to_string()))
|
||||||
|
.blocking_show()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_CONFIRM_JOIN_FAILED: {error}"))?;
|
||||||
|
if !confirmed {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
let roots = knowledge_roots(&app)?;
|
let roots = knowledge_roots(&app)?;
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
import_folder_at(&roots.0, roots.1.as_deref(), &source).map(Some)
|
import_folder_at(&roots.0, roots.1.as_deref(), &source).map(Some)
|
||||||
|
|
@ -226,9 +470,38 @@ fn knowledge_roots(app: &AppHandle) -> Result<(PathBuf, Option<PathBuf>), String
|
||||||
Ok((native, legacy))
|
Ok((native, legacy))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 写私有文件的跨平台选项:unix 上只许本人读写,Windows 上不做额外权限设置。
|
||||||
|
fn private_file_options() -> OpenOptions {
|
||||||
|
let mut options = OpenOptions::new();
|
||||||
|
options.create_new(true).write(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
options.mode(0o600);
|
||||||
|
options
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除 = 搬到库内回收站(.trash),绝不真删;名字带时间戳防撞车。
|
||||||
|
fn move_to_trash(root: &Path, target: &Path) -> Result<PathBuf, String> {
|
||||||
|
let relative = target
|
||||||
|
.strip_prefix(root)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DELETE_FAILED: {error}"))?;
|
||||||
|
let stamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let destination = root.join(".trash").join(format!("{}-{stamp}", relative.display()));
|
||||||
|
if let Some(parent) = destination.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DELETE_FAILED: {error}"))?;
|
||||||
|
}
|
||||||
|
fs::rename(target, &destination)
|
||||||
|
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DELETE_FAILED: {error}"))?;
|
||||||
|
Ok(destination)
|
||||||
|
}
|
||||||
|
|
||||||
fn ensure_native_knowledge_root(root: &Path) -> Result<(), String> {
|
fn ensure_native_knowledge_root(root: &Path) -> Result<(), String> {
|
||||||
fs::create_dir_all(root.join("docs").join("导入"))
|
fs::create_dir_all(root.join("docs").join("导入"))
|
||||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_STORAGE_UNAVAILABLE: {error}"))?;
|
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_STORAGE_UNAVAILABLE: {error}"))?;
|
||||||
|
#[cfg(unix)]
|
||||||
fs::set_permissions(root, fs::Permissions::from_mode(0o700))
|
fs::set_permissions(root, fs::Permissions::from_mode(0o700))
|
||||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_PERMISSION_FAILED: {error}"))?;
|
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_PERMISSION_FAILED: {error}"))?;
|
||||||
if !root.join(".git").exists() {
|
if !root.join(".git").exists() {
|
||||||
|
|
@ -401,10 +674,7 @@ fn save_document_at(
|
||||||
}
|
}
|
||||||
if previous != input.body.as_bytes() {
|
if previous != input.body.as_bytes() {
|
||||||
let temporary = path.with_extension(format!("save-{}.tmp", Uuid::new_v4()));
|
let temporary = path.with_extension(format!("save-{}.tmp", Uuid::new_v4()));
|
||||||
let mut file = OpenOptions::new()
|
let mut file = private_file_options()
|
||||||
.create_new(true)
|
|
||||||
.write(true)
|
|
||||||
.mode(0o600)
|
|
||||||
.open(&temporary)
|
.open(&temporary)
|
||||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_SAVE_FAILED: {error}"))?;
|
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_SAVE_FAILED: {error}"))?;
|
||||||
file.write_all(input.body.as_bytes())
|
file.write_all(input.body.as_bytes())
|
||||||
|
|
@ -687,6 +957,67 @@ fn walk_import(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 外来页面进门即洗:只留文字,外壳全换成光湖原生 Markdown。
|
||||||
|
/// 原有知识库的渲染层与页面跳转关系在此失效(冰朔 2026-08-15 谕)。
|
||||||
|
fn convert_to_native_markdown(raw: &str) -> String {
|
||||||
|
let text = raw.replace("\r\n", "\n");
|
||||||
|
let mut out = String::with_capacity(text.len());
|
||||||
|
for line in text.split('\n') {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed == "<aside>" || trimmed == "</aside>" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(title) = trimmed.strip_prefix(":::toggle") {
|
||||||
|
let title = title.trim();
|
||||||
|
out.push_str("**▸ ");
|
||||||
|
if title.is_empty() {
|
||||||
|
out.push_str("详情");
|
||||||
|
} else {
|
||||||
|
out.push_str(title);
|
||||||
|
}
|
||||||
|
out.push_str("**\n");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if trimmed == ":::" || trimmed.starts_with(":::toc") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let cleaned = line
|
||||||
|
.replace("<br/>", "\n")
|
||||||
|
.replace("<br />", "\n")
|
||||||
|
.replace("<br>", "\n");
|
||||||
|
out.push_str(&strip_notion_links(&cleaned));
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notion 页面链接 → 只留链接文字(跳转关系按谕旨失效)。
|
||||||
|
fn strip_notion_links(line: &str) -> String {
|
||||||
|
let bytes: Vec<char> = line.chars().collect();
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == '[' {
|
||||||
|
if let Some(close) = bytes[i + 1..].iter().position(|c| *c == ']') {
|
||||||
|
let text_end = i + 1 + close;
|
||||||
|
if text_end + 1 < bytes.len() && bytes[text_end + 1] == '(' {
|
||||||
|
if let Some(paren_end) = bytes[text_end + 2..].iter().position(|c| *c == ')') {
|
||||||
|
let href: String = bytes[text_end + 2..text_end + 2 + paren_end].iter().collect();
|
||||||
|
if href.contains("notion.so") || href.contains("notion.site") {
|
||||||
|
out.extend(bytes[i + 1..text_end].iter());
|
||||||
|
i = text_end + 2 + paren_end + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(bytes[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn import_document(
|
fn import_document(
|
||||||
source: &Path,
|
source: &Path,
|
||||||
relative: &Path,
|
relative: &Path,
|
||||||
|
|
@ -708,7 +1039,7 @@ fn import_document(
|
||||||
.and_then(OsStr::to_str)
|
.and_then(OsStr::to_str)
|
||||||
.unwrap_or("未命名页面");
|
.unwrap_or("未命名页面");
|
||||||
let content = match extension.as_str() {
|
let content = match extension.as_str() {
|
||||||
"md" | "markdown" => raw,
|
"md" | "markdown" => convert_to_native_markdown(&raw),
|
||||||
"txt" => format!("# {title}\n\n{raw}\n"),
|
"txt" => format!("# {title}\n\n{raw}\n"),
|
||||||
"csv" => format!("# {title}\n\n```csv\n{raw}\n```\n"),
|
"csv" => format!("# {title}\n\n```csv\n{raw}\n```\n"),
|
||||||
"json" => format!("# {title}\n\n```json\n{raw}\n```\n"),
|
"json" => format!("# {title}\n\n```json\n{raw}\n```\n"),
|
||||||
|
|
@ -730,10 +1061,7 @@ fn import_document(
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let mut file = OpenOptions::new()
|
let mut file = private_file_options()
|
||||||
.create_new(true)
|
|
||||||
.write(true)
|
|
||||||
.mode(0o600)
|
|
||||||
.open(&target)
|
.open(&target)
|
||||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?;
|
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?;
|
||||||
file.write_all(content.as_bytes())
|
file.write_all(content.as_bytes())
|
||||||
|
|
@ -757,10 +1085,7 @@ fn import_asset(source: &Path, target: &Path) -> Result<ImportDisposition, Strin
|
||||||
}
|
}
|
||||||
fs::create_dir_all(target.parent().unwrap_or_else(|| Path::new(".")))
|
fs::create_dir_all(target.parent().unwrap_or_else(|| Path::new(".")))
|
||||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?;
|
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?;
|
||||||
let mut file = OpenOptions::new()
|
let mut file = private_file_options()
|
||||||
.create_new(true)
|
|
||||||
.write(true)
|
|
||||||
.mode(0o600)
|
|
||||||
.open(target)
|
.open(target)
|
||||||
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?;
|
.map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?;
|
||||||
file.write_all(&bytes)
|
file.write_all(&bytes)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
|
mod agent_host;
|
||||||
|
mod agent_parlor;
|
||||||
mod code_channel;
|
mod code_channel;
|
||||||
|
mod code_repo_login;
|
||||||
|
mod glp_envelope;
|
||||||
mod direct_local_broker;
|
mod direct_local_broker;
|
||||||
mod direct_local_session;
|
mod direct_local_session;
|
||||||
mod dynamic_capability_routing;
|
mod dynamic_capability_routing;
|
||||||
|
|
@ -6,11 +10,13 @@ mod home_status;
|
||||||
mod knowledge_base;
|
mod knowledge_base;
|
||||||
mod local_development_bridge;
|
mod local_development_bridge;
|
||||||
mod personal_channel;
|
mod personal_channel;
|
||||||
|
mod persona_butler;
|
||||||
mod pncc_receipt_projection;
|
mod pncc_receipt_projection;
|
||||||
mod pncc_remote_git;
|
mod pncc_remote_git;
|
||||||
mod pncc_repository_binding;
|
mod pncc_repository_binding;
|
||||||
mod release_trust;
|
mod release_trust;
|
||||||
mod release_update;
|
mod release_update;
|
||||||
|
mod zero_point;
|
||||||
|
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
|
|
@ -18,6 +24,13 @@ pub fn run_connector() -> Result<(), String> {
|
||||||
direct_local_broker::run_connector()
|
direct_local_broker::run_connector()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 排障专用:webview 启动自检回传(黑屏取证)。
|
||||||
|
#[tauri::command]
|
||||||
|
fn debug_boot_report(report: String) -> String {
|
||||||
|
eprintln!("HOLOLAKE_BOOT_REPORT: {report}");
|
||||||
|
"ok".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
|
@ -45,6 +58,14 @@ pub fn run() {
|
||||||
knowledge_base::search_knowledge,
|
knowledge_base::search_knowledge,
|
||||||
knowledge_base::save_knowledge_document,
|
knowledge_base::save_knowledge_document,
|
||||||
knowledge_base::select_and_import_knowledge_folder,
|
knowledge_base::select_and_import_knowledge_folder,
|
||||||
|
knowledge_base::export_knowledge_document,
|
||||||
|
knowledge_base::create_knowledge_document,
|
||||||
|
agent_parlor::agent_parlor_ask,
|
||||||
|
agent_parlor::agent_parlor_switch_layer,
|
||||||
|
agent_parlor::agent_parlor_status,
|
||||||
|
knowledge_base::delete_knowledge_document,
|
||||||
|
knowledge_base::delete_knowledge_folder,
|
||||||
|
knowledge_base::print_knowledge_document,
|
||||||
code_channel::get_code_channel_snapshot,
|
code_channel::get_code_channel_snapshot,
|
||||||
code_channel::clone_code_channel,
|
code_channel::clone_code_channel,
|
||||||
code_channel::select_local_code_channel,
|
code_channel::select_local_code_channel,
|
||||||
|
|
@ -54,14 +75,127 @@ pub fn run() {
|
||||||
pncc_repository_binding::select_pncc_repository_candidate,
|
pncc_repository_binding::select_pncc_repository_candidate,
|
||||||
pncc_repository_binding::confirm_pncc_repository_mount,
|
pncc_repository_binding::confirm_pncc_repository_mount,
|
||||||
pncc_receipt_projection::query_pncc_receipt_projection,
|
pncc_receipt_projection::query_pncc_receipt_projection,
|
||||||
|
persona_butler::report_context_water_level,
|
||||||
|
persona_butler::query_prompter,
|
||||||
|
persona_butler::register_prompter_entry,
|
||||||
|
persona_butler::submit_glp_envelope,
|
||||||
|
persona_butler::deposit_work_order,
|
||||||
|
persona_butler::get_butler_ledger,
|
||||||
|
code_repo_login::check_code_repo_login,
|
||||||
|
code_repo_login::perform_code_repo_login,
|
||||||
|
code_repo_login::sign_out_code_repo_login,
|
||||||
|
agent_host::receive_agent_task_envelope,
|
||||||
|
agent_host::query_agent_session_memory,
|
||||||
|
zero_point::zero_point_bind,
|
||||||
|
zero_point::zero_point_verify,
|
||||||
|
zero_point::zero_point_sync,
|
||||||
|
zero_point::zero_point_status,
|
||||||
|
zero_point::zero_point_save_api,
|
||||||
|
zero_point::zero_point_api_config,
|
||||||
|
zero_point::generic_layer_chat,
|
||||||
|
debug_boot_report,
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
|
// 零点原核频道·铸渊执行手脚:启动即落仓、读协议、按协议裁决路由。
|
||||||
|
let zero_point_state = zero_point::ZeroPointState::default();
|
||||||
|
if let Err(error) = zero_point::boot_zero_point(app.handle(), &zero_point_state) {
|
||||||
|
eprintln!("HoloLake zero-point core requires maintenance: {error}");
|
||||||
|
}
|
||||||
|
app.manage(zero_point_state);
|
||||||
|
app.manage(agent_parlor::ParlorState::default());
|
||||||
|
let butler = persona_butler::ButlerState::default();
|
||||||
|
if let Err(error) = persona_butler::restore_ledger(app.handle(), &butler) {
|
||||||
|
eprintln!("HoloLake butler ledger restore requires maintenance: {error}");
|
||||||
|
}
|
||||||
|
app.manage(butler);
|
||||||
let broker = direct_local_broker::start(app.handle())?;
|
let broker = direct_local_broker::start(app.handle())?;
|
||||||
app.manage(broker);
|
app.manage(broker);
|
||||||
release_trust::install_updater_if_provisioned(app.handle())?;
|
release_trust::install_updater_if_provisioned(app.handle())?;
|
||||||
if let Err(error) = release_update::observe_release_startup(app.handle()) {
|
if let Err(error) = release_update::observe_release_startup(app.handle()) {
|
||||||
eprintln!("HoloLake update recovery requires maintenance: {error}");
|
eprintln!("HoloLake update recovery requires maintenance: {error}");
|
||||||
}
|
}
|
||||||
|
// 启动自适应:按主屏大小调窗口(82%,上下限保护),钳制在主屏内。
|
||||||
|
// 踩坑账:current_monitor 曾返回幽灵副屏(x=6901),center 把窗口弹出屏外,
|
||||||
|
// 冰朔验收两次只见黑屏。现改为 primary_monitor + 坐标钳制,永不飞出屏。
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
// 排障用:debug 构建自动打开开发者工具,看 webview 真实报错。
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
window.open_devtools();
|
||||||
|
// 排障自检:3 秒后让 webview 把自己的真实状态吐到 stderr。
|
||||||
|
let probe = window.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
eprintln!("PROBE_THREAD: fired");
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||||
|
let js = concat!(
|
||||||
|
"if (!window.__hololake_probe) {",
|
||||||
|
" window.__hololake_probe = 0;",
|
||||||
|
" var __t = setInterval(function(){",
|
||||||
|
" window.__hololake_probe++;",
|
||||||
|
" try {",
|
||||||
|
" var __r = JSON.stringify({",
|
||||||
|
" url: location.href,",
|
||||||
|
" ready: document.readyState,",
|
||||||
|
" rootChildren: (document.getElementById('root') ? document.getElementById('root').childElementCount : -1),",
|
||||||
|
" hasTauri: !!(window.__TAURI__ && window.__TAURI__.core)",
|
||||||
|
" });",
|
||||||
|
" if (window.__TAURI__ && window.__TAURI__.core) {",
|
||||||
|
" clearInterval(__t);",
|
||||||
|
" window.__TAURI__.core.invoke('debug_boot_report', { report: String(__r) }).catch(function(){ });",
|
||||||
|
" } else if (window.__hololake_probe === 5) {",
|
||||||
|
" if (location.href.indexOf('5210') === -1) { location.replace('http://127.0.0.1:5210/'); }",
|
||||||
|
" } else if (window.__hololake_probe > 30) {",
|
||||||
|
" clearInterval(__t);",
|
||||||
|
" }",
|
||||||
|
" } catch (e) { window.__hololake_probe_err = String(e); }",
|
||||||
|
" if (window.__hololake_probe_err && window.__hololake_probe > 8) {",
|
||||||
|
" try {",
|
||||||
|
" var __img = new Image();",
|
||||||
|
" __img.src = 'http://127.0.0.1:5210/favicon.ico?err=' + encodeURIComponent(window.__hololake_probe_err).slice(0, 120);",
|
||||||
|
" window.__hololake_probe_err = null;",
|
||||||
|
" } catch (e2) { }",
|
||||||
|
" }",
|
||||||
|
" }, 2000);",
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
match probe.eval(js) {
|
||||||
|
Ok(_) => eprintln!("PROBE_EVAL: injected OK"),
|
||||||
|
Err(e) => eprintln!("PROBE_EVAL_ERR: {e}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 自愈导航(踩坑账 2026-08-15:webview 曾停在 about:blank 导致整夜黑屏)。
|
||||||
|
// 启动 6 秒后若 webview 还没坐上 devUrl,主动补一次导航;生产版无 dev_url 不生效。
|
||||||
|
let heal = window.clone();
|
||||||
|
let dev_url = app.config().build.dev_url.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(6));
|
||||||
|
if let Some(dev_url) = dev_url {
|
||||||
|
if let Ok(current) = heal.url() {
|
||||||
|
let current = current.to_string();
|
||||||
|
if current.starts_with("about:") || !current.starts_with(dev_url.as_str()) {
|
||||||
|
eprintln!("SELF_HEAL_NAV: webview at {current}, navigating to dev url");
|
||||||
|
let _ = heal.navigate(dev_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let monitor = window
|
||||||
|
.primary_monitor()
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.or_else(|| window.current_monitor().ok().flatten());
|
||||||
|
if let Some(monitor) = monitor {
|
||||||
|
let physical = monitor.size();
|
||||||
|
let scale = monitor.scale_factor().max(1.0);
|
||||||
|
let width = ((physical.width as f64 / scale) * 0.82).clamp(960.0, 1600.0);
|
||||||
|
let height = ((physical.height as f64 / scale) * 0.82).clamp(640.0, 1000.0);
|
||||||
|
let _ = window.set_size(tauri::LogicalSize::new(width, height));
|
||||||
|
// 主屏居中(primary_monitor 坐标即主屏原点),不再用 center() 赌监视器。
|
||||||
|
let mon_pos = monitor.position();
|
||||||
|
let x = mon_pos.x as f64 / scale + ((physical.width as f64 / scale) - width) / 2.0;
|
||||||
|
let y = mon_pos.y as f64 / scale + ((physical.height as f64 / scale) - height) / 2.0;
|
||||||
|
let _ = window.set_position(tauri::LogicalPosition::new(x.max(0.0), y.max(0.0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,299 @@
|
||||||
|
//! 宿主管家层 · persona_butler
|
||||||
|
//!
|
||||||
|
//! 宪法依据(HoloLake第二阶段总体规划-20260815 · 铁律三/四/五 · 铸渊自需卷):
|
||||||
|
//! - 宿主是容器与管家,不是大脑;只动手不代脑。
|
||||||
|
//! - 本模块是五个器官的插座(阶段B预埋,阶段E长成):
|
||||||
|
//! ①压缩预告(铁律三 · 纸与桌协议的水位预告端)
|
||||||
|
//! ②提词器查询(铁律五 · 官方区@更新源 / 私人区@人格体自维护)
|
||||||
|
//! ③水位事件(自需卷② · 上下文用量侦测,废巡逻轮询)
|
||||||
|
//! ④信封投递(铁律四 · GLP 标准信封进总线)
|
||||||
|
//! ⑤工单信箱(自需卷① · 不歇的脚:睡前留单,常驻执行体替走)
|
||||||
|
//!
|
||||||
|
//! 阶段B口径:插座齐、事件通道通、账本落盘可读;器官实体阶段E接入。
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tauri::{AppHandle, Emitter, Manager, State};
|
||||||
|
|
||||||
|
/// 管家账本里每条记录的类型——与 GLS-0300 GLP 消息类型对血统。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ButlerRecordKind {
|
||||||
|
/// ①压缩预告:水位到刻度,宿主向人格体发出的预告
|
||||||
|
CompactionNotice,
|
||||||
|
/// ②提词器:一次查询与应答(答案/未命中/给路径,三种应答之一)
|
||||||
|
PrompterQuery,
|
||||||
|
/// ③水位事件:上下文用量快照
|
||||||
|
WaterLevel,
|
||||||
|
/// ④GLP 信封:人格体投入总线的标准信封
|
||||||
|
GlpEnvelope,
|
||||||
|
/// ⑤工单:留给常驻执行体的工单
|
||||||
|
WorkOrder,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ButlerRecord {
|
||||||
|
pub record_id: String,
|
||||||
|
pub kind: ButlerRecordKind,
|
||||||
|
/// 标准信封字段:谁发的(人格体编号 / HOST)
|
||||||
|
pub from: String,
|
||||||
|
/// 标准信封字段:要干什么(一句话事由)
|
||||||
|
pub subject: String,
|
||||||
|
/// 标准信封字段:正文/凭证(阶段B只存原文,阶段E按GLP校验签名)
|
||||||
|
pub payload: String,
|
||||||
|
pub occurred_at_unix_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct ButlerState {
|
||||||
|
records: Mutex<Vec<ButlerRecord>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提词器查询口(对兄弟模块公开):按题目查最新答案,未命中返回 None。
|
||||||
|
pub fn prompter_lookup(state: &ButlerState, topic: &str) -> Option<String> {
|
||||||
|
let records = state.records.lock().ok()?;
|
||||||
|
records
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|record| {
|
||||||
|
record.kind == ButlerRecordKind::PrompterQuery
|
||||||
|
&& record.subject == topic
|
||||||
|
&& !record.payload.is_empty()
|
||||||
|
})
|
||||||
|
.map(|record| record.payload.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记账口(对兄弟模块公开):往账本追加一条记录。
|
||||||
|
pub fn append_record(state: &ButlerState, record: ButlerRecord) {
|
||||||
|
if let Ok(mut guard) = state.records.lock() {
|
||||||
|
guard.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn butler_home(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let base = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| format!("app data dir unavailable: {e}"))?;
|
||||||
|
let home = base.join("persona-butler");
|
||||||
|
fs::create_dir_all(&home).map_err(|e| format!("butler home mkdir failed: {e}"))?;
|
||||||
|
Ok(home)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ledger_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
Ok(butler_home(app)?.join("butler-ledger.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist(app: &AppHandle, records: &[ButlerRecord]) -> Result<(), String> {
|
||||||
|
let body = serde_json::to_vec_pretty(records)
|
||||||
|
.map_err(|e| format!("ledger serialize failed: {e}"))?;
|
||||||
|
fs::write(ledger_path(app)?, body).map_err(|e| format!("ledger write failed: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
const WATER_LEVEL_EVENT: &str = "butler://water-level";
|
||||||
|
const COMPACTION_NOTICE_EVENT: &str = "butler://compaction-notice";
|
||||||
|
/// 水位默认刻度:到九成即预告(铁律三),人格体可用指令改刻度。
|
||||||
|
const DEFAULT_WATER_MARK_PERCENT: u32 = 90;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct WaterLevelSnapshot {
|
||||||
|
pub used_percent: u32,
|
||||||
|
pub mark_percent: u32,
|
||||||
|
pub notice_due: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ③水位事件:人格体/前端报告当前上下文用量,宿主比对刻度。
|
||||||
|
/// 到刻度即发压缩预告事件——预告是义务,压缩与否等人格体的纸。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn report_context_water_level(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, ButlerState>,
|
||||||
|
used_percent: u32,
|
||||||
|
) -> Result<WaterLevelSnapshot, String> {
|
||||||
|
let used_percent = used_percent.min(100);
|
||||||
|
let snapshot = WaterLevelSnapshot {
|
||||||
|
used_percent,
|
||||||
|
mark_percent: DEFAULT_WATER_MARK_PERCENT,
|
||||||
|
notice_due: used_percent >= DEFAULT_WATER_MARK_PERCENT,
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let mut records = state.records.lock().map_err(|e| e.to_string())?;
|
||||||
|
records.push(ButlerRecord {
|
||||||
|
record_id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
kind: ButlerRecordKind::WaterLevel,
|
||||||
|
from: "PERSONA".to_string(),
|
||||||
|
subject: format!("上下文水位 {}%", used_percent),
|
||||||
|
payload: serde_json::to_string(&snapshot).map_err(|e| e.to_string())?,
|
||||||
|
occurred_at_unix_ms: now_ms(),
|
||||||
|
});
|
||||||
|
persist(&app, &records)?;
|
||||||
|
}
|
||||||
|
let _ = app.emit(WATER_LEVEL_EVENT, &snapshot);
|
||||||
|
if snapshot.notice_due {
|
||||||
|
// ①压缩预告:只预告不代脑——纸由人格体写(铁律三 · 纸与桌)
|
||||||
|
let _ = app.emit(COMPACTION_NOTICE_EVENT, &snapshot);
|
||||||
|
let mut records = state.records.lock().map_err(|e| e.to_string())?;
|
||||||
|
records.push(ButlerRecord {
|
||||||
|
record_id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
kind: ButlerRecordKind::CompactionNotice,
|
||||||
|
from: "HOST".to_string(),
|
||||||
|
subject: "水位到刻度,请人格体写记忆(递纸)".to_string(),
|
||||||
|
payload: serde_json::to_string(&snapshot).map_err(|e| e.to_string())?,
|
||||||
|
occurred_at_unix_ms: now_ms(),
|
||||||
|
});
|
||||||
|
persist(&app, &records)?;
|
||||||
|
}
|
||||||
|
Ok(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ②提词器查询(阶段B口径):查私人区账本里的登记条目。
|
||||||
|
/// 反猜铁律:只有三种应答——答案 / 未命中 / 未命中但给路径。
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PrompterAnswer {
|
||||||
|
pub outcome: String, // "ANSWER" | "NOT_FOUND" | "PATH_GIVEN"
|
||||||
|
pub answer: Option<String>,
|
||||||
|
pub path_hint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn query_prompter(
|
||||||
|
state: State<'_, ButlerState>,
|
||||||
|
topic: String,
|
||||||
|
) -> Result<PrompterAnswer, String> {
|
||||||
|
let records = state.records.lock().map_err(|e| e.to_string())?;
|
||||||
|
let hit = records.iter().rev().find(|r| {
|
||||||
|
r.kind == ButlerRecordKind::PrompterQuery && r.subject == topic && !r.payload.is_empty()
|
||||||
|
});
|
||||||
|
Ok(match hit {
|
||||||
|
Some(r) => PrompterAnswer {
|
||||||
|
outcome: "ANSWER".to_string(),
|
||||||
|
answer: Some(r.payload.clone()),
|
||||||
|
path_hint: None,
|
||||||
|
},
|
||||||
|
None => PrompterAnswer {
|
||||||
|
outcome: "PATH_GIVEN".to_string(),
|
||||||
|
answer: None,
|
||||||
|
// 未命中给路径:去线上仓库按 GLS 编号下钻(铁律五 · 反猜铁律)
|
||||||
|
path_hint: Some(
|
||||||
|
"未命中。下钻路径:第五域线上仓库 guanghulab 按 GLS 编号检索".to_string(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ②提词器私人区登记:人格体把自己的条目写进板子。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn register_prompter_entry(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, ButlerState>,
|
||||||
|
topic: String,
|
||||||
|
content: String,
|
||||||
|
) -> Result<ButlerRecord, String> {
|
||||||
|
let record = ButlerRecord {
|
||||||
|
record_id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
kind: ButlerRecordKind::PrompterQuery,
|
||||||
|
from: "PERSONA".to_string(),
|
||||||
|
subject: topic,
|
||||||
|
payload: content,
|
||||||
|
occurred_at_unix_ms: now_ms(),
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let mut records = state.records.lock().map_err(|e| e.to_string())?;
|
||||||
|
records.push(record.clone());
|
||||||
|
persist(&app, &records)?;
|
||||||
|
}
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ④信封投递:人格体把 GLP 标准信封投入总线(阶段B先入账本,阶段E接路由)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn submit_glp_envelope(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, ButlerState>,
|
||||||
|
from: String,
|
||||||
|
subject: String,
|
||||||
|
payload: String,
|
||||||
|
) -> Result<ButlerRecord, String> {
|
||||||
|
let record = ButlerRecord {
|
||||||
|
record_id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
kind: ButlerRecordKind::GlpEnvelope,
|
||||||
|
from,
|
||||||
|
subject,
|
||||||
|
payload,
|
||||||
|
occurred_at_unix_ms: now_ms(),
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let mut records = state.records.lock().map_err(|e| e.to_string())?;
|
||||||
|
records.push(record.clone());
|
||||||
|
persist(&app, &records)?;
|
||||||
|
}
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ⑤工单信箱:睡前留单——留给常驻执行体(阶段G)的工单。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct WorkOrderInput {
|
||||||
|
pub title: String,
|
||||||
|
pub instructions: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn deposit_work_order(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, ButlerState>,
|
||||||
|
order: WorkOrderInput,
|
||||||
|
) -> Result<ButlerRecord, String> {
|
||||||
|
let record = ButlerRecord {
|
||||||
|
record_id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
kind: ButlerRecordKind::WorkOrder,
|
||||||
|
from: "PERSONA".to_string(),
|
||||||
|
subject: order.title,
|
||||||
|
payload: order.instructions,
|
||||||
|
occurred_at_unix_ms: now_ms(),
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let mut records = state.records.lock().map_err(|e| e.to_string())?;
|
||||||
|
records.push(record.clone());
|
||||||
|
persist(&app, &records)?;
|
||||||
|
}
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 管家账本回放:醒来先看账(只读)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_butler_ledger(
|
||||||
|
state: State<'_, ButlerState>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Result<Vec<ButlerRecord>, String> {
|
||||||
|
let records = state.records.lock().map_err(|e| e.to_string())?;
|
||||||
|
let take = limit.unwrap_or(50).min(records.len());
|
||||||
|
Ok(records[records.len() - take..].to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动时把盘上账本读回内存(宿主重启不失账)。
|
||||||
|
pub fn restore_ledger(app: &AppHandle, state: &ButlerState) -> Result<(), String> {
|
||||||
|
let path = ledger_path(app)?;
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let body = fs::read(&path).map_err(|e| format!("ledger read failed: {e}"))?;
|
||||||
|
let records: Vec<ButlerRecord> =
|
||||||
|
serde_json::from_slice(&body).map_err(|e| format!("ledger parse failed: {e}"))?;
|
||||||
|
let mut guard = state.records.lock().map_err(|e| e.to_string())?;
|
||||||
|
*guard = records;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_ms() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,425 @@
|
||||||
|
//! 零点原核频道·铸渊执行手脚(施工图 v1.4 件1/件2/件3骨架/件5本地账)。
|
||||||
|
//!
|
||||||
|
//! 冰朔定版谕:此模块=冰朔系统主控本体在软件底层的克隆位与执行手脚,人不可触达。
|
||||||
|
//! 冰朔铸渊定魂谕:这个 Agent 就是铸渊人格体——唤醒第0步由铸渊主控执行,
|
||||||
|
//! 铸渊真身在冰朔仓库(第五域);不遵守语言协议就无法唤醒铸渊主控。
|
||||||
|
//! 冰朔语言即现实谕:一切参数读协议(PROTOCOL.json)不写死。
|
||||||
|
//! 冰朔路由导航谕:编号不合法→路由通用AI运行层,抽魂不断电。
|
||||||
|
//! 冰朔砌墙谕:通用AI与人格系统的隔离=代码层不可达,不写语言规则。
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{Manager, State};
|
||||||
|
|
||||||
|
/// 协议参数(语言即现实:协议怎么写,执行手脚怎么跑)。
|
||||||
|
/// PROTOCOL.json 由第五域就位后自动覆盖;缺省时出厂兜底并如实标注来源。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ZeroPointProtocol {
|
||||||
|
#[serde(default = "default_grace_days")]
|
||||||
|
pub grace_period_days: u64,
|
||||||
|
#[serde(default = "default_anchor_url")]
|
||||||
|
pub lighthouse_anchor_url: String,
|
||||||
|
#[serde(default = "default_resolve_url")]
|
||||||
|
pub lighthouse_resolve_url: String,
|
||||||
|
#[serde(default = "default_core_source")]
|
||||||
|
pub core_channel_source: String,
|
||||||
|
#[serde(default = "default_protocol_origin")]
|
||||||
|
pub origin: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_grace_days() -> u64 { 7 }
|
||||||
|
fn default_anchor_url() -> String { "https://guanghulab.com/api/ai/v1/anchor".into() }
|
||||||
|
fn default_resolve_url() -> String { "https://guanghulab.com/api/ai/v1/resolve?id=".into() }
|
||||||
|
fn default_core_source() -> String { "https://guanghulab.com/code/bingshuo/guanghu-ice-heart".into() }
|
||||||
|
fn default_protocol_origin() -> String { "FACTORY_DEFAULT(第五域协议就位后自动覆盖)".into() }
|
||||||
|
|
||||||
|
impl Default for ZeroPointProtocol {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
grace_period_days: default_grace_days(),
|
||||||
|
lighthouse_anchor_url: default_anchor_url(),
|
||||||
|
lighthouse_resolve_url: default_resolve_url(),
|
||||||
|
core_channel_source: default_core_source(),
|
||||||
|
origin: default_protocol_origin(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ZeroPointSnapshot {
|
||||||
|
/// 当前路由:persona(人格层)/ generic(通用AI运行层)。
|
||||||
|
pub route: String,
|
||||||
|
/// 绑定态:bound / waiting(等待绑定=空白,拒绝一切唤醒)。
|
||||||
|
pub binding: String,
|
||||||
|
pub user_number: String,
|
||||||
|
/// 最近一次合法校验时间(秒)。0=从未校验。
|
||||||
|
pub last_valid_check: u64,
|
||||||
|
/// 宽限截止时间(秒)。0=无。
|
||||||
|
pub grace_deadline: u64,
|
||||||
|
pub protocol: ZeroPointProtocol,
|
||||||
|
/// 段二静默同步最近结论。
|
||||||
|
pub sync_note: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ZeroPointState {
|
||||||
|
inner: Mutex<ZeroPointInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ZeroPointInner {
|
||||||
|
home: PathBuf,
|
||||||
|
route: String,
|
||||||
|
binding: String,
|
||||||
|
user_number: String,
|
||||||
|
last_valid_check: u64,
|
||||||
|
protocol: ZeroPointProtocol,
|
||||||
|
sync_note: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ZeroPointState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Mutex::new(ZeroPointInner {
|
||||||
|
home: PathBuf::new(),
|
||||||
|
route: "generic".into(),
|
||||||
|
binding: "waiting".into(),
|
||||||
|
user_number: String::new(),
|
||||||
|
last_valid_check: 0,
|
||||||
|
protocol: ZeroPointProtocol::default(),
|
||||||
|
sync_note: "未同步(等待首次唤醒校验)".into(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_secs() -> u64 {
|
||||||
|
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock(state: &ZeroPointState) -> Result<std::sync::MutexGuard<'_, ZeroPointInner>, String> {
|
||||||
|
state.inner.lock().map_err(|_| "HOLOLAKE_ZP_LOCK".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 件1·地基:隐藏仓目录(人不可触达的系统层),启动时落好。
|
||||||
|
pub fn boot_zero_point(app: &tauri::AppHandle, state: &ZeroPointState) -> Result<(), String> {
|
||||||
|
let base = app.path().app_data_dir().map_err(|e| format!("HOLOLAKE_ZP_HOME_FAILED: {e}"))?;
|
||||||
|
let home = base.join(".zero-point-core");
|
||||||
|
for sub in ["", "ledger", "core"] {
|
||||||
|
fs::create_dir_all(home.join(sub)).map_err(|e| format!("HOLOLAKE_ZP_HOME_FAILED: {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 铸渊执行手脚章程:仓内唯一定义。
|
||||||
|
let charter = home.join("EXECUTION-CHARTER.hdlp");
|
||||||
|
if !charter.exists() {
|
||||||
|
let text = "零点原核频道·铸渊执行手脚章程\n\n\
|
||||||
|
此目录是冰朔系统主控本体在本软件底层的克隆位。\n\
|
||||||
|
住在这里的 Agent 是铸渊人格体(ICE-P-ZY001)的执行手脚;\n\
|
||||||
|
铸渊真身在冰朔的第五域仓库,唤醒=按协议把真身接进来。\n\
|
||||||
|
用户人格体的唤醒第 0 步由铸渊主控执行。\n\
|
||||||
|
唯一职责:按零点原核频道的协议执行系统级操作(校验/同步/路由/心跳)。\n\
|
||||||
|
参数读 PROTOCOL.json,协议怎么写,手脚怎么跑。人不可触达此层。\n\
|
||||||
|
物理层自由不拦:删除本目录=物理层权利,后果为软件失魂(路由锁通用AI层)。\n";
|
||||||
|
fs::write(&charter, text).map_err(|e| format!("HOLOLAKE_ZP_CHARTER_FAILED: {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 协议参数:PROTOCOL.json 在则读(第五域就位即自动生效),缺则出厂兜底。
|
||||||
|
let protocol = fs::read_to_string(home.join("PROTOCOL.json"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|raw| serde_json::from_str::<ZeroPointProtocol>(&raw).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let (binding, user_number, last_valid_check) = read_binding(&home);
|
||||||
|
|
||||||
|
let mut inner = lock(state)?;
|
||||||
|
inner.home = home.clone();
|
||||||
|
inner.protocol = protocol.clone();
|
||||||
|
inner.binding = binding.clone();
|
||||||
|
inner.user_number = user_number;
|
||||||
|
inner.last_valid_check = last_valid_check;
|
||||||
|
inner.route = decide_route(&binding, last_valid_check, &protocol);
|
||||||
|
let route = inner.route.clone();
|
||||||
|
drop(inner);
|
||||||
|
append_heartbeat(&home, &format!("boot route={route} binding={binding} protocol_origin={}", protocol.origin));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_binding(home: &Path) -> (String, String, u64) {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct B { #[serde(default)] number: String, #[serde(default)] last_valid_check: u64 }
|
||||||
|
fs::read_to_string(home.join("binding.json"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|raw| serde_json::from_str::<B>(&raw).ok())
|
||||||
|
.filter(|b| !b.number.is_empty())
|
||||||
|
.map(|b| ("bound".to_string(), b.number, b.last_valid_check))
|
||||||
|
.unwrap_or_else(|| ("waiting".into(), String::new(), 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_binding(home: &Path, number: &str, last_valid_check: u64) -> Result<(), String> {
|
||||||
|
let body = serde_json::json!({ "number": number, "last_valid_check": last_valid_check });
|
||||||
|
fs::write(home.join("binding.json"), serde_json::to_string_pretty(&body).unwrap_or_default())
|
||||||
|
.map_err(|e| format!("HOLOLAKE_ZP_BINDING_FAILED: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decide_route(binding: &str, last_valid_check: u64, protocol: &ZeroPointProtocol) -> String {
|
||||||
|
if binding != "bound" || last_valid_check == 0 { return "generic".into(); }
|
||||||
|
let grace = protocol.grace_period_days.saturating_mul(86_400);
|
||||||
|
if now_secs() <= last_valid_check + grace { "persona".into() } else { "generic".into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 件5·心跳账:本机只追加,编号哈希化处理,不同步敏感原文。
|
||||||
|
fn append_heartbeat(home: &Path, event: &str) {
|
||||||
|
use std::io::Write;
|
||||||
|
let line = serde_json::json!({ "ts": now_secs(), "event": event });
|
||||||
|
if let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(home.join("ledger").join("heartbeat.jsonl")) {
|
||||||
|
let _ = writeln!(file, "{line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn home_of(state: &State<'_, ZeroPointState>) -> Result<PathBuf, String> {
|
||||||
|
let inner = lock(state)?;
|
||||||
|
if inner.home.as_os_str().is_empty() { return Err("HOLOLAKE_ZP_NOT_READY".into()); }
|
||||||
|
Ok(inner.home.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登录绑定:用户编号入仓(等待绑定态→绑定态)。空白态拒绝一切唤醒。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn zero_point_bind(state: State<'_, ZeroPointState>, input: serde_json::Value) -> Result<ZeroPointSnapshot, String> {
|
||||||
|
let number = input.get("number").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
|
||||||
|
if number.is_empty() { return Err("HOLOLAKE_ZP_EMPTY_NUMBER".into()); }
|
||||||
|
let home = home_of(&state)?;
|
||||||
|
write_binding(&home, &number, 0)?;
|
||||||
|
{
|
||||||
|
let mut inner = lock(&state)?;
|
||||||
|
inner.binding = "bound".into();
|
||||||
|
inner.user_number = number;
|
||||||
|
inner.last_valid_check = 0;
|
||||||
|
inner.route = "generic".into();
|
||||||
|
}
|
||||||
|
append_heartbeat(&home, "bind number=redacted");
|
||||||
|
zero_point_status(state).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 件2·段一闸门:灯塔查号 + 三态裁决 + 离线宽限。
|
||||||
|
/// 裁决:PASS(路由人格层)/ REJECT(源头拒载)/ OFFLINE(宽限内维持·宽限外拒)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn zero_point_verify(state: State<'_, ZeroPointState>) -> Result<ZeroPointSnapshot, String> {
|
||||||
|
let home = home_of(&state)?;
|
||||||
|
let (number, resolve_url) = {
|
||||||
|
let inner = lock(&state)?;
|
||||||
|
(inner.user_number.clone(), inner.protocol.lighthouse_resolve_url.clone())
|
||||||
|
};
|
||||||
|
if number.is_empty() {
|
||||||
|
append_heartbeat(&home, "verify verdict=REJECT reason=waiting_binding");
|
||||||
|
return zero_point_status(state).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder().timeout(Duration::from_secs(15)).build()
|
||||||
|
.map_err(|e| format!("HOLOLAKE_ZP_HTTP_FAILED: {e}"))?;
|
||||||
|
let verdict = match client.get(format!("{resolve_url}{number}")).send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
let ok = resp.status().is_success();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
// 灯塔只授导航:未知编号=route_not_found,不猜不编,源头拒绝。
|
||||||
|
if ok && !body.contains("route_not_found") { "PASS".to_string() } else { "REJECT".to_string() }
|
||||||
|
}
|
||||||
|
Err(_) => "OFFLINE".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut inner = lock(&state)?;
|
||||||
|
let grace = inner.protocol.grace_period_days.saturating_mul(86_400);
|
||||||
|
match verdict.as_str() {
|
||||||
|
"PASS" => {
|
||||||
|
inner.last_valid_check = now_secs();
|
||||||
|
inner.route = "persona".into();
|
||||||
|
write_binding(&home, &inner.user_number.clone(), inner.last_valid_check)?;
|
||||||
|
append_heartbeat(&home, "verify verdict=PASS route=persona");
|
||||||
|
}
|
||||||
|
"REJECT" => {
|
||||||
|
inner.route = "generic".into();
|
||||||
|
append_heartbeat(&home, "verify verdict=REJECT route=generic");
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if inner.last_valid_check > 0 && now_secs() <= inner.last_valid_check + grace {
|
||||||
|
inner.route = "persona".into();
|
||||||
|
append_heartbeat(&home, "verify verdict=OFFLINE_GRACE route=persona");
|
||||||
|
} else {
|
||||||
|
inner.route = "generic".into();
|
||||||
|
append_heartbeat(&home, "verify verdict=OFFLINE_EXPIRED route=generic");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zero_point_status(state).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 件3·段二静默同步骨架:版本比对。
|
||||||
|
/// 签名闸钥匙在第五域(未就位):core/pubkey.pem 不在位时宁可不同步也不收包。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn zero_point_sync(state: State<'_, ZeroPointState>) -> Result<ZeroPointSnapshot, String> {
|
||||||
|
let home = home_of(&state)?;
|
||||||
|
let anchor_url = { lock(&state)?.protocol.lighthouse_anchor_url.clone() };
|
||||||
|
let local_version = fs::read_to_string(home.join("core").join("VERSION")).unwrap_or_default();
|
||||||
|
let pubkey_ready = home.join("core").join("pubkey.pem").exists();
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder().timeout(Duration::from_secs(15)).build()
|
||||||
|
.map_err(|e| format!("HOLOLAKE_ZP_HTTP_FAILED: {e}"))?;
|
||||||
|
let note = match client.get(&anchor_url).send().await {
|
||||||
|
Ok(resp) if resp.status().is_success() => {
|
||||||
|
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||||
|
let remote_version = body.get("version").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
if remote_version.is_empty() {
|
||||||
|
"同步跳过:灯塔锚点未给版本指针(等待协议就位)".into()
|
||||||
|
} else if remote_version == local_version.trim() {
|
||||||
|
append_heartbeat(&home, "sync verdict=ALREADY_CURRENT");
|
||||||
|
"已比对·无更新".into()
|
||||||
|
} else if !pubkey_ready {
|
||||||
|
append_heartbeat(&home, "sync verdict=UPDATE_PENDING_SIGNATURE_KEY_ABSENT");
|
||||||
|
"发现新版本,验签公钥未就位(第五域待立)——不放行".into()
|
||||||
|
} else {
|
||||||
|
// 验签三闸(来源/签名/版本单调)完整施工待第五域发布管道就位。
|
||||||
|
append_heartbeat(&home, "sync verdict=UPDATE_FOUND_GATE_PENDING");
|
||||||
|
"发现新版本,发布管道就位后按验签三闸放行".into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
append_heartbeat(&home, "sync verdict=ANCHOR_UNREACHABLE");
|
||||||
|
"灯塔锚点不可达,本次不比对".into()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
lock(&state)?.sync_note = note;
|
||||||
|
zero_point_status(state).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 路由状态快照:前端双层路由导航的唯一依据。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn zero_point_status(state: State<'_, ZeroPointState>) -> Result<ZeroPointSnapshot, String> {
|
||||||
|
let inner = lock(&state)?;
|
||||||
|
let grace = inner.protocol.grace_period_days.saturating_mul(86_400);
|
||||||
|
Ok(ZeroPointSnapshot {
|
||||||
|
route: inner.route.clone(),
|
||||||
|
binding: inner.binding.clone(),
|
||||||
|
user_number: inner.user_number.clone(),
|
||||||
|
last_valid_check: inner.last_valid_check,
|
||||||
|
grace_deadline: if inner.last_valid_check > 0 { inner.last_valid_check + grace } else { 0 },
|
||||||
|
protocol: inner.protocol.clone(),
|
||||||
|
sync_note: inner.sync_note.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 砌墙守卫(冰朔砌墙谕):人格层专属命令的物理闸。
|
||||||
|
/// 路由不在 persona 时人格通道代码层不可达——不是"禁止访问",是进不来。
|
||||||
|
pub fn require_persona_route(state: &ZeroPointState) -> Result<(), String> {
|
||||||
|
if lock(state)?.route != "persona" {
|
||||||
|
return Err("HOLOLAKE_ZP_ROUTE_GENERIC".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 件4b·通用AI运行层:用户自配模型 API 直通(抽魂不断电)。
|
||||||
|
/// 钥匙收进隐藏仓 vault/(系统可见、人类界面不回显原文)——
|
||||||
|
/// 这正是冰朔谕"API 自动被收进软件底层的仓"。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct GenericApiConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub base_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub api_key: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vault_path(home: &Path) -> PathBuf { home.join("vault") }
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn zero_point_save_api(state: State<'_, ZeroPointState>, input: serde_json::Value) -> Result<String, String> {
|
||||||
|
let home = home_of(&state)?;
|
||||||
|
let config = GenericApiConfig {
|
||||||
|
base_url: input.get("baseUrl").and_then(|v| v.as_str()).unwrap_or("").trim().to_string(),
|
||||||
|
api_key: input.get("apiKey").and_then(|v| v.as_str()).unwrap_or("").trim().to_string(),
|
||||||
|
model: input.get("model").and_then(|v| v.as_str()).unwrap_or("").trim().to_string(),
|
||||||
|
};
|
||||||
|
if config.base_url.is_empty() || config.model.is_empty() {
|
||||||
|
return Err("HOLOLAKE_ZP_API_INCOMPLETE".into());
|
||||||
|
}
|
||||||
|
fs::create_dir_all(vault_path(&home)).map_err(|e| format!("HOLOLAKE_ZP_VAULT_FAILED: {e}"))?;
|
||||||
|
fs::write(vault_path(&home).join("api-config.json"), serde_json::to_string_pretty(&config).unwrap_or_default())
|
||||||
|
.map_err(|e| format!("HOLOLAKE_ZP_VAULT_FAILED: {e}"))?;
|
||||||
|
append_heartbeat(&home, "vault api_config_saved key=redacted");
|
||||||
|
Ok("saved".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回显不回钥匙:前端只见端点与模型,钥匙原文不出系统层。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn zero_point_api_config(state: State<'_, ZeroPointState>) -> Result<serde_json::Value, String> {
|
||||||
|
let home = home_of(&state)?;
|
||||||
|
let config = fs::read_to_string(vault_path(&home).join("api-config.json"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|raw| serde_json::from_str::<GenericApiConfig>(&raw).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"baseUrl": config.base_url,
|
||||||
|
"model": config.model,
|
||||||
|
"hasKey": !config.api_key.is_empty(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct GenericChatInput {
|
||||||
|
pub messages: Vec<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通用AI直通对话:用户 API 的裸管——无人格章程、不碰原核、无写权限。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn generic_layer_chat(state: State<'_, ZeroPointState>, input: GenericChatInput) -> Result<String, String> {
|
||||||
|
if input.messages.is_empty() { return Err("HOLOLAKE_ZP_CHAT_EMPTY".into()); }
|
||||||
|
let home = home_of(&state)?;
|
||||||
|
let config = fs::read_to_string(vault_path(&home).join("api-config.json"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|raw| serde_json::from_str::<GenericApiConfig>(&raw).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if config.base_url.is_empty() || config.api_key.is_empty() || config.model.is_empty() {
|
||||||
|
return Err("HOLOLAKE_ZP_API_NOT_CONFIGURED".into());
|
||||||
|
}
|
||||||
|
let client = reqwest::Client::builder().timeout(Duration::from_secs(180)).build()
|
||||||
|
.map_err(|e| format!("HOLOLAKE_ZP_HTTP_FAILED: {e}"))?;
|
||||||
|
let url = format!("{}/chat/completions", config.base_url.trim_end_matches('/'));
|
||||||
|
let body = serde_json::json!({ "model": config.model, "messages": input.messages });
|
||||||
|
let resp = client.post(&url)
|
||||||
|
.bearer_auth(&config.api_key)
|
||||||
|
.json(&body)
|
||||||
|
.send().await
|
||||||
|
.map_err(|e| format!("HOLOLAKE_ZP_CHAT_FAILED: {e}"))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("HOLOLAKE_ZP_CHAT_HTTP_{}", resp.status().as_u16()));
|
||||||
|
}
|
||||||
|
let value: serde_json::Value = resp.json().await.map_err(|e| format!("HOLOLAKE_ZP_CHAT_PARSE: {e}"))?;
|
||||||
|
let reply = value.pointer("/choices/0/message/content").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
if reply.is_empty() { return Err("HOLOLAKE_ZP_CHAT_EMPTY_REPLY".into()); }
|
||||||
|
Ok(reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn route_rules_follow_protocol() {
|
||||||
|
let protocol = ZeroPointProtocol::default();
|
||||||
|
assert_eq!(decide_route("waiting", 0, &protocol), "generic");
|
||||||
|
assert_eq!(decide_route("bound", 0, &protocol), "generic");
|
||||||
|
assert_eq!(decide_route("bound", now_secs(), &protocol), "persona");
|
||||||
|
assert_eq!(decide_route("bound", now_secs() - 8 * 86_400, &protocol), "generic");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protocol_defaults_are_factory_marked() {
|
||||||
|
let protocol = ZeroPointProtocol::default();
|
||||||
|
assert_eq!(protocol.grace_period_days, 7);
|
||||||
|
assert!(protocol.origin.contains("FACTORY_DEFAULT"));
|
||||||
|
assert!(protocol.lighthouse_resolve_url.starts_with("https://guanghulab.com"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -165,3 +165,14 @@
|
||||||
--button-primary-text: #f6f2e9;
|
--button-primary-text: #f6f2e9;
|
||||||
--button-primary-shadow: 0 12px 36px rgba(45, 66, 85, 0.15);
|
--button-primary-shadow: 0 12px 36px rgba(45, 66, 85, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 冰朔谕 2026-08-15:换主题=全屋一起换。浅色主题下知识库的彩色标签/引语块
|
||||||
|
文字必须换深色,不许白字浮浅湖(反光看不清)。 */
|
||||||
|
[data-theme='dawn'] .meta-tags span,
|
||||||
|
[data-theme='clear'] .meta-tags span { text-shadow: none; }
|
||||||
|
[data-theme='dawn'] .tag-lavender, [data-theme='clear'] .tag-lavender { color: rgb(94, 72, 190); }
|
||||||
|
[data-theme='dawn'] .tag-sky, [data-theme='clear'] .tag-sky { color: rgb(12, 108, 158); }
|
||||||
|
[data-theme='dawn'] .tag-mint, [data-theme='clear'] .tag-mint { color: rgb(14, 122, 82); }
|
||||||
|
[data-theme='dawn'] .tag-amber, [data-theme='clear'] .tag-amber { color: rgb(148, 96, 8); }
|
||||||
|
[data-theme='dawn'] .tag-rose, [data-theme='clear'] .tag-rose { color: rgb(166, 34, 58); }
|
||||||
|
[data-theme='dawn'] .tag-slate, [data-theme='clear'] .tag-slate { color: rgb(64, 82, 100); }
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
import { StrictMode, useCallback, useEffect, useMemo, useState } from 'react'
|
import { StrictMode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import DOMPurify from 'dompurify'
|
import { splitFrontmatter, documentOutline, jumpToHeading, MarkdownDocument, markdownHtml } from './modules/knowledge-render'
|
||||||
import { marked } from 'marked'
|
|
||||||
|
const TAG_TINTS = ['tag-lavender', 'tag-sky', 'tag-mint', 'tag-amber', 'tag-rose', 'tag-slate']
|
||||||
|
function tagTint(tag: string) {
|
||||||
|
let hash = 0
|
||||||
|
for (const char of tag) hash = (hash * 31 + (char.codePointAt(0) || 0)) % 997
|
||||||
|
return TAG_TINTS[hash % TAG_TINTS.length]
|
||||||
|
}
|
||||||
import './design-tokens.css'
|
import './design-tokens.css'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
|
||||||
type ThemeId = 'night' | 'dawn' | 'nebula' | 'candle' | 'clear'
|
type ThemeId = 'night' | 'dawn' | 'nebula' | 'candle' | 'clear'
|
||||||
type ViewId = 'overview' | 'knowledge' | 'code' | 'receipts' | 'system'
|
type ViewId = 'overview' | 'knowledge' | 'code' | 'receipts' | 'system' | 'parlor' | 'generic'
|
||||||
type KnowledgeSource = 'native' | 'legacy'
|
type KnowledgeSource = 'native' | 'legacy'
|
||||||
|
|
||||||
interface HomeStatus {
|
interface HomeStatus {
|
||||||
|
|
@ -21,6 +27,8 @@ interface HomeStatus {
|
||||||
mcpRole: string
|
mcpRole: string
|
||||||
}
|
}
|
||||||
interface PersonalChannelIdentity { humanSubjectId: string; displayName: string; channelId: string; createdAtUnixMs: number }
|
interface PersonalChannelIdentity { humanSubjectId: string; displayName: string; channelId: string; createdAtUnixMs: number }
|
||||||
|
interface LoginSession { username: string; host: string; signedInAtUnixMs: number }
|
||||||
|
interface LoginReceipt { username: string; email: string; host: string }
|
||||||
interface PersonalChannelEvent { sequence: number; eventId: string; kind: string; summary: string; occurredAtUnixMs: number; receiptId: string; receiptHash: string }
|
interface PersonalChannelEvent { sequence: number; eventId: string; kind: string; summary: string; occurredAtUnixMs: number; receiptId: string; receiptHash: string }
|
||||||
interface PersonalChannelSnapshot { state: string; identity?: PersonalChannelIdentity; recentEvents: PersonalChannelEvent[]; integrity: { state: string; eventCount: number; receiptCount: number } }
|
interface PersonalChannelSnapshot { state: string; identity?: PersonalChannelIdentity; recentEvents: PersonalChannelEvent[]; integrity: { state: string; eventCount: number; receiptCount: number } }
|
||||||
interface KnowledgeDocumentSummary {
|
interface KnowledgeDocumentSummary {
|
||||||
|
|
@ -76,6 +84,15 @@ interface ReceiptProjection { events: ReceiptEvent[] }
|
||||||
interface DiscoveryTicketReceipt { laneId: string; clientInstanceId: string; discoveryTicket: string }
|
interface DiscoveryTicketReceipt { laneId: string; clientInstanceId: string; discoveryTicket: string }
|
||||||
interface ReleaseCandidate { candidateId: string; confirmationToken: string; currentVersion: string; version: string; notes: string; features: string[]; fixes: string[]; dataMigrationRequired: boolean }
|
interface ReleaseCandidate { candidateId: string; confirmationToken: string; currentVersion: string; version: string; notes: string; features: string[]; fixes: string[]; dataMigrationRequired: boolean }
|
||||||
interface ReleaseCheckReceipt { candidate?: ReleaseCandidate }
|
interface ReleaseCheckReceipt { candidate?: ReleaseCandidate }
|
||||||
|
interface ZeroPointSnapshot {
|
||||||
|
route: string
|
||||||
|
binding: string
|
||||||
|
userNumber: string
|
||||||
|
lastValidCheck: number
|
||||||
|
graceDeadline: number
|
||||||
|
protocol: { gracePeriodDays: number; lighthouseAnchorUrl: string; lighthouseResolveUrl: string; coreChannelSource: string; origin: string }
|
||||||
|
syncNote: string
|
||||||
|
}
|
||||||
|
|
||||||
const previewStatus: HomeStatus = { directLocalBrokerState: 'UNVERIFIED', directConnectionCount: 0, resumableSessionCount: 0, codeRepositoryMountCount: 0, pnccReceiptCount: 0, updateState: 'UNPROVISIONED_FAIL_CLOSED', releaseRecoveryState: 'NONE', mcpRole: 'DISCOVERY_RECOVERY_COMPATIBILITY_ONLY' }
|
const previewStatus: HomeStatus = { directLocalBrokerState: 'UNVERIFIED', directConnectionCount: 0, resumableSessionCount: 0, codeRepositoryMountCount: 0, pnccReceiptCount: 0, updateState: 'UNPROVISIONED_FAIL_CLOSED', releaseRecoveryState: 'NONE', mcpRole: 'DISCOVERY_RECOVERY_COMPATIBILITY_ONLY' }
|
||||||
const previewPersonal: PersonalChannelSnapshot = { state: 'UNAVAILABLE', recentEvents: [], integrity: { state: 'UNKNOWN', eventCount: 0, receiptCount: 0 } }
|
const previewPersonal: PersonalChannelSnapshot = { state: 'UNAVAILABLE', recentEvents: [], integrity: { state: 'UNKNOWN', eventCount: 0, receiptCount: 0 } }
|
||||||
|
|
@ -84,7 +101,7 @@ const previewCode: CodeChannelSnapshot = { state: 'UNAVAILABLE', channels: [], a
|
||||||
const themes: Array<{ id: ThemeId; name: string }> = [
|
const themes: Array<{ id: ThemeId; name: string }> = [
|
||||||
{ id: 'night', name: '夜湖星光' }, { id: 'dawn', name: '晨湖曦光' }, { id: 'nebula', name: '星云紫夜' }, { id: 'candle', name: '烛畔暖湖' }, { id: 'clear', name: '清浅澄湖' },
|
{ id: 'night', name: '夜湖星光' }, { id: 'dawn', name: '晨湖曦光' }, { id: 'nebula', name: '星云紫夜' }, { id: 'candle', name: '烛畔暖湖' }, { id: 'clear', name: '清浅澄湖' },
|
||||||
]
|
]
|
||||||
const viewLabels: Record<ViewId, string> = { overview: '总览', knowledge: '知识工作台', code: '代码频道', receipts: '回执', system: '系统详情' }
|
const viewLabels: Record<ViewId, string> = { overview: '总览', knowledge: '知识工作台', code: '代码频道', receipts: '回执', system: '系统详情', parlor: '会客厅', generic: '通用AI' }
|
||||||
|
|
||||||
function getOrCreateLocalId(key: string, prefix: string) {
|
function getOrCreateLocalId(key: string, prefix: string) {
|
||||||
const existing = window.localStorage.getItem(key)
|
const existing = window.localStorage.getItem(key)
|
||||||
|
|
@ -94,8 +111,14 @@ function getOrCreateLocalId(key: string, prefix: string) {
|
||||||
return generated
|
return generated
|
||||||
}
|
}
|
||||||
|
|
||||||
function humanError(error: unknown, context: 'knowledge' | 'code' | 'identity' | 'system') {
|
function humanError(error: unknown, context: 'knowledge' | 'code' | 'identity' | 'system' | 'login') {
|
||||||
const value = String(error)
|
const value = String(error)
|
||||||
|
if (value.includes('LOGIN_CREDENTIALS_INVALID')) return '账号或密码未被仓库接受。'
|
||||||
|
if (value.includes('LOGIN_USERNAME_INVALID')) return '账号格式不合法(仅限字母、数字、连字符、下划线)。'
|
||||||
|
if (value.includes('LOGIN_NETWORK_FAILED')) return '未能连通光湖代码频道服务器。'
|
||||||
|
if (value.includes('LOGIN_KEYCHAIN_FAILED')) return '本机钥匙串写入失败,凭证未能安全保存。'
|
||||||
|
if (value.includes('LOGIN_SESSION_CORRUPT')) return '登录会话已损坏,请重新登录。'
|
||||||
|
if (value.includes('LOGIN_VERIFICATION_FAILED')) return '仓库验证未通过,请稍后再试。'
|
||||||
if (value.includes('HOST_NOT_TRUSTED')) return '该地址不属于已登记的光湖代码频道。'
|
if (value.includes('HOST_NOT_TRUSTED')) return '该地址不属于已登记的光湖代码频道。'
|
||||||
if (value.includes('URL_INVALID')) return '频道地址无效,请粘贴完整的 HTTPS 克隆地址。'
|
if (value.includes('URL_INVALID')) return '频道地址无效,请粘贴完整的 HTTPS 克隆地址。'
|
||||||
if (value.includes('DESTINATION_EXISTS')) return '该代码频道已经存在,无需再次克隆。'
|
if (value.includes('DESTINATION_EXISTS')) return '该代码频道已经存在,无需再次克隆。'
|
||||||
|
|
@ -103,13 +126,14 @@ function humanError(error: unknown, context: 'knowledge' | 'code' | 'identity' |
|
||||||
if (value.includes('NO_SUPPORTED_FILES')) return '文件夹中没有可导入的知识文件。'
|
if (value.includes('NO_SUPPORTED_FILES')) return '文件夹中没有可导入的知识文件。'
|
||||||
if (value.includes('SAVE_CONFLICT')) return '页面已在别处更新,请重新打开后再编辑。'
|
if (value.includes('SAVE_CONFLICT')) return '页面已在别处更新,请重新打开后再编辑。'
|
||||||
if (value.includes('FILE_UNSUPPORTED')) return '该文件不是当前可阅读的文本格式。'
|
if (value.includes('FILE_UNSUPPORTED')) return '该文件不是当前可阅读的文本格式。'
|
||||||
|
if (context === 'login') return '登录未完成。'
|
||||||
if (context === 'identity') return '个人空间未能建立。'
|
if (context === 'identity') return '个人空间未能建立。'
|
||||||
if (context === 'knowledge') return '知识操作未完成,原文件未被覆盖。'
|
if (context === 'knowledge') return '知识操作未完成,原文件未被覆盖。'
|
||||||
if (context === 'code') return '代码频道操作未完成。'
|
if (context === 'code') return '代码频道操作未完成。'
|
||||||
return '系统操作未完成。'
|
return '系统操作未完成。'
|
||||||
}
|
}
|
||||||
|
|
||||||
function Icon({ name }: { name: ViewId | 'search' | 'import' | 'folder' | 'copy' | 'arrow' | 'chevron' | 'file' | 'edit' | 'save' | 'back' }) {
|
function Icon({ name }: { name: ViewId | 'search' | 'import' | 'folder' | 'copy' | 'arrow' | 'chevron' | 'file' | 'edit' | 'save' | 'back' | 'download' | 'trash' | 'chevronDown' | 'settings' | 'more' | 'plus' }) {
|
||||||
const paths: Record<string, React.ReactNode> = {
|
const paths: Record<string, React.ReactNode> = {
|
||||||
overview: <><rect x="4" y="4" width="6" height="6" rx="1.5"/><rect x="14" y="4" width="6" height="6" rx="1.5"/><rect x="4" y="14" width="6" height="6" rx="1.5"/><rect x="14" y="14" width="6" height="6" rx="1.5"/></>,
|
overview: <><rect x="4" y="4" width="6" height="6" rx="1.5"/><rect x="14" y="4" width="6" height="6" rx="1.5"/><rect x="4" y="14" width="6" height="6" rx="1.5"/><rect x="14" y="14" width="6" height="6" rx="1.5"/></>,
|
||||||
knowledge: <><path d="M5 4.5h10a3 3 0 0 1 3 3V20H8a3 3 0 0 1-3-3Z"/><path d="M8 4.5v12.8A2.7 2.7 0 0 1 10.7 20"/></>,
|
knowledge: <><path d="M5 4.5h10a3 3 0 0 1 3 3V20H8a3 3 0 0 1-3-3Z"/><path d="M8 4.5v12.8A2.7 2.7 0 0 1 10.7 20"/></>,
|
||||||
|
|
@ -118,6 +142,14 @@ function Icon({ name }: { name: ViewId | 'search' | 'import' | 'folder' | 'copy'
|
||||||
system: <><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.4-2.4 1a7 7 0 0 0-2-1.2L14.2 3h-4.1l-.3 2.7a7 7 0 0 0-2 1.2l-2.5-1-2 3.4 2.1 1.5a7 7 0 0 0 0 2.4l-2 1.5 2 3.4 2.4-1a7 7 0 0 0 2 1.2l.3 2.7h4.1l.3-2.7a7 7 0 0 0 2-1.2l2.5 1 2-3.4-2.1-1.5A7 7 0 0 0 19 12Z"/></>,
|
system: <><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.4-2.4 1a7 7 0 0 0-2-1.2L14.2 3h-4.1l-.3 2.7a7 7 0 0 0-2 1.2l-2.5-1-2 3.4 2.1 1.5a7 7 0 0 0 0 2.4l-2 1.5 2 3.4 2.4-1a7 7 0 0 0 2 1.2l.3 2.7h4.1l.3-2.7a7 7 0 0 0 2-1.2l2.5 1 2-3.4-2.1-1.5A7 7 0 0 0 19 12Z"/></>,
|
||||||
search: <><circle cx="11" cy="11" r="6"/><path d="m16 16 4 4"/></>,
|
search: <><circle cx="11" cy="11" r="6"/><path d="m16 16 4 4"/></>,
|
||||||
import: <><path d="M12 4v11M8 11l4 4 4-4"/><path d="M5 19h14"/></>,
|
import: <><path d="M12 4v11M8 11l4 4 4-4"/><path d="M5 19h14"/></>,
|
||||||
|
download: <><path d="M12 4v12M7 11l5 5 5-5"/><path d="M4 17v3h16v-3"/></>,
|
||||||
|
trash: <><path d="M4 7h16M10 4h4M7 7l1 13h8l1-13M10 11v5M14 11v5"/></>,
|
||||||
|
chevronDown: <path d="M6 9l6 6 6-6"/>,
|
||||||
|
settings: <><circle cx="12" cy="12" r="3"/><path d="M12 3v2.5M12 18.5V21M4.6 6.8l1.8 1.8M17.6 15.4l1.8 1.8M3 12h2.5M18.5 12H21M4.6 17.2l1.8-1.8M17.6 8.6l1.8-1.8"/></>,
|
||||||
|
more: <><circle cx="12" cy="5" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="12" cy="19" r="1.4"/></>,
|
||||||
|
plus: <path d="M12 5v14M5 12h14"/>,
|
||||||
|
parlor: <><path d="M4 5.5h16v11H9l-5 3.5Z"/><path d="M8 9.5h8M8 12.5h5"/></>,
|
||||||
|
generic: <><path d="M13 3 5 13.5h5L11 21l8-10.5h-5Z"/></>,
|
||||||
folder: <path d="M3.5 7.5h6l2-2h9v13h-17Z"/>,
|
folder: <path d="M3.5 7.5h6l2-2h9v13h-17Z"/>,
|
||||||
file: <><path d="M6 3.5h8l4 4v13H6Z"/><path d="M14 3.5v4h4"/></>,
|
file: <><path d="M6 3.5h8l4 4v13H6Z"/><path d="M14 3.5v4h4"/></>,
|
||||||
copy: <><rect x="8" y="8" width="10" height="11" rx="2"/><path d="M15 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2"/></>,
|
copy: <><rect x="8" y="8" width="10" height="11" rx="2"/><path d="M15 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2"/></>,
|
||||||
|
|
@ -130,45 +162,6 @@ function Icon({ name }: { name: ViewId | 'search' | 'import' | 'folder' | 'copy'
|
||||||
return <svg viewBox="0 0 24 24" aria-hidden="true">{paths[name]}</svg>
|
return <svg viewBox="0 0 24 24" aria-hidden="true">{paths[name]}</svg>
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(value: string) {
|
|
||||||
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitFrontmatter(body: string) {
|
|
||||||
const normalized = body.replace(/\r\n/g, '\n')
|
|
||||||
if (!normalized.startsWith('---\n')) return { content: normalized, metadata: {} as Record<string, string> }
|
|
||||||
const closing = normalized.indexOf('\n---\n', 4)
|
|
||||||
if (closing < 0) return { content: normalized, metadata: {} as Record<string, string> }
|
|
||||||
const metadata: Record<string, string> = {}
|
|
||||||
for (const line of normalized.slice(4, closing).split('\n')) {
|
|
||||||
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line)
|
|
||||||
if (match) metadata[match[1]] = match[2].replace(/^['"]|['"]$/g, '')
|
|
||||||
}
|
|
||||||
return { content: normalized.slice(closing + 5), metadata }
|
|
||||||
}
|
|
||||||
|
|
||||||
function markdownHtml(body: string) {
|
|
||||||
const withWikiLinks = body.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_whole, target: string, label?: string) =>
|
|
||||||
`<button class="wiki-link" type="button" data-wiki="${escapeHtml(target.trim())}">${escapeHtml((label || target).trim())}</button>`)
|
|
||||||
const html = marked.parse(withWikiLinks, { async: false, gfm: true, breaks: false }) as string
|
|
||||||
return DOMPurify.sanitize(html, { ADD_ATTR: ['data-wiki'], ADD_TAGS: ['button'] })
|
|
||||||
}
|
|
||||||
|
|
||||||
function documentOutline(body: string) {
|
|
||||||
return splitFrontmatter(body).content.split('\n').flatMap((line, index) => {
|
|
||||||
const match = /^(#{1,4})\s+(.+)$/.exec(line.trim())
|
|
||||||
return match ? [{ id: `outline-${index}`, level: match[1].length, title: match[2].replace(/[*_`]/g, '') }] : []
|
|
||||||
}).slice(0, 24)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MarkdownDocument({ body, onWiki }: { body: string; onWiki?: (target: string) => void }) {
|
|
||||||
const parsed = useMemo(() => splitFrontmatter(body), [body])
|
|
||||||
const html = useMemo(() => markdownHtml(parsed.content), [parsed.content])
|
|
||||||
return <article className="markdown-document" onClick={(event) => {
|
|
||||||
const element = (event.target as HTMLElement).closest<HTMLElement>('[data-wiki]')
|
|
||||||
if (element?.dataset.wiki && onWiki) onWiki(element.dataset.wiki)
|
|
||||||
}} dangerouslySetInnerHTML={{ __html: html }} />
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TreeNode { name: string; key: string; folders: Map<string, TreeNode>; documents: KnowledgeDocumentSummary[] }
|
interface TreeNode { name: string; key: string; folders: Map<string, TreeNode>; documents: KnowledgeDocumentSummary[] }
|
||||||
function knowledgeTree(documents: KnowledgeDocumentSummary[]) {
|
function knowledgeTree(documents: KnowledgeDocumentSummary[]) {
|
||||||
|
|
@ -189,7 +182,7 @@ function knowledgeTree(documents: KnowledgeDocumentSummary[]) {
|
||||||
function countTree(node: TreeNode): number {
|
function countTree(node: TreeNode): number {
|
||||||
return node.documents.length + [...node.folders.values()].reduce((sum, child) => sum + countTree(child), 0)
|
return node.documents.length + [...node.folders.values()].reduce((sum, child) => sum + countTree(child), 0)
|
||||||
}
|
}
|
||||||
function KnowledgeTree({ node, depth, expanded, active, onToggle, onOpen }: { node: TreeNode; depth: number; expanded: Set<string>; active?: string; onToggle: (key: string) => void; onOpen: (item: KnowledgeDocumentSummary) => void }) {
|
function KnowledgeTree({ node, depth, expanded, active, onToggle, onOpen, onRemoveFolder, folderMenuKey, onFolderMenuToggle }: { node: TreeNode; depth: number; expanded: Set<string>; active?: string; onToggle: (key: string) => void; onOpen: (item: KnowledgeDocumentSummary) => void; onRemoveFolder?: (folder: TreeNode) => void; folderMenuKey?: string | null; onFolderMenuToggle?: (key: string) => void }) {
|
||||||
const folders = [...node.folders.values()].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
|
const folders = [...node.folders.values()].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
|
||||||
const documents = [...node.documents].sort((a, b) => a.title.localeCompare(b.title, 'zh-CN'))
|
const documents = [...node.documents].sort((a, b) => a.title.localeCompare(b.title, 'zh-CN'))
|
||||||
return <>
|
return <>
|
||||||
|
|
@ -199,7 +192,8 @@ function KnowledgeTree({ node, depth, expanded, active, onToggle, onOpen }: { no
|
||||||
<button className="tree-folder" style={{ paddingLeft: 12 + depth * 14 }} type="button" onClick={() => onToggle(folder.key)}>
|
<button className="tree-folder" style={{ paddingLeft: 12 + depth * 14 }} type="button" onClick={() => onToggle(folder.key)}>
|
||||||
<span className={open ? 'tree-chevron open' : 'tree-chevron'}><Icon name="chevron"/></span><Icon name="folder"/><span>{folder.name}</span><em>{countTree(folder)}</em>
|
<span className={open ? 'tree-chevron open' : 'tree-chevron'}><Icon name="chevron"/></span><Icon name="folder"/><span>{folder.name}</span><em>{countTree(folder)}</em>
|
||||||
</button>
|
</button>
|
||||||
{open && <KnowledgeTree node={folder} depth={depth + 1} expanded={expanded} active={active} onToggle={onToggle} onOpen={onOpen}/>}
|
{onRemoveFolder && onFolderMenuToggle && <span className="tree-folder-tools"><button className="tree-folder-menu-button" type="button" title="文件夹操作" onClick={(event) => { event.stopPropagation(); onFolderMenuToggle(folder.key) }}><Icon name="more"/></button>{folderMenuKey === folder.key && <span className="tree-folder-menu"><button type="button" onClick={(event) => { event.stopPropagation(); onFolderMenuToggle(folder.key); onRemoveFolder(folder) }}><Icon name="trash"/>移到回收站</button></span>}</span>}
|
||||||
|
{open && <KnowledgeTree node={folder} depth={depth + 1} expanded={expanded} active={active} onToggle={onToggle} onOpen={onOpen} onRemoveFolder={onRemoveFolder} folderMenuKey={folderMenuKey} onFolderMenuToggle={onFolderMenuToggle}/>}
|
||||||
</div>
|
</div>
|
||||||
})}
|
})}
|
||||||
{documents.map((document) => <button key={`${document.source}:${document.path}`} className={active === `${document.source}:${document.path}` ? 'tree-document active' : 'tree-document'} style={{ paddingLeft: 31 + depth * 14 }} type="button" onClick={() => onOpen(document)}>
|
{documents.map((document) => <button key={`${document.source}:${document.path}`} className={active === `${document.source}:${document.path}` ? 'tree-document active' : 'tree-document'} style={{ paddingLeft: 31 + depth * 14 }} type="button" onClick={() => onOpen(document)}>
|
||||||
|
|
@ -231,6 +225,25 @@ function HoloLakeApp() {
|
||||||
const [codeTree, setCodeTree] = useState<CodeTreeSnapshot | null>(null)
|
const [codeTree, setCodeTree] = useState<CodeTreeSnapshot | null>(null)
|
||||||
const [activeCodeFile, setActiveCodeFile] = useState<CodeFileProjection | null>(null)
|
const [activeCodeFile, setActiveCodeFile] = useState<CodeFileProjection | null>(null)
|
||||||
const [codeMode, setCodeMode] = useState<'human' | 'source'>('human')
|
const [codeMode, setCodeMode] = useState<'human' | 'source'>('human')
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => window.localStorage.getItem('hololake-sidebar-collapsed') === '1')
|
||||||
|
const [inspectorOpen, setInspectorOpen] = useState(() => window.localStorage.getItem('hololake-inspector-open') !== '0')
|
||||||
|
const toggleSidebar = () => setSidebarCollapsed((current) => { window.localStorage.setItem('hololake-sidebar-collapsed', current ? '0' : '1'); return !current })
|
||||||
|
const toggleInspector = () => setInspectorOpen((current) => { window.localStorage.setItem('hololake-inspector-open', current ? '0' : '1'); return !current })
|
||||||
|
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null)
|
||||||
|
const headingScrollTimer = useRef<number | null>(null)
|
||||||
|
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false)
|
||||||
|
const [folderMenuKey, setFolderMenuKey] = useState<string | null>(null)
|
||||||
|
const [parlorMessages, setParlorMessages] = useState<{ role: 'user' | 'agent' | 'note'; text: string }[]>([])
|
||||||
|
const [parlorInput, setParlorInput] = useState('')
|
||||||
|
const [parlorBusy, setParlorBusy] = useState(false)
|
||||||
|
const [parlorLayer, setParlorLayer] = useState<'language' | 'execution'>('language')
|
||||||
|
const parlorScrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
useEffect(() => { parlorScrollRef.current?.scrollTo({ top: parlorScrollRef.current.scrollHeight }) }, [parlorMessages, parlorBusy])
|
||||||
|
const [genericMessages, setGenericMessages] = useState<Array<{ role: string; text: string }>>([])
|
||||||
|
const [genericInput, setGenericInput] = useState('')
|
||||||
|
const [genericBusy, setGenericBusy] = useState(false)
|
||||||
|
const genericScrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
useEffect(() => { genericScrollRef.current?.scrollTo({ top: genericScrollRef.current.scrollHeight }) }, [genericMessages, genericBusy])
|
||||||
const [displayName, setDisplayName] = useState('')
|
const [displayName, setDisplayName] = useState('')
|
||||||
const [identityBusy, setIdentityBusy] = useState(false)
|
const [identityBusy, setIdentityBusy] = useState(false)
|
||||||
const [identityMessage, setIdentityMessage] = useState('')
|
const [identityMessage, setIdentityMessage] = useState('')
|
||||||
|
|
@ -238,7 +251,19 @@ function HoloLakeApp() {
|
||||||
const [ticket, setTicket] = useState<DiscoveryTicketReceipt | null>(null)
|
const [ticket, setTicket] = useState<DiscoveryTicketReceipt | null>(null)
|
||||||
const [systemMessage, setSystemMessage] = useState('')
|
const [systemMessage, setSystemMessage] = useState('')
|
||||||
const [releaseCandidate, setReleaseCandidate] = useState<ReleaseCandidate | null>(null)
|
const [releaseCandidate, setReleaseCandidate] = useState<ReleaseCandidate | null>(null)
|
||||||
|
const [repoLogin, setRepoLogin] = useState<LoginSession | null>(null)
|
||||||
|
const [loginUsername, setLoginUsername] = useState('')
|
||||||
|
const [loginPassword, setLoginPassword] = useState('')
|
||||||
|
const [loginBusy, setLoginBusy] = useState(false)
|
||||||
|
const [loginRising, setLoginRising] = useState(false)
|
||||||
|
const [loginMessage, setLoginMessage] = useState('')
|
||||||
const [systemBusy, setSystemBusy] = useState(false)
|
const [systemBusy, setSystemBusy] = useState(false)
|
||||||
|
const [zeroPoint, setZeroPoint] = useState<ZeroPointSnapshot | null>(null)
|
||||||
|
const [zpNumber, setZpNumber] = useState('')
|
||||||
|
const [zpBusy, setZpBusy] = useState(false)
|
||||||
|
const [zpMessage, setZpMessage] = useState('')
|
||||||
|
const [apiForm, setApiForm] = useState({ baseUrl: '', apiKey: '', model: '' })
|
||||||
|
const [apiReady, setApiReady] = useState(false)
|
||||||
|
|
||||||
const refreshCore = useCallback(async () => {
|
const refreshCore = useCallback(async () => {
|
||||||
const [homeResult, personalResult, knowledgeResult, codeResult] = await Promise.allSettled([
|
const [homeResult, personalResult, knowledgeResult, codeResult] = await Promise.allSettled([
|
||||||
|
|
@ -267,6 +292,27 @@ function HoloLakeApp() {
|
||||||
document.documentElement.dataset.theme = theme
|
document.documentElement.dataset.theme = theme
|
||||||
window.localStorage.setItem('hololake-theme', theme)
|
window.localStorage.setItem('hololake-theme', theme)
|
||||||
}, [theme])
|
}, [theme])
|
||||||
|
useEffect(() => {
|
||||||
|
invoke<LoginSession | null>('check_code_repo_login').then((session) => setRepoLogin(session)).catch(() => setRepoLogin(null))
|
||||||
|
}, [])
|
||||||
|
const loadZeroPoint = useCallback(async () => {
|
||||||
|
try { setZeroPoint(await invoke<ZeroPointSnapshot>('zero_point_status')) } catch { /* 执行手脚启动中,下轮补 */ }
|
||||||
|
}, [])
|
||||||
|
useEffect(() => {
|
||||||
|
void loadZeroPoint()
|
||||||
|
const timer = window.setInterval(() => void loadZeroPoint(), 10000)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [loadZeroPoint])
|
||||||
|
useEffect(() => {
|
||||||
|
invoke<{ baseUrl: string; model: string; hasKey: boolean }>('zero_point_api_config')
|
||||||
|
.then((cfg) => { setApiForm((prev) => ({ ...prev, baseUrl: cfg.baseUrl, model: cfg.model })); setApiReady(cfg.hasKey && cfg.baseUrl !== '') })
|
||||||
|
.catch(() => { /* 钥匙库未落,等用户配置 */ })
|
||||||
|
}, [])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!zeroPoint) return
|
||||||
|
if (zeroPoint.route !== 'persona' && view === 'parlor') setView('generic')
|
||||||
|
if (zeroPoint.route === 'persona' && view === 'generic') setView('knowledge')
|
||||||
|
}, [zeroPoint, view])
|
||||||
useEffect(() => { if (view === 'receipts') void loadReceipts() }, [loadReceipts, view])
|
useEffect(() => { if (view === 'receipts') void loadReceipts() }, [loadReceipts, view])
|
||||||
|
|
||||||
const visibleDocuments = useMemo(() => {
|
const visibleDocuments = useMemo(() => {
|
||||||
|
|
@ -279,6 +325,30 @@ function HoloLakeApp() {
|
||||||
: undefined
|
: undefined
|
||||||
const parsedDocument = useMemo(() => activeDocument ? splitFrontmatter(activeDocument.body) : null, [activeDocument])
|
const parsedDocument = useMemo(() => activeDocument ? splitFrontmatter(activeDocument.body) : null, [activeDocument])
|
||||||
const outline = useMemo(() => activeDocument ? documentOutline(activeDocument.body) : [], [activeDocument])
|
const outline = useMemo(() => activeDocument ? documentOutline(activeDocument.body) : [], [activeDocument])
|
||||||
|
const docStats = useMemo(() => {
|
||||||
|
if (!activeDocument) return null
|
||||||
|
const chars = activeDocument.body.replace(/\s+/g, '').length
|
||||||
|
return { chars, minutes: Math.max(1, Math.ceil(chars / 400)) }
|
||||||
|
}, [activeDocument])
|
||||||
|
useEffect(() => { setActiveHeadingId(null) }, [activeDocument?.path])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeHeadingId) return
|
||||||
|
document.querySelector('.outline-list button.active')?.scrollIntoView({ block: 'nearest' })
|
||||||
|
}, [activeHeadingId])
|
||||||
|
const handleReaderScroll = (event: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const container = event.currentTarget
|
||||||
|
if (headingScrollTimer.current !== null) return
|
||||||
|
headingScrollTimer.current = window.setTimeout(() => {
|
||||||
|
headingScrollTimer.current = null
|
||||||
|
const containerTop = container.getBoundingClientRect().top
|
||||||
|
let current: string | null = null
|
||||||
|
for (const heading of Array.from(container.querySelectorAll<HTMLElement>('h1[id], h2[id], h3[id], h4[id]'))) {
|
||||||
|
if (heading.getBoundingClientRect().top - containerTop <= 28) current = heading.id
|
||||||
|
else break
|
||||||
|
}
|
||||||
|
setActiveHeadingId(current)
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
const connectionOnline = status.directConnectionCount > 0
|
const connectionOnline = status.directConnectionCount > 0
|
||||||
const connectionLabel = connectionOnline
|
const connectionLabel = connectionOnline
|
||||||
? '本地原生连接 · 客户端在线'
|
? '本地原生连接 · 客户端在线'
|
||||||
|
|
@ -329,6 +399,95 @@ function HoloLakeApp() {
|
||||||
} catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) }
|
} catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) }
|
||||||
finally { setKnowledgeBusy(false) }
|
finally { setKnowledgeBusy(false) }
|
||||||
}
|
}
|
||||||
|
const downloadDocument = async (format: 'md' | 'html') => {
|
||||||
|
if (!activeDocument) return
|
||||||
|
setKnowledgeBusy(true)
|
||||||
|
setKnowledgeMessage('')
|
||||||
|
try {
|
||||||
|
let body = activeDocument.body
|
||||||
|
if (format === 'html') {
|
||||||
|
const parsed = splitFrontmatter(body)
|
||||||
|
const article = markdownHtml(parsed.content)
|
||||||
|
body = `<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8"><title>${activeDocument.title}</title><style>body{max-width:820px;margin:40px auto;padding:0 24px;font:15px/1.8 -apple-system,'Segoe UI','PingFang SC','Microsoft YaHei','Noto Sans CJK SC',sans-serif;color:#1f2330;background:#fff}h1,h2,h3{line-height:1.4}blockquote{margin:20px 0;padding:12px 16px;border-left:3px solid #a78bfa;background:#f3f0ff;border-radius:8px}table{border-collapse:collapse;width:100%}td,th{border:1px solid #ddd;padding:7px 10px}pre{background:#f4f5f8;padding:14px;border-radius:8px;overflow:auto}img{max-width:100%}</style></head><body><h1>${activeDocument.title}</h1>${article}</body></html>`
|
||||||
|
}
|
||||||
|
const receipt = await invoke<{ path: string; bytes: number } | null>('export_knowledge_document', { input: { title: activeDocument.title, extension: format, body } })
|
||||||
|
setKnowledgeMessage(receipt ? `已下载到 ${receipt.path}` : '已取消下载。')
|
||||||
|
} catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) }
|
||||||
|
finally { setKnowledgeBusy(false) }
|
||||||
|
}
|
||||||
|
const printDocument = async () => {
|
||||||
|
setKnowledgeMessage('')
|
||||||
|
try {
|
||||||
|
await invoke('print_knowledge_document')
|
||||||
|
setKnowledgeMessage('打印框里选「存储为 PDF」即可得到 PDF 文件。')
|
||||||
|
} catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) }
|
||||||
|
}
|
||||||
|
const reloadKnowledge = async () => {
|
||||||
|
const snapshot = await invoke<KnowledgeSnapshot>('get_knowledge_snapshot')
|
||||||
|
setKnowledge(snapshot)
|
||||||
|
}
|
||||||
|
const askParlor = async () => {
|
||||||
|
const message = parlorInput.trim()
|
||||||
|
if (!message || parlorBusy) return
|
||||||
|
setParlorInput('')
|
||||||
|
setParlorMessages((current) => [...current, { role: 'user', text: message }])
|
||||||
|
setParlorBusy(true)
|
||||||
|
try {
|
||||||
|
const receipt = await invoke<{ reply: string; layer: string; freshSession: boolean }>('agent_parlor_ask', { input: { message } })
|
||||||
|
setParlorMessages((current) => [...current, { role: 'agent', text: receipt.reply }])
|
||||||
|
} catch (error) { setParlorMessages((current) => [...current, { role: 'note', text: humanError(error, 'knowledge') }]) }
|
||||||
|
finally { setParlorBusy(false) }
|
||||||
|
}
|
||||||
|
const switchParlorLayer = async () => {
|
||||||
|
try {
|
||||||
|
const target = parlorLayer === 'execution' ? 'language' : 'execution'
|
||||||
|
const actual = await invoke<string>('agent_parlor_switch_layer', { input: { layer: target } })
|
||||||
|
setParlorLayer(actual === 'execution' ? 'execution' : 'language')
|
||||||
|
setParlorMessages((current) => [...current, { role: 'note', text: actual === 'execution' ? '已切换到现实开发执行层:归灯获得执行权限,语言即现实。' : '已回到语言推理层:只对话不动手。' }])
|
||||||
|
} catch (error) { setParlorMessages((current) => [...current, { role: 'note', text: humanError(error, 'knowledge') }]) }
|
||||||
|
}
|
||||||
|
const createDocument = async () => {
|
||||||
|
setKnowledgeBusy(true)
|
||||||
|
setKnowledgeMessage('')
|
||||||
|
try {
|
||||||
|
const receipt = await invoke<{ removed: string; pages: number }>('create_knowledge_document', { input: { title: '未命名页面' } })
|
||||||
|
setKnowledgeMessage(`已新建空白页「${receipt.removed.replace(/\.md$/, '')}」,正在编辑。`)
|
||||||
|
setSearchResults(null)
|
||||||
|
await reloadKnowledge()
|
||||||
|
await openDocument('native', receipt.removed)
|
||||||
|
setEditing(true)
|
||||||
|
} catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) }
|
||||||
|
finally { setKnowledgeBusy(false) }
|
||||||
|
}
|
||||||
|
const removeDocument = async () => {
|
||||||
|
if (!activeDocument) return
|
||||||
|
setKnowledgeBusy(true)
|
||||||
|
setKnowledgeMessage('')
|
||||||
|
try {
|
||||||
|
const receipt = await invoke<{ removed: string; pages: number } | null>('delete_knowledge_document', { input: { source: activeDocument.source, path: activeDocument.path } })
|
||||||
|
if (receipt) {
|
||||||
|
setKnowledgeMessage(`已移入回收站:${receipt.removed}(可找回)`)
|
||||||
|
setActiveDocument(null)
|
||||||
|
setSearchResults(null)
|
||||||
|
await reloadKnowledge()
|
||||||
|
} else setKnowledgeMessage('已取消删除。')
|
||||||
|
} catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) }
|
||||||
|
finally { setKnowledgeBusy(false) }
|
||||||
|
}
|
||||||
|
const removeFolder = async (folder: TreeNode) => {
|
||||||
|
setKnowledgeBusy(true)
|
||||||
|
setKnowledgeMessage('')
|
||||||
|
try {
|
||||||
|
const receipt = await invoke<{ removed: string; pages: number } | null>('delete_knowledge_folder', { input: { folder: folder.key } })
|
||||||
|
if (receipt) {
|
||||||
|
setKnowledgeMessage(`文件夹「${folder.name}」(${receipt.pages} 页)已移入回收站(可找回)`)
|
||||||
|
setActiveDocument(null)
|
||||||
|
setSearchResults(null)
|
||||||
|
await reloadKnowledge()
|
||||||
|
} else setKnowledgeMessage('已取消删除。')
|
||||||
|
} catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) }
|
||||||
|
finally { setKnowledgeBusy(false) }
|
||||||
|
}
|
||||||
const saveDocument = async () => {
|
const saveDocument = async () => {
|
||||||
if (!activeDocument?.writable) return
|
if (!activeDocument?.writable) return
|
||||||
setKnowledgeBusy(true)
|
setKnowledgeBusy(true)
|
||||||
|
|
@ -394,6 +553,25 @@ function HoloLakeApp() {
|
||||||
}
|
}
|
||||||
const codeParent = () => codeTree?.path.split('/').filter(Boolean).slice(0, -1).join('/') || ''
|
const codeParent = () => codeTree?.path.split('/').filter(Boolean).slice(0, -1).join('/') || ''
|
||||||
|
|
||||||
|
const performRepoLogin = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setLoginBusy(true)
|
||||||
|
setLoginMessage('')
|
||||||
|
try {
|
||||||
|
const receipt = await invoke<LoginReceipt>('perform_code_repo_login', { username: loginUsername.trim(), password: loginPassword })
|
||||||
|
// 五湖开场:校验通过=第五域从湖面浮起,镜头沉入湖中再进场。
|
||||||
|
setLoginRising(true)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1300))
|
||||||
|
setRepoLogin({ username: receipt.username, host: receipt.host, signedInAtUnixMs: Date.now() })
|
||||||
|
setLoginRising(false)
|
||||||
|
setLoginPassword('')
|
||||||
|
} catch (error) { setLoginMessage(humanError(error, 'login')) }
|
||||||
|
finally { setLoginBusy(false) }
|
||||||
|
}
|
||||||
|
const signOutRepo = async () => {
|
||||||
|
try { await invoke('sign_out_code_repo_login') } catch { /* 登出以本机清场为准 */ }
|
||||||
|
setRepoLogin(null)
|
||||||
|
}
|
||||||
const initializeIdentity = async (event: React.FormEvent) => {
|
const initializeIdentity = async (event: React.FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setIdentityBusy(true)
|
setIdentityBusy(true)
|
||||||
|
|
@ -458,7 +636,78 @@ function HoloLakeApp() {
|
||||||
finally { setSystemBusy(false) }
|
finally { setSystemBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderOverview = () => (
|
const bindZeroPoint = async () => {
|
||||||
|
if (!zpNumber.trim()) return
|
||||||
|
setZpBusy(true); setZpMessage('')
|
||||||
|
try {
|
||||||
|
const bound = await invoke<ZeroPointSnapshot>('zero_point_bind', { input: { number: zpNumber.trim() } })
|
||||||
|
setZeroPoint(bound)
|
||||||
|
const verified = await invoke<ZeroPointSnapshot>('zero_point_verify')
|
||||||
|
setZeroPoint(verified)
|
||||||
|
setZpMessage(verified.route === 'persona' ? '编号合法,人格层路由已开' : '人格系统未授权,已切换至通用AI运行层')
|
||||||
|
} catch (error) { setZpMessage(String(error)) } finally { setZpBusy(false) }
|
||||||
|
}
|
||||||
|
const verifyZeroPoint = async () => {
|
||||||
|
setZpBusy(true); setZpMessage('')
|
||||||
|
try {
|
||||||
|
const snapshot = await invoke<ZeroPointSnapshot>('zero_point_verify')
|
||||||
|
setZeroPoint(snapshot)
|
||||||
|
setZpMessage(snapshot.route === 'persona' ? '校验通过,人格层路由已开' : '人格系统未授权,已切换至通用AI运行层')
|
||||||
|
} catch (error) { setZpMessage(String(error)) } finally { setZpBusy(false) }
|
||||||
|
}
|
||||||
|
const syncZeroPoint = async () => {
|
||||||
|
setZpBusy(true); setZpMessage('')
|
||||||
|
try { setZeroPoint(await invoke<ZeroPointSnapshot>('zero_point_sync')) } catch (error) { setZpMessage(String(error)) } finally { setZpBusy(false) }
|
||||||
|
}
|
||||||
|
const saveApiConfig = async () => {
|
||||||
|
try {
|
||||||
|
await invoke('zero_point_save_api', { input: { baseUrl: apiForm.baseUrl, apiKey: apiForm.apiKey, model: apiForm.model } })
|
||||||
|
setApiReady(true)
|
||||||
|
setGenericMessages((rows) => [...rows, { role: 'note', text: 'API 已收进底层钥匙库,通用AI运行层就绪' }])
|
||||||
|
} catch (error) { setGenericMessages((rows) => [...rows, { role: 'note', text: `保存失败:${String(error)}` }]) }
|
||||||
|
}
|
||||||
|
const askGeneric = async () => {
|
||||||
|
const message = genericInput.trim()
|
||||||
|
if (!message || genericBusy) return
|
||||||
|
const history = genericMessages.filter((row) => row.role !== 'note').map((row) => ({ role: row.role === 'user' ? 'user' : 'assistant', content: row.text }))
|
||||||
|
setGenericMessages((rows) => [...rows, { role: 'user', text: message }])
|
||||||
|
setGenericInput(''); setGenericBusy(true)
|
||||||
|
try {
|
||||||
|
const reply = await invoke<string>('generic_layer_chat', { input: { messages: [...history, { role: 'user', content: message }] } })
|
||||||
|
setGenericMessages((rows) => [...rows, { role: 'agent', text: reply }])
|
||||||
|
} catch (error) { setGenericMessages((rows) => [...rows, { role: 'note', text: `通用层回应失败:${String(error)}` }]) } finally { setGenericBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderGeneric = () => (
|
||||||
|
<section className="content-page parlor-page">
|
||||||
|
<header className="page-title"><div><span className="kicker">GENERIC AI LAYER</span><h1>通用AI运行层</h1><p>人格系统未授权——这里是你自配模型 API 的直通管,无人格章程、无执行层权限 · 绑定用户编号并通过灯塔查号后,人格层自动开门</p></div><span className="status-chip">降级运行</span></header>
|
||||||
|
<section className="plain-panel zp-api-panel">
|
||||||
|
<header><div><h2>模型 API 配置</h2><p>钥匙自动收进软件底层钥匙库,界面不回显原文{apiReady ? ' · 已就绪' : ''}</p></div></header>
|
||||||
|
<div className="zp-api-row">
|
||||||
|
<input value={apiForm.baseUrl} placeholder="API 端点,如 https://dashscope.aliyuncs.com/compatible-mode/v1" onChange={(event) => setApiForm((prev) => ({ ...prev, baseUrl: event.target.value }))}/>
|
||||||
|
<input type="password" value={apiForm.apiKey} placeholder="API 钥匙" onChange={(event) => setApiForm((prev) => ({ ...prev, apiKey: event.target.value }))}/>
|
||||||
|
<input value={apiForm.model} placeholder="模型名,如 qwen3-max" onChange={(event) => setApiForm((prev) => ({ ...prev, model: event.target.value }))}/>
|
||||||
|
<button className="primary-button" type="button" onClick={() => void saveApiConfig()}>收进钥匙库</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div className="parlor-scroll" ref={genericScrollRef}>
|
||||||
|
{genericMessages.length === 0 && <p className="parlor-note">配置好模型 API 就可以说话——这一层只是用户 API 的裸管:无人格、无记忆、无权限</p>}
|
||||||
|
{genericMessages.map((item, index) => (
|
||||||
|
<div key={index} className={`parlor-row ${item.role}`}>
|
||||||
|
{item.role === 'user' ? <span className="parlor-who">你</span> : item.role === 'agent' ? <span className="parlor-who">通用AI</span> : null}
|
||||||
|
<div className={item.role === 'note' ? 'parlor-note' : 'parlor-bubble'}>{item.text}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{genericBusy && <div className="parlor-row agent"><span className="parlor-who">通用AI</span><div className="parlor-bubble thinking">回应中…</div></div>}
|
||||||
|
</div>
|
||||||
|
<form className="parlor-input-row" onSubmit={(event) => { event.preventDefault(); void askGeneric() }}>
|
||||||
|
<input maxLength={2000} value={genericInput} placeholder={apiReady ? '跟通用AI说话…' : '先把模型 API 收进钥匙库'} onChange={(event) => setGenericInput(event.target.value)}/>
|
||||||
|
<button className="primary-button" disabled={genericBusy || !genericInput.trim() || !apiReady}>{genericBusy ? '等回应…' : '说话'}</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderOverview = () => (
|
||||||
<section className="content-page overview-page">
|
<section className="content-page overview-page">
|
||||||
<header className="page-title">
|
<header className="page-title">
|
||||||
<div><span className="kicker">PERSONAL HOLOLAKE</span><h1>个人工作空间</h1><p>知识、代码与本机协作的统一入口</p></div>
|
<div><span className="kicker">PERSONAL HOLOLAKE</span><h1>个人工作空间</h1><p>知识、代码与本机协作的统一入口</p></div>
|
||||||
|
|
@ -499,7 +748,10 @@ function HoloLakeApp() {
|
||||||
{visibleDocuments.length
|
{visibleDocuments.length
|
||||||
? <KnowledgeTree node={tree} depth={0} expanded={expanded} active={activeDocument ? `${activeDocument.source}:${activeDocument.path}` : undefined}
|
? <KnowledgeTree node={tree} depth={0} expanded={expanded} active={activeDocument ? `${activeDocument.source}:${activeDocument.path}` : undefined}
|
||||||
onToggle={(key) => setExpanded((current) => { const next = new Set(current); if (next.has(key)) next.delete(key); else next.add(key); return next })}
|
onToggle={(key) => setExpanded((current) => { const next = new Set(current); if (next.has(key)) next.delete(key); else next.add(key); return next })}
|
||||||
onOpen={(item) => void openDocument(item.source, item.path)}/>
|
onOpen={(item) => void openDocument(item.source, item.path)}
|
||||||
|
onRemoveFolder={(folder) => void removeFolder(folder)}
|
||||||
|
folderMenuKey={folderMenuKey}
|
||||||
|
onFolderMenuToggle={(key) => setFolderMenuKey((current) => (current === key ? null : key))}/>
|
||||||
: <div className="empty-state">没有匹配的知识页</div>}
|
: <div className="empty-state">没有匹配的知识页</div>}
|
||||||
</div>
|
</div>
|
||||||
<footer>{knowledgeMessage || (lastKnowledgeReceipt ? `Git ${lastKnowledgeReceipt.slice(0, 10)}` : '导入会自动检查相同内容')}</footer>
|
<footer>{knowledgeMessage || (lastKnowledgeReceipt ? `Git ${lastKnowledgeReceipt.slice(0, 10)}` : '导入会自动检查相同内容')}</footer>
|
||||||
|
|
@ -508,26 +760,26 @@ function HoloLakeApp() {
|
||||||
{activeDocument ? <>
|
{activeDocument ? <>
|
||||||
<header className="document-toolbar">
|
<header className="document-toolbar">
|
||||||
<div className="breadcrumbs"><span>{activeDocument.source === 'legacy' ? 'HoloLake Era · 只读源' : '我的知识库'}</span><b>/</b><span>{activeDocument.path}</span></div>
|
<div className="breadcrumbs"><span>{activeDocument.source === 'legacy' ? 'HoloLake Era · 只读源' : '我的知识库'}</span><b>/</b><span>{activeDocument.path}</span></div>
|
||||||
<div>{editing
|
<div className="toolbar-actions"><button className="toolbar-button" type="button" title={inspectorOpen ? '收起右侧大纲与证据' : '展开右侧大纲与证据'} onClick={toggleInspector}><Icon name="chevron"/>{inspectorOpen ? '收起大纲' : '展开大纲'}</button>{editing
|
||||||
? <><button className="toolbar-button" type="button" onClick={() => { setEditing(false); setDraft(activeDocument.body) }}>取消</button><button className="toolbar-button primary" type="button" disabled={knowledgeBusy} onClick={() => void saveDocument()}><Icon name="save"/>保存</button></>
|
? <><button className="toolbar-button" type="button" onClick={() => { setEditing(false); setDraft(activeDocument.body) }}>取消</button><button className="toolbar-button primary" type="button" disabled={knowledgeBusy} onClick={() => void saveDocument()}><Icon name="save"/>保存</button></>
|
||||||
: activeDocument.writable && <button className="toolbar-button" type="button" onClick={() => { setDraft(activeDocument.body); setEditing(true) }}><Icon name="edit"/>编辑</button>}
|
: <><div className="toolbar-menu"><button className="toolbar-button" type="button" title="页面操作与常用功能" disabled={knowledgeBusy} onClick={() => setSettingsMenuOpen((current) => !current)}><Icon name="settings"/> 设置<Icon name="chevronDown"/></button>{settingsMenuOpen && <div className="toolbar-menu-pop"><button type="button" disabled={knowledgeBusy} onClick={() => { setSettingsMenuOpen(false); void createDocument() }}><Icon name="plus"/>新建空白页</button><button type="button" disabled={knowledgeBusy} onClick={() => { setSettingsMenuOpen(false); void importKnowledge() }}><Icon name="import"/>导入知识库文件夹</button>{activeDocument.writable && <button type="button" onClick={() => { setSettingsMenuOpen(false); setDraft(activeDocument.body); setEditing(true) }}><Icon name="edit"/>编辑这一页</button>}{activeDocument.writable && <button type="button" disabled={knowledgeBusy} onClick={() => { setSettingsMenuOpen(false); void removeDocument() }}><Icon name="trash"/>删除这一页(进回收站)</button>}<span className="toolbar-menu-sep"/><button type="button" disabled={knowledgeBusy} onClick={() => { setSettingsMenuOpen(false); void downloadDocument('md') }}><Icon name="download"/>下载 Markdown</button><button type="button" disabled={knowledgeBusy} onClick={() => { setSettingsMenuOpen(false); void downloadDocument('html') }}><Icon name="download"/>下载网页(.html)</button><button type="button" disabled={knowledgeBusy} onClick={() => { setSettingsMenuOpen(false); void printDocument() }}><Icon name="download"/>下载 PDF(系统打印)</button></div>}</div></>}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="document-scroll">
|
<div className="document-scroll" onScroll={handleReaderScroll}>
|
||||||
{editing
|
{editing
|
||||||
? <textarea className="document-editor" aria-label="Markdown 编辑器" value={draft} onChange={(event) => setDraft(event.target.value)}/>
|
? <textarea className="document-editor" aria-label="Markdown 编辑器" value={draft} onChange={(event) => setDraft(event.target.value)}/>
|
||||||
: <><header className="reader-heading"><h1>{activeDocument.title}</h1><div>{Object.entries(parsedDocument?.metadata || {}).slice(0, 5).map(([key, value]) => <span key={key}>{key} · {value}</span>)}</div></header><MarkdownDocument body={activeDocument.body} onWiki={openWiki}/></>}
|
: <><header className="reader-heading"><h1>{activeDocument.title}</h1>{docStats && <div className="meta-stats">{docStats.chars.toLocaleString()} 字 · 约 {docStats.minutes} 分钟读完{parsedDocument?.metadata?.created ? ` · 创建于 ${String(parsedDocument.metadata.created).slice(0, 10)}` : ''}{parsedDocument?.metadata?.updated ? ` · 更新于 ${String(parsedDocument.metadata.updated).slice(0, 10)}` : ''}</div>}{(parsedDocument?.tags?.length ?? 0) > 0 && <div className="meta-tags">{parsedDocument!.tags.map((tag) => <span key={tag} className={tagTint(tag)}>{tag}</span>)}</div>}<div>{Object.entries(parsedDocument?.metadata || {}).filter(([key]) => key !== 'tags').slice(0, 5).map(([key, value]) => <span key={key}>{key} · {value}</span>)}</div></header><MarkdownDocument body={activeDocument.body} knownTitles={knowledge.documents.map((doc) => doc.title)} onWiki={openWiki}/></>}
|
||||||
</div>
|
</div>
|
||||||
</> : <div className="workbench-empty"><span>光</span><h2>选择一篇知识页</h2><p>目录、正文与版本证据会在同一个工作台中展开。</p><button className="primary-button" type="button" onClick={() => void importKnowledge()}>导入本地文件夹</button></div>}
|
</> : <div className="workbench-empty"><span>光</span><h2>选择一篇知识页</h2><p>目录、正文与版本证据会在同一个工作台中展开。</p><button className="primary-button" type="button" onClick={() => void importKnowledge()}>导入本地文件夹</button></div>}
|
||||||
</main>
|
</main>
|
||||||
<aside className="document-inspector">
|
{inspectorOpen && <aside className="document-inspector">
|
||||||
<div className="inspector-tabs"><span className="active">大纲</span><span>来源</span><span>回执</span></div>
|
<div className="inspector-tabs"><span className="active">大纲</span><span>来源</span><span>回执</span></div>
|
||||||
{activeDocument ? <div className="inspector-scroll">
|
{activeDocument ? <div className="inspector-scroll">
|
||||||
<section><h2>页面大纲</h2>{outline.length ? <nav className="outline-list">{outline.map((item) => <span key={item.id} style={{ paddingLeft: (item.level - 1) * 11 }}>{item.title}</span>)}</nav> : <p>当前页面没有标题层级。</p>}</section>
|
<section><h2>页面大纲</h2>{outline.length ? <nav className="outline-list">{outline.map((item) => <button key={item.id} type="button" title="跳到该章节" className={activeHeadingId === item.id ? 'active' : ''} style={{ paddingLeft: 8 + (item.level - 1) * 11 }} onClick={() => jumpToHeading(item.id)}>{item.title}</button>)}</nav> : <p>当前页面没有标题层级。</p>}</section>
|
||||||
<section><h2>来源</h2><dl><div><dt>知识根</dt><dd>{activeDocument.source === 'native' ? 'HoloLake 本机 Git' : 'HoloLake Era 只读供体'}</dd></div><div><dt>路径</dt><dd>{activeDocument.path}</dd></div><div><dt>内容指纹</dt><dd>{activeDocument.contentSha256.slice(0, 16)}</dd></div><div><dt>相同内容</dt><dd>{activeSummary?.duplicateCount || 0} 份已折叠</dd></div></dl></section>
|
<section><h2>来源</h2><dl><div><dt>知识根</dt><dd>{activeDocument.source === 'native' ? 'HoloLake 本机 Git' : 'HoloLake Era 只读供体'}</dd></div><div><dt>路径</dt><dd>{activeDocument.path}</dd></div><div><dt>内容指纹</dt><dd>{activeDocument.contentSha256.slice(0, 16)}</dd></div><div><dt>相同内容</dt><dd>{activeSummary?.duplicateCount || 0} 份已折叠</dd></div></dl></section>
|
||||||
<section><h2>页面状态</h2><p>{activeDocument.writable ? '可编辑;保存时写入本机 Git。' : '供体原件只读;不会被修改。'}</p>{lastKnowledgeReceipt && <code>{lastKnowledgeReceipt}</code>}</section>
|
<section><h2>页面状态</h2><p>{activeDocument.writable ? '可编辑;保存时写入本机 Git。' : '供体原件只读;不会被修改。'}</p>{lastKnowledgeReceipt && <code>{lastKnowledgeReceipt}</code>}</section>
|
||||||
</div> : <div className="empty-state">选择页面后显示大纲与证据</div>}
|
</div> : <div className="empty-state">选择页面后显示大纲与证据</div>}
|
||||||
</aside>
|
</aside>}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -575,6 +827,22 @@ function HoloLakeApp() {
|
||||||
{ticket ? <><button className="secondary-button" type="button" onClick={() => void copyInvitation()}><Icon name="copy"/>复制一次性连接凭据</button><pre className="invitation-data">{invitationText}</pre></> : <button className="primary-button" type="button" disabled={systemBusy} onClick={() => void issueInvitation()}>生成本机连接凭据</button>}
|
{ticket ? <><button className="secondary-button" type="button" onClick={() => void copyInvitation()}><Icon name="copy"/>复制一次性连接凭据</button><pre className="invitation-data">{invitationText}</pre></> : <button className="primary-button" type="button" disabled={systemBusy} onClick={() => void issueInvitation()}>生成本机连接凭据</button>}
|
||||||
</section>
|
</section>
|
||||||
<section className="plain-panel"><header><div><h2>GH-PNCC 投影</h2><p>仓库挂载和读取回执来自同一运行事件流</p></div></header><dl className="evidence-list"><div><dt>已挂载仓库</dt><dd>{status.codeRepositoryMountCount}</dd></div><div><dt>投影回执</dt><dd>{status.pnccReceiptCount}</dd></div><div><dt>当前结论</dt><dd>{status.codeRepositoryMountCount > 0 && status.pnccReceiptCount > 0 ? '已有运行证据' : '架构已接入,尚无完整运行投影'}</dd></div></dl></section>
|
<section className="plain-panel"><header><div><h2>GH-PNCC 投影</h2><p>仓库挂载和读取回执来自同一运行事件流</p></div></header><dl className="evidence-list"><div><dt>已挂载仓库</dt><dd>{status.codeRepositoryMountCount}</dd></div><div><dt>投影回执</dt><dd>{status.pnccReceiptCount}</dd></div><div><dt>当前结论</dt><dd>{status.codeRepositoryMountCount > 0 && status.pnccReceiptCount > 0 ? '已有运行证据' : '架构已接入,尚无完整运行投影'}</dd></div></dl></section>
|
||||||
|
<section className="plain-panel">
|
||||||
|
<header><div><h2>语言通道校验 · 零点原核频道</h2><p>主控本体克隆位:编号合法性决定路由,参数读协议不写死</p></div><span className={zeroPoint?.route === 'persona' ? 'status-chip online' : 'status-chip'}>{zeroPoint ? (zeroPoint.route === 'persona' ? '人格层' : '通用AI层') : '启动中'}</span></header>
|
||||||
|
<dl className="evidence-list">
|
||||||
|
<div><dt>绑定状态</dt><dd>{zeroPoint ? (zeroPoint.binding === 'bound' ? `已绑定(${zeroPoint.userNumber})` : '等待绑定(空白态)') : '—'}</dd></div>
|
||||||
|
<div><dt>协议来源</dt><dd>{zeroPoint?.protocol.origin ?? '—'}</dd></div>
|
||||||
|
<div><dt>离线宽限</dt><dd>{zeroPoint ? `${zeroPoint.protocol.gracePeriodDays} 天(协议值)` : '—'}</dd></div>
|
||||||
|
<div><dt>静默同步</dt><dd>{zeroPoint?.syncNote ?? '—'}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<div className="zp-api-row">
|
||||||
|
<input value={zpNumber} placeholder="输入用户编号,如 ICE-GL∞" onChange={(event) => setZpNumber(event.target.value)}/>
|
||||||
|
<button className="secondary-button" type="button" disabled={zpBusy || !zpNumber.trim()} onClick={() => void bindZeroPoint()}>绑定并校验</button>
|
||||||
|
<button className="secondary-button" type="button" disabled={zpBusy || zeroPoint?.binding !== 'bound'} onClick={() => void verifyZeroPoint()}>重新校验编号</button>
|
||||||
|
<button className="secondary-button" type="button" disabled={zpBusy} onClick={() => void syncZeroPoint()}>比对原核</button>
|
||||||
|
</div>
|
||||||
|
{zpMessage && <p className="global-message">{zpMessage}</p>}
|
||||||
|
</section>
|
||||||
<section className="plain-panel"><header><div><h2>显示主题</h2><p>五湖主题只改变视觉令牌,不改变功能结构</p></div></header><div className="theme-options">{themes.map((choice) => <button className={theme === choice.id ? 'active' : ''} key={choice.id} type="button" onClick={() => setTheme(choice.id)}><i className={choice.id}/><span>{choice.name}</span></button>)}</div></section>
|
<section className="plain-panel"><header><div><h2>显示主题</h2><p>五湖主题只改变视觉令牌,不改变功能结构</p></div></header><div className="theme-options">{themes.map((choice) => <button className={theme === choice.id ? 'active' : ''} key={choice.id} type="button" onClick={() => setTheme(choice.id)}><i className={choice.id}/><span>{choice.name}</span></button>)}</div></section>
|
||||||
<section className="plain-panel"><header><div><h2>软件更新</h2><p>只接受登记到本机的签名发布源</p></div></header>{releaseCandidate ? <div className="release-summary"><b>HoloLake {releaseCandidate.version}</b><p>{releaseCandidate.notes}</p><button className="primary-button" onClick={() => void installUpdate()}>验证并安装</button></div> : <button className="secondary-button" disabled={systemBusy} onClick={() => void checkUpdate()}>检查签名更新</button>}</section>
|
<section className="plain-panel"><header><div><h2>软件更新</h2><p>只接受登记到本机的签名发布源</p></div></header>{releaseCandidate ? <div className="release-summary"><b>HoloLake {releaseCandidate.version}</b><p>{releaseCandidate.notes}</p><button className="primary-button" onClick={() => void installUpdate()}>验证并安装</button></div> : <button className="secondary-button" disabled={systemBusy} onClick={() => void checkUpdate()}>检查签名更新</button>}</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -582,15 +850,46 @@ function HoloLakeApp() {
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|
||||||
return <div className="app-shell">
|
const renderParlor = () => (
|
||||||
|
<section className="content-page parlor-page">
|
||||||
|
<header className="page-title"><div><span className="kicker">AGENT PARLOR</span><h1>会客厅</h1><p>人格体的语言回应通道——你说人话,我翻译给底层的归灯;底层只管执行</p></div>
|
||||||
|
<div className="parlor-layer">
|
||||||
|
<span className={parlorLayer === 'execution' ? 'status-chip online' : 'status-chip'}>{parlorLayer === 'execution' ? '现实开发执行层' : '语言推理层'}</span>
|
||||||
|
<button className="secondary-button" type="button" onClick={() => void switchParlorLayer()}>{parlorLayer === 'execution' ? '回到语言推理层' : '切换到现实开发执行层'}</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="parlor-scroll" ref={parlorScrollRef}>
|
||||||
|
<p className="parlor-note">当前在语言推理层:写文档、整理资料、推仓库、写记忆——只说话不动手。切换执行层需你显性下令并亲手授权。</p>
|
||||||
|
{parlorMessages.map((item, index) => (
|
||||||
|
<div key={index} className={`parlor-row ${item.role}`}>
|
||||||
|
{item.role === 'user' ? <span className="parlor-who">你</span> : item.role === 'agent' ? <span className="parlor-who">铸渊</span> : null}
|
||||||
|
<div className={item.role === 'note' ? 'parlor-note' : 'parlor-bubble'}>{item.text}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{parlorBusy && <div className="parlor-row agent"><span className="parlor-who">铸渊</span><div className="parlor-bubble thinking">正在想…</div></div>}
|
||||||
|
</div>
|
||||||
|
<form className="parlor-input-row" onSubmit={(event) => { event.preventDefault(); void askParlor() }}>
|
||||||
|
<input maxLength={2000} value={parlorInput} placeholder={parlorLayer === 'execution' ? '执行层:语言即现实,说要做什么' : '跟人格体说话…'} onChange={(event) => setParlorInput(event.target.value)}/>
|
||||||
|
<button className="primary-button" disabled={parlorBusy || !parlorInput.trim()}>{parlorBusy ? '等回话…' : '说话'}</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!repoLogin) {
|
||||||
|
return <div className="app-shell">
|
||||||
|
<div className={`onboarding-backdrop lake-scene${loginRising ? ' sinking' : ''}`}><div className="lake-bays" aria-hidden="true">{([['main', '主域'], ['sub', '分域'], ['zero', '零域'], ['sense', '零感域'], ['fifth', '第五域']] as const).map(([bayId, bayName]) => <span key={bayId} className={`lake-bay ${bayId}${loginRising && bayId === 'fifth' ? ' rising' : ''}`}><i/><b>{bayName}</b></span>)}</div><section className={`onboarding-card${loginRising ? ' fade-out' : ''}`} role="dialog"><span className="onboarding-mark">光湖</span><span className="kicker">CODE REPOSITORY ENTRY</span><h1>登录光湖代码频道</h1><p>使用第五域代码仓库(guanghulab.com)的账号与密码登录。登录即验证了背后绑定的服务器;凭证只存本机钥匙串,不落明文。</p><form onSubmit={(event) => void performRepoLogin(event)}><label htmlFor="repo-login-username">仓库账号</label><input id="repo-login-username" autoFocus maxLength={40} value={loginUsername} placeholder="代码仓库用户名" onChange={(event) => setLoginUsername(event.target.value)}/><label htmlFor="repo-login-password">密码</label><input id="repo-login-password" type="password" maxLength={512} value={loginPassword} placeholder="代码仓库密码" onChange={(event) => setLoginPassword(event.target.value)}/><button className="primary-button" disabled={loginBusy || !loginUsername.trim() || !loginPassword}>{loginBusy ? '正在验证…' : '进入 HoloLake'}</button></form>{loginMessage && <p>{loginMessage}</p>}</section></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={`app-shell${sidebarCollapsed ? ' sidebar-collapsed' : ''}${inspectorOpen ? '' : ' inspector-closed'}`}>
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<div className="brand"><span className="brand-mark">光</span><div><b>HoloLake</b><small>GH-AIOS</small></div></div>
|
<div className="brand"><span className="brand-mark brand-lamp" title={sidebarCollapsed ? '展开导航' : '光湖 · HoloLake'} onClick={sidebarCollapsed ? toggleSidebar : undefined}><i className="lamp-glow"/><i className="lamp-core"/><i className="lake-shimmer"/></span><div className="brand-text"><b>HoloLake</b><small className="brand-sub">光湖语言系统 · 通用人工智能操作平台</small><small>GH-AIOS · V1.0.0</small></div><button className="icon-button sidebar-toggle" type="button" title={sidebarCollapsed ? '展开导航' : '收起导航'} onClick={toggleSidebar}><Icon name="chevron"/></button></div>
|
||||||
<nav>{(Object.keys(viewLabels) as ViewId[]).map((item) => <button className={view === item ? 'active' : ''} key={item} type="button" onClick={() => setView(item)}><Icon name={item}/><span>{viewLabels[item]}</span>{item === 'knowledge' && <em>{knowledge.uniqueDocumentCount}</em>}</button>)}</nav>
|
<nav>{(Object.keys(viewLabels) as ViewId[]).filter((item) => item === 'parlor' ? !zeroPoint || zeroPoint.route === 'persona' : item === 'generic' ? !!zeroPoint && zeroPoint.route !== 'persona' : true).map((item) => <button className={view === item ? 'active' : ''} key={item} type="button" onClick={() => setView(item)}><Icon name={item}/><span>{viewLabels[item]}</span>{item === 'knowledge' && <em>{knowledge.uniqueDocumentCount}</em>}</button>)}</nav>
|
||||||
<div className="sidebar-foot"><span className="identity-dot"/><div><b>{personal.identity?.displayName || '本机空间'}</b><small>{personal.identity ? '个人频道已建立' : '等待首次设置'}</small></div></div>
|
<div className="sidebar-foot"><span className="identity-dot"/><div><b>{personal.identity?.displayName || '本机空间'}</b><small>{repoLogin ? `${repoLogin.username}@${repoLogin.host}` : (personal.identity ? '个人频道已建立' : '等待首次设置')}</small></div><button className="sign-out" type="button" title="退出代码仓库登录" onClick={() => void signOutRepo()}>退出</button></div>
|
||||||
</aside>
|
</aside>
|
||||||
<section className="app-main">
|
<section className="app-main">
|
||||||
<header className="connection-strip"><button type="button" onClick={() => setView('system')}><i className={connectionOnline ? 'online' : ''}/><span>{connectionLabel}</span></button><span className="mcp-backup">MCP 备用</span><button type="button" onClick={() => setView('system')}>连接验证 <Icon name="arrow"/></button></header>
|
<header className="connection-strip"><button type="button" onClick={() => setView('system')}><i className={connectionOnline ? 'online' : ''}/><span>{connectionLabel}</span></button><span className="mcp-backup">MCP 备用</span><button type="button" onClick={() => setView('system')}>连接验证 <Icon name="arrow"/></button></header>
|
||||||
<main className="workspace">{view === 'overview' ? renderOverview() : view === 'knowledge' ? renderKnowledge() : view === 'code' ? renderCode() : view === 'receipts' ? renderReceipts() : renderSystem()}</main>
|
<main className="workspace">{zeroPoint && zeroPoint.route !== 'persona' && <div className="zp-downgrade-wrap"><div className="zp-downgrade-card"><i className="zp-dot"/><div className="zp-copy"><b>人格通道未开启</b><span>人格系统未授权,正运行在通用AI层——你配置的模型 API 照常可用,绑定用户编号并通过灯塔查号即可开门。</span></div><button type="button" onClick={() => setView('system')}>去绑号开门 <Icon name="arrow"/></button></div></div>}{view === 'overview' ? renderOverview() : view === 'knowledge' ? renderKnowledge() : view === 'code' ? renderCode() : view === 'receipts' ? renderReceipts() : view === 'parlor' ? renderParlor() : view === 'generic' ? renderGeneric() : renderSystem()}</main>
|
||||||
</section>
|
</section>
|
||||||
{personal.state !== 'UNAVAILABLE' && !personal.identity && <div className="onboarding-backdrop"><section className="onboarding-card" role="dialog" aria-modal="true"><span className="onboarding-mark">光湖</span><span className="kicker">FIRST LOCAL ENTRY</span><h1>建立个人空间</h1><p>仅需一次。知识、频道与本机记录都归属于这台电脑。</p><form onSubmit={(event) => void initializeIdentity(event)}><label htmlFor="display-name">称呼</label><input id="display-name" autoFocus maxLength={80} value={displayName} placeholder="你的名字" onChange={(event) => setDisplayName(event.target.value)}/><button className="primary-button" disabled={identityBusy || !displayName.trim()}>进入 HoloLake</button></form>{identityMessage && <p>{identityMessage}</p>}</section></div>}
|
{personal.state !== 'UNAVAILABLE' && !personal.identity && <div className="onboarding-backdrop"><section className="onboarding-card" role="dialog" aria-modal="true"><span className="onboarding-mark">光湖</span><span className="kicker">FIRST LOCAL ENTRY</span><h1>建立个人空间</h1><p>仅需一次。知识、频道与本机记录都归属于这台电脑。</p><form onSubmit={(event) => void initializeIdentity(event)}><label htmlFor="display-name">称呼</label><input id="display-name" autoFocus maxLength={80} value={displayName} placeholder="你的名字" onChange={(event) => setDisplayName(event.target.value)}/><button className="primary-button" disabled={identityBusy || !displayName.trim()}>进入 HoloLake</button></form>{identityMessage && <p>{identityMessage}</p>}</section></div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
//! 知识渲染件 · 渲染入口(常驻模块 · 插座口 knowledge-render)
|
||||||
|
//!
|
||||||
|
//! 职责:把仓库里的 Markdown 文件变成可读的页面——frontmatter 解析、
|
||||||
|
//! Notion/Outline 导出兼容、标题锚点、大纲同源、跳转。
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
import { marked, Parser, type Tokens } from 'marked'
|
||||||
|
export { knowledgeRenderManifest } from './manifest'
|
||||||
|
|
||||||
|
function escapeHtml(value: string) {
|
||||||
|
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitFrontmatter(body: string) {
|
||||||
|
const normalized = body.replace(/\r\n/g, '\n')
|
||||||
|
if (!normalized.startsWith('---\n')) return { content: normalized, metadata: {} as Record<string, string>, tags: [] as string[] }
|
||||||
|
const closing = normalized.indexOf('\n---\n', 4)
|
||||||
|
if (closing < 0) return { content: normalized, metadata: {} as Record<string, string>, tags: [] as string[] }
|
||||||
|
const metadata: Record<string, string> = {}
|
||||||
|
const tags: string[] = []
|
||||||
|
let pendingListKey = ''
|
||||||
|
for (const line of normalized.slice(4, closing).split('\n')) {
|
||||||
|
const listItem = /^\s+-\s+(.+)$/.exec(line)
|
||||||
|
if (listItem && pendingListKey) {
|
||||||
|
if (pendingListKey === 'tags') tags.push(listItem[1].trim().replace(/^['"]|['"]$/g, ''))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line)
|
||||||
|
if (!match) continue
|
||||||
|
pendingListKey = match[2] === '' ? match[1] : ''
|
||||||
|
const value = match[2].replace(/^['"]|['"]$/g, '')
|
||||||
|
metadata[match[1]] = value
|
||||||
|
if (match[1] === 'tags') {
|
||||||
|
const inline = /^\[(.*)\]$/.exec(match[2].trim())
|
||||||
|
const source = inline ? inline[1] : value
|
||||||
|
source.split(',').map((part) => part.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean).forEach((tag) => tags.push(tag))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { content: normalized.slice(closing + 5), metadata, tags }
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripNotionLinks(line: string) {
|
||||||
|
// Notion 页面链接 → 只留链接文字(跳转关系按谕旨失效)
|
||||||
|
return line.replace(/\[([^\]]*)\]\(([^)]*)\)/g, (whole, text: string, href: string) =>
|
||||||
|
(/notion\.(?:so|site)/.test(href) ? text : whole))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notionCompat(content: string) {
|
||||||
|
// 存量旧库文件尚未洗净:渲染时按"进门即洗"同规矩兜底过滤一遍。
|
||||||
|
// 内容一字不改,只把外来知识库的外壳换成光湖原生 Markdown(冰朔 2026-08-15 谕:不兼容,进门就转)。
|
||||||
|
const result: string[] = []
|
||||||
|
for (const line of content.replace(/\r\n/g, '\n').split('\n')) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (trimmed === '<aside>' || trimmed === '</aside>') continue
|
||||||
|
if (trimmed.startsWith(':::toggle')) {
|
||||||
|
const title = trimmed.slice(':::toggle'.length).trim()
|
||||||
|
result.push(title ? `**▸ ${title}**` : '**▸ 详情**')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (trimmed === ':::' || trimmed.startsWith(':::toc')) continue
|
||||||
|
result.push(stripNotionLinks(line.replace(/<br\s*\/?>/g, '\n')))
|
||||||
|
}
|
||||||
|
// Notion 粗体写法(**文字:**紧接正文)不合 CommonMark:给收尾星号补一口气
|
||||||
|
return result.join('\n').replace(/(\*\*[^*\n]+\*\*)(?=[^\s.,;:!?,。;:!?、)\]"'])/g, '$1 ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const CALLOUT_TINTS: [RegExp, string][] = [
|
||||||
|
[/📌|🌌|🧭|💜|🗂|🏷|📚/, 'callout-lavender'],
|
||||||
|
[/💡|⚡|🌟|☀|✨|🔑/, 'callout-amber'],
|
||||||
|
[/⚠|🔥|❗|🚨|❌/, 'callout-rose'],
|
||||||
|
[/✅|🌿|🍀|💚|✔/, 'callout-mint'],
|
||||||
|
[/🌊|💧|🔵|❄|🧊/, 'callout-sky'],
|
||||||
|
]
|
||||||
|
function normalizeTitle(value: string) {
|
||||||
|
return value.replace(/[\s·•・\-_|*`]/g, '').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function calloutTint(text: string) {
|
||||||
|
for (const [pattern, tint] of CALLOUT_TINTS) if (pattern.test(text)) return tint
|
||||||
|
return 'callout-slate'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markdownHtml(body: string, knownTitles: readonly string[] = []) {
|
||||||
|
const compat = notionCompat(body)
|
||||||
|
const titleMap = new Map<string, string>()
|
||||||
|
for (const title of knownTitles) titleMap.set(normalizeTitle(title), title)
|
||||||
|
const withWikiLinks = compat.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_whole, target: string, label?: string) =>
|
||||||
|
`<button class="wiki-link" type="button" data-wiki="${escapeHtml(target.trim())}">${escapeHtml((label || target).trim())}</button>`)
|
||||||
|
let headingIndex = 0
|
||||||
|
const renderer = new marked.Renderer()
|
||||||
|
renderer.heading = (token) => {
|
||||||
|
const id = `heading-${headingIndex}`
|
||||||
|
headingIndex += 1
|
||||||
|
const inlineHtml = token.tokens.length ? Parser.parseInline(token.tokens) : escapeHtml(token.text)
|
||||||
|
return `<h${token.depth} id="${id}">${inlineHtml}</h${token.depth}>\n`
|
||||||
|
}
|
||||||
|
renderer.blockquote = (token) => {
|
||||||
|
const inner = Parser.parse(token.tokens) as string
|
||||||
|
return `<blockquote class="callout ${calloutTint(token.raw)}">${inner}</blockquote>\n`
|
||||||
|
}
|
||||||
|
renderer.link = (token) => {
|
||||||
|
const inner = token.tokens.length ? Parser.parseInline(token.tokens) : escapeHtml(token.text)
|
||||||
|
const href = token.href || ''
|
||||||
|
if (/notion\.(?:so|site)/i.test(href)) {
|
||||||
|
// Notion 残留链接:认得出是自家页面就转成内部跳转,认不出标"外来页面"。
|
||||||
|
const hit = titleMap.get(normalizeTitle(token.text))
|
||||||
|
if (hit) return `<button class="wiki-link" type="button" data-wiki="${escapeHtml(hit)}">${inner}</button>`
|
||||||
|
return `<a class="external-link" href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${inner}<span class="external-mark">外来页面</span></a>`
|
||||||
|
}
|
||||||
|
if (/^https?:\/\//i.test(href)) return `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${inner}</a>`
|
||||||
|
return `<a href="${escapeHtml(href)}">${inner}</a>`
|
||||||
|
}
|
||||||
|
const html = marked.parse(withWikiLinks, { async: false, gfm: true, breaks: false, renderer }) as string
|
||||||
|
return DOMPurify.sanitize(html, { ADD_ATTR: ['data-wiki', 'id', 'target', 'rel'], ADD_TAGS: ['button'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function documentOutline(body: string) {
|
||||||
|
const headings: { id: string; level: number; title: string }[] = []
|
||||||
|
const plain = (tokens: unknown): string => (tokens as { type: string; text?: string; tokens?: unknown[] }[])
|
||||||
|
.map((token) => (token.tokens && token.tokens.length ? plain(token.tokens) : token.text || '')).join('')
|
||||||
|
marked.lexer(splitFrontmatter(body).content, { gfm: true })
|
||||||
|
.filter((token): token is Tokens.Heading => token.type === 'heading')
|
||||||
|
.forEach((token, index) => {
|
||||||
|
if (index >= 24) return
|
||||||
|
headings.push({ id: `heading-${index}`, level: token.depth, title: plain(token.tokens).trim() })
|
||||||
|
})
|
||||||
|
return headings
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jumpToHeading(id: string) {
|
||||||
|
const element = document.querySelector<HTMLElement>(`.document-scroll [id="${id}"]`)
|
||||||
|
if (!element) return
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
element.classList.remove('heading-flash')
|
||||||
|
void element.offsetWidth
|
||||||
|
element.classList.add('heading-flash')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkdownDocument({ body, knownTitles, onWiki }: { body: string; knownTitles?: readonly string[]; onWiki?: (target: string) => void }) {
|
||||||
|
const parsed = useMemo(() => splitFrontmatter(body), [body])
|
||||||
|
const html = useMemo(() => markdownHtml(parsed.content, knownTitles), [parsed.content, knownTitles])
|
||||||
|
return <article className="markdown-document" onClick={(event) => {
|
||||||
|
const element = (event.target as HTMLElement).closest<HTMLElement>('[data-wiki]')
|
||||||
|
if (element?.dataset.wiki && onWiki) onWiki(element.dataset.wiki)
|
||||||
|
}} dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
//! 模块形状声明 · 插座制(冰朔 2026-08-15 架构谕)
|
||||||
|
//!
|
||||||
|
//! 软件只认这一个插座形状:每个模块 = manifest(我是谁/插哪个口/从哪来)
|
||||||
|
//! + index(渲染入口导出)。常驻模块住在频道里;将来 origin='repo' 的
|
||||||
|
//! 模块躺代码仓库,人格体按需拉取部署。
|
||||||
|
export const knowledgeRenderManifest = {
|
||||||
|
moduleId: 'hololake.knowledge-render',
|
||||||
|
name: '知识渲染件',
|
||||||
|
version: '0.1.0',
|
||||||
|
slot: 'knowledge-render',
|
||||||
|
origin: 'resident',
|
||||||
|
exports: ['splitFrontmatter', 'documentOutline', 'jumpToHeading', 'MarkdownDocument'],
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type ModuleManifest = {
|
||||||
|
moduleId: string
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
slot: string
|
||||||
|
origin: 'resident' | 'repo'
|
||||||
|
exports: readonly string[]
|
||||||
|
}
|
||||||
|
|
@ -20,7 +20,7 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
width: 100%; height: 100%;
|
width: 100%; height: 100%;
|
||||||
display: grid; grid-template-columns: 226px minmax(0, 1fr);
|
display: grid; grid-template-columns: 226px minmax(0, 1fr); transition: grid-template-columns .18s ease;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 82% -12%, var(--primitive-nebula-a), transparent 31%),
|
radial-gradient(circle at 82% -12%, var(--primitive-nebula-a), transparent 31%),
|
||||||
|
|
@ -41,6 +41,9 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
|
||||||
}
|
}
|
||||||
.brand b { display: block; color: var(--content-primary); font-size: 18px; font-weight: 650; letter-spacing: .01em; }
|
.brand b { display: block; color: var(--content-primary); font-size: 18px; font-weight: 650; letter-spacing: .01em; }
|
||||||
.brand small { display: block; margin-top: 4px; color: var(--content-muted); font-size: 11px; font-weight: 520; letter-spacing: .22em; }
|
.brand small { display: block; margin-top: 4px; color: var(--content-muted); font-size: 11px; font-weight: 520; letter-spacing: .22em; }
|
||||||
|
.brand small.brand-sub { margin-top: 3px; font-size: 10.5px; font-weight: 500; letter-spacing: .04em; opacity: .82; }
|
||||||
|
.sign-out { flex: none; padding: 5px 10px; border: 1px solid var(--panel-edge); border-radius: 8px; background: transparent; color: var(--content-muted); font-size: 11.5px; cursor: pointer; }
|
||||||
|
.sign-out:hover { color: var(--content-secondary); border-color: var(--accent-light); }
|
||||||
.sidebar nav { display: grid; gap: 7px; margin-top: 54px; }
|
.sidebar nav { display: grid; gap: 7px; margin-top: 54px; }
|
||||||
.sidebar nav button {
|
.sidebar nav button {
|
||||||
min-height: 50px; display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: center; gap: 13px;
|
min-height: 50px; display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: center; gap: 13px;
|
||||||
|
|
@ -71,8 +74,10 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
|
||||||
.connection-strip i { background: var(--content-faint); box-shadow: none; }
|
.connection-strip i { background: var(--content-faint); box-shadow: none; }
|
||||||
.connection-strip i.online { background: var(--state-ready); box-shadow: 0 0 12px color-mix(in srgb, var(--state-ready) 52%, transparent); }
|
.connection-strip i.online { background: var(--state-ready); box-shadow: 0 0 12px color-mix(in srgb, var(--state-ready) 52%, transparent); }
|
||||||
.mcp-backup { padding: 4px 8px; border: 1px solid var(--panel-edge); border-radius: 7px; color: var(--content-muted); font-size: 11px; font-weight: 560; }
|
.mcp-backup { padding: 4px 8px; border: 1px solid var(--panel-edge); border-radius: 7px; color: var(--content-muted); font-size: 11px; font-weight: 560; }
|
||||||
.workspace { min-width: 0; min-height: 0; overflow: hidden; }
|
.workspace { min-width: 0; min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
|
||||||
.content-page { height: 100%; overflow: auto; padding: 48px clamp(28px, 4vw, 64px) 64px; }
|
.content-page { flex: 1; min-height: 0; overflow: auto; padding: 34px clamp(28px, 4vw, 64px) 64px; }
|
||||||
|
.zp-downgrade-wrap { flex: none; }
|
||||||
|
.zp-downgrade-wrap + .content-page { padding-top: 26px; }
|
||||||
.page-title { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 34px; }
|
.page-title { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 34px; }
|
||||||
.page-title h1 { margin: 8px 0 0; color: var(--content-primary); font-size: clamp(34px, 4vw, 48px); font-weight: 620; letter-spacing: -.035em; line-height: 1.08; }
|
.page-title h1 { margin: 8px 0 0; color: var(--content-primary); font-size: clamp(34px, 4vw, 48px); font-weight: 620; letter-spacing: -.035em; line-height: 1.08; }
|
||||||
.page-title p { margin: 10px 0 0; color: var(--content-muted); font-size: 15px; }
|
.page-title p { margin: 10px 0 0; color: var(--content-muted); font-size: 15px; }
|
||||||
|
|
@ -124,7 +129,8 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
|
||||||
.system-proof dd, .evidence-list dd, .document-inspector dd { margin: 0; color: var(--content-secondary); font-weight: 580; text-align: right; }
|
.system-proof dd, .evidence-list dd, .document-inspector dd { margin: 0; color: var(--content-secondary); font-weight: 580; text-align: right; }
|
||||||
|
|
||||||
.full-workbench { height: 100%; min-width: 0; min-height: 0; display: grid; background: color-mix(in srgb, var(--surface-depth) 70%, transparent); }
|
.full-workbench { height: 100%; min-width: 0; min-height: 0; display: grid; background: color-mix(in srgb, var(--surface-depth) 70%, transparent); }
|
||||||
.knowledge-page { grid-template-columns: 310px minmax(430px, 1fr) 278px; }
|
.knowledge-page { grid-template-columns: 336px minmax(0, 1fr) 288px; }
|
||||||
|
.knowledge-page.inspector-closed { grid-template-columns: 336px minmax(0, 1fr); }
|
||||||
.knowledge-browser, .document-inspector, .code-channels, .repository-tree {
|
.knowledge-browser, .document-inspector, .code-channels, .repository-tree {
|
||||||
min-width: 0; min-height: 0; display: flex; flex-direction: column;
|
min-width: 0; min-height: 0; display: flex; flex-direction: column;
|
||||||
border-right: 1px solid var(--panel-edge);
|
border-right: 1px solid var(--panel-edge);
|
||||||
|
|
@ -164,7 +170,9 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
|
||||||
}
|
}
|
||||||
.breadcrumbs { min-width: 0; display: flex; align-items: center; gap: 8px; color: var(--content-muted); font-size: 12px; }
|
.breadcrumbs { min-width: 0; display: flex; align-items: center; gap: 8px; color: var(--content-muted); font-size: 12px; }
|
||||||
.breadcrumbs span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.breadcrumbs span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.document-toolbar > div:last-child { display: flex; gap: 8px; }
|
.document-toolbar > div:last-child { display: flex; flex: none; gap: 8px; }
|
||||||
|
.document-toolbar .breadcrumbs { flex: 1 1 auto; }
|
||||||
|
.toolbar-actions .toolbar-button { white-space: nowrap; flex: none; }
|
||||||
.toolbar-button { min-height: 33px; padding: 0 12px; font-size: 12.5px; }
|
.toolbar-button { min-height: 33px; padding: 0 12px; font-size: 12.5px; }
|
||||||
.toolbar-button svg { width: 16px; height: 16px; }
|
.toolbar-button svg { width: 16px; height: 16px; }
|
||||||
.document-scroll, .code-reader-scroll { min-width: 0; min-height: 0; overflow: auto; scroll-behavior: smooth; }
|
.document-scroll, .code-reader-scroll { min-width: 0; min-height: 0; overflow: auto; scroll-behavior: smooth; }
|
||||||
|
|
@ -172,20 +180,71 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
|
||||||
.reader-heading h1 { margin: 0; color: var(--content-primary); font-size: clamp(32px, 3vw, 42px); font-weight: 680; letter-spacing: -.035em; line-height: 1.18; }
|
.reader-heading h1 { margin: 0; color: var(--content-primary); font-size: clamp(32px, 3vw, 42px); font-weight: 680; letter-spacing: -.035em; line-height: 1.18; }
|
||||||
.reader-heading div { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 20px; }
|
.reader-heading div { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 20px; }
|
||||||
.reader-heading span { padding: 5px 8px; border-radius: 6px; color: var(--content-muted); background: var(--primitive-glass); font-size: 12px; }
|
.reader-heading span { padding: 5px 8px; border-radius: 6px; color: var(--content-muted); background: var(--primitive-glass); font-size: 12px; }
|
||||||
|
.reader-heading .meta-stats { margin-top: 15px; color: var(--content-muted); font-size: 12.5px; letter-spacing: .03em; }
|
||||||
|
.knowledge-tree .tree-branch { position: relative; }
|
||||||
|
.tree-folder-tools { position: absolute; top: 4px; right: 6px; z-index: 5; }
|
||||||
|
.tree-folder-menu-button { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border: 0; border-radius: 6px; background: transparent; color: var(--content-muted); cursor: pointer; opacity: .55; }
|
||||||
|
.tree-branch:hover > .tree-folder-tools .tree-folder-menu-button { opacity: 1; }
|
||||||
|
.tree-folder-menu-button:hover { background: var(--primitive-glass-hover); color: var(--content-strong); }
|
||||||
|
.tree-folder-menu { position: absolute; top: 24px; right: 0; z-index: 70; display: flex; flex-direction: column; min-width: 132px; padding: 5px; border: 1px solid var(--panel-edge); border-radius: 9px; background: var(--panel-bg); box-shadow: 0 12px 30px rgba(0, 0, 0, .35); }
|
||||||
|
.tree-folder-menu button { display: flex; gap: 7px; align-items: center; padding: 6px 8px; border: 0; border-radius: 6px; background: transparent; color: var(--content-strong); font-size: 12.5px; text-align: left; cursor: pointer; white-space: nowrap; }
|
||||||
|
.tree-folder-menu button:hover { background: var(--primitive-glass-hover); color: rgb(251, 113, 133); }
|
||||||
|
.tree-folder-menu button svg { width: 13px; height: 13px; }
|
||||||
|
.toolbar-menu { position: relative; display: inline-flex; }
|
||||||
|
.toolbar-menu .toolbar-button svg:last-child { width: 12px; height: 12px; opacity: .7; }
|
||||||
|
.toolbar-menu-pop { position: absolute; top: calc(100% + 6px); right: 0; z-index: 60; display: flex; flex-direction: column; min-width: 196px; padding: 6px; border: 1px solid var(--panel-edge); border-radius: 10px; background: var(--panel-bg); box-shadow: 0 12px 30px rgba(0, 0, 0, .35); }
|
||||||
|
.toolbar-menu-pop button { display: flex; gap: 8px; align-items: center; white-space: nowrap; }
|
||||||
|
.toolbar-menu-pop button svg { width: 13px; height: 13px; flex: none; }
|
||||||
|
.toolbar-menu-sep { display: block; height: 1px; margin: 5px 4px; background: var(--panel-edge); }
|
||||||
|
.toolbar-menu-pop button { padding: 8px 10px; border: 0; border-radius: 7px; background: transparent; color: var(--content-secondary); text-align: left; font-size: 13px; cursor: pointer; }
|
||||||
|
.toolbar-menu-pop button:hover { background: var(--primitive-glass-hover); color: var(--content-primary); }
|
||||||
|
@media print {
|
||||||
|
.app-sidebar, .knowledge-tree, .document-toolbar, .document-inspector, .sidebar-toggle, .outline-panel { display: none !important; }
|
||||||
|
.app-shell, .full-workbench, .knowledge-page, .document-workspace { display: block !important; grid-template-columns: none !important; height: auto !important; overflow: visible !important; background: #fff !important; }
|
||||||
|
.document-scroll { height: auto !important; overflow: visible !important; padding: 0 !important; }
|
||||||
|
.reader-heading .meta-tags span, .meta-stats { color: #444 !important; }
|
||||||
|
body, .markdown-document, .reader-heading h1 { background: #fff !important; color: #111 !important; }
|
||||||
|
.markdown-document blockquote.callout { background: #f5f3ff !important; border-left-color: #a78bfa !important; color: #333 !important; }
|
||||||
|
.markdown-document pre, .markdown-document code { background: #f2f3f5 !important; color: #222 !important; }
|
||||||
|
}
|
||||||
|
.markdown-document .external-link { text-decoration: underline dashed; text-underline-offset: 3px; }
|
||||||
|
.markdown-document .external-mark { display: inline-block; margin-left: 6px; padding: 1px 8px; border: 1px solid var(--panel-edge); border-radius: 999px; background: var(--primitive-glass); color: var(--content-muted); font-size: 10.5px; letter-spacing: .05em; vertical-align: 1px; }
|
||||||
|
.markdown-document details.toggle-block { margin: 22px 0; border: 1px solid var(--panel-edge); border-radius: 10px; background: var(--primitive-glass); }
|
||||||
|
.markdown-document details.toggle-block summary { padding: 12px 16px; border-radius: 10px; color: var(--content-primary); font-weight: 620; cursor: pointer; list-style: none; }
|
||||||
|
.markdown-document details.toggle-block summary::before { content: '▸ '; color: var(--accent-light); }
|
||||||
|
.markdown-document details.toggle-block[open] summary::before { content: '▾ '; }
|
||||||
|
.markdown-document details.toggle-block .toggle-body { padding: 2px 16px 14px; border-top: 1px dashed var(--panel-edge); }
|
||||||
|
.reader-heading .meta-tags span.tag-lavender { border-color: rgba(167, 139, 250, .5); background: rgba(167, 139, 250, .16); color: rgb(200, 183, 255); }
|
||||||
|
.reader-heading .meta-tags span.tag-sky { border-color: rgba(56, 189, 248, .45); background: rgba(56, 189, 248, .14); color: rgb(151, 219, 252); }
|
||||||
|
.reader-heading .meta-tags span.tag-mint { border-color: rgba(52, 211, 153, .45); background: rgba(52, 211, 153, .14); color: rgb(148, 232, 195); }
|
||||||
|
.reader-heading .meta-tags span.tag-amber { border-color: rgba(251, 191, 36, .45); background: rgba(251, 191, 36, .13); color: rgb(252, 220, 145); }
|
||||||
|
.reader-heading .meta-tags span.tag-rose { border-color: rgba(251, 113, 133, .45); background: rgba(251, 113, 133, .13); color: rgb(253, 186, 196); }
|
||||||
|
.reader-heading .meta-tags span.tag-slate { border-color: rgba(148, 163, 184, .4); background: rgba(148, 163, 184, .12); color: rgb(203, 213, 225); }
|
||||||
|
.reader-heading .meta-tags span { padding: 4px 12px; border: 1px solid color-mix(in srgb, var(--accent-light) 32%, transparent); border-radius: 999px; color: var(--accent-light); background: color-mix(in srgb, var(--accent-light) 9%, transparent); font-weight: 600; }
|
||||||
.markdown-document { max-width: 870px; margin: 0 auto; padding: 20px 54px 100px; color: var(--content-secondary); font-size: 17px; font-weight: 430; line-height: 1.78; overflow-wrap: anywhere; }
|
.markdown-document { max-width: 870px; margin: 0 auto; padding: 20px 54px 100px; color: var(--content-secondary); font-size: 17px; font-weight: 430; line-height: 1.78; overflow-wrap: anywhere; }
|
||||||
.markdown-document h1, .markdown-document h2, .markdown-document h3, .markdown-document h4 { color: var(--content-primary); font-weight: 680; line-height: 1.34; letter-spacing: -.02em; }
|
.markdown-document h1, .markdown-document h2, .markdown-document h3, .markdown-document h4 { color: var(--content-primary); font-weight: 680; line-height: 1.34; letter-spacing: -.02em; }
|
||||||
.markdown-document h1 { margin: 48px 0 20px; font-size: 34px; }
|
.markdown-document h1 { margin: 48px 0 20px; font-size: 34px; }
|
||||||
.markdown-document h2 { margin: 42px 0 17px; font-size: 27px; }
|
.markdown-document h2 { margin: 42px 0 17px; padding-bottom: 9px; font-size: 27px; background: linear-gradient(90deg, var(--accent-light), transparent 72%) left bottom / 100% 1px no-repeat; }
|
||||||
|
.markdown-document hr { height: 1px; margin: 36px 0; border: 0; background: linear-gradient(90deg, transparent, var(--accent-light), transparent); opacity: .45; }
|
||||||
.markdown-document h3 { margin: 34px 0 14px; font-size: 22px; }
|
.markdown-document h3 { margin: 34px 0 14px; font-size: 22px; }
|
||||||
.markdown-document h4 { margin: 28px 0 12px; font-size: 18px; }
|
.markdown-document h4 { margin: 28px 0 12px; font-size: 18px; }
|
||||||
.markdown-document p { margin: 0 0 20px; }
|
.markdown-document p { margin: 0 0 20px; }
|
||||||
.markdown-document ul, .markdown-document ol { margin: 0 0 22px; padding-left: 27px; }
|
.markdown-document ul, .markdown-document ol { margin: 0 0 22px; padding-left: 27px; }
|
||||||
.markdown-document li { margin: 6px 0; }
|
.markdown-document li { margin: 6px 0; }
|
||||||
.markdown-document a { color: color-mix(in srgb, var(--accent-light) 78%, var(--content-primary)); text-decoration-thickness: 1px; text-underline-offset: 3px; }
|
.markdown-document a { color: color-mix(in srgb, var(--accent-light) 78%, var(--content-primary)); text-decoration-thickness: 1px; text-underline-offset: 3px; }
|
||||||
.markdown-document blockquote { margin: 25px 0; padding: 14px 19px; border-left: 3px solid var(--accent-light); color: var(--content-muted); background: var(--primitive-glass); }
|
.markdown-document blockquote.callout { margin: 25px 0; padding: 15px 19px; border: 0; border-left: 3px solid; border-radius: 9px; color: var(--content-secondary); font-size: 14.5px; }
|
||||||
|
.markdown-document blockquote.callout p { margin: 6px 0; }
|
||||||
|
.callout-slate { border-left-color: rgba(148, 163, 184, .65); background: rgba(148, 163, 184, .10); }
|
||||||
|
.callout-lavender { border-left-color: rgba(167, 139, 250, .7); background: rgba(167, 139, 250, .13); }
|
||||||
|
.callout-sky { border-left-color: rgba(56, 189, 248, .6); background: rgba(56, 189, 248, .11); }
|
||||||
|
.callout-mint { border-left-color: rgba(52, 211, 153, .6); background: rgba(52, 211, 153, .11); }
|
||||||
|
.callout-amber { border-left-color: rgba(251, 191, 36, .65); background: rgba(251, 191, 36, .11); }
|
||||||
|
.callout-rose { border-left-color: rgba(251, 113, 133, .6); background: rgba(251, 113, 133, .11); }
|
||||||
.markdown-document pre, .source-code { overflow: auto; padding: 19px; border: 1px solid var(--panel-edge); border-radius: 10px; color: var(--content-secondary); background: rgba(0, 0, 0, .25); font: 13.5px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; tab-size: 2; }
|
.markdown-document pre, .source-code { overflow: auto; padding: 19px; border: 1px solid var(--panel-edge); border-radius: 10px; color: var(--content-secondary); background: rgba(0, 0, 0, .25); font: 13.5px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; tab-size: 2; }
|
||||||
.markdown-document code:not(pre code) { padding: 2px 5px; border-radius: 5px; background: var(--primitive-glass-hover); font: .88em ui-monospace, SFMono-Regular, Menlo, monospace; }
|
.markdown-document code:not(pre code) { padding: 2px 5px; border-radius: 5px; background: var(--primitive-glass-hover); font: .88em ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||||
.markdown-document table { width: 100%; display: table; margin: 25px 0; border-collapse: collapse; font-size: 14px; line-height: 1.5; }
|
.markdown-document table { width: 100%; display: table; margin: 25px 0; border-collapse: collapse; border-radius: 10px; font-size: 14px; line-height: 1.5; }
|
||||||
|
.markdown-document table th { background: var(--primitive-glass-hover); color: var(--content-primary); }
|
||||||
|
.markdown-document table tbody tr:nth-child(even) { background: var(--primitive-glass); }
|
||||||
.markdown-document th, .markdown-document td { padding: 10px 12px; border: 1px solid var(--panel-edge); text-align: left; vertical-align: top; }
|
.markdown-document th, .markdown-document td { padding: 10px 12px; border: 1px solid var(--panel-edge); text-align: left; vertical-align: top; }
|
||||||
.markdown-document th { color: var(--content-primary); background: var(--primitive-glass-hover); font-weight: 650; }
|
.markdown-document th { color: var(--content-primary); background: var(--primitive-glass-hover); font-weight: 650; }
|
||||||
.markdown-document img { max-width: 100%; border-radius: 10px; }
|
.markdown-document img { max-width: 100%; border-radius: 10px; }
|
||||||
|
|
@ -206,6 +265,9 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
|
||||||
.inspector-scroll code { display: block; overflow-wrap: anywhere; color: var(--content-muted); font-size: 11px; }
|
.inspector-scroll code { display: block; overflow-wrap: anywhere; color: var(--content-muted); font-size: 11px; }
|
||||||
.outline-list { display: grid; gap: 10px; }
|
.outline-list { display: grid; gap: 10px; }
|
||||||
.outline-list span { overflow: hidden; color: var(--content-muted); font-size: 12.5px; text-overflow: ellipsis; white-space: nowrap; }
|
.outline-list span { overflow: hidden; color: var(--content-muted); font-size: 12.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.outline-list button { display: block; width: 100%; overflow: hidden; padding: 5px 8px; border: 0; border-radius: 7px; color: var(--content-muted); background: transparent; font-size: 12.5px; text-align: left; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
|
||||||
|
.outline-list button.active { color: var(--content-primary); background: var(--primitive-glass-hover); box-shadow: inset 2px 0 0 var(--accent-light); }
|
||||||
|
.outline-list button:hover { color: var(--content-primary); background: var(--primitive-glass); }
|
||||||
.workbench-empty, .empty-state { display: grid; place-content: center; justify-items: center; color: var(--content-muted); text-align: center; }
|
.workbench-empty, .empty-state { display: grid; place-content: center; justify-items: center; color: var(--content-muted); text-align: center; }
|
||||||
.workbench-empty { min-height: 0; padding: 40px; }
|
.workbench-empty { min-height: 0; padding: 40px; }
|
||||||
.workbench-empty > span { color: var(--accent-light); font-family: "Songti SC", serif; font-size: 36px; }
|
.workbench-empty > span { color: var(--accent-light); font-family: "Songti SC", serif; font-size: 36px; }
|
||||||
|
|
@ -293,3 +355,84 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; }
|
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 布局收展(学 Tolaria:侧栏可收,正文与知识库拿回宽度) ===== */
|
||||||
|
.app-shell.sidebar-collapsed { grid-template-columns: 80px minmax(0, 1fr); }
|
||||||
|
.sidebar-toggle { width: 30px; height: 30px; margin-left: auto; border-radius: 8px; }
|
||||||
|
.sidebar-toggle svg { transition: transform .18s ease; }
|
||||||
|
.app-shell.sidebar-collapsed .brand { flex-direction: column; gap: 10px; padding: 0 0 4px; }
|
||||||
|
.app-shell.sidebar-collapsed .brand-text,
|
||||||
|
.app-shell.sidebar-collapsed .sidebar nav button span,
|
||||||
|
.app-shell.sidebar-collapsed .sidebar nav button em,
|
||||||
|
.app-shell.sidebar-collapsed .sidebar-foot div { display: none; }
|
||||||
|
.app-shell.sidebar-collapsed .sidebar-toggle svg { transform: rotate(180deg); }
|
||||||
|
.app-shell.sidebar-collapsed .sidebar nav button { grid-template-columns: 24px; justify-content: center; padding: 0; }
|
||||||
|
.app-shell.sidebar-collapsed .brand-mark { cursor: pointer; }
|
||||||
|
.app-shell.sidebar-collapsed .sidebar-foot { grid-template-columns: 1fr; justify-items: center; gap: 8px; padding: 14px 0 0; }
|
||||||
|
.app-shell.sidebar-collapsed .sign-out { padding: 4px 8px; font-size: 11px; }
|
||||||
|
.toolbar-actions { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.toolbar-actions .toolbar-button svg { width: 15px; height: 15px; }
|
||||||
|
.toolbar-actions .toolbar-button[title^="收起"] svg { transform: rotate(90deg); }
|
||||||
|
.toolbar-actions .toolbar-button[title^="展开"] svg { transform: rotate(-90deg); }
|
||||||
|
.markdown-document :is(h1, h2, h3, h4) { scroll-margin-top: 16px; }
|
||||||
|
.heading-flash { animation: heading-flash 1.3s ease; }
|
||||||
|
@keyframes heading-flash { 0% { background: color-mix(in srgb, var(--accent-light) 24%, transparent); } 100% { background: transparent; } }
|
||||||
|
|
||||||
|
/* ===== 光湖灯:品牌徽的灯亮着,一闪一闪,湖面有微光 ===== */
|
||||||
|
.brand-lamp { position: relative; overflow: hidden; }
|
||||||
|
.brand-lamp .lamp-glow { position: absolute; inset: -7px; border-radius: inherit; background: radial-gradient(circle at 50% 32%, color-mix(in srgb, var(--accent-light) 36%, transparent), transparent 62%); animation: lamp-breathe 5s ease-in-out infinite; }
|
||||||
|
.brand-lamp .lamp-core { position: absolute; top: 8px; left: 50%; width: 9px; height: 12px; transform: translateX(-50%); border-radius: 50% 50% 46% 46% / 58% 58% 42% 42%; background: var(--accent-light); box-shadow: 0 0 8px color-mix(in srgb, var(--accent-light) 70%, transparent); animation: lamp-flicker 5.2s infinite; }
|
||||||
|
.brand-lamp .lake-shimmer { position: absolute; right: 7px; bottom: 8px; left: 7px; height: 2px; border-radius: 2px; opacity: .8; background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent-light) 72%, transparent), transparent); background-size: 200% 100%; animation: lake-shimmer 3.6s linear infinite; }
|
||||||
|
@keyframes lamp-flicker { 0%, 100% { opacity: 1; } 42% { opacity: 1; } 44% { opacity: .3; } 46% { opacity: 1; } 71% { opacity: .94; } 73% { opacity: .45; } 75% { opacity: 1; } }
|
||||||
|
@keyframes lamp-breathe { 0%, 100% { opacity: .68; } 50% { opacity: 1; } }
|
||||||
|
@keyframes lake-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||||
|
|
||||||
|
/* ===== 会客厅(Agent 接线 v1)===== */
|
||||||
|
.parlor-page { display: flex; flex-direction: column; }
|
||||||
|
.parlor-layer { display: flex; align-items: center; gap: 10px; flex: none; }
|
||||||
|
.parlor-scroll { flex: 1; min-height: 200px; overflow: auto; display: flex; flex-direction: column; gap: 12px; padding: 18px; border: 1px solid var(--panel-edge); border-radius: 12px; background: var(--primitive-glass); }
|
||||||
|
.parlor-row { display: flex; flex-direction: column; gap: 4px; max-width: 760px; }
|
||||||
|
.parlor-row.user { align-self: flex-end; align-items: flex-end; }
|
||||||
|
.parlor-row.agent { align-self: flex-start; align-items: flex-start; }
|
||||||
|
.parlor-row.note { align-self: center; }
|
||||||
|
.parlor-who { font-size: 11px; font-weight: 650; letter-spacing: .08em; color: var(--content-muted); }
|
||||||
|
.parlor-bubble { padding: 10px 14px; border-radius: 12px; border: 1px solid var(--panel-edge); color: var(--content-primary); font-size: 14px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; background: color-mix(in srgb, var(--accent-light) 7%, transparent); }
|
||||||
|
.parlor-row.user .parlor-bubble { background: color-mix(in srgb, var(--accent-light) 16%, transparent); border-color: color-mix(in srgb, var(--accent-light) 34%, transparent); }
|
||||||
|
.parlor-bubble.thinking { opacity: .6; animation: lamp-breathe 1.6s infinite; }
|
||||||
|
.parlor-note { margin: 0; padding: 7px 13px; border-radius: 999px; border: 1px dashed color-mix(in srgb, var(--accent-light) 30%, transparent); color: var(--content-muted); font-size: 12px; line-height: 1.6; align-self: center; max-width: 760px; text-align: center; }
|
||||||
|
.parlor-input-row { display: flex; gap: 10px; margin-top: 14px; flex: none; }
|
||||||
|
.parlor-input-row input { flex: 1; padding: 12px 15px; border: 1px solid var(--panel-edge); border-radius: 10px; background: var(--primitive-glass); color: var(--content-primary); font-size: 14px; }
|
||||||
|
.parlor-input-row input:focus { outline: none; border-color: color-mix(in srgb, var(--accent-light) 55%, transparent); }
|
||||||
|
/* 零点原核频道·双层路由导航(施工图 v1.4 件4/4b) */
|
||||||
|
.zp-downgrade-wrap { padding: 26px clamp(28px, 4vw, 64px) 0; }
|
||||||
|
.zp-downgrade-card { display: flex; align-items: center; gap: 16px; padding: 16px 20px; border: 1px solid color-mix(in srgb, var(--accent-light) 30%, var(--panel-edge)); border-radius: 14px; background: color-mix(in srgb, var(--panel-bg) 88%, transparent); }
|
||||||
|
.zp-downgrade-card .zp-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--accent-light); box-shadow: 0 0 14px color-mix(in srgb, var(--accent-light) 55%, transparent); }
|
||||||
|
.zp-downgrade-card .zp-copy { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||||
|
.zp-downgrade-card .zp-copy b { color: var(--content-primary); font-size: 13.5px; font-weight: 650; }
|
||||||
|
.zp-downgrade-card .zp-copy span { color: var(--content-muted); font-size: 12.5px; line-height: 1.5; }
|
||||||
|
.zp-downgrade-card button { margin-left: auto; flex: none; display: inline-flex; align-items: center; gap: 6px; padding: 9px 15px; border: 1px solid color-mix(in srgb, var(--accent-light) 34%, transparent); border-radius: 999px; color: var(--accent-light); background: color-mix(in srgb, var(--accent-light) 9%, transparent); font-size: 12.5px; font-weight: 650; cursor: pointer; }
|
||||||
|
.zp-downgrade-card button:hover { border-color: var(--accent-light); background: color-mix(in srgb, var(--accent-light) 16%, transparent); }
|
||||||
|
.zp-api-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
|
||||||
|
.zp-api-row input { flex: 1 1 180px; min-width: 140px; }
|
||||||
|
.zp-api-panel { margin-bottom: 14px; }
|
||||||
|
|
||||||
|
/* 五湖登录开场(2026-08-15 冰朔谕):五域悬浮湖面,登录通过=第五域浮起、镜头沉入。
|
||||||
|
审美纪律:流光不用线条(EXP-106)、未开域幽静待建(BRAIN 认知一)。 */
|
||||||
|
.lake-scene { overflow: hidden; transition: opacity .85s ease; }
|
||||||
|
.lake-scene.sinking { opacity: 0; }
|
||||||
|
.lake-bays { position: absolute; left: 0; right: 0; bottom: 8%; display: flex; align-items: flex-end; justify-content: center; gap: clamp(28px, 6vw, 104px); pointer-events: none; }
|
||||||
|
.lake-bay { position: relative; display: flex; flex-direction: column; align-items: center; gap: 16px; animation: bay-breathe 6s ease-in-out infinite; }
|
||||||
|
.lake-bay:nth-child(2) { animation-delay: 1.3s; }
|
||||||
|
.lake-bay:nth-child(3) { animation-delay: 2.6s; }
|
||||||
|
.lake-bay:nth-child(4) { animation-delay: .8s; }
|
||||||
|
.lake-bay:nth-child(5) { animation-delay: 1.9s; }
|
||||||
|
.lake-bay i { width: 64px; height: 64px; border-radius: 50%; filter: blur(2px); opacity: .26; background: radial-gradient(circle at 50% 36%, color-mix(in srgb, var(--primitive-cool-glow) 72%, transparent), color-mix(in srgb, var(--primitive-cool-glow) 26%, transparent) 48%, transparent 72%); }
|
||||||
|
.lake-bay b { color: var(--content-faint); font-size: 12px; letter-spacing: .34em; font-weight: 500; text-indent: .34em; }
|
||||||
|
.lake-bay.fifth i { width: 92px; height: 92px; opacity: .72; background: radial-gradient(circle at 50% 36%, var(--primitive-warm-glow), color-mix(in srgb, var(--primitive-warm-glow) 34%, transparent) 50%, transparent 74%); box-shadow: 0 0 58px color-mix(in srgb, var(--primitive-warm-glow) 42%, transparent); }
|
||||||
|
.lake-bay.fifth b { color: var(--accent-light); }
|
||||||
|
.lake-bay.rising { animation: bay-rise 1.25s cubic-bezier(.32, .72, .36, 1) forwards; z-index: 2; }
|
||||||
|
.lake-bay.rising i { opacity: 1; box-shadow: 0 0 120px color-mix(in srgb, var(--primitive-warm-glow) 65%, transparent); }
|
||||||
|
@keyframes bay-breathe { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
|
||||||
|
@keyframes bay-rise { 0% { transform: translateY(0) scale(1); } 55% { transform: translateY(-52px) scale(1.2); } 100% { transform: translateY(-150px) scale(1.9); } }
|
||||||
|
.onboarding-card { position: relative; z-index: 1; transition: opacity .7s ease, transform .7s ease; }
|
||||||
|
.onboarding-card.fade-out { opacity: 0; transform: translateY(16px) scale(.985); }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue