fix(chat): stabilize web UI tool runs
This commit is contained in:
parent
86c404c48b
commit
b7e708c764
@ -18,6 +18,7 @@
|
|||||||
- Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding.
|
- Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding.
|
||||||
- Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments.
|
- Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments.
|
||||||
- Control UI: show a reading indicator bubble while the assistant is responding.
|
- Control UI: show a reading indicator bubble while the assistant is responding.
|
||||||
|
- Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping).
|
||||||
- Status: show runtime (docker/direct) and move shortcuts to `/help`.
|
- Status: show runtime (docker/direct) and move shortcuts to `/help`.
|
||||||
- Status: show model auth source (api-key/oauth).
|
- Status: show model auth source (api-key/oauth).
|
||||||
- Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split.
|
- Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split.
|
||||||
|
|||||||
@ -650,23 +650,10 @@ export function subscribeEmbeddedPiSession(params: {
|
|||||||
if (evtType === "text_end" && blockReplyBreak === "text_end") {
|
if (evtType === "text_end" && blockReplyBreak === "text_end") {
|
||||||
if (blockChunking && blockBuffer.length > 0) {
|
if (blockChunking && blockBuffer.length > 0) {
|
||||||
drainBlockBuffer(true);
|
drainBlockBuffer(true);
|
||||||
} else if (next && next !== lastBlockReplyText) {
|
} else if (blockBuffer.length > 0) {
|
||||||
lastBlockReplyText = next || undefined;
|
emitBlockChunk(blockBuffer);
|
||||||
if (next) assistantTexts.push(next);
|
|
||||||
if (next && params.onBlockReply) {
|
|
||||||
const { text: cleanedText, mediaUrls } =
|
|
||||||
splitMediaFromOutput(next);
|
|
||||||
if (cleanedText || (mediaUrls && mediaUrls.length > 0)) {
|
|
||||||
void params.onBlockReply({
|
|
||||||
text: cleanedText,
|
|
||||||
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
deltaBuffer = "";
|
|
||||||
blockBuffer = "";
|
blockBuffer = "";
|
||||||
lastStreamedAssistant = undefined;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -205,6 +205,7 @@ export async function handleCommands(params: {
|
|||||||
resolvedVerboseLevel,
|
resolvedVerboseLevel,
|
||||||
resolvedElevatedLevel,
|
resolvedElevatedLevel,
|
||||||
resolveDefaultThinkingLevel,
|
resolveDefaultThinkingLevel,
|
||||||
|
provider,
|
||||||
model,
|
model,
|
||||||
contextTokens,
|
contextTokens,
|
||||||
isGroup,
|
isGroup,
|
||||||
|
|||||||
@ -41,6 +41,7 @@ import {
|
|||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
|
import { registerAgentRunContext } from "../infra/agent-events.js";
|
||||||
import {
|
import {
|
||||||
loadVoiceWakeConfig,
|
loadVoiceWakeConfig,
|
||||||
setVoiceWakeTriggers,
|
setVoiceWakeTriggers,
|
||||||
@ -844,12 +845,12 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
|||||||
ctx.chatAbortControllers.delete(runId);
|
ctx.chatAbortControllers.delete(runId);
|
||||||
ctx.chatRunBuffers.delete(runId);
|
ctx.chatRunBuffers.delete(runId);
|
||||||
ctx.chatDeltaSentAt.delete(runId);
|
ctx.chatDeltaSentAt.delete(runId);
|
||||||
ctx.removeChatRun(active.sessionId, runId, sessionKey);
|
ctx.removeChatRun(runId, runId, sessionKey);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
runId,
|
runId,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
seq: (ctx.agentRunSeq.get(active.sessionId) ?? 0) + 1,
|
seq: (ctx.agentRunSeq.get(runId) ?? 0) + 1,
|
||||||
state: "aborted" as const,
|
state: "aborted" as const,
|
||||||
};
|
};
|
||||||
ctx.broadcast("chat", payload);
|
ctx.broadcast("chat", payload);
|
||||||
@ -940,6 +941,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
|||||||
lastTo: entry?.lastTo,
|
lastTo: entry?.lastTo,
|
||||||
};
|
};
|
||||||
const clientRunId = p.idempotencyKey;
|
const clientRunId = p.idempotencyKey;
|
||||||
|
registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey });
|
||||||
|
|
||||||
const cached = ctx.dedupe.get(`chat:${clientRunId}`);
|
const cached = ctx.dedupe.get(`chat:${clientRunId}`);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@ -962,7 +964,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
|||||||
sessionId,
|
sessionId,
|
||||||
sessionKey: p.sessionKey,
|
sessionKey: p.sessionKey,
|
||||||
});
|
});
|
||||||
ctx.addChatRun(sessionId, {
|
ctx.addChatRun(clientRunId, {
|
||||||
sessionKey: p.sessionKey,
|
sessionKey: p.sessionKey,
|
||||||
clientRunId,
|
clientRunId,
|
||||||
});
|
});
|
||||||
@ -978,6 +980,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
|||||||
{
|
{
|
||||||
message: messageWithAttachments,
|
message: messageWithAttachments,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
runId: clientRunId,
|
||||||
thinking: p.thinking,
|
thinking: p.thinking,
|
||||||
deliver: p.deliver,
|
deliver: p.deliver,
|
||||||
timeout: Math.ceil(timeoutMs / 1000).toString(),
|
timeout: Math.ceil(timeoutMs / 1000).toString(),
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { resolveThinkingDefault } from "../../agents/model-selection.js";
|
import { resolveThinkingDefault } from "../../agents/model-selection.js";
|
||||||
import { agentCommand } from "../../commands/agent.js";
|
import { agentCommand } from "../../commands/agent.js";
|
||||||
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
|
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
|
||||||
|
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||||
import { buildMessageWithAttachments } from "../chat-attachments.js";
|
import { buildMessageWithAttachments } from "../chat-attachments.js";
|
||||||
@ -115,12 +116,12 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
context.chatAbortControllers.delete(runId);
|
context.chatAbortControllers.delete(runId);
|
||||||
context.chatRunBuffers.delete(runId);
|
context.chatRunBuffers.delete(runId);
|
||||||
context.chatDeltaSentAt.delete(runId);
|
context.chatDeltaSentAt.delete(runId);
|
||||||
context.removeChatRun(active.sessionId, runId, sessionKey);
|
context.removeChatRun(runId, runId, sessionKey);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
runId,
|
runId,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
seq: (context.agentRunSeq.get(active.sessionId) ?? 0) + 1,
|
seq: (context.agentRunSeq.get(runId) ?? 0) + 1,
|
||||||
state: "aborted" as const,
|
state: "aborted" as const,
|
||||||
};
|
};
|
||||||
context.broadcast("chat", payload);
|
context.broadcast("chat", payload);
|
||||||
@ -201,6 +202,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
lastTo: entry?.lastTo,
|
lastTo: entry?.lastTo,
|
||||||
};
|
};
|
||||||
const clientRunId = p.idempotencyKey;
|
const clientRunId = p.idempotencyKey;
|
||||||
|
registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey });
|
||||||
|
|
||||||
const sendPolicy = resolveSendPolicy({
|
const sendPolicy = resolveSendPolicy({
|
||||||
cfg,
|
cfg,
|
||||||
@ -236,7 +238,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
sessionId,
|
sessionId,
|
||||||
sessionKey: p.sessionKey,
|
sessionKey: p.sessionKey,
|
||||||
});
|
});
|
||||||
context.addChatRun(sessionId, {
|
context.addChatRun(clientRunId, {
|
||||||
sessionKey: p.sessionKey,
|
sessionKey: p.sessionKey,
|
||||||
clientRunId,
|
clientRunId,
|
||||||
});
|
});
|
||||||
@ -252,6 +254,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
{
|
{
|
||||||
message: messageWithAttachments,
|
message: messageWithAttachments,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
runId: clientRunId,
|
||||||
thinking: p.thinking,
|
thinking: p.thinking,
|
||||||
deliver: p.deliver,
|
deliver: p.deliver,
|
||||||
timeout: Math.ceil(timeoutMs / 1000).toString(),
|
timeout: Math.ceil(timeoutMs / 1000).toString(),
|
||||||
|
|||||||
@ -830,7 +830,7 @@ describe("gateway server chat", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
runId: "sess-main",
|
runId: "idem-1",
|
||||||
stream: "lifecycle",
|
stream: "lifecycle",
|
||||||
data: { phase: "end" },
|
data: { phase: "end" },
|
||||||
});
|
});
|
||||||
@ -852,7 +852,7 @@ describe("gateway server chat", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
runId: "sess-main",
|
runId: "idem-2",
|
||||||
stream: "lifecycle",
|
stream: "lifecycle",
|
||||||
data: { phase: "end" },
|
data: { phase: "end" },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -378,16 +378,20 @@ export function renderApp(state: AppViewState) {
|
|||||||
state.sessionKey = next;
|
state.sessionKey = next;
|
||||||
state.chatMessage = "";
|
state.chatMessage = "";
|
||||||
state.chatStream = null;
|
state.chatStream = null;
|
||||||
|
state.chatStreamStartedAt = null;
|
||||||
state.chatRunId = null;
|
state.chatRunId = null;
|
||||||
state.resetToolStream();
|
state.resetToolStream();
|
||||||
|
state.resetChatScroll();
|
||||||
state.applySettings({ ...state.settings, sessionKey: next });
|
state.applySettings({ ...state.settings, sessionKey: next });
|
||||||
void loadChatHistory(state);
|
void loadChatHistory(state);
|
||||||
},
|
},
|
||||||
thinkingLevel: state.chatThinkingLevel,
|
thinkingLevel: state.chatThinkingLevel,
|
||||||
loading: state.chatLoading,
|
loading: state.chatLoading,
|
||||||
sending: state.chatSending,
|
sending: state.chatSending,
|
||||||
messages: [...state.chatMessages, ...state.chatToolMessages],
|
messages: state.chatMessages,
|
||||||
|
toolMessages: state.chatToolMessages,
|
||||||
stream: state.chatStream,
|
stream: state.chatStream,
|
||||||
|
streamStartedAt: state.chatStreamStartedAt,
|
||||||
draft: state.chatMessage,
|
draft: state.chatMessage,
|
||||||
connected: state.connected,
|
connected: state.connected,
|
||||||
canSend: state.connected,
|
canSend: state.connected,
|
||||||
|
|||||||
@ -178,6 +178,7 @@ export class ClawdbotApp extends LitElement {
|
|||||||
@state() hello: GatewayHelloOk | null = null;
|
@state() hello: GatewayHelloOk | null = null;
|
||||||
@state() lastError: string | null = null;
|
@state() lastError: string | null = null;
|
||||||
@state() eventLog: EventLogEntry[] = [];
|
@state() eventLog: EventLogEntry[] = [];
|
||||||
|
private eventLogBuffer: EventLogEntry[] = [];
|
||||||
|
|
||||||
@state() sessionKey = this.settings.sessionKey;
|
@state() sessionKey = this.settings.sessionKey;
|
||||||
@state() chatLoading = false;
|
@state() chatLoading = false;
|
||||||
@ -186,6 +187,7 @@ export class ClawdbotApp extends LitElement {
|
|||||||
@state() chatMessages: unknown[] = [];
|
@state() chatMessages: unknown[] = [];
|
||||||
@state() chatToolMessages: unknown[] = [];
|
@state() chatToolMessages: unknown[] = [];
|
||||||
@state() chatStream: string | null = null;
|
@state() chatStream: string | null = null;
|
||||||
|
@state() chatStreamStartedAt: number | null = null;
|
||||||
@state() chatRunId: string | null = null;
|
@state() chatRunId: string | null = null;
|
||||||
@state() chatThinkingLevel: string | null = null;
|
@state() chatThinkingLevel: string | null = null;
|
||||||
|
|
||||||
@ -341,6 +343,7 @@ export class ClawdbotApp extends LitElement {
|
|||||||
client: GatewayBrowserClient | null = null;
|
client: GatewayBrowserClient | null = null;
|
||||||
private chatScrollFrame: number | null = null;
|
private chatScrollFrame: number | null = null;
|
||||||
private chatScrollTimeout: number | null = null;
|
private chatScrollTimeout: number | null = null;
|
||||||
|
private chatHasAutoScrolled = false;
|
||||||
private nodesPollInterval: number | null = null;
|
private nodesPollInterval: number | null = null;
|
||||||
private toolStreamById = new Map<string, ToolStreamEntry>();
|
private toolStreamById = new Map<string, ToolStreamEntry>();
|
||||||
private toolStreamOrder: string[] = [];
|
private toolStreamOrder: string[] = [];
|
||||||
@ -386,10 +389,14 @@ export class ClawdbotApp extends LitElement {
|
|||||||
changed.has("chatToolMessages") ||
|
changed.has("chatToolMessages") ||
|
||||||
changed.has("chatStream") ||
|
changed.has("chatStream") ||
|
||||||
changed.has("chatLoading") ||
|
changed.has("chatLoading") ||
|
||||||
changed.has("chatMessage") ||
|
|
||||||
changed.has("tab"))
|
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();
|
this.client.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleChatScroll() {
|
private scheduleChatScroll(force = false) {
|
||||||
if (this.chatScrollFrame) cancelAnimationFrame(this.chatScrollFrame);
|
if (this.chatScrollFrame) cancelAnimationFrame(this.chatScrollFrame);
|
||||||
if (this.chatScrollTimeout != null) {
|
if (this.chatScrollTimeout != null) {
|
||||||
clearTimeout(this.chatScrollTimeout);
|
clearTimeout(this.chatScrollTimeout);
|
||||||
@ -434,11 +441,19 @@ export class ClawdbotApp extends LitElement {
|
|||||||
this.chatScrollFrame = null;
|
this.chatScrollFrame = null;
|
||||||
const container = this.querySelector(".chat-thread") as HTMLElement | null;
|
const container = this.querySelector(".chat-thread") as HTMLElement | null;
|
||||||
if (!container) return;
|
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;
|
container.scrollTop = container.scrollHeight;
|
||||||
this.chatScrollTimeout = window.setTimeout(() => {
|
this.chatScrollTimeout = window.setTimeout(() => {
|
||||||
this.chatScrollTimeout = null;
|
this.chatScrollTimeout = null;
|
||||||
const latest = this.querySelector(".chat-thread") as HTMLElement | null;
|
const latest = this.querySelector(".chat-thread") as HTMLElement | null;
|
||||||
if (!latest) return;
|
if (!latest) return;
|
||||||
|
const latestDistanceFromBottom =
|
||||||
|
latest.scrollHeight - latest.scrollTop - latest.clientHeight;
|
||||||
|
if (!force && latestDistanceFromBottom >= 180) return;
|
||||||
latest.scrollTop = latest.scrollHeight;
|
latest.scrollTop = latest.scrollHeight;
|
||||||
}, 120);
|
}, 120);
|
||||||
});
|
});
|
||||||
@ -477,6 +492,10 @@ export class ClawdbotApp extends LitElement {
|
|||||||
this.chatToolMessages = [];
|
this.chatToolMessages = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetChatScroll() {
|
||||||
|
this.chatHasAutoScrolled = false;
|
||||||
|
}
|
||||||
|
|
||||||
private trimToolStream() {
|
private trimToolStream() {
|
||||||
if (this.toolStreamOrder.length <= TOOL_STREAM_LIMIT) return;
|
if (this.toolStreamOrder.length <= TOOL_STREAM_LIMIT) return;
|
||||||
const overflow = this.toolStreamOrder.length - TOOL_STREAM_LIMIT;
|
const overflow = this.toolStreamOrder.length - TOOL_STREAM_LIMIT;
|
||||||
@ -520,6 +539,8 @@ export class ClawdbotApp extends LitElement {
|
|||||||
if (sessionKey && sessionKey !== this.sessionKey) return;
|
if (sessionKey && sessionKey !== this.sessionKey) return;
|
||||||
// Fallback: only accept session-less events for the active run.
|
// Fallback: only accept session-less events for the active run.
|
||||||
if (!sessionKey && this.chatRunId && payload.runId !== this.chatRunId) return;
|
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 data = payload.data ?? {};
|
||||||
const toolCallId =
|
const toolCallId =
|
||||||
@ -564,10 +585,13 @@ export class ClawdbotApp extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private onEvent(evt: GatewayEventFrame) {
|
private onEvent(evt: GatewayEventFrame) {
|
||||||
this.eventLog = [
|
this.eventLogBuffer = [
|
||||||
{ ts: Date.now(), event: evt.event, payload: evt.payload },
|
{ ts: Date.now(), event: evt.event, payload: evt.payload },
|
||||||
...this.eventLog,
|
...this.eventLogBuffer,
|
||||||
].slice(0, 250);
|
].slice(0, 250);
|
||||||
|
if (this.tab === "debug") {
|
||||||
|
this.eventLog = this.eventLogBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
if (evt.event === "agent") {
|
if (evt.event === "agent") {
|
||||||
this.handleAgentEvent(evt.payload as AgentEventPayload | undefined);
|
this.handleAgentEvent(evt.payload as AgentEventPayload | undefined);
|
||||||
@ -577,6 +601,9 @@ export class ClawdbotApp extends LitElement {
|
|||||||
if (evt.event === "chat") {
|
if (evt.event === "chat") {
|
||||||
const payload = evt.payload as ChatEventPayload | undefined;
|
const payload = evt.payload as ChatEventPayload | undefined;
|
||||||
const state = handleChatEvent(this, payload);
|
const state = handleChatEvent(this, payload);
|
||||||
|
if (state === "final" || state === "error" || state === "aborted") {
|
||||||
|
this.resetToolStream();
|
||||||
|
}
|
||||||
if (state === "final") void loadChatHistory(this);
|
if (state === "final") void loadChatHistory(this);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -633,6 +660,7 @@ export class ClawdbotApp extends LitElement {
|
|||||||
|
|
||||||
setTab(next: Tab) {
|
setTab(next: Tab) {
|
||||||
if (this.tab !== next) this.tab = next;
|
if (this.tab !== next) this.tab = next;
|
||||||
|
if (next === "chat") this.chatHasAutoScrolled = false;
|
||||||
void this.refreshActiveTab();
|
void this.refreshActiveTab();
|
||||||
this.syncUrlWithTab(next, false);
|
this.syncUrlWithTab(next, false);
|
||||||
}
|
}
|
||||||
@ -667,7 +695,10 @@ export class ClawdbotApp extends LitElement {
|
|||||||
await loadConfigSchema(this);
|
await loadConfigSchema(this);
|
||||||
await loadConfig(this);
|
await loadConfig(this);
|
||||||
}
|
}
|
||||||
if (this.tab === "debug") await loadDebug(this);
|
if (this.tab === "debug") {
|
||||||
|
await loadDebug(this);
|
||||||
|
this.eventLog = this.eventLogBuffer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private inferBasePath() {
|
private inferBasePath() {
|
||||||
@ -740,6 +771,7 @@ export class ClawdbotApp extends LitElement {
|
|||||||
|
|
||||||
private setTabFromRoute(next: Tab) {
|
private setTabFromRoute(next: Tab) {
|
||||||
if (this.tab !== next) this.tab = next;
|
if (this.tab !== next) this.tab = next;
|
||||||
|
if (next === "chat") this.chatHasAutoScrolled = false;
|
||||||
if (this.connected) void this.refreshActiveTab();
|
if (this.connected) void this.refreshActiveTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -776,8 +808,16 @@ export class ClawdbotApp extends LitElement {
|
|||||||
}
|
}
|
||||||
async handleSendChat() {
|
async handleSendChat() {
|
||||||
if (!this.connected) return;
|
if (!this.connected) return;
|
||||||
|
this.resetToolStream();
|
||||||
const ok = await sendChat(this);
|
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();
|
this.scheduleChatScroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export type ChatState = {
|
|||||||
chatMessage: string;
|
chatMessage: string;
|
||||||
chatRunId: string | null;
|
chatRunId: string | null;
|
||||||
chatStream: string | null;
|
chatStream: string | null;
|
||||||
|
chatStreamStartedAt: number | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -62,6 +63,7 @@ export async function sendChat(state: ChatState): Promise<boolean> {
|
|||||||
const runId = generateUUID();
|
const runId = generateUUID();
|
||||||
state.chatRunId = runId;
|
state.chatRunId = runId;
|
||||||
state.chatStream = "";
|
state.chatStream = "";
|
||||||
|
state.chatStreamStartedAt = now;
|
||||||
try {
|
try {
|
||||||
await state.client.request("chat.send", {
|
await state.client.request("chat.send", {
|
||||||
sessionKey: state.sessionKey,
|
sessionKey: state.sessionKey,
|
||||||
@ -74,6 +76,7 @@ export async function sendChat(state: ChatState): Promise<boolean> {
|
|||||||
const error = String(err);
|
const error = String(err);
|
||||||
state.chatRunId = null;
|
state.chatRunId = null;
|
||||||
state.chatStream = null;
|
state.chatStream = null;
|
||||||
|
state.chatStreamStartedAt = null;
|
||||||
state.chatMessage = msg;
|
state.chatMessage = msg;
|
||||||
state.lastError = error;
|
state.lastError = error;
|
||||||
state.chatMessages = [
|
state.chatMessages = [
|
||||||
@ -100,13 +103,25 @@ export function handleChatEvent(
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
if (payload.state === "delta") {
|
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") {
|
} else if (payload.state === "final") {
|
||||||
state.chatStream = null;
|
state.chatStream = null;
|
||||||
state.chatRunId = 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") {
|
} else if (payload.state === "error") {
|
||||||
state.chatStream = null;
|
state.chatStream = null;
|
||||||
state.chatRunId = null;
|
state.chatRunId = null;
|
||||||
|
state.chatStreamStartedAt = null;
|
||||||
state.lastError = payload.errorMessage ?? "chat error";
|
state.lastError = payload.errorMessage ?? "chat error";
|
||||||
}
|
}
|
||||||
return payload.state;
|
return payload.state;
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
|
import { repeat } from "lit/directives/repeat.js";
|
||||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||||
|
|
||||||
import type { SessionsListResult } from "../types";
|
import type { SessionsListResult } from "../types";
|
||||||
@ -12,7 +13,9 @@ export type ChatProps = {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
sending: boolean;
|
sending: boolean;
|
||||||
messages: unknown[];
|
messages: unknown[];
|
||||||
|
toolMessages: unknown[];
|
||||||
stream: string | null;
|
stream: string | null;
|
||||||
|
streamStartedAt: number | null;
|
||||||
draft: string;
|
draft: string;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
canSend: boolean;
|
canSend: boolean;
|
||||||
@ -77,19 +80,20 @@ export function renderChat(props: ChatProps) {
|
|||||||
|
|
||||||
<div class="chat-thread" role="log" aria-live="polite">
|
<div class="chat-thread" role="log" aria-live="polite">
|
||||||
${props.loading ? html`<div class="muted">Loading chat…</div>` : nothing}
|
${props.loading ? html`<div class="muted">Loading chat…</div>` : nothing}
|
||||||
${props.messages.map((m) => renderMessage(m))}
|
${repeat(buildChatItems(props), (item) => item.key, (item) => {
|
||||||
${props.stream !== null
|
if (item.kind === "reading-indicator") return renderReadingIndicator();
|
||||||
? props.stream.trim().length > 0
|
if (item.kind === "stream") {
|
||||||
? renderMessage(
|
return renderMessage(
|
||||||
{
|
{
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: [{ type: "text", text: props.stream }],
|
content: [{ type: "text", text: item.text }],
|
||||||
timestamp: Date.now(),
|
timestamp: item.startedAt,
|
||||||
},
|
},
|
||||||
{ streaming: true },
|
{ streaming: true },
|
||||||
)
|
);
|
||||||
: renderReadingIndicator()
|
}
|
||||||
: nothing}
|
return renderMessage(item.message);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chat-compose">
|
<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 = {
|
type SessionOption = {
|
||||||
key: string;
|
key: string;
|
||||||
updatedAt?: number | null;
|
updatedAt?: number | null;
|
||||||
|
|||||||
@ -17,6 +17,9 @@ export default defineConfig(({ command }) => {
|
|||||||
const base = envBase ? normalizeBase(envBase) : "/";
|
const base = envBase ? normalizeBase(envBase) : "/";
|
||||||
return {
|
return {
|
||||||
base,
|
base,
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ["lit/directives/repeat.js"],
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: path.resolve(here, "../dist/control-ui"),
|
outDir: path.resolve(here, "../dist/control-ui"),
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user