feat(paliadin): stream + honest late-recovery (t-paliad-235)
m's 14:56 observation: long Paliadin turns showed "Verbindung verloren —
Antwort wird nachgereicht …" but never delivered. The aichat backend
finished the turn upstream; paliad's HTTP client had given up at 130 s
and the legacy filesystem janitor never ran for the aichat path.
Three intertwined fixes, all shipped together because they share the
same wire shape and the same UI states:
1. Switch the aichat backend to /chat/turn/stream
- new AichatPaliadinService.RunTurnStream relays incremental chunks
- SSE parser handles default `data:` frames (chunk/meta/done/error)
and named `event: heartbeat` frames per the upstream contract
- no more 130 s hard ceiling — stream stays open as long as data or
heartbeats flow; silenceTimeout (90 s) catches a true upstream
stall instead
2. Proof-of-life thinking events
- handler emits `event: thinking` every 5 s while the upstream is
silent (synthesised locally) AND relays aichat's `heartbeat`
events as thinking pings
- frontend renders a lime-dot pulse + monospace counter inside the
assistant bubble — the user can SEE the chat is still working
3. Honest disconnect copy + real late-recovery
- new dispatching endpoint GET /api/paliadin/turns/{id}/recover
- aichat backend: asks aichat via GET /chat/conversations and
/chat/conversations/{id}/turns whether the turn actually finished
- legacy backend: falls through to the local row read (janitor)
- frontend swaps "wird nachgereicht" → "Lade frische Antwort …"
while the recovery polls; on confirmed "lost" swaps to
"Antwort konnte nicht zugestellt werden — bitte erneut stellen"
- migration 118 adds aichat_conversation_id to paliadin_turns so
the recovery has a fast path when the done frame arrived before
the drop
Streaming + recovery are a no-op for PALIADIN_BACKEND=legacy: the
StreamingPaliadin interface is detected via type assertion, the
LocalPaliadinService stays on the one-shot RunTurn + filesystem
janitor path.
13 new unit tests cover the SSE parser, the conversation-API client,
and the match-assistant-response helper.
go build ./... + go test ./internal/... + go test ./cmd/server/...
+ bun run build all clean.
This commit is contained in:
@@ -2048,8 +2048,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"paliadin.error.timeout": "Paliadin antwortet nicht (Timeout 60s). Nochmal versuchen.",
|
||||
"paliadin.error.connection_lost": "Verbindung verloren.",
|
||||
"paliadin.error.upstream": "Fehler beim Senden.",
|
||||
"paliadin.error.upstream_silence": "Paliadin meldet sich nicht mehr — Verbindung wird beendet.",
|
||||
"paliadin.late.waiting": "Antwort wird nachgereicht, sobald sie eintrifft …",
|
||||
"paliadin.late.checking": "Verbindung verloren — Paliadin denkt vielleicht noch. Lade frische Antwort …",
|
||||
"paliadin.late.lost": "Antwort konnte nicht zugestellt werden — bitte Frage erneut stellen.",
|
||||
"paliadin.late.marker": "verspätet",
|
||||
"paliadin.thinking": "Paliadin denkt nach",
|
||||
"paliadin.thinking.seconds": "{seconds}s",
|
||||
"paliadin.widget.title": "Paliadin",
|
||||
"paliadin.widget.trigger": "Paliadin (Cmd+J)",
|
||||
"paliadin.widget.empty": "Was kann ich für dich tun?",
|
||||
@@ -4907,8 +4912,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"paliadin.error.timeout": "Paliadin didn't respond in time (60s). Try again.",
|
||||
"paliadin.error.connection_lost": "Connection lost.",
|
||||
"paliadin.error.upstream": "Send failed.",
|
||||
"paliadin.error.upstream_silence": "Paliadin went silent — closing the connection.",
|
||||
"paliadin.late.waiting": "Will fill in the response when it arrives …",
|
||||
"paliadin.late.checking": "Connection lost — Paliadin may still be thinking. Fetching fresh answer …",
|
||||
"paliadin.late.lost": "Answer couldn't be delivered — please ask again.",
|
||||
"paliadin.late.marker": "late",
|
||||
"paliadin.thinking": "Paliadin is thinking",
|
||||
"paliadin.thinking.seconds": "{seconds}s",
|
||||
"paliadin.widget.title": "Paliadin",
|
||||
"paliadin.widget.trigger": "Paliadin (Cmd+J)",
|
||||
"paliadin.widget.empty": "What can I help you with?",
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
// Late-response polling. The Go backend's pollForResponse window is
|
||||
// 60 s; if Claude writes the response file after that (because the
|
||||
// tmux pane was busy mid-turn when the message arrived), the SSE
|
||||
// stream has already closed with an `error` event. The Janitor
|
||||
// (services.LocalPaliadinService.runJanitor) then patches the
|
||||
// paliadin_turns row when the file lands.
|
||||
// Late-response polling (t-paliad-235 rewrite).
|
||||
//
|
||||
// This module is the FE half of that loop: after the bubble shows an
|
||||
// error, the caller registers the turn here. We poll
|
||||
// `/api/paliadin/turns/{id}` every 3 s for up to 10 minutes; once the
|
||||
// row has a non-empty response, we hand it back so the caller can
|
||||
// swap the bubble content in place.
|
||||
// When the SSE stream closes mid-turn with an error event, the bubble
|
||||
// can't tell from the wire whether (a) the upstream is still finishing
|
||||
// the turn and we just lost transport, or (b) the upstream is truly
|
||||
// dead.
|
||||
//
|
||||
// This module hits the dispatching recovery endpoint
|
||||
// `/api/paliadin/turns/{id}/recover`, which knows the active backend:
|
||||
//
|
||||
// - aichat backend → asks aichat via its conversation API whether
|
||||
// the turn actually completed upstream
|
||||
// - legacy backend → reads the local row (paliad's filesystem
|
||||
// janitor patches it when claude writes the
|
||||
// response file late)
|
||||
//
|
||||
// The endpoint returns:
|
||||
//
|
||||
// recovery_state="recovered" → response is in the payload, render it
|
||||
// recovery_state="pending" → keep polling
|
||||
// recovery_state="lost" → upstream is truly gone, give up
|
||||
|
||||
export interface LateTurn {
|
||||
turn_id: string;
|
||||
@@ -28,6 +37,10 @@ export interface LatePollOptions {
|
||||
intervalMs?: number; // default 3000
|
||||
maxDurationMs?: number; // default 600000 (10 min)
|
||||
onLateResponse: (turn: LateTurn) => void;
|
||||
// onLost — backend confirmed the turn is unrecoverable. Caller should
|
||||
// swap the bubble copy to the "verloren" string. Distinct from
|
||||
// onGiveUp (which fires only on the local timeout).
|
||||
onLost?: () => void;
|
||||
onGiveUp?: () => void;
|
||||
}
|
||||
|
||||
@@ -35,6 +48,20 @@ export interface LatePollHandle {
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
interface RecoverResponse {
|
||||
turn_id: string;
|
||||
started_at: string;
|
||||
response: string | null;
|
||||
error_code: string | null;
|
||||
finished_at: string | null;
|
||||
duration_ms: number | null;
|
||||
used_tools: string[];
|
||||
rows_seen: number[];
|
||||
chip_count: number;
|
||||
classifier_tag: string | null;
|
||||
recovery_state: "recovered" | "pending" | "lost";
|
||||
}
|
||||
|
||||
export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
|
||||
const interval = opts.intervalMs ?? 3000;
|
||||
const maxDuration = opts.maxDurationMs ?? 10 * 60 * 1000;
|
||||
@@ -50,18 +77,24 @@ export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`/api/paliadin/turns/${opts.turnId}`, {
|
||||
const r = await fetch(`/api/paliadin/turns/${opts.turnId}/recover`, {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (r.ok) {
|
||||
const turn = (await r.json()) as LateTurn;
|
||||
if (turn.response && turn.response.length > 0) {
|
||||
opts.onLateResponse(turn);
|
||||
const body = (await r.json()) as RecoverResponse;
|
||||
if (body.recovery_state === "recovered" && body.response) {
|
||||
opts.onLateResponse(toLateTurn(body));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 404: row gone (very unlikely) — give up.
|
||||
if (r.status === 404) {
|
||||
if (body.recovery_state === "lost") {
|
||||
opts.onLost?.();
|
||||
return;
|
||||
}
|
||||
// pending — keep polling
|
||||
} else if (r.status === 404) {
|
||||
// Row gone — give up. Different signal from `lost`: a missing row
|
||||
// is a paliad-side bookkeeping problem; aichat may still have the
|
||||
// answer but we can't surface it without the row.
|
||||
opts.onGiveUp?.();
|
||||
return;
|
||||
}
|
||||
@@ -72,7 +105,8 @@ export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
|
||||
};
|
||||
|
||||
// First poll deliberately runs after one interval so we don't race
|
||||
// the 60 s timeout on the very first tick.
|
||||
// the dispatch endpoint on the very first tick (gives the upstream a
|
||||
// moment to actually settle the row after the stream drop).
|
||||
timer = window.setTimeout(tick, interval);
|
||||
|
||||
return {
|
||||
@@ -82,3 +116,17 @@ export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toLateTurn(body: RecoverResponse): LateTurn {
|
||||
return {
|
||||
turn_id: body.turn_id,
|
||||
response: body.response,
|
||||
error_code: body.error_code,
|
||||
finished_at: body.finished_at,
|
||||
duration_ms: body.duration_ms,
|
||||
used_tools: body.used_tools ?? [],
|
||||
rows_seen: body.rows_seen ?? [],
|
||||
chip_count: body.chip_count ?? 0,
|
||||
classifier_tag: body.classifier_tag,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -381,11 +381,32 @@ async function sendTurn(): Promise<void> {
|
||||
const es = new EventSource(turnRes.sse_url);
|
||||
activeStream = es;
|
||||
|
||||
startWidgetThinking(placeholder);
|
||||
|
||||
let fullText = "";
|
||||
es.addEventListener("thinking", (ev) => {
|
||||
let elapsed = 0;
|
||||
try {
|
||||
const data = JSON.parse((ev as MessageEvent).data || "{}");
|
||||
if (typeof data.elapsed_seconds === "number") elapsed = data.elapsed_seconds;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
updateWidgetThinking(placeholder, elapsed);
|
||||
});
|
||||
es.addEventListener("content", (ev) => {
|
||||
try {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
if (typeof data.delta === "string" && data.delta) {
|
||||
// Streamed delta (aichat backend) — append.
|
||||
stopWidgetThinking(placeholder);
|
||||
fullText += data.delta;
|
||||
setBubbleText(placeholder, fullText);
|
||||
return;
|
||||
}
|
||||
// Legacy one-shot full-text payload.
|
||||
fullText = String(data.text || "");
|
||||
stopWidgetThinking(placeholder);
|
||||
setBubbleText(placeholder, fullText);
|
||||
} catch {
|
||||
/* ignore parse error */
|
||||
@@ -393,13 +414,15 @@ async function sendTurn(): Promise<void> {
|
||||
});
|
||||
es.addEventListener("end", () => {
|
||||
placeholder.dataset.streaming = "false";
|
||||
stopWidgetThinking(placeholder);
|
||||
history.push({ role: "assistant", text: fullText || "", ts: new Date().toISOString() });
|
||||
saveHistory();
|
||||
cleanupStream();
|
||||
});
|
||||
es.addEventListener("error", () => {
|
||||
stopWidgetThinking(placeholder);
|
||||
const errText = t("paliadin.error.connection_lost");
|
||||
setBubbleText(placeholder, errText + " " + t("paliadin.late.waiting"));
|
||||
setBubbleText(placeholder, errText + " " + t("paliadin.late.checking"));
|
||||
placeholder.classList.add("paliadin-widget-bubble--error");
|
||||
placeholder.classList.add("paliadin-widget-bubble--late-pending");
|
||||
placeholder.dataset.streaming = "false";
|
||||
@@ -412,6 +435,39 @@ async function sendTurn(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function startWidgetThinking(bubble: HTMLElement): void {
|
||||
if (bubble.querySelector(".paliadin-widget-thinking")) return;
|
||||
// Clear the static placeholder text — the live pulse + counter is
|
||||
// the canonical "denkt nach" signal.
|
||||
const textNode = bubble.querySelector(".paliadin-widget-bubble-text");
|
||||
if (textNode) textNode.textContent = "";
|
||||
const node = document.createElement("div");
|
||||
node.className = "paliadin-widget-thinking";
|
||||
node.innerHTML = `
|
||||
<span class="paliadin-widget-thinking-dot" aria-hidden="true"></span>
|
||||
<span class="paliadin-widget-thinking-label"></span>
|
||||
<span class="paliadin-widget-thinking-elapsed"></span>
|
||||
`;
|
||||
const label = node.querySelector(".paliadin-widget-thinking-label")!;
|
||||
label.textContent = t("paliadin.thinking");
|
||||
bubble.appendChild(node);
|
||||
updateWidgetThinking(bubble, 0);
|
||||
}
|
||||
|
||||
function updateWidgetThinking(bubble: HTMLElement, elapsedSeconds: number): void {
|
||||
const node = bubble.querySelector(".paliadin-widget-thinking") as HTMLElement | null;
|
||||
if (!node) return;
|
||||
const elapsed = node.querySelector(".paliadin-widget-thinking-elapsed");
|
||||
if (elapsed) {
|
||||
const s = elapsedSeconds < 0 ? 0 : Math.round(elapsedSeconds);
|
||||
elapsed.textContent = t("paliadin.thinking.seconds").replace("{seconds}", String(s));
|
||||
}
|
||||
}
|
||||
|
||||
function stopWidgetThinking(bubble: HTMLElement): void {
|
||||
bubble.querySelector(".paliadin-widget-thinking")?.remove();
|
||||
}
|
||||
|
||||
function cleanupStream(): void {
|
||||
activeStream?.close();
|
||||
activeStream = null;
|
||||
@@ -427,13 +483,24 @@ function startWidgetLatePoll(turnId: string, bubble: HTMLElement): void {
|
||||
lateWidgetPolls.delete(turnId);
|
||||
applyWidgetLateResponse(bubble, turn);
|
||||
},
|
||||
onLost: () => {
|
||||
lateWidgetPolls.delete(turnId);
|
||||
applyWidgetLost(bubble);
|
||||
},
|
||||
onGiveUp: () => {
|
||||
lateWidgetPolls.delete(turnId);
|
||||
applyWidgetLost(bubble);
|
||||
},
|
||||
});
|
||||
lateWidgetPolls.set(turnId, handle);
|
||||
}
|
||||
|
||||
function applyWidgetLost(bubble: HTMLElement): void {
|
||||
bubble.classList.remove("paliadin-widget-bubble--late-pending");
|
||||
bubble.classList.add("paliadin-widget-bubble--lost");
|
||||
setBubbleText(bubble, t("paliadin.late.lost"));
|
||||
}
|
||||
|
||||
function applyWidgetLateResponse(bubble: HTMLElement, turn: LateTurn): void {
|
||||
if (!turn.response) return;
|
||||
bubble.classList.remove(
|
||||
|
||||
@@ -3,16 +3,25 @@ import { initSidebar } from "./sidebar";
|
||||
import { renderResponseHTML } from "./paliadin-render";
|
||||
import { pollForLateResponse, type LateTurn, type LatePollHandle } from "./paliadin-late-poll";
|
||||
|
||||
// Paliadin chat panel client (t-paliad-146 PoC).
|
||||
// Paliadin chat panel client (t-paliad-146 PoC, streaming upgrade
|
||||
// t-paliad-235).
|
||||
//
|
||||
// State machine: empty → typing → sending → streaming → done.
|
||||
// State machine: empty → typing → sending → thinking → 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).
|
||||
// `event: thinking`, `event: end`, `event: error`, `event: ping`.
|
||||
//
|
||||
// `content` events from the aichat backend arrive as incremental
|
||||
// `{delta: "..."}` chunks; the bubble accumulates them in real time —
|
||||
// no typewriter simulation needed. Legacy backends still emit a single
|
||||
// `{text: "..."}` payload and we fall back to the typewriter for that
|
||||
// shape.
|
||||
//
|
||||
// `thinking` events fire while the upstream is alive but hasn't
|
||||
// produced content yet (or stalled mid-stream); the bubble renders a
|
||||
// pulse + counter so the user can SEE the chat is still working.
|
||||
|
||||
interface HistoryEntry {
|
||||
role: "user" | "assistant";
|
||||
@@ -167,25 +176,53 @@ async function sendTurn(text: string): Promise<void> {
|
||||
const es = new EventSource(turnRes.sse_url);
|
||||
currentEventSource = es;
|
||||
|
||||
// Show the thinking pulse immediately — the placeholder text already
|
||||
// says "denkt nach", but the visible pulse + counter is the live
|
||||
// proof-of-life signal m needs to trust that the chat is working.
|
||||
startThinkingIndicator(placeholder);
|
||||
// Reset the streamed accumulator for this turn.
|
||||
placeholder.dataset.fullText = "";
|
||||
|
||||
es.addEventListener("meta", () => {
|
||||
// Could surface a "thinking" indicator; placeholder text already does.
|
||||
});
|
||||
|
||||
es.addEventListener("thinking", (ev) => {
|
||||
let elapsed = 0;
|
||||
try {
|
||||
const data = JSON.parse((ev as MessageEvent).data || "{}");
|
||||
if (typeof data.elapsed_seconds === "number") {
|
||||
elapsed = data.elapsed_seconds;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
updateThinkingIndicator(placeholder, elapsed);
|
||||
});
|
||||
|
||||
es.addEventListener("content", (ev) => {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
const delta = typeof data.delta === "string" ? data.delta : "";
|
||||
if (delta) {
|
||||
// Aichat streaming path — accumulate the delta into the bubble.
|
||||
stopThinkingIndicator(placeholder);
|
||||
const current = placeholder.dataset.fullText ?? "";
|
||||
const next = current + delta;
|
||||
placeholder.dataset.fullText = next;
|
||||
writeStreamedText(placeholder, next);
|
||||
return;
|
||||
}
|
||||
// Legacy one-shot path — full body in `text`.
|
||||
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;
|
||||
stopThinkingIndicator(placeholder);
|
||||
typewriter(placeholder, text);
|
||||
});
|
||||
|
||||
es.addEventListener("end", (ev) => {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
placeholder.dataset.streaming = "false";
|
||||
stopThinkingIndicator(placeholder);
|
||||
finishBubble(placeholder, data);
|
||||
history.push({
|
||||
role: "assistant",
|
||||
@@ -210,12 +247,12 @@ async function sendTurn(text: string): Promise<void> {
|
||||
|
||||
es.addEventListener("error", (ev) => {
|
||||
const errText = friendlyErrorMessage((ev as MessageEvent).data);
|
||||
// Annotate the error bubble with a "warten auf späte Antwort" hint
|
||||
// so m knows the turn isn't dead; if Claude finishes after the
|
||||
// 60 s window the Janitor (services.LocalPaliadinService.runJanitor)
|
||||
// patches the row and pollForLateResponse swaps in the real reply.
|
||||
stopThinkingIndicator(placeholder);
|
||||
// Honest copy: we don't claim "nachgereicht" because the recovery
|
||||
// path may report "lost". Frame it as "checking" while we ask the
|
||||
// backend whether the turn actually completed upstream.
|
||||
placeholder.querySelector(".paliadin-bubble-text")!.textContent =
|
||||
errText + " " + t("paliadin.late.waiting");
|
||||
errText + " " + t("paliadin.late.checking");
|
||||
placeholder.classList.add("paliadin-bubble--error");
|
||||
placeholder.classList.add("paliadin-bubble--late-pending");
|
||||
placeholder.dataset.streaming = "false";
|
||||
@@ -232,6 +269,65 @@ async function sendTurn(text: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// thinking indicator — proof-of-life pulse + elapsed counter
|
||||
// =============================================================================
|
||||
|
||||
function startThinkingIndicator(bubble: HTMLElement): void {
|
||||
// Append a thinking node next to the bubble text (sibling, so the
|
||||
// typewriter rewriting text content doesn't clobber it). The node
|
||||
// shows a pulse dot + the elapsed counter.
|
||||
let node = bubble.querySelector(".paliadin-thinking") as HTMLElement | null;
|
||||
if (node) return; // already running
|
||||
// Clear the static placeholder text — the live pulse + counter is
|
||||
// now the canonical "denkt nach" signal. Leaving the text in place
|
||||
// would render the same phrase twice.
|
||||
const textNode = bubble.querySelector(".paliadin-bubble-text");
|
||||
if (textNode) textNode.textContent = "";
|
||||
node = document.createElement("div");
|
||||
node.className = "paliadin-thinking";
|
||||
node.innerHTML = `
|
||||
<span class="paliadin-thinking-dot" aria-hidden="true"></span>
|
||||
<span class="paliadin-thinking-label"></span>
|
||||
<span class="paliadin-thinking-elapsed"></span>
|
||||
`;
|
||||
const label = node.querySelector(".paliadin-thinking-label")!;
|
||||
label.textContent = t("paliadin.thinking");
|
||||
bubble.appendChild(node);
|
||||
// Initial 0s — replaced as soon as a thinking event arrives or our
|
||||
// local ticker fires.
|
||||
updateThinkingIndicator(bubble, 0);
|
||||
}
|
||||
|
||||
function updateThinkingIndicator(bubble: HTMLElement, elapsedSeconds: number): void {
|
||||
const node = bubble.querySelector(".paliadin-thinking") as HTMLElement | null;
|
||||
if (!node) return;
|
||||
const elapsed = node.querySelector(".paliadin-thinking-elapsed");
|
||||
if (elapsed) {
|
||||
elapsed.textContent = formatThinkingSeconds(elapsedSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
function stopThinkingIndicator(bubble: HTMLElement): void {
|
||||
bubble.querySelector(".paliadin-thinking")?.remove();
|
||||
}
|
||||
|
||||
function formatThinkingSeconds(s: number): string {
|
||||
if (s < 0) s = 0;
|
||||
return t("paliadin.thinking.seconds").replace("{seconds}", String(Math.round(s)));
|
||||
}
|
||||
|
||||
// writeStreamedText fills the bubble with raw text as it accumulates.
|
||||
// Cheaper than the typewriter — we already have the real cadence from
|
||||
// the wire, no need to simulate it.
|
||||
function writeStreamedText(bubble: HTMLElement, text: string): void {
|
||||
const node = bubble.querySelector(".paliadin-bubble-text");
|
||||
if (!node) return;
|
||||
node.textContent = text;
|
||||
const stream = document.getElementById("paliadin-stream");
|
||||
if (stream) stream.scrollTop = stream.scrollHeight;
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -361,11 +457,12 @@ function finishBubble(bubble: HTMLElement, data: any): void {
|
||||
}
|
||||
|
||||
|
||||
// startLatePoll registers the Janitor-patched row poller for one
|
||||
// errored turn. When the row gains a response we swap the bubble's
|
||||
// content + drop the error class + retroactively replace the history
|
||||
// entry (which was never written for the failed turn — append now so
|
||||
// reload renders the late reply).
|
||||
// startLatePoll registers the recovery-endpoint poller for one errored
|
||||
// turn. When the row gains a response we swap the bubble's content +
|
||||
// drop the error class + retroactively replace the history entry
|
||||
// (which was never written for the failed turn — append now so reload
|
||||
// renders the late reply). When the backend confirms the turn is
|
||||
// "lost", we swap the bubble to the honest "verloren" copy.
|
||||
function startLatePoll(turnId: string, bubble: HTMLElement): void {
|
||||
// Avoid duplicate pollers for the same turn (e.g. SSE error fires
|
||||
// twice in some browsers when the connection drops).
|
||||
@@ -376,13 +473,25 @@ function startLatePoll(turnId: string, bubble: HTMLElement): void {
|
||||
latePolls.delete(turnId);
|
||||
applyLateResponse(bubble, turn);
|
||||
},
|
||||
onLost: () => {
|
||||
latePolls.delete(turnId);
|
||||
applyLostResponse(bubble);
|
||||
},
|
||||
onGiveUp: () => {
|
||||
latePolls.delete(turnId);
|
||||
applyLostResponse(bubble);
|
||||
},
|
||||
});
|
||||
latePolls.set(turnId, handle);
|
||||
}
|
||||
|
||||
function applyLostResponse(bubble: HTMLElement): void {
|
||||
bubble.classList.remove("paliadin-bubble--late-pending");
|
||||
bubble.classList.add("paliadin-bubble--lost");
|
||||
const node = bubble.querySelector(".paliadin-bubble-text");
|
||||
if (node) node.textContent = t("paliadin.late.lost");
|
||||
}
|
||||
|
||||
function applyLateResponse(bubble: HTMLElement, turn: LateTurn): void {
|
||||
if (!turn.response) return;
|
||||
bubble.classList.remove("paliadin-bubble--error", "paliadin-bubble--late-pending");
|
||||
|
||||
@@ -1985,8 +1985,11 @@ export type I18nKey =
|
||||
| "paliadin.error.shim_error"
|
||||
| "paliadin.error.timeout"
|
||||
| "paliadin.error.upstream"
|
||||
| "paliadin.error.upstream_silence"
|
||||
| "paliadin.heading"
|
||||
| "paliadin.input.placeholder"
|
||||
| "paliadin.late.checking"
|
||||
| "paliadin.late.lost"
|
||||
| "paliadin.late.marker"
|
||||
| "paliadin.late.waiting"
|
||||
| "paliadin.reset"
|
||||
@@ -1996,6 +1999,8 @@ export type I18nKey =
|
||||
| "paliadin.starter.week"
|
||||
| "paliadin.stop"
|
||||
| "paliadin.tagline"
|
||||
| "paliadin.thinking"
|
||||
| "paliadin.thinking.seconds"
|
||||
| "paliadin.title"
|
||||
| "paliadin.widget.close"
|
||||
| "paliadin.widget.context.on_page"
|
||||
|
||||
@@ -13353,6 +13353,48 @@ dialog.quick-add-sheet::backdrop {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* lost: backend confirmed the turn is unrecoverable (t-paliad-235).
|
||||
Different from error: the upstream had a chance to finish but the
|
||||
conversation lookup didn't find a response — show the honest
|
||||
"verloren" copy. */
|
||||
.paliadin-bubble--lost {
|
||||
color: var(--status-red-fg);
|
||||
border-color: var(--status-red-border);
|
||||
background: var(--status-red-bg);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Thinking indicator (t-paliad-235) — proof-of-life pulse + elapsed
|
||||
counter while the upstream is alive but no content has streamed
|
||||
yet. Lives as a sibling node inside the assistant bubble; removed
|
||||
once the first chunk arrives. */
|
||||
.paliadin-thinking {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.paliadin-thinking-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-bg-lime);
|
||||
animation: paliadin-thinking-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.paliadin-thinking-elapsed {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@keyframes paliadin-thinking-pulse {
|
||||
0%, 100% { opacity: 0.4; transform: scale(0.9); }
|
||||
50% { opacity: 1.0; transform: scale(1.1); }
|
||||
}
|
||||
|
||||
.paliadin-bubble-role {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
@@ -14718,6 +14760,55 @@ dialog.quick-add-sheet::backdrop {
|
||||
border: 1px solid var(--status-red-border, var(--color-border));
|
||||
}
|
||||
|
||||
/* late-pending: stream dropped, recovery endpoint still polling. */
|
||||
.paliadin-widget-bubble--late-pending {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* late: response arrived after the stream closed. */
|
||||
.paliadin-widget-bubble--late {
|
||||
color: inherit;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.paliadin-widget-bubble-late-tag {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* lost: backend confirmed the turn is unrecoverable (t-paliad-235). */
|
||||
.paliadin-widget-bubble--lost {
|
||||
background: var(--status-red-bg, var(--color-surface-2));
|
||||
color: var(--status-red-fg, var(--color-text));
|
||||
border: 1px solid var(--status-red-border, var(--color-border));
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Thinking indicator inside widget bubbles (t-paliad-235). */
|
||||
.paliadin-widget-thinking {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.paliadin-widget-thinking-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-bg-lime);
|
||||
animation: paliadin-thinking-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.paliadin-widget-thinking-elapsed {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.paliadin-widget-form {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
||||
Reference in New Issue
Block a user