fix(chat): stabilize web UI tool runs

This commit is contained in:
Peter Steinberger 2026-01-05 17:15:17 +00:00
parent 86c404c48b
commit b7e708c764
11 changed files with 176 additions and 45 deletions

View File

@ -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.

View File

@ -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;
} }
} }
} }

View File

@ -205,6 +205,7 @@ export async function handleCommands(params: {
resolvedVerboseLevel, resolvedVerboseLevel,
resolvedElevatedLevel, resolvedElevatedLevel,
resolveDefaultThinkingLevel, resolveDefaultThinkingLevel,
provider,
model, model,
contextTokens, contextTokens,
isGroup, isGroup,

View File

@ -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(),

View File

@ -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(),

View File

@ -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" },
}); });

View File

@ -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,

View File

@ -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();
} }

View File

@ -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;

View File

@ -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;

View File

@ -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,