mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
perf(webui): render cached model catalog while the chat pane refreshes (#124868)
* perf(webui): render cached model catalog while the chat pane refreshes * test(webui): fix seed-test types and shrink assertion baseline
This commit is contained in:
committed by
GitHub
parent
7344ce4341
commit
800a0bb52a
@@ -1850,7 +1850,7 @@ src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts 5
|
||||
src/agents/embedded-agent-runner/run/abortable.ts 2
|
||||
src/agents/embedded-agent-runner/run/attempt-async-tasks.ts 1
|
||||
src/agents/embedded-agent-runner/run/attempt-before-agent-run.ts 2
|
||||
src/agents/embedded-agent-runner/run/attempt-client-tools.ts 1
|
||||
src/agents/embedded-agent-runner/run/attempt-client-tools.ts 2
|
||||
src/agents/embedded-agent-runner/run/attempt-context-summary.ts 2
|
||||
src/agents/embedded-agent-runner/run/attempt-execution-phase.ts 1
|
||||
src/agents/embedded-agent-runner/run/attempt-finalize.ts 3
|
||||
@@ -4247,7 +4247,6 @@ ui/src/pages/chat/connect-error.ts 1
|
||||
ui/src/pages/chat/critical-observer-notice.ts 1
|
||||
ui/src/pages/chat/export.ts 1
|
||||
ui/src/pages/chat/input-history.ts 3
|
||||
ui/src/pages/chat/models.ts 1
|
||||
ui/src/pages/chat/performance.ts 1
|
||||
ui/src/pages/chat/realtime-talk-gateway-relay.ts 1
|
||||
ui/src/pages/chat/realtime-talk-google-live.ts 2
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { reduceSessionProjection } from "@openclaw/gateway-client/browser";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { render } from "lit";
|
||||
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDeferred } from "../../../../test/helpers/promise.js";
|
||||
import { GatewayRequestError } from "../../api/gateway.ts";
|
||||
import type { AgentsListResult, GatewaySessionRow, SessionsListResult } from "../../api/types.ts";
|
||||
import { rememberChatMetadata } from "../../lib/chat/chat-metadata-store.ts";
|
||||
import {
|
||||
buildFallbackSlashCommands,
|
||||
buildSlashCommandsFromEntries,
|
||||
@@ -30,6 +32,7 @@ import { refreshChatAvatar } from "./chat-avatar.ts";
|
||||
import * as chatCommandExecutor from "./chat-command-executor.ts";
|
||||
import type { executeSlashCommand } from "./chat-command-executor.ts";
|
||||
import { makeChatHost, makeRequestMock } from "./chat-host.test-support.ts";
|
||||
import { renderChatPaneComposerControls } from "./chat-pane-session-controls.ts";
|
||||
import type { ChatHost } from "./chat-send-contract.ts";
|
||||
import {
|
||||
getPendingChatPickerPatch,
|
||||
@@ -423,6 +426,67 @@ describe("refreshChat", () => {
|
||||
expect(host.request).not.toHaveBeenCalledWith("commands.list", expect.anything());
|
||||
});
|
||||
|
||||
it("renders cached models while startup metadata refreshes", async () => {
|
||||
const startup = createDeferred<unknown>();
|
||||
const host = makeChatHost({
|
||||
chatModelSwitchPromises: {},
|
||||
hello: {
|
||||
features: { methods: ["chat.metadata", "chat.startup"] },
|
||||
} as TestChatHost["hello"],
|
||||
requestHandlers: {
|
||||
"chat.startup": () => startup.promise,
|
||||
},
|
||||
});
|
||||
const cachedModel = {
|
||||
available: true,
|
||||
id: "cached-model",
|
||||
name: "Cached Model",
|
||||
provider: "openai",
|
||||
};
|
||||
rememberChatMetadata(expectDefined(host.client, "chat host client"), "main", {
|
||||
commands: [],
|
||||
models: [cachedModel],
|
||||
});
|
||||
|
||||
const refresh = refreshPageChat(asChatPageHost(host), {
|
||||
awaitHistory: true,
|
||||
deferBranches: true,
|
||||
startup: true,
|
||||
});
|
||||
|
||||
expect(host.chatModelCatalog).toEqual([cachedModel]);
|
||||
expect(asChatPageHost(host).chatModelsLoading).toBe(true);
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderChatPaneComposerControls({
|
||||
state: asChatPageHost(host),
|
||||
selectedSession: undefined,
|
||||
agentDefaultModel: undefined,
|
||||
modelAccess: { allowed: true, requiredScope: "operator.write" },
|
||||
effortAccess: { allowed: true, requiredScope: "operator.write" },
|
||||
onModelSetup: vi.fn(),
|
||||
}),
|
||||
container,
|
||||
);
|
||||
expect(container.querySelector('[data-chat-model-catalog-state="refreshing"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain("Refreshing models…");
|
||||
expect(container.textContent).not.toContain("Loading models…");
|
||||
|
||||
startup.resolve({
|
||||
messages: [],
|
||||
metadata: {
|
||||
commands: [],
|
||||
models: [{ ...cachedModel, id: "fresh-model", name: "Fresh Model" }],
|
||||
},
|
||||
});
|
||||
await expect(refresh).resolves.toBeUndefined();
|
||||
await waitForFast(() =>
|
||||
expect(host.chatModelCatalog).toEqual([
|
||||
{ ...cachedModel, id: "fresh-model", name: "Fresh Model" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("fills omitted startup metadata immediately and populates models and commands", async () => {
|
||||
const startup = createDeferred<unknown>();
|
||||
const host = makeChatHost({
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { GatewaySessionRow } from "../../api/types.ts";
|
||||
import {
|
||||
loadChatMetadata,
|
||||
peekChatMetadata,
|
||||
rememberChatMetadata,
|
||||
type ChatMetadataResult,
|
||||
} from "../../lib/chat/chat-metadata-store.ts";
|
||||
@@ -17,7 +18,7 @@ import { flushChatQueueForEvent } from "./chat-send-actions.ts";
|
||||
import { flushChatQueueAfterIdleSessionReconciliation } from "./chat-session.ts";
|
||||
import type { ChatPageHost } from "./chat-state-host.ts";
|
||||
import { resolveChatAgentId } from "./chat-state-route.ts";
|
||||
import { applyModelCatalogResult, loadModels } from "./models.ts";
|
||||
import { loadModels } from "./models.ts";
|
||||
import {
|
||||
reconcileChatRunFromCurrentSessionRow,
|
||||
reconcileChatRunFromSessionRow,
|
||||
@@ -50,8 +51,6 @@ type ChatMetadataRequest = {
|
||||
};
|
||||
|
||||
type ChatMetadataRefreshOptions = {
|
||||
preserveModelCatalogOnFallback?: boolean;
|
||||
refreshModelCatalog?: boolean;
|
||||
requestVersion?: number;
|
||||
};
|
||||
|
||||
@@ -90,7 +89,8 @@ function applyChatMetadataResult(
|
||||
result: ChatMetadataResult,
|
||||
fields: { commands?: boolean; models?: boolean } = {},
|
||||
): ChatMetadataApplyResult {
|
||||
const models = fields.models === false ? undefined : applyModelCatalogResult(result.models);
|
||||
const models =
|
||||
fields.models === false || !Array.isArray(result.models) ? undefined : result.models;
|
||||
if (models) {
|
||||
host.chatModelCatalog = models;
|
||||
host.chatModelCatalogError = null;
|
||||
@@ -106,6 +106,17 @@ function applyChatMetadataResult(
|
||||
return { commands: commandsApplied, models: Boolean(models) };
|
||||
}
|
||||
|
||||
function seedChatModelCatalogFromStore(host: ChatPageHost, client: GatewayBrowserClient): void {
|
||||
const cached = peekChatMetadata(client, resolveChatAgentId(host));
|
||||
if (!Array.isArray(cached?.models)) {
|
||||
return;
|
||||
}
|
||||
// A warm snapshot turns mount-time loading into refreshing; the in-flight
|
||||
// request still owns the authoritative apply.
|
||||
host.chatModelCatalog = cached.models;
|
||||
host.chatModelCatalogError = null;
|
||||
}
|
||||
|
||||
function ownsChatMetadataRequest(request: ChatMetadataRequest): boolean {
|
||||
return (
|
||||
request.host.client === request.client &&
|
||||
@@ -115,17 +126,14 @@ function ownsChatMetadataRequest(request: ChatMetadataRequest): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshCompatibilityModelCatalog(
|
||||
request: ChatMetadataRequest,
|
||||
opts?: { refresh?: boolean },
|
||||
) {
|
||||
async function refreshCompatibilityModelCatalog(request: ChatMetadataRequest) {
|
||||
const agentId = request.agentId?.trim();
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
const models = await loadModels(request.client, {
|
||||
agentId,
|
||||
...(opts?.refresh ? { refresh: true } : { preparedOnly: true }),
|
||||
preparedOnly: true,
|
||||
});
|
||||
if (ownsChatMetadataRequest(request)) {
|
||||
request.host.chatModelCatalog = models;
|
||||
@@ -144,7 +152,6 @@ async function refreshCompatibilityCommands(request: ChatMetadataRequest) {
|
||||
async function refreshMissingChatMetadata(
|
||||
request: ChatMetadataRequest,
|
||||
applied: ChatMetadataApplyResult,
|
||||
opts?: ChatMetadataRefreshOptions,
|
||||
): Promise<void> {
|
||||
if (!ownsChatMetadataRequest(request)) {
|
||||
return;
|
||||
@@ -152,14 +159,9 @@ async function refreshMissingChatMetadata(
|
||||
const commandsRefresh = applied.commands
|
||||
? Promise.resolve()
|
||||
: refreshCompatibilityCommands(request);
|
||||
const preserveModels = opts?.preserveModelCatalogOnFallback;
|
||||
const modelsRefresh =
|
||||
applied.models || preserveModels
|
||||
? Promise.resolve()
|
||||
: refreshCompatibilityModelCatalog(
|
||||
request,
|
||||
opts?.refreshModelCatalog ? { refresh: true } : undefined,
|
||||
);
|
||||
const modelsRefresh = applied.models
|
||||
? Promise.resolve()
|
||||
: refreshCompatibilityModelCatalog(request);
|
||||
await Promise.allSettled([commandsRefresh, modelsRefresh]);
|
||||
}
|
||||
|
||||
@@ -181,9 +183,10 @@ export async function refreshChatMetadata(
|
||||
const agentId = resolveChatAgentId(host);
|
||||
const request = { host, client, agentId, version: requestVersion };
|
||||
host.chatModelsLoading = true;
|
||||
seedChatModelCatalogFromStore(host, client);
|
||||
try {
|
||||
if (isGatewayMethodAdvertised(host, "chat.metadata") === false) {
|
||||
await refreshMissingChatMetadata(request, EMPTY_CHAT_METADATA_APPLY_RESULT, opts);
|
||||
await refreshMissingChatMetadata(request, EMPTY_CHAT_METADATA_APPLY_RESULT);
|
||||
return EMPTY_CHAT_METADATA_APPLY_RESULT;
|
||||
}
|
||||
|
||||
@@ -193,12 +196,12 @@ export async function refreshChatMetadata(
|
||||
}
|
||||
const metadataApplied = applyChatMetadataResult(host, client, agentId, result);
|
||||
if (!metadataApplied.models || !metadataApplied.commands) {
|
||||
await refreshMissingChatMetadata(request, metadataApplied, opts);
|
||||
await refreshMissingChatMetadata(request, metadataApplied);
|
||||
}
|
||||
return metadataApplied;
|
||||
} catch {
|
||||
if (ownsChatMetadataRequest(request)) {
|
||||
await refreshMissingChatMetadata(request, EMPTY_CHAT_METADATA_APPLY_RESULT, opts);
|
||||
await refreshMissingChatMetadata(request, EMPTY_CHAT_METADATA_APPLY_RESULT);
|
||||
}
|
||||
return EMPTY_CHAT_METADATA_APPLY_RESULT;
|
||||
} finally {
|
||||
@@ -388,8 +391,9 @@ export function refreshPageChat(host: ChatPageHost, opts?: ChatRefreshOptions) {
|
||||
const startupMetadataRequestVersion = ownsStartupMetadata
|
||||
? ++host.chatMetadataRequestVersion
|
||||
: null;
|
||||
if (ownsStartupMetadata) {
|
||||
if (ownsStartupMetadata && host.client) {
|
||||
host.chatModelsLoading = true;
|
||||
seedChatModelCatalogFromStore(host, host.client);
|
||||
}
|
||||
|
||||
const refresh = refreshChat(host, {
|
||||
|
||||
@@ -1725,29 +1725,6 @@ describe("refreshChatMetadata", () => {
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("preserves startup models when the gateway does not advertise chat metadata", async () => {
|
||||
const request = vi.fn(async (method: string) => {
|
||||
expect(method).toBe("commands.list");
|
||||
return { commands: [] };
|
||||
});
|
||||
const startupCatalog = [
|
||||
{ id: "startup-model", name: "Startup Model", provider: "openai", available: true },
|
||||
];
|
||||
const state = createMetadataState(request, {
|
||||
chatMetadataRequestVersion: 4,
|
||||
chatModelCatalog: startupCatalog,
|
||||
chatModelsLoading: true,
|
||||
hello: { features: { methods: ["chat.startup"] } },
|
||||
});
|
||||
|
||||
await refreshChatMetadata(state, { preserveModelCatalogOnFallback: true });
|
||||
|
||||
expect(state.chatMetadataRequestVersion).toBe(5);
|
||||
expect(state.chatModelCatalog).toBe(startupCatalog);
|
||||
expect(state.chatModelsLoading).toBe(false);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("loads agent-scoped compatibility models for a non-default agent", async () => {
|
||||
const request = vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "models.list") {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Control UI tests cover models behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import { applyModelCatalogResult, loadModels } from "./models.ts";
|
||||
import { loadModels } from "./models.ts";
|
||||
|
||||
describe("loadModels", () => {
|
||||
it("requests the configured model list view", async () => {
|
||||
@@ -140,37 +140,3 @@ describe("loadModels", () => {
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyModelCatalogResult", () => {
|
||||
it("preserves availability from metadata results", () => {
|
||||
expect(
|
||||
applyModelCatalogResult([
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
provider: "openai",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.3-codex-spark",
|
||||
name: "GPT-5.3 Codex Spark",
|
||||
provider: "codex",
|
||||
available: false,
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
provider: "openai",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.3-codex-spark",
|
||||
name: "GPT-5.3 Codex Spark",
|
||||
provider: "codex",
|
||||
available: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,13 +92,6 @@ export async function loadModels(
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
export function applyModelCatalogResult(models: unknown): ModelCatalogEntry[] | null {
|
||||
if (!Array.isArray(models)) {
|
||||
return null;
|
||||
}
|
||||
return models as ModelCatalogEntry[];
|
||||
}
|
||||
|
||||
async function requestModels(
|
||||
client: GatewayBrowserClient,
|
||||
fallback: ModelCatalogEntry[] | undefined,
|
||||
|
||||
Reference in New Issue
Block a user