Files
openclaw/extensions/qa-lab/web/src/ui-render-shell.ts
2026-07-27 14:05:14 +08:00

311 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { findScenarioOutcome, statusDotClass } from "./ui-render-scenario.js";
import { badgeHtml, esc, formatIso } from "./ui-render-utils.js";
import type { RunnerModelOption, RunnerSelection, TabId, UiState } from "./ui-types.js";
const MOCK_MODELS: RunnerModelOption[] = [
{
key: "mock-openai/gpt-5.6-luna",
name: "GPT-5.6 Luna (mock)",
provider: "mock-openai",
input: "text",
preferred: true,
},
{
key: "mock-openai/gpt-5.6-luna-alt",
name: "GPT-5.6 Luna Alt (mock)",
provider: "mock-openai",
input: "text",
preferred: false,
},
];
function deriveSelection(state: UiState): RunnerSelection | null {
return state.runnerDraft ?? state.bootstrap?.runner.selection ?? null;
}
/* ===== Render: Header ===== */
export function renderHeader(state: UiState): string {
const runner = state.bootstrap?.runner ?? null;
const run = state.scenarioRun;
const controlUrl = state.bootstrap?.controlUiUrl;
return `
<header class="header">
<div class="header-left">
<span class="header-title">QA Lab</span>
<div class="header-status">
${runner ? badgeHtml(runner.status) : ""}
${run ? `<span class="badge badge-accent">${run.counts.passed}/${run.counts.total} pass</span>` : ""}
${state.error ? `<span class="badge badge-fail">${esc(state.error)}</span>` : ""}
</div>
</div>
<div class="header-right">
${controlUrl ? `<a class="header-link" href="${esc(controlUrl)}" target="_blank" rel="noreferrer">Control UI</a>` : ""}
<button class="btn-ghost btn-sm" data-action="toggle-sidebar">${state.sidebarCollapsed ? "Show sidebar" : "Hide sidebar"}</button>
<button class="btn-ghost btn-sm" data-action="refresh"${state.busy ? " disabled" : ""}>Refresh</button>
<button class="btn-ghost btn-sm" data-action="reset"${state.busy ? " disabled" : ""}>Reset</button>
<button class="theme-toggle" data-action="toggle-theme" title="Toggle theme">${state.theme === "dark" ? "\u2600" : "\u263E"}</button>
</div>
</header>`;
}
/* ===== Render: Sidebar ===== */
function renderModelSelect(params: {
id: string;
label: string;
value: string;
options: RunnerModelOption[];
disabled: boolean;
}): string {
const values = new Set(params.options.map((o) => o.key));
const options = [...params.options];
if (!values.has(params.value) && params.value.trim()) {
options.unshift({
key: params.value,
name: params.value,
provider: params.value.split("/")[0] ?? "custom",
input: "text",
preferred: false,
});
}
return `
<div class="config-field">
<span class="config-label">${esc(params.label)}</span>
<select id="${esc(params.id)}"${params.disabled ? " disabled" : ""}>
${options
.map(
(o) =>
`<option value="${esc(o.key)}"${o.key === params.value ? " selected" : ""}>${esc(o.key)}</option>`,
)
.join("")}
</select>
</div>`;
}
export function renderSidebar(state: UiState): string {
const scenarios = state.bootstrap?.scenarios ?? [];
const selection = deriveSelection(state);
const runner = state.bootstrap?.runner ?? null;
const run = state.scenarioRun;
const isRunning = runner?.status === "running";
const realModels = state.bootstrap?.runnerCatalog.real ?? [];
const modelOptions =
selection?.providerMode === "live-frontier" && realModels.length > 0 ? realModels : MOCK_MODELS;
const plan = state.runnerPlanOverride ?? state.bootstrap?.runner.plan ?? null;
const resolvedIds = plan?.selectedScenarios.map((scenario) => scenario.id) ?? [];
const selectedIds = new Set(
selection?.scenarioIds ?? (state.runnerDraftDirty ? [] : resolvedIds),
);
const profiles = state.bootstrap?.runnerCatalog.profiles ?? [];
const channels = state.bootstrap?.runnerCatalog.channels ?? [];
const hasRunnableSelection =
selection?.scenarioIds === null
? Boolean(selection.profile)
: Boolean(selection?.scenarioIds.length);
return `
<aside class="sidebar${state.sidebarCollapsed ? " is-collapsed" : ""}">
<div class="sidebar-panel-tabs">
<button class="btn-sm btn-ghost sidebar-panel-tab${state.sidebarPanel === "scenarios" ? " active" : ""}" data-sidebar-panel="scenarios">Scenarios</button>
<button class="btn-sm btn-ghost sidebar-panel-tab${state.sidebarPanel === "config" ? " active" : ""}" data-sidebar-panel="config">Config</button>
<button class="btn-sm btn-ghost sidebar-panel-tab${state.sidebarPanel === "run" ? " active" : ""}" data-sidebar-panel="run">Run</button>
</div>
${
state.sidebarPanel === "config"
? `<div class="sidebar-section sidebar-panel-body">
<div class="sidebar-section-title"><h3>Configuration</h3></div>
<div class="config-field">
<span class="config-label">Profile</span>
<select id="run-profile"${isRunning ? " disabled" : ""}>
${profiles
.map(
(profile) =>
`<option value="${esc(profile.id)}"${selection?.profile === profile.id ? " selected" : ""}>${esc(profile.id)}</option>`,
)
.join("")}
</select>
</div>
<div class="config-field">
<span class="config-label">Provider lane</span>
<select id="provider-mode"${isRunning ? " disabled" : ""}>
<option value="mock-openai"${selection?.providerMode === "mock-openai" ? " selected" : ""}>Synthetic (mock)</option>
<option value="live-frontier"${selection?.providerMode === "live-frontier" ? " selected" : ""}>Real frontier providers</option>
</select>
</div>
<div class="config-field">
<span class="config-label">Channel driver</span>
<select id="channel-driver"${isRunning ? " disabled" : ""}>
<option value="qa-channel"${selection?.channelDriver === "qa-channel" ? " selected" : ""}>Synthetic QA channel</option>
<option value="crabline"${selection?.channelDriver === "crabline" ? " selected" : ""}>Crabline channel driver</option>
<option value="live"${selection?.channelDriver === "live" ? " selected" : ""}>Real channels</option>
</select>
</div>
<div class="config-field">
<span class="config-label">Execution channel</span>
<select id="execution-channel"${isRunning ? " disabled" : ""}>
<option value=""${selection?.channel ? "" : " selected"}>Catalog/default</option>
${channels
.map(
(channel) =>
`<option value="${esc(channel)}"${selection?.channel === channel ? " selected" : ""}>${esc(channel)}</option>`,
)
.join("")}
</select>
</div>
<div class="config-field">
<span class="config-label">Evidence mode</span>
<select id="evidence-mode"${isRunning ? " disabled" : ""}>
<option value="full"${selection?.evidenceMode === "full" ? " selected" : ""}>Full</option>
<option value="slim"${selection?.evidenceMode === "slim" ? " selected" : ""}>Slim</option>
</select>
</div>
<div class="config-field">
<span class="config-label">Runtime pair</span>
<select id="runtime-pair"${isRunning ? " disabled" : ""}>
<option value=""${selection?.runtimePair ? "" : " selected"}>Single runtime</option>
<option value="openclaw,codex"${selection?.runtimePair ? " selected" : ""}>OpenClaw × Codex</option>
</select>
</div>
<div class="config-field">
<span class="config-label">Runtime-pair lane</span>
<select id="runtime-pair-lane"${isRunning ? " disabled" : ""}>
<option value=""${selection?.runtimePairLane ? "" : " selected"}>Profile/default</option>
${(["core", "extended", "soak"] as const)
.map(
(lane) =>
`<option value="${lane}"${selection?.runtimePairLane === lane ? " selected" : ""}>${lane}</option>`,
)
.join("")}
</select>
</div>
${renderModelSelect({
id: "primary-model",
label: "Primary model",
value: selection?.primaryModel ?? "",
options: modelOptions,
disabled: isRunning,
})}
${renderModelSelect({
id: "alternate-model",
label: "Alternate model",
value: selection?.alternateModel ?? "",
options: modelOptions,
disabled: isRunning,
})}
${
selection?.providerMode === "live-frontier"
? `<div class="config-hint">${esc(
state.bootstrap?.runnerCatalog.status === "loading"
? "Loading model catalog\u2026"
: state.bootstrap?.runnerCatalog.status === "failed"
? "Catalog unavailable; using manual input."
: `${realModels.length} models available`,
)}</div>`
: ""
}
</div>`
: state.sidebarPanel === "run"
? `<div class="sidebar-panel-body">${run || runner ? renderRunStatus(state) : '<div class="sidebar-section"><div class="text-dimmed text-sm">No run data yet.</div></div>'}</div>`
: `<div class="sidebar-section sidebar-scenarios sidebar-panel-body">
<div class="sidebar-section-title">
<h3>Scenarios (${selection?.scenarioIds === null ? "profile" : selectedIds.size}/${scenarios.length})</h3>
<div class="btn-group">
<button class="btn-sm btn-ghost" data-action="select-all-scenarios"${isRunning ? " disabled" : ""}>All</button>
<button class="btn-sm btn-ghost" data-action="clear-scenarios"${isRunning ? " disabled" : ""}>Profile</button>
</div>
</div>
<div class="scenario-scroll">
${scenarios
.map((s) => {
const outcome = findScenarioOutcome(state, s);
const status = outcome?.status ?? "pending";
return `
<label class="scenario-item">
<input type="checkbox" data-scenario-toggle-id="${esc(s.id)}"${selectedIds.has(s.id) ? " checked" : ""}${isRunning ? " disabled" : ""} />
<span class="${statusDotClass(status)}"></span>
<div class="scenario-item-info">
<span class="scenario-item-title">${esc(s.title)}</span>
<span class="scenario-item-meta">${esc(s.surface)} · ${esc(s.execution?.kind ?? "flow")} · ${esc(s.id)}</span>
</div>
</label>`;
})
.join("")}
</div>
</div>`
}
<!-- Actions -->
<div class="sidebar-actions">
<button class="btn-primary" data-action="run-suite"${isRunning || !hasRunnableSelection || state.busy ? " disabled" : ""}>
${selection?.scenarioIds === null ? `Resolve & run ${esc(selection.profile)}` : `Run ${selectedIds.size} scenario${selectedIds.size === 1 ? "" : "s"}`}
</button>
<div class="btn-row">
<button data-action="self-check"${isRunning || state.busy ? " disabled" : ""}>Self-check</button>
<button data-action="kickoff"${isRunning || state.busy ? " disabled" : ""}>Kickoff</button>
</div>
</div>
</aside>`;
}
function renderRunStatus(state: UiState): string {
const run = state.scenarioRun;
const runner = state.bootstrap?.runner ?? null;
const plan = state.runnerPlanOverride ?? (state.runnerDraftDirty ? null : (runner?.plan ?? null));
if (!run && !runner) {
return "";
}
return `
<div class="sidebar-section run-status">
<div class="sidebar-section-title">
<h3>Run Status</h3>
${runner ? badgeHtml(runner.status) : ""}
</div>
${
run
? `<div class="run-counts">
<div class="run-count"><span class="run-count-value">${run.counts.total}</span><span class="run-count-label">Total</span></div>
<div class="run-count"><span class="run-count-value count-pass">${run.counts.passed}</span><span class="run-count-label">Pass</span></div>
<div class="run-count"><span class="run-count-value count-fail">${run.counts.failed}</span><span class="run-count-label">Fail</span></div>
<div class="run-count"><span class="run-count-value">${run.counts.pending + run.counts.running}</span><span class="run-count-label">Left</span></div>
</div>`
: ""
}
<div class="run-meta">
${plan ? `<strong>Resolved plan:</strong> ${plan.selectedScenarios.length} selected · ${esc(plan.executionKinds.join(", ") || "none")}` : ""}
${plan?.exclusions.length ? `<br>${plan.exclusions.length} excluded: ${esc(plan.exclusions.map((item) => `${item.scenarioId} (${item.reasons.join(", ")})`).join("; "))}` : ""}
${plan?.errors.length ? `<br><span style="color:var(--danger)">${esc(plan.errors.join(" "))}</span>` : ""}
${runner?.startedAt ? `Started ${esc(formatIso(runner.startedAt))}` : ""}
${runner?.finishedAt ? `<br>Finished ${esc(formatIso(runner.finishedAt))}` : ""}
${runner?.error ? `<br><span style="color:var(--danger)">${esc(runner.error)}</span>` : ""}
</div>
</div>`;
}
/* ===== Render: Tab bar ===== */
export function renderTabBar(state: UiState): string {
const tabs: Array<{ id: TabId; label: string }> = [
{ id: "chat", label: "Chat" },
{ id: "results", label: "Results" },
{ id: "evidence", label: "Evidence Archive" },
{ id: "report", label: "Report" },
{ id: "events", label: "Events" },
{ id: "capture", label: "Capture" },
];
return `
<nav class="tab-bar">
${tabs
.map(
(t) =>
`<button class="tab-btn${state.activeTab === t.id ? " active" : ""}" data-tab="${t.id}">${t.label}</button>`,
)
.join("")}
<div class="tab-spacer"></div>
</nav>`;
}
/* ===== Render: Chat tab ===== */