v0.2.0-llm: LLM integration, core utilities, and persistence

- Rust backend: LLM abstraction with OpenAI-compatible + Ollama API clients
- Tauri commands: generate, generate_stream, get/set LLM config
- LLM config: api_url, api_key, model, temperature, max_tokens, top_p
- Works with Ollama (localhost:11434), LM Studio, any OpenAI-compatible API
- Streaming infrastructure via Tauri Channels (LlmEvent)
- tauri-plugin-store, tauri-plugin-fs added
- Frontend: NPC Generator with race/class/alignment selection + AI generation
- Frontend: Dice Roller with notation parsing (2d6+3), presets, history
- Frontend: Session Logger with note-taking + AI summarization
- Frontend: Settings panel for LLM configuration (URL, key, model, temperature)
- App shell: left rail nav with active states, settings toggle
- All glassmorphism cards with gold glow hover states
- Builds clean: tsc, vite build, cargo check, tauri build
This commit is contained in:
itsamejms
2026-06-28 22:36:30 +01:00
parent f30a92ddeb
commit 9675e05b07
13 changed files with 1358 additions and 56 deletions
+498 -6
View File
@@ -451,6 +451,16 @@ dependencies = [
"version_check", "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]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@@ -474,9 +484,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.13.0",
"core-foundation", "core-foundation 0.10.1",
"core-graphics-types", "core-graphics-types",
"foreign-types", "foreign-types 0.5.0",
"libc", "libc",
] ]
@@ -487,7 +497,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.13.0",
"core-foundation", "core-foundation 0.10.1",
"libc", "libc",
] ]
@@ -730,11 +740,15 @@ name = "dm-pal"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"log", "log",
"reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-fs",
"tauri-plugin-log", "tauri-plugin-log",
"tauri-plugin-store",
"tokio",
] ]
[[package]] [[package]]
@@ -823,6 +837,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" 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]] [[package]]
name = "env_filter" name = "env_filter"
version = "0.1.4" version = "0.1.4"
@@ -850,6 +873,16 @@ dependencies = [
"typeid", "typeid",
] ]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.4.1" version = "2.4.1"
@@ -912,6 +945,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.5.0" version = "0.5.0"
@@ -919,7 +961,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [ dependencies = [
"foreign-types-macros", "foreign-types-macros",
"foreign-types-shared", "foreign-types-shared 0.3.1",
] ]
[[package]] [[package]]
@@ -933,6 +975,12 @@ dependencies = [
"syn 2.0.118", "syn 2.0.118",
] ]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]] [[package]]
name = "foreign-types-shared" name = "foreign-types-shared"
version = "0.3.1" version = "0.3.1"
@@ -1316,6 +1364,25 @@ dependencies = [
"syn 2.0.118", "syn 2.0.118",
] ]
[[package]]
name = "h2"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
@@ -1408,6 +1475,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
@@ -1418,6 +1486,37 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.20" version = "0.1.20"
@@ -1436,9 +1535,11 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
] ]
[[package]] [[package]]
@@ -1797,6 +1898,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.2" version = "0.8.2"
@@ -1895,6 +2002,23 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]] [[package]]
name = "ndk" name = "ndk"
version = "0.9.0" version = "0.9.0"
@@ -2094,6 +2218,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.13.0",
"block2", "block2",
"libc",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -2172,6 +2297,49 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl"
version = "0.10.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
dependencies = [
"bitflags 2.13.0",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -2598,6 +2766,46 @@ dependencies = [
"bytecheck", "bytecheck",
] ]
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.4" version = "0.13.4"
@@ -2632,6 +2840,20 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "rkyv" name = "rkyv"
version = "0.7.46" version = "0.7.46"
@@ -2693,12 +2915,64 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.13.0",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
dependencies = [
"once_cell",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]] [[package]]
name = "same-file" name = "same-file"
version = "1.0.6" version = "1.0.6"
@@ -2708,6 +2982,15 @@ dependencies = [
"winapi-util", "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]] [[package]]
name = "schemars" name = "schemars"
version = "0.8.22" version = "0.8.22"
@@ -2771,6 +3054,29 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.0",
"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]] [[package]]
name = "selectors" name = "selectors"
version = "0.36.1" version = "0.36.1"
@@ -2895,6 +3201,18 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]] [[package]]
name = "serde_with" name = "serde_with"
version = "3.21.0" version = "3.21.0"
@@ -2975,6 +3293,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.9" version = "0.3.9"
@@ -3099,6 +3427,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.7" version = "1.0.7"
@@ -3152,6 +3486,27 @@ dependencies = [
"syn 2.0.118", "syn 2.0.118",
] ]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.0",
"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]] [[package]]
name = "system-deps" name = "system-deps"
version = "6.2.2" version = "6.2.2"
@@ -3173,7 +3528,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.13.0",
"block2", "block2",
"core-foundation", "core-foundation 0.10.1",
"core-graphics", "core-graphics",
"crossbeam-channel", "crossbeam-channel",
"dbus", "dbus",
@@ -3258,7 +3613,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"plist", "plist",
"raw-window-handle", "raw-window-handle",
"reqwest", "reqwest 0.13.4",
"serde", "serde",
"serde_json", "serde_json",
"serde_repr", "serde_repr",
@@ -3357,6 +3712,30 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]] [[package]]
name = "tauri-plugin-log" name = "tauri-plugin-log"
version = "2.8.0" version = "2.8.0"
@@ -3379,6 +3758,22 @@ dependencies = [
"time", "time",
] ]
[[package]]
name = "tauri-plugin-store"
version = "2.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c72dda16786eb4a3f903e43a17b64d8d78dc0f00fe2aa4b757c28f617a8630b"
dependencies = [
"dunce",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.11.3" version = "2.11.3"
@@ -3479,6 +3874,19 @@ dependencies = [
"toml 1.1.2+spec-1.1.0", "toml 1.1.2+spec-1.1.0",
] ]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "tendril" name = "tendril"
version = "0.5.0" version = "0.5.0"
@@ -3595,11 +4003,45 @@ dependencies = [
"bytes", "bytes",
"libc", "libc",
"mio", "mio",
"parking_lot",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry",
"socket2", "socket2",
"tokio-macros",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.18" version = "0.7.18"
@@ -3785,9 +4227,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"pin-project-lite", "pin-project-lite",
"tracing-attributes",
"tracing-core", "tracing-core",
] ]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]] [[package]]
name = "tracing-core" name = "tracing-core"
version = "0.1.36" version = "0.1.36"
@@ -3890,6 +4344,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -3951,6 +4411,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"
@@ -4336,6 +4802,17 @@ dependencies = [
"windows-link 0.1.3", "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]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.3.4" version = "0.3.4"
@@ -4381,6 +4858,15 @@ dependencies = [
"windows-targets 0.42.2", "windows-targets 0.42.2",
] ]
[[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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@@ -4722,6 +5208,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.4" version = "0.2.4"
+4 -2
View File
@@ -8,8 +8,6 @@ repository = ""
edition = "2021" edition = "2021"
rust-version = "1.77.2" rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib] [lib]
name = "app_lib" name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"] crate-type = ["staticlib", "cdylib", "rlib"]
@@ -23,3 +21,7 @@ serde = { version = "1.0", features = ["derive"] }
log = "0.4" log = "0.4"
tauri = { version = "2.11.3", features = [] } tauri = { version = "2.11.3", features = [] }
tauri-plugin-log = "2" tauri-plugin-log = "2"
tauri-plugin-store = "2"
tauri-plugin-fs = "2"
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
+14 -1
View File
@@ -6,6 +6,19 @@
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:default", "core:window:default",
"core:window:allow-set-title" "core:window:allow-set-title",
"store:default",
"fs:default",
"fs:allow-read-text-file",
"fs:allow-write-text-file",
"fs:allow-read-file",
"fs:allow-write-file",
"fs:allow-exists",
"fs:allow-mkdir",
"fs:allow-remove",
"fs:allow-rename",
"fs:allow-read-dir",
"fs:allow-appdata-read-recursive",
"fs:allow-appdata-write-recursive"
] ]
} }
+166
View File
@@ -0,0 +1,166 @@
use crate::llm::{self, AppState, ChatMessage, ChatResponse, GenerateRequest, LlmEvent, OllamaChatResponse};
use serde_json::json;
use tauri::ipc::Channel;
// ─── Simple (non-streaming) generate ─────────────────────────
#[tauri::command]
pub async fn generate(state: tauri::State<'_, AppState>, req: GenerateRequest) -> Result<String, String> {
let config = {
let guard = state.config.lock().map_err(|e| e.to_string())?;
guard.clone()
};
let client = reqwest::Client::new();
let messages = llm::build_messages(req.system.as_deref(), &req.prompt);
let temperature = req.temperature.unwrap_or(config.temperature);
let max_tokens = req.max_tokens.unwrap_or(config.max_tokens);
if llm::is_ollama(&config.api_url) {
call_ollama(&client, &config, &messages, temperature, max_tokens).await
} else {
call_openai(&client, &config, &messages, temperature, max_tokens).await
}
}
// ─── Streaming generate via Tauri Channel ─────────────────────
#[tauri::command]
pub async fn generate_stream(
state: tauri::State<'_, AppState>,
req: GenerateRequest,
channel: Channel<LlmEvent>,
) -> Result<(), String> {
let config = state.config.lock().map_err(|e| e.to_string())?.clone();
tauri::async_runtime::spawn(async move {
let client = reqwest::Client::new();
let messages = llm::build_messages(req.system.as_deref(), &req.prompt);
let temperature = req.temperature.unwrap_or(config.temperature);
let max_tokens = req.max_tokens.unwrap_or(config.max_tokens);
// For now, we do a non-streaming call and emit the full response as one token
// Real SSE streaming from Ollama/OpenAI can be added later
let result = if llm::is_ollama(&config.api_url) {
call_ollama(&client, &config, &messages, temperature, max_tokens).await
} else {
call_openai(&client, &config, &messages, temperature, max_tokens).await
};
match result {
Ok(text) => {
let _ = channel.send(LlmEvent::Token(text.clone()));
let _ = channel.send(LlmEvent::Done(text));
}
Err(e) => {
let _ = channel.send(LlmEvent::Error(e));
}
}
});
Ok(())
}
// ─── Get / Set LLM Config ────────────────────────────────────
#[tauri::command]
pub fn get_llm_config(state: tauri::State<'_, AppState>) -> Result<crate::llm::LlmConfig, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
Ok(config.clone())
}
#[tauri::command]
pub fn set_llm_config(state: tauri::State<'_, AppState>, config: crate::llm::LlmConfig) -> Result<(), String> {
let mut lock = state.config.lock().map_err(|e| e.to_string())?;
*lock = config;
Ok(())
}
// ─── Ollama API ───────────────────────────────────────────────
async fn call_ollama(
client: &reqwest::Client,
config: &crate::llm::LlmConfig,
messages: &[ChatMessage],
temperature: f32,
max_tokens: u32,
) -> Result<String, String> {
let body = json!({
"model": config.model,
"messages": messages,
"stream": false,
"options": {
"temperature": temperature,
"num_predict": max_tokens,
}
});
let url = format!("{}/api/chat", config.api_url.trim_end_matches('/'));
let res = client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| format!("Ollama request failed: {e}"))?;
if !res.status().is_success() {
let status = res.status();
let text = res.text().await.unwrap_or_default();
return Err(format!("Ollama error {status}: {text}"));
}
let response: OllamaChatResponse = res
.json()
.await
.map_err(|e| format!("Ollama parse error: {e}"))?;
Ok(response.message.content)
}
// ─── OpenAI-compatible API ────────────────────────────────────
async fn call_openai(
client: &reqwest::Client,
config: &crate::llm::LlmConfig,
messages: &[ChatMessage],
temperature: f32,
max_tokens: u32,
) -> Result<String, String> {
let body = json!({
"model": config.model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
});
let url = format!("{}/v1/chat/completions", config.api_url.trim_end_matches('/'));
let mut req = client.post(&url);
if !config.api_key.is_empty() {
req = req.bearer_auth(&config.api_key);
}
let res = req
.json(&body)
.send()
.await
.map_err(|e| format!("API request failed: {e}"))?;
if !res.status().is_success() {
let status = res.status();
let text = res.text().await.unwrap_or_default();
return Err(format!("API error {status}: {text}"));
}
let response: ChatResponse = res
.json()
.await
.map_err(|e| format!("API parse error: {e}"))?;
response
.choices
.first()
.map(|c| c.message.content.clone())
.ok_or_else(|| "No response from API".to_string())
}
+1
View File
@@ -0,0 +1 @@
pub mod llm_commands;
+18 -10
View File
@@ -1,17 +1,25 @@
mod llm;
mod commands;
use llm::AppState;
use std::sync::Mutex;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.setup(|app| { .plugin(tauri_plugin_log::Builder::default().build())
if cfg!(debug_assertions) { .plugin(tauri_plugin_store::Builder::default().build())
app.handle().plugin( .plugin(tauri_plugin_fs::init())
tauri_plugin_log::Builder::default() .manage(AppState {
.level(log::LevelFilter::Info) config: Mutex::new(llm::LlmConfig::default()),
.build(),
)?;
}
Ok(())
}) })
.invoke_handler(tauri::generate_handler![greet]) .invoke_handler(tauri::generate_handler![
greet,
commands::llm_commands::generate,
commands::llm_commands::generate_stream,
commands::llm_commands::get_llm_config,
commands::llm_commands::set_llm_config,
])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
+108
View File
@@ -0,0 +1,108 @@
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
use tauri::ipc::Channel;
// ─── LLM Event (for streaming) ───────────────────────────────
#[derive(Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "data", rename_all = "camelCase")]
pub enum LlmEvent {
Token(String),
Done(String), // full text
Error(String),
}
// ─── App State ────────────────────────────────────────────────
pub struct AppState {
pub config: Mutex<LlmConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmConfig {
pub api_url: String,
pub api_key: String,
pub model: String,
pub temperature: f32,
pub max_tokens: u32,
pub top_p: f32,
}
impl Default for LlmConfig {
fn default() -> Self {
Self {
// Default to Ollama local server; also works with LM Studio, llama.cpp server, or OpenAI
api_url: "http://localhost:11434".to_string(),
api_key: String::new(),
model: "llama3.2".to_string(),
temperature: 0.7,
max_tokens: 512,
top_p: 0.9,
}
}
}
// ─── Generation Request ───────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct GenerateRequest {
pub prompt: String,
pub system: Option<String>,
pub temperature: Option<f32>,
pub max_tokens: Option<u32>,
}
// ─── OpenAI-Compatible Chat Response ─────────────────────────
#[derive(Debug, Deserialize)]
pub struct ChatResponse {
pub choices: Vec<ChatChoice>,
}
#[derive(Debug, Deserialize)]
pub struct ChatChoice {
pub message: ChatMessage,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: String,
pub content: String,
}
// ─── Ollama Generate Response ─────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct OllamaGenerateResponse {
pub response: String,
pub done: bool,
}
#[derive(Debug, Deserialize)]
pub struct OllamaChatResponse {
pub message: ChatMessage,
pub done: bool,
}
// ─── Helper: detect if we're talking to Ollama ───────────────
pub fn is_ollama(url: &str) -> bool {
url.contains("localhost:11434") || url.contains("127.0.0.1:11434")
}
// ─── Build system + user messages from a prompt ──────────────
pub fn build_messages(system: Option<&str>, prompt: &str) -> Vec<ChatMessage> {
let mut messages = Vec::new();
if let Some(sys) = system {
messages.push(ChatMessage {
role: "system".to_string(),
content: sys.to_string(),
});
}
messages.push(ChatMessage {
role: "user".to_string(),
content: prompt.to_string(),
});
messages
}
+30 -8
View File
@@ -8,7 +8,11 @@ import {
Settings, Settings,
ChevronDown, ChevronDown,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react";
import { Dashboard } from "./components/Dashboard"; import { Dashboard } from "./components/Dashboard";
import { SettingsPanel } from "./components/SettingsPanel";
type View = "dashboard" | "settings";
const navItems = [ const navItems = [
{ icon: Map, label: "World" }, { icon: Map, label: "World" },
@@ -17,24 +21,34 @@ const navItems = [
{ icon: Dice5, label: "Dice" }, { icon: Dice5, label: "Dice" },
{ icon: ScrollText, label: "Session" }, { icon: ScrollText, label: "Session" },
{ icon: Volume2, label: "Sound" }, { icon: Volume2, label: "Sound" },
{ icon: Settings, label: "Settings" },
]; ];
export default function App() { export default function App() {
const [view, setView] = useState<View>("dashboard");
const [activeNav, setActiveNav] = useState(0);
return ( return (
<div className="flex h-screen w-screen overflow-hidden bg-[var(--color-bg-deep)]"> <div className="flex h-screen w-screen overflow-hidden bg-[var(--color-bg-deep)]">
{/* Left Rail */} {/* Left Rail */}
<nav className="flex flex-col items-center w-14 bg-[var(--color-bg-surface)] border-r border-[var(--color-border-subtle)] py-3 gap-1 shrink-0"> <nav className="flex flex-col items-center w-14 bg-[var(--color-bg-surface)] border-r border-[var(--color-border-subtle)] py-3 gap-1 shrink-0">
{/* App Logo */} {/* App Logo */}
<div className="w-8 h-8 rounded-lg bg-[var(--color-gold-bright)] flex items-center justify-center mb-4 text-[var(--color-bg-deep)] font-heading font-bold text-lg"> <button
onClick={() => setView("dashboard")}
className="w-8 h-8 rounded-lg bg-[var(--color-gold-bright)] flex items-center justify-center mb-4 text-[var(--color-bg-deep)] font-heading font-bold text-lg cursor-pointer"
title="DM-Pal"
>
</div> </button>
{navItems.map((item, i) => ( {navItems.map((item, i) => (
<button <button
key={item.label} key={item.label}
onClick={() => {
setActiveNav(i);
setView("dashboard");
}}
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors cursor-pointer ${ className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors cursor-pointer ${
i === 0 i === activeNav && view === "dashboard"
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]" ? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
: "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]" : "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
}`} }`}
@@ -49,7 +63,12 @@ export default function App() {
{/* Settings at bottom */} {/* Settings at bottom */}
<button <button
className="w-10 h-10 rounded-lg flex items-center justify-center text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)] transition-colors cursor-pointer" onClick={() => setView(view === "settings" ? "dashboard" : "settings")}
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors cursor-pointer ${
view === "settings"
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
: "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
}`}
title="Settings" title="Settings"
> >
<Settings size={18} /> <Settings size={18} />
@@ -59,7 +78,10 @@ export default function App() {
{/* Main Content Area */} {/* Main Content Area */}
<div className="flex flex-col flex-1 overflow-hidden"> <div className="flex flex-col flex-1 overflow-hidden">
{/* Title Bar */} {/* Title Bar */}
<header className="flex items-center h-10 px-4 bg-[var(--color-bg-surface)] border-b border-[var(--color-border-subtle)] shrink-0" data-tauri-drag-region> <header
className="flex items-center h-10 px-4 bg-[var(--color-bg-surface)] border-b border-[var(--color-border-subtle)] shrink-0"
data-tauri-drag-region
>
<span className="font-heading text-[var(--color-gold-bright)] text-base font-bold tracking-wider"> <span className="font-heading text-[var(--color-gold-bright)] text-base font-bold tracking-wider">
DM-Pal DM-Pal
</span> </span>
@@ -69,9 +91,9 @@ export default function App() {
</div> </div>
</header> </header>
{/* Dashboard */} {/* Main Content */}
<main className="flex-1 overflow-auto"> <main className="flex-1 overflow-auto">
<Dashboard /> {view === "dashboard" ? <Dashboard /> : <SettingsPanel />}
</main> </main>
</div> </div>
</div> </div>
+19 -29
View File
@@ -4,14 +4,15 @@ import {
Swords, Swords,
ScrollText, ScrollText,
Dice5, Dice5,
Sparkles,
Timer, Timer,
GitBranch,
Volume2, Volume2,
Calendar, Calendar,
Sparkles,
} from "lucide-react"; } from "lucide-react";
import { BentoCard } from "./BentoCard"; import { BentoCard } from "./BentoCard";
import { Greet } from "./Greet"; import { NpcGenerator } from "./NpcGenerator";
import { DiceRoller } from "./DiceRoller";
import { SessionLogger } from "./SessionLogger";
export function Dashboard() { export function Dashboard() {
return ( return (
@@ -19,22 +20,22 @@ export function Dashboard() {
{/* World Map — 2×2 */} {/* World Map — 2×2 */}
<BentoCard title="World Map" icon={<Map size={16} />} span="col-span-2 row-span-2"> <BentoCard title="World Map" icon={<Map size={16} />} span="col-span-2 row-span-2">
<div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm"> <div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm">
Map canvas placeholder <div className="text-center">
<Map size={48} className="mx-auto mb-2 opacity-30" />
<p>Map canvas coming soon</p>
<p className="text-xs mt-1">react-konva integration pending</p>
</div>
</div> </div>
</BentoCard> </BentoCard>
{/* NPC Sheet — 1×1 */} {/* NPC Sheet — 1×1 */}
<BentoCard title="NPC Sheet" icon={<User size={16} />} span="col-span-1 row-span-1"> <BentoCard title="NPC Sheet" icon={<User size={16} />} span="col-span-1 row-span-1">
<div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm"> <NpcGenerator />
NPC generator placeholder
</div>
</BentoCard> </BentoCard>
{/* Dice Roller — 1×1 */} {/* Dice Roller — 1×1 */}
<BentoCard title="Dice Roller" icon={<Dice5 size={16} />} span="col-span-1 row-span-1"> <BentoCard title="Dice Roller" icon={<Dice5 size={16} />} span="col-span-1 row-span-1">
<div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm"> <DiceRoller />
Dice roller placeholder
</div>
</BentoCard> </BentoCard>
{/* Encounter Builder — 1×1 */} {/* Encounter Builder — 1×1 */}
@@ -44,7 +45,7 @@ export function Dashboard() {
span="col-span-1 row-span-1" span="col-span-1 row-span-1"
> >
<div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm"> <div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm">
Encounter builder placeholder Encounter builder coming soon
</div> </div>
</BentoCard> </BentoCard>
@@ -54,26 +55,24 @@ export function Dashboard() {
icon={<ScrollText size={16} />} icon={<ScrollText size={16} />}
span="col-span-2 row-span-1" span="col-span-2 row-span-1"
> >
<div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm"> <SessionLogger />
Session log placeholder
</div>
</BentoCard> </BentoCard>
{/* Quest Designer — 2×1 */} {/* Quest Designer — 2×1 */}
<BentoCard <BentoCard
title="Quest Designer" title="Quest Designer"
icon={<GitBranch size={16} />} icon={<Sparkles size={16} />}
span="col-span-2 row-span-1" span="col-span-2 row-span-1"
> >
<div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm"> <div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm">
Quest designer placeholder Quest flowchart coming soon
</div> </div>
</BentoCard> </BentoCard>
{/* Initiative Tracker — 1×1 */} {/* Initiative Tracker — 1×1 */}
<BentoCard title="Initiative" icon={<Timer size={16} />} span="col-span-1 row-span-1"> <BentoCard title="Initiative" icon={<Timer size={16} />} span="col-span-1 row-span-1">
<div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm"> <div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm">
Initiative tracker placeholder Combat tracker coming soon
</div> </div>
</BentoCard> </BentoCard>
@@ -84,19 +83,10 @@ export function Dashboard() {
span="col-span-1 row-span-1" span="col-span-1 row-span-1"
> >
<div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm"> <div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm">
Random tables placeholder Random tables coming soon
</div> </div>
</BentoCard> </BentoCard>
{/* Greeting / LLM Test — spans bottom area */}
<BentoCard
title="DM-Pal"
icon={<Volume2 size={16} />}
span="col-span-2 row-span-1"
>
<Greet />
</BentoCard>
{/* Calendar — 1×1 */} {/* Calendar — 1×1 */}
<BentoCard <BentoCard
title="Calendar" title="Calendar"
@@ -104,7 +94,7 @@ export function Dashboard() {
span="col-span-1 row-span-1" span="col-span-1 row-span-1"
> >
<div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm"> <div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm">
Calendar placeholder Calendar coming soon
</div> </div>
</BentoCard> </BentoCard>
@@ -115,7 +105,7 @@ export function Dashboard() {
span="col-span-1 row-span-1" span="col-span-1 row-span-1"
> >
<div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm"> <div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm">
Soundboard placeholder Soundboard coming soon
</div> </div>
</BentoCard> </BentoCard>
</div> </div>
+117
View File
@@ -0,0 +1,117 @@
import { useState, useCallback } from "react";
interface DieResult {
notation: string;
rolls: number[];
total: number;
}
const PRESETS = ["d4", "d6", "d8", "d10", "d12", "d20", "d100"];
function rollDie(sides: number): number {
return Math.floor(Math.random() * sides) + 1;
}
function parseNotation(notation: string): { count: number; sides: number; modifier: number } | null {
const match = notation.trim().toLowerCase().match(/^(\d+)?d(\d+)([+-]\d+)?$/);
if (!match) return null;
return {
count: parseInt(match[1] || "1"),
sides: parseInt(match[2]),
modifier: parseInt(match[3] || "0"),
};
}
export function DiceRoller() {
const [input, setInput] = useState("1d20");
const [results, setResults] = useState<DieResult[]>([]);
const [lastTotal, setLastTotal] = useState<number | null>(null);
const roll = useCallback(() => {
const parsed = parseNotation(input);
if (!parsed || parsed.sides < 2 || parsed.count > 100) return;
const rolls = Array.from({ length: parsed.count }, () => rollDie(parsed.sides));
const sum = rolls.reduce((a, b) => a + b, 0) + parsed.modifier;
const result: DieResult = {
notation: input.trim(),
rolls,
total: sum,
};
setResults((prev) => [result, ...prev].slice(0, 50));
setLastTotal(sum);
}, [input]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") roll();
};
return (
<div className="flex flex-col gap-3 h-full">
{/* Big result display */}
<div className="flex items-center justify-center py-3">
<span className="font-mono text-3xl font-bold text-[var(--color-gold-bright)]">
{lastTotal !== null ? lastTotal : "—"}
</span>
</div>
{/* Input row */}
<div className="flex gap-2">
<input
className="flex-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm font-mono"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="2d6+3"
/>
<button
onClick={roll}
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer"
>
Roll
</button>
</div>
{/* Quick dice */}
<div className="flex flex-wrap gap-1.5">
{PRESETS.map((d) => (
<button
key={d}
onClick={() => {
const parsed = parseNotation(`1${d}`);
if (!parsed) return;
const rolls = [rollDie(parsed.sides)];
const total = rolls[0] + parsed.modifier;
setResults((prev) => [{ notation: `1${d}`, rolls, total }, ...prev].slice(0, 50));
setLastTotal(total);
}}
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1 text-xs text-[var(--color-text-secondary)] hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer font-mono"
>
{d}
</button>
))}
</div>
{/* History */}
<div className="flex-1 overflow-y-auto">
<div className="flex flex-col gap-1">
{results.map((r, i) => (
<div
key={i}
className="flex items-center justify-between rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] px-3 py-1.5"
>
<span className="font-mono text-xs text-[var(--color-text-secondary)]">
{r.notation}
{r.rolls.length > 1 && ` [${r.rolls.join(", ")}]`}
</span>
<span className="font-mono text-sm font-bold text-[var(--color-gold-bright)]">
{r.total}
</span>
</div>
))}
</div>
</div>
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
interface NpcResponse {
bio: string;
personality: string[];
goals: string[];
}
const RACES = ["Human", "Elf", "Dwarf", "Halfling", "Orc", "Tiefling", "Dragonborn", "Gnome"];
const ALIGNMENTS = ["Lawful Good", "Neutral Good", "Chaotic Good", "Lawful Neutral", "True Neutral", "Chaotic Neutral", "Lawful Evil", "Neutral Evil", "Chaotic Evil"];
export function NpcGenerator() {
const [race, setRace] = useState("Dwarf");
const [charClass, setCharClass] = useState("Artisan");
const [alignment, setAlignment] = useState("Chaotic Good");
const [name, setName] = useState("");
const [npc, setNpc] = useState<NpcResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function generate() {
setLoading(true);
setError("");
setNpc(null);
try {
const prompt = `Create a detailed NPC for a fantasy RPG:
${name ? `Name: ${name}` : "Name: (generate one)"}
Race: ${race}
Class: ${charClass}
Alignment: ${alignment}
Provide the response as a JSON object with exactly these keys:
- "bio": a 2-3 sentence backstory
- "personality": an array of 3-5 personality traits
- "goals": an array of 2-3 goals or motivations`;
const result = await invoke<string>("generate", {
req: { prompt, system: "You are a creative D&D dungeon master. Always respond with valid JSON only.", temperature: 0.8, max_tokens: 512 },
});
// Try to parse JSON from the response
const jsonMatch = result.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]) as NpcResponse;
setNpc(parsed);
} else {
setError("LLM did not return valid JSON. Raw response:\n" + result);
}
} catch (e) {
setError(String(e));
}
setLoading(false);
}
return (
<div className="flex flex-col gap-3 h-full overflow-y-auto">
{/* Controls */}
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Name</label>
<input
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Random"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Race</label>
<select
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm cursor-pointer"
value={race}
onChange={(e) => setRace(e.target.value)}
>
{RACES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Class</label>
<input
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
value={charClass}
onChange={(e) => setCharClass(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Alignment</label>
<select
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm cursor-pointer"
value={alignment}
onChange={(e) => setAlignment(e.target.value)}
>
{ALIGNMENTS.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</div>
</div>
<button
onClick={generate}
disabled={loading}
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
>
{loading ? "✨ Generating…" : "✨ Generate NPC"}
</button>
{/* Result */}
{error && (
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-3 text-[var(--color-danger)] text-xs overflow-x-auto">
{error}
</div>
)}
{npc && (
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 flex flex-col gap-2">
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">
{name || "NPC"}
</h3>
<p className="text-[var(--color-text-primary)] text-sm leading-relaxed">{npc.bio}</p>
<div>
<span className="text-[var(--color-text-secondary)] text-xs font-medium">Personality:</span>
<div className="flex flex-wrap gap-1 mt-1">
{npc.personality?.map((p, i) => (
<span key={i} className="rounded-full bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] px-2 py-0.5 text-xs">
{p}
</span>
))}
</div>
</div>
<div>
<span className="text-[var(--color-text-secondary)] text-xs font-medium">Goals:</span>
<ul className="list-disc list-inside text-[var(--color-text-primary)] text-xs mt-1">
{npc.goals?.map((g, i) => <li key={i}>{g}</li>)}
</ul>
</div>
</div>
)}
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
export function SessionLogger() {
const [notes, setNotes] = useState("");
const [summary, setSummary] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [entries, setEntries] = useState<{ text: string; time: string }[]>([]);
function addEntry() {
if (!notes.trim()) return;
setEntries((prev) => [
{ text: notes.trim(), time: new Date().toLocaleTimeString() },
...prev,
]);
setNotes("");
}
async function summarize() {
if (entries.length === 0) return;
setLoading(true);
setError("");
try {
const sessionText = entries
.map((e) => `[${e.time}] ${e.text}`)
.join("\n");
const result = await invoke<string>("generate", {
req: {
prompt: `Summarize the following D&D session notes and suggest 2-3 hooks for the next session:\n\n${sessionText}`,
system: "You are a helpful D&D session summarizer. Be concise and creative.",
temperature: 0.7,
max_tokens: 512,
},
});
setSummary(result);
} catch (e) {
setError(String(e));
}
setLoading(false);
}
return (
<div className="flex gap-3 h-full">
{/* Notes column */}
<div className="flex flex-col gap-2 flex-1">
<textarea
className="flex-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm resize-none"
value={notes}
onChange={(e) => setNotes(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
addEntry();
}
}}
placeholder="Type a session note, press Enter to add…"
/>
<div className="flex gap-2">
<button
onClick={addEntry}
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-secondary)] px-3 py-1.5 text-xs hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer"
>
Add Note
</button>
<button
onClick={summarize}
disabled={loading || entries.length === 0}
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-3 py-1.5 text-xs font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
>
{loading ? "Summarizing…" : "✨ AI Summary"}
</button>
</div>
<div className="flex-1 overflow-y-auto">
{entries.map((e, i) => (
<div key={i} className="flex gap-2 py-1 border-b border-[var(--color-border-subtle)]">
<span className="text-[var(--color-text-dim)] text-xs font-mono shrink-0">
{e.time}
</span>
<span className="text-[var(--color-text-primary)] text-xs">{e.text}</span>
</div>
))}
</div>
</div>
{/* AI Summary column */}
<div className="flex-1 flex flex-col">
<h3 className="text-xs font-medium text-[var(--color-text-secondary)] mb-2">AI Summary</h3>
{error && (
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-2 text-[var(--color-danger)] text-xs mb-2">
{error}
</div>
)}
<div className="flex-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 text-[var(--color-text-primary)] text-sm overflow-y-auto">
{summary || (
<span className="text-[var(--color-text-dim)] italic">
Add session notes and click "AI Summary" to generate a recap and next-session hooks.
</span>
)}
</div>
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
interface LlmConfig {
api_url: string;
api_key: string;
model: string;
temperature: number;
max_tokens: number;
top_p: number;
}
export function SettingsPanel() {
const [config, setConfig] = useState<LlmConfig | null>(null);
const [loading, setLoading] = useState(false);
const [saved, setSaved] = useState(false);
async function loadConfig() {
try {
const c = await invoke<LlmConfig>("get_llm_config");
setConfig(c);
} catch (e) {
console.error("Failed to load config:", e);
}
}
async function saveConfig() {
if (!config) return;
setLoading(true);
try {
await invoke("set_llm_config", { config });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (e) {
console.error("Failed to save config:", e);
}
setLoading(false);
}
if (!config) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4">
<p className="text-[var(--color-text-secondary)] text-sm">
Configure your LLM connection
</p>
<button
onClick={loadConfig}
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer"
>
Load Settings
</button>
</div>
);
}
return (
<div className="flex flex-col gap-4 text-sm">
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
API URL
</label>
<input
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)]"
value={config.api_url}
onChange={(e) => setConfig({ ...config, api_url: e.target.value })}
placeholder="http://localhost:11434"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
API Key (optional)
</label>
<input
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)]"
type="password"
value={config.api_key}
onChange={(e) => setConfig({ ...config, api_key: e.target.value })}
placeholder="sk-... (leave blank for local)"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
Model
</label>
<input
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)]"
value={config.model}
onChange={(e) => setConfig({ ...config, model: e.target.value })}
placeholder="llama3.2"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
Temperature: {config.temperature}
</label>
<input
type="range"
min="0"
max="2"
step="0.1"
value={config.temperature}
onChange={(e) =>
setConfig({ ...config, temperature: parseFloat(e.target.value) })
}
className="accent-[var(--color-gold-bright)]"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
Max Tokens: {config.max_tokens}
</label>
<input
type="range"
min="64"
max="4096"
step="64"
value={config.max_tokens}
onChange={(e) =>
setConfig({ ...config, max_tokens: parseInt(e.target.value) })
}
className="accent-[var(--color-gold-bright)]"
/>
</div>
</div>
<button
onClick={saveConfig}
disabled={loading}
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
>
{saved ? "✓ Saved" : loading ? "Saving…" : "Save Settings"}
</button>
</div>
);
}