Files
paliad/frontend/src/client/paliadin.ts
m be2150c17d fix(paliadin): used_tools NOT NULL violation + frontend response truncation
Two bugs surfaced in m's dogfood of t-paliad-155 (2026-05-08 13:55).

## A. used_tools NOT NULL constraint violation on casual turns

paliad.paliadin_turns.used_tools is text[] NOT NULL DEFAULT '{}'. parseTrailer
leaves trailerMeta.UsedTools as nil when Claude omits the trailer ("Heyhey!")
or sends an empty list. completeTurn passed pq.StringArray(nil) which the pq
driver writes as NULL — UPDATE failed with constraint 23502 on every casual
chat turn, leaving the row half-finalized.

Fix: coerce UsedTools to a non-nil empty pq.StringArray before the UPDATE,
mirroring the existing rowsSeen pattern in the same function.

## B. Frontend rendered "## Proje" instead of the full 1408-byte response

m saw the first 8 characters of his Markdown response in the chat bubble,
plus the full meta row underneath. The DB row had the complete cleanBody
in 'response'. Truncation lived entirely in the browser.

Root cause: finishBubble read textNode.textContent at the moment of the
'end' event — but typewriter() animates the text 8 chars at a time, so
textContent was "## Proje" (one tick into 1408 bytes) when finishBubble
fired. renderResponseHTML(raw) baked in the partial state, then the
typewriter's next tick saw streaming='false' and ran 'node.textContent =
text' which overwrote the rendered HTML with the raw string — except in
this case the second tick never ran in time, leaving the partial render.

Fix:
1. Cache the full SSE-delivered text on placeholder.dataset.fullText at
   content-event time. finishBubble prefers that over textContent.
2. Typewriter's abort branch no longer overwrites the node — finishBubble
   already owns the final rendered HTML, so a delayed tick should just
   return rather than blow away the rendered Markdown.

Both fixes verified locally: go build clean, bun build clean.

Refs t-paliad-155, m/paliad#12.
2026-05-08 14:00:03 +02:00

427 lines
14 KiB
TypeScript

