fix(chat): stabilize web UI tool runs
This commit is contained in:
@@ -378,16 +378,20 @@ export function renderApp(state: AppViewState) {
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
state.chatStream = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
state.chatRunId = null;
|
||||
state.resetToolStream();
|
||||
state.resetChatScroll();
|
||||
state.applySettings({ ...state.settings, sessionKey: next });
|
||||
void loadChatHistory(state);
|
||||
},
|
||||
thinkingLevel: state.chatThinkingLevel,
|
||||
loading: state.chatLoading,
|
||||
sending: state.chatSending,
|
||||
messages: [...state.chatMessages, ...state.chatToolMessages],
|
||||
messages: state.chatMessages,
|
||||
toolMessages: state.chatToolMessages,
|
||||
stream: state.chatStream,
|
||||
streamStartedAt: state.chatStreamStartedAt,
|
||||
draft: state.chatMessage,
|
||||
connected: state.connected,
|
||||
canSend: state.connected,
|
||||
|
||||
@@ -178,6 +178,7 @@ export class ClawdbotApp extends LitElement {
|
||||
@state() hello: GatewayHelloOk | null = null;
|
||||
@state() lastError: string | null = null;
|
||||
@state() eventLog: EventLogEntry[] = [];
|
||||
private eventLogBuffer: EventLogEntry[] = [];
|
||||
|
||||
@state() sessionKey = this.settings.sessionKey;
|
||||
@state() chatLoading = false;
|
||||
@@ -186,6 +187,7 @@ export class ClawdbotApp extends LitElement {
|
||||
@state() chatMessages: unknown[] = [];
|
||||
@state() chatToolMessages: unknown[] = [];
|
||||
@state() chatStream: string | null = null;
|
||||
@state() chatStreamStartedAt: number | null = null;
|
||||
@state() chatRunId: string | null = null;
|
||||
@state() chatThinkingLevel: string | null = null;
|
||||
|
||||
@@ -341,6 +343,7 @@ export class ClawdbotApp extends LitElement {
|
||||
client: GatewayBrowserClient | null = null;
|
||||
private chatScrollFrame: number | null = null;
|
||||
private chatScrollTimeout: number | null = null;
|
||||
private chatHasAutoScrolled = false;
|
||||
private nodesPollInterval: number | null = null;
|
||||
private toolStreamById = new Map<string, ToolStreamEntry>();
|
||||
private toolStreamOrder: string[] = [];
|
||||
@@ -386,10 +389,14 @@ export class ClawdbotApp extends LitElement {
|
||||
changed.has("chatToolMessages") ||
|
||||
changed.has("chatStream") ||
|
||||
changed.has("chatLoading") ||
|
||||
changed.has("chatMessage") ||
|
||||
changed.has("tab"))
|
||||
) {
|
||||
this.scheduleChatScroll();
|
||||
const forcedByTab = changed.has("tab");
|
||||
const forcedByLoad =
|
||||
changed.has("chatLoading") &&
|
||||
changed.get("chatLoading") === true &&
|
||||
this.chatLoading === false;
|
||||
this.scheduleChatScroll(forcedByTab || forcedByLoad || !this.chatHasAutoScrolled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,7 +431,7 @@ export class ClawdbotApp extends LitElement {
|
||||
this.client.start();
|
||||
}
|
||||
|
||||
private scheduleChatScroll() {
|
||||
private scheduleChatScroll(force = false) {
|
||||
if (this.chatScrollFrame) cancelAnimationFrame(this.chatScrollFrame);
|
||||
if (this.chatScrollTimeout != null) {
|
||||
clearTimeout(this.chatScrollTimeout);
|
||||
@@ -434,11 +441,19 @@ export class ClawdbotApp extends LitElement {
|
||||
this.chatScrollFrame = null;
|
||||
const container = this.querySelector(".chat-thread") as HTMLElement | null;
|
||||
if (!container) return;
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
const shouldStick = force || distanceFromBottom < 140;
|
||||
if (!shouldStick) return;
|
||||
if (force) this.chatHasAutoScrolled = true;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
this.chatScrollTimeout = window.setTimeout(() => {
|
||||
this.chatScrollTimeout = null;
|
||||
const latest = this.querySelector(".chat-thread") as HTMLElement | null;
|
||||
if (!latest) return;
|
||||
const latestDistanceFromBottom =
|
||||
latest.scrollHeight - latest.scrollTop - latest.clientHeight;
|
||||
if (!force && latestDistanceFromBottom >= 180) return;
|
||||
latest.scrollTop = latest.scrollHeight;
|
||||
}, 120);
|
||||
});
|
||||
@@ -477,6 +492,10 @@ export class ClawdbotApp extends LitElement {
|
||||
this.chatToolMessages = [];
|
||||
}
|
||||
|
||||
resetChatScroll() {
|
||||
this.chatHasAutoScrolled = false;
|
||||
}
|
||||
|
||||
private trimToolStream() {
|
||||
if (this.toolStreamOrder.length <= TOOL_STREAM_LIMIT) return;
|
||||
const overflow = this.toolStreamOrder.length - TOOL_STREAM_LIMIT;
|
||||
@@ -520,6 +539,8 @@ export class ClawdbotApp extends LitElement {
|
||||
if (sessionKey && sessionKey !== this.sessionKey) return;
|
||||
// Fallback: only accept session-less events for the active run.
|
||||
if (!sessionKey && this.chatRunId && payload.runId !== this.chatRunId) return;
|
||||
if (this.chatRunId && payload.runId !== this.chatRunId) return;
|
||||
if (!this.chatRunId) return;
|
||||
|
||||
const data = payload.data ?? {};
|
||||
const toolCallId =
|
||||
@@ -564,10 +585,13 @@ export class ClawdbotApp extends LitElement {
|
||||
}
|
||||
|
||||
private onEvent(evt: GatewayEventFrame) {
|
||||
this.eventLog = [
|
||||
this.eventLogBuffer = [
|
||||
{ ts: Date.now(), event: evt.event, payload: evt.payload },
|
||||
...this.eventLog,
|
||||
...this.eventLogBuffer,
|
||||
].slice(0, 250);
|
||||
if (this.tab === "debug") {
|
||||
this.eventLog = this.eventLogBuffer;
|
||||
}
|
||||
|
||||
if (evt.event === "agent") {
|
||||
this.handleAgentEvent(evt.payload as AgentEventPayload | undefined);
|
||||
@@ -577,6 +601,9 @@ export class ClawdbotApp extends LitElement {
|
||||
if (evt.event === "chat") {
|
||||
const payload = evt.payload as ChatEventPayload | undefined;
|
||||
const state = handleChatEvent(this, payload);
|
||||
if (state === "final" || state === "error" || state === "aborted") {
|
||||
this.resetToolStream();
|
||||
}
|
||||
if (state === "final") void loadChatHistory(this);
|
||||
return;
|
||||
}
|
||||
@@ -633,6 +660,7 @@ export class ClawdbotApp extends LitElement {
|
||||
|
||||
setTab(next: Tab) {
|
||||
if (this.tab !== next) this.tab = next;
|
||||
if (next === "chat") this.chatHasAutoScrolled = false;
|
||||
void this.refreshActiveTab();
|
||||
this.syncUrlWithTab(next, false);
|
||||
}
|
||||
@@ -667,7 +695,10 @@ export class ClawdbotApp extends LitElement {
|
||||
await loadConfigSchema(this);
|
||||
await loadConfig(this);
|
||||
}
|
||||
if (this.tab === "debug") await loadDebug(this);
|
||||
if (this.tab === "debug") {
|
||||
await loadDebug(this);
|
||||
this.eventLog = this.eventLogBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
private inferBasePath() {
|
||||
@@ -740,6 +771,7 @@ export class ClawdbotApp extends LitElement {
|
||||
|
||||
private setTabFromRoute(next: Tab) {
|
||||
if (this.tab !== next) this.tab = next;
|
||||
if (next === "chat") this.chatHasAutoScrolled = false;
|
||||
if (this.connected) void this.refreshActiveTab();
|
||||
}
|
||||
|
||||
@@ -776,8 +808,16 @@ export class ClawdbotApp extends LitElement {
|
||||
}
|
||||
async handleSendChat() {
|
||||
if (!this.connected) return;
|
||||
this.resetToolStream();
|
||||
const ok = await sendChat(this);
|
||||
if (ok) void loadChatHistory(this);
|
||||
if (ok && this.chatRunId) {
|
||||
// chat.send returned (run finished), but we missed the chat final event.
|
||||
this.chatRunId = null;
|
||||
this.chatStream = null;
|
||||
this.chatStreamStartedAt = null;
|
||||
this.resetToolStream();
|
||||
void loadChatHistory(this);
|
||||
}
|
||||
this.scheduleChatScroll();
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export type ChatState = {
|
||||
chatMessage: string;
|
||||
chatRunId: string | null;
|
||||
chatStream: string | null;
|
||||
chatStreamStartedAt: number | null;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
@@ -62,6 +63,7 @@ export async function sendChat(state: ChatState): Promise<boolean> {
|
||||
const runId = generateUUID();
|
||||
state.chatRunId = runId;
|
||||
state.chatStream = "";
|
||||
state.chatStreamStartedAt = now;
|
||||
try {
|
||||
await state.client.request("chat.send", {
|
||||
sessionKey: state.sessionKey,
|
||||
@@ -74,6 +76,7 @@ export async function sendChat(state: ChatState): Promise<boolean> {
|
||||
const error = String(err);
|
||||
state.chatRunId = null;
|
||||
state.chatStream = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
state.chatMessage = msg;
|
||||
state.lastError = error;
|
||||
state.chatMessages = [
|
||||
@@ -100,13 +103,25 @@ export function handleChatEvent(
|
||||
return null;
|
||||
|
||||
if (payload.state === "delta") {
|
||||
state.chatStream = extractText(payload.message) ?? state.chatStream;
|
||||
const next = extractText(payload.message);
|
||||
if (typeof next === "string") {
|
||||
const current = state.chatStream ?? "";
|
||||
if (!current || next.length >= current.length) {
|
||||
state.chatStream = next;
|
||||
}
|
||||
}
|
||||
} else if (payload.state === "final") {
|
||||
state.chatStream = null;
|
||||
state.chatRunId = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
} else if (payload.state === "aborted") {
|
||||
state.chatStream = null;
|
||||
state.chatRunId = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
} else if (payload.state === "error") {
|
||||
state.chatStream = null;
|
||||
state.chatRunId = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
state.lastError = payload.errorMessage ?? "chat error";
|
||||
}
|
||||
return payload.state;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
|
||||
import type { SessionsListResult } from "../types";
|
||||
@@ -12,7 +13,9 @@ export type ChatProps = {
|
||||
loading: boolean;
|
||||
sending: boolean;
|
||||
messages: unknown[];
|
||||
toolMessages: unknown[];
|
||||
stream: string | null;
|
||||
streamStartedAt: number | null;
|
||||
draft: string;
|
||||
connected: boolean;
|
||||
canSend: boolean;
|
||||
@@ -77,19 +80,20 @@ export function renderChat(props: ChatProps) {
|
||||
|
||||
<div class="chat-thread" role="log" aria-live="polite">
|
||||
${props.loading ? html`<div class="muted">Loading chat…</div>` : nothing}
|
||||
${props.messages.map((m) => renderMessage(m))}
|
||||
${props.stream !== null
|
||||
? props.stream.trim().length > 0
|
||||
? renderMessage(
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: props.stream }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{ streaming: true },
|
||||
)
|
||||
: renderReadingIndicator()
|
||||
: nothing}
|
||||
${repeat(buildChatItems(props), (item) => item.key, (item) => {
|
||||
if (item.kind === "reading-indicator") return renderReadingIndicator();
|
||||
if (item.kind === "stream") {
|
||||
return renderMessage(
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: item.text }],
|
||||
timestamp: item.startedAt,
|
||||
},
|
||||
{ streaming: true },
|
||||
);
|
||||
}
|
||||
return renderMessage(item.message);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div class="chat-compose">
|
||||
@@ -123,6 +127,76 @@ export function renderChat(props: ChatProps) {
|
||||
`;
|
||||
}
|
||||
|
||||
type ChatItem =
|
||||
| { kind: "message"; key: string; message: unknown }
|
||||
| { kind: "stream"; key: string; text: string; startedAt: number }
|
||||
| { kind: "reading-indicator"; key: string };
|
||||
|
||||
function buildChatItems(props: ChatProps): ChatItem[] {
|
||||
const items: ChatItem[] = [];
|
||||
const history = Array.isArray(props.messages) ? props.messages : [];
|
||||
const tools = Array.isArray(props.toolMessages) ? props.toolMessages : [];
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
items.push({ kind: "message", key: messageKey(history[i], i), message: history[i] });
|
||||
}
|
||||
for (let i = 0; i < tools.length; i++) {
|
||||
items.push({
|
||||
kind: "message",
|
||||
key: messageKey(tools[i], i + history.length),
|
||||
message: tools[i],
|
||||
});
|
||||
}
|
||||
|
||||
if (props.stream !== null) {
|
||||
const key = `stream:${props.sessionKey}:${props.streamStartedAt ?? "live"}`;
|
||||
if (props.stream.trim().length > 0) {
|
||||
items.push({
|
||||
kind: "stream",
|
||||
key,
|
||||
text: props.stream,
|
||||
startedAt: props.streamStartedAt ?? Date.now(),
|
||||
});
|
||||
} else {
|
||||
items.push({ kind: "reading-indicator", key });
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function messageKey(message: unknown, index: number): string {
|
||||
const m = message as Record<string, unknown>;
|
||||
const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : "";
|
||||
if (toolCallId) return `tool:${toolCallId}`;
|
||||
const id = typeof m.id === "string" ? m.id : "";
|
||||
if (id) return `msg:${id}`;
|
||||
const messageId = typeof m.messageId === "string" ? m.messageId : "";
|
||||
if (messageId) return `msg:${messageId}`;
|
||||
const timestamp = typeof m.timestamp === "number" ? m.timestamp : null;
|
||||
const role = typeof m.role === "string" ? m.role : "unknown";
|
||||
const fingerprint = extractText(message) ?? (typeof m.content === "string" ? m.content : null);
|
||||
const seed = fingerprint ?? safeJson(message) ?? String(index);
|
||||
const hash = fnv1a(seed);
|
||||
return timestamp ? `msg:${role}:${timestamp}:${hash}` : `msg:${role}:${hash}`;
|
||||
}
|
||||
|
||||
function safeJson(value: unknown): string | null {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function fnv1a(input: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
type SessionOption = {
|
||||
key: string;
|
||||
updatedAt?: number | null;
|
||||
|
||||
Reference in New Issue
Block a user