working through the plan + UI/ UX

This commit is contained in:
itsamejms
2026-07-12 22:13:43 +01:00
parent 5b256242be
commit a24f3615e0
38 changed files with 5295 additions and 328 deletions
@@ -0,0 +1,59 @@
use crate::generations::{Generation, GenerationStore, GenerationSummary};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct GenerationAddRequest {
pub kind: String,
pub title: String,
pub data: String,
/// Optional input/source (theme, name, level, etc.) for the history list.
pub source: Option<String>,
}
#[tauri::command]
pub fn generation_add(
state: tauri::State<'_, crate::llm::AppState>,
req: GenerationAddRequest,
) -> Result<i64, String> {
state
.gen
.add(&req.kind, &req.title, &req.data, req.source.as_deref())
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn generation_list(
state: tauri::State<'_, crate::llm::AppState>,
kind: Option<String>,
) -> Result<Vec<GenerationSummary>, String> {
state.gen.list(kind.as_deref()).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn generation_get(
state: tauri::State<'_, crate::llm::AppState>,
id: i64,
) -> Result<Option<Generation>, String> {
state.gen.get(id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn generation_delete(
state: tauri::State<'_, crate::llm::AppState>,
id: Option<i64>,
) -> Result<usize, String> {
state.gen.delete(id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn generation_counts(
state: tauri::State<'_, crate::llm::AppState>,
) -> Result<Vec<(String, i64)>, String> {
state.gen.counts().map_err(|e| e.to_string())
}
// Reference `GenerationStore` so the type is considered used by the module
// (the public functions go through `state.gen`, which already uses it,
// but this silences a future-proofing warning if all wrappers get removed).
#[allow(dead_code)]
fn _store_type_used(_: &GenerationStore) {}
+203
View File
@@ -0,0 +1,203 @@
use crate::llm::AppState;
use serde::Deserialize;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use tauri::Manager;
// ponytail: DefaultHasher is fine for a cache filename — not crypto, just a stable key.
#[derive(Debug, Deserialize)]
pub struct ImageRequest {
pub prompt: String,
/// Override the configured image model for this call.
pub model: Option<String>,
}
/// One line of Ollama's NDJSON image-generation response.
#[derive(Debug, Deserialize)]
struct OllamaImageLine {
#[serde(default)]
done: bool,
#[serde(default)]
image: Option<String>,
}
/// Generate (or fetch from disk cache) an image for `prompt` via the configured
/// Ollama image model. Returns a `data:image/png;base64,...` URL ready for `<img src>`.
///
/// Ollama image models are macOS-only today; on other platforms we return an error
/// so the front-end can fall back to a placeholder instead of a confusing timeout.
#[tauri::command]
pub async fn generate_image(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
req: ImageRequest,
) -> Result<String, String> {
if cfg!(not(target_os = "macos")) {
return Err("image generation is macOS-only via Ollama (for now)".into());
}
let config = state.config.lock().map_err(|e| e.to_string())?.clone();
let model = req.model.unwrap_or(config.image_model.clone());
let cache_dir = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("dm-toolkit")
.join("images");
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
// Stable cache key over model + prompt. Regenerating with a tweaked prompt
// produces a new file; identical prompt reuses the cached PNG.
let mut hasher = DefaultHasher::new();
model.hash(&mut hasher);
req.prompt.hash(&mut hasher);
let cache_path = cache_dir.join(format!("{:016x}.png", hasher.finish()));
// Cache hit: return the stored PNG without calling the model.
if cache_path.exists() {
let bytes = std::fs::read(&cache_path).map_err(|e| e.to_string())?;
return Ok(data_url(&bytes));
}
let client = reqwest::Client::new();
let url = format!("{}/api/generate", config.api_url.trim_end_matches('/'));
let body = serde_json::json!({
"model": model,
"prompt": req.prompt,
"stream": false,
});
let res = client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| format!("image request failed: {e}"))?;
if !res.status().is_success() {
let status = res.status();
let text = res.text().await.unwrap_or_default();
return Err(format!("image error {status}: {text}"));
}
// Ollama returns newline-delimited JSON even with stream:false for image models;
// the final line with `done: true` carries the singular `image` base64 field.
let body_text = res.text().await.map_err(|e| format!("image read error: {e}"))?;
let mut png_b64: Option<String> = None;
for line in body_text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(parsed) = serde_json::from_str::<OllamaImageLine>(line) {
if let Some(b64) = parsed.image {
png_b64 = Some(b64);
if parsed.done {
break;
}
}
}
}
let b64 = png_b64.ok_or_else(|| "no image data in Ollama response".to_string())?;
// Decode + persist to cache, then return a data URL.
let png_bytes = base64_decode(&b64)?;
std::fs::write(&cache_path, &png_bytes).map_err(|e| e.to_string())?;
Ok(data_url(&png_bytes))
}
fn data_url(png: &[u8]) -> String {
// ponytail: no base64 crate dep — a 30-line encoder.
let table: [u8; 64] = *b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity((png.len() + 2) / 3 * 4);
let mut chunks = png.chunks_exact(3);
for c in &mut chunks {
let n = (c[0] as usize) << 16 | (c[1] as usize) << 8 | c[2] as usize;
out.push(table[(n >> 18) & 63] as char);
out.push(table[(n >> 12) & 63] as char);
out.push(table[(n >> 6) & 63] as char);
out.push(table[n & 63] as char);
}
let rem = chunks.remainder();
match rem.len() {
1 => {
let n = (rem[0] as usize) << 16;
out.push(table[(n >> 18) & 63] as char);
out.push(table[(n >> 12) & 63] as char);
out.push('=');
out.push('=');
}
2 => {
let n = (rem[0] as usize) << 16 | (rem[1] as usize) << 8;
out.push(table[(n >> 18) & 63] as char);
out.push(table[(n >> 12) & 63] as char);
out.push(table[(n >> 6) & 63] as char);
out.push('=');
}
_ => {}
}
format!("data:image/png;base64,{out}")
}
/// Minimal standard base64 decoder (no extra dependency).
fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
fn val(c: u8) -> Option<u8> {
match c {
b'A'..=b'Z' => Some(c - b'A'),
b'a'..=b'z' => Some(c - b'a' + 26),
b'0'..=b'9' => Some(c - b'0' + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
let input = input.trim();
let bytes: Vec<u8> = input.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
if bytes.is_empty() {
return Ok(Vec::new());
}
let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
let mut buf: u32 = 0;
let mut bits: u32 = 0;
for &b in &bytes {
if b == b'=' {
break;
}
let v = val(b).ok_or_else(|| format!("invalid base64 char: {b}"))? as u32;
buf = (buf << 6) | v;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((buf >> bits) as u8);
buf &= (1 << bits) - 1;
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_base64() {
let data = b"hello world \x00\xff\x10";
let url = data_url(data);
let b64 = url.strip_prefix("data:image/png;base64,").unwrap();
let decoded = base64_decode(b64).unwrap();
assert_eq!(decoded, data);
}
#[test]
fn cache_key_is_stable() {
// sanity: same inputs → same filename shape (16 hex digits)
let mut h = DefaultHasher::new();
"x/flux2-klein:4b".hash(&mut h);
"prompt".hash(&mut h);
let s = format!("{:016x}", h.finish());
assert_eq!(s.len(), 16);
}
}
+42 -5
View File
@@ -16,6 +16,10 @@ pub async fn generate(state: tauri::State<'_, AppState>, req: GenerateRequest) -
let temperature = req.temperature.unwrap_or(config.temperature);
let max_tokens = req.max_tokens.unwrap_or(config.max_tokens);
// Inject retrieved lore into the system prompt so every generator stays
// consistent with the user's world bible. Shared path = every caller.
let messages = inject_lore(&state, &client, &config, messages, &req.rag_query).await;
if llm::is_ollama(&config.api_url) {
call_ollama(&client, &config, &messages, temperature, max_tokens).await
} else {
@@ -33,12 +37,13 @@ pub async fn generate_stream(
) -> 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);
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);
let messages = inject_lore(&state, &client, &config, messages, &req.rag_query).await;
tauri::async_runtime::spawn(async move {
// 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) {
@@ -76,6 +81,38 @@ pub fn set_llm_config(state: tauri::State<'_, AppState>, config: crate::llm::Llm
Ok(())
}
// ─── Lore RAG injection (shared by generate + generate_stream) ─
/// Best-effort: if `rag_query` is set, retrieve top lore chunks and fold them
/// into the system message so generation is grounded in the user's world bible.
/// Silently degrades to the original messages on any retrieval error.
async fn inject_lore(
state: &tauri::State<'_, AppState>,
_client: &reqwest::Client,
config: &crate::llm::LlmConfig,
mut messages: Vec<ChatMessage>,
rag_query: &Option<String>,
) -> Vec<ChatMessage> {
let Some(query) = rag_query.as_ref() else { return messages; };
if query.trim().is_empty() { return messages; }
let Ok(hits) = state.rag.search(config, query, 4).await else { return messages; };
if hits.is_empty() { return messages; }
let lore = hits.iter().map(|(t, _s, _sc)| t.clone()).collect::<Vec<_>>().join("\n\n---\n\n");
let injection = format!(
"Relevant lore from the campaign bible — stay consistent with it:\n\n{lore}"
);
if let Some(first) = messages.first_mut() {
if first.role == "system" {
first.content = format!("{}\n\n{}", first.content, injection);
return messages;
}
}
messages.insert(0, ChatMessage { role: "system".into(), content: injection });
messages
}
// ─── Ollama API ───────────────────────────────────────────────
async fn call_ollama(
+4 -1
View File
@@ -1 +1,4 @@
pub mod llm_commands;
pub mod llm_commands;
pub mod image_commands;
pub mod rag_commands;
pub mod generation_commands;
+58
View File
@@ -0,0 +1,58 @@
use crate::llm::AppState;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct RagAddRequest {
pub source: String,
pub text: String,
}
#[derive(Debug, Serialize)]
pub struct RagHit {
pub text: String,
pub source: String,
pub score: f32,
}
#[derive(Debug, Serialize)]
pub struct RagSource {
pub source: String,
pub chunks: i64,
}
#[tauri::command]
pub async fn rag_add(state: tauri::State<'_, AppState>, req: RagAddRequest) -> Result<usize, String> {
let config = state.config.lock().map_err(|e| e.to_string())?.clone();
state.rag.add_document(&config, &req.source, &req.text).await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn rag_search(
state: tauri::State<'_, AppState>,
query: String,
top_k: Option<usize>,
) -> Result<Vec<RagHit>, String> {
let config = state.config.lock().map_err(|e| e.to_string())?.clone();
let hits = state
.rag
.search(&config, &query, top_k.unwrap_or(4))
.await
.map_err(|e| e.to_string())?;
Ok(hits.into_iter().map(|(text, source, score)| RagHit { text, source, score }).collect())
}
#[tauri::command]
pub fn rag_list(state: tauri::State<'_, AppState>) -> Result<Vec<RagSource>, String> {
state
.rag
.list_sources()
.map_err(|e| e.to_string())?
.into_iter()
.map(|(source, chunks)| Ok(RagSource { source, chunks }))
.collect()
}
#[tauri::command]
pub fn rag_clear(state: tauri::State<'_, AppState>, source: Option<String>) -> Result<(), String> {
state.rag.clear(source.as_deref()).map_err(|e| e.to_string())
}