fix: handle PG INTERVAL type, robust compact LLM output + feedback

INTERVAL handling
- pg_value_to_json now decodes PG INTERVAL via PgInterval and renders
  it psql-style: `1 year 2 mons 3 days 04:05:06`. Previously
  AVG(timestamp - timestamp) and similar interval-returning queries
  showed `<unsupported type: INTERVAL>` in chat results.
- 7 unit tests covering zero, days-only, mixed, negative, microsecond
  fraction, and the singular/plural unit rules.

Compact reliability
- Sharper system prompt: explicitly instructs plain text starting with
  `-`, no JSON, no fences, no field names. qwen3-coder is heavily
  trained on the agent JSON protocol and was sometimes returning
  `{"action":"final","text":"..."}` even for the compact prompt.
- New clean_summary helper strips ``` fences (with or without lang
  identifier) and extracts the underlying string from a JSON envelope
  if the model still wraps the answer (looks for text/summary/content/
  answer/output keys). 6 unit tests.
- Frontend useChat.compact: success/no-op/error toasts via sonner so
  the user sees what happened. "Nothing to compact" appears when there
  is no older history beyond the last user turn (previously silent).

Verification: cargo test --lib 66 pass (+13), tsc clean, vitest 20
pass.
This commit is contained in:
2026-05-06 20:01:50 +03:00
parent 27fed0dbf8
commit 83f204816a
3 changed files with 196 additions and 6 deletions

View File

@@ -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<boolean> => {
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(),
},
]);