import { initI18n, getLang, t } from "./i18n";
import { initSidebar } from "./sidebar";
// Paliadin chat panel client (t-paliad-146 PoC).
//
// State machine: empty → typing → sending → streaming → done.
// History lives in localStorage under "paliadin:history:<sessionId>"
// — design §0.5.4 session-only persistence.
//
// SSE consumer subscribes to `event: meta`, `event: content`,
// `event: end`, `event: error`, `event: ping`. Backend currently
// emits one `content` blob per turn (real chunked streaming is
// production-v1; PoC simulates with a typewriter effect).
interface HistoryEntry {
role: "user" | "assistant";
text: string;
meta?: {
used_tools?: string[];
rows_seen?: number[];
classifier_tag?: string;
duration_ms?: number;
chip_count?: number;
};
ts: string; // ISO
}
const SESSION_KEY = "paliadin:session";
const HISTORY_PREFIX = "paliadin:history:";
let sessionId: string;
let history: HistoryEntry[] = [];
let currentEventSource: EventSource | null = null;
let currentTurnId: string | null = null;
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
bootSession();
wireForm();
wireStarters();
wireReset();
renderHistory();
});
function bootSession(): void {
let s = localStorage.getItem(SESSION_KEY);
if (!s) {
s = crypto.randomUUID();
localStorage.setItem(SESSION_KEY, s);
}
sessionId = s;
const stored = localStorage.getItem(HISTORY_PREFIX + sessionId);
if (stored) {
try {
history = JSON.parse(stored);
} catch {
history = [];
}
}
}
function wireForm(): void {
const form = document.getElementById("paliadin-form") as HTMLFormElement | null;
const input = document.getElementById("paliadin-input") as HTMLTextAreaElement | null;
if (!form || !input) return;
form.addEventListener("submit", (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text) return;
input.value = "";
sendTurn(text);
});
// Enter sends; Shift+Enter inserts newline.
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
form.dispatchEvent(new Event("submit"));
}
});
}
function wireStarters(): void {
const starters = document.querySelectorAll<HTMLButtonElement>(".paliadin-starter");
starters.forEach((btn) => {
btn.addEventListener("click", () => {
const lang = getLang();
const promptText = lang === "en"
? btn.dataset.promptEn || btn.textContent?.trim() || ""
: btn.dataset.promptDe || btn.textContent?.trim() || "";
if (promptText) sendTurn(promptText);
});
});
}
function wireReset(): void {
const btn = document.getElementById("paliadin-reset");
if (!btn) return;
btn.addEventListener("click", async () => {
history = [];
saveHistory();
renderHistory();
try {
await fetch("/api/paliadin/reset", { method: "POST", credentials: "same-origin" });
} catch {
// Reset failure is non-fatal — the next turn will spin up a fresh pane anyway.
}
});
}
async function sendTurn(text: string): Promise<void> {
// Hide empty state on first send.
const empty = document.getElementById("paliadin-empty");
if (empty) empty.style.display = "none";
// Append user bubble.
history.push({ role: "user", text, ts: new Date().toISOString() });
saveHistory();
appendBubble("user", text);
// Insert placeholder assistant bubble.
const placeholder = appendBubble("assistant", "");
placeholder.dataset.streaming = "true";
placeholder.querySelector(".paliadin-bubble-text")!.textContent = "Paliadin denkt nach …";
toggleStopButton(true);
// Kick off the turn.
let turnRes: { turn_id: string; sse_url: string };
try {
const r = await fetch("/api/paliadin/turn", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({
user_message: text,
session_id: sessionId,
page_origin: "/paliadin",
}),
});
if (!r.ok) throw new Error("HTTP " + r.status);
turnRes = await r.json();
} catch (err) {
placeholder.querySelector(".paliadin-bubble-text")!.textContent =
t("paliadin.error.upstream");
placeholder.dataset.streaming = "false";
placeholder.classList.add("paliadin-bubble--error");
toggleStopButton(false);
return;
}
currentTurnId = turnRes.turn_id;
// Open SSE.
const es = new EventSource(turnRes.sse_url);
currentEventSource = es;
es.addEventListener("meta", () => {
// Could surface a "thinking" indicator; placeholder text already does.
});
es.addEventListener("content", (ev) => {
const data = JSON.parse((ev as MessageEvent).data);
const text = String(data.text || "");
// Cache the full text on the bubble so finishBubble can render the
// complete response even when the typewriter is mid-flight when end
// arrives. textContent reflects only what's been typed so far and
// would otherwise truncate the rendered Markdown (m, 2026-05-08 —
// saw "## Proje" instead of the full 1408-byte body).
placeholder.dataset.fullText = text;
typewriter(placeholder, text);
});
es.addEventListener("end", (ev) => {
const data = JSON.parse((ev as MessageEvent).data);
placeholder.dataset.streaming = "false";
finishBubble(placeholder, data);
history.push({
role: "assistant",
text: getBubbleText(placeholder),
meta: {
used_tools: data.used_tools,
rows_seen: data.rows_seen,
classifier_tag: data.classifier_tag,
duration_ms: data.duration_ms,
chip_count: data.chip_count,
},
ts: new Date().toISOString(),
});
saveHistory();
cleanupTurn();
});
es.addEventListener("error", (ev) => {
placeholder.querySelector(".paliadin-bubble-text")!.textContent =
friendlyErrorMessage((ev as MessageEvent).data);
placeholder.classList.add("paliadin-bubble--error");
placeholder.dataset.streaming = "false";
cleanupTurn();
});
es.addEventListener("ping", () => {
// heartbeat — no-op
});
}
// Server emits SSE error events as JSON `{code, message}`. Map known
// codes to localised, user-friendly text; fall through to a generic
// "connection lost" for anything we don't recognise (including raw
// EventSource transport errors where data is absent).
function friendlyErrorMessage(data: unknown): string {
if (typeof data !== "string" || data === "") {
return t("paliadin.error.connection_lost");
}
try {
const parsed = JSON.parse(data) as { code?: string };
switch (parsed.code) {
case "tmux_unavailable":
// Local PoC path: paliad is running on a host without tmux/claude
// (typically the legacy laptop-only build).
return t("paliadin.error.local_only");
case "mriver_unreachable":
// t-paliad-151: prod path's mRiver is offline (laptop asleep, off
// tailnet, or paliadin-shim missing).
return t("paliadin.error.mriver_unreachable");
case "shim_auth_failed":
// SSH key wrong or authorized_keys drifted.
return t("paliadin.error.shim_auth_failed");
case "shim_error":
case "bootstrap_failed":
// Generic remote shim failure or system-prompt bootstrap error.
return t("paliadin.error.shim_error");
case "timeout":
return t("paliadin.error.timeout");
}
} catch {
// Not JSON — fall through to the generic connection-lost message
// rather than leaking a raw payload into the bubble.
}
return t("paliadin.error.connection_lost");
}
function cleanupTurn(): void {
if (currentEventSource) {
currentEventSource.close();
currentEventSource = null;
}
currentTurnId = null;
toggleStopButton(false);
}
function toggleStopButton(streaming: boolean): void {
const send = document.getElementById("paliadin-send") as HTMLButtonElement | null;
const stop = document.getElementById("paliadin-stop") as HTMLButtonElement | null;
if (send) send.style.display = streaming ? "none" : "";
if (stop) {
stop.style.display = streaming ? "" : "none";
stop.onclick = () => {
cleanupTurn();
};
}
}
function appendBubble(role: "user" | "assistant", text: string): HTMLElement {
const stream = document.getElementById("paliadin-stream")!;
const bubble = document.createElement("div");
bubble.className = "paliadin-bubble paliadin-bubble--" + role;
bubble.innerHTML = `
<div class="paliadin-bubble-role">${role === "user" ? "Du" : "Paliadin"}</div>
<div class="paliadin-bubble-text"></div>
<div class="paliadin-bubble-meta" style="display:none"></div>
`;
bubble.querySelector(".paliadin-bubble-text")!.textContent = text;
stream.appendChild(bubble);
stream.scrollTop = stream.scrollHeight;
return bubble;
}
// typewriter incrementally fills the bubble's text node so a one-shot
// content blob feels like streaming. ~5 ms per character; fast enough
// to keep up with even a 4k-char response.
function typewriter(bubble: HTMLElement, text: string): void {
const node = bubble.querySelector(".paliadin-bubble-text")!;
node.textContent = "";
let i = 0;
const speed = 6;
const tick = () => {
if (bubble.dataset.streaming !== "true") {
// Streaming finished — finishBubble has already rendered the full
// Markdown via dataset.fullText. Return without writing so we
// don't replace the rendered HTML with raw text on a delayed tick.
return;
}
if (i >= text.length) return;
const next = Math.min(i + 8, text.length);
node.textContent = text.slice(0, next);
i = next;
const stream = document.getElementById("paliadin-stream")!;
stream.scrollTop = stream.scrollHeight;
setTimeout(tick, speed);
};
tick();
}
function getBubbleText(bubble: HTMLElement): string {
return bubble.querySelector(".paliadin-bubble-text")?.textContent || "";
}
// finishBubble parses the response for citation markers + tool-use
// evidence and renders both. Markers found in the text get replaced
// by anchor buttons; the meta row at the bottom shows
// "ran search_my_deadlines (3 results)".
function finishBubble(bubble: HTMLElement, data: any): void {
const textNode = bubble.querySelector(".paliadin-bubble-text")! as HTMLElement;
// Prefer the full text cached on the bubble at content-event time;
// textContent may still reflect the typewriter's partial state.
const raw = bubble.dataset.fullText ?? textNode.textContent ?? "";
textNode.innerHTML = renderResponseHTML(raw);
const metaEl = bubble.querySelector(".paliadin-bubble-meta") as HTMLElement | null;
if (metaEl) {
const tools = (data.used_tools || []) as string[];
const rows = (data.rows_seen || []) as number[];
if (tools.length > 0) {
const parts = tools.map((t, i) => {
const r = rows[i];
return r != null ? `${t} (${r})` : t;
});
metaEl.innerHTML = "▸ " + parts.join(" · ");
metaEl.style.display = "";
}
}
}
// Marker → button render. Mirrors §4.4 of the design.
const CHIP_RE = /\[(?:#([a-z]+)-OPEN:([A-Za-z0-9\-_]+)|chip:([a-z]+):([^\]]+))\]/g;
function renderResponseHTML(raw: string): string {
// First escape any HTML in the raw text (simple textContent → innerHTML
// would have been fine but we then need to inject anchors, so the
// manual escape is unavoidable).
const esc = raw
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
// Walk markers; replace each with a paliadin-chip anchor.
return esc.replace(CHIP_RE, (_match, kind, id, chipKind, chipArg) => {
if (kind && id) {
const url = chipURL(kind, id);
const label = chipLabel(kind);
return `<a class="paliadin-chip" href="${url}">${label}</a>`;
}
if (chipKind === "nav") {
return `<a class="paliadin-chip" href="${chipArg}">öffnen</a>`;
}
if (chipKind === "filter") {
return `<a class="paliadin-chip" href="/inbox?${chipArg}">Filter anwenden</a>`;
}
return "";
});
}
function chipURL(kind: string, id: string): string {
switch (kind) {
case "deadline":
case "frist":
return "/deadlines/" + id;
case "projekt":
case "project":
return "/projects/" + id;
case "termin":
case "appointment":
return "/appointments/" + id;
default:
return "#";
}
}
function chipLabel(kind: string): string {
switch (kind) {
case "deadline":
case "frist":
return "Frist öffnen";
case "projekt":
case "project":
return "Akte ansehen";
case "termin":
case "appointment":
return "Termin öffnen";
default:
return "öffnen";
}
}
function saveHistory(): void {
localStorage.setItem(HISTORY_PREFIX + sessionId, JSON.stringify(history));
}
function renderHistory(): void {
const stream = document.getElementById("paliadin-stream");
if (!stream) return;
// Clear non-empty bubbles, keep the empty-state.
Array.from(stream.children).forEach((el) => {
if (!el.classList.contains("paliadin-empty")) el.remove();
});
if (history.length === 0) {
const empty = document.getElementById("paliadin-empty");
if (empty) empty.style.display = "";
return;
}
const empty = document.getElementById("paliadin-empty");
if (empty) empty.style.display = "none";
history.forEach((h) => {
const bubble = appendBubble(h.role, h.text);
if (h.role === "assistant" && h.meta) {
bubble.dataset.streaming = "false";
finishBubble(bubble, {
used_tools: h.meta.used_tools,
rows_seen: h.meta.rows_seen,
});
}
});
}