Files
lvmh/web/src/ChatStream.tsx
T

738 lines
20 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import type { ChatMessage, ToolState } from "./derive";
const PREVIEW_LEN: number = 120;
const COPY_FEEDBACK_MS: number = 1200;
const PIN_THRESHOLD_PX: number = 80;
const FAB_THRESHOLD_PX: number = 400;
const FENCE: string = "```";
const ENTER_KEY: string = "Enter";
const ESCAPE_KEY: string = "Escape";
const INLINE_RE: RegExp =
/(`[^`\n]+`)|(\[[^\]\n]+\]\([^)\s]+\))|(\*\*[^*\n]+\*\*)|(\*[^*\n]+\*)/g;
const SAFE_URL_RE: RegExp = /^https?:\/\//i;
// ---------- safe mini-markdown (no deps, no innerHTML) ----------
function highlight(text: string, query: string): React.ReactNode {
if (query.length === 0 || text.length === 0) return text;
const hay: string = text.toLowerCase();
const needle: string = query.toLowerCase();
if (!hay.includes(needle)) return text;
const parts: React.ReactNode[] = [];
let from = 0;
let at: number = hay.indexOf(needle);
let n = 0;
while (at !== -1) {
if (at > from) parts.push(text.slice(from, at));
parts.push(
<mark className="hit" key={`h-${n}`}>
{text.slice(at, at + needle.length)}
</mark>,
);
n += 1;
from = at + needle.length;
at = hay.indexOf(needle, from);
}
if (from < text.length) parts.push(text.slice(from));
return parts;
}
function tokenNode(tok: string, query: string, key: string): React.ReactNode {
if (tok.startsWith("`")) return <code key={key}>{tok.slice(1, -1)}</code>;
if (tok.startsWith("**"))
return <strong key={key}>{highlight(tok.slice(2, -2), query)}</strong>;
if (tok.startsWith("*"))
return <em key={key}>{highlight(tok.slice(1, -1), query)}</em>;
// link token; INLINE_RE guarantees the [label](url) form
const closeAt: number = tok.indexOf("]");
const label: string = tok.slice(1, closeAt);
const url: string = tok.slice(closeAt + 2, -1);
if (SAFE_URL_RE.test(url))
return (
<a key={key} href={url} target="_blank" rel="noopener noreferrer">
{label}
</a>
);
// non-http scheme (javascript:, data:, …): the anchor is dropped, the
// raw token stays visible as plain text
return <span key={key}>{highlight(tok, query)}</span>;
}
function inlineNodes(
text: string,
query: string,
keyBase: string,
): React.ReactNode[] {
const out: React.ReactNode[] = [];
let from = 0;
let n = 0;
for (const m of text.matchAll(INLINE_RE)) {
const tok: string = m[0];
const at: number = m.index ?? 0;
if (at > from) out.push(highlight(text.slice(from, at), query));
out.push(tokenNode(tok, query, `${keyBase}-${n}`));
n += 1;
from = at + tok.length;
}
if (from < text.length) out.push(highlight(text.slice(from), query));
return out;
}
/** ```fences``` → <pre class="md-code">, `code`, **bold**, *italic*,
* [t](http…) links (http/https only). Plain segments keep the bubble's
* pre-wrap. Unterminated fences (streaming) render the tail as code. */
export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
const out: React.ReactNode[] = [];
let plain: string[] = [];
let code: string[] | null = null;
let n = 0;
const flushPlain = (): void => {
if (plain.length > 0) {
out.push(...inlineNodes(plain.join("\n"), query, `p-${n}`));
n += 1;
plain = [];
}
};
for (const line of text.split("\n")) {
if (line.trimStart().startsWith(FENCE)) {
if (code === null) {
flushPlain();
code = [];
} else {
out.push(
<pre className="md-code" key={`c-${n}`}>
{code.join("\n")}
</pre>,
);
n += 1;
code = null;
}
} else if (code === null) {
plain.push(line);
} else {
code.push(line);
}
}
if (code === null) {
flushPlain();
} else {
out.push(
<pre className="md-code" key={`c-${n}`}>
{code.join("\n")}
</pre>,
);
}
return out;
}
function formatTs(ts: number): string {
return new Date(ts).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
function searchHaystack(m: ChatMessage): string {
const names: string[] = m.toolCalls.map((c) => c.name);
return `${m.text} ${m.thinking ?? ""} ${names.join(" ")}`.toLowerCase();
}
function oneLine(text: string): string {
const flat = text.replace(/\s+/g, " ").trim();
return flat.length > PREVIEW_LEN ? `${flat.slice(0, PREVIEW_LEN)}…` : flat;
}
/** true when the standalone result bubble is skipped because its ToolCard
* (which carries the output preview) is already rendered */
function resultSkipped(m: ChatMessage, tools: Map<string, ToolState>): boolean {
return (
m.role === "toolResult" &&
m.toolCallId !== null &&
tools.has(m.toolCallId)
);
}
function CopyButton({ text }: { text: string }): React.ReactNode {
const [copied, setCopied] = useState<boolean>(false);
return (
<button
type="button"
className="msg-copy"
aria-label="Copy message"
onClick={() => {
void navigator.clipboard?.writeText(text).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), COPY_FEEDBACK_MS);
});
}}
>
{copied ? "copied" : "copy"}
</button>
);
}
// ---------- pretty tool args ----------
// Keys shown first when present (most readable "what is this tool doing"
// signal); the rest follow in their original order.
const ARG_PRIORITY: string[] = [
"command",
"path",
"file_path",
"pattern",
"url",
"query",
"content",
"prompt",
"task",
"description",
];
// Above this many lines per side, LCS is skipped and the whole old block is
// rendered removed + the whole new block added.
const DIFF_MAX_LINES: number = 400;
export interface ArgEntry {
k: string | null;
v: string;
}
/** Parse a tool's args (JSON object, JSON string, or raw text) into labeled
* display entries. A lone string entry renders without a label. */
export function toolArgsEntries(argsText: string): ArgEntry[] {
let parsed: unknown;
try {
parsed = JSON.parse(argsText);
} catch {
return [{ k: null, v: argsText }];
}
if (typeof parsed === "string") return [{ k: null, v: parsed }];
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
return [{ k: null, v: argsText }];
const obj = parsed as Record<string, unknown>;
const keys = Object.keys(obj);
const ordered = [
...ARG_PRIORITY.filter((k) => k in obj),
...keys.filter((k) => !ARG_PRIORITY.includes(k)),
];
const entries: ArgEntry[] = [];
for (const k of ordered) {
const raw = obj[k];
const v =
typeof raw === "string"
? raw
: (JSON.stringify(raw, null, 2) ?? String(raw));
entries.push({ k, v });
}
if (entries.length === 1 && entries[0] !== undefined)
return [{ k: null, v: entries[0].v }];
return entries;
}
/** One-line "what is this tool doing" for the collapsed summary line:
* the highest-priority arg's value (bash → command, read/write/edit → path). */
export function toolSummaryText(argsText: string): string {
const first = toolArgsEntries(argsText)[0];
return first === undefined ? "" : first.v;
}
// ---------- edit/write diff view ----------
export interface DiffLine {
kind: "ctx" | "add" | "del";
text: string;
}
/** Line-level LCS diff between two text blocks. */
export function lineDiff(a: string, b: string): DiffLine[] {
const left: string[] = a.split("\n");
const right: string[] = b.split("\n");
if (left.length > DIFF_MAX_LINES || right.length > DIFF_MAX_LINES)
return [
...left.map((text): DiffLine => ({ kind: "del", text })),
...right.map((text): DiffLine => ({ kind: "add", text })),
];
const n: number = left.length;
const m: number = right.length;
const dp: number[][] = Array.from({ length: n + 1 }, () =>
Array.from({ length: m + 1 }, (): number => 0),
);
for (let i = n - 1; i >= 0; i--) {
const row: number[] | undefined = dp[i];
if (row === undefined) continue;
for (let j = m - 1; j >= 0; j--) {
const a: string | undefined = left[i];
const b: string | undefined = right[j];
row[j] =
a !== undefined && a === b
? (dp[i + 1]?.[j + 1] ?? 0) + 1
: Math.max(dp[i + 1]?.[j] ?? 0, dp[i]?.[j + 1] ?? 0);
}
}
const out: DiffLine[] = [];
let i: number = 0;
let j: number = 0;
while (i < n && j < m) {
const a: string | undefined = left[i];
const b: string | undefined = right[j];
if (a !== undefined && a === b) {
out.push({ kind: "ctx", text: a });
i += 1;
j += 1;
} else if ((dp[i + 1]?.[j] ?? 0) >= (dp[i]?.[j + 1] ?? 0)) {
if (a !== undefined) out.push({ kind: "del", text: a });
i += 1;
} else {
if (b !== undefined) out.push({ kind: "add", text: b });
j += 1;
}
}
for (; i < n; i++) {
const a: string | undefined = left[i];
if (a !== undefined) out.push({ kind: "del", text: a });
}
for (; j < m; j++) {
const b: string | undefined = right[j];
if (b !== undefined) out.push({ kind: "add", text: b });
}
return out;
}
interface TextEdit {
oldText: string;
newText: string;
}
function parseArgsObj(argsText: string): Record<string, unknown> | null {
try {
const p: unknown = JSON.parse(argsText);
return typeof p === "object" && p !== null && !Array.isArray(p)
? (p as Record<string, unknown>)
: null;
} catch {
return null;
}
}
/** Diff blocks for edit/write tool args; null when the tool is neither or
* the args do not carry the expected fields. */
export function toolDiffBlocks(
name: string,
argsText: string,
): DiffLine[][] | null {
const obj: Record<string, unknown> | null = parseArgsObj(argsText);
if (obj === null) return null;
if (name === "write" && typeof obj.content === "string")
return [
obj.content
.split("\n")
.slice(0, DIFF_MAX_LINES)
.map((text): DiffLine => ({ kind: "add", text })),
];
if (name !== "edit") return null;
const edits: TextEdit[] = [];
if (Array.isArray(obj.edits)) {
for (const e of obj.edits) {
if (
typeof e === "object" &&
e !== null &&
typeof (e as Record<string, unknown>).oldText === "string" &&
typeof (e as Record<string, unknown>).newText === "string"
)
edits.push(e as TextEdit);
}
} else if (
typeof obj.oldText === "string" &&
typeof obj.newText === "string"
) {
edits.push({ oldText: obj.oldText, newText: obj.newText });
}
return edits.length === 0
? null
: edits.map((e): DiffLine[] => lineDiff(e.oldText, e.newText));
}
const DIFF_MARK: Record<DiffLine["kind"], string> = {
add: "+",
del: "-",
ctx: " ",
};
function DiffBlock({ lines }: { lines: DiffLine[] }): React.ReactNode {
return (
<pre className="diff">
{lines.map((l, i) => (
<span key={i} className={`diff-line diff-${l.kind}`}>
{DIFF_MARK[l.kind] + l.text}
{"\n"}
</span>
))}
</pre>
);
}
// Well-known tools get a compact glyph + their primary arg instead of
// the raw tool name (bash → "$ ls -la", read → "📄 src/main.ts", …).
const TOOL_ICON: Record<string, string> = {
bash: "$",
read: "📄",
edit: "✎",
write: "📝",
};
function ToolCard({ tool }: { tool: ToolState }) {
const status: string = tool.running ? "⋯" : tool.isError ? "✗" : "✓";
const statusClass: string = tool.isError
? "tool-status-err"
: tool.running
? "tool-status-run"
: "tool-status-ok";
const summary: string = oneLine(toolSummaryText(tool.args));
const icon: string | undefined = TOOL_ICON[tool.name];
const label: string =
icon === undefined ? tool.name : `${icon} ${summary}`.trim();
const rest: string = icon === undefined ? summary : "";
const diffBlocks: DiffLine[][] | null = toolDiffBlocks(tool.name, tool.args);
return (
<details className="tool-card">
<summary>
<span className="tool-label">{label}</span>
{rest.length > 0 && <span className="tool-summary">{rest}</span>}
<span className={`tool-status ${statusClass}`}>{status}</span>
</summary>
<div className="tool-body">
{diffBlocks === null ? (
<div className="tool-args">
{toolArgsEntries(tool.args).map((e, i) => (
<div key={e.k ?? i} className="tool-arg">
{e.k !== null && <span className="arg-k">{e.k}</span>}
<pre className="arg-v">{e.v}</pre>
</div>
))}
</div>
) : (
<div className="tool-diffs">
{diffBlocks.map((b, i) => (
<DiffBlock key={i} lines={b} />
))}
</div>
)}
<div>
<span className="arg-k">output</span>
<pre style={{ margin: "4px 0 0", whiteSpace: "pre-wrap" }}>
{tool.preview}
</pre>
</div>
</div>
</details>
);
}
function Thinking({ text }: { text: string }) {
return (
<details className="thinking">
<summary>thinking</summary>
<div className="thinking-body">{text}</div>
</details>
);
}
export function Bubble({
msg,
tools,
query = "",
showTs = false,
}: {
msg: ChatMessage;
tools: Map<string, ToolState>;
/** live search query: matches in plain text get <mark class="hit"> */
query?: string;
showTs?: boolean;
}) {
if (msg.notice === true) {
return (
<div className="bubble-row system">
<div className="notice-line"> {msg.text}</div>
</div>
);
}
const rowClass =
msg.role === "user"
? "user"
: msg.role === "assistant"
? "assistant"
: "system";
const msgTools: ToolState[] = [];
if (msg.role === "assistant") {
for (const c of msg.toolCalls) {
const t = tools.get(c.id);
if (t !== undefined) msgTools.push(t);
}
}
// the ToolCard above already carries this result (output preview): skip
// the duplicate standalone "result" bubble when the tool state exists
if (resultSkipped(msg, tools)) return null;
const copyable: boolean =
msg.role === "assistant" || (msg.role === "user" && msg.text.length > 0);
const tsTitle: string = msg.ts > 0 ? new Date(msg.ts).toISOString() : "";
return (
<div
className={`bubble-row ${rowClass}`}
data-key={msg.key}
title={tsTitle.length > 0 ? tsTitle : undefined}
>
{copyable && <CopyButton text={msg.text} />}
<div className="bubble">
{msg.thinking !== null && msg.thinking.length > 0 && (
<Thinking text={msg.thinking} />
)}
{msgTools.map((t) => (
<ToolCard key={t.id} tool={t} />
))}
{msg.role === "toolResult" ? (
<details className="tool-card">
<summary>
<span className="tool-name">result</span>
<span>{oneLine(msg.text)}</span>
</summary>
<div className="tool-body">{msg.text}</div>
</details>
) : (
msg.text.length > 0 && <div>{renderMarkdown(msg.text, query)}</div>
)}
{msg.streaming && <span className="stream-caret"></span>}
</div>
{showTs && msg.ts > 0 && (
<div className="ts" title={tsTitle}>
{formatTs(msg.ts)}
</div>
)}
</div>
);
}
export function TypingIndicator() {
return (
<div className="bubble-row assistant" aria-live="polite">
<div className="typing">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</div>
</div>
);
}
interface Props {
messages: ChatMessage[];
tools: Map<string, ToolState>;
busy: boolean;
/** messages queued client-side while the agent runs; sent on settle */
queued: string[];
/** drop a queued message before it is sent (the ✕ on a queued bubble) */
unqueue: (index: number) => void;
/** an older page exists beyond the loaded window (B1) */
hasOlder: boolean;
loadingOlder: boolean;
onLoadOlder: () => void;
/** search overlay open (the 🔍 button or the / hotkey in ChatView) */
searchOpen: boolean;
onSearchClose: () => void;
/** per-bubble timestamps (⋯ menu in ChatView) */
showTs: boolean;
}
export default function ChatStream({
messages,
tools,
busy,
queued,
unqueue,
hasOlder,
loadingOlder,
onLoadOlder,
searchOpen,
onSearchClose,
showTs,
}: Props) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const pinnedRef = useRef<boolean>(true);
const rowRefs = useRef<Map<string, HTMLElement>>(new Map());
const [query, setQuery] = useState<string>("");
const [matchIdx, setMatchIdx] = useState<number>(0);
const [showFab, setShowFab] = useState<boolean>(false);
const awayFromBottom = (): number => {
const el = scrollRef.current;
if (el === null) return 0;
return el.scrollHeight - el.scrollTop - el.clientHeight;
};
const onScroll = (): void => {
if (scrollRef.current === null) return;
const away: number = awayFromBottom();
pinnedRef.current = away < PIN_THRESHOLD_PX;
setShowFab(away > FAB_THRESHOLD_PX);
};
useEffect(() => {
const el = scrollRef.current;
if (el === null) return;
if (pinnedRef.current) el.scrollTop = el.scrollHeight;
setShowFab(awayFromBottom() > FAB_THRESHOLD_PX);
}, [messages, busy]);
useEffect(() => {
pinnedRef.current = true;
const el = scrollRef.current;
if (el !== null) el.scrollTop = el.scrollHeight;
}, []);
// overlay closed → query and cursor reset (ChatStream is keyed per session,
// but the overlay must also forget a stale query when merely closed)
useEffect(() => {
if (!searchOpen) {
setQuery("");
setMatchIdx(0);
}
}, [searchOpen]);
const matches: string[] = useMemo(() => {
const q: string = query.trim().toLowerCase();
if (q.length === 0) return [];
return messages
.filter(
(m) => !resultSkipped(m, tools) && searchHaystack(m).includes(q),
)
.map((m) => m.key);
}, [messages, tools, query]);
// live events can shrink the match set under the cursor
useEffect(() => {
setMatchIdx((i) =>
matches.length === 0 ? 0 : Math.min(i, matches.length - 1),
);
}, [matches.length]);
const cycleMatch = (dir: 1 | -1): void => {
if (matches.length === 0) return;
const len: number = matches.length;
const next: number = (((matchIdx + dir) % len) + len) % len;
setMatchIdx(next);
const key: string | undefined = matches[next];
if (key !== undefined)
rowRefs.current.get(key)?.scrollIntoView({ block: "center" });
};
const jumpToBottom = (): void => {
const el = scrollRef.current;
if (el === null) return;
pinnedRef.current = true;
el.scrollTop = el.scrollHeight;
setShowFab(false);
};
const currentKey: string | undefined =
query.length > 0 && matches.length > 0 ? matches[matchIdx] : undefined;
const last: ChatMessage | undefined = messages[messages.length - 1];
const streamingOpen: boolean = last !== undefined && last.streaming;
const showTyping: boolean = busy && !streamingOpen;
return (
<div className="chat-stream">
<div className="chat-scroll" ref={scrollRef} onScroll={onScroll}>
<div className="chat-inner">
{hasOlder && (
<div className="load-older">
<button
type="button"
className="btn-secondary"
aria-label="Load older messages"
disabled={loadingOlder}
onClick={onLoadOlder}
>
{loadingOlder ? "loading…" : "Load older"}
</button>
</div>
)}
{messages.map((m) => (
<div
key={m.key}
className={m.key === currentKey ? "msg-current" : undefined}
ref={(el: HTMLDivElement | null): void => {
if (el === null) rowRefs.current.delete(m.key);
else rowRefs.current.set(m.key, el);
}}
>
<Bubble msg={m} tools={tools} query={query} showTs={showTs} />
</div>
))}
{queued.map((text, i) => (
<div className="bubble-row user" key={`q-${i}-${text}`}>
<div className="bubble queued-bubble">
<div className="queued-line">{text}</div>
<button
type="button"
className="icon-btn queued-remove"
aria-label="Remove queued message"
onClick={() => unqueue(i)}
>
</button>
</div>
</div>
))}
{showTyping && <TypingIndicator />}
</div>
</div>
{searchOpen && (
<div className="search-bar">
<input
type="text"
autoFocus
value={query}
placeholder="Search…"
aria-label="Search"
onChange={(e) => {
setQuery(e.target.value);
setMatchIdx(0);
}}
onKeyDown={(e) => {
if (e.key === ESCAPE_KEY) onSearchClose();
else if (e.key === ENTER_KEY) {
e.preventDefault();
cycleMatch(e.shiftKey ? -1 : 1);
}
}}
/>
{query.length > 0 && (
<span className="search-count" aria-live="polite">
{matches.length === 0 ? 0 : matchIdx + 1}/{matches.length}
</span>
)}
<button
type="button"
className="icon-btn"
aria-label="Close search"
onClick={onSearchClose}
>
</button>
</div>
)}
{showFab && (
<button
type="button"
className="scroll-fab"
aria-label="Jump to latest"
onClick={jumpToBottom}
>
</button>
)}
</div>
);
}