building out the components, updating the UI in overview and adding a little sqlite viewer

This commit is contained in:
itsamejms
2026-07-17 09:31:51 +01:00
parent 82f2dcf3df
commit b89a39ec9d
30 changed files with 2664 additions and 635 deletions
+136 -2
View File
@@ -752,6 +752,7 @@ name = "dm-pal"
version = "0.1.0"
dependencies = [
"anyhow",
"futures-util",
"log",
"reqwest 0.12.28",
"rusqlite",
@@ -759,6 +760,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-log",
"tauri-plugin-store",
@@ -2831,6 +2833,7 @@ dependencies = [
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
@@ -2852,12 +2855,14 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
]
@@ -2891,10 +2896,34 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"wasm-streams 0.5.0",
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -3781,6 +3810,24 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
@@ -4607,6 +4654,19 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"
@@ -4945,6 +5005,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -4978,13 +5047,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
@@ -5015,6 +5101,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -5027,6 +5119,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -5039,12 +5137,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -5057,6 +5167,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -5069,6 +5185,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -5081,6 +5203,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -5093,6 +5221,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.5.40"
+3 -1
View File
@@ -24,6 +24,8 @@ tauri = { version = "2.11.3", features = [] }
tauri-plugin-log = "2"
tauri-plugin-store = "2"
tauri-plugin-fs = "2"
reqwest = { version = "0.12", features = ["json"] }
tauri-plugin-dialog = "2"
reqwest = { version = "0.12", features = ["json", "stream"] }
futures-util = "0.3"
tokio = { version = "1", features = ["full"] }
rusqlite = { version = "0.31", features = ["bundled"] }
+3 -1
View File
@@ -19,6 +19,8 @@
"fs:allow-rename",
"fs:allow-read-dir",
"fs:allow-appdata-read-recursive",
"fs:allow-appdata-write-recursive"
"fs:allow-appdata-write-recursive",
"dialog:default",
"dialog:allow-open"
]
}
+200
View File
@@ -0,0 +1,200 @@
use crate::llm::AppState;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tauri::Manager;
use tauri_plugin_store::StoreExt;
// ponytail: the data-dir pref store + key are defined in lib.rs; mirror them
// here so this module is self-contained for reads/writes.
const PREFS_STORE: &str = "dm-pal-prefs.json";
const DATA_DIR_KEY: &str = "dataDir";
#[derive(Debug, Serialize)]
pub struct DataDirInfo {
/// Absolute path currently in use (may differ from `configured` if it was
/// unset/fell back to the default).
pub current: String,
/// Absolute path that will be used after the next restart, if different.
pub configured: Option<String>,
/// The OS-default location ($APPDATA/dm-toolkit).
pub default: String,
}
/// Return the current data dir + the configured (pending-restart) value, so
/// Settings can show "using X — restart to apply Y".
#[tauri::command]
pub fn get_data_dir(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
) -> Result<DataDirInfo, String> {
let default = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("dm-toolkit");
let configured = app
.store(PREFS_STORE)
.map_err(|e| e.to_string())?
.get(DATA_DIR_KEY)
.and_then(|v| v.as_str().map(|s| s.to_string()));
Ok(DataDirInfo {
current: state.data_dir.display().to_string(),
configured,
default: default.display().to_string(),
})
}
/// Subdirs/files that make up the campaign data and must move together.
const DATA_RELATIVE: &[&str] = &["lore/lore.db", "generations.db", "images"];
/// Recursively copy `src` to `dst`, but never overwrite an existing file —
/// a re-locate must not clobber data already at the destination, and never
/// deletes the source (the old location stays as a backup until the DM deletes it).
/// When both src and dst are directories, recurse so new files inside an
/// existing destination tree still get copied.
fn copy_if_absent(src: &Path, dst: &Path) -> std::io::Result<()> {
if !src.exists() {
return Ok(());
}
if src.is_dir() {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
copy_if_absent(&entry.path(), &dst.join(entry.file_name()))?;
}
return Ok(());
}
// leaf file: skip if the destination already has it (no overwrite).
if dst.exists() {
return Ok(());
}
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(src, dst)?;
Ok(())
}
/// Set the data dir to `new_dir`: create it, copy existing campaign data
/// (lore.db, generations.db, images/) into it, and persist the preference so
/// the next restart uses it. The currently-open SQLite connections stay on the
/// old paths until restart — the caller must prompt the user to relaunch.
#[tauri::command]
pub fn set_data_dir(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
new_dir: String,
) -> Result<DataDirInfo, String> {
let new_path = PathBuf::from(new_dir.trim());
if !new_path.is_absolute() {
return Err("Choose an absolute folder path".into());
}
std::fs::create_dir_all(&new_path).map_err(|e| format!("can't create dir: {e}"))?;
// Copy existing data into the new location (no overwrite; old data stays put).
for rel in DATA_RELATIVE {
let src = state.data_dir.join(rel);
let dst = new_path.join(rel);
if src.exists() {
copy_if_absent(&src, &dst).map_err(|e| format!("migrate {rel}: {e}"))?;
}
}
// Persist the preference for the next startup.
let store = app.store(PREFS_STORE).map_err(|e| e.to_string())?;
store.set(DATA_DIR_KEY, serde_json::Value::String(new_path.display().to_string()));
store.save().map_err(|e| e.to_string())?;
Ok(DataDirInfo {
current: state.data_dir.display().to_string(),
configured: Some(new_path.display().to_string()),
default: app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("dm-toolkit")
.display()
.to_string(),
})
}
/// Clear the preference so the next restart uses the OS default. Does not
/// delete any files — the DM re-points, then restarts.
#[tauri::command]
pub fn reset_data_dir(app: tauri::AppHandle) -> Result<(), String> {
let store = app.store(PREFS_STORE).map_err(|e| e.to_string())?;
store.delete(DATA_DIR_KEY);
store.save().map_err(|e| e.to_string())?;
Ok(())
}
// ─── SQLite viewer (read-only) ─────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct SqlQueryReq {
/// "lore" or "generations".
pub db: String,
pub sql: String,
}
#[derive(Debug, Serialize)]
pub struct SqlQueryResult {
pub columns: Vec<String>,
pub rows: Vec<Vec<serde_json::Value>>,
}
/// Run a read-only SQL query against one of the two SQLite DBs so a DM can
/// inspect the backend from Settings → Advanced. Only SELECT/PRAGMA/WITH.
#[tauri::command]
pub fn sql_query(state: tauri::State<'_, AppState>, req: SqlQueryReq) -> Result<SqlQueryResult, String> {
let res = match req.db.as_str() {
"lore" => state.rag.raw_query(&req.sql),
"generations" => state.gen.raw_query(&req.sql),
other => Err(anyhow::anyhow!("unknown db: {other}")),
}
.map_err(|e| e.to_string())?;
Ok(SqlQueryResult { columns: res.0, rows: res.1 })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn copy_if_absent_does_not_overwrite() {
let tmp = std::env::temp_dir().join(format!("dm-pal-data-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let src = tmp.join("src");
let dst = tmp.join("dst");
std::fs::create_dir_all(src.join("images")).unwrap();
std::fs::write(src.join("images/a.png"), b"png").unwrap();
std::fs::create_dir_all(src.join("lore")).unwrap();
std::fs::write(src.join("lore/lore.db"), b"db").unwrap();
// first copy creates dst with the files
copy_if_absent(&src, &dst).unwrap();
assert!(dst.join("images/a.png").exists());
assert!(dst.join("lore/lore.db").exists());
// mutate src; second copy must NOT overwrite existing dst files
std::fs::write(src.join("images/a.png"), b"changed").unwrap();
copy_if_absent(&src, &dst).unwrap();
assert_eq!(std::fs::read(dst.join("images/a.png")).unwrap(), b"png");
// a new file in src does get copied (absent in dst)
std::fs::write(src.join("images/b.png"), b"png2").unwrap();
copy_if_absent(&src, &dst).unwrap();
assert!(dst.join("images/b.png").exists());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn copy_if_absent_missing_src_is_noop() {
let tmp = std::env::temp_dir().join(format!("dm-pal-data-test2-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
copy_if_absent(&tmp.join("nope"), &tmp.join("dst")).unwrap();
assert!(!tmp.join("dst").exists());
let _ = std::fs::remove_dir_all(&tmp);
}
}
+212 -42
View File
@@ -1,8 +1,9 @@
use crate::llm::AppState;
use serde::Deserialize;
use futures_util::StreamExt;
use serde::{Deserialize, Serialize};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use tauri::Manager;
use tauri::ipc::Channel;
// ponytail: DefaultHasher is fine for a cache filename — not crypto, just a stable key.
@@ -14,14 +15,57 @@ pub struct ImageRequest {
}
/// One line of Ollama's NDJSON image-generation response.
#[derive(Debug, Deserialize)]
/// `step`/`total` carry progress; the final `done: true` line carries the
/// singular `image` base64 field. All fields optional per-line.
#[derive(Debug, Default, Deserialize)]
struct OllamaImageLine {
#[serde(default)]
done: bool,
#[serde(default)]
step: Option<u32>,
#[serde(default)]
total: Option<u32>,
#[serde(default)]
image: Option<String>,
}
/// Parsed view of one NDJSON line used by both the streaming and the
/// buffered paths. Extracted so the parsing logic is unit-testable without
/// touching the network.
#[derive(Debug, PartialEq)]
struct ImageProgress {
done: bool,
step: Option<u32>,
total: Option<u32>,
image: Option<String>,
}
fn parse_image_line(line: &str) -> Option<ImageProgress> {
let line = line.trim();
if line.is_empty() {
return None;
}
let parsed: OllamaImageLine = serde_json::from_str(line).ok()?;
Some(ImageProgress {
done: parsed.done,
step: parsed.step,
total: parsed.total,
image: parsed.image,
})
}
/// Channel events for streaming image generation.
#[derive(Clone, Serialize)]
#[serde(tag = "type", content = "data", rename_all = "camelCase")]
pub enum ImageEvent {
/// Progress update: (step, total). Either may be None if Ollama omits it.
Progress { step: Option<u32>, total: Option<u32> },
/// Final result: a `data:image/png;base64,...` URL ready for `<img src>`.
Done(String),
/// Fatal error.
Error(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>`.
///
@@ -29,7 +73,6 @@ struct OllamaImageLine {
/// 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> {
@@ -40,34 +83,92 @@ pub async fn generate_image(
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 (cache_path, cache_hit) = prepare_cache(&state.data_dir, &model, &req.prompt)?;
if cache_hit {
let bytes = std::fs::read(&cache_path).map_err(|e| e.to_string())?;
return Ok(data_url(&bytes));
}
let png_bytes = request_image_bytes(&config, &model, &req.prompt, None).await?;
std::fs::write(&cache_path, &png_bytes).map_err(|e| e.to_string())?;
Ok(data_url(&png_bytes))
}
/// Streaming variant: emits `ImageEvent::Progress` as Ollama reports `step`/`total`,
/// then `ImageEvent::Done` with the data URL (or `Error`). Reuses the same disk cache
/// as `generate_image`. The DM gets a real progress bar for the multi-second wait.
#[tauri::command]
pub async fn generate_image_stream(
state: tauri::State<'_, AppState>,
req: ImageRequest,
channel: Channel<ImageEvent>,
) -> Result<(), String> {
if cfg!(not(target_os = "macos")) {
let _ = channel.send(ImageEvent::Error(
"image generation is macOS-only via Ollama (for now)".into(),
));
return Ok(());
}
let config = state.config.lock().map_err(|e| e.to_string())?.clone();
let model = req.model.unwrap_or(config.image_model.clone());
let (cache_path, cache_hit) = prepare_cache(&state.data_dir, &model, &req.prompt)?;
if cache_hit {
let bytes = std::fs::read(&cache_path).map_err(|e| e.to_string())?;
let _ = channel.send(ImageEvent::Done(data_url(&bytes)));
return Ok(());
}
// Drive the request on a background task so the command returns immediately
// and progress flows through the channel. Errors become ImageEvent::Error.
let channel = std::sync::Arc::new(channel);
let ch = channel.clone();
tauri::async_runtime::spawn(async move {
match request_image_bytes(&config, &model, &req.prompt, Some(ch)).await {
Ok(png_bytes) => {
let _ = std::fs::write(&cache_path, &png_bytes);
let _ = channel.send(ImageEvent::Done(data_url(&png_bytes)));
}
Err(e) => {
let _ = channel.send(ImageEvent::Error(e));
}
}
});
Ok(())
}
/// Resolve the on-disk cache path for (model, prompt) and report a cache hit.
/// `base` is the configured data dir (AppState.data_dir).
fn prepare_cache(
base: &std::path::Path,
model: &str,
prompt: &str,
) -> Result<(std::path::PathBuf, bool), String> {
let cache_dir = base.join("images");
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
let mut hasher = DefaultHasher::new();
model.hash(&mut hasher);
prompt.hash(&mut hasher);
let cache_path = cache_dir.join(format!("{:016x}.png", hasher.finish()));
let hit = cache_path.exists();
Ok((cache_path, hit))
}
/// POST the image request to Ollama and collect the PNG bytes. When a channel
/// is given, parse the NDJSON body incrementally and emit progress; otherwise
/// read the whole body at once (legacy buffered path).
async fn request_image_bytes(
config: &crate::llm::LlmConfig,
model: &str,
prompt: &str,
channel: Option<std::sync::Arc<Channel<ImageEvent>>>,
) -> Result<Vec<u8>, String> {
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 body = serde_json::json!({ "model": model, "prompt": prompt, "stream": false });
let res = client
.post(&url)
@@ -82,19 +183,47 @@ pub async fn generate_image(
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}"))?;
// ponytail: Ollama image models emit NDJSON even with stream:false
// progress lines (step/total) then a final done:true carrying the image.
// The buffered path reads it all at once; the streaming path splits lines
// as they arrive so the bar animates.
let mut png_b64: Option<String> = None;
for line in body_text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
let mut buffer = String::new();
let apply_line = |line: &str, png: &mut Option<String>, ch: Option<&Channel<ImageEvent>>| {
if let Some(p) = parse_image_line(line) {
if let Some(c) = ch {
let _ = c.send(ImageEvent::Progress { step: p.step, total: p.total });
}
if let Some(b64) = p.image {
*png = Some(b64);
}
}
if let Ok(parsed) = serde_json::from_str::<OllamaImageLine>(line) {
if let Some(b64) = parsed.image {
png_b64 = Some(b64);
if parsed.done {
};
if let Some(ch) = channel {
let mut stream = res.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("image read error: {e}"))?;
buffer.push_str(&String::from_utf8_lossy(&chunk));
// Process complete lines; keep the trailing partial line in buffer.
while let Some(idx) = buffer.find('\n') {
let line = buffer.split_off(idx + 1);
let complete = std::mem::replace(&mut buffer, line);
apply_line(&complete, &mut png_b64, Some(&ch));
}
}
if !buffer.trim().is_empty() {
apply_line(&buffer, &mut png_b64, Some(&ch));
}
} else {
let body_text = res.text().await.map_err(|e| format!("image read error: {e}"))?;
for line in body_text.lines() {
apply_line(line, &mut png_b64, None);
// ponytail: stop after the done line in the buffered path — the
// final image is the one we want.
if let Some(p) = parse_image_line(line) {
if p.done && p.image.is_some() {
break;
}
}
@@ -102,11 +231,8 @@ pub async fn generate_image(
}
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))
Ok(png_bytes)
}
fn data_url(png: &[u8]) -> String {
@@ -115,7 +241,7 @@ fn data_url(png: &[u8]) -> String {
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;
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);
@@ -200,4 +326,48 @@ mod tests {
let s = format!("{:016x}", h.finish());
assert_eq!(s.len(), 16);
}
#[test]
fn parse_progress_and_image_lines() {
// intermediate progress line, no image
let p = parse_image_line(r#"{"model":"x/flux2-klein:4b","done":false,"total":4,"step":1}"#).unwrap();
assert_eq!(p, ImageProgress { done: false, step: Some(1), total: Some(4), image: None });
// final done line carries the image
let p = parse_image_line(r#"{"model":"x/flux2-klein:4b","done":true,"image":"iVBORw0KGgoAAAANSUhEUgAA"}"#).unwrap();
assert!(p.done);
assert_eq!(p.image.as_deref(), Some("iVBORw0KGgoAAAANSUhEUgAA"));
assert!(p.total.is_none() && p.step.is_none());
// blank/garbage lines are ignored, not errors
assert!(parse_image_line("").is_none());
assert!(parse_image_line("not json").is_none());
}
#[test]
fn split_ndjson_buffer_keeps_trailing_partial() {
// Simulate two chunks arriving separately where the split falls mid-line.
let chunk1 = "{\"done\":false,\"step\":1,\"total\":4}\n{\"done\":tru";
let chunk2 = "e,\"image\":\"abc\"}\n";
let mut buffer = String::new();
let mut png: Option<String> = None;
let whole = format!("{chunk1}{chunk2}");
// emulate the streaming loop over the concatenated body
buffer.push_str(&whole);
let mut lines = Vec::new();
while let Some(idx) = buffer.find('\n') {
let line = buffer.split_off(idx + 1);
let complete = std::mem::replace(&mut buffer, line);
lines.push(complete);
}
if !buffer.trim().is_empty() {
lines.push(std::mem::take(&mut buffer));
}
for line in &lines {
if let Some(p) = parse_image_line(line) {
if let Some(b) = p.image { png = Some(b); }
}
}
assert_eq!(png.as_deref(), Some("abc"));
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
pub mod llm_commands;
pub mod image_commands;
pub mod rag_commands;
pub mod generation_commands;
pub mod generation_commands;
pub mod data_commands;
+19
View File
@@ -55,4 +55,23 @@ pub fn rag_list(state: tauri::State<'_, AppState>) -> Result<Vec<RagSource>, Str
#[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())
}
#[derive(Debug, Serialize)]
pub struct RagChunk {
pub id: i64,
pub preview: String,
}
/// Preview the chunks stored under `source` (first 200 chars each). Lets the
/// DM verify what actually got embedded without dumping the whole corpus.
#[tauri::command]
pub fn rag_chunks(state: tauri::State<'_, AppState>, source: String) -> Result<Vec<RagChunk>, String> {
state
.rag
.list_chunks(&source)
.map_err(|e| e.to_string())?
.into_iter()
.map(|(id, preview)| Ok(RagChunk { id, preview }))
.collect()
}
+9
View File
@@ -144,6 +144,15 @@ impl GenerationStore {
}
Ok(out)
}
/// Read-only SQL pass-through for the advanced SQLite viewer.
pub fn raw_query(
&self,
sql: &str,
) -> anyhow::Result<(Vec<String>, Vec<Vec<serde_json::Value>>)> {
let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?;
crate::sql_viewer::run_readonly_query(&db, sql)
}
}
#[cfg(test)]
+36 -5
View File
@@ -2,10 +2,37 @@ mod llm;
mod rag;
mod generations;
mod commands;
mod sql_viewer;
use llm::AppState;
use std::sync::Mutex;
use tauri::Manager;
use tauri_plugin_store::StoreExt;
// ponytail: the data-dir preference lives in a tiny store at the OS default
// app_data_dir so it's always discoverable at startup, even before we know the
// custom location. Key: `dataDir` (absolute path). Empty/missing → default.
const PREFS_STORE: &str = "dm-pal-prefs.json";
const DATA_DIR_KEY: &str = "dataDir";
/// Resolve the data dir: the configured one if set and usable, else the
/// default `$APPDATA/dm-toolkit`.
fn resolve_data_dir(app: &tauri::AppHandle) -> std::path::PathBuf {
let default = app
.path()
.app_data_dir()
.expect("app data dir")
.join("dm-toolkit");
if let Ok(store) = app.store(PREFS_STORE) {
if let Some(Some(v)) = store.get(DATA_DIR_KEY).map(|v| v.as_str().map(|s| s.to_string())) {
let p = std::path::PathBuf::from(v);
// ponytail: fall back to default if the configured path is gone —
// a DM who unmounts the external drive shouldn't lose the app.
if p.is_absolute() { return p; }
}
}
default
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
@@ -13,12 +40,9 @@ pub fn run() {
.plugin(tauri_plugin_log::Builder::default().build())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
let data_dir = app
.path()
.app_data_dir()
.expect("app data dir")
.join("dm-toolkit");
let data_dir = resolve_data_dir(&app.handle());
let rag = rag::RagStore::open(&data_dir.join("lore"))
.expect("open lore db");
let gen = generations::GenerationStore::open(&data_dir)
@@ -27,6 +51,7 @@ pub fn run() {
config: Mutex::new(llm::LlmConfig::default()),
rag,
gen,
data_dir,
});
Ok(())
})
@@ -38,15 +63,21 @@ pub fn run() {
commands::llm_commands::set_llm_config,
commands::llm_commands::test_connection,
commands::image_commands::generate_image,
commands::image_commands::generate_image_stream,
commands::rag_commands::rag_add,
commands::rag_commands::rag_search,
commands::rag_commands::rag_list,
commands::rag_commands::rag_clear,
commands::rag_commands::rag_chunks,
commands::generation_commands::generation_add,
commands::generation_commands::generation_list,
commands::generation_commands::generation_get,
commands::generation_commands::generation_delete,
commands::generation_commands::generation_counts,
commands::data_commands::get_data_dir,
commands::data_commands::set_data_dir,
commands::data_commands::reset_data_dir,
commands::data_commands::sql_query,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+4
View File
@@ -17,6 +17,10 @@ pub struct AppState {
pub config: Mutex<LlmConfig>,
pub rag: crate::rag::RagStore,
pub gen: crate::generations::GenerationStore,
/// Where lore.db, generations.db, and cached images live. Configurable via
/// the `dataDir` key in the `dm-pal-prefs.json` store; defaults to
/// `$APPDATA/dm-toolkit` when unset. Set once at startup.
pub data_dir: std::path::PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+24
View File
@@ -179,6 +179,30 @@ impl RagStore {
}
Ok(())
}
/// Preview chunks for a source: (id, first 200 chars of text). Used by the
/// Lore panel so a DM can see what actually got embedded without a full dump.
pub fn list_chunks(&self, source: &str) -> anyhow::Result<Vec<(i64, String)>> {
let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?;
let mut stmt = db.prepare("SELECT id, substr(text, 1, 200) FROM chunks WHERE source = ? ORDER BY id")?;
let rows = stmt.query_map(params![source], |r| {
Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?))
})?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
/// Read-only SQL pass-through for the advanced SQLite viewer.
pub fn raw_query(
&self,
sql: &str,
) -> anyhow::Result<(Vec<String>, Vec<Vec<serde_json::Value>>)> {
let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?;
crate::sql_viewer::run_readonly_query(&db, sql)
}
}
#[cfg(test)]
+111
View File
@@ -0,0 +1,111 @@
use rusqlite::Connection;
use rusqlite::types::Value as SqlValue;
use serde_json::Value as JsonValue;
// ponytail: a read-only SQL viewer for the two SQLite DBs (lore.db,
// generations.db). We gate on the statement starting with SELECT / PRAGMA /
// WITH so a curious DM can inspect the backend but can't mutate it from the
// UI. Enough for "what's going on in the backend" without a footgun.
/// Run a read-only query against `conn`. Returns (column names, rows).
pub fn run_readonly_query(
conn: &Connection,
sql: &str,
) -> anyhow::Result<(Vec<String>, Vec<Vec<JsonValue>>)> {
let trimmed = sql.trim().trim_end_matches(';').trim();
if trimmed.is_empty() {
return Ok((Vec::new(), Vec::new()));
}
let upper = trimmed.to_uppercase();
let allowed = upper.starts_with("SELECT")
|| upper.starts_with("PRAGMA")
|| upper.starts_with("WITH");
if !allowed {
anyhow::bail!("Only read-only queries (SELECT / PRAGMA / WITH) are allowed");
}
let mut stmt = conn.prepare(trimmed)?;
let col_count = stmt.column_count();
let columns: Vec<String> = (0..col_count)
.map(|i| stmt.column_name(i).unwrap_or("?").to_string())
.collect();
let rows = stmt.query_map([], |row| {
let mut vals = Vec::with_capacity(col_count);
for i in 0..col_count {
let v: SqlValue = row.get(i)?;
vals.push(sql_to_json(v));
}
Ok(vals)
})?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok((columns, out))
}
fn sql_to_json(v: SqlValue) -> JsonValue {
match v {
SqlValue::Null => JsonValue::Null,
SqlValue::Integer(i) => i.into(),
SqlValue::Real(f) => f.into(),
SqlValue::Text(s) => JsonValue::String(s),
// ponytail: blobs aren't useful to render raw — show a size hint so the
// DM knows there's data without dumping megabytes of embedding bytes.
SqlValue::Blob(b) => JsonValue::String(format!("<blob {} bytes>", b.len())),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mem() -> Connection {
let c = Connection::open_in_memory().unwrap();
c.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, weight REAL);").unwrap();
c.execute("INSERT INTO t (name, weight) VALUES (?1, ?2), (?1, ?2)", rusqlite::params!["goblin", 23.5]).unwrap();
c
}
#[test]
fn select_returns_columns_and_rows() {
let (cols, rows) = run_readonly_query(&mem(), "SELECT id, name, weight FROM t ORDER BY id").unwrap();
assert_eq!(cols, vec!["id".to_string(), "name".into(), "weight".into()]);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0][1], serde_json::json!("goblin"));
assert_eq!(rows[0][2], serde_json::json!(23.5));
}
#[test]
fn rejects_writes() {
let c = mem();
let err = run_readonly_query(&c, "DELETE FROM t").unwrap_err();
assert!(err.to_string().contains("read-only"));
let err = run_readonly_query(&c, "INSERT INTO t (name, weight) VALUES ('x', 1)").unwrap_err();
assert!(err.to_string().contains("read-only"));
let err = run_readonly_query(&c, "UPDATE t SET name='x'").unwrap_err();
assert!(err.to_string().contains("read-only"));
}
#[test]
fn pragma_table_info_allowed() {
let (cols, rows) = run_readonly_query(&mem(), "PRAGMA table_info(t)").unwrap();
assert!(cols.contains(&"name".to_string()));
assert!(rows.iter().any(|r| r.contains(&serde_json::json!("id"))));
}
#[test]
fn with_cte_allowed() {
let (_cols, rows) = run_readonly_query(&mem(), "WITH x AS (SELECT 1 AS n) SELECT * FROM x").unwrap();
assert_eq!(rows.len(), 1);
}
#[test]
fn empty_and_trailing_semicolon_ok() {
let (cols, rows) = run_readonly_query(&mem(), "SELECT 1 AS n;").unwrap();
assert_eq!(cols, vec!["n"]);
assert_eq!(rows.len(), 1);
let Ok((c, r)) = run_readonly_query(&mem(), " ") else { panic!("empty query failed") };
assert!(c.is_empty() && r.is_empty());
}
}