fix: stream tool summaries early and tool output
This commit is contained in:
@@ -84,6 +84,7 @@ export type AppViewState = {
|
||||
chatSending: boolean;
|
||||
chatMessage: string;
|
||||
chatMessages: unknown[];
|
||||
chatToolMessages: unknown[];
|
||||
chatStream: string | null;
|
||||
chatRunId: string | null;
|
||||
chatThinkingLevel: string | null;
|
||||
@@ -168,6 +169,7 @@ export type AppViewState = {
|
||||
handleWhatsAppLogout: () => Promise<void>;
|
||||
handleTelegramSave: () => Promise<void>;
|
||||
handleSendChat: () => Promise<void>;
|
||||
resetToolStream: () => void;
|
||||
};
|
||||
|
||||
export function renderApp(state: AppViewState) {
|
||||
@@ -241,6 +243,7 @@ export function renderApp(state: AppViewState) {
|
||||
onSessionKeyChange: (next) => {
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
state.resetToolStream();
|
||||
state.applySettings({ ...state.settings, sessionKey: next });
|
||||
},
|
||||
onRefresh: () => state.loadOverview(),
|
||||
@@ -370,20 +373,24 @@ export function renderApp(state: AppViewState) {
|
||||
state.chatMessage = "";
|
||||
state.chatStream = null;
|
||||
state.chatRunId = null;
|
||||
state.resetToolStream();
|
||||
state.applySettings({ ...state.settings, sessionKey: next });
|
||||
void loadChatHistory(state);
|
||||
},
|
||||
thinkingLevel: state.chatThinkingLevel,
|
||||
loading: state.chatLoading,
|
||||
sending: state.chatSending,
|
||||
messages: state.chatMessages,
|
||||
messages: [...state.chatMessages, ...state.chatToolMessages],
|
||||
stream: state.chatStream,
|
||||
draft: state.chatMessage,
|
||||
connected: state.connected,
|
||||
canSend: state.connected && hasConnectedMobileNode,
|
||||
disabledReason: chatDisabledReason,
|
||||
sessions: state.sessionsResult,
|
||||
onRefresh: () => loadChatHistory(state),
|
||||
onRefresh: () => {
|
||||
state.resetToolStream();
|
||||
return loadChatHistory(state);
|
||||
},
|
||||
onDraftChange: (next) => (state.chatMessage = next),
|
||||
onSend: () => state.handleSendChat(),
|
||||
})
|
||||
|
||||
157
ui/src/ui/app.ts
157
ui/src/ui/app.ts
@@ -81,6 +81,62 @@ type EventLogEntry = {
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
const TOOL_STREAM_LIMIT = 50;
|
||||
|
||||
type AgentEventPayload = {
|
||||
runId: string;
|
||||
seq: number;
|
||||
stream: string;
|
||||
ts: number;
|
||||
sessionKey?: string;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ToolStreamEntry = {
|
||||
toolCallId: string;
|
||||
runId: string;
|
||||
sessionKey?: string;
|
||||
name: string;
|
||||
args?: unknown;
|
||||
output?: string;
|
||||
startedAt: number;
|
||||
updatedAt: number;
|
||||
message: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function extractToolOutputText(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.text === "string") return record.text;
|
||||
const content = record.content;
|
||||
if (!Array.isArray(content)) return null;
|
||||
const parts = content
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const entry = item as Record<string, unknown>;
|
||||
if (entry.type === "text" && typeof entry.text === "string") return entry.text;
|
||||
return null;
|
||||
})
|
||||
.filter((part): part is string => Boolean(part));
|
||||
if (parts.length === 0) return null;
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function formatToolOutput(value: unknown): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
const contentText = extractToolOutputText(value);
|
||||
if (contentText) return contentText;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__CLAWDIS_CONTROL_UI_BASE_PATH__?: string;
|
||||
@@ -125,6 +181,7 @@ export class ClawdisApp extends LitElement {
|
||||
@state() chatSending = false;
|
||||
@state() chatMessage = "";
|
||||
@state() chatMessages: unknown[] = [];
|
||||
@state() chatToolMessages: unknown[] = [];
|
||||
@state() chatStream: string | null = null;
|
||||
@state() chatRunId: string | null = null;
|
||||
@state() chatThinkingLevel: string | null = null;
|
||||
@@ -260,6 +317,8 @@ export class ClawdisApp extends LitElement {
|
||||
private chatScrollFrame: number | null = null;
|
||||
private chatScrollTimeout: number | null = null;
|
||||
private nodesPollInterval: number | null = null;
|
||||
private toolStreamById = new Map<string, ToolStreamEntry>();
|
||||
private toolStreamOrder: string[] = [];
|
||||
basePath = "";
|
||||
private popStateHandler = () => this.onPopState();
|
||||
private themeMedia: MediaQueryList | null = null;
|
||||
@@ -292,6 +351,7 @@ export class ClawdisApp extends LitElement {
|
||||
if (
|
||||
this.tab === "chat" &&
|
||||
(changed.has("chatMessages") ||
|
||||
changed.has("chatToolMessages") ||
|
||||
changed.has("chatStream") ||
|
||||
changed.has("chatLoading") ||
|
||||
changed.has("chatMessage") ||
|
||||
@@ -377,12 +437,109 @@ export class ClawdisApp extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
resetToolStream() {
|
||||
this.toolStreamById.clear();
|
||||
this.toolStreamOrder = [];
|
||||
this.chatToolMessages = [];
|
||||
}
|
||||
|
||||
private trimToolStream() {
|
||||
if (this.toolStreamOrder.length <= TOOL_STREAM_LIMIT) return;
|
||||
const overflow = this.toolStreamOrder.length - TOOL_STREAM_LIMIT;
|
||||
const removed = this.toolStreamOrder.splice(0, overflow);
|
||||
for (const id of removed) this.toolStreamById.delete(id);
|
||||
}
|
||||
|
||||
private syncToolStreamMessages() {
|
||||
this.chatToolMessages = this.toolStreamOrder
|
||||
.map((id) => this.toolStreamById.get(id)?.message)
|
||||
.filter((msg): msg is Record<string, unknown> => Boolean(msg));
|
||||
}
|
||||
|
||||
private buildToolStreamMessage(entry: ToolStreamEntry): Record<string, unknown> {
|
||||
const content: Array<Record<string, unknown>> = [];
|
||||
content.push({
|
||||
type: "toolcall",
|
||||
name: entry.name,
|
||||
arguments: entry.args ?? {},
|
||||
});
|
||||
if (entry.output) {
|
||||
content.push({
|
||||
type: "toolresult",
|
||||
name: entry.name,
|
||||
text: entry.output,
|
||||
});
|
||||
}
|
||||
return {
|
||||
role: "assistant",
|
||||
toolCallId: entry.toolCallId,
|
||||
runId: entry.runId,
|
||||
content,
|
||||
timestamp: entry.startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private handleAgentEvent(payload?: AgentEventPayload) {
|
||||
if (!payload || payload.stream !== "tool") return;
|
||||
const sessionKey =
|
||||
typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
|
||||
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;
|
||||
|
||||
const data = payload.data ?? {};
|
||||
const toolCallId =
|
||||
typeof data.toolCallId === "string" ? data.toolCallId : "";
|
||||
if (!toolCallId) return;
|
||||
const name = typeof data.name === "string" ? data.name : "tool";
|
||||
const phase = typeof data.phase === "string" ? data.phase : "";
|
||||
const args = phase === "start" ? data.args : undefined;
|
||||
const output =
|
||||
phase === "update"
|
||||
? formatToolOutput(data.partialResult)
|
||||
: phase === "result"
|
||||
? formatToolOutput(data.result)
|
||||
: undefined;
|
||||
|
||||
const now = Date.now();
|
||||
let entry = this.toolStreamById.get(toolCallId);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
toolCallId,
|
||||
runId: payload.runId,
|
||||
sessionKey,
|
||||
name,
|
||||
args,
|
||||
output,
|
||||
startedAt: typeof payload.ts === "number" ? payload.ts : now,
|
||||
updatedAt: now,
|
||||
message: {},
|
||||
};
|
||||
this.toolStreamById.set(toolCallId, entry);
|
||||
this.toolStreamOrder.push(toolCallId);
|
||||
} else {
|
||||
entry.name = name;
|
||||
if (args !== undefined) entry.args = args;
|
||||
if (output !== undefined) entry.output = output;
|
||||
entry.updatedAt = now;
|
||||
}
|
||||
|
||||
entry.message = this.buildToolStreamMessage(entry);
|
||||
this.trimToolStream();
|
||||
this.syncToolStreamMessages();
|
||||
}
|
||||
|
||||
private onEvent(evt: GatewayEventFrame) {
|
||||
this.eventLog = [
|
||||
{ ts: Date.now(), event: evt.event, payload: evt.payload },
|
||||
...this.eventLog,
|
||||
].slice(0, 250);
|
||||
|
||||
if (evt.event === "agent") {
|
||||
this.handleAgentEvent(evt.payload as AgentEventPayload | undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.event === "chat") {
|
||||
const payload = evt.payload as ChatEventPayload | undefined;
|
||||
const state = handleChatEvent(this, payload);
|
||||
|
||||
@@ -170,14 +170,18 @@ function renderMessage(message: unknown, opts?: { streaming?: boolean }) {
|
||||
const m = message as Record<string, unknown>;
|
||||
const role = typeof m.role === "string" ? m.role : "unknown";
|
||||
const toolCards = extractToolCards(message);
|
||||
const hasToolCards = toolCards.length > 0;
|
||||
const isToolResult = isToolResultMessage(message);
|
||||
const text =
|
||||
!isToolResult
|
||||
? extractText(message) ??
|
||||
(typeof m.content === "string"
|
||||
? m.content
|
||||
: JSON.stringify(message, null, 2))
|
||||
: null;
|
||||
const extractedText = extractText(message);
|
||||
const contentText = typeof m.content === "string" ? m.content : null;
|
||||
const fallback = hasToolCards ? null : JSON.stringify(message, null, 2);
|
||||
const text = !isToolResult
|
||||
? extractedText?.trim()
|
||||
? extractedText
|
||||
: contentText?.trim()
|
||||
? contentText
|
||||
: fallback
|
||||
: null;
|
||||
|
||||
const timestamp =
|
||||
typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : "";
|
||||
|
||||
Reference in New Issue
Block a user