134 lines
3.9 KiB
TypeScript
134 lines
3.9 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
import type { ChatMessage, ToolState } from "./derive";
|
|
|
|
const PREVIEW_LEN: number = 120;
|
|
|
|
function oneLine(text: string): string {
|
|
const flat = text.replace(/\s+/g, " ").trim();
|
|
return flat.length > PREVIEW_LEN ? `${flat.slice(0, PREVIEW_LEN)}…` : flat;
|
|
}
|
|
|
|
function ToolCard({ tool }: { tool: ToolState }) {
|
|
const status: string = tool.running
|
|
? "running…"
|
|
: tool.isError
|
|
? "error"
|
|
: "done";
|
|
const statusClass: string = tool.isError ? "tool-status-err" : "tool-status-ok";
|
|
return (
|
|
<details className="tool-card">
|
|
<summary>
|
|
<span className="tool-name">🛠 {tool.name}</span>
|
|
<span className={tool.running ? "" : statusClass}>{tool.running ? "working…" : status}</span>
|
|
</summary>
|
|
<div className="tool-body">
|
|
<div>
|
|
<strong>args</strong>
|
|
<pre style={{ margin: "4px 0 10px", whiteSpace: "pre-wrap" }}>{tool.args}</pre>
|
|
</div>
|
|
<div>
|
|
<strong>result</strong>
|
|
<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 }: { msg: ChatMessage; tools: Map<string, ToolState> }) {
|
|
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);
|
|
}
|
|
}
|
|
return (
|
|
<div className={`bubble-row ${rowClass}`}>
|
|
<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>{msg.text}</div>
|
|
)}
|
|
{msg.streaming && <span className="stream-caret">▍</span>}
|
|
</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;
|
|
}
|
|
|
|
export default function ChatStream({ messages, tools, busy }: Props) {
|
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
const pinnedRef = useRef<boolean>(true);
|
|
|
|
const onScroll = (): void => {
|
|
const el = scrollRef.current;
|
|
if (el === null) return;
|
|
pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
|
};
|
|
|
|
useEffect(() => {
|
|
const el = scrollRef.current;
|
|
if (el !== null && pinnedRef.current) el.scrollTop = el.scrollHeight;
|
|
}, [messages, busy]);
|
|
|
|
useEffect(() => {
|
|
pinnedRef.current = true;
|
|
const el = scrollRef.current;
|
|
if (el !== null) el.scrollTop = el.scrollHeight;
|
|
}, []);
|
|
|
|
const last: ChatMessage | undefined = messages[messages.length - 1];
|
|
const streamingOpen: boolean = last !== undefined && last.streaming;
|
|
const showTyping: boolean = busy && !streamingOpen;
|
|
|
|
return (
|
|
<div className="chat-scroll" ref={scrollRef} onScroll={onScroll}>
|
|
<div className="chat-inner">
|
|
{messages.map((m) => (
|
|
<Bubble key={m.key} msg={m} tools={tools} />
|
|
))}
|
|
{showTyping && <TypingIndicator />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|