Files
beaver-gateway/ui/src/lib/panel/chat-view.svelte
T

344 lines
10 KiB
Svelte

<script lang="ts">
import { tick, untrack } from "svelte";
import type { ApiClient } from "$lib/api/client";
import type { ContentBlock, HistoryMessage } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import { clip, fmtDateTime, fmtTime } from "$lib/format";
import { cn } from "$lib/utils";
import type { ActivityModel } from "./activity.svelte";
import { summarizeInput, toolLabel } from "./activity.svelte";
import { usePanelHost } from "./host";
import Markdown from "./markdown.svelte";
import QuestionCard from "./question-card.svelte";
import TurnCard from "./turn-card.svelte";
let {
client,
conversationId,
model,
refreshKey = 0,
}: {
client: ApiClient;
conversationId: string;
model: ActivityModel;
refreshKey?: number;
} = $props();
const RESULT_CLIP = 600;
const CACHED_MESSAGES = 80;
const host = usePanelHost();
const NEAR_BOTTOM_PX = 120;
const TICK_MS = 1000;
let messages = $state<HistoryMessage[] | null>(null);
let failure = $state<string | null>(null);
let showResults = $state(false);
let loadedAt = $state(new Date(0).toISOString());
let scroller = $state<HTMLDivElement | null>(null);
let body = $state<HTMLDivElement | null>(null);
const SETTLE_MS = 2500;
const SETTLE_STEP_MS = 120;
let settleUntil = 0;
let now = $state(Date.now());
let pinned = true;
const tail = $derived(
model.turns
.filter((turn) => turn.status === "running" || turn.startedAt > loadedAt)
.reverse()
);
const tailSize = $derived(
tail.reduce(
(n, turn) => n + turn.text.length + Object.keys(turn.nodes).length,
0
)
);
async function load() {
failure = null;
if (messages === null) {
const cached = host.cache?.get<HistoryMessage[]>(
`history:${conversationId}`
);
if (cached) {
messages = cached;
}
}
try {
({ messages } = await client.history(conversationId));
loadedAt = new Date().toISOString();
settle();
host.cache?.set(
`history:${conversationId}`,
messages.slice(-CACHED_MESSAGES)
);
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
}
}
// The scroller is column-reverse: its origin is the bottom, scrollTop is
// zero there and goes negative upward, and the browser keeps the view at
// the bottom while content grows - no matter how late markdown lands.
function nearBottom(): boolean {
return !scroller || Math.abs(scroller.scrollTop) < NEAR_BOTTOM_PX;
}
function toBottom() {
if (scroller) {
scroller.scrollTop = 0;
}
}
async function scrollToBottom() {
await tick();
toBottom();
}
// A fresh thread keeps growing after it is on screen: markdown renders
// late, embeds load, fonts land. Hold the bottom for a while after a load.
function settle() {
settleUntil = Date.now() + SETTLE_MS;
const step = () => {
if (!pinned || Date.now() > settleUntil) {
return;
}
toBottom();
setTimeout(step, SETTLE_STEP_MS);
};
step();
}
// The date changes rarely; say it once, then only the time.
function dayOf(iso: string | null | undefined): string {
return iso ? iso.slice(0, 10) : "";
}
function minuteOf(iso: string | null | undefined): string {
return iso ? fmtTime(iso).replace(SECONDS, "") : "";
}
$effect(() => {
if (refreshKey >= 0) {
pinned = true;
untrack(() => load());
}
});
$effect(() => {
const count = (messages?.length ?? 0) + tailSize;
if (pinned && count >= 0) {
scrollToBottom();
}
});
// Markdown lands asynchronously (Obsidian renders after the DOM exists),
// so follow the content's height, not just the message count.
$effect(() => {
if (!(scroller && body)) {
return;
}
const observer = new ResizeObserver(() => {
if (pinned && scroller) {
scroller.scrollTop = scroller.scrollHeight;
}
});
observer.observe(body);
return () => observer.disconnect();
});
$effect(() => {
const timer = setInterval(() => {
if (model.running) {
now = Date.now();
}
}, TICK_MS);
return () => clearInterval(timer);
});
const SYSTEM_HEAD = /^\[[^\]\n]+\]/;
const SECONDS = /:\d{2}$/;
const DAY_TIME = /,?\s*\d{2}:\d{2}$/;
let openSystem = $state<Set<number>>(new Set());
function systemText(message: HistoryMessage): string | null {
if (message.role !== "user" || typeof message.content !== "string") {
return null;
}
return SYSTEM_HEAD.test(message.content.trimStart())
? message.content
: null;
}
function toggleSystem(index: number) {
const next = new Set(openSystem);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
openSystem = next;
}
function blocks(message: HistoryMessage): ContentBlock[] {
return typeof message.content === "string"
? [{ text: message.content, type: "text" }]
: message.content;
}
function resultText(block: ContentBlock): string {
const { content } = block;
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
return content
.map((part) =>
part && typeof part === "object" && "text" in part
? String((part as { text: unknown }).text)
: ""
)
.join("\n");
}
return "";
}
</script>
<div
class="flex min-h-0 flex-1 flex-col-reverse overflow-y-auto overflow-x-hidden px-3 py-3 [overflow-anchor:none] @md:px-6"
onscroll={() => {
if (Date.now() > settleUntil) {
pinned = nearBottom();
}
}}
bind:this={scroller}
>
<div class="flex min-w-0 max-w-full flex-col gap-3" bind:this={body}>
<button
class="rule-word self-end text-xs"
data-active={showResults}
onclick={() => {
showResults = !showResults;
}}
type="button"
>
{showResults ? "hide tool results" : "show tool results"}
</button>
{#if failure}
<ErrorNote message={failure} retry={load} />
{:else if messages === null}
<p class="py-6 text-center text-muted-foreground text-xs">
Loading the thread…
</p>
{:else if messages.length === 0 && tail.length === 0}
<EmptyState
hint="Nothing has been said here yet. Write below, or wait for the first inject."
title="Empty conversation"
/>
{:else}
{#each messages as message, index (index)}
{@const parts = blocks(message)}
{@const isResultOnly = parts.every((b) => b.type === "tool_result")}
{@const system = systemText(message)}
{@const newDay =
message.ts && dayOf(message.ts) !== dayOf(messages[index - 1]?.ts)}
{@const newMinute =
message.ts &&
minuteOf(message.ts) !== minuteOf(messages[index - 1]?.ts)}
{@const spoken =
message.role === "user" ||
parts.some((b) => b.type === "text" && Boolean(b.text))}
{#if newDay}
<p class="label-quiet self-center pt-2">
{fmtDateTime(message.ts).replace(DAY_TIME, "")}
</p>
{/if}
{#if system}
<div class="flex min-w-0 items-start justify-center gap-2">
{#if message.ts}
<span
class="tabular shrink-0 py-1 text-[11px] text-muted-foreground opacity-60"
title={fmtDateTime(message.ts)}
>
{minuteOf(message.ts)}
</span>
{/if}
<button
aria-expanded={openSystem.has(index)}
class="@md:max-w-[75ch] min-w-0 max-w-full rounded-md px-2 py-1 text-left font-mono text-muted-foreground text-xs hover:bg-accent"
onclick={() => toggleSystem(index)}
type="button"
>
{#if openSystem.has(index)}
<span class="whitespace-pre-wrap break-words">{system}</span>
{:else}
<span class="block truncate"
>{clip(system.split("\n")[0], 120)}</span
>
{/if}
</button>
</div>
{:else if !(isResultOnly && !showResults)}
<div
class={cn(
"group flex min-w-0 max-w-full flex-col gap-1.5",
message.role === "user" && !isResultOnly && "items-end"
)}
>
{#if message.ts && (newMinute || spoken) && !isResultOnly}
<span
class="tabular px-1 text-[11px] text-muted-foreground opacity-60 transition-opacity group-hover:opacity-100"
title={fmtDateTime(message.ts)}
>
{minuteOf(message.ts)}
</span>
{/if}
{#each parts as block, blockIndex (blockIndex)}
{#if block.type === "text" && block.text}
<Markdown
class={cn(
"min-w-0 @md:max-w-[75ch] max-w-full",
message.role === "user"
? "rounded-2xl rounded-br-md bg-primary/10 px-3.5 py-2"
: "px-1 py-1"
)}
text={block.text}
/>
{:else if block.type === "tool_use"}
<p
class="flex min-w-0 max-w-full items-baseline gap-2 px-1 text-xs"
>
<span class="shrink-0 font-medium"
>{toolLabel(block.name ?? "?")}</span
>
<span class="min-w-0 truncate text-muted-foreground">
{clip(summarizeInput(block.name ?? "", block.input), 160)}
</span>
</p>
{:else if block.type === "tool_result" && showResults}
<pre
class={cn(
"max-h-48 max-w-full overflow-auto whitespace-pre-wrap break-all rounded-md p-2 text-xs",
block.is_error
? "bg-destructive/8 text-destructive"
: "bg-muted/40"
)}
>{clip(resultText(block), RESULT_CLIP)}</pre>
{:else if block.type === "thinking"}
<p class="px-1 text-kind-deep text-xs">thinking</p>
{/if}
{/each}
</div>
{/if}
{/each}
{/if}
{#if model.question}
<QuestionCard {client} {conversationId} question={model.question} />
{/if}
{#each tail as turn (turn.id)}
<TurnCard {now} {turn} />
{/each}
</div>
</div>