mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-03 01:51:33 +00:00
311 lines
15 KiB
TypeScript
311 lines
15 KiB
TypeScript
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 ===== */
|