working through a review
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
use crate::commands::emit_busy;
|
||||
use crate::llm::AppState;
|
||||
use futures_util::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use tauri::ipc::Channel;
|
||||
use tauri::AppHandle;
|
||||
|
||||
// ponytail: DefaultHasher is fine for a cache filename — not crypto, just a stable key.
|
||||
|
||||
@@ -74,8 +76,18 @@ pub enum ImageEvent {
|
||||
#[tauri::command]
|
||||
pub async fn generate_image(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app: AppHandle,
|
||||
req: ImageRequest,
|
||||
) -> Result<String, String> {
|
||||
// ponytail: emit a global busy signal so the shell shows a working
|
||||
// indicator even if the DM navigates away. Paired with the false below.
|
||||
emit_busy(&app, true);
|
||||
let result = generate_image_inner(state, req).await;
|
||||
emit_busy(&app, false);
|
||||
result
|
||||
}
|
||||
|
||||
async fn generate_image_inner(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());
|
||||
}
|
||||
@@ -100,6 +112,7 @@ pub async fn generate_image(
|
||||
#[tauri::command]
|
||||
pub async fn generate_image_stream(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app: AppHandle,
|
||||
req: ImageRequest,
|
||||
channel: Channel<ImageEvent>,
|
||||
) -> Result<(), String> {
|
||||
@@ -124,6 +137,9 @@ pub async fn generate_image_stream(
|
||||
// and progress flows through the channel. Errors become ImageEvent::Error.
|
||||
let channel = std::sync::Arc::new(channel);
|
||||
let ch = channel.clone();
|
||||
// ponytail: only emit busy around the actual generation (not cache hits),
|
||||
// and always balance it in the spawn — even on error.
|
||||
emit_busy(&app, true);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match request_image_bytes(&config, &model, &req.prompt, Some(ch)).await {
|
||||
Ok(png_bytes) => {
|
||||
@@ -134,6 +150,7 @@ pub async fn generate_image_stream(
|
||||
let _ = channel.send(ImageEvent::Error(e));
|
||||
}
|
||||
}
|
||||
emit_busy(&app, false);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
use crate::commands::emit_busy;
|
||||
use crate::llm::{self, AppState, ChatMessage, ChatResponse, GenerateRequest, LlmEvent, OllamaChatResponse};
|
||||
use serde_json::json;
|
||||
use tauri::ipc::Channel;
|
||||
use tauri::AppHandle;
|
||||
|
||||
// ─── Simple (non-streaming) generate ─────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn generate(state: tauri::State<'_, AppState>, req: GenerateRequest) -> Result<String, String> {
|
||||
pub async fn generate(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app: AppHandle,
|
||||
req: GenerateRequest,
|
||||
) -> Result<String, String> {
|
||||
// ponytail: emit a global busy signal so the shell can show a working
|
||||
// indicator even after the DM navigates away. Paired with the false
|
||||
// emit below — every return path balances the counter.
|
||||
emit_busy(&app, true);
|
||||
let result = generate_inner(state, req).await;
|
||||
emit_busy(&app, false);
|
||||
result
|
||||
}
|
||||
|
||||
async fn generate_inner(state: tauri::State<'_, AppState>, req: GenerateRequest) -> Result<String, String> {
|
||||
let config = {
|
||||
let guard = state.config.lock().map_err(|e| e.to_string())?;
|
||||
guard.clone()
|
||||
@@ -32,6 +48,7 @@ pub async fn generate(state: tauri::State<'_, AppState>, req: GenerateRequest) -
|
||||
#[tauri::command]
|
||||
pub async fn generate_stream(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app: AppHandle,
|
||||
req: GenerateRequest,
|
||||
channel: Channel<LlmEvent>,
|
||||
) -> Result<(), String> {
|
||||
@@ -43,6 +60,7 @@ pub async fn generate_stream(
|
||||
let max_tokens = req.max_tokens.unwrap_or(config.max_tokens);
|
||||
let messages = inject_lore(&state, &client, &config, messages, &req.rag_query).await;
|
||||
|
||||
emit_busy(&app, true);
|
||||
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
|
||||
@@ -61,6 +79,8 @@ pub async fn generate_stream(
|
||||
let _ = channel.send(LlmEvent::Error(e));
|
||||
}
|
||||
}
|
||||
// ponytail: always balance the busy counter, even on error.
|
||||
emit_busy(&app, false);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -2,4 +2,21 @@ pub mod llm_commands;
|
||||
pub mod image_commands;
|
||||
pub mod rag_commands;
|
||||
pub mod generation_commands;
|
||||
pub mod data_commands;
|
||||
pub mod data_commands;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
// ponytail: a global "generation in flight" signal. Every generate command
|
||||
// emits busy:true at start and busy:false at end so the shell can show a
|
||||
// working indicator even after the DM navigates away from the generating
|
||||
// tool. The frontend maintains a counter (true = +1, false = -1) so
|
||||
// concurrent generations balance out.
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct GenBusy {
|
||||
pub busy: bool,
|
||||
}
|
||||
|
||||
pub fn emit_busy(app: &AppHandle, busy: bool) {
|
||||
let _ = app.emit("gen-busy", GenBusy { busy });
|
||||
}
|
||||
@@ -74,4 +74,94 @@ pub fn rag_chunks(state: tauri::State<'_, AppState>, source: String) -> Result<V
|
||||
.into_iter()
|
||||
.map(|(id, preview)| Ok(RagChunk { id, preview }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RagAddDirRequest {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RagAddDirReport {
|
||||
pub files: usize,
|
||||
pub chunks: usize,
|
||||
pub skipped: Vec<String>,
|
||||
}
|
||||
|
||||
/// Walk a directory (recursive, skipping hidden entries) and index every
|
||||
/// `.md`/`.markdown`/`.txt` file as its own lore source, named by its path
|
||||
/// relative to the picked root. Done in Rust rather than the webview `fs`
|
||||
/// plugin so a DM can pick an external world-bible folder without needing an
|
||||
/// fs scope permission for that path. ponytail: one embed batch per file is
|
||||
/// fine here — directory import is a one-off prep action, not a hot path.
|
||||
#[tauri::command]
|
||||
pub async fn rag_add_directory(
|
||||
state: tauri::State<'_, AppState>,
|
||||
req: RagAddDirRequest,
|
||||
) -> Result<RagAddDirReport, String> {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?.clone();
|
||||
let root = std::path::PathBuf::from(&req.path);
|
||||
if !root.is_dir() {
|
||||
return Err(format!("not a directory: {}", req.path));
|
||||
}
|
||||
let mut files = 0usize;
|
||||
let mut chunks = 0usize;
|
||||
let mut skipped: Vec<String> = Vec::new();
|
||||
let mut stack = vec![root.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let entries = match std::fs::read_dir(&dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
skipped.push(format!("{}: {e}", dir.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if entry.file_name().to_string_lossy().starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
continue;
|
||||
}
|
||||
let is_text = matches!(
|
||||
p.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.as_deref(),
|
||||
Some("md") | Some("markdown") | Some("txt")
|
||||
);
|
||||
if !is_text {
|
||||
continue;
|
||||
}
|
||||
let text = match std::fs::read_to_string(&p) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
skipped.push(format!("{}: {e}", p.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// ponytail: source = path relative to the picked root, extension
|
||||
// stripped, so two files with the same name in different folders
|
||||
// don't silently merge into one lore source.
|
||||
let rel = p.strip_prefix(&root).unwrap_or(&p);
|
||||
let source = rel.with_extension("").to_string_lossy().to_string();
|
||||
let source = if source.is_empty() {
|
||||
p.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
source
|
||||
};
|
||||
match state.rag.add_document(&config, &source, &text).await {
|
||||
Ok(n) => {
|
||||
files += 1;
|
||||
chunks += n;
|
||||
}
|
||||
Err(e) => skipped.push(format!("{}: {e}", p.display())),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(RagAddDirReport { files, chunks, skipped })
|
||||
}
|
||||
@@ -56,7 +56,6 @@ pub fn run() {
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
commands::llm_commands::generate,
|
||||
commands::llm_commands::generate_stream,
|
||||
commands::llm_commands::get_llm_config,
|
||||
@@ -69,6 +68,7 @@ pub fn run() {
|
||||
commands::rag_commands::rag_list,
|
||||
commands::rag_commands::rag_clear,
|
||||
commands::rag_commands::rag_chunks,
|
||||
commands::rag_commands::rag_add_directory,
|
||||
commands::generation_commands::generation_add,
|
||||
commands::generation_commands::generation_list,
|
||||
commands::generation_commands::generation_get,
|
||||
@@ -83,7 +83,6 @@ pub fn run() {
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! Welcome to DM-Pal ⚔", name)
|
||||
}
|
||||
// ponytail: removed the scaffold `greet` command + its frontend component —
|
||||
// nothing in the shell referenced it. Kept here as a marker so the
|
||||
// invoke_handler list above stays in sync.
|
||||
Reference in New Issue
Block a user