mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 03:20:20 +00:00
feat(auto-reply): add model fallback lifecycle visibility in status, verbose logs, and WebUI (#20704)
This commit is contained in:
@@ -635,6 +635,16 @@
|
||||
border-color: rgba(34, 197, 94, 0.35);
|
||||
}
|
||||
|
||||
.compaction-indicator--fallback {
|
||||
color: #d97706;
|
||||
border-color: rgba(217, 119, 6, 0.35);
|
||||
}
|
||||
|
||||
.compaction-indicator--fallback-cleared {
|
||||
color: var(--ok);
|
||||
border-color: rgba(34, 197, 94, 0.35);
|
||||
}
|
||||
|
||||
@keyframes compaction-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
|
||||
@@ -824,6 +824,7 @@ export function renderApp(state: AppViewState) {
|
||||
loading: state.chatLoading,
|
||||
sending: state.chatSending,
|
||||
compactionStatus: state.compactionStatus,
|
||||
fallbackStatus: state.fallbackStatus,
|
||||
assistantAvatarUrl: chatAvatarUrl,
|
||||
messages: state.chatMessages,
|
||||
toolMessages: state.chatToolMessages,
|
||||
|
||||
139
ui/src/ui/app-tool-stream.node.test.ts
Normal file
139
ui/src/ui/app-tool-stream.node.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { handleAgentEvent, type FallbackStatus, type ToolStreamEntry } from "./app-tool-stream.ts";
|
||||
|
||||
type ToolStreamHost = Parameters<typeof handleAgentEvent>[0];
|
||||
type MutableHost = ToolStreamHost & {
|
||||
compactionStatus?: unknown;
|
||||
compactionClearTimer?: number | null;
|
||||
fallbackStatus?: FallbackStatus | null;
|
||||
fallbackClearTimer?: number | null;
|
||||
};
|
||||
|
||||
function createHost(overrides?: Partial<MutableHost>): MutableHost {
|
||||
return {
|
||||
sessionKey: "main",
|
||||
chatRunId: null,
|
||||
toolStreamById: new Map<string, ToolStreamEntry>(),
|
||||
toolStreamOrder: [],
|
||||
chatToolMessages: [],
|
||||
toolStreamSyncTimer: null,
|
||||
compactionStatus: null,
|
||||
compactionClearTimer: null,
|
||||
fallbackStatus: null,
|
||||
fallbackClearTimer: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("app-tool-stream fallback lifecycle handling", () => {
|
||||
beforeAll(() => {
|
||||
const globalWithWindow = globalThis as typeof globalThis & {
|
||||
window?: Window & typeof globalThis;
|
||||
};
|
||||
if (!globalWithWindow.window) {
|
||||
globalWithWindow.window = globalThis as unknown as Window & typeof globalThis;
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts session-scoped fallback lifecycle events when no run is active", () => {
|
||||
vi.useFakeTimers();
|
||||
const host = createHost();
|
||||
|
||||
handleAgentEvent(host, {
|
||||
runId: "run-1",
|
||||
seq: 1,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
sessionKey: "main",
|
||||
data: {
|
||||
phase: "fallback",
|
||||
selectedProvider: "fireworks",
|
||||
selectedModel: "fireworks/minimax-m2p5",
|
||||
activeProvider: "deepinfra",
|
||||
activeModel: "moonshotai/Kimi-K2.5",
|
||||
reasonSummary: "rate limit",
|
||||
},
|
||||
});
|
||||
|
||||
expect(host.fallbackStatus?.selected).toBe("fireworks/minimax-m2p5");
|
||||
expect(host.fallbackStatus?.active).toBe("deepinfra/moonshotai/Kimi-K2.5");
|
||||
expect(host.fallbackStatus?.reason).toBe("rate limit");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("rejects idle fallback lifecycle events for other sessions", () => {
|
||||
vi.useFakeTimers();
|
||||
const host = createHost();
|
||||
|
||||
handleAgentEvent(host, {
|
||||
runId: "run-1",
|
||||
seq: 1,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
sessionKey: "agent:other:main",
|
||||
data: {
|
||||
phase: "fallback",
|
||||
selectedProvider: "fireworks",
|
||||
selectedModel: "fireworks/minimax-m2p5",
|
||||
activeProvider: "deepinfra",
|
||||
activeModel: "moonshotai/Kimi-K2.5",
|
||||
},
|
||||
});
|
||||
|
||||
expect(host.fallbackStatus).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("auto-clears fallback status after toast duration", () => {
|
||||
vi.useFakeTimers();
|
||||
const host = createHost();
|
||||
|
||||
handleAgentEvent(host, {
|
||||
runId: "run-1",
|
||||
seq: 1,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
sessionKey: "main",
|
||||
data: {
|
||||
phase: "fallback",
|
||||
selectedProvider: "fireworks",
|
||||
selectedModel: "fireworks/minimax-m2p5",
|
||||
activeProvider: "deepinfra",
|
||||
activeModel: "moonshotai/Kimi-K2.5",
|
||||
},
|
||||
});
|
||||
|
||||
expect(host.fallbackStatus).not.toBeNull();
|
||||
vi.advanceTimersByTime(7_999);
|
||||
expect(host.fallbackStatus).not.toBeNull();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(host.fallbackStatus).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("builds previous fallback label from provider + model on fallback_cleared", () => {
|
||||
vi.useFakeTimers();
|
||||
const host = createHost();
|
||||
|
||||
handleAgentEvent(host, {
|
||||
runId: "run-1",
|
||||
seq: 1,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
sessionKey: "main",
|
||||
data: {
|
||||
phase: "fallback_cleared",
|
||||
selectedProvider: "fireworks",
|
||||
selectedModel: "fireworks/minimax-m2p5",
|
||||
activeProvider: "fireworks",
|
||||
activeModel: "fireworks/minimax-m2p5",
|
||||
previousActiveProvider: "deepinfra",
|
||||
previousActiveModel: "moonshotai/Kimi-K2.5",
|
||||
},
|
||||
});
|
||||
|
||||
expect(host.fallbackStatus?.phase).toBe("cleared");
|
||||
expect(host.fallbackStatus?.previous).toBe("deepinfra/moonshotai/Kimi-K2.5");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,82 @@ type ToolStreamHost = {
|
||||
toolStreamSyncTimer: number | null;
|
||||
};
|
||||
|
||||
function toTrimmedString(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function resolveModelLabel(provider: unknown, model: unknown): string | null {
|
||||
const modelValue = toTrimmedString(model);
|
||||
if (!modelValue) {
|
||||
return null;
|
||||
}
|
||||
const providerValue = toTrimmedString(provider);
|
||||
if (providerValue) {
|
||||
const prefix = `${providerValue}/`;
|
||||
if (modelValue.toLowerCase().startsWith(prefix.toLowerCase())) {
|
||||
const trimmedModel = modelValue.slice(prefix.length).trim();
|
||||
if (trimmedModel) {
|
||||
return `${providerValue}/${trimmedModel}`;
|
||||
}
|
||||
}
|
||||
return `${providerValue}/${modelValue}`;
|
||||
}
|
||||
const slashIndex = modelValue.indexOf("/");
|
||||
if (slashIndex > 0) {
|
||||
const p = modelValue.slice(0, slashIndex).trim();
|
||||
const m = modelValue.slice(slashIndex + 1).trim();
|
||||
if (p && m) {
|
||||
return `${p}/${m}`;
|
||||
}
|
||||
}
|
||||
return modelValue;
|
||||
}
|
||||
|
||||
type FallbackAttempt = {
|
||||
provider: string;
|
||||
model: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
function parseFallbackAttemptSummaries(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.map((entry) => toTrimmedString(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
}
|
||||
|
||||
function parseFallbackAttempts(value: unknown): FallbackAttempt[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
const out: FallbackAttempt[] = [];
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
const item = entry as Record<string, unknown>;
|
||||
const provider = toTrimmedString(item.provider);
|
||||
const model = toTrimmedString(item.model);
|
||||
if (!provider || !model) {
|
||||
continue;
|
||||
}
|
||||
const reason =
|
||||
toTrimmedString(item.reason)?.replace(/_/g, " ") ??
|
||||
toTrimmedString(item.code) ??
|
||||
(typeof item.status === "number" ? `HTTP ${item.status}` : null) ??
|
||||
toTrimmedString(item.error) ??
|
||||
"error";
|
||||
out.push({ provider, model, reason });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractToolOutputText(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
@@ -167,12 +243,25 @@ export type CompactionStatus = {
|
||||
completedAt: number | null;
|
||||
};
|
||||
|
||||
export type FallbackStatus = {
|
||||
phase?: "active" | "cleared";
|
||||
selected: string;
|
||||
active: string;
|
||||
previous?: string;
|
||||
reason?: string;
|
||||
attempts: string[];
|
||||
occurredAt: number;
|
||||
};
|
||||
|
||||
type CompactionHost = ToolStreamHost & {
|
||||
compactionStatus?: CompactionStatus | null;
|
||||
compactionClearTimer?: number | null;
|
||||
fallbackStatus?: FallbackStatus | null;
|
||||
fallbackClearTimer?: number | null;
|
||||
};
|
||||
|
||||
const COMPACTION_TOAST_DURATION_MS = 5000;
|
||||
const FALLBACK_TOAST_DURATION_MS = 8000;
|
||||
|
||||
export function handleCompactionEvent(host: CompactionHost, payload: AgentEventPayload) {
|
||||
const data = payload.data ?? {};
|
||||
@@ -204,6 +293,95 @@ export function handleCompactionEvent(host: CompactionHost, payload: AgentEventP
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAcceptedSession(
|
||||
host: ToolStreamHost,
|
||||
payload: AgentEventPayload,
|
||||
options?: {
|
||||
allowSessionScopedWhenIdle?: boolean;
|
||||
},
|
||||
): { accepted: boolean; sessionKey?: string } {
|
||||
const sessionKey = typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
|
||||
if (sessionKey && sessionKey !== host.sessionKey) {
|
||||
return { accepted: false };
|
||||
}
|
||||
if (!host.chatRunId && options?.allowSessionScopedWhenIdle && sessionKey) {
|
||||
return { accepted: true, sessionKey };
|
||||
}
|
||||
// Fallback: only accept session-less events for the active run.
|
||||
if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) {
|
||||
return { accepted: false };
|
||||
}
|
||||
if (host.chatRunId && payload.runId !== host.chatRunId) {
|
||||
return { accepted: false };
|
||||
}
|
||||
if (!host.chatRunId) {
|
||||
return { accepted: false };
|
||||
}
|
||||
return { accepted: true, sessionKey };
|
||||
}
|
||||
|
||||
function handleLifecycleFallbackEvent(host: CompactionHost, payload: AgentEventPayload) {
|
||||
const data = payload.data ?? {};
|
||||
const phase = payload.stream === "fallback" ? "fallback" : toTrimmedString(data.phase);
|
||||
if (payload.stream === "lifecycle" && phase !== "fallback" && phase !== "fallback_cleared") {
|
||||
return;
|
||||
}
|
||||
|
||||
const accepted = resolveAcceptedSession(host, payload, { allowSessionScopedWhenIdle: true });
|
||||
if (!accepted.accepted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selected =
|
||||
resolveModelLabel(data.selectedProvider, data.selectedModel) ??
|
||||
resolveModelLabel(data.fromProvider, data.fromModel);
|
||||
const active =
|
||||
resolveModelLabel(data.activeProvider, data.activeModel) ??
|
||||
resolveModelLabel(data.toProvider, data.toModel);
|
||||
const previous =
|
||||
resolveModelLabel(data.previousActiveProvider, data.previousActiveModel) ??
|
||||
toTrimmedString(data.previousActiveModel);
|
||||
if (!selected || !active) {
|
||||
return;
|
||||
}
|
||||
if (phase === "fallback" && selected === active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = toTrimmedString(data.reasonSummary) ?? toTrimmedString(data.reason);
|
||||
const attempts = (() => {
|
||||
const summaries = parseFallbackAttemptSummaries(data.attemptSummaries);
|
||||
if (summaries.length > 0) {
|
||||
return summaries;
|
||||
}
|
||||
return parseFallbackAttempts(data.attempts).map((attempt) => {
|
||||
const modelRef = resolveModelLabel(attempt.provider, attempt.model);
|
||||
return `${modelRef ?? `${attempt.provider}/${attempt.model}`}: ${attempt.reason}`;
|
||||
});
|
||||
})();
|
||||
|
||||
if (host.fallbackClearTimer != null) {
|
||||
window.clearTimeout(host.fallbackClearTimer);
|
||||
host.fallbackClearTimer = null;
|
||||
}
|
||||
host.fallbackStatus = {
|
||||
phase: phase === "fallback_cleared" ? "cleared" : "active",
|
||||
selected,
|
||||
active: phase === "fallback_cleared" ? selected : active,
|
||||
previous:
|
||||
phase === "fallback_cleared"
|
||||
? (previous ?? (active !== selected ? active : undefined))
|
||||
: undefined,
|
||||
reason: reason ?? undefined,
|
||||
attempts,
|
||||
occurredAt: Date.now(),
|
||||
};
|
||||
host.fallbackClearTimer = window.setTimeout(() => {
|
||||
host.fallbackStatus = null;
|
||||
host.fallbackClearTimer = null;
|
||||
}, FALLBACK_TOAST_DURATION_MS);
|
||||
}
|
||||
|
||||
export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) {
|
||||
if (!payload) {
|
||||
return;
|
||||
@@ -215,23 +393,19 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.stream === "lifecycle" || payload.stream === "fallback") {
|
||||
handleLifecycleFallbackEvent(host as CompactionHost, payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.stream !== "tool") {
|
||||
return;
|
||||
}
|
||||
const sessionKey = typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
|
||||
if (sessionKey && sessionKey !== host.sessionKey) {
|
||||
return;
|
||||
}
|
||||
// Fallback: only accept session-less events for the active run.
|
||||
if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) {
|
||||
return;
|
||||
}
|
||||
if (host.chatRunId && payload.runId !== host.chatRunId) {
|
||||
return;
|
||||
}
|
||||
if (!host.chatRunId) {
|
||||
const accepted = resolveAcceptedSession(host, payload);
|
||||
if (!accepted.accepted) {
|
||||
return;
|
||||
}
|
||||
const sessionKey = accepted.sessionKey;
|
||||
|
||||
const data = payload.data ?? {};
|
||||
const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : "";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EventLogEntry } from "./app-events.ts";
|
||||
import type { CompactionStatus } from "./app-tool-stream.ts";
|
||||
import type { CompactionStatus, FallbackStatus } from "./app-tool-stream.ts";
|
||||
import type { DevicePairingList } from "./controllers/devices.ts";
|
||||
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
|
||||
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts";
|
||||
@@ -61,6 +61,7 @@ export type AppViewState = {
|
||||
chatStreamStartedAt: number | null;
|
||||
chatRunId: string | null;
|
||||
compactionStatus: CompactionStatus | null;
|
||||
fallbackStatus: FallbackStatus | null;
|
||||
chatAvatarUrl: string | null;
|
||||
chatThinkingLevel: string | null;
|
||||
chatQueue: ChatQueueItem[];
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
resetToolStream as resetToolStreamInternal,
|
||||
type ToolStreamEntry,
|
||||
type CompactionStatus,
|
||||
type FallbackStatus,
|
||||
} from "./app-tool-stream.ts";
|
||||
import type { AppViewState } from "./app-view-state.ts";
|
||||
import { normalizeAssistantIdentity } from "./assistant-identity.ts";
|
||||
@@ -140,6 +141,7 @@ export class OpenClawApp extends LitElement {
|
||||
@state() chatStreamStartedAt: number | null = null;
|
||||
@state() chatRunId: string | null = null;
|
||||
@state() compactionStatus: CompactionStatus | null = null;
|
||||
@state() fallbackStatus: FallbackStatus | null = null;
|
||||
@state() chatAvatarUrl: string | null = null;
|
||||
@state() chatThinkingLevel: string | null = null;
|
||||
@state() chatQueue: ChatQueueItem[] = [];
|
||||
|
||||
@@ -23,6 +23,7 @@ function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
|
||||
sending: false,
|
||||
canAbort: false,
|
||||
compactionStatus: null,
|
||||
fallbackStatus: null,
|
||||
messages: [],
|
||||
toolMessages: [],
|
||||
stream: null,
|
||||
@@ -111,6 +112,75 @@ describe("chat view", () => {
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("renders fallback indicator shortly after fallback event", () => {
|
||||
const container = document.createElement("div");
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
fallbackStatus: {
|
||||
selected: "fireworks/minimax-m2p5",
|
||||
active: "deepinfra/moonshotai/Kimi-K2.5",
|
||||
attempts: ["fireworks/minimax-m2p5: rate limit"],
|
||||
occurredAt: 900,
|
||||
},
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const indicator = container.querySelector(".compaction-indicator--fallback");
|
||||
expect(indicator).not.toBeNull();
|
||||
expect(indicator?.textContent).toContain("Fallback active: deepinfra/moonshotai/Kimi-K2.5");
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("hides stale fallback indicator", () => {
|
||||
const container = document.createElement("div");
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(20_000);
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
fallbackStatus: {
|
||||
selected: "fireworks/minimax-m2p5",
|
||||
active: "deepinfra/moonshotai/Kimi-K2.5",
|
||||
attempts: [],
|
||||
occurredAt: 0,
|
||||
},
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".compaction-indicator--fallback")).toBeNull();
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("renders fallback-cleared indicator shortly after transition", () => {
|
||||
const container = document.createElement("div");
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
fallbackStatus: {
|
||||
phase: "cleared",
|
||||
selected: "fireworks/minimax-m2p5",
|
||||
active: "fireworks/minimax-m2p5",
|
||||
previous: "deepinfra/moonshotai/Kimi-K2.5",
|
||||
attempts: [],
|
||||
occurredAt: 900,
|
||||
},
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const indicator = container.querySelector(".compaction-indicator--fallback-cleared");
|
||||
expect(indicator).not.toBeNull();
|
||||
expect(indicator?.textContent).toContain("Fallback cleared: fireworks/minimax-m2p5");
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("shows a stop button when aborting is available", () => {
|
||||
const container = document.createElement("div");
|
||||
const onAbort = vi.fn();
|
||||
|
||||
@@ -21,6 +21,16 @@ export type CompactionIndicatorStatus = {
|
||||
completedAt: number | null;
|
||||
};
|
||||
|
||||
export type FallbackIndicatorStatus = {
|
||||
phase?: "active" | "cleared";
|
||||
selected: string;
|
||||
active: string;
|
||||
previous?: string;
|
||||
reason?: string;
|
||||
attempts: string[];
|
||||
occurredAt: number;
|
||||
};
|
||||
|
||||
export type ChatProps = {
|
||||
sessionKey: string;
|
||||
onSessionKeyChange: (next: string) => void;
|
||||
@@ -30,6 +40,7 @@ export type ChatProps = {
|
||||
sending: boolean;
|
||||
canAbort?: boolean;
|
||||
compactionStatus?: CompactionIndicatorStatus | null;
|
||||
fallbackStatus?: FallbackIndicatorStatus | null;
|
||||
messages: unknown[];
|
||||
toolMessages: unknown[];
|
||||
stream: string | null;
|
||||
@@ -72,6 +83,7 @@ export type ChatProps = {
|
||||
};
|
||||
|
||||
const COMPACTION_TOAST_DURATION_MS = 5000;
|
||||
const FALLBACK_TOAST_DURATION_MS = 8000;
|
||||
|
||||
function adjustTextareaHeight(el: HTMLTextAreaElement) {
|
||||
el.style.height = "auto";
|
||||
@@ -107,6 +119,45 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
|
||||
return nothing;
|
||||
}
|
||||
|
||||
function renderFallbackIndicator(status: FallbackIndicatorStatus | null | undefined) {
|
||||
if (!status) {
|
||||
return nothing;
|
||||
}
|
||||
const phase = status.phase ?? "active";
|
||||
const elapsed = Date.now() - status.occurredAt;
|
||||
if (elapsed >= FALLBACK_TOAST_DURATION_MS) {
|
||||
return nothing;
|
||||
}
|
||||
const details = [
|
||||
`Selected: ${status.selected}`,
|
||||
phase === "cleared" ? `Active: ${status.selected}` : `Active: ${status.active}`,
|
||||
phase === "cleared" && status.previous ? `Previous fallback: ${status.previous}` : null,
|
||||
status.reason ? `Reason: ${status.reason}` : null,
|
||||
status.attempts.length > 0 ? `Attempts: ${status.attempts.slice(0, 3).join(" | ")}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" • ");
|
||||
const message =
|
||||
phase === "cleared"
|
||||
? `Fallback cleared: ${status.selected}`
|
||||
: `Fallback active: ${status.active}`;
|
||||
const className =
|
||||
phase === "cleared"
|
||||
? "compaction-indicator compaction-indicator--fallback-cleared"
|
||||
: "compaction-indicator compaction-indicator--fallback";
|
||||
const icon = phase === "cleared" ? icons.check : icons.brain;
|
||||
return html`
|
||||
<div
|
||||
class=${className}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
title=${details}
|
||||
>
|
||||
${icon} ${message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function generateAttachmentId(): string {
|
||||
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
@@ -352,6 +403,7 @@ export function renderChat(props: ChatProps) {
|
||||
: nothing
|
||||
}
|
||||
|
||||
${renderFallbackIndicator(props.fallbackStatus)}
|
||||
${renderCompactionIndicator(props.compactionStatus)}
|
||||
|
||||
${
|
||||
|
||||
Reference in New Issue
Block a user