diff --git a/src-tauri/src/commands/chat.rs b/src-tauri/src/commands/chat.rs index dd5ceb3..bfaba97 100644 --- a/src-tauri/src/commands/chat.rs +++ b/src-tauri/src/commands/chat.rs @@ -748,6 +748,40 @@ fn render_thread_for_summary(messages: &[ChatMessage]) -> String { out } +/// Strip JSON envelopes, markdown fences, and known field-extraction patterns +/// that the agent-trained model tends to emit even for non-agent prompts. +/// Returns the underlying summary text. +fn clean_summary(raw: &str) -> String { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return trimmed.to_string(); + } + + // Strip ```...``` fences (with or without lang). + let unfenced = if trimmed.starts_with("```") { + let body = trimmed.trim_start_matches('`').trim_start_matches('`').trim_start_matches('`'); + // Drop optional language identifier on the first line. + let after_lang = body.split_once('\n').map(|(_, rest)| rest).unwrap_or(body); + let trimmed_end = after_lang.trim_end_matches('`').trim_end_matches('`').trim_end_matches('`'); + trimmed_end.trim().to_string() + } else { + trimmed.to_string() + }; + + // If the model returned a JSON envelope, extract a known string field. + if unfenced.starts_with('{') { + if let Ok(v) = serde_json::from_str::(&unfenced) { + for key in ["text", "summary", "content", "answer", "output"] { + if let Some(s) = v.get(key).and_then(|x| x.as_str()) { + return s.trim().to_string(); + } + } + } + } + + unfenced +} + /// Find the index of the last User message; returns messages.len() if no user message. fn last_user_turn_index(messages: &[ChatMessage]) -> usize { for (i, m) in messages.iter().enumerate().rev() { @@ -793,8 +827,12 @@ pub async fn chat_compact( Produce a SHORT summary in the SAME language the user spoke. \ Use 3-6 bullet points covering: the user's goal, key tables/columns/queries used, \ numerical findings, conclusions reached, any open questions. \ - Be concrete with numbers and identifiers. Total length < 800 chars. \ - Output the bullets directly with no preamble, no JSON, no markdown fences."; + Be concrete with numbers and identifiers. Total length < 800 chars.\n\ + \n\ + OUTPUT FORMAT: PLAIN TEXT. Start each bullet with `- `. \ + DO NOT output JSON. DO NOT wrap output in `{` or `}`. \ + DO NOT use markdown fences. DO NOT include field names like `action`, `text`, `summary`. \ + DO NOT add a preamble. The first character of your reply must be `-`."; let llm_messages = vec![ OllamaChatMessage { @@ -810,7 +848,7 @@ pub async fn chat_compact( .await .map_err(|e| TuskError::Ai(format!("Compact failed: {}", e)))?; - let cleaned = summary.trim(); + let cleaned = clean_summary(&summary); let compacted_msg = ChatMessage::Assistant { id: new_id("asst"), text: format!( @@ -1033,6 +1071,42 @@ mod tests { assert_eq!(last_user_turn_index(&msgs), msgs.len()); } + #[test] + fn clean_summary_passes_plain_text() { + let s = "- bullet one\n- bullet two"; + assert_eq!(clean_summary(s), s); + } + + #[test] + fn clean_summary_strips_markdown_fences() { + let s = "```\n- bullet\n```"; + assert_eq!(clean_summary(s), "- bullet"); + } + + #[test] + fn clean_summary_strips_lang_fence() { + let s = "```text\n- bullet one\n- bullet two\n```"; + assert_eq!(clean_summary(s), "- bullet one\n- bullet two"); + } + + #[test] + fn clean_summary_extracts_text_field_from_json_envelope() { + let s = r#"{"action":"final","text":"- bullet one\n- bullet two"}"#; + assert_eq!(clean_summary(s), "- bullet one\n- bullet two"); + } + + #[test] + fn clean_summary_extracts_summary_field() { + let s = r#"{"summary":"- a\n- b"}"#; + assert_eq!(clean_summary(s), "- a\n- b"); + } + + #[test] + fn clean_summary_returns_unchanged_for_unrecognised_json() { + let s = r#"{"weird":42}"#; + assert_eq!(clean_summary(s), s); + } + #[test] fn render_thread_for_summary_includes_roles_and_skips_rows() { let msgs = vec![ diff --git a/src-tauri/src/commands/queries.rs b/src-tauri/src/commands/queries.rs index a92e015..1a18ad4 100644 --- a/src-tauri/src/commands/queries.rs +++ b/src-tauri/src/commands/queries.rs @@ -44,6 +44,11 @@ pub fn pg_value_to_json(row: &PgRow, index: usize) -> Value { } "DATE" => try_get!(chrono::NaiveDate), "TIME" => try_get!(chrono::NaiveTime), + "INTERVAL" => match row.try_get::, _>(index) { + Ok(Some(v)) => return Value::String(format_pg_interval(&v)), + Ok(None) => return Value::Null, + Err(_) => {} + }, "BYTEA" => match row.try_get::>, _>(index) { Ok(Some(v)) => return Value::String(format!("\\x{}", hex::encode(&v))), Ok(None) => return Value::Null, @@ -76,6 +81,44 @@ pub fn pg_value_to_json(row: &PgRow, index: usize) -> Value { } } +/// Render a PostgreSQL INTERVAL the same way `psql` does: +/// "1 year 2 mons 3 days 04:05:06.789012" +/// Components are emitted only when non-zero; the time component fires +/// whenever microseconds != 0 OR everything else is zero. +fn format_pg_interval(iv: &sqlx::postgres::types::PgInterval) -> String { + let years = iv.months / 12; + let months = iv.months % 12; + let mut parts: Vec = Vec::new(); + if years != 0 { + parts.push(format!("{} year{}", years, if years.abs() == 1 { "" } else { "s" })); + } + if months != 0 { + parts.push(format!("{} mon{}", months, if months.abs() == 1 { "" } else { "s" })); + } + if iv.days != 0 { + parts.push(format!("{} day{}", iv.days, if iv.days.abs() == 1 { "" } else { "s" })); + } + if iv.microseconds != 0 || parts.is_empty() { + let total_us = iv.microseconds.unsigned_abs(); + let total_seconds = total_us / 1_000_000; + let micros = (total_us % 1_000_000) as u32; + let h = total_seconds / 3600; + let m = (total_seconds / 60) % 60; + let s = total_seconds % 60; + let sign = if iv.microseconds < 0 { "-" } else { "" }; + let time_part = if micros == 0 { + format!("{}{:02}:{:02}:{:02}", sign, h, m, s) + } else { + format!( + "{}{:02}:{:02}:{:02}.{:06}", + sign, h, m, s, micros + ) + }; + parts.push(time_part); + } + parts.join(" ") +} + pub async fn execute_query_core( state: &AppState, connection_id: &str, @@ -157,3 +200,57 @@ pub async fn execute_query( ) -> TuskResult { execute_query_core(&state, &connection_id, &sql).await } + +#[cfg(test)] +mod tests { + use super::format_pg_interval; + use sqlx::postgres::types::PgInterval; + + #[test] + fn interval_zero_renders_as_zero_time() { + let iv = PgInterval { months: 0, days: 0, microseconds: 0 }; + assert_eq!(format_pg_interval(&iv), "00:00:00"); + } + + #[test] + fn interval_pure_time_micros() { + // 1h 30m + let iv = PgInterval { months: 0, days: 0, microseconds: 90 * 60 * 1_000_000 }; + assert_eq!(format_pg_interval(&iv), "01:30:00"); + } + + #[test] + fn interval_days_only() { + let iv = PgInterval { months: 0, days: 3, microseconds: 0 }; + assert_eq!(format_pg_interval(&iv), "3 days"); + } + + #[test] + fn interval_one_day() { + let iv = PgInterval { months: 0, days: 1, microseconds: 0 }; + assert_eq!(format_pg_interval(&iv), "1 day"); + } + + #[test] + fn interval_mixed_components() { + // 1 year 2 mons 3 days 04:05:06 + let iv = PgInterval { + months: 14, + days: 3, + microseconds: ((4 * 3600) + (5 * 60) + 6) * 1_000_000, + }; + assert_eq!(format_pg_interval(&iv), "1 year 2 mons 3 days 04:05:06"); + } + + #[test] + fn interval_negative_time() { + let iv = PgInterval { months: 0, days: 0, microseconds: -3_600_000_000 }; + assert_eq!(format_pg_interval(&iv), "-01:00:00"); + } + + #[test] + fn interval_with_microseconds_fraction() { + let iv = PgInterval { months: 0, days: 0, microseconds: 1_500_000 }; + assert_eq!(format_pg_interval(&iv), "00:00:01.500000"); + } +} diff --git a/src/hooks/use-chat.ts b/src/hooks/use-chat.ts index 2a49246..6b0cd07 100644 --- a/src/hooks/use-chat.ts +++ b/src/hooks/use-chat.ts @@ -1,4 +1,5 @@ import { useCallback } from "react"; +import { toast } from "sonner"; import { chatCompact, chatSend } from "@/lib/tauri"; import { useAppStore } from "@/stores/app-store"; import type { ChatMessage } from "@/types"; @@ -24,22 +25,40 @@ export function useChat(tabId: string, connectionId: string) { const compact = useCallback(async (): Promise => { const state = useAppStore.getState(); - if (state.chatPending[tabId]) return false; + if (state.chatPending[tabId]) { + toast.message("Wait for the agent to finish first."); + return false; + } const history = state.chatThreads[tabId] ?? []; - if (history.length === 0) return false; + if (history.length === 0) { + toast.message("Nothing to compact yet."); + return false; + } + const beforeCount = history.length; setChatPending(tabId, true); try { const turn = await chatCompact(connectionId, history); + const afterCount = turn.messages.length; + // Backend returns the same thread untouched when there's nothing older + // than the last user turn; surface that instead of silently no-op. + if (afterCount >= beforeCount) { + toast.message("Nothing to compact (no older history beyond the last question)."); + return false; + } replaceChatThread(tabId, turn.messages); setChatUsage(tabId, turn.usage); + const removed = beforeCount - afterCount + 1; // +1: original older replaced by single summary + toast.success(`Compacted ${removed} earlier message${removed === 1 ? "" : "s"}.`); return true; } catch (err) { + const text = `Compact failed: ${String(err)}`; + toast.error("Compact failed", { description: String(err) }); appendChatMessages(tabId, [ { id: newId("err"), role: "assistant", - text: `Compact failed: ${String(err)}`, + text, created_at: Date.now(), }, ]);