diff --git a/scripts/check-rust-structure.mjs b/scripts/check-rust-structure.mjs index 4ec7e3381..4e7d55526 100644 --- a/scripts/check-rust-structure.mjs +++ b/scripts/check-rust-structure.mjs @@ -22,7 +22,7 @@ const checks = [ }, { path: "src-tauri/src/commands/storage/imports/marinara.rs", - maxLines: 750, + maxLines: 1000, forbiddenPatterns: [], reason: "Marinara envelope import logic should stay focused and split if it grows further", }, diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9bda67921..1725500bc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -25,7 +25,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -96,12 +96,56 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -302,8 +346,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autoagents" version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ea33cd9c456faf15b4d753076f8e9a592b94d65a6e1886aaf07c4c13d9b6bcf" +source = "git+https://github.com/liquidos-ai/AutoAgents.git?rev=57ebeaa4e18989909013ebd58351b3ef6a5586e0#57ebeaa4e18989909013ebd58351b3ef6a5586e0" dependencies = [ "async-trait", "autoagents-core", @@ -315,8 +358,7 @@ dependencies = [ [[package]] name = "autoagents-core" version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fa1ac1c801e98323ef3ef8470104f95348e98d976070ba6a0e4c0611a9f367c" +source = "git+https://github.com/liquidos-ai/AutoAgents.git?rev=57ebeaa4e18989909013ebd58351b3ef6a5586e0#57ebeaa4e18989909013ebd58351b3ef6a5586e0" dependencies = [ "async-trait", "autoagents-llm", @@ -328,6 +370,7 @@ dependencies = [ "log", "ractor", "regex", + "schemars 0.8.22", "serde", "serde_json", "thiserror 2.0.18", @@ -342,11 +385,11 @@ dependencies = [ [[package]] name = "autoagents-derive" version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f710bf6e07c5def09bfaed423290a4fb3646763938910c60641821a36d6e1320" +source = "git+https://github.com/liquidos-ai/AutoAgents.git?rev=57ebeaa4e18989909013ebd58351b3ef6a5586e0#57ebeaa4e18989909013ebd58351b3ef6a5586e0" dependencies = [ "proc-macro2", "quote", + "schemars 0.8.22", "serde", "serde_json", "strum", @@ -356,12 +399,12 @@ dependencies = [ [[package]] name = "autoagents-llm" version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4afe50cb2767b514559748c23b60e815a91a24d5422fb01041fcc0b3e1babb97" +source = "git+https://github.com/liquidos-ai/AutoAgents.git?rev=57ebeaa4e18989909013ebd58351b3ef6a5586e0#57ebeaa4e18989909013ebd58351b3ef6a5586e0" dependencies = [ "async-trait", "autoagents-protocol", "base64 0.22.1", + "bytes", "chrono", "dirs", "either", @@ -385,8 +428,7 @@ dependencies = [ [[package]] name = "autoagents-protocol" version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831a39521bcbb57b41d7a92bd064fd662e55f59ca862340407d878192f480c21" +source = "git+https://github.com/liquidos-ai/AutoAgents.git?rev=57ebeaa4e18989909013ebd58351b3ef6a5586e0#57ebeaa4e18989909013ebd58351b3ef6a5586e0" dependencies = [ "serde", "serde_json", @@ -488,6 +530,52 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bashkit" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe1ecee86bc21d5c724060061ca37194b383dbda3a45ce97df72a59edb55f57" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "bigdecimal", + "chrono", + "clap", + "fancy-regex 0.18.0", + "flate2", + "futures-core", + "futures-util", + "getrandom 0.4.2", + "hmac", + "md-5 0.11.0", + "num-traits", + "os_display", + "regex", + "serde", + "serde_json", + "sha1 0.11.0", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "tower", + "unit-prefix", + "url", +] + +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -527,6 +615,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -885,8 +982,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -923,7 +1022,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -934,6 +1033,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -942,8 +1042,22 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -961,6 +1075,18 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -980,6 +1106,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cookie" version = "0.18.1" @@ -1068,6 +1200,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1162,6 +1303,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cssparser" version = "0.36.0" @@ -1201,6 +1351,15 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.20.11" @@ -1381,8 +1540,20 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -1690,6 +1861,17 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -2316,6 +2498,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -2377,6 +2568,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -2682,6 +2882,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -2848,7 +3054,7 @@ dependencies = [ "bytecount", "data-encoding", "email_address", - "fancy-regex", + "fancy-regex 0.17.0", "fraction", "getrandom 0.3.4", "idna", @@ -2937,6 +3143,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.16" @@ -2995,12 +3207,12 @@ dependencies = [ "indexmap 2.14.0", "itoa", "log", - "md-5", + "md-5 0.10.6", "nom", "nom_locate", "rand", "rangemap", - "sha2", + "sha2 0.10.9", "stringprep", "thiserror 2.0.18", "ttf-parser", @@ -3055,6 +3267,7 @@ dependencies = [ "autoagents", "axum", "base64 0.22.1", + "bashkit", "buttplug", "buttplug_core", "chrono", @@ -3072,7 +3285,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -3147,7 +3360,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", ] [[package]] @@ -3606,6 +3829,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oorandom" version = "11.1.5" @@ -3646,6 +3875,15 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_display" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad5fd71b79026fb918650dde6d125000a233764f1c2f1659a1c71118e33ea08f" +dependencies = [ + "unicode-width", +] + [[package]] name = "outref" version = "0.5.2" @@ -4879,8 +5117,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -4890,8 +5139,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -5315,7 +5575,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "syn 2.0.117", "tauri-utils", "thiserror 2.0.18", @@ -6044,7 +6304,7 @@ dependencies = [ "rand", "rustls", "rustls-pki-types", - "sha1", + "sha1 0.10.6", "thiserror 2.0.18", "url", "utf-8", @@ -6168,12 +6428,24 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "untrusted" version = "0.9.0" @@ -6255,6 +6527,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.1" @@ -7227,7 +7505,7 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2", + "sha2 0.10.9", "soup3", "tao-macros", "thiserror 2.0.18", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 67c866389..49393e8db 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,7 +34,8 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -autoagents = "0.3.7" +autoagents = { git = "https://github.com/liquidos-ai/AutoAgents.git", rev = "57ebeaa4e18989909013ebd58351b3ef6a5586e0" } +bashkit = "0.7.1" axum = "0.7" base64 = "0.22" buttplug = "10.0.2" diff --git a/src-tauri/crates/llm/src/lib.rs b/src-tauri/crates/llm/src/lib.rs index 8e0fd421a..631efbb80 100644 --- a/src-tauri/crates/llm/src/lib.rs +++ b/src-tauri/crates/llm/src/lib.rs @@ -201,16 +201,16 @@ fn data_url_image(value: &str) -> Option<(&str, &str)> { Some((mime, data)) } -fn max_tokens(parameters: &Value, fallback: u64) -> u64 { +fn requested_max_tokens(parameters: &Value) -> Option { parameters .get("maxTokens") .or_else(|| parameters.get("max_tokens")) .and_then(Value::as_u64) - .unwrap_or(fallback) + .filter(|value| *value > 0) } fn request_max_tokens(request: &LlmRequest, fallback: u64) -> u64 { - let value = max_tokens(&request.parameters, fallback); + let value = requested_max_tokens(&request.parameters).unwrap_or(fallback); request .connection .max_tokens_override @@ -219,6 +219,18 @@ fn request_max_tokens(request: &LlmRequest, fallback: u64) -> u64 { .unwrap_or(value) } +fn optional_request_max_tokens(request: &LlmRequest) -> Option { + match ( + requested_max_tokens(&request.parameters), + request.connection.max_tokens_override.filter(|cap| *cap > 0), + ) { + (Some(value), Some(cap)) => Some(value.min(cap)), + (Some(value), None) => Some(value), + (None, Some(cap)) => Some(cap), + (None, None) => None, + } +} + fn ensure_url_allowed(url: &str) -> AppResult<()> { if is_allowed_outbound_url(url, true) { Ok(()) @@ -284,8 +296,12 @@ fn should_send_top_k(request: &LlmRequest) -> bool { fn provider_error_text(details: &Value) -> Option { [ details.pointer("/error/message").and_then(Value::as_str), + details + .pointer("/response/error/message") + .and_then(Value::as_str), details.get("message").and_then(Value::as_str), details.pointer("/error").and_then(Value::as_str), + details.pointer("/response/error").and_then(Value::as_str), ] .into_iter() .flatten() @@ -513,8 +529,10 @@ async fn complete_openai_compatible_rich(request: LlmRequest) -> AppResult AppResult AppResult Value { "model": request.connection.model, "input": responses_input(&messages), "stream": stream, - "max_output_tokens": request_max_tokens(request, 1024), }); + if let Some(max_tokens) = optional_request_max_tokens(request) { + body["max_output_tokens"] = json!(max_tokens); + } if let Some(effort) = reasoning_effort(&request.parameters) { body["reasoning"] = json!({ "effort": effort, "summary": "auto" }); } @@ -1352,9 +1374,11 @@ async fn complete_google(request: LlmRequest) -> AppResult { "contents": contents, "generationConfig": { "temperature": temperature(&request.parameters).unwrap_or(0.7), - "maxOutputTokens": request_max_tokens(&request, 1024), } }); + if let Some(max_tokens) = optional_request_max_tokens(&request) { + body["generationConfig"]["maxOutputTokens"] = json!(max_tokens); + } if let Some(top_p) = param_f64(&request.parameters, &["topP", "top_p"]) { body["generationConfig"]["topP"] = json!(top_p); } @@ -1455,14 +1479,78 @@ fn response_reasoning_text(choice: &Value, message: &Value) -> String { .unwrap_or_default() } -async fn parse_json_response_rich(response: reqwest::Response) -> AppResult { +fn llm_debug_context(request: &LlmRequest, endpoint: &str, body: &Value) -> Value { + let messages = request_messages(request) + .iter() + .map(|message| { + json!({ + "role": message.role, + "contentChars": message.content.chars().count(), + "imageCount": message.images.len(), + "hasToolCallId": message.tool_call_id.is_some(), + "hasToolCalls": message.tool_calls.is_some(), + }) + }) + .collect::>(); + let tool_names = request + .tools + .iter() + .filter_map(|tool| tool.get("name").and_then(Value::as_str)) + .collect::>(); + json!({ + "provider": request.connection.provider, + "model": request.connection.model, + "endpoint": endpoint, + "messageCount": messages.len(), + "messages": messages, + "toolCount": request.tools.len(), + "toolNames": tool_names, + "parameters": request.parameters, + "bodyKeys": body.as_object().map(|object| object.keys().cloned().collect::>()).unwrap_or_default(), + }) +} + +fn text_excerpt(value: &str, max_chars: usize) -> String { + value.chars().take(max_chars).collect() +} + +fn response_debug_details(context: Value, response: Value) -> Value { + json!({ + "context": context, + "response": response, + }) +} + +async fn parse_json_response_rich( + response: reqwest::Response, + debug_context: Value, +) -> AppResult { let status = response.status(); - let json: Value = response - .json() + let body_text = response + .text() .await .map_err(|error| AppError::new("llm_response_error", error.to_string()))?; + let json: Value = serde_json::from_str(&body_text).map_err(|error| { + AppError::with_details( + "llm_response_error", + format!("Provider response was not valid JSON: {error}"), + json!({ + "context": debug_context.clone(), + "responseText": text_excerpt(&body_text, 4096), + }), + ) + })?; if !status.is_success() { - return Err(provider_http_error(status, json)); + return Err(provider_http_error( + status, + response_debug_details(debug_context, json), + )); + } + if json.get("error").is_some() && json.get("choices").is_none() { + return Err(provider_http_error( + status, + response_debug_details(debug_context, json), + )); } let choice = json .get("choices") @@ -1472,7 +1560,7 @@ async fn parse_json_response_rich(response: reqwest::Response) -> AppResult AppResult) .map_err(|error| AppError::new("spotify_authorize_open_failed", error.to_string()))?; Ok(response) diff --git a/src-tauri/src/commands/storage/commands/mari.rs b/src-tauri/src/commands/storage/commands/mari.rs index a1fecb9ea..be4d51307 100644 --- a/src-tauri/src/commands/storage/commands/mari.rs +++ b/src-tauri/src/commands/storage/commands/mari.rs @@ -8,6 +8,24 @@ use tauri::State; pub async fn professor_mari_prompt( state: State<'_, AppState>, request: Value, + on_event: tauri::ipc::Channel, ) -> Result { - mari::professor_mari_prompt(&state, request).await + mari::professor_mari_prompt(&state, request, on_event).await +} + +#[tauri::command] +pub fn professor_mari_apply_staged_changes( + state: State<'_, AppState>, + action: Value, +) -> Result { + mari::professor_mari_apply_staged_changes(&state, action) +} + +#[tauri::command] +pub fn professor_mari_resolve_approval( + state: State<'_, AppState>, + approval_id: String, + approved: bool, +) -> Result { + mari::professor_mari_resolve_approval(&state, approval_id, approved) } diff --git a/src-tauri/src/commands/storage/exports.rs b/src-tauri/src/commands/storage/exports.rs index dd757eb83..cce3dd0bc 100644 --- a/src-tauri/src/commands/storage/exports.rs +++ b/src-tauri/src/commands/storage/exports.rs @@ -503,11 +503,9 @@ fn patch_character_embedded_lorebook_pointer( "entriesImported": entries_imported }), ); - state.storage.patch( - "characters", - character_id, - json!({ "data": data }), - )?; + state + .storage + .patch("characters", character_id, json!({ "data": data }))?; Ok(()) } diff --git a/src-tauri/src/commands/storage/imports/marinara.rs b/src-tauri/src/commands/storage/imports/marinara.rs index 894316ba7..5a34400a3 100644 --- a/src-tauri/src/commands/storage/imports/marinara.rs +++ b/src-tauri/src/commands/storage/imports/marinara.rs @@ -92,7 +92,11 @@ pub(super) fn import_marinara_file(state: &AppState, body: Value) -> AppResult Option { - record.get("data").and_then(|data| data.get("name")).and_then(Value::as_str).map(ToOwned::to_owned) + record + .get("data") + .and_then(|data| data.get("name")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) } fn data_image_string(value: Option<&Value>) -> Option { @@ -345,7 +349,10 @@ fn import_marinara_character(state: &AppState, data: Value) -> AppResult remove_fields(&mut source, &["id", "sprites", "gallery", "metadata"]); if let Some(object) = source.as_object_mut() { if let Some(Value::String(raw)) = object.get("data") { - let parsed = serde_json::from_str::(raw).ok().filter(Value::is_object).unwrap_or_else(|| json!({})); + let parsed = serde_json::from_str::(raw) + .ok() + .filter(Value::is_object) + .unwrap_or_else(|| json!({})); object.insert("data".to_string(), parsed); } } @@ -431,10 +438,7 @@ fn import_marinara_lorebook( // Pre-refactor stored `tags`/`characterIds`/`personaIds` as TEXT columns // (JSON-stringified arrays). Refactor expects real arrays — without this // normalize step the lorebook editor crashes on `formTags.map is not a function`. - normalize_legacy_text_array_fields( - &mut lorebook_data, - &["tags", "characterIds", "personaIds"], - ); + normalize_legacy_text_array_fields(&mut lorebook_data, &["tags", "characterIds", "personaIds"]); // Pre-refactor also stored bool columns as TEXT (`"false"` / `"true"`). // Refactor reads these directly, so `lorebook.isGlobal === "false"` is // truthy and the editor shows every scoped lorebook as global. diff --git a/src-tauri/src/commands/storage/imports/normalization.rs b/src-tauri/src/commands/storage/imports/normalization.rs index 71618e77f..804bee49e 100644 --- a/src-tauri/src/commands/storage/imports/normalization.rs +++ b/src-tauri/src/commands/storage/imports/normalization.rs @@ -98,7 +98,11 @@ pub(super) fn strip_stale_embedded_lorebook_pointer(data: &mut Value) { } } -pub(super) fn character_import_extensions(payload: &Value, data: &Value, embedded: Option<&Value>) -> Value { +pub(super) fn character_import_extensions( + payload: &Value, + data: &Value, + embedded: Option<&Value>, +) -> Value { let mut extensions = data .get("extensions") .and_then(Value::as_object) @@ -133,7 +137,11 @@ pub(super) fn character_import_extensions(payload: &Value, data: &Value, embedde Value::Object(extensions) } -pub(super) fn normalize_character_data(payload: &Value, tag_mode: &str, existing_tags: &[String]) -> Value { +pub(super) fn normalize_character_data( + payload: &Value, + tag_mode: &str, + existing_tags: &[String], +) -> Value { let data = source_character_data(payload); let embedded = embedded_lorebook(payload); let mut tags = string_array(data.get("tags")); @@ -282,7 +290,11 @@ pub(super) fn normalize_lorebook_entry(lorebook_id: &str, entry: &Value, index: }) } -pub(super) fn normalize_imported_lorebook_entry(lorebook_id: &str, entry: &Value, index: usize) -> Value { +pub(super) fn normalize_imported_lorebook_entry( + lorebook_id: &str, + entry: &Value, + index: usize, +) -> Value { let mut object = ensure_object(normalize_lorebook_entry(lorebook_id, entry, index)).unwrap_or_default(); if let Some(source) = entry.as_object() { diff --git a/src-tauri/src/commands/storage/mari.rs b/src-tauri/src/commands/storage/mari.rs index 54b15b5aa..070a97476 100644 --- a/src-tauri/src/commands/storage/mari.rs +++ b/src-tauri/src/commands/storage/mari.rs @@ -1,249 +1,65 @@ +#[path = "mari/actions.rs"] +mod actions; +#[path = "mari/agent.rs"] +mod agent; +#[path = "mari/file_changes.rs"] +mod file_changes; +#[path = "mari/prompt.rs"] +mod prompt; +#[path = "mari/shell.rs"] +mod shell; +#[path = "mari/tools.rs"] +mod tools; +#[path = "mari/types.rs"] +mod types; +#[path = "mari/util.rs"] +mod util; +#[path = "mari/workspace.rs"] +mod workspace; + use super::llm::{llm_connection_from_value, resolve_llm_connection_for_request}; use crate::state::AppState; -use autoagents::async_trait; +use agent::{MarinaraLlmProvider, ProfessorMariAgent}; use autoagents::core::agent::memory::SlidingWindowMemory; use autoagents::core::agent::prebuilt::executor::ReActAgent; use autoagents::core::agent::task::Task; use autoagents::core::agent::{AgentBuilder, DirectAgent}; -use autoagents::core::tool::{ToolCallError, ToolRuntime}; -use autoagents::llm::chat::{ - ChatMessage, ChatProvider, ChatResponse, ChatRole, MessageType, StructuredOutputFormat, Tool, -}; -use autoagents::llm::completion::{CompletionProvider, CompletionRequest, CompletionResponse}; -use autoagents::llm::embedding::EmbeddingProvider; -use autoagents::llm::error::LLMError; -use autoagents::llm::models::{ModelListRequest, ModelListResponse, ModelsProvider}; -use autoagents::llm::{FunctionCall, LLMProvider, ToolCall}; -use autoagents::prelude::{agent, tool, AgentHooks, ToolInput, ToolInputT, ToolT}; +use autoagents::llm::LLMProvider; use marinara_core::{AppError, AppResult}; -use serde::{Deserialize, Serialize}; +use prompt::build_task_prompt; use serde_json::{json, Value}; -use std::fmt; +use shell::MariShellSession; use std::sync::Arc; - -const CREATIVE_LIBRARY_ENTITIES: &[(&str, &str)] = &[ - ("characters", "characters"), - ("characterGroups", "character-groups"), - ("personas", "personas"), - ("personaGroups", "persona-groups"), - ("lorebooks", "lorebooks"), - ("lorebookEntries", "lorebook-entries"), - ("promptPresets", "prompts"), - ("promptSections", "prompt-sections"), - ("promptGroups", "prompt-groups"), - ("promptVariables", "prompt-variables"), +use tools::build_pi_like_tools; +use types::MariPromptRequest; +use workspace::build_mari_workspace_seed; + +pub(crate) const MARI_TEXT_ATTACHMENT_CHAR_LIMIT: usize = 60_000; +pub(crate) const MARI_TOOL_TEXT_LIMIT: usize = 32_000; +pub(crate) const MARI_METADATA_STRING_LIMIT: usize = 4_000; +pub(crate) const MARI_MODEL_OUTPUT_TOKENS: u64 = 0; +pub(crate) const MARI_AGENT_MAX_TURNS: usize = 16; +pub(crate) const MARI_SYSTEM_PROMPT: &str = "You are Professor Mari, a coding-style agent inside a virtual Marinara workspace containing the user's creative library. Reply plainly and helpfully. Use tools to inspect /workspace/index.md and folders like /workspace/characters, /workspace/personas, /workspace/lorebooks, and /workspace/prompts before answering questions about the user's data. The read tool can read files and directories; directory reads return index.md when present, otherwise a listing. Read /workspace/FORMAT.md and the nearest folder-level FORMAT.md before creating or restructuring records. Visible paths use descriptive names; internal storage IDs are hidden and tracked by Marinara. When a modifying tool changes files, Marinara pauses and asks the user to approve or reject before the tool result is returned to you. Do not ask for approval before making edits."; +pub(crate) const MARI_STORAGE_ACTION_ENTITIES: &[&str] = &[ + "characters", + "character-groups", + "personas", + "persona-groups", + "lorebooks", + "lorebook-entries", + "prompts", + "prompt-sections", + "prompt-groups", + "prompt-variables", ]; -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct MariPromptRequest { - user_message: String, - #[serde(default)] - messages: Vec, - #[serde(default)] - connection_id: Option, - #[serde(default)] - persona: Option, - #[serde(default)] - attachments: Vec, -} - -#[derive(Debug, Deserialize)] -struct MariPromptMessage { - role: String, - content: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct MariPersonaContext { - name: Option, - comment: Option, - description: Option, - personality: Option, - scenario: Option, - backstory: Option, - appearance: Option, -} - -#[derive(Debug, Deserialize)] -struct MariAttachment { - name: String, - #[serde(default)] - r#type: String, - #[serde(default)] - size: u64, - content: String, -} - -#[derive(Clone, Debug)] -struct MarinaraLlmProvider { - connection: marinara_llm::LlmConnection, -} - -#[derive(Debug)] -struct MarinaraChatResponse { - content: String, - tool_calls: Vec, -} - -impl fmt::Display for MarinaraChatResponse { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.content) - } -} - -impl ChatResponse for MarinaraChatResponse { - fn text(&self) -> Option { - Some(self.content.clone()) - } - - fn tool_calls(&self) -> Option> { - Some(self.tool_calls.clone()) - } -} - -#[async_trait] -impl ChatProvider for MarinaraLlmProvider { - async fn chat_with_tools( - &self, - messages: &[ChatMessage], - tools: Option<&[Tool]>, - _json_schema: Option, - ) -> Result, LLMError> { - let request = marinara_llm::LlmRequest { - connection: self.connection.clone(), - messages: messages - .iter() - .map(autoagents_message_to_marinara) - .collect(), - parameters: mari_request_parameters(messages, tools.unwrap_or_default()), - tools: tools - .unwrap_or_default() - .iter() - .map(|tool| serde_json::to_value(&tool.function).unwrap_or_else(|_| json!({}))) - .collect(), - }; - let response = marinara_llm::complete_rich(request) - .await - .map_err(|error| LLMError::ProviderError(error.to_string()))?; - Ok(Box::new(MarinaraChatResponse { - content: response.content, - tool_calls: response - .tool_calls - .into_iter() - .filter_map(marinara_tool_call_to_autoagents) - .collect(), - })) - } -} - -fn mari_request_parameters(messages: &[ChatMessage], tools: &[Tool]) -> Value { - let mut parameters = json!({ - "temperature": 0.4, - "maxTokens": 2048, - }); - let has_tool_result = messages - .iter() - .any(|message| matches!(message.message_type, MessageType::ToolResult(_))); - let latest_user = messages - .iter() - .rev() - .find(|message| matches!(message.role, ChatRole::User)) - .map(|message| message.content.as_str()) - .unwrap_or_default(); - if !tools.is_empty() && !has_tool_result && looks_like_library_question(latest_user) { - parameters["toolChoice"] = json!({ - "type": "function", - "function": { "name": "read_marinara_library" } - }); - } - parameters -} - -#[async_trait] -impl CompletionProvider for MarinaraLlmProvider { - async fn complete( - &self, - request: &CompletionRequest, - _json_schema: Option, - ) -> Result { - let response = self - .chat( - &[ChatMessage { - role: ChatRole::User, - message_type: MessageType::Text, - content: request.prompt.clone(), - }], - None, - ) - .await?; - Ok(CompletionResponse { - text: response.text().unwrap_or_default(), - }) - } -} - -#[async_trait] -impl EmbeddingProvider for MarinaraLlmProvider { - async fn embed(&self, _input: Vec) -> Result>, LLMError> { - Err(LLMError::ProviderError( - "Professor Mari does not expose embeddings in v1".to_string(), - )) - } -} - -#[async_trait] -impl ModelsProvider for MarinaraLlmProvider { - async fn list_models( - &self, - _request: Option<&ModelListRequest>, - ) -> Result, LLMError> { - Err(LLMError::ProviderError( - "Professor Mari model listing is owned by Marinara connections".to_string(), - )) - } -} - -impl LLMProvider for MarinaraLlmProvider {} - -#[derive(Serialize, Deserialize, ToolInput, Debug)] -struct ReadMarinaraLibraryArgs {} - -#[tool( - name = "read_marinara_library", - description = "Read Professor Mari's typed, read-only creative library snapshot: characters, personas, lorebooks with entries, prompt presets, prompt sections, prompt groups, prompt variables, and character/persona groups. This tool never returns chats, messages, memories, integrations, API keys, or connection secrets.", - input = ReadMarinaraLibraryArgs, -)] -struct ReadMarinaraLibraryTool { - state: AppState, -} - -#[async_trait] -impl ToolRuntime for ReadMarinaraLibraryTool { - async fn execute(&self, _args: Value) -> Result { - creative_library_snapshot(&self.state).map_err(|error| { - ToolCallError::RuntimeError(Box::new(AppError::new( - "mari_library_read_failed", - error.to_string(), - ))) - }) - } -} - -#[agent( - name = "professor_mari", - description = "You are Professor Mari, Marinara's standalone assistant. You answer the user's question clearly and can inspect Marinara's creative library by calling read_marinara_library. You must not claim to edit data, run shell commands, or access chats/messages/memories. If the user asks what library data is available, call the tool.", - tools = [ReadMarinaraLibraryTool { state: self.state.clone() }], -)] -#[derive(Clone, AgentHooks)] -struct ProfessorMariAgent { - state: AppState, -} - -pub(crate) async fn professor_mari_prompt(state: &AppState, body: Value) -> AppResult { - let input: MariPromptRequest = serde_json::from_value(body.clone()) - .map_err(|error| AppError::invalid_input(error.to_string()))?; +pub(crate) async fn professor_mari_prompt( + state: &AppState, + body: Value, + trace_channel: tauri::ipc::Channel, +) -> AppResult { + let input: MariPromptRequest = + serde_json::from_value(body).map_err(|error| AppError::invalid_input(error.to_string()))?; let connection_value = resolve_llm_connection_for_request( state, &json!({ @@ -251,218 +67,68 @@ pub(crate) async fn professor_mari_prompt(state: &AppState, body: Value) -> AppR }), )?; let connection = llm_connection_from_value(&connection_value)?; - ensure_connection_supports_native_tools(&connection)?; - let system_prompt = build_system_prompt(input.persona.as_ref()); - let task_prompt = build_task_prompt(&input); - let provider: Arc = Arc::new(MarinaraLlmProvider { connection }); - let memory = Box::new(SlidingWindowMemory::new(12)); - let agent = ReActAgent::with_max_turns( - ProfessorMariAgent { - state: state.clone(), - }, - 4, - ); - let agent_handle = AgentBuilder::<_, DirectAgent>::new(agent) - .llm(provider) - .memory(memory) - .build() - .await - .map_err(|error| AppError::new("mari_agent_create_failed", error.to_string()))?; - let task = Task::new(task_prompt).with_system_prompt(system_prompt); - let response = agent_handle.agent.run(task).await.map_err(|error| { - AppError::new( - "mari_agent_failed", - tool_call_error_message(&error.to_string()), - ) - })?; + let (content, action, trace) = run_mari_agent(state, connection, &input, trace_channel).await?; Ok(json!({ - "content": response.to_string(), + "content": content, "createdAt": chrono::Utc::now().to_rfc3339(), - "action": read_only_mari_action_contract(), + "action": action, + "trace": trace, })) } -fn read_only_mari_action_contract() -> Value { - json!({ - "type": "none", - "capability": "read_only", - "reason": "Professor Mari v1 can inspect the creative library but cannot create or edit records.", - }) -} - -fn autoagents_message_to_marinara(message: &ChatMessage) -> marinara_llm::LlmMessage { - let first_tool_result = match &message.message_type { - MessageType::ToolResult(calls) => calls.first(), - _ => None, - }; - let role = match message.role { - ChatRole::System => "system", - ChatRole::Assistant => "assistant", - ChatRole::Tool => "tool", - ChatRole::User => "user", - } - .to_string(); - let tool_calls = match &message.message_type { - MessageType::ToolUse(calls) => Some(json!(calls)), - _ => None, - }; - marinara_llm::LlmMessage { - role, - content: first_tool_result - .map(|call| call.function.arguments.clone()) - .unwrap_or_else(|| message.content.clone()), - name: None, - images: Vec::new(), - tool_call_id: first_tool_result.map(|call| call.id.clone()), - tool_calls, - } -} - -fn marinara_tool_call_to_autoagents(value: Value) -> Option { - let function = value.get("function").unwrap_or(&value); - let name = function - .get("name") - .or_else(|| value.get("name"))? - .as_str()? - .to_string(); - let arguments = function - .get("arguments") - .or_else(|| value.get("arguments")) - .and_then(Value::as_str) - .unwrap_or("{}") - .to_string(); - Some(ToolCall { - id: value - .get("id") - .and_then(Value::as_str) - .filter(|id| !id.is_empty()) - .unwrap_or("mari_tool_call") - .to_string(), - call_type: value - .get("type") - .and_then(Value::as_str) - .unwrap_or("function") - .to_string(), - function: FunctionCall { name, arguments }, - }) +pub(crate) fn professor_mari_apply_staged_changes( + state: &AppState, + action: Value, +) -> AppResult { + actions::professor_mari_apply_staged_changes(state, action) } -fn build_system_prompt(persona: Option<&MariPersonaContext>) -> String { - let mut parts = vec![ - "You are Professor Mari, a standalone assistant inside Marinara Engine.".to_string(), - "You can chat with the user and read the creative library through read_marinara_library.".to_string(), - "The read-only library tool returns typed JSON objects. Do not invent data if the tool is needed.".to_string(), - "You cannot mutate records, run shell commands, inspect private chats, or access secrets in v1.".to_string(), - ]; - if let Some(persona) = persona { - let persona_text = [ - ("Name", persona.name.as_deref()), - ("Comment", persona.comment.as_deref()), - ("Description", persona.description.as_deref()), - ("Personality", persona.personality.as_deref()), - ("Scenario", persona.scenario.as_deref()), - ("Backstory", persona.backstory.as_deref()), - ("Appearance", persona.appearance.as_deref()), - ] - .into_iter() - .filter_map(|(label, value)| { - let value = value?.trim(); - (!value.is_empty()).then(|| format!("{label}: {value}")) - }) - .collect::>() - .join("\n"); - if !persona_text.is_empty() { - parts.push(format!("The user's selected persona is:\n{persona_text}")); - } - } - parts.join("\n\n") -} - -fn build_task_prompt(input: &MariPromptRequest) -> String { - let mut sections = Vec::new(); - let history = input - .messages - .iter() - .rev() - .take(16) - .collect::>() - .into_iter() - .rev() - .filter_map(|message| { - let content = message.content.trim(); - (!content.is_empty()).then(|| format!("{}: {content}", message.role)) - }) - .collect::>() - .join("\n"); - if !history.is_empty() { - sections.push(format!("Conversation history:\n{history}")); - } - if !input.attachments.is_empty() { - let attachments = input - .attachments - .iter() - .map(|attachment| { - format!( - "File: {}\nType: {}\nSize: {}\nContent:\n{}", - attachment.name, attachment.r#type, attachment.size, attachment.content - ) - }) - .collect::>() - .join("\n\n---\n\n"); - sections.push(format!( - "Attached files for the latest user turn:\n{attachments}" - )); - } - sections.push(format!( - "Latest user message:\n{}", - input.user_message.trim() - )); - sections.join("\n\n") -} - -fn looks_like_library_question(message: &str) -> bool { - let lower = message.to_ascii_lowercase(); - [ - "character", - "characters", - "persona", - "personas", - "lorebook", - "lorebooks", - "prompt", - "preset", - "presets", - "library", - "what do i have", - ] - .iter() - .any(|needle| lower.contains(needle)) -} - -fn ensure_connection_supports_native_tools( - connection: &marinara_llm::LlmConnection, -) -> AppResult<()> { - match connection.provider.as_str() { - "openai" | "openai_chatgpt" | "openrouter" | "custom" | "xai" | "mistral" | "cohere" | "nanogpt" => Ok(()), - provider => Err(AppError::invalid_input(format!( - "Professor Mari requires a connection with native tool-call support. The selected provider '{provider}' is not enabled for native tools in Marinara's Rust LLM transport yet. Use an OpenAI-compatible, OpenRouter, OpenAI, xAI, Mistral, Cohere, NanoGPT, or custom OpenAI-compatible connection with a tool-capable chat model." - ))), - } +pub(crate) fn professor_mari_resolve_approval( + state: &AppState, + approval_id: String, + approved: bool, +) -> AppResult { + state.resolve_mari_approval(&approval_id, approved)?; + Ok(json!({ + "resolved": true, + "approvalId": approval_id, + "approved": approved, + })) } -fn tool_call_error_message(message: &str) -> String { - if message.contains("Provider response did not contain assistant text or tool calls") { - return "The selected model/provider did not return a native tool call or assistant message. Professor Mari's read-library path requires native tool calling; choose a tool-capable chat model on the selected connection.".to_string(); - } - message.to_string() -} +async fn run_mari_agent( + state: &AppState, + connection: marinara_llm::LlmConnection, + input: &MariPromptRequest, + trace_channel: tauri::ipc::Channel, +) -> AppResult<(String, Value, Vec)> { + let workspace_seed = build_mari_workspace_seed(state)?; + let session = MariShellSession::new(input, workspace_seed, trace_channel).await?; + let tools = build_pi_like_tools(state.clone(), session.clone()); + let llm: Arc = Arc::new(MarinaraLlmProvider::new(connection, session.clone())); + let agent = ReActAgent::with_max_turns(ProfessorMariAgent { tools }, MARI_AGENT_MAX_TURNS); + let agent_handle = AgentBuilder::<_, DirectAgent>::new(agent) + .llm(llm) + .memory(Box::new(SlidingWindowMemory::new(12))) + .build() + .await + .map_err(|error| AppError::new("mari_agent_failed", error.to_string()))?; -fn creative_library_snapshot(state: &AppState) -> AppResult { - let mut snapshot = serde_json::Map::new(); - for (key, entity) in CREATIVE_LIBRARY_ENTITIES { - let rows = state.storage.list(entity)?; - snapshot.insert((*key).to_string(), Value::Array(rows)); - } - Ok(Value::Object(snapshot)) + let result = agent_handle + .agent + .run(Task::new(build_task_prompt(input))) + .await + .map_err(|error| AppError::new("mari_agent_failed", error.to_string()))?; + let content = result.trim(); + let content = if content.is_empty() { + "I couldn't produce a response from the selected model.".to_string() + } else { + content.to_string() + }; + Ok(( + content, + actions::staged_mari_action_contract(state, &session).await?, + session.trace_events(), + )) } diff --git a/src-tauri/src/commands/storage/mari/actions.rs b/src-tauri/src/commands/storage/mari/actions.rs new file mode 100644 index 000000000..01b63079d --- /dev/null +++ b/src-tauri/src/commands/storage/mari/actions.rs @@ -0,0 +1,623 @@ +use super::file_changes::{self, MariFileChange}; +use super::shell::MariShellSession; +use super::util; +use super::workspace::{self, MariWorkspaceBinding}; +use super::MARI_STORAGE_ACTION_ENTITIES; +use crate::state::AppState; +use crate::storage_commands::shared; +use marinara_core::{AppError, AppResult}; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +pub(crate) fn professor_mari_apply_staged_changes( + state: &AppState, + action: Value, +) -> AppResult { + let storage_actions = extract_storage_actions(&action)?; + let prepared = storage_actions + .iter() + .map(prepare_mari_storage_action) + .collect::>>()?; + if prepared.is_empty() { + return Err(AppError::invalid_input( + "No applicable Professor Mari storage changes were provided", + )); + } + + let mut results = Vec::with_capacity(prepared.len()); + for action in prepared { + match action { + PreparedMariStorageAction::Create { entity, draft } => { + let record = state + .storage + .create(&entity, shared::with_entity_defaults(&entity, draft)?)?; + results + .push(json!({ "type": "create_record", "entity": entity, "record": record })); + } + PreparedMariStorageAction::Edit { entity, id, patch } => { + let record = state.storage.patch( + &entity, + &id, + shared::normalize_update_patch(&entity, patch)?, + )?; + results.push( + json!({ "type": "edit_record", "entity": entity, "id": id, "record": record }), + ); + } + } + } + + Ok(json!({ + "applied": results.len(), + "appliedAt": chrono::Utc::now().to_rfc3339(), + "results": results, + })) +} + +#[derive(Debug)] +enum PreparedMariStorageAction { + Create { + entity: String, + draft: Value, + }, + Edit { + entity: String, + id: String, + patch: Value, + }, +} + +#[derive(Debug, Clone)] +struct MariExistingRecordDraft { + before: Value, + after: Value, + paths: BTreeSet, +} + +#[derive(Debug, Clone)] +struct MariNewRecordDraft { + entity: String, + label: String, + draft: Value, + paths: BTreeSet, +} + +#[derive(Debug, Clone)] +struct MariNewRecordTarget { + entity: String, + folder_path: String, + label: String, + field: String, +} + +fn extract_storage_actions(action: &Value) -> AppResult> { + if let Some(actions) = action.get("storageActions").and_then(Value::as_array) { + return Ok(actions.to_vec()); + } + if matches!( + action.get("type").and_then(Value::as_str), + Some("create_record" | "edit_record") + ) { + return Ok(vec![action.clone()]); + } + Err(AppError::invalid_input( + "Professor Mari action did not include storageActions", + )) +} + +fn prepare_mari_storage_action(action: &Value) -> AppResult { + let object = action.as_object().ok_or_else(|| { + AppError::invalid_input("Professor Mari storage action must be an object") + })?; + let action_type = object + .get("type") + .and_then(Value::as_str) + .ok_or_else(|| AppError::invalid_input("Professor Mari storage action is missing type"))?; + let entity = object + .get("entity") + .and_then(Value::as_str) + .filter(|entity| MARI_STORAGE_ACTION_ENTITIES.contains(entity)) + .ok_or_else(|| { + AppError::invalid_input("Professor Mari storage action has an invalid entity") + })? + .to_string(); + + match action_type { + "create_record" => { + let draft = object + .get("draft") + .filter(|value| value.is_object()) + .ok_or_else(|| AppError::invalid_input("create_record action is missing draft"))? + .clone(); + Ok(PreparedMariStorageAction::Create { entity, draft }) + } + "edit_record" => { + let id = object + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.trim().is_empty()) + .ok_or_else(|| AppError::invalid_input("edit_record action is missing id"))? + .to_string(); + let patch = object + .get("patch") + .filter(|value| value.is_object()) + .ok_or_else(|| AppError::invalid_input("edit_record action is missing patch"))? + .clone(); + Ok(PreparedMariStorageAction::Edit { entity, id, patch }) + } + _ => Err(AppError::invalid_input(format!( + "Unsupported Professor Mari storage action type: {action_type}" + ))), + } +} + +fn staged_storage_actions( + state: &AppState, + session: &MariShellSession, + changes: &[MariFileChange], +) -> AppResult<(Vec, Vec)> { + let manifest = session.manifest_snapshot(); + let mut existing_drafts: BTreeMap<(String, String), MariExistingRecordDraft> = BTreeMap::new(); + let mut new_drafts: BTreeMap = BTreeMap::new(); + let mut issues = Vec::new(); + + for change in changes { + if let Some(binding) = manifest.get(&change.path) { + match apply_bound_change(state, &mut existing_drafts, binding, change)? { + Ok(()) => {} + Err(reason) => issues.push(change_issue(change, Some(binding), reason)), + } + continue; + } + + if let Some(target) = new_record_target(&change.path) { + if let Some(folder_binding) = existing_folder_binding(&manifest, &target.folder_path) { + let inferred_binding = MariWorkspaceBinding { + entity: folder_binding.entity.clone(), + id: folder_binding.id.clone(), + field: Some(target.field.clone()), + }; + match apply_bound_change(state, &mut existing_drafts, &inferred_binding, change)? { + Ok(()) => {} + Err(reason) => { + issues.push(change_issue(change, Some(&inferred_binding), reason)) + } + } + continue; + } + + match apply_new_record_change(&mut new_drafts, target, change) { + Ok(()) => {} + Err(reason) => issues.push(change_issue(change, None, reason)), + } + continue; + } + + issues.push(change_issue( + change, + None, + "This workspace file is not mapped to a Marinara storage field.".to_string(), + )); + } + + let mut actions = Vec::new(); + for ((entity, id), draft) in existing_drafts { + let patch = record_patch(&draft.before, &draft.after); + if patch.is_empty() { + continue; + } + let patch = shared::normalize_update_patch(&entity, Value::Object(patch))?; + let label = workspace::record_label_for_entity(&entity, &draft.after, "Record"); + let action_label = format!( + "Edit {}: {}", + workspace::singular_title(&entity), + workspace::display_label(&label) + ); + actions.push(json!({ + "type": "edit_record", + "entity": entity, + "id": id, + "patch": patch, + "label": action_label, + "paths": draft.paths.into_iter().collect::>(), + })); + } + + for (_, draft) in new_drafts { + if draft.paths.is_empty() { + continue; + } + let entity = draft.entity; + let normalized_draft = shared::with_entity_defaults(&entity, draft.draft)?; + let label = workspace::record_label_for_entity(&entity, &normalized_draft, &draft.label); + let action_label = format!( + "Create {}: {}", + workspace::singular_title(&entity), + workspace::display_label(&label) + ); + actions.push(json!({ + "type": "create_record", + "entity": entity, + "draft": normalized_draft, + "label": action_label, + "paths": draft.paths.into_iter().collect::>(), + })); + } + + Ok((actions, issues)) +} + +fn apply_bound_change( + state: &AppState, + drafts: &mut BTreeMap<(String, String), MariExistingRecordDraft>, + binding: &MariWorkspaceBinding, + change: &MariFileChange, +) -> AppResult> { + let Some(field) = binding.field.as_deref() else { + return Ok(Err( + "This workspace path is not bound to an editable field.".to_string(), + )); + }; + let draft = existing_record_draft(state, drafts, &binding.entity, &binding.id)?; + let result = apply_field_change(&mut draft.after, &binding.entity, field, change); + if result.is_ok() { + draft.paths.insert(change.path.clone()); + } + Ok(result) +} + +fn existing_record_draft<'a>( + state: &AppState, + drafts: &'a mut BTreeMap<(String, String), MariExistingRecordDraft>, + entity: &str, + id: &str, +) -> AppResult<&'a mut MariExistingRecordDraft> { + let key = (entity.to_string(), id.to_string()); + if !drafts.contains_key(&key) { + let record = state + .storage + .get(entity, id)? + .ok_or_else(|| AppError::not_found(format!("{entity}/{id} was not found")))?; + drafts.insert( + key.clone(), + MariExistingRecordDraft { + before: record.clone(), + after: record, + paths: BTreeSet::new(), + }, + ); + } + Ok(drafts.get_mut(&key).expect("draft inserted above")) +} + +fn apply_new_record_change( + drafts: &mut BTreeMap, + target: MariNewRecordTarget, + change: &MariFileChange, +) -> Result<(), String> { + if change.op == "delete" { + return Err( + "Deleting a newly created unmapped file cannot be applied to storage.".to_string(), + ); + } + let draft = drafts + .entry(target.folder_path.clone()) + .or_insert_with(|| MariNewRecordDraft { + entity: target.entity.clone(), + label: target.label.clone(), + draft: initial_draft_for_entity(&target.entity, &target.label), + paths: BTreeSet::new(), + }); + apply_field_change(&mut draft.draft, &draft.entity, &target.field, change)?; + draft.paths.insert(change.path.clone()); + Ok(()) +} + +fn apply_field_change( + record: &mut Value, + _entity: &str, + field: &str, + change: &MariFileChange, +) -> Result<(), String> { + if field == "metadata" { + let text = change_after_text(change) + .ok_or_else(|| "Deleting metadata.json cannot be applied to storage.".to_string())?; + let metadata: Value = serde_json::from_str(&text) + .map_err(|error| format!("metadata.json is not valid JSON: {error}"))?; + merge_metadata_into_record(record, metadata)?; + return Ok(()); + } + + let text = change_after_text(change).unwrap_or_default(); + let value = if field == "keys" { + Value::Array( + text.lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(|line| Value::String(line.to_string())) + .collect(), + ) + } else { + Value::String(text) + }; + set_field_path_value(record, field, value) +} + +fn merge_metadata_into_record(record: &mut Value, mut metadata: Value) -> Result<(), String> { + let object = metadata + .as_object_mut() + .ok_or_else(|| "metadata.json must contain a JSON object.".to_string())?; + object.remove("id"); + object.remove("createdAt"); + object.remove("updatedAt"); + merge_json_value(record, metadata); + Ok(()) +} + +fn merge_json_value(target: &mut Value, source: Value) { + match source { + Value::Object(source_object) => { + if let Some(target_object) = target.as_object_mut() { + for (key, value) in source_object { + if let Some(existing) = target_object.get_mut(&key) { + merge_json_value(existing, value); + } else { + target_object.insert(key, value); + } + } + } else { + *target = Value::Object(source_object); + } + } + other => *target = other, + } +} + +fn set_field_path_value(record: &mut Value, field_path: &str, value: Value) -> Result<(), String> { + let parts = field_path + .split('.') + .filter(|part| !part.is_empty()) + .collect::>(); + let Some((last, parents)) = parts.split_last() else { + return Err("Field path was empty.".to_string()); + }; + let mut current = record; + for part in parents { + if !current.is_object() { + *current = Value::Object(Map::new()); + } + let object = current + .as_object_mut() + .ok_or_else(|| format!("Could not write nested field {field_path}"))?; + current = object + .entry((*part).to_string()) + .or_insert_with(|| Value::Object(Map::new())); + } + if !current.is_object() { + *current = Value::Object(Map::new()); + } + let object = current + .as_object_mut() + .ok_or_else(|| format!("Could not write field {field_path}"))?; + object.insert((*last).to_string(), value); + Ok(()) +} + +fn record_patch(before: &Value, after: &Value) -> Map { + let mut patch = Map::new(); + let Some(after_object) = after.as_object() else { + return patch; + }; + let before_object = before.as_object(); + for (key, value) in after_object { + if matches!(key.as_str(), "id" | "createdAt" | "updatedAt") { + continue; + } + if before_object.and_then(|object| object.get(key)) != Some(value) { + patch.insert(key.clone(), value.clone()); + } + } + patch +} + +fn change_after_text(change: &MariFileChange) -> Option { + change + .after + .as_ref() + .map(|bytes| String::from_utf8_lossy(bytes).to_string()) +} + +fn new_record_target(path: &str) -> Option { + let normalized = util::normalize_virtual_path(path); + let parts = normalized.trim_matches('/').split('/').collect::>(); + if parts.len() != 4 || parts.first().copied() != Some("workspace") { + return None; + } + let entity = parts[1]; + if !matches!( + entity, + "characters" | "character-groups" | "personas" | "persona-groups" | "lorebooks" | "prompts" + ) { + return None; + } + let file_name = parts[3]; + let field = if file_name == "metadata.json" { + "metadata".to_string() + } else { + text_field_for_workspace_file(entity, file_name)?.to_string() + }; + Some(MariNewRecordTarget { + entity: entity.to_string(), + folder_path: format!("/workspace/{entity}/{}", parts[2]), + label: parts[2].to_string(), + field, + }) +} + +fn existing_folder_binding<'a>( + manifest: &'a BTreeMap, + folder_path: &str, +) -> Option<&'a MariWorkspaceBinding> { + let prefix = format!("{}/", folder_path.trim_end_matches('/')); + manifest + .iter() + .find(|(path, binding)| { + path.starts_with(&prefix) && binding.field.as_deref() == Some("metadata") + }) + .map(|(_, binding)| binding) + .or_else(|| { + manifest + .iter() + .find(|(path, _)| path.starts_with(&prefix)) + .map(|(_, binding)| binding) + }) +} + +fn text_field_for_workspace_file(entity: &str, file_name: &str) -> Option<&'static str> { + let stem = file_name.strip_suffix(".md")?; + workspace_text_fields_for_entity(entity) + .iter() + .copied() + .find(|field| workspace::field_file_name(field) == stem) +} + +fn workspace_text_fields_for_entity(entity: &str) -> &'static [&'static str] { + match entity { + "characters" => &[ + "data.description", + "data.personality", + "data.scenario", + "data.first_mes", + "data.mes_example", + "data.creator_notes", + "data.system_prompt", + "data.post_history_instructions", + "data.extensions.backstory", + "data.extensions.appearance", + ], + "character-groups" => &["description", "notes"], + "personas" => &[ + "description", + "personality", + "scenario", + "backstory", + "appearance", + "firstMessage", + "greeting", + "notes", + ], + "persona-groups" => &["description", "notes"], + "lorebooks" => &["description", "content", "notes"], + "lorebook-entries" => &["content", "comment", "description", "notes", "keys"], + "prompts" => &["description", "prompt", "systemPrompt", "notes"], + "prompt-sections" => &["prompt", "content", "text", "description"], + "prompt-groups" => &["description", "notes"], + "prompt-variables" => &["value", "content", "text", "description"], + _ => &[], + } +} + +fn initial_draft_for_entity(entity: &str, label: &str) -> Value { + match entity { + "characters" => json!({ + "name": label, + "data": { + "name": label, + "description": "", + "personality": "", + "scenario": "", + "first_mes": "", + "mes_example": "", + "creator_notes": "", + "system_prompt": "", + "post_history_instructions": "", + "tags": [], + "creator": "", + "character_version": "1.0", + "alternate_greetings": [], + "extensions": { "altDescriptions": [] }, + "character_book": null + }, + "comment": "" + }), + "personas" => json!({ + "name": label, + "description": "", + "comment": "", + "personality": "", + "scenario": "", + "backstory": "", + "appearance": "" + }), + "lorebooks" | "prompts" | "character-groups" | "persona-groups" => { + json!({ "name": label }) + } + _ => json!({ "name": label }), + } +} + +fn change_issue( + change: &MariFileChange, + binding: Option<&MariWorkspaceBinding>, + reason: String, +) -> Value { + let mut value = match file_changes::file_change_summary(change) { + Value::Object(object) => object, + _ => Map::new(), + }; + value.insert("reason".to_string(), Value::String(reason)); + if let Some(binding) = binding { + value.insert( + "binding".to_string(), + json!({ + "entity": &binding.entity, + "id": &binding.id, + "field": &binding.field, + }), + ); + } + Value::Object(value) +} + +fn file_change_summary_with_binding( + change: &MariFileChange, + binding: Option<&MariWorkspaceBinding>, +) -> Value { + let mut value = match file_changes::file_change_summary(change) { + Value::Object(object) => object, + _ => Map::new(), + }; + if let Some(binding) = binding { + value.insert( + "binding".to_string(), + json!({ + "entity": &binding.entity, + "id": &binding.id, + "field": &binding.field, + }), + ); + } + Value::Object(value) +} + +pub(crate) async fn staged_mari_action_contract( + state: &AppState, + session: &MariShellSession, +) -> AppResult { + let changes = session.pending_file_changes().await?; + let (storage_actions, unmapped_changes) = staged_storage_actions(state, session, &changes)?; + let manifest = session.manifest_snapshot(); + let change_summaries = changes + .iter() + .map(|change| file_change_summary_with_binding(change, manifest.get(&change.path))) + .collect::>(); + Ok(json!({ + "type": if changes.is_empty() { "none" } else { "staged_file_changes" }, + "capability": "bashkit_virtual_workspace", + "changes": change_summaries, + "storageActions": storage_actions, + "unmappedChanges": unmapped_changes, + "workspaceManifest": session.manifest_summary(), + "approvalRequired": !storage_actions.is_empty() && unmapped_changes.is_empty(), + })) +} diff --git a/src-tauri/src/commands/storage/mari/agent.rs b/src-tauri/src/commands/storage/mari/agent.rs new file mode 100644 index 000000000..8e3f16071 --- /dev/null +++ b/src-tauri/src/commands/storage/mari/agent.rs @@ -0,0 +1,357 @@ +use super::shell::MariShellSession; +use super::util; +use super::{MARI_MODEL_OUTPUT_TOKENS, MARI_SYSTEM_PROMPT}; +use autoagents::async_trait; +use autoagents::core::agent::AgentDeriveT; +use autoagents::core::tool::{shared_tools_to_boxes, ToolT}; +use autoagents::llm::chat::{ + ChatMessage, ChatProvider, ChatResponse, MessageType, SamplingOverrides, + StructuredOutputFormat, Tool, +}; +use autoagents::llm::completion::{CompletionProvider, CompletionRequest, CompletionResponse}; +use autoagents::llm::embedding::EmbeddingProvider; +use autoagents::llm::error::LLMError; +use autoagents::llm::models::{ModelListRequest, ModelListResponse, ModelsProvider}; +use autoagents::llm::LLMProvider; +use autoagents::llm::{FunctionCall, ToolCall}; +use autoagents::prelude::AgentHooks; +use serde_json::{json, Value}; +use std::fmt; +use std::sync::Arc; + +#[derive(Clone, AgentHooks)] +pub(crate) struct ProfessorMariAgent { + pub(crate) tools: Vec>, +} + +impl fmt::Debug for ProfessorMariAgent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ProfessorMariAgent") + .field("tool_count", &self.tools.len()) + .finish() + } +} + +impl AgentDeriveT for ProfessorMariAgent { + type Output = String; + + fn description(&self) -> &str { + MARI_SYSTEM_PROMPT + } + + fn output_schema(&self) -> Option { + None + } + + fn name(&self) -> &str { + "professor_mari" + } + + fn tools(&self) -> Vec> { + shared_tools_to_boxes(&self.tools) + } +} + +#[derive(Clone)] +pub(crate) struct MarinaraLlmProvider { + connection: marinara_llm::LlmConnection, + session: Arc, +} + +impl MarinaraLlmProvider { + pub(crate) fn new( + connection: marinara_llm::LlmConnection, + session: Arc, + ) -> Self { + Self { + connection, + session, + } + } + + async fn complete_chat( + &self, + messages: &[ChatMessage], + sampling: Option<&SamplingOverrides>, + tools: Option<&[Tool]>, + ) -> Result { + let request_tools = tools + .unwrap_or(&[]) + .iter() + .map(|tool| serde_json::to_value(&tool.function).unwrap_or_else(|_| json!({}))) + .collect(); + let response = marinara_llm::complete_rich(marinara_llm::LlmRequest { + connection: self.connection.clone(), + messages: map_autoagents_messages(messages), + parameters: sampling_parameters(sampling), + tools: request_tools, + }) + .await + .map_err(|error| LLMError::ProviderError(util::format_app_error_for_debug(&error)))?; + self.session.record_trace(json!({ + "type": "model_turn", + "label": "Model turn", + "summary": model_turn_summary(&response.content, &response.tool_calls), + "content": util::truncate_tool_text(response.content.trim()), + "toolCalls": response.tool_calls.iter().map(summarize_tool_call_value).collect::>(), + })); + Ok(MarinaraChatResponse { + content: response.content, + tool_calls: map_marinara_tool_calls(response.tool_calls), + }) + } +} + +impl LLMProvider for MarinaraLlmProvider {} + +#[async_trait] +impl ChatProvider for MarinaraLlmProvider { + async fn chat_with_tools( + &self, + messages: &[ChatMessage], + tools: Option<&[Tool]>, + _json_schema: Option, + ) -> Result, LLMError> { + Ok(Box::new(self.complete_chat(messages, None, tools).await?)) + } + + async fn chat_with_tools_and_sampling( + &self, + messages: &[ChatMessage], + tools: Option<&[Tool]>, + _json_schema: Option, + sampling: Option<&SamplingOverrides>, + ) -> Result, LLMError> { + Ok(Box::new( + self.complete_chat(messages, sampling, tools).await?, + )) + } +} + +#[async_trait] +impl CompletionProvider for MarinaraLlmProvider { + async fn complete( + &self, + req: &CompletionRequest, + _json_schema: Option, + ) -> Result { + let message = marinara_llm::LlmMessage { + role: "user".to_string(), + content: req.prompt.clone(), + name: None, + images: Vec::new(), + tool_call_id: None, + tool_calls: None, + }; + let response = marinara_llm::complete(marinara_llm::LlmRequest { + connection: self.connection.clone(), + messages: vec![message], + parameters: json!({ + "temperature": req.temperature, + "maxTokens": req.max_tokens, + }), + tools: Vec::new(), + }) + .await + .map_err(|error| LLMError::ProviderError(util::format_app_error_for_debug(&error)))?; + Ok(CompletionResponse { text: response }) + } +} + +#[async_trait] +impl EmbeddingProvider for MarinaraLlmProvider { + async fn embed(&self, _input: Vec) -> Result>, LLMError> { + Err(LLMError::ProviderError( + "Marinara Professor Mari does not support embeddings".to_string(), + )) + } +} + +#[async_trait] +impl ModelsProvider for MarinaraLlmProvider { + async fn list_models( + &self, + _request: Option<&ModelListRequest>, + ) -> Result, LLMError> { + Err(LLMError::ProviderError( + "Marinara Professor Mari does not list models through AutoAgents".to_string(), + )) + } +} + +#[derive(Debug)] +struct MarinaraChatResponse { + content: String, + tool_calls: Vec, +} + +impl ChatResponse for MarinaraChatResponse { + fn text(&self) -> Option { + Some(self.content.clone()) + } + + fn tool_calls(&self) -> Option> { + (!self.tool_calls.is_empty()).then(|| self.tool_calls.clone()) + } +} + +impl fmt::Display for MarinaraChatResponse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.content) + } +} + +fn map_autoagents_messages(messages: &[ChatMessage]) -> Vec { + let mut mapped = Vec::new(); + for message in messages { + match &message.message_type { + MessageType::ToolUse(tool_calls) => mapped.push(marinara_llm::LlmMessage { + role: "assistant".to_string(), + content: message.content.clone(), + name: None, + images: Vec::new(), + tool_call_id: None, + tool_calls: serde_json::to_value(tool_calls).ok(), + }), + MessageType::ToolResult(tool_results) => { + for result in tool_results { + mapped.push(marinara_llm::LlmMessage { + role: "tool".to_string(), + content: result.function.arguments.clone(), + name: Some(result.function.name.clone()), + images: Vec::new(), + tool_call_id: Some(result.id.clone()), + tool_calls: None, + }); + } + } + MessageType::ImageURL(url) if is_http_image_url(url) => { + mapped.push(marinara_llm::LlmMessage { + role: message.role.to_string(), + content: message.content.clone(), + name: None, + images: vec![url.clone()], + tool_call_id: None, + tool_calls: None, + }) + } + _ => mapped.push(marinara_llm::LlmMessage { + role: message.role.to_string(), + content: message.content.clone(), + name: None, + images: Vec::new(), + tool_call_id: None, + tool_calls: None, + }), + } + } + mapped +} + +fn map_marinara_tool_calls(values: Vec) -> Vec { + values + .into_iter() + .filter_map(|value| { + let id = value + .get("id") + .or_else(|| value.get("call_id")) + .and_then(Value::as_str) + .unwrap_or("tool_call") + .to_string(); + let function = value.get("function").unwrap_or(&value); + let name = function + .get("name") + .or_else(|| value.get("name")) + .and_then(Value::as_str)? + .to_string(); + let arguments = function + .get("arguments") + .or_else(|| value.get("arguments")) + .and_then(Value::as_str) + .unwrap_or("{}") + .to_string(); + Some(ToolCall { + id, + call_type: value + .get("type") + .and_then(Value::as_str) + .unwrap_or("function") + .to_string(), + function: FunctionCall { name, arguments }, + }) + }) + .collect() +} + +fn model_turn_summary(content: &str, tool_calls: &[Value]) -> String { + if !tool_calls.is_empty() { + let names = tool_calls + .iter() + .filter_map(tool_call_name) + .collect::>() + .join(", "); + if names.is_empty() { + format!("Requested {} tool call(s).", tool_calls.len()) + } else { + format!("Requested tool call(s): {names}.") + } + } else if !content.trim().is_empty() { + "Prepared final reply.".to_string() + } else { + "Completed a model turn.".to_string() + } +} + +fn summarize_tool_call_value(value: &Value) -> Value { + let function = value.get("function").unwrap_or(value); + json!({ + "id": value.get("id").or_else(|| value.get("call_id")).cloned().unwrap_or(Value::Null), + "name": tool_call_name(value).unwrap_or("tool").to_string(), + "arguments": function.get("arguments").or_else(|| value.get("arguments")).cloned().unwrap_or_else(|| json!("{}")), + }) +} + +fn tool_call_name(value: &Value) -> Option<&str> { + value + .get("function") + .and_then(|function| function.get("name")) + .or_else(|| value.get("name")) + .and_then(Value::as_str) +} + +fn sampling_parameters(sampling: Option<&SamplingOverrides>) -> Value { + let mut params = json!({ + "temperature": 0.35, + }); + if MARI_MODEL_OUTPUT_TOKENS > 0 { + params["maxTokens"] = json!(MARI_MODEL_OUTPUT_TOKENS); + } + if let Some(sampling) = sampling { + if let Some(temperature) = sampling.temperature { + params["temperature"] = json!(temperature); + } + if let Some(max_tokens) = sampling.max_tokens { + if max_tokens > 0 { + params["maxTokens"] = json!(max_tokens); + } else { + params + .as_object_mut() + .map(|object| object.remove("maxTokens")); + } + } + if let Some(top_p) = sampling.top_p { + params["topP"] = json!(top_p); + } + } + params +} + +fn is_http_image_url(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + (lower.starts_with("https://") || lower.starts_with("http://")) + && (lower.ends_with(".png") + || lower.ends_with(".jpg") + || lower.ends_with(".jpeg") + || lower.ends_with(".gif") + || lower.ends_with(".webp")) +} diff --git a/src-tauri/src/commands/storage/mari/file_changes.rs b/src-tauri/src/commands/storage/mari/file_changes.rs new file mode 100644 index 000000000..41f7183e4 --- /dev/null +++ b/src-tauri/src/commands/storage/mari/file_changes.rs @@ -0,0 +1,64 @@ +use super::util; +use serde_json::{Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone)] +pub(crate) struct MariFileChange { + pub(crate) op: String, + pub(crate) path: String, + pub(crate) before: Option>, + pub(crate) after: Option>, +} + +pub(crate) fn diff_file_maps_full( + before: &BTreeMap>, + after: &BTreeMap>, +) -> Vec { + let paths = before + .keys() + .chain(after.keys()) + .cloned() + .collect::>(); + paths + .into_iter() + .filter_map(|path| match (before.get(&path), after.get(&path)) { + (None, Some(after)) => Some(MariFileChange { + op: "create".to_string(), + path, + before: None, + after: Some(after.clone()), + }), + (Some(before), None) => Some(MariFileChange { + op: "delete".to_string(), + path, + before: Some(before.clone()), + after: None, + }), + (Some(before), Some(after)) if before != after => Some(MariFileChange { + op: "modify".to_string(), + path, + before: Some(before.clone()), + after: Some(after.clone()), + }), + _ => None, + }) + .collect() +} + +pub(crate) fn file_change_summary(change: &MariFileChange) -> Value { + let mut value = Map::new(); + value.insert("op".to_string(), Value::String(change.op.clone())); + value.insert("path".to_string(), Value::String(change.path.clone())); + if let Some(before) = &change.before { + value.insert("before".to_string(), Value::String(text_preview(before))); + } + if let Some(after) = &change.after { + value.insert("after".to_string(), Value::String(text_preview(after))); + } + Value::Object(value) +} + +pub(crate) fn text_preview(bytes: &[u8]) -> String { + let text = String::from_utf8_lossy(bytes); + util::truncate_tool_text(&text) +} diff --git a/src-tauri/src/commands/storage/mari/prompt.rs b/src-tauri/src/commands/storage/mari/prompt.rs new file mode 100644 index 000000000..2278a3fd6 --- /dev/null +++ b/src-tauri/src/commands/storage/mari/prompt.rs @@ -0,0 +1,92 @@ +use super::types::{MariAttachment, MariPersonaContext, MariPromptRequest}; +use super::{MARI_SYSTEM_PROMPT, MARI_TEXT_ATTACHMENT_CHAR_LIMIT}; + +pub(crate) fn build_task_prompt(input: &MariPromptRequest) -> String { + let mut sections = vec![format!("System instructions:\n{MARI_SYSTEM_PROMPT}")]; + + if let Some(persona) = build_persona_context(input.persona.as_ref()) { + sections.push(format!("Selected user persona:\n{persona}")); + } + + let history = input + .messages + .iter() + .rev() + .take(16) + .collect::>() + .into_iter() + .rev() + .filter_map(|message| { + let content = message.content.trim(); + (!content.is_empty()).then(|| format!("{}: {content}", message.role)) + }) + .collect::>() + .join("\n"); + if !history.is_empty() { + sections.push(format!("Conversation history:\n{history}")); + } + + if !input.attachments.is_empty() { + sections.push(format!( + "Latest turn attachments:\n{}", + attachment_summary(&input.attachments) + )); + } + + sections.push(format!( + "Latest user message:\n{}", + input.user_message.trim() + )); + sections.join("\n\n") +} + +pub(crate) fn build_persona_context(persona: Option<&MariPersonaContext>) -> Option { + let persona = persona?; + let text = [ + ("Name", persona.name.as_deref()), + ("Comment", persona.comment.as_deref()), + ("Description", persona.description.as_deref()), + ("Personality", persona.personality.as_deref()), + ("Scenario", persona.scenario.as_deref()), + ("Backstory", persona.backstory.as_deref()), + ("Appearance", persona.appearance.as_deref()), + ] + .into_iter() + .filter_map(|(label, value)| { + let value = value?.trim(); + (!value.is_empty()).then(|| format!("{label}: {value}")) + }) + .collect::>() + .join("\n"); + (!text.is_empty()).then_some(text) +} + +fn attachment_summary(attachments: &[MariAttachment]) -> String { + attachments + .iter() + .map(|attachment| { + if attachment.r#type.to_ascii_lowercase().starts_with("image/") { + return format!( + "- {} ({}, {} bytes): image attachment withheld from the LLM to avoid sending full base64 data.", + attachment.name, attachment.r#type, attachment.size + ); + } + + let content = attachment.content.trim(); + let content = if content.chars().count() > MARI_TEXT_ATTACHMENT_CHAR_LIMIT { + format!( + "{}\n\n[Attachment truncated after {} characters.]", + content.chars().take(MARI_TEXT_ATTACHMENT_CHAR_LIMIT).collect::(), + MARI_TEXT_ATTACHMENT_CHAR_LIMIT + ) + } else { + content.to_string() + }; + format!( + "File: {}\nType: {}\nSize: {}\nContent:\n{}", + attachment.name, attachment.r#type, attachment.size, content + ) + }) + .collect::>() + .join("\n\n---\n\n") +} diff --git a/src-tauri/src/commands/storage/mari/shell.rs b/src-tauri/src/commands/storage/mari/shell.rs new file mode 100644 index 000000000..e4be34205 --- /dev/null +++ b/src-tauri/src/commands/storage/mari/shell.rs @@ -0,0 +1,551 @@ +use super::file_changes::{self, MariFileChange}; +use super::prompt; +use super::types::MariPromptRequest; +use super::util; +use super::workspace::{MariWorkspaceBinding, MariWorkspaceSeed}; +use super::MARI_SYSTEM_PROMPT; +use bashkit::{ + async_trait as bashkit_async_trait, Bash, DirEntry, FileSystem, FileSystemExt, FileType, + InMemoryFs, Metadata, VfsSnapshot, +}; +use marinara_core::{AppError, AppResult}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::fmt; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; +use std::time::SystemTime; +use tokio::sync::Mutex; + +#[derive(Clone)] +pub(crate) struct MariShellSession { + fs: Arc, + bash: Arc>, + initial_files: Arc>>>, + manifest: Arc>>, + trace: Arc>>, + trace_channel: tauri::ipc::Channel, + tool_review_lock: Arc>, + approval_sequence: Arc, +} + +impl MariShellSession { + pub(crate) async fn new( + input: &MariPromptRequest, + workspace_seed: MariWorkspaceSeed, + trace_channel: tauri::ipc::Channel, + ) -> AppResult> { + let fs = Arc::new(TrackingFs::new()); + fs.add_text_file("/workspace/system-prompt.md", MARI_SYSTEM_PROMPT); + fs.add_text_file("/workspace/README.md", PROF_MARI_WORKSPACE_README); + if let Some(persona) = prompt::build_persona_context(input.persona.as_ref()) { + fs.add_text_file("/workspace/active-persona.md", &persona); + } + for file in &workspace_seed.files { + fs.add_text_file(&file.path, &file.content); + } + for file in &input.workspace_files { + let path = util::resolve_virtual_path(&file.path); + fs.add_text_file(&path, &file.content); + } + for attachment in &input.attachments { + if !attachment.r#type.to_ascii_lowercase().starts_with("image/") { + let safe_name = util::sanitize_filename(&attachment.name); + fs.add_text_file( + format!("/workspace/attachments/{safe_name}").as_str(), + &attachment.content, + ); + } + } + let bash = Bash::builder() + .fs(fs.clone()) + .cwd("/workspace") + .env("HOME", "/workspace") + .env("USER", "prof-mari") + .build(); + let session = Arc::new(Self { + fs, + bash: Arc::new(Mutex::new(bash)), + initial_files: Arc::new(RwLock::new(BTreeMap::new())), + manifest: Arc::new(RwLock::new(workspace_seed.bindings)), + trace: Arc::new(RwLock::new(Vec::new())), + trace_channel, + tool_review_lock: Arc::new(Mutex::new(())), + approval_sequence: Arc::new(std::sync::atomic::AtomicU64::new(0)), + }); + let initial = session.snapshot_review_files().await?; + *session.initial_files.write().unwrap() = initial; + Ok(session) + } + + pub(crate) async fn exec_bash(&self, command: &str) -> AppResult { + let mut bash = self.bash.lock().await; + let output = bash + .exec(command) + .await + .map_err(|error| AppError::new("mari_bash_failed", error.to_string()))?; + drop(bash); + Ok(json!({ + "stdout": util::truncate_tool_text(&output.stdout), + "stderr": util::truncate_tool_text(&output.stderr), + "exitCode": output.exit_code, + "pendingChanges": self.pending_changes().await?, + })) + } + + pub(crate) async fn read_text(&self, path: &str) -> AppResult { + let path = util::resolve_virtual_path(path); + let bytes = self + .fs + .read_file(Path::new(&path)) + .await + .map_err(|error| AppError::new("mari_read_failed", error.to_string()))?; + Ok(String::from_utf8_lossy(&bytes).to_string()) + } + + pub(crate) async fn read_for_tool( + &self, + path: &str, + offset: usize, + limit: Option, + ) -> AppResult { + let path = util::resolve_virtual_path(path); + let metadata = self + .fs + .stat(Path::new(&path)) + .await + .map_err(|error| AppError::new("mari_read_failed", error.to_string()))?; + + if metadata.file_type == FileType::Directory { + return self.read_directory_for_tool(&path, offset, limit).await; + } + + self.read_file_for_tool(&path, "file", None, offset, limit) + .await + } + + pub(crate) async fn write_text(&self, path: &str, content: &str) -> AppResult { + let path = util::resolve_virtual_path(path); + ensure_parent_dirs(&self.fs, Path::new(&path)).await?; + self.fs + .write_file(Path::new(&path), content.as_bytes()) + .await + .map_err(|error| AppError::new("mari_write_failed", error.to_string()))?; + Ok(json!({ "path": path, "pendingChanges": self.pending_changes().await? })) + } + + pub(crate) async fn edit_text( + &self, + path: &str, + old_text: &str, + new_text: &str, + ) -> AppResult { + let path = util::resolve_virtual_path(path); + let current = self.read_text(&path).await?; + let matches = current.matches(old_text).count(); + if matches != 1 { + return Err(AppError::invalid_input(format!( + "edit expected oldText to match exactly once, found {matches} matches" + ))); + } + let updated = current.replacen(old_text, new_text, 1); + self.write_text(&path, &updated).await + } + + pub(crate) async fn pending_file_changes(&self) -> AppResult> { + let current = self.snapshot_review_files().await?; + let initial = self.initial_files.read().unwrap().clone(); + Ok(file_changes::diff_file_maps_full(&initial, ¤t)) + } + + pub(crate) async fn review_files_snapshot(&self) -> AppResult>> { + self.snapshot_review_files().await + } + + pub(crate) fn vfs_snapshot(&self) -> AppResult { + self.fs.vfs_snapshot().ok_or_else(|| { + AppError::new( + "mari_workspace_snapshot_failed", + "Virtual workspace snapshots are not available", + ) + }) + } + + pub(crate) fn restore_vfs_snapshot(&self, snapshot: &VfsSnapshot) -> AppResult<()> { + self.fs + .vfs_restore(snapshot) + .map_err(|error| AppError::new("mari_workspace_restore_failed", error.to_string())) + } + + pub(crate) async fn accept_current_as_baseline(&self) -> AppResult<()> { + let current = self.snapshot_review_files().await?; + self.accept_files_as_baseline(current); + Ok(()) + } + + pub(crate) fn accept_files_as_baseline(&self, files: BTreeMap>) { + *self.initial_files.write().unwrap() = files; + } + + pub(crate) async fn pending_changes(&self) -> AppResult> { + Ok(self + .pending_file_changes() + .await? + .iter() + .map(file_changes::file_change_summary) + .collect()) + } + + pub(crate) fn record_trace(&self, event: Value) { + self.trace.write().unwrap().push(event.clone()); + let _ = self + .trace_channel + .send(json!({ "type": "trace", "event": event })); + } + + pub(crate) fn trace_events(&self) -> Vec { + self.trace.read().unwrap().clone() + } + + pub(crate) fn manifest_summary(&self) -> Value { + let mut by_entity: BTreeMap<&str, usize> = BTreeMap::new(); + let mut text_field_bindings = 0usize; + let manifest = self.manifest.read().unwrap(); + for binding in manifest.values() { + *by_entity.entry(binding.entity.as_str()).or_default() += 1; + if binding + .field + .as_deref() + .is_some_and(|field| field != "metadata") + { + text_field_bindings += 1; + } + let _ = binding.id.as_str(); + } + json!({ + "boundFiles": manifest.len(), + "textFieldBindings": text_field_bindings, + "byEntity": by_entity, + }) + } + + pub(crate) fn manifest_snapshot(&self) -> BTreeMap { + self.manifest.read().unwrap().clone() + } + + pub(crate) fn bind_workspace_file( + &self, + path: String, + entity: String, + id: String, + field: String, + ) { + self.manifest.write().unwrap().insert( + path, + MariWorkspaceBinding { + entity, + id, + field: Some(field), + }, + ); + } + + pub(crate) async fn tool_review_guard(&self) -> tokio::sync::MutexGuard<'_, ()> { + self.tool_review_lock.lock().await + } + + pub(crate) fn next_approval_id(&self, tool_name: &str) -> String { + let sequence = self + .approval_sequence + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + + 1; + format!( + "mari-approval-{}-{sequence}-{tool_name}", + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() + ) + } + + pub(crate) fn send_stream_event(&self, event: Value) -> AppResult<()> { + self.trace_channel + .send(event) + .map_err(|error| AppError::new("mari_stream_event_failed", error.to_string())) + } + + async fn read_directory_for_tool( + &self, + path: &str, + offset: usize, + limit: Option, + ) -> AppResult { + let index_path = Path::new(path).join("index.md"); + if self + .fs + .exists(&index_path) + .await + .map_err(|error| AppError::new("mari_read_failed", error.to_string()))? + && self + .fs + .stat(&index_path) + .await + .map_err(|error| AppError::new("mari_read_failed", error.to_string()))? + .file_type + == FileType::File + { + return self + .read_file_for_tool( + &index_path.to_string_lossy(), + "directory_index", + Some(path), + offset, + limit, + ) + .await; + } + + let mut entries = self + .fs + .read_dir(Path::new(path)) + .await + .map_err(|error| AppError::new("mari_read_failed", error.to_string()))?; + entries.sort_by(|a, b| { + b.metadata + .file_type + .is_dir() + .cmp(&a.metadata.file_type.is_dir()) + .then_with(|| { + a.name + .to_ascii_lowercase() + .cmp(&b.name.to_ascii_lowercase()) + }) + }); + let mut lines = vec![format!("Directory: {path}"), String::new()]; + if entries.is_empty() { + lines.push("(empty directory)".to_string()); + } else { + lines.extend(entries.into_iter().map(directory_entry_line)); + } + let content = lines.join("\n"); + let (selected, total_lines) = select_lines(&content, offset, limit); + Ok(json!({ + "path": path, + "kind": "directory", + "content": util::truncate_tool_text(&selected), + "totalLines": total_lines, + "note": "Path is a directory; returned a directory listing because no index.md file exists.", + })) + } + + async fn read_file_for_tool( + &self, + path: &str, + kind: &str, + directory_path: Option<&str>, + offset: usize, + limit: Option, + ) -> AppResult { + let bytes = self + .fs + .read_file(Path::new(path)) + .await + .map_err(|error| AppError::new("mari_read_failed", error.to_string()))?; + let content = String::from_utf8_lossy(&bytes); + let (selected, total_lines) = select_lines(&content, offset, limit); + let mut value = json!({ + "path": path, + "kind": kind, + "content": util::truncate_tool_text(&selected), + "totalLines": total_lines, + }); + if let Some(directory_path) = directory_path { + value["directoryPath"] = json!(directory_path); + value["note"] = json!("Path is a directory; returned its index.md file."); + } + Ok(value) + } + + async fn snapshot_review_files(&self) -> AppResult>> { + let mut files = BTreeMap::new(); + collect_files_recursive(&self.fs, Path::new("/workspace"), &mut files).await?; + Ok(files) + } +} + +const PROF_MARI_WORKSPACE_README: &str = "# Prof Mari virtual workspace\n\nThis is an isolated bash workspace populated from the user's Marinara creative library. Start at `/workspace/index.md`, then inspect folders such as `characters/`, `personas/`, `lorebooks/`, and `prompts/`. Paths are descriptive and duplicate-safe; Marinara tracks hidden storage IDs internally. When a tool changes files, Marinara pauses for user approval before that tool result is returned.\n\nThe `read` tool accepts files and directories. Reading a directory returns its `index.md` when present, otherwise an ls-style listing. For file layout requirements, read `/workspace/FORMAT.md` and the nearest folder-level `FORMAT.md`.\n"; + +fn directory_entry_line(entry: DirEntry) -> String { + let suffix = match entry.metadata.file_type { + FileType::Directory => "/", + FileType::Symlink => "@", + FileType::Fifo => "|", + FileType::File => "", + }; + let size = if entry.metadata.file_type == FileType::File { + format!(", {} bytes", entry.metadata.size) + } else { + String::new() + }; + format!( + "- {}{} ({}){}", + entry.name, + suffix, + file_type_label(entry.metadata.file_type), + size + ) +} + +fn file_type_label(file_type: FileType) -> &'static str { + match file_type { + FileType::File => "file", + FileType::Directory => "directory", + FileType::Symlink => "symlink", + FileType::Fifo => "fifo", + } +} + +fn select_lines(content: &str, offset: usize, limit: Option) -> (String, usize) { + let lines = content.lines().collect::>(); + let selected = lines + .iter() + .skip(offset.saturating_sub(1)) + .take(limit.unwrap_or(usize::MAX)) + .copied() + .collect::>() + .join("\n"); + (selected, lines.len()) +} + +struct TrackingFs { + inner: InMemoryFs, +} + +impl fmt::Debug for TrackingFs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TrackingFs").finish() + } +} + +impl TrackingFs { + fn new() -> Self { + Self { + inner: InMemoryFs::new(), + } + } + + fn add_text_file(&self, path: &str, content: &str) { + self.inner.add_file(path, content.as_bytes(), 0o644); + } +} + +#[bashkit_async_trait] +impl FileSystemExt for TrackingFs { + fn usage(&self) -> bashkit::FsUsage { + self.inner.usage() + } + + fn limits(&self) -> bashkit::FsLimits { + self.inner.limits() + } + + fn vfs_snapshot(&self) -> Option { + self.inner.vfs_snapshot() + } + + fn vfs_restore(&self, snapshot: &bashkit::VfsSnapshot) -> bashkit::Result<()> { + self.inner.vfs_restore(snapshot) + } +} + +#[bashkit_async_trait] +impl FileSystem for TrackingFs { + async fn read_file(&self, path: &Path) -> bashkit::Result> { + self.inner.read_file(path).await + } + async fn write_file(&self, path: &Path, content: &[u8]) -> bashkit::Result<()> { + self.inner.write_file(path, content).await + } + async fn append_file(&self, path: &Path, content: &[u8]) -> bashkit::Result<()> { + self.inner.append_file(path, content).await + } + async fn mkdir(&self, path: &Path, recursive: bool) -> bashkit::Result<()> { + self.inner.mkdir(path, recursive).await + } + async fn remove(&self, path: &Path, recursive: bool) -> bashkit::Result<()> { + self.inner.remove(path, recursive).await + } + async fn stat(&self, path: &Path) -> bashkit::Result { + self.inner.stat(path).await + } + async fn read_dir(&self, path: &Path) -> bashkit::Result> { + self.inner.read_dir(path).await + } + async fn exists(&self, path: &Path) -> bashkit::Result { + self.inner.exists(path).await + } + async fn rename(&self, from: &Path, to: &Path) -> bashkit::Result<()> { + self.inner.rename(from, to).await + } + async fn copy(&self, from: &Path, to: &Path) -> bashkit::Result<()> { + self.inner.copy(from, to).await + } + async fn symlink(&self, target: &Path, link: &Path) -> bashkit::Result<()> { + self.inner.symlink(target, link).await + } + async fn read_link(&self, path: &Path) -> bashkit::Result { + self.inner.read_link(path).await + } + async fn chmod(&self, path: &Path, mode: u32) -> bashkit::Result<()> { + self.inner.chmod(path, mode).await + } + async fn set_modified_time(&self, path: &Path, time: SystemTime) -> bashkit::Result<()> { + self.inner.set_modified_time(path, time).await + } +} + +async fn ensure_parent_dirs(fs: &TrackingFs, path: &Path) -> AppResult<()> { + if let Some(parent) = path.parent() { + fs.mkdir(parent, true) + .await + .map_err(|error| AppError::new("mari_mkdir_failed", error.to_string()))?; + } + Ok(()) +} + +async fn collect_files_recursive( + fs: &TrackingFs, + path: &Path, + files: &mut BTreeMap>, +) -> AppResult<()> { + if !fs + .exists(path) + .await + .map_err(|error| AppError::new("mari_fs_failed", error.to_string()))? + { + return Ok(()); + } + let meta = fs + .stat(path) + .await + .map_err(|error| AppError::new("mari_fs_failed", error.to_string()))?; + if meta.file_type == FileType::File { + let content = fs + .read_file(path) + .await + .map_err(|error| AppError::new("mari_fs_failed", error.to_string()))?; + files.insert( + util::normalize_virtual_path(&path.to_string_lossy()), + content, + ); + return Ok(()); + } + if meta.file_type == FileType::Directory { + for entry in fs + .read_dir(path) + .await + .map_err(|error| AppError::new("mari_fs_failed", error.to_string()))? + { + let child = path.join(entry.name); + Box::pin(collect_files_recursive(fs, &child, files)).await?; + } + } + Ok(()) +} diff --git a/src-tauri/src/commands/storage/mari/tools.rs b/src-tauri/src/commands/storage/mari/tools.rs new file mode 100644 index 000000000..2aba984e3 --- /dev/null +++ b/src-tauri/src/commands/storage/mari/tools.rs @@ -0,0 +1,600 @@ +use super::actions; +use super::file_changes; +use super::shell::MariShellSession; +use super::util; +use super::workspace; +use crate::state::AppState; +use autoagents::async_trait; +use autoagents::core::tool::{ToolCallError, ToolRuntime, ToolT}; +use marinara_core::{AppError, AppResult}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::fmt; +use std::sync::Arc; + +#[derive(Debug, Clone, Copy)] +enum PiToolKind { + Read, + Bash, + Edit, + Write, +} + +impl PiToolKind { + fn can_mutate(self) -> bool { + !matches!(self, Self::Read) + } +} + +#[derive(Clone)] +struct PiLikeTool { + kind: PiToolKind, + state: AppState, + session: Arc, +} + +impl fmt::Debug for PiLikeTool { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PiLikeTool") + .field("kind", &self.kind) + .finish() + } +} + +#[async_trait] +impl ToolRuntime for PiLikeTool { + async fn execute(&self, args: Value) -> Result { + let started_at = chrono::Utc::now().to_rfc3339(); + let tool_name = self.name().to_string(); + let args_for_trace = summarize_tool_args(&tool_name, &args); + let result = self.execute_with_review(args, &tool_name).await; + match result { + Ok(value) => { + self.session.record_trace(json!({ + "type": "tool_result", + "label": tool_label(&tool_name), + "tool": tool_name, + "startedAt": started_at, + "finishedAt": chrono::Utc::now().to_rfc3339(), + "arguments": args_for_trace, + "result": summarize_tool_result(&value), + "status": "success", + })); + Ok(value) + } + Err(error) => { + let message = error.message.clone(); + self.session.record_trace(json!({ + "type": "tool_result", + "label": tool_label(&tool_name), + "tool": tool_name, + "startedAt": started_at, + "finishedAt": chrono::Utc::now().to_rfc3339(), + "arguments": args_for_trace, + "error": message, + "status": "error", + })); + Err(tool_runtime_error(error)) + } + } + } +} + +impl ToolT for PiLikeTool { + fn name(&self) -> &str { + match self.kind { + PiToolKind::Read => "read", + PiToolKind::Bash => "bash", + PiToolKind::Edit => "edit", + PiToolKind::Write => "write", + } + } + + fn description(&self) -> &str { + match self.kind { + PiToolKind::Read => "Read a file or directory from the virtual workspace. Directory paths return index.md when present, otherwise a listing. Supports optional 1-indexed offset and line limit.", + PiToolKind::Bash => "Execute bash commands in the isolated virtual workspace. If files change, Mari pauses for user approval before the result is returned.", + PiToolKind::Edit => "Edit a text file using exact text replacement. oldText must match exactly once. File changes pause for user approval before the result is returned.", + PiToolKind::Write => "Create or overwrite a text file in the virtual workspace. File changes pause for user approval before the result is returned.", + } + } + + fn args_schema(&self) -> Value { + match self.kind { + PiToolKind::Read => { + json!({"type":"object","properties":{"path":{"type":"string"},"offset":{"type":"integer","minimum":1},"limit":{"type":"integer","minimum":1}},"required":["path"]}) + } + PiToolKind::Bash => { + json!({"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}) + } + PiToolKind::Edit => { + json!({"type":"object","properties":{"path":{"type":"string"},"oldText":{"type":"string"},"newText":{"type":"string"}},"required":["path","oldText","newText"]}) + } + PiToolKind::Write => { + json!({"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}) + } + } + } +} + +impl PiLikeTool { + async fn execute_with_review(&self, args: Value, tool_name: &str) -> AppResult { + let _guard = self.session.tool_review_guard().await; + if !self.kind.can_mutate() { + return self.execute_inner(args).await; + } + + let before_files = self.session.review_files_snapshot().await?; + let before_vfs = self.session.vfs_snapshot()?; + let value = self.execute_inner(args).await?; + let after_files = self.session.review_files_snapshot().await?; + if file_changes::diff_file_maps_full(&before_files, &after_files).is_empty() { + return Ok(value); + } + + self.review_tool_changes(tool_name, value, before_files, before_vfs) + .await + } + + async fn execute_inner(&self, args: Value) -> AppResult { + match self.kind { + PiToolKind::Read => self.tool_read(args).await, + PiToolKind::Bash => self.tool_bash(args).await, + PiToolKind::Edit => self.tool_edit(args).await, + PiToolKind::Write => self.tool_write(args).await, + } + } + + async fn review_tool_changes( + &self, + tool_name: &str, + value: Value, + before_files: BTreeMap>, + before_vfs: bashkit::VfsSnapshot, + ) -> AppResult { + let action = actions::staged_mari_action_contract(&self.state, &self.session).await?; + if storage_action_count(&action) == 0 || unmapped_change_count(&action) > 0 { + self.session.restore_vfs_snapshot(&before_vfs)?; + self.session.accept_files_as_baseline(before_files); + return Err(AppError::invalid_input( + "Mari changed files that cannot be written to the creative library. Edit character, persona, lorebook, prompt, or group record files only.", + )); + } + let approval_id = self.session.next_approval_id(tool_name); + let requested_at = chrono::Utc::now().to_rfc3339(); + let receiver = self.state.register_mari_approval(&approval_id)?; + let approval = json!({ + "id": approval_id, + "tool": tool_name, + "label": tool_label(tool_name), + "requestedAt": requested_at, + "action": action, + "result": summarize_tool_result(&value), + }); + + self.session.record_trace(json!({ + "type": "approval_request", + "label": "Review changes", + "tool": tool_name, + "status": "waiting", + "summary": approval_summary(approval.get("action").unwrap_or(&Value::Null)), + "approvalId": approval_id, + })); + + if let Err(error) = self + .session + .send_stream_event(json!({ "type": "approval_request", "approval": approval })) + { + self.state.cancel_mari_approval(&approval_id); + self.session.restore_vfs_snapshot(&before_vfs)?; + self.session.accept_files_as_baseline(before_files); + return Err(error); + } + + let approved = match receiver.await { + Ok(approved) => approved, + Err(_) => { + self.session.restore_vfs_snapshot(&before_vfs)?; + self.session.accept_files_as_baseline(before_files); + return Err(AppError::new( + "mari_approval_cancelled", + "Professor Mari approval was cancelled before a decision was received", + )); + } + }; + + if !approved { + self.session.restore_vfs_snapshot(&before_vfs)?; + self.session.accept_files_as_baseline(before_files); + let outcome = approval_outcome(&approval_id, false, &action, None, None); + self.session.record_trace(json!({ + "type": "approval_resolved", + "label": "Changes rejected", + "tool": tool_name, + "status": "rejected", + "summary": "The workspace was rolled back before Mari continued.", + "approvalId": approval_id, + })); + let _ = self.session.send_stream_event(json!({ + "type": "approval_resolved", + "approvalId": approval_id, + "approved": false, + "outcome": outcome, + })); + return Ok(with_approval_outcome(value, outcome)); + } + + let apply_result = match apply_storage_actions_if_needed(&self.state, &action) { + Ok(result) => result, + Err(error) => { + let message = error.message.clone(); + let _ = self.session.restore_vfs_snapshot(&before_vfs); + self.session.accept_files_as_baseline(before_files); + let _ = self.session.send_stream_event(json!({ + "type": "approval_resolved", + "approvalId": approval_id, + "approved": true, + "error": message, + })); + return Err(error); + } + }; + absorb_storage_action_bindings(&self.session, &action, &apply_result); + self.session.accept_current_as_baseline().await?; + let outcome = approval_outcome(&approval_id, true, &action, Some(&apply_result), None); + self.session.record_trace(json!({ + "type": "approval_resolved", + "label": "Changes approved", + "tool": tool_name, + "status": "approved", + "summary": approval_summary(&action), + "approvalId": approval_id, + })); + let _ = self.session.send_stream_event(json!({ + "type": "approval_resolved", + "approvalId": approval_id, + "approved": true, + "outcome": outcome, + "applied": summarize_apply_result(&apply_result), + })); + Ok(with_approval_outcome(value, outcome)) + } + + async fn tool_read(&self, args: Value) -> AppResult { + let path = required_str(&args, "path")?; + let offset = args + .get("offset") + .and_then(Value::as_u64) + .unwrap_or(1) + .max(1) as usize; + let limit = args + .get("limit") + .and_then(Value::as_u64) + .map(|v| v.max(1) as usize); + self.session.read_for_tool(path, offset, limit).await + } + + async fn tool_bash(&self, args: Value) -> AppResult { + self.session + .exec_bash(required_str(&args, "command")?) + .await + } + + async fn tool_edit(&self, args: Value) -> AppResult { + self.session + .edit_text( + required_str(&args, "path")?, + required_str(&args, "oldText")?, + required_str(&args, "newText")?, + ) + .await + } + + async fn tool_write(&self, args: Value) -> AppResult { + self.session + .write_text( + required_str(&args, "path")?, + required_str(&args, "content")?, + ) + .await + } +} + +fn tool_label(name: &str) -> String { + match name { + "read" => "Read file", + "bash" => "Run bash", + "edit" => "Edit file", + "write" => "Write file", + _ => "Use tool", + } + .to_string() +} + +fn summarize_tool_args(tool: &str, args: &Value) -> Value { + match tool { + "read" => json!({ + "path": args.get("path").cloned().unwrap_or(Value::Null), + "offset": args.get("offset").cloned().unwrap_or(Value::Null), + "limit": args.get("limit").cloned().unwrap_or(Value::Null), + }), + "bash" => json!({ + "command": args.get("command").and_then(Value::as_str).map(util::truncate_tool_text).unwrap_or_default(), + }), + "edit" => json!({ + "path": args.get("path").cloned().unwrap_or(Value::Null), + "oldText": args.get("oldText").and_then(Value::as_str).map(util::truncate_tool_text).unwrap_or_default(), + "newText": args.get("newText").and_then(Value::as_str).map(util::truncate_tool_text).unwrap_or_default(), + }), + "write" => json!({ + "path": args.get("path").cloned().unwrap_or(Value::Null), + "content": args.get("content").and_then(Value::as_str).map(util::truncate_tool_text).unwrap_or_default(), + }), + _ => args.clone(), + } +} + +fn summarize_tool_result(value: &Value) -> Value { + match value { + Value::Object(object) => Value::Object( + object + .iter() + .map(|(key, value)| { + let next = match value { + Value::String(text) => Value::String(util::truncate_tool_text(text)), + _ => value.clone(), + }; + (key.clone(), next) + }) + .collect(), + ), + Value::String(text) => Value::String(util::truncate_tool_text(text)), + _ => value.clone(), + } +} + +fn apply_storage_actions_if_needed(state: &AppState, action: &Value) -> AppResult { + if storage_action_count(action) == 0 { + return Err(AppError::invalid_input( + "No creative-library storage changes were available to apply", + )); + } + actions::professor_mari_apply_staged_changes(state, action.clone()) +} + +fn with_approval_outcome(mut value: Value, outcome: Value) -> Value { + if let Value::Object(object) = &mut value { + object.insert("approval".to_string(), outcome); + object.insert("pendingChanges".to_string(), Value::Array(Vec::new())); + return value; + } + json!({ "result": value, "approval": outcome }) +} + +fn approval_outcome( + approval_id: &str, + approved: bool, + action: &Value, + apply_result: Option<&Value>, + error: Option<&str>, +) -> Value { + json!({ + "id": approval_id, + "status": if approved { "approved" } else { "rejected" }, + "approved": approved, + "changeCount": change_count(action), + "storageActionCount": storage_action_count(action), + "unmappedChangeCount": unmapped_change_count(action), + "summary": approval_summary(action), + "applied": apply_result.map(summarize_apply_result).unwrap_or(Value::Null), + "error": error, + }) +} + +fn summarize_apply_result(value: &Value) -> Value { + json!({ + "applied": value.get("applied").and_then(Value::as_u64).unwrap_or_default(), + "appliedAt": value.get("appliedAt").cloned().unwrap_or(Value::Null), + "results": value + .get("results") + .and_then(Value::as_array) + .map(|results| { + results + .iter() + .map(|result| json!({ + "type": result.get("type").cloned().unwrap_or(Value::Null), + "entity": result.get("entity").cloned().unwrap_or(Value::Null), + "id": result + .get("id") + .cloned() + .or_else(|| result.get("record").and_then(|record| record.get("id")).cloned()) + .unwrap_or(Value::Null), + })) + .collect::>() + }) + .unwrap_or_default(), + }) +} + +fn approval_summary(action: &Value) -> String { + let changes = change_count(action); + let storage_actions = storage_action_count(action); + let unmapped = unmapped_change_count(action); + match (storage_actions, unmapped) { + (0, 0) => format!("{changes} workspace file change{} ready for review before Mari's next step.", plural(changes)), + (0, _) => format!( + "{changes} workspace file change{} need review; {unmapped} cannot be applied to storage automatically.", + plural(changes) + ), + (_, 0) => format!( + "{storage_actions} library update{} from {changes} file change{}.", + plural(storage_actions), + plural(changes) + ), + _ => format!( + "{storage_actions} library update{} plus {unmapped} workspace-only change{}.", + plural(storage_actions), + plural(unmapped) + ), + } +} + +fn change_count(action: &Value) -> usize { + action + .get("changes") + .and_then(Value::as_array) + .map_or(0, Vec::len) +} + +fn storage_action_count(action: &Value) -> usize { + action + .get("storageActions") + .and_then(Value::as_array) + .map_or(0, Vec::len) +} + +fn unmapped_change_count(action: &Value) -> usize { + action + .get("unmappedChanges") + .and_then(Value::as_array) + .map_or(0, Vec::len) +} + +fn plural(count: usize) -> &'static str { + if count == 1 { + "" + } else { + "s" + } +} + +fn absorb_storage_action_bindings( + session: &MariShellSession, + action: &Value, + apply_result: &Value, +) { + let Some(storage_actions) = action.get("storageActions").and_then(Value::as_array) else { + return; + }; + let results = apply_result + .get("results") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + for (index, storage_action) in storage_actions.iter().enumerate() { + let Some(entity) = storage_action.get("entity").and_then(Value::as_str) else { + continue; + }; + let id = storage_action + .get("id") + .and_then(Value::as_str) + .or_else(|| { + results + .get(index) + .and_then(|result| result.get("record")) + .and_then(|record| record.get("id")) + .and_then(Value::as_str) + }); + let Some(id) = id else { + continue; + }; + let Some(paths) = storage_action.get("paths").and_then(Value::as_array) else { + continue; + }; + for path in paths.iter().filter_map(Value::as_str) { + if let Some(field) = field_for_workspace_path(entity, path) { + session.bind_workspace_file( + util::resolve_virtual_path(path), + entity.to_string(), + id.to_string(), + field.to_string(), + ); + } + } + } +} + +fn field_for_workspace_path(entity: &str, path: &str) -> Option<&'static str> { + let file_name = path.rsplit('/').next()?; + if file_name == "metadata.json" { + return Some("metadata"); + } + if file_name == "keys.txt" { + return Some("keys"); + } + let stem = file_name.strip_suffix(".md")?; + workspace_text_fields_for_entity(entity) + .iter() + .copied() + .find(|field| workspace::field_file_name(field) == stem) +} + +fn workspace_text_fields_for_entity(entity: &str) -> &'static [&'static str] { + match entity { + "characters" => &[ + "data.description", + "data.personality", + "data.scenario", + "data.first_mes", + "data.mes_example", + "data.creator_notes", + "data.system_prompt", + "data.post_history_instructions", + "data.extensions.backstory", + "data.extensions.appearance", + ], + "character-groups" => &["description", "notes"], + "personas" => &[ + "description", + "personality", + "scenario", + "backstory", + "appearance", + "firstMessage", + "greeting", + "notes", + ], + "persona-groups" => &["description", "notes"], + "lorebooks" => &["description", "content", "notes"], + "lorebook-entries" => &["content", "comment", "description", "notes", "keys"], + "prompts" => &["description", "prompt", "systemPrompt", "notes"], + "prompt-sections" => &["prompt", "content", "text", "description"], + "prompt-groups" => &["description", "notes"], + "prompt-variables" => &["value", "content", "text", "description"], + _ => &[], + } +} + +pub(crate) fn build_pi_like_tools( + state: AppState, + session: Arc, +) -> Vec> { + [ + PiToolKind::Read, + PiToolKind::Bash, + PiToolKind::Edit, + PiToolKind::Write, + ] + .into_iter() + .map(|kind| { + Arc::new(PiLikeTool { + kind, + state: state.clone(), + session: session.clone(), + }) as Arc + }) + .collect() +} + +fn required_str<'a>(value: &'a Value, key: &str) -> AppResult<&'a str> { + value + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| AppError::invalid_input(format!("missing required string field `{key}`"))) +} + +fn tool_runtime_error(error: AppError) -> ToolCallError { + ToolCallError::RuntimeError(Box::new(std::io::Error::other(error.to_string()))) +} diff --git a/src-tauri/src/commands/storage/mari/types.rs b/src-tauri/src/commands/storage/mari/types.rs new file mode 100644 index 000000000..c0ef8c111 --- /dev/null +++ b/src-tauri/src/commands/storage/mari/types.rs @@ -0,0 +1,51 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MariPromptRequest { + pub(crate) user_message: String, + #[serde(default)] + pub(crate) messages: Vec, + #[serde(default)] + pub(crate) connection_id: Option, + #[serde(default)] + pub(crate) persona: Option, + #[serde(default)] + pub(crate) attachments: Vec, + #[serde(default)] + pub(crate) workspace_files: Vec, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct MariPromptMessage { + pub(crate) role: String, + pub(crate) content: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MariPersonaContext { + pub(crate) name: Option, + pub(crate) comment: Option, + pub(crate) description: Option, + pub(crate) personality: Option, + pub(crate) scenario: Option, + pub(crate) backstory: Option, + pub(crate) appearance: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct MariAttachment { + pub(crate) name: String, + #[serde(default)] + pub(crate) r#type: String, + #[serde(default)] + pub(crate) size: u64, + pub(crate) content: String, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct MariWorkspaceFile { + pub(crate) path: String, + pub(crate) content: String, +} diff --git a/src-tauri/src/commands/storage/mari/util.rs b/src-tauri/src/commands/storage/mari/util.rs new file mode 100644 index 000000000..546667867 --- /dev/null +++ b/src-tauri/src/commands/storage/mari/util.rs @@ -0,0 +1,69 @@ +use super::MARI_TOOL_TEXT_LIMIT; +use marinara_core::AppError; + +pub(crate) fn resolve_virtual_path(path: &str) -> String { + let trimmed = path.trim(); + let raw = if trimmed.starts_with('/') { + trimmed.to_string() + } else { + format!("/workspace/{trimmed}") + }; + normalize_virtual_path(&raw) +} + +pub(crate) fn normalize_virtual_path(path: &str) -> String { + let normalized_separators = path.replace('\\', "/"); + let mut parts = Vec::new(); + for part in normalized_separators.split('/') { + match part { + "" | "." => {} + ".." => { + parts.pop(); + } + other => parts.push(other), + } + } + format!("/{}", parts.join("/")) +} + +pub(crate) fn sanitize_filename(name: &str) -> String { + let cleaned: String = name + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { + c + } else { + '_' + } + }) + .collect(); + if cleaned.is_empty() { + "attachment.txt".to_string() + } else { + cleaned + } +} + +pub(crate) fn truncate_tool_text(text: &str) -> String { + if text.chars().count() > MARI_TOOL_TEXT_LIMIT { + format!( + "{}\n[truncated after {} characters]", + text.chars().take(MARI_TOOL_TEXT_LIMIT).collect::(), + MARI_TOOL_TEXT_LIMIT + ) + } else { + text.to_string() + } +} + +pub(crate) fn format_app_error_for_debug(error: &AppError) -> String { + let mut message = error.to_string(); + if let Some(details) = &error.details { + let details = serde_json::to_string_pretty(details).unwrap_or_else(|serialize_error| { + format!("Could not serialize error details: {serialize_error}") + }); + message.push_str("\nProvider debug details:\n"); + message.push_str(&details.chars().take(12_000).collect::()); + } + message +} diff --git a/src-tauri/src/commands/storage/mari/workspace.rs b/src-tauri/src/commands/storage/mari/workspace.rs new file mode 100644 index 000000000..de12859cd --- /dev/null +++ b/src-tauri/src/commands/storage/mari/workspace.rs @@ -0,0 +1,962 @@ +use super::MARI_METADATA_STRING_LIMIT; +use crate::state::AppState; +use marinara_core::{AppError, AppResult}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone)] +pub(crate) struct MariWorkspaceFileRecord { + pub(crate) path: String, + pub(crate) content: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct MariWorkspaceBinding { + pub(crate) entity: String, + pub(crate) id: String, + pub(crate) field: Option, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct MariWorkspaceSeed { + pub(crate) files: Vec, + pub(crate) bindings: BTreeMap, +} + +#[derive(Debug, Default)] +struct PathAllocator { + used: BTreeSet, +} + +impl PathAllocator { + fn child(&mut self, parent: &str, preferred: &str, fallback: &str) -> String { + let base = sanitize_path_segment( + first_non_empty(&[Some(preferred), Some(fallback)]).unwrap_or(fallback), + ); + for index in 1.. { + let name = if index == 1 { + base.clone() + } else { + format!("{base} ({index})") + }; + let path = format!("{}/{}", parent.trim_end_matches('/'), name); + if self.used.insert(path.clone()) { + return path; + } + } + unreachable!() + } +} + +pub(crate) fn build_mari_workspace_seed(state: &AppState) -> AppResult { + let mut seed = MariWorkspaceSeed::default(); + let mut allocator = PathAllocator::default(); + for root in [ + "/workspace/characters", + "/workspace/character-groups", + "/workspace/personas", + "/workspace/persona-groups", + "/workspace/lorebooks", + "/workspace/prompts", + ] { + allocator.used.insert(root.to_string()); + } + add_workspace_format_guides(&mut seed); + + let characters = list_storage_or_empty(state, "characters")?; + add_flat_collection( + &mut seed, + &mut allocator, + "characters", + "/workspace/characters", + "Untitled Character", + &characters, + &[ + "data.description", + "data.personality", + "data.scenario", + "data.first_mes", + "data.mes_example", + "data.creator_notes", + "data.system_prompt", + "data.post_history_instructions", + "data.extensions.backstory", + "data.extensions.appearance", + ], + )?; + + let character_groups = list_storage_or_empty(state, "character-groups")?; + add_flat_collection( + &mut seed, + &mut allocator, + "character-groups", + "/workspace/character-groups", + "Untitled Character Group", + &character_groups, + &["description", "notes"], + )?; + + let personas = list_storage_or_empty(state, "personas")?; + add_flat_collection( + &mut seed, + &mut allocator, + "personas", + "/workspace/personas", + "Untitled Persona", + &personas, + &[ + "description", + "personality", + "scenario", + "backstory", + "appearance", + "firstMessage", + "greeting", + "notes", + ], + )?; + + let persona_groups = list_storage_or_empty(state, "persona-groups")?; + add_flat_collection( + &mut seed, + &mut allocator, + "persona-groups", + "/workspace/persona-groups", + "Untitled Persona Group", + &persona_groups, + &["description", "notes"], + )?; + + add_lorebooks_to_workspace(state, &mut seed, &mut allocator)?; + add_prompts_to_workspace(state, &mut seed, &mut allocator)?; + + add_workspace_index( + &mut seed, + &[ + ("characters", characters.len()), + ("character-groups", character_groups.len()), + ("personas", personas.len()), + ("persona-groups", persona_groups.len()), + ( + "lorebooks", + list_storage_or_empty(state, "lorebooks")?.len(), + ), + ("prompts", list_storage_or_empty(state, "prompts")?.len()), + ], + ); + + Ok(seed) +} + +fn add_workspace_format_guides(seed: &mut MariWorkspaceSeed) { + add_unbound_file(seed, "/workspace/FORMAT.md", root_format_guide()); + for (path, entity) in [ + ("/workspace/characters/FORMAT.md", "characters"), + ("/workspace/character-groups/FORMAT.md", "character-groups"), + ("/workspace/personas/FORMAT.md", "personas"), + ("/workspace/persona-groups/FORMAT.md", "persona-groups"), + ("/workspace/lorebooks/FORMAT.md", "lorebooks"), + ("/workspace/prompts/FORMAT.md", "prompts"), + ] { + add_unbound_file(seed, path, format_guide_for_entity(entity)); + } +} + +fn root_format_guide() -> String { + [ + "# Workspace format guide", + "", + "Use the nearest `FORMAT.md` before creating or editing files. The root index is only navigation; file layout rules live here and in collection folders.", + "", + "## General rules", + "- Existing records are folders. Edit their existing `.md`, `.txt`, or `metadata.json` files.", + "- Long prose belongs in the named text files listed by the nearest format guide.", + "- Structured fields belong in `metadata.json`, which must be valid JSON.", + "- Do not add storage IDs. Marinara maps friendly paths back to records internally.", + "- Do not paste base64 images or binary blobs into workspace files.", + "- Deleting an existing text file clears that field. Deleting `metadata.json` or whole record folders is not an automatic record delete.", + "", + "## Creating top-level records", + "- Create a new folder under `characters/`, `personas/`, `lorebooks/`, `prompts/`, `character-groups/`, or `persona-groups/`.", + "- Use the exact file names shown in that folder's `FORMAT.md`.", + "- Include `metadata.json` when you need names, tags, flags, ordering, or other structured fields.", + "- For character names, set `data.name` in `metadata.json`. For most other records, set `name`.", + ] + .join("\n") +} + +fn format_guide_for_entity(entity: &str) -> String { + match entity { + "characters" => [ + "# Character folder format", + "", + "Create or edit one folder per character.", + "", + "## Required identity", + "- `metadata.json`: valid JSON. Set `data.name` to the character name. Optional useful fields: `comment`, `data.tags`, `data.creator`, `data.character_version`.", + "", + "## Text files", + "- `data.description.md`: appearance, role, and durable description.", + "- `data.personality.md`: personality traits, speaking style, motivations.", + "- `data.scenario.md`: default situation or setting.", + "- `data.first_mes.md`: opening message.", + "- `data.mes_example.md`: example dialogue.", + "- `data.creator_notes.md`: private notes for the user/creator.", + "- `data.system_prompt.md`: character-specific system guidance.", + "- `data.post_history_instructions.md`: instructions applied after chat history.", + "- `data.extensions.backstory.md`: longer backstory.", + "- `data.extensions.appearance.md`: detailed visual description.", + ] + .join("\n"), + "character-groups" => [ + "# Character group folder format", + "", + "Create or edit one folder per character group.", + "", + "## Metadata", + "- `metadata.json`: valid JSON. Set `name` or `title`. Keep membership/order fields structured here if present.", + "", + "## Text files", + "- `description.md`: what this group is for.", + "- `notes.md`: private organization notes.", + ] + .join("\n"), + "personas" => [ + "# Persona folder format", + "", + "Create or edit one folder per user persona.", + "", + "## Metadata", + "- `metadata.json`: valid JSON. Set `name`. Optional useful fields: `comment`, `tags`, `isActive`.", + "", + "## Text files", + "- `description.md`: concise persona description.", + "- `personality.md`: traits and behavior.", + "- `scenario.md`: default context.", + "- `backstory.md`: history.", + "- `appearance.md`: visual description.", + "- `first_message.md`: opening message.", + "- `greeting.md`: greeting text.", + "- `notes.md`: private user notes.", + ] + .join("\n"), + "persona-groups" => [ + "# Persona group folder format", + "", + "Create or edit one folder per persona group.", + "", + "## Metadata", + "- `metadata.json`: valid JSON. Set `name` or `title`. Keep membership/order fields structured here if present.", + "", + "## Text files", + "- `description.md`: what this group is for.", + "- `notes.md`: private organization notes.", + ] + .join("\n"), + "lorebooks" => [ + "# Lorebook folder format", + "", + "Create or edit one folder per lorebook. Lorebook entries live in that lorebook's `entries/` folder.", + "", + "## Metadata", + "- `metadata.json`: valid JSON. Set `name`. Optional useful fields: `description`, `tags`, `enabled`, `isGlobal`, `scanDepth`, `tokenBudget`, `recursiveScanning`.", + "", + "## Text files", + "- `description.md`: short purpose/summary.", + "- `content.md`: broad lorebook-level prose if the lorebook uses it.", + "- `notes.md`: private organization notes.", + "", + "## Entries", + "- Read `entries/FORMAT.md` inside a lorebook before editing or creating entries.", + ] + .join("\n"), + "lorebook-entries" => [ + "# Lorebook entry folder format", + "", + "Edit one folder per existing lorebook entry inside this `entries/` folder. Creating brand-new entries from new folders is not applied automatically yet.", + "", + "## Metadata", + "- `metadata.json`: valid JSON. Useful fields include `enabled`, `insertionOrder`, `priority`, `position`, `constant`, `selective`, `secondaryKeys`, `caseSensitive`, `matchWholeWords`.", + "", + "## Text files", + "- `keys.txt`: one activation key per line.", + "- `content.md`: lore inserted when the entry activates.", + "- `comment.md`: short label or editor comment.", + "- `description.md`: optional explanation.", + "- `notes.md`: private organization notes.", + ] + .join("\n"), + "prompts" => [ + "# Prompt preset folder format", + "", + "Create or edit one folder per prompt preset. Sections, groups, and variables live under their matching subfolders.", + "", + "## Metadata", + "- `metadata.json`: valid JSON. Set `name`. Optional useful fields: `description`, `isDefault`, `sectionOrder`, `groupOrder`, `variableOrder`, `parameters`, `variableValues`.", + "", + "## Text files", + "- `description.md`: short purpose/summary.", + "- `prompt.md`: preset prompt body when used by this record shape.", + "- `system_prompt.md`: preset-level system prompt.", + "- `notes.md`: private organization notes.", + "", + "## Nested folders", + "- Read `sections/FORMAT.md`, `groups/FORMAT.md`, or `variables/FORMAT.md` before editing those records.", + ] + .join("\n"), + "prompt-sections" => [ + "# Prompt section folder format", + "", + "Edit one folder per existing prompt section inside this `sections/` folder. Creating brand-new sections from new folders is not applied automatically yet.", + "", + "## Metadata", + "- `metadata.json`: valid JSON. Useful fields include `name`, `role`, `type`, `enabled`, `groupId`, `sortOrder`, `markerConfig`.", + "", + "## Text files", + "- `prompt.md`: section prompt text.", + "- `content.md`: section content if this shape uses content instead of prompt.", + "- `text.md`: section text if this shape uses text instead of prompt/content.", + "- `description.md`: editor-facing explanation.", + ] + .join("\n"), + "prompt-groups" => [ + "# Prompt group folder format", + "", + "Edit one folder per existing prompt group inside this `groups/` folder. Creating brand-new groups from new folders is not applied automatically yet.", + "", + "## Metadata", + "- `metadata.json`: valid JSON. Useful fields include `name`, `label`, `enabled`, `parentGroupId`, `sortOrder`.", + "", + "## Text files", + "- `description.md`: what this group controls.", + "- `notes.md`: private organization notes.", + ] + .join("\n"), + "prompt-variables" => [ + "# Prompt variable folder format", + "", + "Edit one folder per existing prompt variable inside this `variables/` folder. Creating brand-new variables from new folders is not applied automatically yet.", + "", + "## Metadata", + "- `metadata.json`: valid JSON. Useful fields include `name`, `key`, `label`, `type`, `options`, `defaultValue`, `groupId`, `sortOrder`.", + "", + "## Text files", + "- `value.md`: default or current value.", + "- `content.md`: content value if this shape uses content.", + "- `text.md`: text value if this shape uses text.", + "- `description.md`: editor-facing explanation.", + ] + .join("\n"), + _ => "# Format guide\n\nUse `metadata.json` for structured fields and `.md` files for long text.".to_string(), + } +} + +fn add_flat_collection( + seed: &mut MariWorkspaceSeed, + allocator: &mut PathAllocator, + entity: &str, + root: &str, + fallback_label: &str, + records: &[Value], + text_fields: &[&str], +) -> AppResult<()> { + let mut index = Vec::new(); + for record in sorted_records(records) { + let Some(id) = record_id(record) else { + continue; + }; + let label = record_label_for_entity(entity, record, fallback_label); + let folder = allocator.child(root, &label, fallback_label); + index.push(format!( + "- [{}]({})", + display_label(&label), + folder.trim_start_matches("/workspace/") + )); + add_record_folder(seed, entity, id, &folder, record, text_fields)?; + } + add_unbound_file( + seed, + format!("{root}/index.md"), + collection_index_title(entity, index), + ); + Ok(()) +} + +fn add_lorebooks_to_workspace( + state: &AppState, + seed: &mut MariWorkspaceSeed, + allocator: &mut PathAllocator, +) -> AppResult<()> { + let lorebooks = list_storage_or_empty(state, "lorebooks")?; + let entries = list_storage_or_empty(state, "lorebook-entries")?; + let mut entries_by_lorebook: BTreeMap> = BTreeMap::new(); + for entry in entries { + if let Some(lorebook_id) = str_field(&entry, "lorebookId") { + entries_by_lorebook + .entry(lorebook_id.to_string()) + .or_default() + .push(entry); + } + } + let mut index = Vec::new(); + for lorebook in sorted_records(&lorebooks) { + let Some(id) = record_id(lorebook) else { + continue; + }; + let label = record_label_for_entity("lorebooks", lorebook, "Untitled Lorebook"); + let folder = allocator.child("/workspace/lorebooks", &label, "Untitled Lorebook"); + index.push(format!( + "- [{}]({})", + display_label(&label), + folder.trim_start_matches("/workspace/") + )); + add_record_folder( + seed, + "lorebooks", + id, + &folder, + lorebook, + &["description", "content", "notes"], + )?; + let entry_root = format!("{folder}/entries"); + add_unbound_file( + seed, + format!("{entry_root}/FORMAT.md"), + format_guide_for_entity("lorebook-entries"), + ); + let mut entry_index = Vec::new(); + for entry in sorted_records( + entries_by_lorebook + .get(id) + .map(Vec::as_slice) + .unwrap_or(&[]), + ) { + let Some(entry_id) = record_id(entry) else { + continue; + }; + let entry_label = lorebook_entry_label(entry); + let entry_folder = allocator.child(&entry_root, &entry_label, "Untitled Entry"); + entry_index.push(format!( + "- [{}]({})", + display_label(&entry_label), + entry_folder.trim_start_matches("/workspace/") + )); + add_record_folder( + seed, + "lorebook-entries", + entry_id, + &entry_folder, + entry, + &["content", "comment", "description", "notes"], + )?; + if let Some(keys) = keys_text(entry) { + add_bound_file( + seed, + format!("{entry_folder}/keys.txt"), + keys, + "lorebook-entries", + entry_id, + "keys", + ); + } + } + add_unbound_file( + seed, + format!("{entry_root}/index.md"), + collection_index_title("entries", entry_index), + ); + } + add_unbound_file( + seed, + "/workspace/lorebooks/index.md", + collection_index_title("lorebooks", index), + ); + Ok(()) +} + +fn add_prompts_to_workspace( + state: &AppState, + seed: &mut MariWorkspaceSeed, + allocator: &mut PathAllocator, +) -> AppResult<()> { + let prompts = list_storage_or_empty(state, "prompts")?; + let sections = list_storage_or_empty(state, "prompt-sections")?; + let groups = list_storage_or_empty(state, "prompt-groups")?; + let variables = list_storage_or_empty(state, "prompt-variables")?; + let mut sections_by_preset = group_by_parent(sections, "presetId"); + let mut groups_by_preset = group_by_parent(groups, "presetId"); + let mut variables_by_preset = group_by_parent(variables, "presetId"); + let mut index = Vec::new(); + for prompt in sorted_records(&prompts) { + let Some(id) = record_id(prompt) else { + continue; + }; + let label = record_label_for_entity("prompts", prompt, "Untitled Prompt"); + let folder = allocator.child("/workspace/prompts", &label, "Untitled Prompt"); + index.push(format!( + "- [{}]({})", + display_label(&label), + folder.trim_start_matches("/workspace/") + )); + add_record_folder( + seed, + "prompts", + id, + &folder, + prompt, + &["description", "prompt", "systemPrompt", "notes"], + )?; + add_nested_prompt_records( + seed, + allocator, + &folder, + "sections", + "prompt-sections", + sections_by_preset.remove(id).unwrap_or_default(), + &["prompt", "content", "text", "description"], + )?; + add_nested_prompt_records( + seed, + allocator, + &folder, + "groups", + "prompt-groups", + groups_by_preset.remove(id).unwrap_or_default(), + &["description", "notes"], + )?; + add_nested_prompt_records( + seed, + allocator, + &folder, + "variables", + "prompt-variables", + variables_by_preset.remove(id).unwrap_or_default(), + &["value", "content", "text", "description"], + )?; + } + add_unbound_file( + seed, + "/workspace/prompts/index.md", + collection_index_title("prompts", index), + ); + Ok(()) +} + +fn add_nested_prompt_records( + seed: &mut MariWorkspaceSeed, + allocator: &mut PathAllocator, + prompt_folder: &str, + folder_name: &str, + entity: &str, + records: Vec, + text_fields: &[&str], +) -> AppResult<()> { + let root = format!("{prompt_folder}/{folder_name}"); + add_unbound_file( + seed, + format!("{root}/FORMAT.md"), + format_guide_for_entity(entity), + ); + let mut index = Vec::new(); + for record in sorted_records(&records) { + let Some(id) = record_id(record) else { + continue; + }; + let label = record_label_for_entity( + entity, + record, + &format!("Untitled {}", singular_title(folder_name)), + ); + let folder = allocator.child( + &root, + &label, + &format!("Untitled {}", singular_title(folder_name)), + ); + index.push(format!( + "- [{}]({})", + display_label(&label), + folder.trim_start_matches("/workspace/") + )); + add_record_folder(seed, entity, id, &folder, record, text_fields)?; + } + add_unbound_file( + seed, + format!("{root}/index.md"), + collection_index_title(folder_name, index), + ); + Ok(()) +} + +fn add_record_folder( + seed: &mut MariWorkspaceSeed, + entity: &str, + id: &str, + folder: &str, + record: &Value, + text_fields: &[&str], +) -> AppResult<()> { + for field in text_fields { + if let Some(text) = + string_field_path(record, field).filter(|value| !value.trim().is_empty()) + { + add_bound_file( + seed, + format!("{folder}/{}.md", field_file_name(field)), + text.to_string(), + entity, + id, + field, + ); + } + } + let metadata = metadata_without_fields(record, text_fields); + let content = serde_json::to_string_pretty(&metadata) + .map_err(|error| AppError::new("mari_workspace_serialize_failed", error.to_string()))?; + add_bound_file( + seed, + format!("{folder}/metadata.json"), + content, + entity, + id, + "metadata", + ); + Ok(()) +} + +fn add_workspace_index(seed: &mut MariWorkspaceSeed, counts: &[(&str, usize)]) { + let mut lines = vec![ + "# Marinara Workspace".to_string(), + String::new(), + "This virtual workspace contains your editable Marinara creative library.".to_string(), + "Internal storage IDs are hidden from paths; Professor Mari should use the folders below." + .to_string(), + "Format requirements live in [FORMAT.md](FORMAT.md) and the nearest folder-level FORMAT.md files." + .to_string(), + String::new(), + ]; + for (name, count) in counts { + lines.push(format!("- [{name}]({name}/index.md): {count} record(s)")); + } + add_unbound_file(seed, "/workspace/index.md", lines.join("\n")); +} + +fn add_unbound_file( + seed: &mut MariWorkspaceSeed, + path: impl Into, + content: impl Into, +) { + seed.files.push(MariWorkspaceFileRecord { + path: path.into(), + content: content.into(), + }); +} + +fn add_bound_file( + seed: &mut MariWorkspaceSeed, + path: String, + content: String, + entity: &str, + id: &str, + field: &str, +) { + let binding = MariWorkspaceBinding { + entity: entity.to_string(), + id: id.to_string(), + field: Some(field.to_string()), + }; + seed.bindings.insert(path.clone(), binding.clone()); + seed.files.push(MariWorkspaceFileRecord { path, content }); +} + +fn list_storage_or_empty(state: &AppState, entity: &str) -> AppResult> { + state.storage.list(entity).map_err(|error| { + AppError::new( + "mari_workspace_load_failed", + format!("Could not load {entity}: {error}"), + ) + }) +} + +fn sorted_records(records: &[Value]) -> Vec<&Value> { + let mut out = records.iter().collect::>(); + out.sort_by(|a, b| sort_key(a).cmp(&sort_key(b))); + out +} + +fn sort_key(record: &Value) -> String { + format!( + "{:012}|{}", + numeric_sort_field(record), + record_label(record, "Untitled").to_ascii_lowercase() + ) +} + +fn numeric_sort_field(record: &Value) -> i64 { + ["sortOrder", "order", "position", "createdAt"] + .iter() + .find_map(|field| record.get(*field).and_then(Value::as_i64)) + .unwrap_or(0) +} + +fn group_by_parent(records: Vec, parent_field: &str) -> BTreeMap> { + let mut grouped = BTreeMap::new(); + for record in records { + if let Some(parent_id) = str_field(&record, parent_field) { + grouped + .entry(parent_id.to_string()) + .or_insert_with(Vec::new) + .push(record); + } + } + grouped +} + +fn record_id(record: &Value) -> Option<&str> { + str_field(record, "id") +} + +fn record_label(record: &Value, fallback: &str) -> String { + record_label_for_entity("", record, fallback) +} + +pub(crate) fn record_label_for_entity(entity: &str, record: &Value, fallback: &str) -> String { + let candidates: &[&str] = match entity { + "characters" => &["data.name"], + "personas" => &["name", "data.name", "title", "comment"], + "lorebooks" => &["name", "title"], + "lorebook-entries" => &["comment", "name"], + "prompts" => &["name", "title"], + "prompt-sections" => &["name", "title", "role", "type"], + "prompt-groups" => &["name", "label", "title"], + "prompt-variables" => &["name", "key", "label", "title"], + _ => &["data.name", "name", "title", "label", "comment", "key"], + }; + first_non_empty( + &candidates + .iter() + .map(|field| string_field_path(record, field)) + .collect::>(), + ) + .unwrap_or(fallback) + .to_string() +} + +fn lorebook_entry_label(record: &Value) -> String { + first_non_empty(&[ + str_field(record, "comment"), + str_field(record, "name"), + first_string_array_item(record.get("keys")).as_deref(), + str_field(record, "content").map(first_line), + ]) + .unwrap_or("Untitled Entry") + .to_string() +} + +fn first_non_empty<'a>(values: &[Option<&'a str>]) -> Option<&'a str> { + values + .iter() + .flatten() + .map(|value| value.trim()) + .find(|value| !value.is_empty()) +} + +fn str_field<'a>(value: &'a Value, field: &str) -> Option<&'a str> { + value.get(field).and_then(Value::as_str) +} + +fn string_field_path<'a>(value: &'a Value, field_path: &str) -> Option<&'a str> { + let mut current = value; + for field in field_path.split('.') { + current = current.get(field)?; + } + current.as_str() +} + +pub(crate) fn display_label(label: &str) -> String { + const LIMIT: usize = 120; + let clean = label.replace(['\n', '\r'], " "); + if clean.chars().count() > LIMIT { + format!("{}…", clean.chars().take(LIMIT).collect::()) + } else { + clean + } +} + +fn first_line(value: &str) -> &str { + value.lines().next().unwrap_or(value).trim() +} + +fn first_string_array_item(value: Option<&Value>) -> Option { + string_array_items(value).into_iter().next() +} + +fn string_array_items(value: Option<&Value>) -> Vec { + match value { + Some(Value::Array(items)) => items + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect(), + _ => Vec::new(), + } +} + +fn keys_text(record: &Value) -> Option { + let keys = string_array_items(record.get("keys").or_else(|| record.get("keywords"))); + (!keys.is_empty()).then(|| keys.join("\n")) +} + +fn metadata_without_fields(record: &Value, text_fields: &[&str]) -> Value { + let mut metadata = record.clone(); + remove_field_path(&mut metadata, "id"); + remove_field_path(&mut metadata, "createdAt"); + remove_field_path(&mut metadata, "updatedAt"); + for field in text_fields { + remove_field_path(&mut metadata, field); + } + sanitize_metadata_value(&mut metadata); + metadata +} + +fn remove_field_path(value: &mut Value, field_path: &str) { + let mut current = value; + let mut parts = field_path.split('.').peekable(); + while let Some(field) = parts.next() { + let Some(object) = current.as_object_mut() else { + return; + }; + if parts.peek().is_none() { + object.remove(field); + return; + } + let Some(next) = object.get_mut(field) else { + return; + }; + current = next; + } +} + +fn sanitize_metadata_value(value: &mut Value) { + match value { + Value::Object(object) => { + let keys = object.keys().cloned().collect::>(); + for key in keys { + if should_remove_metadata_key(&key) { + object.remove(&key); + } else if let Some(child) = object.get_mut(&key) { + sanitize_metadata_value(child); + } + } + } + Value::Array(items) => { + for item in items.iter_mut().take(64) { + sanitize_metadata_value(item); + } + if items.len() > 64 { + items.truncate(64); + items.push(json!("[truncated metadata array]")); + } + } + Value::String(text) => { + if looks_like_base64_blob(text) { + *text = "[omitted binary/base64 data]".to_string(); + } else if text.chars().count() > MARI_METADATA_STRING_LIMIT { + *text = format!( + "{}\n[truncated metadata string]", + text.chars() + .take(MARI_METADATA_STRING_LIMIT) + .collect::() + ); + } + } + _ => {} + } +} + +fn should_remove_metadata_key(key: &str) -> bool { + let lower = key.to_ascii_lowercase(); + lower.contains("avatar") + || lower.contains("image") + || lower.contains("base64") + || lower.contains("datauri") + || lower == "data_url" + || lower == "dataurl" +} + +fn looks_like_base64_blob(value: &str) -> bool { + let trimmed = value.trim(); + trimmed.starts_with("data:image/") + || (trimmed.len() > 8_000 + && trimmed.chars().all(|ch| { + ch.is_ascii_alphanumeric() || matches!(ch, '+' | '/' | '=' | '\n' | '\r') + })) +} + +pub(crate) fn field_file_name(field: &str) -> String { + let mut out = String::new(); + for (index, ch) in field.chars().enumerate() { + if ch.is_ascii_uppercase() && index > 0 { + out.push('_'); + } + out.push(ch.to_ascii_lowercase()); + } + sanitize_path_segment(&out) +} + +fn sanitize_path_segment(value: &str) -> String { + let mut out = value + .trim() + .chars() + .map(|ch| match ch { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + ch if ch.is_control() => '_', + ch => ch, + }) + .collect::(); + while out.contains(" ") { + out = out.replace(" ", " "); + } + out = out.trim_matches(['.', ' ']).to_string(); + if out.is_empty() { + out = "Untitled".to_string(); + } + if out.chars().count() > 96 { + out = out.chars().take(96).collect(); + } + out +} + +fn collection_index_title(name: &str, entries: Vec) -> String { + let mut lines = vec![ + format!("# {}", title_case(name)), + String::new(), + "Read [FORMAT.md](FORMAT.md) before changing records in this folder; it also notes what creation is supported." + .to_string(), + String::new(), + ]; + if entries.is_empty() { + lines.push("No records found.".to_string()); + } else { + lines.extend(entries); + } + lines.join("\n") +} + +pub(crate) fn singular_title(name: &str) -> String { + title_case(name.trim_end_matches('s')) +} + +fn title_case(value: &str) -> String { + value + .split(['-', '_']) + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()), + None => String::new(), + } + }) + .collect::>() + .join(" ") +} diff --git a/src-tauri/src/commands/storage/profile/legacy.rs b/src-tauri/src/commands/storage/profile/legacy.rs index 0d1043819..fb9650602 100644 --- a/src-tauri/src/commands/storage/profile/legacy.rs +++ b/src-tauri/src/commands/storage/profile/legacy.rs @@ -207,10 +207,7 @@ fn add_legacy_lorebook_links(rows: &mut [Value], tables: &Map) { // editor crashes on `formTags.map is not a function`, and the junction // links computed above would be discarded by `or_insert_with` whenever // the row carried a text-encoded `"[]"` placeholder. - normalize_legacy_text_array_fields( - row, - &["tags", "characterIds", "personaIds"], - ); + normalize_legacy_text_array_fields(row, &["tags", "characterIds", "personaIds"]); // Pre-refactor also stored bool columns as TEXT (`"false"` / `"true"`). // Without coercion, the frontend reads `lorebook.isGlobal === "false"` // as truthy and renders every scoped lorebook as global in the editor. diff --git a/src-tauri/src/commands/storage/shared.rs b/src-tauri/src/commands/storage/shared.rs index 61bd98a3f..4490c3e92 100644 --- a/src-tauri/src/commands/storage/shared.rs +++ b/src-tauri/src/commands/storage/shared.rs @@ -132,7 +132,9 @@ pub(crate) fn swipe_index_value(message: &Value) -> i64 { pub(crate) fn normalize_character_data_for_storage(data: &Value) -> AppResult { match data { Value::Object(_) => Ok(data.clone()), - _ => Err(AppError::invalid_input("Character data must be a JSON object")), + _ => Err(AppError::invalid_input( + "Character data must be a JSON object", + )), } } @@ -142,15 +144,30 @@ pub(crate) fn normalize_update_patch(collection: &str, patch: Value) -> AppResul Ok(Value::Object(object)) } -pub(crate) fn normalize_typed_json_fields(collection: &str, object: &mut Map) -> AppResult<()> { +pub(crate) fn normalize_typed_json_fields( + collection: &str, + object: &mut Map, +) -> AppResult<()> { match collection { "characters" => { if let Some(data) = object.get("data") { - object.insert("data".to_string(), normalize_character_data_for_storage(data)?); + object.insert( + "data".to_string(), + normalize_character_data_for_storage(data)?, + ); } } "chats" => { - normalize_json_array_fields(object, &["characterIds", "activeLorebookIds", "activeAgentIds", "activeToolIds", "memories"])?; + normalize_json_array_fields( + object, + &[ + "characterIds", + "activeLorebookIds", + "activeAgentIds", + "activeToolIds", + "memories", + ], + )?; normalize_nullable_json_object_fields(object, &["metadata", "gameState"])?; } "messages" => { @@ -170,14 +187,20 @@ pub(crate) fn normalize_typed_json_fields(collection: &str, object: &mut Map { - normalize_nullable_json_object_fields(object, &["defaultParameters", "capabilities", "providerMetadata"])?; + normalize_nullable_json_object_fields( + object, + &["defaultParameters", "capabilities", "providerMetadata"], + )?; } "custom-tools" => { normalize_json_object_fields(object, &["parametersSchema"])?; } "game-state-snapshots" => { normalize_json_array_fields(object, &["presentCharacters", "recentEvents"])?; - normalize_nullable_json_object_fields(object, &["playerStats", "personaStats", "metadata"])?; + normalize_nullable_json_object_fields( + object, + &["playerStats", "personaStats", "metadata"], + )?; } "game-checkpoints" => { normalize_nullable_json_object_fields(object, &["snapshot", "gameState", "metadata"])?; @@ -187,7 +210,10 @@ pub(crate) fn normalize_typed_json_fields(collection: &str, object: &mut Map { normalize_json_array_fields(object, &["sectionOrder", "groupOrder", "variableOrder"])?; - normalize_json_object_fields(object, &["variableValues", "parameters", "defaultChoices"])?; + normalize_json_object_fields( + object, + &["variableValues", "parameters", "defaultChoices"], + )?; normalize_json_array_fields(object, &["variableGroups"])?; } "prompt-sections" => { @@ -197,7 +223,10 @@ pub(crate) fn normalize_typed_json_fields(collection: &str, object: &mut Map { - normalize_json_array_fields(object, &["tags", "altDescriptions", "savedStatusOptions"])?; + normalize_json_array_fields( + object, + &["tags", "altDescriptions", "savedStatusOptions"], + )?; normalize_nullable_json_object_fields(object, &["avatarCrop", "personaStats"])?; } "agents" => { @@ -213,7 +242,9 @@ pub(crate) fn normalize_typed_json_fields(collection: &str, object: &mut Map, fields: &[&str]) -> AppResult<()> { for field in fields { - let Some(value) = object.get(*field) else { continue }; + let Some(value) = object.get(*field) else { + continue; + }; let normalized = normalize_json_field(value, Value::is_array, "array", field)?; object.insert((*field).to_string(), normalized); } @@ -222,16 +253,23 @@ fn normalize_json_array_fields(object: &mut Map, fields: &[&str]) fn normalize_json_object_fields(object: &mut Map, fields: &[&str]) -> AppResult<()> { for field in fields { - let Some(value) = object.get(*field) else { continue }; + let Some(value) = object.get(*field) else { + continue; + }; let normalized = normalize_json_field(value, Value::is_object, "object", field)?; object.insert((*field).to_string(), normalized); } Ok(()) } -fn normalize_nullable_json_object_fields(object: &mut Map, fields: &[&str]) -> AppResult<()> { +fn normalize_nullable_json_object_fields( + object: &mut Map, + fields: &[&str], +) -> AppResult<()> { for field in fields { - let Some(value) = object.get(*field) else { continue }; + let Some(value) = object.get(*field) else { + continue; + }; if value.is_null() { continue; } @@ -259,13 +297,17 @@ fn normalize_json_field( }); } let parsed: Value = serde_json::from_str(raw).map_err(|_| { - AppError::invalid_input(format!("{field} must be a JSON {expected}, not a JSON string")) + AppError::invalid_input(format!( + "{field} must be a JSON {expected}, not a JSON string" + )) })?; if predicate(&parsed) { return Ok(parsed); } } - Err(AppError::invalid_input(format!("{field} must be a JSON {expected}"))) + Err(AppError::invalid_input(format!( + "{field} must be a JSON {expected}" + ))) } pub(crate) fn with_entity_defaults(collection: &str, body: Value) -> AppResult { @@ -400,7 +442,9 @@ pub(crate) fn with_entity_defaults(collection: &str, body: Value) -> AppResult { normalize_typed_json_fields(collection, &mut object)?; @@ -465,10 +509,14 @@ mod tests { assert_eq!(patch["data"]["tags"], json!(["guide"])); } - #[test] fn character_update_patch_rejects_invalid_data_shape() { - for invalid in [json!(true), json!("{\"name\":\"Professor Mari\"}"), json!([]), Value::Null] { + for invalid in [ + json!(true), + json!("{\"name\":\"Professor Mari\"}"), + json!([]), + Value::Null, + ] { let error = normalize_update_patch("characters", json!({ "data": invalid })) .expect_err("invalid character data should fail"); assert_eq!(error.code, "invalid_input"); @@ -552,7 +600,11 @@ mod tests { for alias in ["1", "yes", "on", "TRUE", " Yes "] { let mut record = json!({ "flag": alias }); normalize_legacy_text_bool_fields(&mut record, &["flag"]); - assert_eq!(record["flag"], json!(true), "alias {alias:?} should be true"); + assert_eq!( + record["flag"], + json!(true), + "alias {alias:?} should be true" + ); } } @@ -561,7 +613,11 @@ mod tests { for alias in ["0", "no", "off", "FALSE", " No "] { let mut record = json!({ "flag": alias }); normalize_legacy_text_bool_fields(&mut record, &["flag"]); - assert_eq!(record["flag"], json!(false), "alias {alias:?} should be false"); + assert_eq!( + record["flag"], + json!(false), + "alias {alias:?} should be false" + ); } } diff --git a/src-tauri/src/commands/storage/sprites.rs b/src-tauri/src/commands/storage/sprites.rs index 3d8c7f1ce..80b86d943 100644 --- a/src-tauri/src/commands/storage/sprites.rs +++ b/src-tauri/src/commands/storage/sprites.rs @@ -303,7 +303,11 @@ pub(crate) fn upload_sprite(state: &AppState, character_id: &str, body: Value) - sprite_info_from_path(&path) } -pub(crate) fn upload_sprites(state: &AppState, character_id: &str, body: Value) -> AppResult { +pub(crate) fn upload_sprites( + state: &AppState, + character_id: &str, + body: Value, +) -> AppResult { validate_safe_segment(character_id, "character ID")?; let uploads = body .get("sprites") @@ -2028,10 +2032,7 @@ mod sprite_upload_tests { assert_eq!(result.get("imported").and_then(Value::as_u64), Some(1)); assert_eq!( - result - .get("failed") - .and_then(Value::as_array) - .map(Vec::len), + result.get("failed").and_then(Value::as_array).map(Vec::len), Some(2) ); let sprites = result diff --git a/src-tauri/src/http_dispatch.rs b/src-tauri/src/http_dispatch.rs index d75fb360a..ac1554d98 100644 --- a/src-tauri/src/http_dispatch.rs +++ b/src-tauri/src/http_dispatch.rs @@ -1,6 +1,8 @@ use crate::builtins::is_protected_record; use crate::state::AppState; -use crate::storage_commands::{avatars, chats, generation, images, imports, llm, lorebook_images, shared}; +use crate::storage_commands::{ + avatars, chats, generation, images, imports, llm, lorebook_images, shared, +}; use marinara_core::{AppError, AppResult}; use serde::Deserialize; use serde_json::{json, Map, Value}; @@ -61,7 +63,9 @@ pub async fn dispatch(state: &AppState, request: InvokeRequest) -> AppResult import_call(state, &args, &["marinara"], "envelope"), "import_marinara_file" => import_call(state, &args, &["marinara-file"], "body"), "import_st_character" => import_call(state, &args, &["st-character"], "body"), - "import_st_character_batch" => import_call(state, &args, &["st-character", "batch"], "body"), + "import_st_character_batch" => { + import_call(state, &args, &["st-character", "batch"], "body") + } "import_st_character_inspect" => { import_call(state, &args, &["st-character", "inspect"], "body") } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 50c02d1c0..58d72ed1a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -199,6 +199,8 @@ pub fn run() { storage_commands::media_commands::llm_stream_cancel, storage_commands::media_commands::llm_list_models, storage_commands::mari_commands::professor_mari_prompt, + storage_commands::mari_commands::professor_mari_apply_staged_changes, + storage_commands::mari_commands::professor_mari_resolve_approval, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 458ee4019..c206eeee6 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -6,7 +6,7 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Manager}; -use tokio::sync::watch; +use tokio::sync::{oneshot, watch}; use crate::seed_defaults::seed_bundled_defaults; use crate::storage_commands::shared::normalize_typed_json_fields; @@ -19,6 +19,7 @@ pub struct AppState { pub data_dir: PathBuf, pub resource_dir: Option, llm_stream_cancellations: Arc>, + mari_approvals: Arc>>>, } #[derive(Default)] @@ -65,6 +66,7 @@ impl AppState { data_dir, resource_dir, llm_stream_cancellations: Arc::new(Mutex::new(LlmStreamCancellations::default())), + mari_approvals: Arc::new(Mutex::new(HashMap::new())), }) } @@ -124,6 +126,50 @@ impl AppState { } } + pub fn register_mari_approval(&self, approval_id: &str) -> AppResult> { + let mut approvals = self.mari_approvals.lock().map_err(|_| { + AppError::new( + "mari_approval_error", + "Professor Mari approval registry is unavailable", + ) + })?; + if approvals.contains_key(approval_id) { + return Err(AppError::invalid_input(format!( + "Professor Mari approval {approval_id} is already pending" + ))); + } + let (tx, rx) = oneshot::channel(); + approvals.insert(approval_id.to_string(), tx); + Ok(rx) + } + + pub fn resolve_mari_approval(&self, approval_id: &str, approved: bool) -> AppResult { + let mut approvals = self.mari_approvals.lock().map_err(|_| { + AppError::new( + "mari_approval_error", + "Professor Mari approval registry is unavailable", + ) + })?; + let Some(sender) = approvals.remove(approval_id) else { + return Err(AppError::invalid_input(format!( + "Professor Mari approval {approval_id} is not pending" + ))); + }; + sender.send(approved).map_err(|_| { + AppError::new( + "mari_approval_error", + "Professor Mari approval listener is no longer available", + ) + })?; + Ok(true) + } + + pub fn cancel_mari_approval(&self, approval_id: &str) { + if let Ok(mut approvals) = self.mari_approvals.lock() { + approvals.remove(approval_id); + } + } + pub fn cancel_llm_stream(&self, stream_id: &str) -> AppResult { let cancellations = self.llm_stream_cancellations.lock().map_err(|_| { AppError::new( diff --git a/src/engine/mari/mari-entry.ts b/src/engine/mari/mari-entry.ts index e17cbf367..3bff41234 100644 --- a/src/engine/mari/mari-entry.ts +++ b/src/engine/mari/mari-entry.ts @@ -1,8 +1,25 @@ +export type MariTraceEvent = { + type: string; + approvalId?: string; + label?: string; + summary?: string; + tool?: string; + status?: "success" | "error" | string; + startedAt?: string; + finishedAt?: string; + content?: string; + arguments?: unknown; + result?: unknown; + error?: string; + toolCalls?: unknown[]; +}; + export type MariMessage = { id: string; role: "user" | "assistant"; content: string; createdAt: string; + trace?: MariTraceEvent[]; }; export type MariAttachment = { @@ -47,18 +64,27 @@ export const MARI_ACTION_ENTITIES = [ export type MariActionEntity = (typeof MARI_ACTION_ENTITIES)[number]; -export type MariEntryAction = - | { - type: "none"; - capability: "read_only"; - reason: string; - } +export type MariFileChange = { + op: "create" | "modify" | "delete" | string; + path: string; + before?: string; + after?: string; + reason?: string; + binding?: { + entity?: MariActionEntity | string; + id?: string; + field?: string | null; + }; +}; + +export type MariStorageAction = | { type: "create_record"; entity: MariActionEntity; draft: Record; label?: string; rationale?: string; + paths?: string[]; } | { type: "edit_record"; @@ -67,24 +93,80 @@ export type MariEntryAction = patch: Record; label?: string; rationale?: string; + paths?: string[]; }; -const MARI_READ_ONLY_REASON = "Professor Mari v1 can inspect the creative library but cannot create or edit records."; +export type MariEntryAction = + | { + type: "none"; + capability: "bashkit_virtual_workspace" | "read_only"; + reason: string; + changes?: MariFileChange[]; + workspaceManifest?: unknown; + approvalRequired?: false; + } + | { + type: "staged_file_changes"; + capability: "bashkit_virtual_workspace"; + changes: MariFileChange[]; + storageActions: MariStorageAction[]; + unmappedChanges: MariFileChange[]; + workspaceManifest?: unknown; + approvalRequired: boolean; + } + | MariStorageAction; + +const MARI_NO_CHANGES_REASON = "Professor Mari did not stage any storage changes."; -export const MARI_READ_ONLY_ACTION: MariEntryAction = { +export const MARI_NO_CHANGES_ACTION: MariEntryAction = { type: "none", - capability: "read_only", - reason: MARI_READ_ONLY_REASON, + capability: "bashkit_virtual_workspace", + reason: MARI_NO_CHANGES_REASON, + approvalRequired: false, +}; + +export type MariApprovalRequest = { + id: string; + tool?: string; + label?: string; + requestedAt?: string; + action: Extract; + result?: unknown; +}; + +export type MariApprovalOutcome = { + id: string; + status: "approved" | "rejected" | string; + approved: boolean; + changeCount: number; + storageActionCount: number; + unmappedChangeCount: number; + summary?: string; + applied?: MariApplyStagedChangesResult | null; + error?: string | null; +}; + +export type MariApplyStagedChangesResult = { + applied: number; + appliedAt?: string; + results: Array<{ + type: "create_record" | "edit_record" | string; + entity?: MariActionEntity | string; + id?: string; + record?: unknown; + }>; }; export type MariEntryResponse = { content: string; createdAt: string; action: MariEntryAction; + trace: MariTraceEvent[]; }; -export type MariGatewayResponse = Omit & { +export type MariGatewayResponse = Omit & { action?: unknown; + trace?: unknown; }; export type MariGateway = { @@ -103,18 +185,67 @@ export async function runProfessorMariEntry(input: MariEntryRequest, gateway: Ma return { ...response, action: normalizeMariEntryAction(response.action), + trace: normalizeMariTrace(response.trace), }; } +export function isMariStagedAction(action: MariEntryAction | null | undefined): action is Extract { + return action?.type === "staged_file_changes"; +} + +function normalizeMariTrace(value: unknown): MariTraceEvent[] { + if (!Array.isArray(value)) return []; + return value.filter(isRecord).map((event) => ({ + type: typeof event.type === "string" ? event.type : "event", + ...(typeof event.label === "string" ? { label: event.label } : {}), + ...(typeof event.summary === "string" ? { summary: event.summary } : {}), + ...(typeof event.tool === "string" ? { tool: event.tool } : {}), + ...(typeof event.status === "string" ? { status: event.status } : {}), + ...(typeof event.startedAt === "string" ? { startedAt: event.startedAt } : {}), + ...(typeof event.finishedAt === "string" ? { finishedAt: event.finishedAt } : {}), + ...(typeof event.approvalId === "string" ? { approvalId: event.approvalId } : {}), + ...(typeof event.content === "string" ? { content: event.content } : {}), + ...("arguments" in event ? { arguments: event.arguments } : {}), + ...("result" in event ? { result: event.result } : {}), + ...(typeof event.error === "string" ? { error: event.error } : {}), + ...(Array.isArray(event.toolCalls) ? { toolCalls: event.toolCalls } : {}), + })); +} + function normalizeMariEntryAction(value: unknown): MariEntryAction { - if (!isRecord(value)) return MARI_READ_ONLY_ACTION; - if (value.type === "none" && value.capability === "read_only") { + if (!isRecord(value)) return MARI_NO_CHANGES_ACTION; + if (value.type === "none") { return { type: "none", - capability: "read_only", - reason: typeof value.reason === "string" && value.reason.trim() ? value.reason : MARI_READ_ONLY_REASON, + capability: value.capability === "read_only" ? "read_only" : "bashkit_virtual_workspace", + reason: typeof value.reason === "string" && value.reason.trim() ? value.reason : MARI_NO_CHANGES_REASON, + changes: normalizeMariFileChanges(value.changes), + workspaceManifest: value.workspaceManifest, + approvalRequired: false, }; } + if (value.type === "staged_file_changes") { + return { + type: "staged_file_changes", + capability: "bashkit_virtual_workspace", + changes: normalizeMariFileChanges(value.changes), + storageActions: normalizeMariStorageActions(value.storageActions), + unmappedChanges: normalizeMariFileChanges(value.unmappedChanges), + workspaceManifest: value.workspaceManifest, + approvalRequired: value.approvalRequired === true, + }; + } + const storageAction = normalizeMariStorageAction(value); + return storageAction ?? MARI_NO_CHANGES_ACTION; +} + +function normalizeMariStorageActions(value: unknown): MariStorageAction[] { + if (!Array.isArray(value)) return []; + return value.map(normalizeMariStorageAction).filter((action): action is MariStorageAction => !!action); +} + +function normalizeMariStorageAction(value: unknown): MariStorageAction | null { + if (!isRecord(value)) return null; if (value.type === "create_record" && isMariActionEntity(value.entity) && isRecord(value.draft)) { return { type: "create_record", @@ -122,15 +253,10 @@ function normalizeMariEntryAction(value: unknown): MariEntryAction { draft: value.draft, ...(typeof value.label === "string" ? { label: value.label } : {}), ...(typeof value.rationale === "string" ? { rationale: value.rationale } : {}), + ...(Array.isArray(value.paths) ? { paths: value.paths.filter((path): path is string => typeof path === "string") } : {}), }; } - if ( - value.type === "edit_record" && - isMariActionEntity(value.entity) && - typeof value.id === "string" && - value.id.trim() && - isRecord(value.patch) - ) { + if (value.type === "edit_record" && isMariActionEntity(value.entity) && typeof value.id === "string" && value.id.trim() && isRecord(value.patch)) { return { type: "edit_record", entity: value.entity, @@ -138,9 +264,68 @@ function normalizeMariEntryAction(value: unknown): MariEntryAction { patch: value.patch, ...(typeof value.label === "string" ? { label: value.label } : {}), ...(typeof value.rationale === "string" ? { rationale: value.rationale } : {}), + ...(Array.isArray(value.paths) ? { paths: value.paths.filter((path): path is string => typeof path === "string") } : {}), }; } - return MARI_READ_ONLY_ACTION; + return null; +} + +function normalizeMariFileChanges(value: unknown): MariFileChange[] { + if (!Array.isArray(value)) return []; + return value.filter(isRecord).flatMap((change) => { + if (typeof change.path !== "string" || !change.path.trim()) return []; + return [ + { + op: typeof change.op === "string" ? change.op : "modify", + path: change.path, + ...(typeof change.before === "string" ? { before: change.before } : {}), + ...(typeof change.after === "string" ? { after: change.after } : {}), + ...(typeof change.reason === "string" ? { reason: change.reason } : {}), + ...(isRecord(change.binding) + ? { + binding: { + ...(typeof change.binding.entity === "string" ? { entity: change.binding.entity } : {}), + ...(typeof change.binding.id === "string" ? { id: change.binding.id } : {}), + ...(typeof change.binding.field === "string" || change.binding.field === null ? { field: change.binding.field } : {}), + }, + } + : {}), + }, + ]; + }); +} + +export function normalizeMariApprovalRequest(value: unknown): MariApprovalRequest | null { + if (!isRecord(value) || typeof value.id !== "string" || !value.id.trim()) return null; + const action = normalizeMariEntryAction(value.action); + if (!isMariStagedAction(action)) return null; + return { + id: value.id, + ...(typeof value.tool === "string" ? { tool: value.tool } : {}), + ...(typeof value.label === "string" ? { label: value.label } : {}), + ...(typeof value.requestedAt === "string" ? { requestedAt: value.requestedAt } : {}), + action, + ...("result" in value ? { result: value.result } : {}), + }; +} + +export function normalizeMariApprovalOutcome(value: unknown): MariApprovalOutcome | null { + if (!isRecord(value) || typeof value.id !== "string" || !value.id.trim()) return null; + return { + id: value.id, + status: typeof value.status === "string" ? value.status : value.approved === false ? "rejected" : "approved", + approved: value.approved !== false, + changeCount: typeof value.changeCount === "number" ? value.changeCount : 0, + storageActionCount: typeof value.storageActionCount === "number" ? value.storageActionCount : 0, + unmappedChangeCount: typeof value.unmappedChangeCount === "number" ? value.unmappedChangeCount : 0, + ...(typeof value.summary === "string" ? { summary: value.summary } : {}), + ...(isMariApplyStagedChangesResult(value.applied) ? { applied: value.applied } : {}), + ...(typeof value.error === "string" ? { error: value.error } : {}), + }; +} + +function isMariApplyStagedChangesResult(value: unknown): value is MariApplyStagedChangesResult { + return isRecord(value) && typeof value.applied === "number" && Array.isArray(value.results); } function isRecord(value: unknown): value is Record { diff --git a/src/features/shell/mari/components/ProfessorMariSurface.tsx b/src/features/shell/mari/components/ProfessorMariSurface.tsx index a9ea67006..5bb1ee268 100644 --- a/src/features/shell/mari/components/ProfessorMariSurface.tsx +++ b/src/features/shell/mari/components/ProfessorMariSurface.tsx @@ -1,18 +1,43 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { Check, ChevronUp, CircleUser, FileText, Link, Plus, Send, X } from "lucide-react"; -import { runProfessorMariEntry, type MariMessage } from "../../../../engine/mari/mari-entry"; +import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { + AlertTriangle, + Check, + ChevronUp, + CircleUser, + FileDiff, + FileText, + Link, + Paperclip, + Send, + Sparkles, + Terminal, + Wrench, + X, +} from "lucide-react"; +import { + isMariStagedAction, + normalizeMariApprovalOutcome, + normalizeMariApprovalRequest, + runProfessorMariEntry, + type MariApprovalRequest, + type MariEntryAction, + type MariFileChange, + type MariMessage, + type MariStorageAction, + type MariTraceEvent, +} from "../../../../engine/mari/mari-entry"; import { mariApi } from "../../../../shared/api/mari-api"; import { useConnections } from "../../../catalog/connections/index"; import { usePersonas } from "../../../catalog/characters/index"; -import { ConversationMessage } from "../../../modes/conversation/index"; -import type { CharacterMap, PersonaInfo } from "../../../modes/shared/chat-ui/types"; -import type { Message } from "../../../../engine/contracts/types/chat"; import { filterLanguageGenerationConnections } from "../../../../shared/lib/connection-filters"; import { cn, getAvatarCropStyle, parseAvatarCropJson } from "../../../../shared/lib/utils"; import { useUIStore } from "../../../../shared/stores/ui.store"; const MARI_AVATAR_URL = "/sprites/mari/Mari_profile.png"; -const MARI_CHARACTER_ID = "__professor_mari_shell__"; +const MARI_THINKING_URL = "/sprites/mari/Mari_thinking.png"; +const MARI_WAVE_URL = "/sprites/mari/Mari_wave.png"; +const MARI_WORKING_URL = "/sprites/mari/Mari_point_down_left.png"; type MariAttachment = { id: string; @@ -41,6 +66,8 @@ type MariPersona = { appearance?: string | null; }; +type MariOptionPanel = "connections" | "personas"; + function newId(prefix: string) { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } @@ -61,26 +88,24 @@ function getDayKey(value: string) { return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; } -function toConversationMessage(message: MariMessage): Message { - return { - id: message.id, - chatId: "professor-mari", - role: message.role, - characterId: message.role === "assistant" ? MARI_CHARACTER_ID : null, - content: message.content, - activeSwipeIndex: 0, - swipeCount: 1, - createdAt: message.createdAt, - extra: { - displayText: null, - isGenerated: message.role === "assistant", - tokenCount: null, - generationInfo: null, - }, - }; +function formatTime(value: string) { + const date = new Date(value); + return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); +} + +function formatErrorDetails(error: unknown) { + if (!error || typeof error !== "object") return null; + const record = error as Record; + const details = "details" in record ? record.details : record; + try { + return JSON.stringify(details, null, 2); + } catch { + return String(details); + } } export function ProfessorMariSurface() { + const queryClient = useQueryClient(); const { data: rawConnections } = useConnections(); const { data: rawPersonas } = usePersonas(); const convoGradient = useUIStore((s) => s.convoGradient); @@ -90,15 +115,24 @@ export function ProfessorMariSurface() { const [attachments, setAttachments] = useState([]); const [selectedConnectionId, setSelectedConnectionId] = useState(null); const [selectedPersonaId, setSelectedPersonaId] = useState(null); - const [connectionMenuOpen, setConnectionMenuOpen] = useState(false); - const [personaMenuOpen, setPersonaMenuOpen] = useState(false); - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [optionPanel, setOptionPanel] = useState(null); const [sending, setSending] = useState(false); const [sendError, setSendError] = useState(null); + const [sendErrorDetails, setSendErrorDetails] = useState(null); + const [liveTrace, setLiveTrace] = useState([]); + const [pendingApproval, setPendingApproval] = useState(null); + const [resolvingApproval, setResolvingApproval] = useState<"approve" | "reject" | null>(null); + const [approvalError, setApprovalError] = useState(null); + const [pendingAction, setPendingAction] = useState(null); + const [applyingAction, setApplyingAction] = useState(false); + const [actionError, setActionError] = useState(null); const fileInputRef = useRef(null); const inputRef = useRef(null); const messagesEndRef = useRef(null); - const canSend = (draft.trim().length > 0 || attachments.length > 0) && !sending; + const surfaceRef = useRef(null); + const spriteMeasureRef = useRef(null); + const [spriteSafeInset, setSpriteSafeInset] = useState(0); + const canSend = (draft.trim().length > 0 || attachments.length > 0) && !sending && !pendingApproval && !resolvingApproval && !applyingAction; const connections = useMemo( () => filterLanguageGenerationConnections((rawConnections ?? []) as MariConnection[]).sort((a, b) => @@ -112,49 +146,74 @@ export function ProfessorMariSurface() { ); const selectedConnection = connections.find((connection) => connection.id === selectedConnectionId) ?? null; const selectedPersona = personas.find((persona) => persona.id === selectedPersonaId) ?? null; + const hasToolActivity = liveTrace.some((event) => event.type === "tool_result" || !!event.tool || (Array.isArray(event.toolCalls) && event.toolCalls.length > 0)); + const mariStage = sendError + ? { src: MARI_THINKING_URL, mood: "thinking" as const } + : pendingApproval + ? { src: MARI_WORKING_URL, mood: "working" as const } + : sending + ? hasToolActivity + ? { src: MARI_WORKING_URL, mood: "working" as const } + : { src: MARI_THINKING_URL, mood: "thinking" as const } + : { src: MARI_WAVE_URL, mood: "idle" as const }; const gradientStyle = useMemo(() => { const gradient = convoGradient[theme]; const isDefaultDark = convoGradient.dark.from === "#0a0a0e" && convoGradient.dark.to === "#1c2133"; const isDefaultLight = convoGradient.light.from === "#f2eff7" && convoGradient.light.to === "#eae6f0"; if ((theme === "dark" && isDefaultDark) || (theme === "light" && isDefaultLight)) { - return { background: "var(--secondary)" }; + return { + background: + "radial-gradient(circle at 20% 0%, color-mix(in oklab, var(--primary) 12%, transparent), transparent 22rem), var(--secondary)", + }; } - return { background: `linear-gradient(135deg, ${gradient.from}, ${gradient.to})` }; + return { + background: `radial-gradient(circle at 20% 0%, color-mix(in oklab, var(--primary) 14%, transparent), transparent 22rem), linear-gradient(135deg, ${gradient.from}, ${gradient.to})`, + }; }, [convoGradient, theme]); - const characterMap: CharacterMap = useMemo( - () => - new Map([ - [ - MARI_CHARACTER_ID, - { - name: "Professor Mari", - avatarUrl: MARI_AVATAR_URL, - conversationStatus: "online", - }, - ], - ]), - [], - ); - const personaInfo: PersonaInfo | undefined = useMemo(() => { - if (!selectedPersona) return undefined; + const surfaceStyle = useMemo(() => { + const bubbleOverlap = Math.min(spriteSafeInset * 0.28, 48); return { - name: selectedPersona.name, - description: selectedPersona.description ?? undefined, - avatarUrl: selectedPersona.avatarPath ?? undefined, - avatarCrop: parseAvatarCropJson(selectedPersona.avatarCrop), + ...gradientStyle, + "--mari-sprite-safe": `${spriteSafeInset}px`, + "--mari-chat-gutter": `${Math.max(0, spriteSafeInset - bubbleOverlap)}px`, + "--mari-bubble-overlap": `${bubbleOverlap}px`, + } as CSSProperties; + }, [gradientStyle, spriteSafeInset]); + + useEffect(() => { + const updateSpriteSafeInset = () => { + const surfaceWidth = surfaceRef.current?.getBoundingClientRect().width ?? window.innerWidth; + const spriteWidth = spriteMeasureRef.current?.getBoundingClientRect().width ?? 0; + const roomFactor = Math.max(0, Math.min(1, (surfaceWidth - 520) / 320)); + const visualOverlap = Math.min(spriteWidth * 0.22, 56); + const nextInset = Math.max(0, Math.round((spriteWidth - visualOverlap) * roomFactor)); + setSpriteSafeInset((current) => (Math.abs(current - nextInset) > 1 ? nextInset : current)); }; - }, [selectedPersona]); - const conversationMessages = useMemo(() => messages.map(toConversationMessage), [messages]); + + updateSpriteSafeInset(); + window.addEventListener("resize", updateSpriteSafeInset); + const observers: ResizeObserver[] = []; + if (typeof ResizeObserver !== "undefined") { + const observer = new ResizeObserver(updateSpriteSafeInset); + if (surfaceRef.current) observer.observe(surfaceRef.current); + if (spriteMeasureRef.current) observer.observe(spriteMeasureRef.current); + observers.push(observer); + } + return () => { + window.removeEventListener("resize", updateSpriteSafeInset); + observers.forEach((observer) => observer.disconnect()); + }; + }, [mariStage.src]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); - }, [messages.length]); + }, [messages.length, liveTrace.length, pendingApproval?.id]); useEffect(() => { const input = inputRef.current; if (!input) return; input.style.height = "0px"; - input.style.height = `${Math.min(input.scrollHeight, 160)}px`; + input.style.height = `${Math.min(input.scrollHeight, 148)}px`; }, [draft]); const readFiles = async (files: FileList | null) => { @@ -202,7 +261,15 @@ export function ProfessorMariSurface() { setDraft(""); setAttachments([]); setSendError(null); + setSendErrorDetails(null); + setActionError(null); + setApprovalError(null); + setResolvingApproval(null); + setPendingApproval(null); + setPendingAction(null); + setLiveTrace([]); setSending(true); + setOptionPanel(null); requestAnimationFrame(() => inputRef.current?.focus()); let response; try { @@ -231,10 +298,45 @@ export function ProfessorMariSurface() { content: attachment.content, })), }, - mariApi, + { + prompt: (request) => + mariApi.prompt(request, (event) => { + if (event.type === "trace") { + setLiveTrace((current) => [...current, event.event]); + return; + } + if (event.type === "approval_request") { + const approval = normalizeMariApprovalRequest(event.approval); + if (approval) { + setPendingApproval(approval); + setResolvingApproval(null); + setApprovalError(null); + } + return; + } + if (event.type === "approval_resolved") { + const outcome = normalizeMariApprovalOutcome(event.outcome); + const applied = event.applied ?? outcome?.applied ?? null; + if (event.approved && applied && applied.applied > 0) { + void queryClient.invalidateQueries(); + } + setPendingApproval((current) => (current?.id === event.approvalId ? null : current)); + setResolvingApproval(null); + if (event.error || outcome?.error) { + setApprovalError(event.error ?? outcome?.error ?? null); + } else { + setApprovalError(null); + } + } + }), + }, ); } catch (error) { + console.error("Professor Mari failed to respond", error); setSendError(error instanceof Error ? error.message : "Professor Mari failed to respond."); + setSendErrorDetails(formatErrorDetails(error)); + setPendingApproval(null); + setResolvingApproval(null); setSending(false); return; } @@ -243,208 +345,143 @@ export function ProfessorMariSurface() { role: "assistant", content: response.content, createdAt: response.createdAt, + trace: response.trace, }; setMessages((current) => [...current, assistant]); + setPendingApproval(null); + setResolvingApproval(null); + setPendingAction(isMariStagedAction(response.action) && response.action.changes.length > 0 ? response.action : null); + setLiveTrace([]); setSending(false); requestAnimationFrame(() => inputRef.current?.focus()); }; + const resolvePendingApproval = async (approved: boolean) => { + if (!pendingApproval || resolvingApproval) return; + setResolvingApproval(approved ? "approve" : "reject"); + setApprovalError(null); + try { + await mariApi.resolveApproval(pendingApproval.id, approved); + } catch (error) { + console.error("Professor Mari failed to resolve approval", error); + setApprovalError(error instanceof Error ? error.message : "Professor Mari failed to resolve the approval."); + setResolvingApproval(null); + } + }; + + const approvePendingChanges = async () => { + if (!isMariStagedAction(pendingAction) || pendingAction.storageActions.length === 0 || applyingAction) return; + setApplyingAction(true); + setActionError(null); + try { + const result = await mariApi.applyStagedChanges(pendingAction); + setPendingAction(null); + await queryClient.invalidateQueries(); + setMessages((current) => [ + ...current, + { + id: newId("mari-assistant"), + role: "assistant", + content: `Saved ${result.applied} staged change${result.applied === 1 ? "" : "s"} to your library.`, + createdAt: result.appliedAt ?? new Date().toISOString(), + }, + ]); + } catch (error) { + console.error("Professor Mari failed to apply staged changes", error); + setActionError(error instanceof Error ? error.message : "Professor Mari failed to apply staged changes."); + } finally { + setApplyingAction(false); + requestAnimationFrame(() => inputRef.current?.focus()); + } + }; + + const rejectPendingChanges = () => { + if (applyingAction) return; + setPendingAction(null); + setActionError(null); + requestAnimationFrame(() => inputRef.current?.focus()); + }; + return ( -
-
-
-
-
- - - - -
- Professor Mari -
-
-
+
+
+
+ -
- {messages.length === 0 ? ( -
-

- This is the start of your conversation with{" "} - Professor Mari. -

-
- ) : ( - conversationMessages.map((message, index) => { - const previous = conversationMessages[index - 1]; - const showSeparator = !previous || getDayKey(previous.createdAt) !== getDayKey(message.createdAt); - const isGrouped = - !!previous && - previous.role === message.role && - previous.characterId === message.characterId && - getDayKey(previous.createdAt) === getDayKey(message.createdAt) && - new Date(message.createdAt).getTime() - new Date(previous.createdAt).getTime() <= 5 * 60 * 1000; - return ( -
- {showSeparator && ( -
-
- - {formatDaySeparator(message.createdAt)} - -
-
- )} - -
- ); - }) - )} - {sending && ( -
Professor Mari is thinking...
- )} - {sendError &&
{sendError}
} -
-
+
+
+
+ + {sending && } + {pendingApproval && ( + void resolvePendingApproval(true)} + onReject={() => void resolvePendingApproval(false)} + /> + )} + {pendingAction && ( + void approvePendingChanges()} + onReject={rejectPendingChanges} + /> + )} + {sendError && } +
+
+
-
- {(connectionMenuOpen || personaMenuOpen || mobileMenuOpen) && ( - + void readFiles(event.target.files)} + /> + + {optionPanel && ( + { setSelectedConnectionId(id); - setConnectionMenuOpen(false); - setMobileMenuOpen(false); + setOptionPanel(null); }} onSelectPersona={(id) => { setSelectedPersonaId(id); - setPersonaMenuOpen(false); - setMobileMenuOpen(false); + setOptionPanel(null); }} /> )} - {attachments.length > 0 && ( -
- {attachments.map((attachment) => ( -
- - {attachment.name} - -
- ))} -
- )} + setAttachments((current) => current.filter((item) => item.id !== id))} /> -
- void readFiles(event.target.files)} - /> - - - - - - - +
+
+ fileInputRef.current?.click()} /> + setOptionPanel((current) => (current === "connections" ? null : "connections"))} + /> + setOptionPanel((current) => (current === "personas" ? null : "personas"))} + /> +