();
+let nextRobotStatusRequestId = 1;
+let robotStatusFilter: RobotStatusFilter = "all";
+let robotStatusesRefreshed = false;
+let robotStatusesRefreshing = false;
+let robotResourcesLoading = false;
+let editingValidatedSequence = false;
+let addActionMenuOpen = false;
+let actionMenuId: string | null = null;
+let draggedActionId: string | null = null;
+let statusMessage = "";
+let statusTone: StatusTone = "neutral";
+
+function render(): void {
+ document.documentElement.lang = language === "zh" ? "zh-CN" : "en";
+ document.title = copy("场景运行 | temi Developer", "Scenario Runner | temi Developer");
+ app.innerHTML = renderPage();
+ bindEvents();
+}
+
+function renderPage(): string {
+ const screen = currentScreen();
+ return `
+
+ ${renderHeader()}
+
+ ${screen === "connect" ? "" : renderProgress(screen)}
+ ${renderScreen(screen)}
+
+ ${renderPlayConfirmation()}
+
+ `;
+}
+
+function currentScreen(): DemoScreen {
+ if (client === null || runState.phase === "idle" || runState.phase === "verifying") {
+ return "connect";
+ }
+ if (discovery === null || discovery.state.selectedRobot === null) return "robot";
+
+ const lifecyclePhase = lifecycle?.state.phase;
+ const hasRunView = lifecycle !== null && (
+ ["starting", "running", "stopping", "terminal", "unknown"].includes(lifecyclePhase ?? "") ||
+ ["starting", "running", "stopping", "terminal", "unknown", "failed"].includes(runState.phase)
+ );
+ if (hasRunView) return "running";
+ if (editingValidatedSequence) return "actions";
+ if (
+ lifecyclePhase === "preflighting" ||
+ lifecyclePhase === "blocked" ||
+ lifecyclePhase === "confirmation_required" ||
+ lifecyclePhase === "failed"
+ ) {
+ return "review";
+ }
+ if (
+ composer?.state.validation.status === "succeeded" &&
+ (runState.phase === "ready" || runState.phase === "terminal")
+ ) {
+ return "review";
+ }
+ return "actions";
+}
+
+function renderHeader(): string {
+ const connected = client !== null;
+ const verified = discovery?.state.verify.status === "succeeded";
+ const statusText = !connected
+ ? copy("未连接", "Not connected")
+ : verified
+ ? copy(`${environmentLabel(environment)} · 已连接`, `${environmentLabel(environment)} · Connected`)
+ : copy("正在连接", "Connecting");
+ const canDisconnect = connected && !isLifecycleLocked() && runState.phase !== "validating";
+ const languageLabel = language === "zh" ? "中文" : "English";
+
+ return `
+
+ `;
+}
+
+function renderLanguageMenu(): string {
+ return `
+
+ `;
+}
+
+function renderProgress(screen: DemoScreen): string {
+ const steps = [
+ copy("选择机器人", "Select robot"),
+ copy("编排动作", "Add actions"),
+ copy("确认运行", "Review"),
+ ];
+ const currentIndex = screen === "robot" ? 0 : screen === "actions" ? 1 : screen === "review" ? 2 : 3;
+ return `
+
+
+ ${steps.map((label, index) => {
+ const stateClass = index < currentIndex ? "complete" : index === currentIndex ? "current" : "";
+ const marker = index < currentIndex ? "✓" : String(index + 1);
+ return `${marker} ${label} `;
+ }).join("")}
+
+
+ `;
+}
+
+function renderScreen(screen: DemoScreen): string {
+ if (screen === "connect") return renderConnect();
+ if (screen === "robot") return renderRobotSelection();
+ if (screen === "actions") return renderActions();
+ if (screen === "review") return renderReview();
+ return renderRunning();
+}
+
+function renderConnect(): string {
+ const connecting = runState.phase === "verifying";
+ const failed = runState.phase === "failed";
+ const blocked = environmentTransport[environment] === "blocked";
+ const canConnect = runState.phase === "idle" && client === null && !blocked;
+ return `
+
+
+
+ ${failed || blocked ? `
${copy("重新连接", "Reconnect")} ` : ""}
+
+
+ `;
+}
+
+function renderRobotSelection(): string {
+ const state = discovery?.state;
+ const allRobots = state?.robots.data ?? [];
+ const orderedRobots = robotStatusesRefreshed ? sortRobotsOnlineFirst(allRobots) : allRobots;
+ const robots = robotStatusFilter === "online"
+ ? orderedRobots.filter((robot) => isRobotOnline(robot.serialNumber))
+ : orderedRobots;
+ const onlineCount = allRobots.filter((robot) => isRobotOnline(robot.serialNumber)).length;
+ const loading = state?.robots.status === "pending" || runState.phase === "discovering" && state?.robots.status !== "succeeded";
+ const failed = state?.robots.status === "failed" || runState.phase === "failed";
+
+ return `
+
+
+
+
${copy("选择机器人", "Select a robot")}
+
${copy("选择这次运行要使用的机器人。", "Choose the robot for this run.")}
+
+ ${allRobots.length > 0 ? `
+
+ ${copy("仅看在线", "Online only")}${robotStatusesRefreshed ? ` ${onlineCount}` : ""}
+
+ ↻
+ ${robotStatusesRefreshing ? copy("刷新中…", "Refreshing…") : copy("刷新状态", "Refresh status")}
+
+ ${copy(`${allRobots.length} 台设备`, `${allRobots.length} ${allRobots.length === 1 ? "device" : "devices"}`)}
+
+ ` : ""}
+
+ ${loading ? renderLoadingPanel(copy("正在读取机器人…", "Loading robots…")) : ""}
+ ${failed ? renderInlineNotice(copy("机器人列表读取失败,请重新连接。", "Could not load the robot list. Reconnect and try again."), "error") : ""}
+ ${!loading && !failed && allRobots.length === 0 ? renderInlineNotice(copy("没有可用的机器人。", "No robots are available."), "error") : ""}
+ ${!loading && !failed && allRobots.length > 0 && robots.length === 0 ? renderInlineNotice(copy("当前没有在线机器人。", "No robots are currently online."), "neutral") : ""}
+ ${robots.length === 0 ? "" : `
+
+ ${robots.map((robot, index) => {
+ const selected = pendingRobotSerialNumber === robot.serialNumber;
+ const statusPanel = robotStatusPanels.get(robot.serialNumber);
+ const expanded = statusPanel?.expanded === true;
+ const panelId = `robot-status-${index}`;
+ return `
+
+
+
+
+ ${displayName(robot.teminame, copy("未命名机器人", "Unnamed robot"))}
+ ${escapeHtml(robot.serialNumber)}
+
+ ${selected ? "✓" : ""}
+
+ ${renderRobotCardStatus(statusPanel)}
+
+
+ ${expanded ? renderRobotStatusPanel(panelId, statusPanel) : ""}
+
+ `;
+ }).join("")}
+
+ `}
+ ${renderStatusNotice()}
+
+ ${failed ? `${copy("重新连接", "Reconnect")} ` : " "}
+
+ ${copy("继续", "Continue")} →
+
+
+
+ `;
+}
+
+function renderRobotCardStatus(panel: RobotStatusPanelState | undefined): string {
+ if (panel?.requestState === "pending") {
+ return ` ${copy("刷新中…", "Refreshing…")} `;
+ }
+ if (panel?.requestState === "failed") {
+ return ` ${copy("读取失败", "Unavailable")} `;
+ }
+ if (panel?.requestState === "succeeded" && panel.data !== null) {
+ return ` ${statusLabel(panel.data.status)} `;
+ }
+ return ` ${copy("未刷新", "Not refreshed")} `;
+}
+
+function sortRobotsOnlineFirst(robots: readonly RobotSummary[]): readonly RobotSummary[] {
+ return [...robots].sort((left, right) => Number(isRobotOnline(right.serialNumber)) - Number(isRobotOnline(left.serialNumber)));
+}
+
+function isRobotOnline(serialNumber: string): boolean {
+ const panel = robotStatusPanels.get(serialNumber);
+ return panel?.data?.status === "online" && panel.requestState !== "failed";
+}
+
+function renderRobotStatusPanel(
+ panelId: string,
+ panel: RobotStatusPanelState | undefined,
+): string {
+ if (panel === undefined || panel.requestState === "idle") return "";
+ if (panel.requestState === "pending" && panel.data === null) {
+ return `
+
+
${copy("正在读取状态…", "Loading status…")}
+
+ `;
+ }
+ if (panel.requestState === "failed" || panel.data === null) {
+ return `
+
+
${escapeHtml(localizeRuntimeText(panel.error ?? "状态读取失败。"))}
+
+ `;
+ }
+
+ const status = panel.data;
+ return `
+
+ ${panel.requestState === "pending" ? `
${copy("正在更新…", "Updating…")}
` : ""}
+
+
${copy("状态", "Status")} ${statusLabel(status.status)}
+
${copy("电量", "Battery")} ${escapeHtml(robotBatteryText(status))}
+
${copy("移动", "Movement")} ${escapeHtml(robotMovementText(status))}
+
${copy("任务", "Sequence")} ${escapeHtml(robotSequenceText(status))}
+
${copy("通话", "Call")} ${escapeHtml(robotCallText(status))}
+
+
${copy("最近一次读取结果", "Latest reading")}${panel.receivedAt === null ? "" : ` · ${formatClock(panel.receivedAt)}`}
+
+ `;
+}
+
+function renderActions(): string {
+ const discoveryState = discovery?.state;
+ const composerState = composer?.state;
+ const selectedRobot = discoveryState?.selectedRobot;
+ if (discoveryState === undefined || selectedRobot === null || selectedRobot === undefined || composerState === undefined) {
+ return renderLoadingPanel(copy("正在准备动作编辑器…", "Preparing the action editor…"));
+ }
+
+ const actions = composerState.actions;
+ const loadingResources = robotResourcesLoading || runState.phase === "discovering";
+ const editLocked = loadingResources || runState.phase === "validating" || isLifecycleLocked();
+ const validationIssues = composerState.validation.status === "failed" ? composerState.validation.issues.length : 0;
+ const hasResourceFailures = [discoveryState.status, discoveryState.locations, discoveryState.contacts]
+ .some((slot) => slot.status === "failed");
+
+ return `
+
+
+
+
${copy("编排动作", "Add actions")}
+
${copy("动作会按照这里的顺序执行。", "Actions run in the order shown here.")}
+
+
${copy(`${actions.length} 个动作`, `${actions.length} ${actions.length === 1 ? "action" : "actions"}`)}
+
+ ${renderRobotContext(discoveryState)}
+ ${loadingResources ? renderInlineNotice(copy("正在读取机器人资源…", "Loading robot resources…"), "neutral") : renderResourceNotice(discoveryState)}
+ ${loadingResources || hasResourceFailures ? "" : renderStatusNotice()}
+ ${actions.length === 0
+ ? `${copy("还没有动作", "No actions yet")} ${copy("从移动、播报或呼叫开始。", "Start with movement, speech, or a call.")}
`
+ : `${actions.map((action, index) => renderActionCard(action, index, composerState, editLocked)).join("")} `}
+ ${renderComposerValidation(composerState)}
+
+
+ + ${copy("添加动作", "Add action")}
+
+ ${addActionMenuOpen ? renderAddActionMenu(discoveryState, editLocked) : ""}
+
+
+
+ ← ${copy("返回", "Back")}
+
+
+ ${runState.phase === "validating"
+ ? copy("正在检查…", "Checking…")
+ : validationIssues > 0
+ ? copy(`修正 ${validationIssues} 个问题`, `Fix ${validationIssues} ${validationIssues === 1 ? "issue" : "issues"}`)
+ : composerState.validation.status === "succeeded"
+ ? copy("返回确认", "Return to review")
+ : copy("检查并继续", "Check and continue")}
+ →
+
+
+
+ `;
+}
+
+function renderRobotContext(state: DiscoveryState): string {
+ const robot = state.selectedRobot;
+ if (robot === null) return "";
+ const status = state.status.data?.status;
+ const battery = state.status.data?.battery?.level;
+ const updatedAt = state.status.receivedAt;
+ const meta = [
+ robot.serialNumber,
+ typeof battery === "number" && Number.isFinite(battery) ? copy(`${battery}% 电量`, `${battery}% battery`) : null,
+ updatedAt === null ? null : copy(`更新于 ${formatClock(updatedAt)}`, `Updated at ${formatClock(updatedAt)}`),
+ ].filter((item): item is string => item !== null).join(" · ");
+
+ return `
+
+
+
t
+
+
${displayName(robot.teminame, copy("未命名机器人", "Unnamed robot"))}
+
${escapeHtml(meta)}
+
+
+
+ ${renderStatusPill(status, robotResourcesLoading || state.status.status === "pending")}
+ ${copy("刷新", "Refresh")}
+
+
+ `;
+}
+
+function renderResourceNotice(state: DiscoveryState): string {
+ const failures: string[] = [];
+ if (state.status.status === "failed") failures.push(copy("状态读取失败", "Status unavailable"));
+ if (state.locations.status === "failed") failures.push(copy("位置不可用", "Locations unavailable"));
+ if (state.contacts.status === "failed") failures.push(copy("联系人不可用", "Contacts unavailable"));
+ if (failures.length > 0) {
+ return `
+
+ ${renderInlineNotice(`${failures.join(copy(",", ", "))}${copy("。", ".")}`, "error")}
+ ${copy("重新读取", "Reload")}
+
+ `;
+ }
+ return "";
+}
+
+function renderAddActionMenu(state: DiscoveryState, locked: boolean): string {
+ const actions = [
+ { type: "MOVEMENT", glyph: "M", label: copy("前往位置", "Go to location") },
+ { type: "SPEAK", glyph: "S", label: copy("播报文字", "Speak text") },
+ { type: "START_CALL", glyph: "C", label: copy("发起呼叫", "Start call") },
+ ] as const;
+ return `
+
+ `;
+}
+
+function renderActionCard(
+ action: SequenceActionDraft,
+ index: number,
+ state: SequenceComposerState,
+ locked: boolean,
+): string {
+ return `
+
+
+ ⠿
+
+
+ ${index + 1}
+
${actionLabel(action.type)}
+
+ ${renderActionFields(action, state, locked)}
+
+
+
+
+ `;
+}
+
+function renderActionFields(
+ action: SequenceActionDraft,
+ state: SequenceComposerState,
+ locked: boolean,
+): string {
+ const error = actionValidationIssue(state, action.id);
+ const context = state.context;
+ if (action.type === "MOVEMENT") {
+ return `
+
+
${copy("位置", "Location")}
+
+ ${copy("请选择位置", "Select a location")}
+ ${(context?.locations ?? []).map((option) => `${escapeHtml(option.label)} `).join("")}
+
+ ${error === null ? "" : `
${escapeHtml(error)}
`}
+
+ `;
+ }
+ if (action.type === "SPEAK") {
+ return `
+
+
${copy("播报内容", "Text to speak")}
+
+ ${error === null ? "" : `
${escapeHtml(error)}
`}
+
+ `;
+ }
+ return `
+
+
${copy("联系人", "Contacts")}
+
+ ${(context?.contacts ?? []).map((option, optionIndex) => `
+
+
+ ${escapeHtml(option.label)}
+
+ `).join("")}
+
+ ${error === null ? "" : `
${escapeHtml(error)}
`}
+
+ `;
+}
+
+function renderActionMenu(action: SequenceActionDraft, index: number, total: number): string {
+ return `
+
+ `;
+}
+
+function renderComposerValidation(state: SequenceComposerState): string {
+ if (state.validation.status === "pending") return renderInlineNotice(copy("正在检查动作…", "Checking actions…"), "neutral");
+ if (state.validation.status !== "failed") return "";
+ const sequenceIssues = state.validation.issues.filter((issue) => issue.actionId === null);
+ const remoteError = state.validation.error;
+ if (sequenceIssues.length === 0 && remoteError === null) return "";
+ const message = remoteError === null
+ ? sequenceIssues.map((issue) => validationIssueText(issue.field)).join(" ")
+ : validationErrorText(remoteError);
+ return renderInlineNotice(message, "error");
+}
+
+function renderReview(): string {
+ const discoveryState = discovery?.state;
+ const composerState = composer?.state;
+ const robot = discoveryState?.selectedRobot;
+ if (discoveryState === undefined || composerState === undefined || robot === null || robot === undefined) {
+ return renderLoadingPanel(copy("正在准备确认内容…", "Preparing the review…"));
+ }
+
+ const snapshot = lifecycle?.state;
+ const preflighting = snapshot?.phase === "preflighting";
+ const confirmationRequired = snapshot?.phase === "confirmation_required";
+ const blocked = snapshot?.phase === "blocked";
+ const preflightFailed = snapshot?.phase === "failed";
+ const status = snapshot?.latestStatus?.status ?? discoveryState.status.data?.status;
+ const primaryLabel = preflighting
+ ? copy("正在检查状态…", "Checking status…")
+ : blocked || preflightFailed
+ ? copy("重新检查", "Check again")
+ : copy("确认运行", "Confirm run");
+
+ return `
+
+
+
+
${copy("确认运行", "Review run")}
+
${copy("检查机器人和动作顺序。", "Review the robot and action order.")}
+
+
+
+
+
+
${displayName(robot.teminame, copy("未命名机器人", "Unnamed robot"))}
+
${escapeHtml(robot.serialNumber)}${discoveryState.status.receivedAt === null ? "" : copy(` · 状态更新于 ${formatClock(discoveryState.status.receivedAt)}`, ` · Status updated at ${formatClock(discoveryState.status.receivedAt)}`)}
+
+ ${renderStatusPill(status, preflighting)}
+
+
+ ${composerState.actions.map((action, index) => `
+
+ ${index + 1}
+ ${actionLabel(action.type)} ${escapeHtml(actionSummary(action, composerState.context))}
+ ${copy("已检查", "Checked")}
+
+ `).join("")}
+
+
+ ${renderReviewCheck(snapshot, status)}
+ ${statusTone === "error" && !blocked && !preflightFailed ? renderStatusNotice() : ""}
+
+
+ ← ${copy("返回修改", "Back to edit")}
+
+ ${primaryLabel}
+
+
+ `;
+}
+
+function renderReviewCheck(snapshot: LifecycleSnapshot | undefined, status: RobotStatus["status"] | undefined): string {
+ if (snapshot?.phase === "preflighting") {
+ return `${copy("正在刷新机器人状态…", "Refreshing robot status…")}
`;
+ }
+ if (snapshot?.phase === "blocked") {
+ return `! ${copy(`机器人当前${statusLabelFor(status, "zh")},暂时无法运行。`, `The robot is currently ${statusLabelFor(status, "en")} and cannot run yet.`)}
`;
+ }
+ if (snapshot?.phase === "failed") {
+ return `! ${copy("状态检查失败,请重试。", "The status check failed. Try again.")}
`;
+ }
+ return `✓ ${copy("动作已检查,运行前会再次确认机器人在线。", "Actions are checked. Robot availability will be confirmed again before running.")}
`;
+}
+
+function renderPlayConfirmation(): string {
+ const snapshot = lifecycle?.state;
+ const composerState = composer?.state;
+ const robot = discovery?.state.selectedRobot;
+ if (snapshot?.phase !== "confirmation_required" || composerState === undefined || robot === null || robot === undefined) {
+ return "";
+ }
+ const hasSafetySensitiveAction = composerState.actions.some((action) => action.type === "MOVEMENT" || action.type === "START_CALL");
+ const actionOrder = composerState.actions.map((action) => actionLabel(action.type)).join(" → ");
+ return `
+
+
+ ${copy("确认运行", "Confirm run")}
+
+
${copy("环境", "Environment")} ${environmentLabel(environment)}
+
${copy("机器人", "Robot")} ${displayName(robot.teminame, copy("未命名机器人", "Unnamed robot"))}
+
${copy("动作", "Actions")} ${copy(`${composerState.actions.length} 个`, `${composerState.actions.length}`)} · ${actionOrder}
+
${copy("当前状态", "Current status")} ${statusLabel(snapshot.latestStatus?.status)}
+
+ ${copy("将控制真实机器人。", "This will control a real robot.")}${hasSafetySensitiveAction ? copy("确认移动路径安全,且呼叫对象已经知情。", " Confirm the route is safe and call recipients have been informed.") : ""}
+
+ ${copy("返回修改", "Back to edit")}
+ ${copy("确认运行", "Confirm run")}
+
+
+
+ `;
+}
+
+function renderRunning(): string {
+ const snapshot = lifecycle?.state;
+ const composerState = composer?.state;
+ const robot = discovery?.state.selectedRobot;
+ if (snapshot === undefined || composerState === undefined || robot === null || robot === undefined) {
+ return renderLoadingPanel(copy("正在读取运行状态…", "Loading run status…"));
+ }
+
+ const title = runningTitle(snapshot);
+ const subtitle = runningSubtitle(snapshot);
+ const canStop = snapshot.identity !== null && snapshot.identity.sequenceId !== null &&
+ ["running", "unknown"].includes(snapshot.phase) && !snapshot.stopPending;
+ const terminal = snapshot.phase === "terminal";
+ const failed = snapshot.phase === "failed";
+
+ return `
+
+
+
+
${title}
+
${subtitle}
+
+ ${renderRunBadge(snapshot)}
+
+
+
+
+
${displayName(robot.teminame, copy("未命名机器人", "Unnamed robot"))}
+
${escapeHtml(robot.serialNumber)}
+
+ ${renderStatusPill(snapshot.latestStatus?.status ?? discovery?.state.status.data?.status, snapshot.pollInFlight)}
+
+
+ ${composerState.actions.map((action, index) => renderRunItem(action, index, composerState.context, snapshot)).join("")}
+
+
+ ${snapshot.warning === null ? "" : renderInlineNotice(localizeRuntimeText(snapshot.warning), "error")}
+ ${snapshot.error === null || snapshot.warning !== null ? "" : renderInlineNotice(lifecycleErrorText(snapshot), "error")}
+ ${statusTone === "error" && snapshot.warning === null && snapshot.error === null ? renderStatusNotice() : ""}
+
+
+ ${canStop
+ ? `${snapshot.stopPending ? copy("正在停止…", "Stopping…") : copy("停止运行", "Stop run")} `
+ : terminal
+ ? `${copy("再次运行", "Run again")} `
+ : failed
+ ? `${copy("重新开始", "Start over")} `
+ : ""}
+
+
+ `;
+}
+
+function renderRunItem(
+ action: SequenceActionDraft,
+ index: number,
+ context: ComposerContext | null,
+ snapshot: LifecycleSnapshot,
+): string {
+ const submitted = ["running", "stopping", "unknown", "terminal"].includes(snapshot.phase);
+ const status = snapshot.phase === "terminal"
+ ? copy("序列已结束", "Sequence ended")
+ : submitted
+ ? copy("已提交", "Submitted")
+ : copy("等待中", "Waiting");
+ const className = submitted ? "active" : "waiting";
+ const marker = String(index + 1);
+ return `
+
+ ${marker}
+ ${actionLabel(action.type)} ${escapeHtml(actionSummary(action, context))}
+ ${status}
+
+ `;
+}
+
+function renderRunBadge(snapshot: LifecycleSnapshot): string {
+ if (snapshot.phase === "running") return ` ${copy("运行中", "Running")} `;
+ const success = snapshot.phase === "terminal" && snapshot.observation === "completed";
+ return `${lifecyclePhaseLabel(snapshot.phase)} `;
+}
+
+function renderStatusPill(status: RobotStatus["status"] | undefined, loading = false): string {
+ if (loading) return ` ${copy("读取中", "Loading")} `;
+ const className = status === "online" ? "online" : status === "busy" || status === "privacy" ? "busy" : "";
+ return `${statusLabel(status)} `;
+}
+
+function renderLoadingPanel(message: string): string {
+ return `${escapeHtml(message)}
`;
+}
+
+function renderStatusNotice(): string {
+ if (statusMessage.trim().length === 0) return "";
+ return renderInlineNotice(localizeRuntimeText(statusMessage), statusTone);
+}
+
+function renderInlineNotice(message: string, tone: StatusTone): string {
+ return `${escapeHtml(message)}
`;
+}
+
+function bindEvents(): void {
+ app.querySelector("[data-language-toggle]")?.addEventListener("click", () => {
+ languageMenuOpen = !languageMenuOpen;
+ render();
+ });
+
+ app.querySelectorAll("[data-language]").forEach((button) => {
+ button.addEventListener("click", () => {
+ const nextLanguage = button.dataset.language;
+ if (!isLanguage(nextLanguage)) return;
+ language = nextLanguage;
+ languageMenuOpen = false;
+ render();
+ });
+ });
+
+ app.querySelector("[data-environment]")?.addEventListener("change", (event) => {
+ const value = (event.currentTarget as HTMLSelectElement).value;
+ if (!isEnvironment(value)) return;
+ environment = value;
+ resetPage("");
+ });
+
+ app.querySelector("[data-connection-form]")?.addEventListener("submit", (event) => {
+ event.preventDefault();
+ connectAndVerify();
+ });
+
+ app.querySelectorAll("[data-robot-card]").forEach((button) => {
+ button.addEventListener("click", () => {
+ pendingRobotSerialNumber = button.dataset.robotCard ?? null;
+ statusMessage = "";
+ render();
+ });
+ });
+
+ app.querySelectorAll("[data-robot-status-toggle]").forEach((button) => {
+ button.addEventListener("click", () => {
+ const serialNumber = button.dataset.robotStatusToggle;
+ if (serialNumber !== undefined) toggleRobotStatus(serialNumber);
+ });
+ });
+
+ app.querySelector("[data-filter-online]")?.addEventListener("click", () => {
+ if (!robotStatusesRefreshed) return;
+ robotStatusFilter = robotStatusFilter === "online" ? "all" : "online";
+ if (
+ robotStatusFilter === "online" &&
+ pendingRobotSerialNumber !== null &&
+ !isRobotOnline(pendingRobotSerialNumber)
+ ) {
+ pendingRobotSerialNumber = null;
+ }
+ render();
+ });
+
+ app.querySelector("[data-refresh-all-statuses]")?.addEventListener("click", () => {
+ void refreshAllRobotStatuses();
+ });
+
+ app.querySelector("[data-continue-robot]")?.addEventListener("click", () => {
+ if (pendingRobotSerialNumber !== null) selectRobot(pendingRobotSerialNumber);
+ });
+
+ app.querySelector("[data-back-robot]")?.addEventListener("click", () => {
+ const selectedSerial = discovery?.state.selectedRobot?.serialNumber ?? null;
+ if (selectedSerial !== null) pendingRobotSerialNumber = selectedSerial;
+ selectRobot("");
+ });
+
+ app.querySelector("[data-refresh-status]")?.addEventListener("click", refreshStatus);
+ app.querySelector("[data-refresh-resources]")?.addEventListener("click", refreshRobotResources);
+
+ app.querySelector("[data-toggle-add-menu]")?.addEventListener("click", () => {
+ addActionMenuOpen = !addActionMenuOpen;
+ actionMenuId = null;
+ render();
+ });
+
+ app.querySelectorAll("[data-add-action]").forEach((button) => {
+ button.addEventListener("click", () => {
+ const type = button.dataset.addAction;
+ if (!isSequenceActionType(type) || composer === null) return;
+ try {
+ composer.addAction(type);
+ addActionMenuOpen = false;
+ statusMessage = "";
+ render();
+ } catch (error: unknown) {
+ handleComposerError(error, "添加动作");
+ }
+ });
+ });
+
+ app.querySelectorAll("[data-toggle-action-menu]").forEach((button) => {
+ button.addEventListener("click", () => {
+ const actionId = button.dataset.toggleActionMenu ?? null;
+ actionMenuId = actionMenuId === actionId ? null : actionId;
+ addActionMenuOpen = false;
+ render();
+ });
+ });
+
+ app.querySelectorAll("[data-remove-action]").forEach((button) => {
+ button.addEventListener("click", () => {
+ const actionId = button.dataset.actionId;
+ if (composer === null || actionId === undefined) return;
+ try {
+ composer.removeAction(actionId);
+ actionMenuId = null;
+ statusMessage = "";
+ render();
+ } catch (error: unknown) {
+ handleComposerError(error, "删除动作");
+ }
+ });
+ });
+
+ app.querySelectorAll("[data-move-action]").forEach((button) => {
+ button.addEventListener("click", () => {
+ const actionId = button.dataset.actionId;
+ const direction = button.dataset.moveAction;
+ if (composer === null || actionId === undefined || !isMoveDirection(direction)) return;
+ try {
+ composer.moveAction(actionId, direction);
+ actionMenuId = null;
+ statusMessage = "";
+ render();
+ } catch (error: unknown) {
+ handleComposerError(error, "调整顺序");
+ }
+ });
+ });
+
+ bindDragAndDrop();
+
+ app.querySelectorAll('[data-action-field="location"]').forEach((select) => {
+ select.addEventListener("change", () => {
+ const actionId = select.dataset.actionId;
+ if (composer === null || actionId === undefined) return;
+ try {
+ statusMessage = "";
+ composer.setMovementLocation(actionId, select.value);
+ } catch (error: unknown) {
+ handleComposerError(error, "修改位置");
+ }
+ });
+ });
+
+ app.querySelectorAll('[data-action-field="tts"]').forEach((textarea) => {
+ textarea.addEventListener("change", () => {
+ const actionId = textarea.dataset.actionId;
+ if (composer === null || actionId === undefined) return;
+ try {
+ statusMessage = "";
+ composer.setSpeakText(actionId, textarea.value);
+ } catch (error: unknown) {
+ handleComposerError(error, "修改播报");
+ }
+ });
+ });
+
+ app.querySelectorAll("[data-action-contact]").forEach((checkbox) => {
+ checkbox.addEventListener("change", () => {
+ const actionId = checkbox.dataset.actionId;
+ if (composer === null || actionId === undefined) return;
+ const contactIds = Array.from(app.querySelectorAll("[data-action-contact]"))
+ .filter((candidate) => candidate.dataset.actionId === actionId && candidate.checked)
+ .map((candidate) => candidate.value);
+ try {
+ statusMessage = "";
+ composer.setCallContacts(actionId, contactIds);
+ } catch (error: unknown) {
+ handleComposerError(error, "修改联系人");
+ }
+ });
+ });
+
+ app.querySelector("[data-validate-sequence]")?.addEventListener("click", validateSequence);
+
+ app.querySelector("[data-back-actions]")?.addEventListener("click", () => {
+ lifecycle?.cancelPreparation();
+ lifecycle?.dispose();
+ lifecycle = null;
+ editingValidatedSequence = true;
+ statusMessage = "";
+ render();
+ });
+
+ app.querySelector("[data-prepare-play]")?.addEventListener("click", preparePlay);
+
+ app.querySelector("[data-cancel-play]")?.addEventListener("click", () => {
+ lifecycle?.cancelPreparation();
+ lifecycle?.dispose();
+ lifecycle = null;
+ editingValidatedSequence = true;
+ statusMessage = "";
+ render();
+ });
+
+ app.querySelector("[data-confirm-play]")?.addEventListener("click", confirmPlay);
+ app.querySelector("[data-stop-run]")?.addEventListener("click", stopRun);
+
+ app.querySelector("[data-run-again]")?.addEventListener("click", () => {
+ lifecycle?.dispose();
+ lifecycle = null;
+ editingValidatedSequence = false;
+ statusMessage = "";
+ render();
+ });
+
+ app.querySelectorAll("[data-reset]").forEach((button) => {
+ button.addEventListener("click", () => resetPage(""));
+ });
+}
+
+function bindDragAndDrop(): void {
+ app.querySelectorAll("[data-action-card]").forEach((card) => {
+ card.addEventListener("dragstart", (event) => {
+ draggedActionId = card.dataset.actionId ?? null;
+ card.classList.add("dragging");
+ if (event.dataTransfer !== null) {
+ event.dataTransfer.effectAllowed = "move";
+ event.dataTransfer.setData("text/plain", draggedActionId ?? "");
+ }
+ });
+ card.addEventListener("dragend", () => {
+ draggedActionId = null;
+ card.classList.remove("dragging");
+ });
+ card.addEventListener("dragover", (event) => {
+ if (draggedActionId === null) return;
+ event.preventDefault();
+ if (event.dataTransfer !== null) event.dataTransfer.dropEffect = "move";
+ });
+ card.addEventListener("drop", (event) => {
+ event.preventDefault();
+ const targetId = card.dataset.actionId;
+ if (composer === null || draggedActionId === null || targetId === undefined || draggedActionId === targetId) return;
+ moveActionTo(draggedActionId, targetId);
+ draggedActionId = null;
+ });
+ });
+}
+
+function moveActionTo(actionId: string, targetId: string): void {
+ const currentComposer = composer;
+ if (currentComposer === null) return;
+ try {
+ let from = currentComposer.state.actions.findIndex((action) => action.id === actionId);
+ const to = currentComposer.state.actions.findIndex((action) => action.id === targetId);
+ if (from < 0 || to < 0) return;
+ while (from < to) {
+ currentComposer.moveAction(actionId, "down");
+ from += 1;
+ }
+ while (from > to) {
+ currentComposer.moveAction(actionId, "up");
+ from -= 1;
+ }
+ actionMenuId = null;
+ statusMessage = "";
+ render();
+ } catch (error: unknown) {
+ handleComposerError(error, "调整顺序");
+ }
+}
+
+function toggleRobotStatus(serialNumber: string): void {
+ const requestClient = client;
+ const requestDiscovery = discovery;
+ const knownRobot = requestDiscovery?.state.robots.data?.some(
+ (robot) => robot.serialNumber === serialNumber,
+ ) === true;
+ if (requestClient === null || requestDiscovery === null || !knownRobot) return;
+
+ const current = robotStatusPanels.get(serialNumber) ?? {
+ expanded: false,
+ requestState: "idle",
+ data: null,
+ receivedAt: null,
+ error: null,
+ requestId: 0,
+ } satisfies RobotStatusPanelState;
+
+ if (current.expanded) {
+ robotStatusPanels.set(serialNumber, { ...current, expanded: false });
+ render();
+ return;
+ }
+ if (current.requestState === "pending") {
+ robotStatusPanels.set(serialNumber, { ...current, expanded: true });
+ render();
+ return;
+ }
+
+ const requestId = nextRobotStatusRequestId++;
+ robotStatusPanels.set(serialNumber, {
+ ...current,
+ expanded: true,
+ requestState: "pending",
+ error: null,
+ requestId,
+ });
+ render();
+
+ void orchestrator.execute(
+ {
+ phase: runState.phase,
+ operation: "getRobotStatus",
+ requestSummary: { serialNumber },
+ },
+ (options) => requestClient.getRobotStatus(serialNumber, options),
+ ).then((status) => {
+ if (client !== requestClient || discovery !== requestDiscovery) return;
+ const latest = robotStatusPanels.get(serialNumber);
+ if (latest?.requestId !== requestId) return;
+ robotStatusPanels.set(serialNumber, {
+ ...latest,
+ requestState: "succeeded",
+ data: status,
+ receivedAt: Date.now(),
+ error: null,
+ });
+ render();
+ }).catch((error: unknown) => {
+ if (client !== requestClient || discovery !== requestDiscovery) return;
+ const latest = robotStatusPanels.get(serialNumber);
+ if (latest?.requestId !== requestId) return;
+ if (error instanceof TemiApiError && error.kind === "unauthorized") {
+ expireRobotStatusConnection(requestClient, requestDiscovery);
+ return;
+ }
+ robotStatusPanels.set(serialNumber, {
+ ...latest,
+ requestState: "failed",
+ error: robotStatusErrorText(error),
+ });
+ render();
+ });
+}
+
+async function refreshAllRobotStatuses(): Promise {
+ const requestClient = client;
+ const requestDiscovery = discovery;
+ const robots = requestDiscovery?.state.robots.data ?? [];
+ if (
+ requestClient === null ||
+ requestDiscovery === null ||
+ robots.length === 0 ||
+ robotStatusesRefreshing
+ ) {
+ return;
+ }
+
+ robotStatusesRefreshing = true;
+ statusMessage = "";
+ const phase = runState.phase;
+ const requests = robots.map((robot) => {
+ const current = robotStatusPanels.get(robot.serialNumber) ?? {
+ expanded: false,
+ requestState: "idle",
+ data: null,
+ receivedAt: null,
+ error: null,
+ requestId: 0,
+ } satisfies RobotStatusPanelState;
+ const requestId = nextRobotStatusRequestId++;
+ robotStatusPanels.set(robot.serialNumber, {
+ ...current,
+ requestState: "pending",
+ error: null,
+ requestId,
+ });
+
+ return orchestrator.execute(
+ {
+ phase,
+ operation: "getRobotStatus",
+ requestSummary: { serialNumber: robot.serialNumber },
+ },
+ (options) => requestClient.getRobotStatus(robot.serialNumber, options),
+ ).then((status) => {
+ if (client !== requestClient || discovery !== requestDiscovery) return;
+ const latest = robotStatusPanels.get(robot.serialNumber);
+ if (latest?.requestId !== requestId) return;
+ robotStatusPanels.set(robot.serialNumber, {
+ ...latest,
+ requestState: "succeeded",
+ data: status,
+ receivedAt: Date.now(),
+ error: null,
+ });
+ }).catch((error: unknown) => {
+ if (client !== requestClient || discovery !== requestDiscovery) return;
+ const latest = robotStatusPanels.get(robot.serialNumber);
+ if (latest?.requestId !== requestId) return;
+ if (error instanceof TemiApiError && error.kind === "unauthorized") {
+ expireRobotStatusConnection(requestClient, requestDiscovery);
+ return;
+ }
+ robotStatusPanels.set(robot.serialNumber, {
+ ...latest,
+ requestState: "failed",
+ error: robotStatusErrorText(error),
+ });
+ });
+ });
+
+ render();
+ await Promise.all(requests);
+ if (client !== requestClient || discovery !== requestDiscovery) return;
+
+ robotStatusesRefreshing = false;
+ robotStatusesRefreshed = true;
+ if (
+ robotStatusFilter === "online" &&
+ pendingRobotSerialNumber !== null &&
+ !isRobotOnline(pendingRobotSerialNumber)
+ ) {
+ pendingRobotSerialNumber = null;
+ }
+ const failed = robots.some((robot) => robotStatusPanels.get(robot.serialNumber)?.requestState === "failed");
+ statusMessage = failed ? "部分机器人状态读取失败。" : "";
+ statusTone = failed ? "error" : "success";
+ render();
+}
+
+function expireRobotStatusConnection(
+ requestClient: TemiApiClient,
+ requestDiscovery: RobotDiscoveryController,
+): void {
+ if (client !== requestClient || discovery !== requestDiscovery) return;
+ orchestrator.abortReads();
+ lifecycle?.dispose();
+ lifecycle = null;
+ discovery = null;
+ composer = null;
+ clearConnection();
+ runState = createRunState();
+ statusMessage = "连接已失效,请重新连接。";
+ statusTone = "error";
+ render();
+}
+
+function connectAndVerify(): void {
+ if (runState.phase !== "idle") return;
+ if (environmentTransport[environment] === "blocked") {
+ statusMessage = "当前环境无法连接,请重新开始。";
+ statusTone = "error";
+ render();
+ return;
+ }
+
+ const input = app.querySelector("[data-oat]");
+ const oat = input?.value.trim() ?? "";
+ if (oat.length === 0) {
+ statusMessage = "请输入访问令牌。";
+ statusTone = "error";
+ render();
+ return;
+ }
+
+ if (input !== null) input.value = "";
+ const selectedEnvironment = environment;
+ const requestClient = new TemiApiClient(selectedEnvironment, oat, {
+ onReadableResponse: () => markEnvironmentReadable(selectedEnvironment),
+ onTransportFailure: () => markEnvironmentBlocked(selectedEnvironment),
+ });
+ client = requestClient;
+ const requestDiscovery = new RobotDiscoveryController(requestClient, orchestrator, {
+ onChange: (state) => {
+ if (client !== requestClient) return;
+ syncComposerContext(state);
+ render();
+ },
+ });
+ discovery = requestDiscovery;
+ runState = transitionRunState(runState, "verifying");
+ statusMessage = "正在验证连接…";
+ statusTone = "neutral";
+ const connection = requestDiscovery.connect(() => {
+ if (client !== requestClient) return;
+ runState = transitionRunState(runState, "discovering");
+ statusMessage = "正在读取机器人…";
+ statusTone = "neutral";
+ });
+ render();
+
+ void connection.then((state) => {
+ if (client !== requestClient) return;
+ const robotCount = state.robots.data?.length ?? 0;
+ if (robotCount === 0) {
+ runState = transitionRunState(runState, "failed");
+ statusMessage = "没有可用的机器人。";
+ statusTone = "error";
+ } else {
+ statusMessage = "";
+ statusTone = "success";
+ }
+ render();
+ }).catch((error: unknown) => {
+ if (client !== requestClient) return;
+ if (runState.phase === "verifying" || runState.phase === "discovering") {
+ runState = transitionRunState(runState, "failed");
+ }
+ const unauthorized = error instanceof TemiApiError && error.kind === "unauthorized";
+ discovery = unauthorized ? null : requestDiscovery;
+ clearConnection();
+ statusMessage = connectionErrorText(error);
+ statusTone = "error";
+ render();
+ });
+}
+
+function selectRobot(serialNumber: string): void {
+ const requestDiscovery = discovery;
+ const requestClient = client;
+ if (requestDiscovery === null || requestClient === null) return;
+ if (isLifecycleLocked() || runState.phase === "validating") return;
+
+ if (serialNumber.length === 0) {
+ robotResourcesLoading = false;
+ requestDiscovery.clearSelection();
+ if (runState.phase === "ready") runState = transitionRunState(runState, "composing");
+ editingValidatedSequence = false;
+ addActionMenuOpen = false;
+ actionMenuId = null;
+ statusMessage = "";
+ render();
+ return;
+ }
+
+ if (runState.phase === "ready") runState = transitionRunState(runState, "composing");
+ const phase = runState.phase;
+ pendingRobotSerialNumber = serialNumber;
+ robotResourcesLoading = true;
+ editingValidatedSequence = false;
+ statusMessage = "正在读取机器人资源…";
+ statusTone = "neutral";
+ render();
+
+ void requestDiscovery.selectRobot(serialNumber, phase).then((state) => {
+ if (client !== requestClient || discovery !== requestDiscovery) return;
+ robotResourcesLoading = false;
+ if (runState.phase === "discovering") runState = transitionRunState(runState, "composing");
+ const failedResources = [state.status, state.locations, state.contacts].filter((slot) => slot.status === "failed").length;
+ statusMessage = failedResources === 0 ? "" : "部分资源读取失败,可重新读取。";
+ statusTone = failedResources === 0 ? "success" : "error";
+ render();
+ }).catch((error: unknown) => {
+ handleDiscoveryError(error, requestClient, requestDiscovery, "读取机器人");
+ });
+}
+
+function refreshRobotResources(): void {
+ const serialNumber = discovery?.state.selectedRobot?.serialNumber;
+ if (serialNumber !== undefined) selectRobot(serialNumber);
+}
+
+function refreshStatus(): void {
+ const requestDiscovery = discovery;
+ const requestClient = client;
+ if (requestDiscovery === null || requestClient === null || requestDiscovery.state.selectedRobot === null) return;
+ if (isLifecycleLocked()) return;
+ statusMessage = "正在刷新状态…";
+ statusTone = "neutral";
+ render();
+
+ void requestDiscovery.refreshStatus(runState.phase).then((state) => {
+ if (client !== requestClient || discovery !== requestDiscovery) return;
+ statusMessage = state.status.status === "succeeded" ? "" : "状态刷新失败。";
+ statusTone = state.status.status === "succeeded" ? "success" : "error";
+ render();
+ }).catch((error: unknown) => {
+ handleDiscoveryError(error, requestClient, requestDiscovery, "刷新状态");
+ });
+}
+
+function preparePlay(): void {
+ const currentComposer = composer;
+ const requestClient = client;
+ const requestDiscovery = discovery;
+ if (currentComposer === null || requestClient === null || requestDiscovery === null) return;
+ if (environmentTransport[environment] === "blocked") {
+ statusMessage = "当前环境无法连接,不能运行。";
+ statusTone = "error";
+ render();
+ return;
+ }
+ if (!(runState.phase === "ready" || runState.phase === "terminal") || isLifecycleLocked()) return;
+
+ const request = currentComposer.getValidatedRequest();
+ if (request === null) {
+ statusMessage = "请先检查动作。";
+ statusTone = "error";
+ render();
+ return;
+ }
+
+ lifecycle?.dispose();
+ let instance!: PlayPollStopLifecycle;
+ instance = new PlayPollStopLifecycle({
+ client: requestClient,
+ environment,
+ orchestrator,
+ onChange: (snapshot) => {
+ if (lifecycle !== instance) return;
+ syncRunStateFromLifecycle(snapshot);
+ render();
+ },
+ onStatus: (status) => {
+ if (lifecycle !== instance || discovery !== requestDiscovery) return;
+ requestDiscovery.recordStatusSnapshot(status);
+ },
+ onWarning: (message) => {
+ if (lifecycle !== instance) return;
+ statusMessage = message;
+ statusTone = "error";
+ render();
+ },
+ });
+ lifecycle = instance;
+ editingValidatedSequence = false;
+ statusMessage = "正在刷新机器人状态…";
+ statusTone = "neutral";
+ render();
+
+ void instance.preparePlay(request).then((snapshot) => {
+ if (lifecycle !== instance) return;
+ if (snapshot.phase === "confirmation_required") {
+ statusMessage = "";
+ statusTone = "success";
+ } else {
+ statusMessage = `机器人当前${statusLabelFor(snapshot.latestStatus?.status, "zh")},暂时无法运行。`;
+ statusTone = "error";
+ }
+ render();
+ }).catch((error: unknown) => {
+ handleLifecycleError(error, instance, requestClient, "状态检查");
+ });
+}
+
+function confirmPlay(): void {
+ const instance = lifecycle;
+ const requestClient = client;
+ if (instance === null || requestClient === null || instance.state.phase !== "confirmation_required") return;
+ if (environmentTransport[environment] === "blocked") {
+ instance.cancelPreparation();
+ instance.dispose();
+ lifecycle = null;
+ statusMessage = "当前环境无法连接,不能运行。";
+ statusTone = "error";
+ render();
+ return;
+ }
+ statusMessage = "正在启动…";
+ statusTone = "neutral";
+ render();
+
+ void instance.confirmPlay().then((snapshot) => {
+ if (lifecycle !== instance) return;
+ statusMessage = snapshot.phase === "running" ? "" : "未能启动运行。";
+ statusTone = snapshot.phase === "running" ? "success" : "error";
+ render();
+ }).catch((error: unknown) => {
+ handleLifecycleError(error, instance, requestClient, "启动运行");
+ });
+}
+
+function stopRun(): void {
+ const instance = lifecycle;
+ const requestClient = client;
+ if (instance === null || requestClient === null) return;
+ statusMessage = "正在停止…";
+ statusTone = "neutral";
+ render();
+
+ void instance.stop().then((snapshot) => {
+ if (lifecycle !== instance) return;
+ statusMessage = snapshot.phase === "stopping" ? "" : "停止请求未被确认。";
+ statusTone = snapshot.phase === "stopping" ? "success" : "error";
+ render();
+ }).catch((error: unknown) => {
+ handleLifecycleError(error, instance, requestClient, "停止运行");
+ });
+}
+
+function validateSequence(): void {
+ const currentComposer = composer;
+ const requestClient = client;
+ if (currentComposer === null || requestClient === null || isLifecycleLocked()) return;
+
+ if (currentComposer.state.validation.status === "succeeded" && (runState.phase === "ready" || runState.phase === "terminal")) {
+ editingValidatedSequence = false;
+ statusMessage = "";
+ render();
+ return;
+ }
+ if (runState.phase === "validating") return;
+ if (!(runState.phase === "composing" || runState.phase === "terminal")) return;
+
+ const inspection = currentComposer.inspect();
+ if (!inspection.valid) {
+ statusMessage = "请修正标出的内容。";
+ statusTone = "error";
+ void currentComposer.validate(requestClient, orchestrator);
+ render();
+ return;
+ }
+
+ runState = transitionRunState(runState, "validating");
+ statusMessage = "正在检查动作…";
+ statusTone = "neutral";
+ render();
+
+ void currentComposer.validate(requestClient, orchestrator).then((validation) => {
+ if (composer !== currentComposer || client !== requestClient || runState.phase !== "validating") return;
+ if (validation.status === "succeeded") {
+ runState = transitionRunState(runState, "ready");
+ editingValidatedSequence = false;
+ statusMessage = "";
+ statusTone = "success";
+ } else {
+ runState = transitionRunState(runState, "composing");
+ statusMessage = "请修正标出的内容。";
+ statusTone = "error";
+ }
+ render();
+ }, (error: unknown) => {
+ if (composer !== currentComposer || client !== requestClient) return;
+ if (runState.phase === "validating") runState = transitionRunState(runState, "composing");
+ if (error instanceof TemiApiError && error.kind === "unauthorized") {
+ requestClient.clearToken();
+ composer = null;
+ discovery = null;
+ clearConnection();
+ runState = createRunState();
+ statusMessage = "连接已失效,请重新连接。";
+ } else {
+ statusMessage = "动作检查失败,请修正后重试。";
+ }
+ statusTone = "error";
+ render();
+ });
+}
+
+function syncComposerContext(state: DiscoveryState): void {
+ const selectedRobot = state.selectedRobot;
+ if (selectedRobot === null) {
+ composer = null;
+ return;
+ }
+
+ if (composer === null) {
+ let instance!: SequenceComposer;
+ instance = new SequenceComposer({
+ onChange: (composerState) => {
+ if (composer !== instance) return;
+ if (["ready", "terminal"].includes(runState.phase) && composerState.validation.status !== "succeeded") {
+ runState = transitionRunState(runState, "composing");
+ }
+ render();
+ },
+ });
+ composer = instance;
+ }
+
+ const context: ComposerContext = {
+ environment,
+ serialNumber: selectedRobot.serialNumber,
+ locations: state.locations.status === "succeeded" ? locationOptions(state) : null,
+ contacts: state.contacts.status === "succeeded" ? contactOptions(state) : null,
+ };
+ try {
+ composer.setContext(context);
+ } catch (error: unknown) {
+ if (!(error instanceof SequenceComposerError && error.kind === "validation_pending")) throw error;
+ }
+}
+
+function syncRunStateFromLifecycle(snapshot: LifecycleSnapshot): void {
+ const identity = snapshot.identity;
+ try {
+ if (snapshot.phase === "starting" && ["ready", "terminal"].includes(runState.phase) && identity !== null) {
+ runState = beginRun(runState, identity.serialNumber);
+ } else if (snapshot.phase === "running" && identity !== null && identity.sequenceId !== null) {
+ if (runState.phase === "starting") runState = acceptRun(runState, identity.sequenceId);
+ else if (runState.phase === "unknown") runState = recoverUnknown(runState);
+ else if (runState.phase === "stopping") runState = transitionRunState(runState, "running");
+ } else if (snapshot.phase === "stopping" && ["running", "unknown"].includes(runState.phase)) {
+ runState = requestStop(runState);
+ } else if (snapshot.phase === "terminal" && ["running", "stopping", "unknown"].includes(runState.phase)) {
+ runState = transitionRunState(runState, "terminal");
+ } else if (snapshot.phase === "unknown" && ["starting", "running", "stopping"].includes(runState.phase)) {
+ runState = transitionRunState(runState, "unknown");
+ } else if (snapshot.phase === "failed" && runState.phase === "starting") {
+ runState = transitionRunState(runState, "failed");
+ }
+ } catch (error: unknown) {
+ statusMessage = error instanceof Error ? error.message : "运行状态异常。";
+ statusTone = "error";
+ }
+}
+
+function handleLifecycleError(error: unknown, instance: PlayPollStopLifecycle, requestClient: TemiApiClient, operation: string): void {
+ if (lifecycle !== instance) return;
+ const snapshot = instance.state;
+ if (error instanceof TemiApiError && error.kind === "unauthorized") {
+ requestClient.clearToken();
+ statusMessage = snapshot.warning ?? "连接已失效,访问令牌已清除。";
+ if (snapshot.phase === "failed") {
+ composer = null;
+ discovery = null;
+ clearConnection();
+ if (runState.phase === "ready") runState = transitionRunState(runState, "failed");
+ }
+ } else if (snapshot.warning !== null) {
+ statusMessage = snapshot.warning;
+ } else {
+ statusMessage = `${operation}失败;不会自动重试运行或停止请求。`;
+ }
+ statusTone = "error";
+ render();
+}
+
+function handleComposerError(error: unknown, operation: string): void {
+ statusMessage = error instanceof SequenceComposerError && error.kind === "resource_unavailable"
+ ? "所需资源尚不可用。"
+ : `${operation}失败。`;
+ statusTone = "error";
+ render();
+}
+
+function handleDiscoveryError(
+ error: unknown,
+ requestClient: TemiApiClient,
+ requestDiscovery: RobotDiscoveryController,
+ operation: string,
+): void {
+ if (client !== requestClient || discovery !== requestDiscovery) return;
+ if (error instanceof RobotSelectionError) {
+ robotResourcesLoading = false;
+ statusMessage = "请选择列表中的机器人。";
+ statusTone = "error";
+ render();
+ return;
+ }
+ if (error instanceof TemiApiError && error.kind === "unauthorized") {
+ robotResourcesLoading = false;
+ requestClient.clearToken();
+ discovery = null;
+ clearConnection();
+ runState = createRunState();
+ statusMessage = "连接已失效,请重新连接。";
+ } else {
+ robotResourcesLoading = false;
+ statusMessage = `${operation}失败:${failureText(discoveryFailure(error))}`;
+ }
+ statusTone = "error";
+ render();
+}
+
+function resetPage(message: string): void {
+ orchestrator.abortReads();
+ lifecycle?.dispose();
+ lifecycle = null;
+ discovery = null;
+ composer = null;
+ clearConnection();
+ runState = createRunState();
+ environmentTransport = { production: "unknown", integration: "unknown" };
+ pendingRobotSerialNumber = null;
+ robotResourcesLoading = false;
+ editingValidatedSequence = false;
+ addActionMenuOpen = false;
+ actionMenuId = null;
+ draggedActionId = null;
+ timeline.clear();
+ statusMessage = message;
+ statusTone = "neutral";
+ render();
+}
+
+function clearConnection(): void {
+ const input = app.querySelector("[data-oat]");
+ if (input !== null) input.value = "";
+ client?.clearToken();
+ client = null;
+ robotStatusPanels.clear();
+ robotStatusFilter = "all";
+ robotStatusesRefreshed = false;
+ robotStatusesRefreshing = false;
+ pendingRobotSerialNumber = null;
+}
+
+function isLifecycleLocked(): boolean {
+ const phase = lifecycle?.state.phase;
+ return hasActiveRun(runState) || (
+ phase !== undefined && ["preflighting", "confirmation_required", "starting", "running", "stopping", "unknown"].includes(phase)
+ );
+}
+
+function locationOptions(state: DiscoveryState): readonly ComposerOption[] {
+ return (state.locations.data?.locations ?? []).flatMap((location) => {
+ if (!isNonEmptyText(location.name)) return [];
+ return [{ value: location.name, label: location.name }];
+ });
+}
+
+function contactOptions(state: DiscoveryState): readonly ComposerOption[] {
+ return (state.contacts.data?.contacts ?? []).flatMap((contact) => {
+ if (!isNonEmptyText(contact.temiId)) return [];
+ return [{ value: contact.temiId, label: isNonEmptyText(contact.name) ? contact.name : copy("未命名联系人", "Unnamed contact") }];
+ });
+}
+
+function actionValidationIssue(state: SequenceComposerState, actionId: string): string | null {
+ const issue = state.validation.issues.find((candidate) => candidate.actionId === actionId);
+ return issue === undefined ? null : validationIssueText(issue.field);
+}
+
+function validationIssueText(field: "sequence" | "location" | "tts" | "contactIds" | "step"): string {
+ if (field === "location") return copy("请选择位置。", "Select a location.");
+ if (field === "tts") return copy("请输入播报内容。", "Enter text for the robot to speak.");
+ if (field === "contactIds") return copy("请选择至少一位联系人。", "Select at least one contact.");
+ if (field === "step") return copy("动作顺序无效。", "The action order is invalid.");
+ return copy("至少添加一个动作。", "Add at least one action.");
+}
+
+function validationErrorText(error: NonNullable): string {
+ if (error.kind === "unauthorized") return copy("连接已失效,请重新连接。", "The connection expired. Reconnect and try again.");
+ if (error.kind === "network") return copy("网络请求失败,请稍后重试。", "The network request failed. Try again shortly.");
+ if (error.kind === "timeout") return copy("检查超时,请稍后重试。", "The check timed out. Try again shortly.");
+ if (error.kind === "http") return copy("动作未通过服务端检查,请修正后重试。", "The server rejected the actions. Fix them and try again.");
+ return copy("动作检查失败,请重试。", "The action check failed. Try again.");
+}
+
+function actionAvailabilityText(state: DiscoveryState, type: SequenceActionDraft["type"]): string {
+ const availability = actionAvailability(state, type);
+ if (availability.enabled) return copy("可添加", "Available");
+ if (type === "MOVEMENT") return copy("位置尚不可用", "Locations are not available yet");
+ if (type === "START_CALL") return copy("联系人尚不可用", "Contacts are not available yet");
+ return copy("暂不可用", "Not available yet");
+}
+
+function actionLabel(type: SequenceActionDraft["type"]): string {
+ if (type === "MOVEMENT") return copy("前往位置", "Go to location");
+ if (type === "SPEAK") return copy("播报文字", "Speak text");
+ return copy("发起呼叫", "Start call");
+}
+
+function actionSummary(action: SequenceActionDraft, context: ComposerContext | null): string {
+ if (action.type === "MOVEMENT") return action.location || copy("未选择位置", "No location selected");
+ if (action.type === "SPEAK") return action.tts || copy("未填写内容", "No text entered");
+ if (action.contactIds.length === 0) return copy("未选择联系人", "No contacts selected");
+ return action.contactIds.map((id) => context?.contacts?.find((contact) => contact.value === id)?.label ?? id).join(copy("、", ", "));
+}
+
+function runningTitle(snapshot: LifecycleSnapshot): string {
+ if (snapshot.phase === "starting") return copy("正在启动", "Starting");
+ if (snapshot.phase === "running") return copy("正在运行", "Running");
+ if (snapshot.phase === "stopping") return copy("正在停止", "Stopping");
+ if (snapshot.phase === "unknown") return copy("结果未知", "Result unknown");
+ if (snapshot.phase === "failed") return copy("启动失败", "Start failed");
+ if (snapshot.observation === "completed") return copy("已完成", "Completed");
+ if (snapshot.observation === "stopped") return copy("已停止", "Stopped");
+ if (snapshot.observation === "aborted") return copy("已中止", "Aborted");
+ if (snapshot.observation === "identity_mismatch") return copy("运行已变化", "Run changed");
+ return copy("已结束", "Ended");
+}
+
+function runningSubtitle(snapshot: LifecycleSnapshot): string {
+ if (snapshot.phase === "starting") return copy("正在等待服务接受运行。", "Waiting for the service to accept the run.");
+ if (snapshot.phase === "running") return copy("正在等待机器人更新状态。", "Waiting for the robot to update its status.");
+ if (snapshot.phase === "stopping") return copy("停止请求已发送,正在等待终态。", "The stop request was sent. Waiting for a final state.");
+ if (snapshot.phase === "unknown") return copy("只会继续读取状态,不会自动重发运行或停止请求。", "Only status reads will continue. Run and stop requests will not be retried automatically.");
+ if (snapshot.phase === "failed") return copy("运行请求未成功。", "The run request did not succeed.");
+ if (snapshot.observation === "identity_mismatch") return copy("机器人返回了另一个运行标识,当前结果不能归因于本次运行。", "The robot returned a different run ID, so this result cannot be attributed to the current run.");
+ return copy("本次运行已经结束。", "This run has ended.");
+}
+
+function lifecyclePhaseLabel(phase: LifecycleSnapshot["phase"]): string {
+ if (phase === "starting") return copy("启动中", "Starting");
+ if (phase === "running") return copy("运行中", "Running");
+ if (phase === "stopping") return copy("停止中", "Stopping");
+ if (phase === "terminal") return copy("已结束", "Ended");
+ if (phase === "failed") return copy("失败", "Failed");
+ if (phase === "unknown") return copy("未知", "Unknown");
+ return copy("准备中", "Preparing");
+}
+
+function lifecycleErrorText(snapshot: LifecycleSnapshot): string {
+ const error = snapshot.error;
+ if (error === null) return copy("运行状态未知。", "The run status is unknown.");
+ if (error.kind === "unauthorized") return copy("连接已失效,访问令牌已清除。", "The connection expired and the access token was cleared.");
+ if (error.kind === "network") return copy("网络请求失败。", "The network request failed.");
+ if (error.kind === "timeout") return copy("请求超时。", "The request timed out.");
+ if (error.kind === "http") return copy(`请求失败(HTTP ${error.status ?? "unknown"})。`, `Request failed (HTTP ${error.status ?? "unknown"}).`);
+ return copy("请求结果未知。", "The request result is unknown.");
+}
+
+function connectionErrorText(error: unknown): string {
+ if (error instanceof TemiApiError && error.kind === "unauthorized") return "访问令牌无效或已过期。";
+ if (error instanceof TemiApiError && error.kind === "timeout") return "连接超时,请检查网络后重试。";
+ if (error instanceof TemiApiError && error.kind === "http") return `连接被拒绝(HTTP ${error.status ?? "unknown"})。`;
+ return "连接失败,请检查网络或浏览器访问限制。";
+}
+
+function robotStatusErrorText(error: unknown): string {
+ if (error instanceof TemiApiError && error.kind === "timeout") return "状态读取超时。";
+ if (error instanceof TemiApiError && error.kind === "network") return "状态读取失败,请检查网络。";
+ if (error instanceof TemiApiError && error.kind === "http" && error.status === 403) return "无权读取该机器人状态。";
+ if (error instanceof TemiApiError && error.kind === "http") return `状态读取失败(HTTP ${error.status ?? "unknown"})。`;
+ return "状态读取失败。";
+}
+
+function robotBatteryText(status: RobotStatus): string {
+ const values: string[] = [];
+ if (typeof status.battery?.level === "number" && Number.isFinite(status.battery.level)) {
+ values.push(`${status.battery.level}%`);
+ }
+ if (typeof status.battery?.isCharging === "boolean") {
+ values.push(status.battery.isCharging ? copy("充电中", "Charging") : copy("未充电", "Not charging"));
+ }
+ return values.length === 0 ? copy("未知", "Unknown") : values.join(" · ");
+}
+
+function robotMovementText(status: RobotStatus): string {
+ return compactRobotStatusValue([
+ status.movement?.type,
+ status.movement?.status,
+ status.movement?.location,
+ ]);
+}
+
+function robotSequenceText(status: RobotStatus): string {
+ const step = status.sequence?.step;
+ const total = status.sequence?.total;
+ const progress = typeof step === "number" && typeof total === "number" ? `${step}/${total}` : undefined;
+ return compactRobotStatusValue([
+ status.sequence?.status,
+ status.sequence?.name,
+ progress,
+ ]);
+}
+
+function robotCallText(status: RobotStatus): string {
+ return compactRobotStatusValue([status.call?.status]);
+}
+
+function compactRobotStatusValue(values: Array): string {
+ const known = values.filter((value): value is string => typeof value === "string" && value.trim().length > 0);
+ return known.length === 0 ? copy("未知", "Unknown") : known.join(" · ");
+}
+
+function failureText(failure: DiscoveryFailure | null): string {
+ if (failure === null) return "请求失败。";
+ if (failure.kind === "unauthorized") return "连接已失效。";
+ if (failure.kind === "network") return "网络不可用。";
+ if (failure.kind === "timeout") return "请求超时。";
+ if (failure.kind === "http") return `HTTP ${failure.status ?? "unknown"}。`;
+ return "请求失败。";
+}
+
+function statusLabel(status: RobotStatus["status"] | undefined): string {
+ return statusLabelFor(status, language);
+}
+
+function statusLabelFor(status: RobotStatus["status"] | undefined, targetLanguage: Language): string {
+ if (status === "online") return targetLanguage === "zh" ? "在线" : "Online";
+ if (status === "busy") return targetLanguage === "zh" ? "忙碌" : "Busy";
+ if (status === "offline") return targetLanguage === "zh" ? "离线" : "Offline";
+ if (status === "privacy") return targetLanguage === "zh" ? "隐私模式" : "Privacy mode";
+ return targetLanguage === "zh" ? "状态未知" : "Status unknown";
+}
+
+function environmentLabel(value: Environment): string {
+ return value === "production" ? "Production" : "Integration";
+}
+
+function displayName(value: string | undefined, fallback: string): string {
+ return escapeHtml(isNonEmptyText(value) ? value : fallback);
+}
+
+function formatClock(value: number): string {
+ return new Intl.DateTimeFormat(language === "zh" ? "zh-CN" : "en-GB", {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ }).format(value);
+}
+
+function markEnvironmentReadable(value: Environment): void {
+ if (environmentTransport[value] === "unknown") environmentTransport[value] = "readable";
+}
+
+function markEnvironmentBlocked(value: Environment): void {
+ environmentTransport[value] = "blocked";
+}
+
+function isNonEmptyText(value: string | undefined): value is string {
+ return typeof value === "string" && value.trim().length > 0;
+}
+
+function isSequenceActionType(value: string | undefined): value is SequenceActionDraft["type"] {
+ return value === "MOVEMENT" || value === "SPEAK" || value === "START_CALL";
+}
+
+function isMoveDirection(value: string | undefined): value is "up" | "down" {
+ return value === "up" || value === "down";
+}
+
+function isLanguage(value: string | undefined): value is Language {
+ return value === "zh" || value === "en";
+}
+
+function localizeRuntimeText(value: string): string {
+ if (language === "zh") return value;
+ const exact = runtimeEnglish[value];
+ if (exact !== undefined) return exact;
+
+ const robotUnavailable = /^机器人当前(.+),暂时无法运行。$/.exec(value);
+ if (robotUnavailable !== null) {
+ return `The robot is currently ${runtimeStatusEnglish(robotUnavailable[1])} and cannot run yet.`;
+ }
+
+ const noRetryFailure = /^(.+)失败;不会自动重试运行或停止请求。$/.exec(value);
+ if (noRetryFailure !== null) {
+ return `${runtimeOperationEnglish(noRetryFailure[1])} failed. Run and stop requests will not be retried automatically.`;
+ }
+
+ const detailedFailure = /^(.+)失败:(.+)$/.exec(value);
+ if (detailedFailure !== null) {
+ return `${runtimeOperationEnglish(detailedFailure[1])} failed: ${localizeRuntimeText(detailedFailure[2])}`;
+ }
+
+ const simpleFailure = /^(.+)失败。$/.exec(value);
+ if (simpleFailure !== null) return `${runtimeOperationEnglish(simpleFailure[1])} failed.`;
+
+ const httpFailure = /^连接被拒绝(HTTP (.+))。$/.exec(value);
+ if (httpFailure !== null) return `Connection was rejected (HTTP ${httpFailure[1]}).`;
+ const statusHttpFailure = /^状态读取失败(HTTP (.+))。$/.exec(value);
+ if (statusHttpFailure !== null) return `Could not read status (HTTP ${statusHttpFailure[1]}).`;
+ const bareHttpFailure = /^HTTP (.+)。$/.exec(value);
+ if (bareHttpFailure !== null) return `HTTP ${bareHttpFailure[1]}.`;
+
+ return value;
+}
+
+function runtimeOperationEnglish(operation: string): string {
+ const operations: Readonly> = {
+ "添加动作": "Add action",
+ "删除动作": "Delete action",
+ "调整顺序": "Reorder actions",
+ "修改位置": "Update location",
+ "修改播报": "Update speech",
+ "修改联系人": "Update contacts",
+ "读取机器人": "Load robot",
+ "刷新状态": "Refresh status",
+ "状态检查": "Status check",
+ "启动运行": "Start run",
+ "停止运行": "Stop run",
+ };
+ return operations[operation] ?? operation;
+}
+
+function runtimeStatusEnglish(status: string): string {
+ const statuses: Readonly> = {
+ "在线": "online",
+ "忙碌": "busy",
+ "离线": "offline",
+ "隐私模式": "in privacy mode",
+ "状态未知": "in an unknown state",
+ };
+ return statuses[status] ?? status;
+}
+
+function copy(chinese: string, english: string): string {
+ return language === "zh" ? chinese : english;
+}
+
+function escapeHtml(value: string): string {
+ return value
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+window.addEventListener("pagehide", () => {
+ orchestrator.abortReads();
+ lifecycle?.dispose();
+ clearConnection();
+});
+
+window.addEventListener("beforeunload", (event) => {
+ if (!isLifecycleLocked()) return;
+ event.preventDefault();
+ event.returnValue = copy(
+ "机器人可能继续执行,且本页关闭后无法恢复当前运行。",
+ "The robot may continue running, and this run cannot be recovered after closing the page.",
+ );
+});
+
+document.addEventListener("visibilitychange", () => {
+ if (lifecycle !== null && document.visibilityState === "visible") render();
+});
+
+render();
diff --git a/demos/temi-openapi-scenario-runner/src/orchestrator.ts b/demos/temi-openapi-scenario-runner/src/orchestrator.ts
new file mode 100644
index 0000000..bd9a7f3
--- /dev/null
+++ b/demos/temi-openapi-scenario-runner/src/orchestrator.ts
@@ -0,0 +1,235 @@
+import {
+ TemiApiError,
+ type TemiApiRequestOptions,
+} from "./api";
+import {
+ API_OPERATION_CONTRACTS,
+ TimelineStore,
+ type ApiOperation,
+ type TimelineOutcome,
+} from "./timeline";
+import type { RunState } from "./run-state";
+
+export type RequestOrchestratorOptions = Readonly<{
+ timeline?: TimelineStore;
+ now?: () => number;
+ timeoutMs?: number;
+}>;
+
+export type OrchestratedRequest = Readonly<{
+ phase: RunState;
+ operation: ApiOperation;
+ requestSummary?: unknown;
+ timeoutMs?: number;
+}>;
+
+export type ApiRequestInvoker = (options: TemiApiRequestOptions) => Promise;
+
+export type RequestCompletion = Readonly<{
+ eventId: number;
+ outcome: Exclude;
+ httpStatus: number | null;
+ response?: T;
+ error?: unknown;
+}>;
+
+export type RequestHooks = Readonly<{
+ validateResponse?: (response: T) => void;
+ onSettled?: (completion: RequestCompletion) => void;
+}>;
+
+type ActiveRequest = {
+ id: number;
+ phase: RunState;
+ operation: ApiOperation;
+ controller: AbortController;
+};
+
+export class RequestOrchestrator {
+ readonly timeline: TimelineStore;
+ #timeoutMs: number;
+ #active = new Map();
+
+ constructor(options: RequestOrchestratorOptions = {}) {
+ const now = options.now ?? (() => Date.now());
+ this.#timeoutMs = options.timeoutMs ?? 10_000;
+ this.timeline = options.timeline ?? new TimelineStore(now);
+ }
+
+ execute(
+ request: OrchestratedRequest,
+ invoke: ApiRequestInvoker,
+ hooks: RequestHooks = {},
+ ): Promise {
+ const id = this.timeline.enqueue(request);
+ const controller = new AbortController();
+ const active: ActiveRequest = {
+ id,
+ phase: request.phase,
+ operation: request.operation,
+ controller,
+ };
+ this.#active.set(id, active);
+ this.timeline.start(id);
+
+ let httpStatus: number | null = null;
+ let timeoutTriggered = false;
+ const timeoutMs = request.timeoutMs ?? this.#timeoutMs;
+ const timeout =
+ Number.isFinite(timeoutMs) && timeoutMs > 0
+ ? globalThis.setTimeout(() => {
+ timeoutTriggered = true;
+ controller.abort("timeout");
+ }, timeoutMs)
+ : null;
+
+ const options: TemiApiRequestOptions = {
+ signal: controller.signal,
+ onResponseStatus: (status) => {
+ httpStatus = status;
+ },
+ };
+
+ let requestPromise: Promise;
+ try {
+ requestPromise = invoke(options);
+ } catch (error: unknown) {
+ requestPromise = Promise.reject(error);
+ }
+
+ return requestPromise.then(
+ (response) => {
+ const forcedError = forcedAbortError(controller.signal, timeoutTriggered);
+ if (forcedError !== null) {
+ return this.finishFailure(active, timeout, httpStatus, forcedError, hooks);
+ }
+ try {
+ hooks.validateResponse?.(response);
+ } catch (error: unknown) {
+ return this.finishFailure(active, timeout, httpStatus, error, hooks);
+ }
+ this.finishSuccess(active, timeout, httpStatus, response, hooks);
+ return response;
+ },
+ (error: unknown) => this.finishFailure(active, timeout, httpStatus, error, hooks),
+ );
+ }
+
+ abortReads(): void {
+ for (const active of this.#active.values()) {
+ if (API_OPERATION_CONTRACTS[active.operation].sideEffect === "read") {
+ active.controller.abort("reset");
+ }
+ }
+ }
+
+ abort(id: number): void {
+ this.#active.get(id)?.controller.abort("abort");
+ }
+
+ private finishSuccess(
+ active: ActiveRequest,
+ timeout: ReturnType | null,
+ httpStatus: number | null,
+ response: T,
+ hooks: RequestHooks,
+ ): void {
+ this.cleanup(active.id, timeout);
+ if (this.timeline.has(active.id)) {
+ this.timeline.complete(active.id, {
+ outcome: "succeeded",
+ httpStatus,
+ response,
+ });
+ }
+ notifySettled(hooks.onSettled, {
+ eventId: active.id,
+ outcome: "succeeded",
+ httpStatus,
+ response,
+ });
+ }
+
+ private finishFailure(
+ active: ActiveRequest,
+ timeout: ReturnType | null,
+ httpStatus: number | null,
+ error: unknown,
+ hooks: RequestHooks,
+ ): never {
+ this.cleanup(active.id, timeout);
+ const requestError = error instanceof Error ? error : new Error("Request failed.");
+ const outcome = outcomeFor(active.operation, active.phase, requestError);
+ if (this.timeline.has(active.id)) {
+ this.timeline.complete(active.id, {
+ outcome,
+ httpStatus: httpStatus ?? errorStatus(requestError),
+ error: requestError,
+ });
+ }
+ notifySettled(hooks.onSettled, {
+ eventId: active.id,
+ outcome,
+ httpStatus: httpStatus ?? errorStatus(requestError),
+ error,
+ });
+ throw error;
+ }
+
+ private cleanup(id: number, timeout: ReturnType | null): void {
+ if (timeout !== null) {
+ globalThis.clearTimeout(timeout);
+ }
+ this.#active.delete(id);
+ }
+}
+
+function notifySettled(
+ hook: ((completion: RequestCompletion) => void) | undefined,
+ completion: RequestCompletion,
+): void {
+ try {
+ hook?.(completion);
+ } catch {
+ // Timeline annotations must not change the request's result.
+ }
+}
+
+function forcedAbortError(signal: AbortSignal, timeoutTriggered: boolean): TemiApiError | null {
+ if (!signal.aborted) return null;
+ return new TemiApiError(timeoutTriggered ? "timeout" : "aborted", null);
+}
+
+function outcomeFor(
+ operation: ApiOperation,
+ phase: RunState,
+ error: Error,
+): Exclude {
+ const kind = error instanceof TemiApiError ? error.kind : "unknown";
+ if (
+ kind === "http" ||
+ kind === "unauthorized" ||
+ (error instanceof TemiApiError && error.status !== null && error.status >= 400)
+ ) {
+ return "failed";
+ }
+ if (kind === "aborted") {
+ return API_OPERATION_CONTRACTS[operation].sideEffect === "write" ? "unknown" : "cancelled";
+ }
+ if (
+ kind === "network" ||
+ kind === "timeout" ||
+ kind === "invalid_response" ||
+ kind === "unknown"
+ ) {
+ return API_OPERATION_CONTRACTS[operation].sideEffect === "write" || phase === "running"
+ ? "unknown"
+ : "failed";
+ }
+ return "failed";
+}
+
+function errorStatus(error: Error): number | null {
+ if (error instanceof TemiApiError) return error.status;
+ return null;
+}
diff --git a/demos/temi-openapi-scenario-runner/src/run-state.ts b/demos/temi-openapi-scenario-runner/src/run-state.ts
new file mode 100644
index 0000000..68e429b
--- /dev/null
+++ b/demos/temi-openapi-scenario-runner/src/run-state.ts
@@ -0,0 +1,169 @@
+export const RUN_STATES = [
+ "idle",
+ "verifying",
+ "discovering",
+ "composing",
+ "validating",
+ "ready",
+ "starting",
+ "running",
+ "stopping",
+ "terminal",
+ "failed",
+ "unknown",
+] as const;
+
+export type RunState = (typeof RUN_STATES)[number];
+
+export type RunIdentity = Readonly<{
+ serialNumber: string;
+ sequenceId: string | null;
+}>;
+
+export type RunStateSnapshot = Readonly<{
+ phase: RunState;
+ activeRun: RunIdentity | null;
+}>;
+
+type TransitionOptions = {
+ activeRun?: RunIdentity | null;
+};
+
+const ALLOWED_TRANSITIONS: Readonly> = Object.freeze({
+ idle: ["verifying"],
+ verifying: ["discovering", "failed", "unknown"],
+ discovering: ["composing", "failed", "unknown"],
+ composing: ["validating", "failed", "unknown"],
+ validating: ["ready", "composing", "failed", "unknown"],
+ ready: ["starting", "composing", "failed", "unknown"],
+ starting: ["running", "failed", "unknown"],
+ running: ["stopping", "terminal", "unknown", "failed"],
+ stopping: ["running", "terminal", "failed", "unknown"],
+ terminal: ["composing", "validating", "starting"],
+ failed: [],
+ unknown: ["running", "stopping", "failed"],
+});
+
+export function createRunState(): RunStateSnapshot {
+ return freezeState({ phase: "idle", activeRun: null });
+}
+
+export function canTransitionRunState(from: RunState, to: RunState): boolean {
+ return ALLOWED_TRANSITIONS[from].includes(to);
+}
+
+export function transitionRunState(
+ current: RunStateSnapshot,
+ next: RunState,
+ options: TransitionOptions = {},
+): RunStateSnapshot {
+ if (!canTransitionRunState(current.phase, next)) {
+ throw new Error(`Illegal run state transition: ${current.phase} -> ${next}.`);
+ }
+
+ const requestedRun = Object.prototype.hasOwnProperty.call(options, "activeRun")
+ ? options.activeRun ?? null
+ : current.activeRun;
+
+ if (next === "starting" && !isStartingIdentity(requestedRun)) {
+ throw new Error("Starting a run requires a serial number.");
+ }
+
+ if (next === "running" && !isAcceptedIdentity(requestedRun)) {
+ throw new Error("Running a run requires a sequence identity.");
+ }
+
+ if (next === "stopping" && !isAcceptedIdentity(requestedRun)) {
+ throw new Error("Stopping a run requires a sequence identity.");
+ }
+
+ return freezeState({
+ phase: next,
+ activeRun: activeRunForState(next, requestedRun),
+ });
+}
+
+export function beginRun(current: RunStateSnapshot, serialNumber: string): RunStateSnapshot {
+ if (serialNumber.trim().length === 0) {
+ throw new Error("Starting a run requires a serial number.");
+ }
+ return transitionRunState(current, "starting", {
+ activeRun: { serialNumber, sequenceId: null },
+ });
+}
+
+export function acceptRun(current: RunStateSnapshot, sequenceId: string): RunStateSnapshot {
+ if (sequenceId.trim().length === 0) {
+ throw new Error("Accepting a run requires a sequence identity.");
+ }
+ if (current.activeRun === null) {
+ throw new Error("Accepting a run requires an active starting run.");
+ }
+ return transitionRunState(current, "running", {
+ activeRun: { ...current.activeRun, sequenceId },
+ });
+}
+
+export function requestStop(current: RunStateSnapshot): RunStateSnapshot {
+ return transitionRunState(current, "stopping");
+}
+
+export function recoverUnknown(current: RunStateSnapshot): RunStateSnapshot {
+ if (current.phase !== "unknown" || !isAcceptedIdentity(current.activeRun)) {
+ throw new Error("Only an unknown active run can resume observation.");
+ }
+ return transitionRunState(current, "running");
+}
+
+export function resetRunState(): RunStateSnapshot {
+ return createRunState();
+}
+
+export function hasActiveRun(state: RunStateSnapshot): boolean {
+ return (
+ (state.phase === "starting" ||
+ state.phase === "running" ||
+ state.phase === "stopping" ||
+ state.phase === "unknown") &&
+ state.activeRun !== null
+ );
+}
+
+export function canStartPlay(state: RunStateSnapshot): boolean {
+ return (state.phase === "ready" || state.phase === "terminal") && !hasActiveRun(state);
+}
+
+export function canStopRun(state: RunStateSnapshot): boolean {
+ return (
+ (state.phase === "running" || state.phase === "unknown") &&
+ isAcceptedIdentity(state.activeRun)
+ );
+}
+
+function activeRunForState(next: RunState, run: RunIdentity | null): RunIdentity | null {
+ if (
+ next === "idle" ||
+ next === "composing" ||
+ next === "ready" ||
+ next === "terminal" ||
+ next === "failed"
+ ) {
+ return null;
+ }
+ return run;
+}
+
+function isStartingIdentity(run: RunIdentity | null): run is RunIdentity {
+ return run !== null && run.serialNumber.trim().length > 0 && run.sequenceId === null;
+}
+
+function isAcceptedIdentity(run: RunIdentity | null): run is RunIdentity {
+ return run !== null && run.serialNumber.trim().length > 0 && run.sequenceId !== null && run.sequenceId.trim().length > 0;
+}
+
+function freezeState(state: { phase: RunState; activeRun: RunIdentity | null }): RunStateSnapshot {
+ if (state.activeRun !== null) {
+ Object.freeze(state.activeRun);
+ }
+ return Object.freeze(state);
+}
diff --git a/demos/temi-openapi-scenario-runner/src/styles.css b/demos/temi-openapi-scenario-runner/src/styles.css
new file mode 100644
index 0000000..51ad469
--- /dev/null
+++ b/demos/temi-openapi-scenario-runner/src/styles.css
@@ -0,0 +1,1582 @@
+:root {
+ color: #111513;
+ background: #f5f7f5;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ --brand: #21cd99;
+ --brand-dark: #087255;
+ --ink: #111513;
+ --muted: #69736e;
+ --faint: #919a95;
+ --canvas: #f5f7f5;
+ --surface: #ffffff;
+ --surface-soft: #f9faf9;
+ --line: #e1e6e3;
+ --line-strong: #cbd3ce;
+ --danger: #bd3f3a;
+ --danger-soft: #fff3f2;
+ --warning: #966514;
+ --warning-soft: #fff9e8;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ min-width: 320px;
+ min-height: 100%;
+}
+
+body {
+ min-width: 320px;
+ min-height: 100vh;
+ margin: 0;
+ background: var(--canvas);
+}
+
+button,
+input,
+select,
+textarea {
+ font: inherit;
+}
+
+button,
+a,
+select,
+input,
+textarea {
+ -webkit-tap-highlight-color: transparent;
+}
+
+button:focus-visible,
+a:focus-visible,
+select:focus-visible,
+input:focus-visible,
+textarea:focus-visible {
+ outline: 3px solid rgb(33 205 153 / 28%);
+ outline-offset: 3px;
+}
+
+button {
+ color: inherit;
+}
+
+.app-shell {
+ min-height: 100vh;
+}
+
+.app-header {
+ position: sticky;
+ z-index: 30;
+ top: 0;
+ border-bottom: 1px solid var(--line);
+ background: rgb(255 255 255 / 94%);
+ backdrop-filter: blur(12px);
+}
+
+.header-inner {
+ display: flex;
+ width: min(100% - 40px, 1120px);
+ min-height: 68px;
+ margin: 0 auto;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+}
+
+.brand-lockup {
+ display: inline-flex;
+ min-width: 0;
+ align-items: center;
+ gap: 14px;
+ color: var(--ink);
+}
+
+.brand-lockup img {
+ display: block;
+ width: 148px;
+ height: auto;
+}
+
+.header-actions {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.language-picker {
+ position: relative;
+}
+
+.language-button {
+ display: inline-flex;
+ min-width: 76px;
+ border: 0;
+ border-radius: 8px;
+ padding: 8px 10px;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ color: #4f5954;
+ background: transparent;
+ font-size: 0.82rem;
+ font-weight: 680;
+ cursor: pointer;
+}
+
+.language-button:hover,
+.language-button[aria-expanded="true"] {
+ color: var(--ink);
+ background: var(--surface-soft);
+}
+
+.language-chevron {
+ display: inline-block;
+ color: var(--faint);
+ font-size: 0.9rem;
+ line-height: 1;
+ transition: transform 140ms ease;
+}
+
+.language-chevron.open {
+ transform: rotate(180deg);
+}
+
+.language-menu {
+ position: absolute;
+ z-index: 40;
+ top: calc(100% + 8px);
+ right: 0;
+ width: 144px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 5px;
+ background: var(--surface);
+ box-shadow: 0 12px 32px rgb(17 21 19 / 12%);
+}
+
+.language-option {
+ display: flex;
+ width: 100%;
+ border: 0;
+ border-radius: 7px;
+ padding: 9px 10px;
+ align-items: center;
+ justify-content: space-between;
+ color: #4f5954;
+ background: transparent;
+ font-size: 0.82rem;
+ text-align: left;
+ cursor: pointer;
+}
+
+.language-option:hover,
+.language-option.selected {
+ color: var(--ink);
+ background: var(--surface-soft);
+}
+
+.language-option.selected {
+ color: var(--brand-dark);
+ font-weight: 700;
+}
+
+.header-button,
+.text-button {
+ border: 0;
+ padding: 0;
+ color: #4f5954;
+ background: transparent;
+ font-size: 0.82rem;
+ font-weight: 680;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.header-button:hover,
+.text-button:hover:not(:disabled) {
+ color: var(--ink);
+}
+
+.header-button {
+ border-right: 1px solid var(--line);
+ padding-right: 16px;
+}
+
+.text-button:disabled {
+ color: var(--faint);
+ cursor: not-allowed;
+}
+
+.connection-status {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: #4f5954;
+ font-size: 0.82rem;
+ font-weight: 650;
+}
+
+.status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 999px;
+ background: #b3bbb7;
+}
+
+.status-dot.online {
+ background: var(--brand);
+ box-shadow: 0 0 0 4px rgb(33 205 153 / 14%);
+}
+
+.app-main {
+ width: min(100% - 40px, 960px);
+ margin: 0 auto;
+ padding: 48px 0 64px;
+}
+
+.progress {
+ margin: 0 0 46px;
+}
+
+.progress-list {
+ display: grid;
+ margin: 0;
+ padding: 0;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ list-style: none;
+}
+
+.progress-step {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: var(--faint);
+ font-size: 0.8rem;
+ font-weight: 680;
+}
+
+.progress-step::after {
+ position: absolute;
+ z-index: -1;
+ top: 14px;
+ right: 14px;
+ left: 14px;
+ height: 1px;
+ background: var(--line);
+ content: "";
+}
+
+.progress-step:first-child::after {
+ left: 28px;
+}
+
+.progress-step:last-child::after {
+ right: calc(100% - 28px);
+}
+
+.progress-step:nth-child(2) {
+ justify-content: center;
+}
+
+.progress-step:last-child {
+ justify-content: flex-end;
+}
+
+.step-index {
+ position: relative;
+ z-index: 1;
+ display: inline-grid;
+ width: 28px;
+ height: 28px;
+ place-items: center;
+ border: 1px solid var(--line-strong);
+ border-radius: 999px;
+ color: #6f7873;
+ background: var(--canvas);
+ font-size: 0.72rem;
+ font-weight: 800;
+}
+
+.progress-step.current,
+.progress-step.complete {
+ color: var(--ink);
+}
+
+.progress-step.current .step-index {
+ border-color: var(--brand);
+ color: #073c2e;
+ background: var(--brand);
+}
+
+.progress-step.complete .step-index {
+ border-color: #9de6d0;
+ color: var(--brand-dark);
+ background: #e8fff8;
+}
+
+.stage {
+ animation: stage-in 180ms ease-out;
+}
+
+@keyframes stage-in {
+ from {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.stage-head {
+ display: flex;
+ margin-bottom: 26px;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 24px;
+}
+
+.stage-head h1,
+.modal h2 {
+ margin: 0;
+ color: var(--ink);
+ letter-spacing: -0.035em;
+}
+
+.stage-head h1 {
+ font-size: clamp(1.9rem, 4vw, 2.65rem);
+ line-height: 1.08;
+}
+
+.stage-head p {
+ margin: 10px 0 0;
+ color: var(--muted);
+ line-height: 1.55;
+}
+
+.stage-count {
+ flex: 0 0 auto;
+ color: var(--muted);
+ font-size: 0.85rem;
+ font-weight: 650;
+}
+
+.robot-list-tools {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.robot-list-tool {
+ display: inline-flex;
+ min-height: 32px;
+ align-items: center;
+ gap: 6px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 6px 10px;
+ color: #4f5954;
+ background: var(--surface);
+ font-size: 0.76rem;
+ font-weight: 680;
+ cursor: pointer;
+}
+
+.robot-list-tool:hover:not(:disabled) {
+ border-color: var(--line-strong);
+ color: var(--ink);
+}
+
+.robot-list-tool.active {
+ border-color: #9de6d0;
+ color: var(--brand-dark);
+ background: #effff9;
+}
+
+.robot-list-tool:disabled {
+ color: var(--faint);
+ background: #f3f5f4;
+ cursor: not-allowed;
+}
+
+.refresh-symbol {
+ font-size: 1rem;
+ line-height: 1;
+}
+
+.refresh-symbol.spinning {
+ animation: refresh-spin 900ms linear infinite;
+}
+
+@keyframes refresh-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.connect-wrap {
+ display: grid;
+ min-height: calc(100vh - 230px);
+ place-items: center;
+}
+
+.connect-card {
+ width: min(100%, 470px);
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ padding: 34px;
+ background: var(--surface);
+}
+
+.connect-form {
+ display: grid;
+ gap: 20px;
+}
+
+.reconnect-button {
+ margin-top: 12px;
+}
+
+.field {
+ display: grid;
+ gap: 8px;
+}
+
+.field label,
+.field-label {
+ color: #313733;
+ font-size: 0.82rem;
+ font-weight: 720;
+}
+
+.field input,
+.field select,
+.field textarea {
+ width: 100%;
+ border: 1px solid var(--line-strong);
+ border-radius: 10px;
+ padding: 11px 13px;
+ color: var(--ink);
+ background: var(--surface);
+ transition: border-color 150ms ease, box-shadow 150ms ease;
+}
+
+.field input,
+.field select {
+ min-height: 44px;
+}
+
+.field textarea {
+ min-height: 92px;
+ line-height: 1.5;
+ resize: vertical;
+}
+
+.field input:focus,
+.field select:focus,
+.field textarea:focus {
+ border-color: #73dabc;
+ box-shadow: 0 0 0 3px rgb(33 205 153 / 12%);
+ outline: none;
+}
+
+.field input:disabled,
+.field select:disabled,
+.field textarea:disabled {
+ color: #7f8883;
+ background: #f3f5f4;
+}
+
+.field-note,
+.field-error {
+ margin: 0;
+ font-size: 0.76rem;
+ line-height: 1.45;
+}
+
+.field-note {
+ color: var(--muted);
+}
+
+.field-error {
+ color: var(--danger);
+ font-weight: 650;
+}
+
+.button {
+ display: inline-flex;
+ min-height: 42px;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ border: 1px solid var(--line-strong);
+ border-radius: 10px;
+ padding: 9px 15px;
+ color: var(--ink);
+ background: var(--surface);
+ font-size: 0.86rem;
+ font-weight: 740;
+ cursor: pointer;
+ transition: border-color 150ms ease, background 150ms ease, transform 150ms ease;
+}
+
+.button:hover:not(:disabled) {
+ border-color: #aab4ae;
+ background: #f7f9f7;
+}
+
+.button:active:not(:disabled) {
+ transform: translateY(1px);
+}
+
+.button.primary {
+ border-color: var(--brand);
+ color: #06291f;
+ background: var(--brand);
+}
+
+.button.primary:hover:not(:disabled) {
+ border-color: #1abb8c;
+ background: #1abb8c;
+}
+
+.button.danger {
+ border-color: #e5b9b6;
+ color: var(--danger);
+ background: #fff;
+}
+
+.button.danger:hover:not(:disabled) {
+ border-color: #d99a96;
+ background: var(--danger-soft);
+}
+
+.button.ghost {
+ border-color: transparent;
+ background: transparent;
+}
+
+.button:disabled {
+ color: #9aa29e;
+ border-color: #e4e8e5;
+ background: #edf0ee;
+ cursor: not-allowed;
+}
+
+.button.wide {
+ width: 100%;
+}
+
+.inline-notice,
+.loading-panel {
+ margin: 16px 0 0;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 12px 14px;
+ color: #4d5651;
+ background: var(--surface);
+ font-size: 0.8rem;
+ line-height: 1.5;
+}
+
+.inline-notice.success {
+ border-color: #b9eadb;
+ color: #075b45;
+ background: #f2fff9;
+}
+
+.inline-notice.error {
+ border-color: #efc8c5;
+ color: #8c302c;
+ background: var(--danger-soft);
+}
+
+.loading-panel {
+ display: flex;
+ min-height: 130px;
+ margin-top: 0;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+}
+
+.loading-dot {
+ display: inline-block;
+ width: 9px;
+ height: 9px;
+ flex: 0 0 auto;
+ border-radius: 999px;
+ background: var(--brand);
+ animation: loading-pulse 1.3s ease-in-out infinite;
+}
+
+@keyframes loading-pulse {
+ 50% {
+ opacity: 0.35;
+ transform: scale(0.78);
+ }
+}
+
+.robot-grid {
+ display: grid;
+ align-items: start;
+ gap: 14px;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.robot-card {
+ display: grid;
+ min-height: 154px;
+ align-content: space-between;
+ gap: 22px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 19px;
+ text-align: left;
+ background: var(--surface);
+ transition: border-color 150ms ease, background 150ms ease, transform 150ms ease;
+}
+
+.robot-card:hover {
+ border-color: #aeb8b2;
+ transform: translateY(-1px);
+}
+
+.robot-card.selected {
+ border-color: var(--brand);
+ background: #f1fff9;
+ box-shadow: 0 0 0 2px rgb(33 205 153 / 13%);
+}
+
+.robot-card.expanded {
+ align-content: start;
+ gap: 18px;
+}
+
+.robot-select-control {
+ display: grid;
+ width: 100%;
+ min-width: 0;
+ border: 0;
+ padding: 0;
+ gap: 16px;
+ text-align: left;
+ background: transparent;
+ cursor: pointer;
+}
+
+.robot-card-top,
+.robot-context,
+.review-robot,
+.run-hero {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+}
+
+.robot-card-top {
+ width: 100%;
+ min-width: 0;
+}
+
+.robot-card-top > span:first-child {
+ min-width: 0;
+}
+
+.robot-name {
+ display: block;
+ overflow: hidden;
+ color: var(--ink);
+ font-size: 1rem;
+ font-weight: 760;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.robot-serial,
+.robot-context p,
+.review-robot p,
+.run-hero p {
+ display: block;
+ margin: 4px 0 0;
+ color: var(--muted);
+ font-size: 0.76rem;
+}
+
+.robot-select-mark {
+ display: inline-grid;
+ width: 26px;
+ height: 26px;
+ flex: 0 0 auto;
+ place-items: center;
+ border: 1px solid var(--line-strong);
+ border-radius: 999px;
+ color: #08684e;
+ background: #fff;
+ font-size: 0.75rem;
+ font-weight: 800;
+}
+
+.robot-card.selected .robot-select-mark {
+ border-color: #9de6d0;
+ background: #dffff5;
+}
+
+.robot-card-status {
+ display: inline-flex;
+ width: fit-content;
+ align-items: center;
+ gap: 6px;
+ color: var(--muted);
+ font-size: 0.75rem;
+ font-weight: 680;
+}
+
+.robot-card-status-dot {
+ width: 7px;
+ height: 7px;
+ flex: 0 0 auto;
+ border-radius: 999px;
+ background: #aeb6b2;
+}
+
+.robot-card-status.online {
+ color: var(--brand-dark);
+}
+
+.robot-card-status.online .robot-card-status-dot {
+ background: var(--brand);
+}
+
+.robot-card-status.busy,
+.robot-card-status.privacy {
+ color: var(--warning);
+}
+
+.robot-card-status.busy .robot-card-status-dot,
+.robot-card-status.privacy .robot-card-status-dot {
+ background: #d5a12e;
+}
+
+.robot-card-status.error {
+ color: var(--danger);
+}
+
+.robot-card-status.error .robot-card-status-dot {
+ background: #d76b65;
+}
+
+.robot-card-status.refreshing .robot-card-status-dot {
+ background: var(--brand);
+ animation: loading-pulse 1.3s ease-in-out infinite;
+}
+
+.robot-card-footer {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.robot-card-action,
+.robot-status-toggle {
+ border: 0;
+ padding: 0;
+ color: var(--muted);
+ background: transparent;
+ font-size: 0.76rem;
+ font-weight: 680;
+ cursor: pointer;
+}
+
+.robot-card-action:hover,
+.robot-status-toggle:hover {
+ color: var(--ink);
+}
+
+.robot-status-toggle {
+ color: var(--brand-dark);
+}
+
+.robot-status-panel {
+ min-width: 0;
+ border-top: 1px solid var(--line);
+ padding-top: 16px;
+}
+
+.robot-status-loading,
+.robot-status-error,
+.robot-status-snapshot {
+ margin: 0;
+ font-size: 0.72rem;
+ line-height: 1.45;
+}
+
+.robot-status-loading {
+ display: flex;
+ min-height: 44px;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ color: var(--muted);
+}
+
+.robot-status-loading.compact {
+ min-height: auto;
+ margin-bottom: 10px;
+ justify-content: flex-start;
+}
+
+.robot-status-error {
+ color: var(--danger);
+ font-weight: 650;
+}
+
+.robot-status-facts {
+ display: grid;
+ margin: 0;
+ gap: 8px;
+}
+
+.robot-status-facts > div {
+ display: grid;
+ align-items: start;
+ gap: 10px;
+ grid-template-columns: 42px minmax(0, 1fr);
+}
+
+.robot-status-facts dt,
+.robot-status-facts dd {
+ margin: 0;
+ font-size: 0.72rem;
+ line-height: 1.4;
+}
+
+.robot-status-facts dt {
+ color: var(--muted);
+}
+
+.robot-status-facts dd {
+ overflow-wrap: anywhere;
+ color: #303733;
+ font-weight: 650;
+}
+
+.robot-status-snapshot {
+ margin-top: 12px;
+ color: var(--faint);
+}
+
+.stage-actions {
+ display: flex;
+ margin-top: 30px;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ border-top: 1px solid var(--line);
+ padding-top: 22px;
+}
+
+.stage-actions-end {
+ margin-left: auto;
+}
+
+.robot-context {
+ margin-bottom: 18px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 14px 16px;
+ background: var(--surface);
+}
+
+.robot-context-main,
+.robot-context-actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.robot-avatar {
+ display: grid;
+ width: 38px;
+ height: 38px;
+ flex: 0 0 auto;
+ place-items: center;
+ border-radius: 10px;
+ color: #063c2e;
+ background: #dffff5;
+ font-size: 0.9rem;
+ font-weight: 850;
+}
+
+.robot-context strong,
+.review-robot strong,
+.run-hero strong {
+ display: block;
+ color: var(--ink);
+ font-size: 0.9rem;
+}
+
+.resource-notice {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.resource-notice .inline-notice {
+ flex: 1;
+}
+
+.sequence-list {
+ display: grid;
+ margin: 0;
+ padding: 0;
+ gap: 12px;
+ list-style: none;
+}
+
+.sequence-card {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--surface);
+}
+
+.sequence-card.dragging {
+ opacity: 0.48;
+}
+
+.drag-handle {
+ display: grid;
+ width: 42px;
+ min-height: 100%;
+ place-items: start center;
+ border: 0;
+ padding-top: 22px;
+ color: #a0a8a4;
+ background: transparent;
+ font-size: 1rem;
+ cursor: grab;
+}
+
+.drag-handle:disabled {
+ cursor: default;
+}
+
+.sequence-content {
+ min-width: 0;
+ padding: 18px 0 20px;
+}
+
+.sequence-heading {
+ display: flex;
+ margin-bottom: 16px;
+ align-items: center;
+ gap: 10px;
+}
+
+.sequence-number {
+ display: inline-grid;
+ width: 24px;
+ height: 24px;
+ place-items: center;
+ border-radius: 7px;
+ color: #08684e;
+ background: #e8fff8;
+ font-size: 0.7rem;
+ font-weight: 850;
+}
+
+.sequence-heading h2 {
+ margin: 0;
+ color: var(--ink);
+ font-size: 0.96rem;
+}
+
+.action-menu-wrap {
+ position: relative;
+ padding: 14px 12px 0 8px;
+}
+
+.icon-button {
+ display: grid;
+ width: 34px;
+ height: 34px;
+ place-items: center;
+ border: 1px solid transparent;
+ border-radius: 8px;
+ color: #68716c;
+ background: transparent;
+ cursor: pointer;
+}
+
+.icon-button:hover:not(:disabled) {
+ border-color: var(--line);
+ background: var(--surface-soft);
+}
+
+.icon-button:disabled {
+ color: #b8bfbb;
+ cursor: default;
+}
+
+.action-menu {
+ position: absolute;
+ z-index: 10;
+ top: 48px;
+ right: 12px;
+ display: grid;
+ width: 142px;
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 5px;
+ background: var(--surface);
+ box-shadow: 0 12px 32px rgb(17 21 19 / 12%);
+}
+
+.action-menu button {
+ border: 0;
+ border-radius: 7px;
+ padding: 8px 10px;
+ text-align: left;
+ background: transparent;
+ font-size: 0.78rem;
+ cursor: pointer;
+}
+
+.action-menu button:hover:not(:disabled) {
+ background: #f2f5f3;
+}
+
+.action-menu button:disabled {
+ color: #b0b7b3;
+ cursor: not-allowed;
+}
+
+.action-menu button.danger-text {
+ color: var(--danger);
+}
+
+.add-action {
+ position: relative;
+ display: flex;
+ margin-top: 14px;
+ justify-content: center;
+}
+
+.add-action-menu {
+ position: absolute;
+ z-index: 10;
+ top: 50px;
+ left: 50%;
+ display: grid;
+ width: min(360px, calc(100vw - 48px));
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 6px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 7px;
+ background: var(--surface);
+ box-shadow: 0 16px 40px rgb(17 21 19 / 13%);
+ transform: translateX(-50%);
+}
+
+.add-action-menu button {
+ display: grid;
+ min-height: 74px;
+ place-items: center;
+ gap: 5px;
+ border: 0;
+ border-radius: 8px;
+ padding: 10px;
+ color: #343b37;
+ background: transparent;
+ font-size: 0.76rem;
+ font-weight: 720;
+ cursor: pointer;
+}
+
+.add-action-menu button:hover:not(:disabled) {
+ background: #effbf7;
+}
+
+.add-action-menu button:disabled {
+ color: #a3aaa6;
+ cursor: not-allowed;
+}
+
+.action-glyph {
+ font-size: 1rem;
+}
+
+.empty-sequence {
+ display: grid;
+ min-height: 180px;
+ place-items: center;
+ border: 1px dashed var(--line-strong);
+ border-radius: 12px;
+ color: var(--muted);
+ background: rgb(255 255 255 / 44%);
+ text-align: center;
+}
+
+.empty-sequence strong {
+ display: block;
+ margin-bottom: 6px;
+ color: #3d4541;
+}
+
+.contact-options {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ border: 0;
+ margin: 0;
+ padding: 0;
+}
+
+.contact-choice input {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ opacity: 0;
+}
+
+.contact-choice span {
+ display: inline-flex;
+ min-height: 38px;
+ align-items: center;
+ border: 1px solid var(--line-strong);
+ border-radius: 9px;
+ padding: 8px 12px;
+ color: #48504c;
+ background: #fff;
+ font-size: 0.8rem;
+ cursor: pointer;
+}
+
+.contact-choice input:checked + span {
+ border-color: var(--brand);
+ color: #09664d;
+ background: #effcf8;
+}
+
+.contact-options:disabled .contact-choice span {
+ color: #969e9a;
+ background: #f3f5f4;
+ cursor: not-allowed;
+}
+
+.review-panel,
+.run-panel {
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ background: var(--surface);
+}
+
+.review-robot,
+.run-hero {
+ padding: 20px 22px;
+ border-bottom: 1px solid var(--line);
+}
+
+.review-list,
+.run-list {
+ margin: 0;
+ padding: 4px 22px;
+ list-style: none;
+}
+
+.review-item,
+.run-item {
+ display: grid;
+ min-height: 64px;
+ align-items: center;
+ gap: 14px;
+ border-bottom: 1px solid #edf0ee;
+ grid-template-columns: 28px minmax(0, 1fr) auto;
+}
+
+.review-item:last-child,
+.run-item:last-child {
+ border-bottom: 0;
+}
+
+.review-item strong,
+.run-item strong {
+ display: block;
+ color: #252b27;
+ font-size: 0.85rem;
+}
+
+.review-item p,
+.run-item p {
+ margin: 4px 0 0;
+ overflow-wrap: anywhere;
+ color: var(--muted);
+ font-size: 0.76rem;
+}
+
+.status-pill {
+ display: inline-flex;
+ min-height: 26px;
+ align-items: center;
+ gap: 6px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ padding: 4px 9px;
+ color: #55605a;
+ background: #fafbfa;
+ font-size: 0.7rem;
+ font-weight: 760;
+ white-space: nowrap;
+}
+
+.status-pill.online {
+ border-color: #aee9d7;
+ color: #087255;
+ background: #effcf8;
+}
+
+.status-pill.busy {
+ border-color: #ead8a5;
+ color: var(--warning);
+ background: var(--warning-soft);
+}
+
+.review-check {
+ display: flex;
+ margin-top: 14px;
+ align-items: center;
+ gap: 12px;
+ border: 1px solid #b9eadb;
+ border-radius: 11px;
+ padding: 14px 16px;
+ color: #075b45;
+ background: #f2fff9;
+ font-size: 0.82rem;
+ font-weight: 680;
+}
+
+.review-check.neutral {
+ border-color: var(--line);
+ color: #4f5954;
+ background: var(--surface);
+}
+
+.review-check.blocked {
+ border-color: #ead8a5;
+ color: #71531b;
+ background: var(--warning-soft);
+}
+
+.check-mark,
+.run-mark {
+ display: inline-grid;
+ width: 28px;
+ height: 28px;
+ flex: 0 0 auto;
+ place-items: center;
+ border-radius: 999px;
+ color: #075b45;
+ background: #c9f5e7;
+ font-weight: 850;
+}
+
+.review-check.blocked .check-mark {
+ color: #765315;
+ background: #f6e5b7;
+}
+
+.run-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: #087255;
+ font-size: 0.82rem;
+ font-weight: 760;
+}
+
+.pulse {
+ width: 9px;
+ height: 9px;
+ border-radius: 999px;
+ background: var(--brand);
+ box-shadow: 0 0 0 0 rgb(33 205 153 / 35%);
+ animation: pulse 1.8s infinite;
+}
+
+@keyframes pulse {
+ 70% {
+ box-shadow: 0 0 0 7px rgb(33 205 153 / 0%);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 rgb(33 205 153 / 0%);
+ }
+}
+
+.run-item .run-mark {
+ width: 26px;
+ height: 26px;
+ color: #7d8782;
+ background: #eef1ef;
+ font-size: 0.72rem;
+}
+
+.run-item.active .run-mark {
+ color: #075b45;
+ background: #c9f5e7;
+}
+
+.run-item.complete .run-mark {
+ color: #fff;
+ background: #178665;
+}
+
+.run-item.waiting {
+ opacity: 0.62;
+}
+
+.modal-backdrop {
+ position: fixed;
+ z-index: 80;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: 20px;
+ background: rgb(12 16 14 / 48%);
+}
+
+.modal {
+ width: min(100%, 510px);
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ padding: 26px;
+ background: var(--surface);
+ box-shadow: 0 24px 72px rgb(5 9 7 / 22%);
+}
+
+.modal h2 {
+ font-size: 1.55rem;
+}
+
+.modal-summary {
+ display: grid;
+ margin: 22px 0 0;
+ gap: 0;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 3px 14px;
+}
+
+.modal-summary > div {
+ display: flex;
+ min-height: 46px;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ border-bottom: 1px solid #edf0ee;
+}
+
+.modal-summary > div:last-child {
+ border-bottom: 0;
+}
+
+.modal-summary span {
+ color: var(--muted);
+ font-size: 0.78rem;
+}
+
+.modal-summary strong {
+ color: #2a312d;
+ font-size: 0.8rem;
+ overflow-wrap: anywhere;
+ text-align: right;
+}
+
+.safety-note {
+ margin: 14px 0 0;
+ border-left: 3px solid #d5a12e;
+ padding: 7px 0 7px 12px;
+ color: #6d5118;
+ font-size: 0.8rem;
+ line-height: 1.5;
+}
+
+.modal-actions {
+ display: flex;
+ margin-top: 24px;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
+@media (max-width: 760px) {
+ .header-inner,
+ .app-main {
+ width: min(100% - 28px, 960px);
+ }
+
+ .brand-lockup img {
+ width: 120px;
+ }
+
+ .header-button,
+ .connection-status span:not(.status-dot) {
+ display: none;
+ }
+
+ .app-main {
+ padding-top: 34px;
+ }
+
+ .progress {
+ margin-bottom: 36px;
+ }
+
+ .progress-step {
+ gap: 7px;
+ font-size: 0.72rem;
+ }
+
+ .robot-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .robot-card {
+ min-height: 122px;
+ }
+
+ .stage-head {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .robot-list-tools {
+ width: 100%;
+ justify-content: flex-start;
+ flex-wrap: wrap;
+ }
+
+ .stage-actions {
+ align-items: stretch;
+ }
+
+ .stage-actions .button {
+ flex: 1;
+ }
+
+ .sequence-card {
+ grid-template-columns: 32px minmax(0, 1fr) 44px;
+ }
+
+ .drag-handle {
+ width: 32px;
+ }
+}
+
+@media (max-width: 520px) {
+ .header-inner {
+ min-height: 60px;
+ }
+
+ .brand-lockup img {
+ width: 105px;
+ }
+
+ .app-main {
+ padding-top: 26px;
+ }
+
+ .progress-label {
+ display: none;
+ }
+
+ .connect-wrap {
+ align-items: start;
+ min-height: auto;
+ }
+
+ .connect-card {
+ border-radius: 12px;
+ padding: 24px 20px;
+ }
+
+ .stage-head h1 {
+ font-size: 1.9rem;
+ }
+
+ .robot-context,
+ .review-robot,
+ .run-hero {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .robot-context-actions {
+ width: 100%;
+ justify-content: space-between;
+ }
+
+ .resource-notice {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .sequence-content {
+ padding-right: 4px;
+ }
+
+ .add-action-menu {
+ grid-template-columns: 1fr;
+ }
+
+ .add-action-menu button {
+ min-height: 46px;
+ grid-template-columns: 24px 1fr;
+ justify-items: start;
+ }
+
+ .review-item,
+ .run-item {
+ grid-template-columns: 28px minmax(0, 1fr);
+ }
+
+ .review-item > :last-child,
+ .run-item > :last-child {
+ display: none;
+ }
+
+ .modal {
+ padding: 22px 18px;
+ }
+
+ .modal-actions {
+ align-items: stretch;
+ flex-direction: column-reverse;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
diff --git a/demos/temi-openapi-scenario-runner/src/timeline.ts b/demos/temi-openapi-scenario-runner/src/timeline.ts
new file mode 100644
index 0000000..0191ed5
--- /dev/null
+++ b/demos/temi-openapi-scenario-runner/src/timeline.ts
@@ -0,0 +1,444 @@
+import { isAbortError, TemiApiError, type ErrorCode, type ValidationHint } from "./api";
+import type { RunState } from "./run-state";
+
+export const API_OPERATION_CONTRACTS = {
+ verify: { method: "GET", path: "/verify", sideEffect: "read" },
+ listRobots: { method: "GET", path: "/robots", sideEffect: "read" },
+ getRobotStatus: { method: "GET", path: "/robots/{serialNumber}", sideEffect: "read" },
+ listRobotLocations: {
+ method: "GET",
+ path: "/robots/{serialNumber}/locations",
+ sideEffect: "read",
+ },
+ listRobotContacts: {
+ method: "GET",
+ path: "/robots/{serialNumber}/contacts",
+ sideEffect: "read",
+ },
+ validateSequence: { method: "POST", path: "/sequences/validate", sideEffect: "read" },
+ playSequence: { method: "POST", path: "/sequences/play", sideEffect: "write" },
+ stopSequence: { method: "POST", path: "/sequences/stop", sideEffect: "write" },
+} as const;
+
+export type ApiOperation = keyof typeof API_OPERATION_CONTRACTS;
+export type TimelineOutcome =
+ | "queued"
+ | "pending"
+ | "succeeded"
+ | "failed"
+ | "unknown"
+ | "cancelled";
+
+export type TimelineObservation =
+ | "completed"
+ | "aborted"
+ | "stopped"
+ | "observation_unknown"
+ | "identity_mismatch";
+
+export type SafeSummaryValue =
+ | string
+ | number
+ | boolean
+ | readonly (string | number)[];
+export type SafeSummary = Readonly>;
+
+export type TimelineErrorKind =
+ | "unauthorized"
+ | "http"
+ | "network"
+ | "timeout"
+ | "aborted"
+ | "invalid_response"
+ | "unknown";
+
+export type SafeErrorSummary = Readonly<{
+ kind: TimelineErrorKind;
+ status: number | null;
+ code: ErrorCode | null;
+ validationHint: ValidationHint | null;
+}>;
+
+export type TimelineEvent = Readonly<{
+ id: number;
+ phase: RunState;
+ operation: ApiOperation;
+ method: "GET" | "POST";
+ path: string;
+ requestSummary: SafeSummary;
+ startedAt: number | null;
+ endedAt: number | null;
+ durationMs: number | null;
+ httpStatus: number | null;
+ outcome: TimelineOutcome;
+ responseSummary: SafeSummary | null;
+ errorSummary: SafeErrorSummary | null;
+ explanation: string;
+}>;
+
+export type TimelineEnqueueInput = Readonly<{
+ phase: RunState;
+ operation: ApiOperation;
+ requestSummary?: unknown;
+}>;
+
+export type TimelineCompletionInput = Readonly<{
+ outcome: Exclude;
+ httpStatus?: number | null;
+ response?: unknown;
+ error?: unknown;
+}>;
+
+export type TimelineAnnotation = Readonly<{
+ observation?: TimelineObservation;
+ explanation?: string;
+}>;
+
+type MutableTimelineEvent = {
+ id: number;
+ phase: RunState;
+ operation: ApiOperation;
+ method: "GET" | "POST";
+ path: string;
+ requestSummary: SafeSummary;
+ startedAt: number | null;
+ endedAt: number | null;
+ durationMs: number | null;
+ httpStatus: number | null;
+ outcome: TimelineOutcome;
+ responseSummary: SafeSummary | null;
+ errorSummary: SafeErrorSummary | null;
+ explanation: string;
+};
+
+export class TimelineStore {
+ #events: MutableTimelineEvent[] = [];
+ #nextId = 1;
+ #now: () => number;
+
+ constructor(now: () => number = () => Date.now()) {
+ this.#now = now;
+ }
+
+ enqueue(input: TimelineEnqueueInput): number {
+ const contract = API_OPERATION_CONTRACTS[input.operation];
+ const event: MutableTimelineEvent = {
+ id: this.#nextId++,
+ phase: input.phase,
+ operation: input.operation,
+ method: contract.method,
+ path: contract.path,
+ requestSummary: sanitizeRequestSummary(input.operation, input.requestSummary),
+ startedAt: null,
+ endedAt: null,
+ durationMs: null,
+ httpStatus: null,
+ outcome: "queued",
+ responseSummary: null,
+ errorSummary: null,
+ explanation: "Request queued.",
+ };
+ this.#events.push(event);
+ return event.id;
+ }
+
+ start(id: number): void {
+ const event = this.find(id);
+ if (event.outcome !== "queued") {
+ throw new Error(`Timeline event ${id} is already started.`);
+ }
+ event.startedAt = this.#now();
+ event.outcome = "pending";
+ event.explanation = "Request is pending.";
+ }
+
+ complete(id: number, input: TimelineCompletionInput): void {
+ const event = this.find(id);
+ if (event.startedAt === null || event.endedAt !== null) {
+ throw new Error(`Timeline event ${id} is not pending.`);
+ }
+
+ event.endedAt = this.#now();
+ event.durationMs = Math.max(0, event.endedAt - event.startedAt);
+ event.httpStatus = safeHttpStatus(input.httpStatus);
+ event.outcome = input.outcome;
+ event.responseSummary =
+ input.response === undefined
+ ? null
+ : sanitizeResponseSummary(event.operation, input.response);
+ event.errorSummary = input.error === undefined ? null : sanitizeErrorSummary(input.error);
+ event.explanation = explainOutcome(
+ event.operation,
+ event.outcome,
+ event.httpStatus,
+ event.responseSummary,
+ );
+ }
+
+ annotate(id: number, input: TimelineAnnotation): void {
+ const event = this.find(id);
+ if (event.endedAt === null) {
+ throw new Error(`Timeline event ${id} is not complete.`);
+ }
+ if (input.observation !== undefined) {
+ event.responseSummary = freezeSummary({
+ ...(event.responseSummary ?? {}),
+ observation: input.observation,
+ });
+ }
+ if (input.explanation !== undefined) {
+ event.explanation = input.explanation;
+ }
+ }
+
+ has(id: number): boolean {
+ return this.#events.some((event) => event.id === id);
+ }
+
+ clear(): void {
+ this.#events = [];
+ }
+
+ getEvents(): readonly TimelineEvent[] {
+ return this.#events.map(copyEvent);
+ }
+
+ private find(id: number): MutableTimelineEvent {
+ const event = this.#events.find((candidate) => candidate.id === id);
+ if (!event) {
+ throw new Error(`Timeline event ${id} does not exist.`);
+ }
+ return event;
+ }
+}
+
+export function sanitizeRequestSummary(operation: ApiOperation, input: unknown = {}): SafeSummary {
+ const record = asRecord(input);
+ if (operation === "validateSequence" || operation === "playSequence") {
+ const actions = Array.isArray(record.actions) ? record.actions : [];
+ const actionRecords = actions.map(asRecord);
+ return freezeSummary({
+ actionCount: actions.length,
+ actionTypes: actionRecords.flatMap((action) =>
+ isSequenceActionType(action.type) ? [action.type] : [],
+ ),
+ steps: actionRecords.flatMap((action) => isStep(action.step) ? [action.step] : []),
+ });
+ }
+
+ if (operation === "stopSequence") {
+ return freezeSummary({ sequenceIdPresent: hasNonEmptyString(record.sequenceId) });
+ }
+
+ if (
+ operation === "getRobotStatus" ||
+ operation === "listRobotLocations" ||
+ operation === "listRobotContacts"
+ ) {
+ return freezeSummary({ serialNumberPresent: hasNonEmptyString(record.serialNumber) });
+ }
+
+ return freezeSummary({});
+}
+
+export function sanitizeResponseSummary(operation: ApiOperation, input: unknown): SafeSummary {
+ const record = asRecord(input);
+ if (operation === "listRobots") {
+ return freezeSummary({ robotCount: arrayLength(record.robots) });
+ }
+ if (operation === "listRobotLocations") {
+ return freezeSummary({ locationCount: arrayLength(record.locations) });
+ }
+ if (operation === "listRobotContacts") {
+ return freezeSummary({ contactCount: arrayLength(record.contacts) });
+ }
+ if (operation === "getRobotStatus") {
+ const sequenceKnown = record.sequence !== undefined && record.sequence !== null;
+ const sequence = asRecord(record.sequence);
+ return freezeSummary({
+ status: safeStatus(record.status) ?? "unknown / unavailable",
+ movementKnown: record.movement !== undefined && record.movement !== null,
+ sequenceKnown,
+ sequenceStatus: sequenceKnown ? safeStatus(sequence.status) ?? "unknown / unavailable" : "absent",
+ callKnown: record.call !== undefined && record.call !== null,
+ batteryKnown: record.battery !== undefined && record.battery !== null,
+ });
+ }
+ if (operation === "verify") {
+ return freezeSummary({
+ status: safeStatus(record.status) ?? "unknown / unavailable",
+ scopeCount: arrayLength(record.scopes),
+ robotScope: safeRobotScope(record.robotScope) ?? "unknown / unavailable",
+ selectedRobotCount: arrayLength(record.serialNumbers),
+ });
+ }
+ if (operation === "validateSequence") {
+ return optionalSummary({ status: safeStatus(record.status) });
+ }
+ return freezeSummary({ sequenceIdPresent: hasNonEmptyString(record.sequenceId) });
+}
+
+export function sanitizeErrorSummary(error: unknown): SafeErrorSummary {
+ if (error instanceof TemiApiError) {
+ return freezeErrorSummary({
+ kind: error.kind,
+ status: safeHttpStatus(error.status),
+ code: error.code,
+ validationHint: error.validationHint,
+ });
+ }
+
+ if (isAbortError(error)) {
+ return freezeErrorSummary({ kind: "aborted", status: null, code: null, validationHint: null });
+ }
+
+ return freezeErrorSummary({ kind: "unknown", status: null, code: null, validationHint: null });
+}
+
+function explainOutcome(
+ operation: ApiOperation,
+ outcome: Exclude,
+ status: number | null,
+ response: SafeSummary | null,
+): string {
+ if (outcome === "succeeded") {
+ switch (operation) {
+ case "verify":
+ return "verify succeeded; continue with GET /robots.";
+ case "listRobots":
+ if (response?.robotCount === 0) {
+ return "robots returned no accessible PRO robots; no hardware serialNumber can be selected and no downstream reads will start.";
+ }
+ return "robots succeeded; choose a hardware serialNumber to read status, locations, and contacts in parallel.";
+ case "getRobotStatus":
+ return "status succeeded; only online enters Play preparation. Missing facts stay unknown/unavailable; this is last-known, not realtime.";
+ case "listRobotLocations":
+ if (response?.locationCount === 0) {
+ return "locations returned no selectable items; MOVEMENT remains unavailable.";
+ }
+ return "locations succeeded; these choices can enable MOVEMENT only.";
+ case "listRobotContacts":
+ if (response?.contactCount === 0) {
+ return "contacts returned no selectable items; START_CALL remains unavailable.";
+ }
+ return "contacts succeeded; these choices can enable START_CALL only.";
+ default:
+ return `${operation} completed.`;
+ }
+ }
+ if (outcome === "failed") {
+ const rejection = status === null
+ ? `${operation} failed with a known request error.`
+ : `${operation} was rejected with HTTP ${status}.`;
+ switch (operation) {
+ case "verify":
+ return `${rejection} No GET /robots request will be sent.`;
+ case "listRobots":
+ return `${rejection} Robot selection and the three robot reads cannot start.`;
+ case "getRobotStatus":
+ return `${rejection} Play preparation stays blocked; the operator can refresh status.`;
+ case "listRobotLocations":
+ return `${rejection} MOVEMENT is disabled; SPEAK and successful contacts are unaffected.`;
+ case "listRobotContacts":
+ return `${rejection} START_CALL is disabled; SPEAK and successful locations are unaffected.`;
+ default:
+ return rejection;
+ }
+ }
+ if (outcome === "cancelled") return `${operation} was cancelled before completion.`;
+ return `${operation} may have had a side effect; the result is not confirmed.`;
+}
+
+function copyEvent(event: MutableTimelineEvent): TimelineEvent {
+ return Object.freeze({
+ ...event,
+ requestSummary: cloneSummary(event.requestSummary),
+ responseSummary: event.responseSummary === null ? null : cloneSummary(event.responseSummary),
+ errorSummary: event.errorSummary === null ? null : Object.freeze({ ...event.errorSummary }),
+ });
+}
+
+function cloneSummary(summary: SafeSummary): SafeSummary {
+ return Object.freeze(
+ Object.fromEntries(
+ Object.entries(summary).map(([key, value]) => [
+ key,
+ Array.isArray(value) ? Object.freeze([...value]) : value,
+ ]),
+ ),
+ );
+}
+
+function freezeSummary(summary: Record): SafeSummary {
+ return cloneSummary(summary);
+}
+
+function optionalSummary(values: Record): SafeSummary {
+ return freezeSummary(
+ Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined)) as Record<
+ string,
+ SafeSummaryValue
+ >,
+ );
+}
+
+function freezeErrorSummary(summary: SafeErrorSummary): SafeErrorSummary {
+ return Object.freeze(summary);
+}
+
+function asRecord(value: unknown): Record {
+ return typeof value === "object" && value !== null ? (value as Record) : {};
+}
+
+function arrayLength(value: unknown): number {
+ return Array.isArray(value) ? value.length : 0;
+}
+
+function hasNonEmptyString(value: unknown): boolean {
+ return typeof value === "string" && value.trim().length > 0;
+}
+
+function isSequenceActionType(value: unknown): value is "MOVEMENT" | "SPEAK" | "START_CALL" {
+ return value === "MOVEMENT" || value === "SPEAK" || value === "START_CALL";
+}
+
+function isStep(value: unknown): value is number {
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
+}
+
+function safeStatus(value: unknown): string | undefined {
+ if (
+ value === "ok" ||
+ value === "success" ||
+ value === "online" ||
+ value === "offline" ||
+ value === "busy" ||
+ value === "privacy" ||
+ value === "start" ||
+ value === "running" ||
+ value === "succeeded" ||
+ value === "complete" ||
+ value === "completed" ||
+ value === "abort" ||
+ value === "stop" ||
+ value === "idle" ||
+ value === "stopped" ||
+ value === "aborted" ||
+ value === "failed" ||
+ value === "cancelled" ||
+ value === "canceled"
+ ) {
+ return value;
+ }
+ return undefined;
+}
+
+function safeRobotScope(value: unknown): "all" | "selected" | undefined {
+ return value === "all" || value === "selected" ? value : undefined;
+}
+
+function safeHttpStatus(value: number | null | undefined): number | null {
+ return typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599
+ ? value
+ : null;
+}
diff --git a/demos/temi-openapi-scenario-runner/tsconfig.json b/demos/temi-openapi-scenario-runner/tsconfig.json
new file mode 100644
index 0000000..6d091a4
--- /dev/null
+++ b/demos/temi-openapi-scenario-runner/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "lib": ["ES2022", "DOM"],
+ "types": ["vite/client", "node"]
+ },
+ "include": ["src/**/*.ts", "vite.config.mts"]
+}
diff --git a/demos/temi-openapi-scenario-runner/vite.config.mts b/demos/temi-openapi-scenario-runner/vite.config.mts
new file mode 100644
index 0000000..6459261
--- /dev/null
+++ b/demos/temi-openapi-scenario-runner/vite.config.mts
@@ -0,0 +1,16 @@
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { defineConfig } from "vite";
+
+const demoRoot = dirname(fileURLToPath(import.meta.url));
+
+export default defineConfig({
+ root: demoRoot,
+ base: "./",
+ build: {
+ outDir: resolve(demoRoot, "dist"),
+ emptyOutDir: true,
+ sourcemap: false,
+ },
+});