From b16ec26b6489d15c450f2e9985ab187f1a38e526 Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Wed, 27 May 2026 14:53:46 -0700 Subject: [PATCH] Add optional LLM cluster naming --- README.md | 14 +- src-tauri/Cargo.lock | 441 +++++++++++++++++++++++++++++++++++++++++- src-tauri/Cargo.toml | 2 + src-tauri/src/lib.rs | 447 +++++++++++++++++++++++++++++++++++++++++-- src/App.css | 75 ++++++++ src/App.tsx | 276 +++++++++++++++++++++++++- 6 files changed, 1216 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 6cbbf98..eb95030 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ An interactive graph visualization tool for ladybugdb. Explore relationships bet - **Visual Encoding** - Node size reflects connection count (more connections = larger nodes), and colors differentiate entity types. - **Dark/Light Mode** - Toggle between dark and light themes for comfortable viewing. - **Relationship Labels** - Hover over edges to see the type of relationship between connected nodes. +- **Optional LLM Cluster Names** - Provide an LLM access token to name Leiden clusters from a random sample of 15 node labels in each cluster. ## Getting Started @@ -19,13 +20,10 @@ An interactive graph visualization tool for ladybugdb. Explore relationships bet 2. **Start the application** ```bash - npm run dev + cargo tauri dev --features=icebug-analytics -- -- ../test.lbdb ``` -3. **Open in browser** - Navigate to `http://localhost:5173` to view the visualizer. - -The application will automatically connect to the backend API at `http://localhost:3001` to fetch available databases and graph data. It will scan for databases starting from the parent directory looking for files with `.lbdb` extension. +The application opens as a Tauri desktop app. Pass a `.lbdb` path after `-- --` to load that database at startup. ## Usage @@ -37,6 +35,12 @@ The application will automatically connect to the backend API at `http://localho 6. Hover over edges to see relationship types 7. Use the theme toggle button to switch between dark and light modes +## Optional LLM Cluster Naming + +Cluster naming is disabled by default. To enable it, expand the sidebar's Cluster Names section, enter an LLM access token, and click Apply. Cluster computation still starts with local fallback names; the backend only sends a random sample of 15 labels for clusters that are currently visible in the drill view, then falls back to `Cluster N` if a request fails. + +The endpoint defaults to `https://openrouter.ai`, and the model field defaults to `deepseek-v4-flash`. Base endpoints are sent through their `/api/v1/chat/completions` path, and both fields can be changed in the sidebar before applying. + ## Requirements - Node.js diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0e0d90d..abfa47d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -347,6 +347,28 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "base64" version = "0.21.7" @@ -445,8 +467,10 @@ dependencies = [ "arrow-ipc", "arrow-schema 56.2.1", "dirs 5.0.1", + "fastrand", "icebug", "lbug", + "reqwest", "serde", "serde_json", "tauri", @@ -555,6 +579,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -591,6 +617,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -688,6 +720,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -711,7 +753,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -724,7 +766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -1135,6 +1177,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1257,6 +1308,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -1264,6 +1321,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1444,8 +1502,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1455,9 +1515,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1621,6 +1683,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -1737,6 +1818,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1747,6 +1829,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1765,9 +1863,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2043,6 +2143,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.99" @@ -2261,6 +2371,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "markup5ever" version = "0.38.0" @@ -2668,6 +2784,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2847,6 +2969,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2934,6 +3065,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -2955,6 +3142,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3049,21 +3265,31 @@ checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", + "futures-channel", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", + "mime", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3075,6 +3301,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rust_decimal" version = "1.42.0" @@ -3100,6 +3340,81 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3121,6 +3436,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3184,6 +3508,29 @@ version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -3506,6 +3853,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3558,6 +3911,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3579,7 +3953,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.11.1", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -3979,6 +4353,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4281,6 +4665,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4517,6 +4907,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.4" @@ -4573,6 +4973,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4758,6 +5167,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -4812,6 +5232,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5289,6 +5718,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 40dad1f..6f02092 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,3 +25,5 @@ arrow-ipc = { version = "56" } arrow-schema = { version = "56" } walkdir = "2" dirs = "5" +fastrand = "2" +reqwest = { version = "0.13.3", features = ["blocking", "json"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3dbe259..7ea76a6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,12 +5,14 @@ use arrow_schema::{DataType, Field, Schema}; use icebug::{GraphR, Leiden}; use lbug::{Connection, Database, SystemConfig, Value}; use serde::{Deserialize, Serialize}; +use serde_json::json; use std::collections::{HashMap, HashSet}; use std::fs; use std::io::Cursor; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::Mutex; +use std::time::Duration; use tauri::{Manager, State}; use walkdir::WalkDir; @@ -105,6 +107,70 @@ struct GraphData { cluster_debug: GraphClusterDebug, } +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +struct LlmClusterNamingConfig { + #[serde(rename = "accessToken")] + access_token: String, + endpoint: Option, + model: Option, + #[serde(rename = "sampleSize")] + sample_size: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct LlmClusterNameRequest { + key: String, + #[serde(rename = "clusterId")] + cluster_id: u64, + labels: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct LlmClusterNameResult { + key: String, + name: Option, + error: Option, +} + +impl LlmClusterNamingConfig { + fn enabled(&self) -> bool { + !self.access_token.trim().is_empty() + } + + fn endpoint(&self) -> String { + let endpoint = self + .endpoint + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("https://openrouter.ai") + .trim_end_matches('/') + .to_string(); + + if endpoint.ends_with("/chat/completions") { + endpoint + } else if endpoint.ends_with("/v1") || endpoint.ends_with("/api/v1") { + format!("{endpoint}/chat/completions") + } else { + format!("{endpoint}/api/v1/chat/completions") + } + } + + fn model(&self) -> String { + self.model + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("deepseek-v4-flash") + .to_string() + } + + fn sample_size(&self) -> usize { + self.sample_size.unwrap_or(LLM_CLUSTER_SAMPLE_SIZE).max(1) + } +} + const SEED_NODE_COUNT: usize = 8; const EXPAND_BATCH_SIZE: usize = 8; const EDGE_SCAN_LIMIT: usize = 10_000; @@ -113,6 +179,8 @@ const SEARCH_RESULT_LIMIT: usize = 30; const SEARCH_NEIGHBOR_LIMIT: usize = 24; #[cfg(feature = "icebug-analytics")] const CLUSTER_LEVEL_LIMIT: usize = 3; +const LLM_CLUSTER_SAMPLE_SIZE: usize = 15; +const LLM_CLUSTER_NAME_LIMIT: usize = 24; const EXPANDER_PREFIX: &str = "__expand__:"; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -561,9 +629,95 @@ fn remap_membership(membership: &[u64]) -> (Vec, HashMap) { } #[cfg(feature = "icebug-analytics")] -fn cluster_records(membership: &[u64], parent_membership: Option<&[u64]>) -> Vec { +fn node_cluster_label(node: &GraphNode) -> String { + let label = node.label.trim(); + let name = node.name.trim(); + if name.is_empty() || name == label { + label.to_string() + } else { + format!("{label}: {name}") + } +} + +fn clean_llm_cluster_name(value: &str) -> Option { + let name = value + .lines() + .next() + .unwrap_or("") + .trim() + .trim_matches(['"', '\'', '.', ':', '-']) + .trim(); + if name.is_empty() { + return None; + } + Some(name.chars().take(80).collect()) +} + +fn llm_cluster_name( + config: &LlmClusterNamingConfig, + cluster_id: u64, + sample_labels: &[String], +) -> Result, String> { + if !config.enabled() || sample_labels.is_empty() { + return Ok(None); + } + + let label_list = sample_labels + .iter() + .map(|label| format!("- {label}")) + .collect::>() + .join("\n"); + let body = json!({ + "model": config.model(), + "messages": [ + { + "role": "system", + "content": "Name a graph cluster from sampled node labels. Return only a concise 2-5 word name, with no punctuation, quotes, or explanation." + }, + { + "role": "user", + "content": format!("Cluster {cluster_id} sample labels:\n{label_list}") + } + ], + "temperature": 0.2, + "max_tokens": 24 + }); + + let response: serde_json::Value = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(20)) + .build() + .map_err(|err| format!("Failed to create LLM client: {err}"))? + .post(config.endpoint()) + .bearer_auth(config.access_token.trim()) + .json(&body) + .send() + .map_err(|err| format!("LLM request failed: {err}"))? + .error_for_status() + .map_err(|err| format!("LLM request returned an error: {err}"))? + .json() + .map_err(|err| format!("LLM response was not valid JSON: {err}"))?; + + let content = response + .get("choices") + .and_then(|choices| choices.get(0)) + .and_then(|choice| choice.get("message")) + .and_then(|message| message.get("content")) + .and_then(|content| content.as_str()) + .and_then(clean_llm_cluster_name); + Ok(content) +} + +#[cfg(feature = "icebug-analytics")] +fn cluster_records( + membership: &[u64], + parent_membership: Option<&[u64]>, + nodes: &[GraphNode], + llm_config: Option<&LlmClusterNamingConfig>, + llm_name_budget: &mut usize, +) -> Vec { let mut counts: HashMap = HashMap::new(); let mut parents: HashMap = HashMap::new(); + let mut labels_by_cluster: HashMap> = HashMap::new(); for (index, cluster_id) in membership.iter().enumerate() { *counts.entry(*cluster_id).or_insert(0) += 1; if let Some(parent_ids) = parent_membership { @@ -571,15 +725,86 @@ fn cluster_records(membership: &[u64], parent_membership: Option<&[u64]>) -> Vec parents.entry(*cluster_id).or_insert(*parent_id); } } + if let Some(node) = nodes.get(index) { + labels_by_cluster + .entry(*cluster_id) + .or_default() + .push(node_cluster_label(node)); + } + } + + let llm_enabled = llm_config.filter(|config| config.enabled()).is_some(); + let mut llm_cluster_ids = HashSet::new(); + if llm_enabled && *llm_name_budget > 0 { + let mut candidates: Vec<(u64, usize)> = counts + .iter() + .filter_map(|(cluster_id, size)| (*size > 1).then_some((*cluster_id, *size))) + .collect(); + candidates.sort_by_key(|(cluster_id, size)| (std::cmp::Reverse(*size), *cluster_id)); + let take_count = candidates.len().min(*llm_name_budget); + llm_cluster_ids.extend( + candidates + .into_iter() + .take(take_count) + .map(|(cluster_id, _)| cluster_id), + ); + eprintln!( + "LLM cluster naming: naming {} cluster(s), {} budgeted call(s) remaining before this level.", + llm_cluster_ids.len(), + *llm_name_budget, + ); + } else if llm_enabled { + eprintln!("LLM cluster naming: budget exhausted; using fallback names for this level."); } let mut clusters: Vec = counts .into_iter() - .map(|(cluster_id, size)| GraphCluster { - cluster_id, - label: format!("Cluster {cluster_id}"), - size, - parent_cluster_id: parents.get(&cluster_id).copied(), + .map(|(cluster_id, size)| { + let fallback_label = format!("Cluster {cluster_id}"); + let label = llm_config + .filter(|config| config.enabled()) + .filter(|_| llm_cluster_ids.contains(&cluster_id)) + .and_then(|config| { + let mut sample = labels_by_cluster + .get(&cluster_id) + .cloned() + .unwrap_or_default(); + sample.sort(); + sample.dedup(); + fastrand::shuffle(&mut sample); + sample.truncate(config.sample_size()); + eprintln!( + "LLM cluster naming: requesting name for cluster {cluster_id} with {} sampled label(s).", + sample.len(), + ); + *llm_name_budget = llm_name_budget.saturating_sub(1); + match llm_cluster_name(config, cluster_id, &sample) { + Ok(name) => { + if let Some(name) = &name { + eprintln!( + "LLM cluster naming: cluster {cluster_id} named {name:?}." + ); + } else { + eprintln!( + "LLM cluster naming: cluster {cluster_id} returned no usable name; using fallback." + ); + } + name + } + Err(err) => { + eprintln!("Cluster {cluster_id} LLM naming failed: {err}"); + None + } + } + }) + .unwrap_or(fallback_label); + + GraphCluster { + cluster_id, + label, + size, + parent_cluster_id: parents.get(&cluster_id).copied(), + } }) .collect(); clusters.sort_by_key(|cluster| cluster.cluster_id); @@ -655,6 +880,8 @@ fn cluster_debug( fn compute_cluster_levels( nodes: &[GraphNode], links: &[GraphLink], + llm_config: Option<&LlmClusterNamingConfig>, + llm_name_budget: &mut usize, ) -> (Option>, GraphClusterDebug) { let node_count = nodes.len(); if node_count < 2 { @@ -736,7 +963,7 @@ fn compute_cluster_levels( levels.push(GraphClusterLevel { level: 0, membership: level_zero.clone(), - clusters: cluster_records(&level_zero, None), + clusters: cluster_records(&level_zero, None, nodes, llm_config, llm_name_budget), }); let mut node_membership = level_zero.clone(); @@ -793,12 +1020,18 @@ fn compute_cluster_levels( } if let Some(previous) = levels.last_mut() { - previous.clusters = cluster_records(&node_membership, Some(&next_membership)); + previous.clusters = cluster_records( + &node_membership, + Some(&next_membership), + nodes, + llm_config, + llm_name_budget, + ); } levels.push(GraphClusterLevel { level, membership: next_membership.clone(), - clusters: cluster_records(&next_membership, None), + clusters: cluster_records(&next_membership, None, nodes, llm_config, llm_name_budget), }); node_membership = next_membership; @@ -836,6 +1069,8 @@ fn compute_cluster_levels( fn compute_cluster_levels( _nodes: &[GraphNode], links: &[GraphLink], + _llm_config: Option<&LlmClusterNamingConfig>, + _llm_name_budget: &mut usize, ) -> (Option>, GraphClusterDebug) { ( None, @@ -852,7 +1087,34 @@ fn compute_cluster_levels( ) } -fn graph_data(nodes: Vec, links: Vec) -> GraphData { +fn graph_data( + nodes: Vec, + links: Vec, + llm_config: Option<&LlmClusterNamingConfig>, + context: &str, +) -> GraphData { + eprintln!( + "Graph data [{context}]: building response for {} node(s), {} edge(s); visible LLM cluster naming {}.", + nodes.len(), + links.len(), + if llm_config + .filter(|config| config.enabled()) + .is_some() + { + "enabled" + } else { + "disabled" + } + ); + if let Some(config) = llm_config.filter(|config| config.enabled()) { + eprintln!( + "Graph data [{context}]: LLM model={}, endpoint={}, sample_size={}, max_visible_cluster_name_calls={}; naming is deferred until clusters are visible.", + config.model(), + config.endpoint(), + config.sample_size(), + LLM_CLUSTER_NAME_LIMIT, + ); + } let csr = build_csr(&nodes, &links); let csr_arrow_ipc = csr_to_arrow_ipc(&csr) .map_err(|err| { @@ -860,8 +1122,10 @@ fn graph_data(nodes: Vec, links: Vec) -> GraphData { err }) .ok(); - let (cluster_levels, cluster_debug) = compute_cluster_levels(&nodes, &links); - eprintln!("Cluster debug: {}", cluster_debug.message); + let mut llm_name_budget = LLM_CLUSTER_NAME_LIMIT; + let (cluster_levels, cluster_debug) = + compute_cluster_levels(&nodes, &links, None, &mut llm_name_budget); + eprintln!("Cluster debug [{context}]: {}", cluster_debug.message); GraphData { nodes, links, @@ -876,6 +1140,46 @@ fn graph_data(nodes: Vec, links: Vec) -> GraphData { } } +fn graph_data_without_clusters( + nodes: Vec, + links: Vec, + context: &str, +) -> GraphData { + eprintln!( + "Graph data [{context}]: building internal graph for {} node(s), {} edge(s); clustering deferred.", + nodes.len(), + links.len(), + ); + let csr = build_csr(&nodes, &links); + let csr_arrow_ipc = csr_to_arrow_ipc(&csr) + .map_err(|err| { + eprintln!("{err}"); + err + }) + .ok(); + GraphData { + nodes, + links, + csr: if csr_arrow_ipc.is_some() { + None + } else { + Some(csr) + }, + csr_arrow_ipc, + cluster_levels: None, + cluster_debug: cluster_debug( + false, + "deferred", + "Cluster computation deferred for internal full graph.".to_string(), + 0, + 0, + 0, + 0, + 0, + ), + } +} + fn collect_edge_graph(conn: &Connection, limit: usize) -> Result { let mut result = conn .query(&format!("MATCH (a)-[r]->(b) RETURN a, r, b LIMIT {limit}")) @@ -934,7 +1238,11 @@ fn collect_edge_graph(conn: &Connection, limit: usize) -> Result GraphData { +fn seed_graph_from_full( + full_graph: GraphData, + llm_config: Option<&LlmClusterNamingConfig>, +) -> GraphData { let mut degrees: HashMap = HashMap::new(); for node in &full_graph.nodes { degrees.entry(node.id.clone()).or_insert(0); @@ -1012,7 +1323,7 @@ fn seed_graph_from_full(full_graph: GraphData) -> GraphData { .collect(); add_expanders(&full_graph, &visible_ids, &mut nodes, &mut links); - graph_data(nodes, links) + graph_data(nodes, links, llm_config, "seed_graph_from_full") } fn expand_node_from_full( @@ -1020,6 +1331,7 @@ fn expand_node_from_full( node_id: &str, visible_node_ids: &[String], offset: usize, + llm_config: Option<&LlmClusterNamingConfig>, ) -> GraphData { let visible_order: Vec = visible_node_ids .iter() @@ -1098,7 +1410,7 @@ fn expand_node_from_full( nodes.push(expander); } - graph_data(nodes, links) + graph_data(nodes, links, llm_config, "expand_node_from_full") } #[tauri::command] @@ -1115,6 +1427,75 @@ fn get_initial_database_id(state: State) -> Option { .map(|db| db.id) } +#[tauri::command] +fn name_visible_clusters( + llm_config: LlmClusterNamingConfig, + clusters: Vec, +) -> Vec { + if !llm_config.enabled() { + eprintln!("Visible cluster naming skipped: LLM config is disabled."); + return Vec::new(); + } + + let request_count = clusters.len().min(LLM_CLUSTER_NAME_LIMIT); + eprintln!( + "Visible cluster naming: naming {} visible cluster(s), capped from {} requested cluster(s); model={}, endpoint={}.", + request_count, + clusters.len(), + llm_config.model(), + llm_config.endpoint(), + ); + + clusters + .into_iter() + .take(LLM_CLUSTER_NAME_LIMIT) + .map(|cluster| { + let mut sample = cluster.labels; + sample.sort(); + sample.dedup(); + fastrand::shuffle(&mut sample); + sample.truncate(llm_config.sample_size()); + eprintln!( + "Visible cluster naming: requesting name for {} with {} sampled label(s).", + cluster.key, + sample.len(), + ); + + match llm_cluster_name(&llm_config, cluster.cluster_id, &sample) { + Ok(name) => { + if let Some(name) = &name { + eprintln!( + "Visible cluster naming: cluster {} named {name:?}.", + cluster.key + ); + } else { + eprintln!( + "Visible cluster naming: cluster {} returned no usable name.", + cluster.key + ); + } + LlmClusterNameResult { + key: cluster.key, + name, + error: None, + } + } + Err(err) => { + eprintln!( + "Visible cluster naming: cluster {} failed: {err}", + cluster.key + ); + LlmClusterNameResult { + key: cluster.key, + name: None, + error: Some(err), + } + } + } + }) + .collect() +} + fn add_database_info(state: &AppState, db_info: DatabaseInfo) -> Result { let mut custom = state.custom_databases.lock().unwrap(); if custom.iter().any(|d| d.path == db_info.path) { @@ -1207,7 +1588,11 @@ fn get_directories( } #[tauri::command] -fn get_graph(state: State, id: usize) -> Result { +fn get_graph( + state: State, + id: usize, + llm_config: Option, +) -> Result { let databases = get_all_databases(&state); let db_info = databases.get(id).ok_or("Database not found")?; @@ -1215,7 +1600,8 @@ fn get_graph(state: State, id: usize) -> Result { .map_err(|e| format!("Failed to open database: {}", e))?; let conn = Connection::new(&db).map_err(|e| format!("Failed to create connection: {}", e))?; - collect_edge_graph(&conn, EDGE_SCAN_LIMIT).map(seed_graph_from_full) + collect_edge_graph(&conn, EDGE_SCAN_LIMIT) + .map(|full_graph| seed_graph_from_full(full_graph, llm_config.as_ref())) } #[tauri::command] @@ -1277,6 +1663,7 @@ fn get_node_neighborhood( state: State, id: usize, node_id: String, + llm_config: Option, ) -> Result { let focus_node_id = node_id; let databases = get_all_databases(&state); @@ -1341,7 +1728,12 @@ fn get_node_neighborhood( .collect(); add_expanders(&full_graph, &visible_ids, &mut nodes, &mut links); - Ok(graph_data(nodes, links)) + Ok(graph_data( + nodes, + links, + llm_config.as_ref(), + "get_node_neighborhood", + )) } #[tauri::command] @@ -1351,6 +1743,7 @@ fn expand_node( node_id: String, visible_node_ids: Vec, offset: Option, + llm_config: Option, ) -> Result { let databases = get_all_databases(&state); let db_info = databases.get(id).ok_or("Database not found")?; @@ -1365,11 +1758,17 @@ fn expand_node( &node_id, &visible_node_ids, offset.unwrap_or(0), + llm_config.as_ref(), )) } #[tauri::command] -fn execute_query(state: State, id: usize, query: String) -> Result { +fn execute_query( + state: State, + id: usize, + query: String, + llm_config: Option, +) -> Result { let databases = get_all_databases(&state); let db_info = databases.get(id).ok_or("Database not found")?; @@ -1441,7 +1840,12 @@ fn execute_query(state: State, id: usize, query: String) -> Result(cmd: string, args?: Record): Promise< return invoke(cmd, args) } +function buildLlmClusterConfig(config: LlmClusterNamingConfig): LlmClusterNamingConfig | null { + const accessToken = config.accessToken.trim() + if (!accessToken) return null + const endpoint = config.endpoint?.trim() + const model = config.model?.trim() + return { + accessToken, + sampleSize: config.sampleSize, + ...(endpoint ? { endpoint } : {}), + ...(model ? { model } : {}), + } +} + +function getNodeClusterLabel(node: GraphNode) { + const label = node.label.trim() + const name = node.name.trim() + return !name || name === label ? label : `${label}: ${name}` +} + +function sampleLabels(labels: string[], sampleSize: number) { + const unique = [...new Set(labels)].filter(Boolean) + for (let index = unique.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(Math.random() * (index + 1)) + const value = unique[index] + unique[index] = unique[swapIndex] + unique[swapIndex] = value + } + return unique.slice(0, sampleSize) +} + function getEndpointId(endpoint: string | NodeObject): string { return typeof endpoint === 'object' ? String(endpoint.id) : endpoint } @@ -812,13 +861,37 @@ function App() { const [searchError, setSearchError] = useState(null) const [focusedNodeId, setFocusedNodeId] = useState(null) const [lastExpandedNodeIds, setLastExpandedNodeIds] = useState>(() => new Set()) + const [llmSettingsOpen, setLlmSettingsOpen] = useState(false) + const [llmClusterConfig, setLlmClusterConfig] = useState({ + accessToken: '', + endpoint: 'https://openrouter.ai', + model: 'deepseek-v4-flash', + sampleSize: 15, + }) // eslint-disable-next-line @typescript-eslint/no-explicit-any const graphRef = useRef(null) const graphContainerRef = useRef(null) const [graphSize, setGraphSize] = useState({ width: 1, height: 1 }) const customQueryRef = useRef('') + const llmClusterConfigRef = useRef(llmClusterConfig) + const graphRequestInFlightRef = useRef(null) + const requestedClusterNameKeysRef = useRef>(new Set()) const debounceTimerRef = useRef | null>(null) + const updateLlmClusterConfig = (patch: Partial) => { + setLlmClusterConfig(current => { + const next = { ...current, ...patch } + llmClusterConfigRef.current = next + return next + }) + } + + const currentLlmClusterConfig = useCallback(() => buildLlmClusterConfig(llmClusterConfigRef.current), []) + + const resetClusterNameRequests = useCallback(() => { + requestedClusterNameKeysRef.current.clear() + }, []) + const fetchDatabases = () => { Promise.all([ invokeCommand('get_databases'), @@ -886,6 +959,7 @@ function App() { const fetchGraphData = useCallback(() => { if (databases.length === 0) { + resetClusterNameRequests() setGraphData({ nodes: [], links: [] }) return } @@ -893,15 +967,46 @@ function App() { setError(null) const query = customQueryRef.current.trim() + const llmConfig = currentLlmClusterConfig() + const requestKey = JSON.stringify({ + id: selectedId, + query, + llmConfig, + }) + if (graphRequestInFlightRef.current === requestKey) { + console.info('Graph fetch skipped: identical request is already in flight', { + id: selectedId, + queryMode: query ? 'custom' : 'default', + llmClusterNaming: Boolean(llmConfig), + model: llmConfig?.model, + endpoint: llmConfig?.endpoint, + }) + return + } + graphRequestInFlightRef.current = requestKey + const finishGraphFetch = () => { + if (graphRequestInFlightRef.current === requestKey) { + graphRequestInFlightRef.current = null + } + setLoading(false) + } + console.info('Graph fetch started', { + id: selectedId, + queryMode: query ? 'custom' : 'default', + llmClusterNaming: Boolean(llmConfig), + model: llmConfig?.model, + endpoint: llmConfig?.endpoint, + }) if (query) { - invokeCommand('execute_query', { id: selectedId, query }) + invokeCommand('execute_query', { id: selectedId, query, llmConfig }) .then(data => { console.info('Graph cluster debug:', data.clusterDebug) + resetClusterNameRequests() setGraphData(data) setLastExpandedNodeIds(new Set()) setClusterPath([]) setFocusedNodeId(null) - setLoading(false) + finishGraphFetch() setTimeout(() => { if (graphRef.current) { graphRef.current.zoomToFit(400) @@ -909,18 +1014,20 @@ function App() { }, 500) }) .catch(err => { + console.error('Graph fetch failed', err) setError(String(err)) - setLoading(false) + finishGraphFetch() }) } else { - invokeCommand('get_graph', { id: selectedId }) + invokeCommand('get_graph', { id: selectedId, llmConfig }) .then(data => { console.info('Graph cluster debug:', data.clusterDebug) + resetClusterNameRequests() setGraphData(data) setLastExpandedNodeIds(new Set()) setClusterPath([]) setFocusedNodeId(null) - setLoading(false) + finishGraphFetch() setTimeout(() => { if (graphRef.current) { graphRef.current.zoomToFit(400) @@ -928,11 +1035,12 @@ function App() { }, 500) }) .catch(err => { + console.error('Graph fetch failed', err) setError(String(err)) - setLoading(false) + finishGraphFetch() }) } - }, [selectedId, databases.length]) + }, [selectedId, databases.length, currentLlmClusterConfig, resetClusterNameRequests]) /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { @@ -989,10 +1097,15 @@ function App() { setLoading(true) setError(null) setSearchError(null) - invokeCommand('get_node_neighborhood', { id: selectedId, nodeId: node.id }) + invokeCommand('get_node_neighborhood', { + id: selectedId, + nodeId: node.id, + llmConfig: currentLlmClusterConfig(), + }) .then(data => { const normalized = normalizeGraphData(data) const levels = buildCommunityClusterLevels(normalized) + resetClusterNameRequests() setGraphData(data) setClusterViewEnabled(levels.length > 0) setClusterPath(getClusterPathForNode(normalized, levels, node.id)) @@ -1004,7 +1117,7 @@ function App() { setError(String(err)) setLoading(false) }) - }, [selectedId]) + }, [selectedId, currentLlmClusterConfig, resetClusterNameRequests]) const colorMapRef = useRef>({}) const edgeColorMapRef = useRef>({}) @@ -1021,6 +1134,7 @@ function App() { nodeId: expandedNodeId, visibleNodeIds: graphData.nodes.map(item => item.id), offset: node.offset ?? 0, + llmConfig: currentLlmClusterConfig(), }) .then(data => { const beforeNodeIds = realNodeIds(graphData.nodes) @@ -1031,6 +1145,7 @@ function App() { beforeNodeIds.forEach(id => { highlightedNodeIds.delete(id) }) + resetClusterNameRequests() setGraphData(data) setLastExpandedNodeIds(highlightedNodeIds) setClusterPath(getClusterPathForNode(normalized, levels, expandedNodeId)) @@ -1041,7 +1156,7 @@ function App() { setError(String(err)) setLoading(false) }) - }, [graphData, selectedId]) + }, [graphData, selectedId, currentLlmClusterConfig, resetClusterNameRequests]) const normalizedGraphData = useMemo(() => normalizeGraphData(graphData), [graphData]) const clusterLevels = useMemo(() => buildCommunityClusterLevels(normalizedGraphData), [normalizedGraphData]) @@ -1069,6 +1184,100 @@ function App() { : normalizedGraphData ), [clusterViewEnabled, clusterLevels, normalizedGraphData, visibleClusterPath]) + useEffect(() => { + const llmConfig = currentLlmClusterConfig() + if (!llmConfig || !clusterViewEnabled || !currentClusterLevel) return + + const visibleClusterIds = visibleGraphData.nodes + .filter(isClusterNode) + .map(node => node.community) + .filter((clusterId): clusterId is number => clusterId !== undefined) + if (visibleClusterIds.length === 0) return + + const requests: LlmClusterNameRequest[] = [] + const currentPathKey = visibleClusterPath.map(item => `${item.level}:${item.clusterId}`).join('/') + + visibleClusterIds.forEach(clusterId => { + const key = `${currentClusterLevel.level}:${clusterId}:${currentPathKey}` + if (requestedClusterNameKeysRef.current.has(key)) return + + const labels = normalizedGraphData.nodes + .filter((node, index) => ( + !isExpanderNode(node) + && currentClusterLevel.membership[index] === clusterId + && nodeMatchesClusterPath(clusterLevels, index, visibleClusterPath) + )) + .map(getNodeClusterLabel) + const sampledLabels = sampleLabels(labels, llmConfig.sampleSize) + if (sampledLabels.length === 0) return + + requests.push({ + key, + clusterId, + labels: sampledLabels, + }) + }) + + if (requests.length === 0) return + const cappedRequests = requests.slice(0, 24) + requests.forEach(request => { + requestedClusterNameKeysRef.current.add(request.key) + }) + console.info('Visible cluster naming started', { + requested: requests.length, + sent: cappedRequests.length, + level: currentClusterLevel.level, + path: currentPathKey || 'root', + model: llmConfig.model, + endpoint: llmConfig.endpoint, + }) + + invokeCommand('name_visible_clusters', { + llmConfig, + clusters: cappedRequests, + }) + .then(results => { + const namesByKey = new Map( + results + .filter(result => result.name) + .map(result => [result.key, result.name as string]), + ) + console.info('Visible cluster naming finished', { + requested: cappedRequests.length, + named: namesByKey.size, + errors: results.filter(result => result.error).length, + }) + if (namesByKey.size === 0) return + + setGraphData(current => ({ + ...current, + clusterLevels: current.clusterLevels?.map(level => ( + level.level !== currentClusterLevel.level + ? level + : { + ...level, + clusters: level.clusters.map(cluster => { + const key = `${level.level}:${cluster.clusterId}:${currentPathKey}` + const name = namesByKey.get(key) + return name ? { ...cluster, label: name } : cluster + }), + } + )), + })) + }) + .catch(err => { + console.error('Visible cluster naming failed', err) + }) + }, [ + clusterLevels, + clusterViewEnabled, + currentClusterLevel, + currentLlmClusterConfig, + normalizedGraphData, + visibleClusterPath, + visibleGraphData.nodes, + ]) + /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { if (clusterLevels.length === 0) { @@ -1264,6 +1473,53 @@ function App() { )} +
+ + {llmSettingsOpen && ( +
+ updateLlmClusterConfig({ accessToken: event.target.value })} + /> + updateLlmClusterConfig({ model: event.target.value })} + /> + updateLlmClusterConfig({ endpoint: event.target.value })} + /> +
+ )} +
+ {llmClusterConfig.accessToken.trim() ? 'Visible only' : 'Using local names'} + +
+