feat: checkpoint device agent runtime milestones

This commit is contained in:
2026-07-06 17:24:03 +08:00
parent 2d4251e98e
commit 5658735bca
153 changed files with 8060 additions and 65 deletions
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://127.0.0.1:8000
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.DS_Store
*.local
+30
View File
@@ -0,0 +1,30 @@
# Apex Agent Console
Independent Vue 3 + Vite SPA for the operator console.
## Run Locally
Start the backend from the repository root:
```bash
uvicorn api.rest:create_app --factory --host 127.0.0.1 --port 8000
```
Start the frontend from this directory:
```bash
npm install
npm run dev
```
The frontend reads `VITE_API_BASE_URL` and defaults to `http://127.0.0.1:8000`.
Copy `.env.example` to `.env.local` if the backend runs on another host or port.
The backend mounts `/console/*` routes and enables permissive CORS in `create_app()`
for local frontend development.
## Build
```bash
npm run build
```
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Apex Agent Console</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1211
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "apex-agent-console",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@lucide/vue": "^1.23.0",
"vue": "^3.5.39"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "^6.0.3",
"vite": "^8.1.3",
"vue-tsc": "^3.3.6"
}
}
+521
View File
@@ -0,0 +1,521 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
import type { Component } from "vue";
import {
ChevronLeft,
ChevronRight,
ListChecks,
LoaderCircle,
MonitorSmartphone,
Plus,
RefreshCw,
Save,
Settings2,
Trash2,
} from "@lucide/vue";
import {
API_BASE_URL,
getConfig,
getTask,
getTimeline,
listDevices,
listTasks,
registerDevice,
unregisterDevice,
updateConfig,
} from "./api";
import type { Device, TaskRecord, TimelineRecord } from "./types";
type ViewId = "dashboard" | "tasks" | "config";
const navItems: { id: ViewId; label: string; icon: Component }[] = [
{ id: "dashboard", label: "Devices", icon: MonitorSmartphone },
{ id: "tasks", label: "Tasks", icon: ListChecks },
{ id: "config", label: "Config", icon: Settings2 },
];
const taskStatuses = ["created", "running", "completed", "failed", "cancelled"];
const activeView = ref<ViewId>("dashboard");
const loading = ref(false);
const refreshError = ref("");
const devices = ref<Device[]>([]);
const tasks = ref<TaskRecord[]>([]);
const selectedTask = ref<TaskRecord | null>(null);
const timeline = ref<TimelineRecord[]>([]);
const selectedStepIndex = ref(0);
const deviceFilter = ref("");
const statusFilter = ref("");
const deviceError = ref("");
const configError = ref("");
const configSaved = ref("");
const maxSteps = ref(20);
const deviceForm = reactive({
name: "",
driver_type: "wda",
server_url: "http://127.0.0.1:4723",
udid: "",
wda_local_port: "",
});
let refreshTimer: number | undefined;
const currentStep = computed<TimelineRecord | null>(() => {
if (!timeline.value.length) {
return null;
}
return timeline.value[selectedStepIndex.value] ?? timeline.value[0];
});
const runningTasks = computed(
() => tasks.value.filter((task) => task.status === "running").length,
);
const failedTasks = computed(
() => tasks.value.filter((task) => task.status === "failed").length,
);
onMounted(async () => {
await refreshAll();
refreshTimer = window.setInterval(() => {
void refreshStatus();
}, 10000);
});
onUnmounted(() => {
if (refreshTimer !== undefined) {
window.clearInterval(refreshTimer);
}
});
async function refreshAll(): Promise<void> {
loading.value = true;
refreshError.value = "";
try {
await Promise.all([refreshDevices(), refreshTasks(), refreshConfig()]);
} catch (error) {
refreshError.value = errorMessage(error);
} finally {
loading.value = false;
}
}
async function refreshStatus(): Promise<void> {
try {
await Promise.all([refreshDevices(), refreshTasks()]);
} catch (error) {
refreshError.value = errorMessage(error);
}
}
async function refreshDevices(): Promise<void> {
devices.value = await listDevices();
}
async function refreshTasks(): Promise<void> {
tasks.value = await listTasks({
deviceId: deviceFilter.value,
status: statusFilter.value,
});
if (selectedTask.value) {
await openTask(selectedTask.value.id, false);
}
}
async function refreshConfig(): Promise<void> {
const config = await getConfig();
maxSteps.value = config.max_steps;
}
async function applyTaskFilters(): Promise<void> {
await refreshTasks();
}
async function openTask(taskId: string, switchView = true): Promise<void> {
const [task, records] = await Promise.all([getTask(taskId), getTimeline(taskId)]);
selectedTask.value = task;
timeline.value = records;
selectedStepIndex.value = records.length ? Math.min(selectedStepIndex.value, records.length - 1) : 0;
if (switchView) {
activeView.value = "tasks";
}
}
async function submitDevice(): Promise<void> {
deviceError.value = "";
const connectionInfo: Record<string, unknown> = {};
if (deviceForm.server_url.trim()) {
connectionInfo.server_url = deviceForm.server_url.trim();
}
if (deviceForm.udid.trim()) {
connectionInfo.udid = deviceForm.udid.trim();
}
if (deviceForm.wda_local_port.trim()) {
const port = Number(deviceForm.wda_local_port);
if (!Number.isFinite(port)) {
deviceError.value = "wda_local_port must be a number";
return;
}
connectionInfo.wda_local_port = port;
}
try {
await registerDevice({
driver_type: deviceForm.driver_type,
name: deviceForm.name.trim() || null,
connection_info: connectionInfo,
});
deviceForm.name = "";
deviceForm.udid = "";
deviceForm.wda_local_port = "";
await refreshDevices();
} catch (error) {
deviceError.value = errorMessage(error);
}
}
async function removeDevice(device: Device): Promise<void> {
if (!window.confirm(`Remove ${displayDeviceName(device)}?`)) {
return;
}
deviceError.value = "";
try {
await unregisterDevice(device.id);
await refreshDevices();
} catch (error) {
deviceError.value = errorMessage(error);
}
}
async function saveConfig(): Promise<void> {
configError.value = "";
configSaved.value = "";
try {
const updated = await updateConfig({ max_steps: Number(maxSteps.value) });
maxSteps.value = updated.max_steps;
configSaved.value = "Saved";
} catch (error) {
configError.value = errorMessage(error);
}
}
function previousStep(): void {
selectedStepIndex.value = Math.max(0, selectedStepIndex.value - 1);
}
function nextStep(): void {
selectedStepIndex.value = Math.min(timeline.value.length - 1, selectedStepIndex.value + 1);
}
function displayDeviceName(device: Device): string {
return device.name || device.id;
}
function findDeviceName(deviceId: string): string {
return devices.value.find((device) => device.id === deviceId)?.name || deviceId;
}
function formatDate(value: string | null): string {
if (!value) {
return "-";
}
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
function prettyJson(value: unknown): string {
return JSON.stringify(value ?? {}, null, 2);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Request failed";
}
</script>
<template>
<div class="app-shell">
<aside class="sidebar" aria-label="Console navigation">
<div class="brand">
<MonitorSmartphone :size="22" aria-hidden="true" />
<div>
<strong>Apex Console</strong>
<span>{{ API_BASE_URL }}</span>
</div>
</div>
<nav class="nav-list">
<button
v-for="item in navItems"
:key="item.id"
class="nav-button"
:class="{ active: activeView === item.id }"
type="button"
@click="activeView = item.id"
>
<component :is="item.icon" :size="18" aria-hidden="true" />
<span>{{ item.label }}</span>
</button>
</nav>
</aside>
<main class="workspace">
<header class="topbar">
<div>
<h1>{{ navItems.find((item) => item.id === activeView)?.label }}</h1>
<p>{{ devices.length }} devices / {{ tasks.length }} tasks</p>
</div>
<button class="icon-text-button" type="button" :disabled="loading" @click="refreshAll">
<LoaderCircle v-if="loading" class="spin" :size="17" aria-hidden="true" />
<RefreshCw v-else :size="17" aria-hidden="true" />
<span>Refresh</span>
</button>
</header>
<p v-if="refreshError" class="alert error">{{ refreshError }}</p>
<section v-if="activeView === 'dashboard'" class="view-grid">
<div class="metrics">
<div class="metric">
<span class="metric-label">Devices</span>
<strong>{{ devices.length }}</strong>
</div>
<div class="metric">
<span class="metric-label">Running</span>
<strong>{{ runningTasks }}</strong>
</div>
<div class="metric">
<span class="metric-label">Failed</span>
<strong>{{ failedTasks }}</strong>
</div>
</div>
<section class="panel">
<div class="section-title">
<h2>Device Status</h2>
</div>
<div v-if="!devices.length" class="empty-state">
<MonitorSmartphone :size="34" aria-hidden="true" />
<span>No devices registered. Add one from Config.</span>
</div>
<ul v-else class="device-list">
<li v-for="device in devices" :key="device.id" class="device-row">
<div>
<strong>{{ displayDeviceName(device) }}</strong>
<span>{{ device.id }}</span>
</div>
<div class="row-meta">
<span class="driver-label">{{ device.driver_type }}</span>
<span class="status-pill" :class="device.status">{{ device.status }}</span>
</div>
</li>
</ul>
</section>
</section>
<section v-if="activeView === 'tasks'" class="tasks-layout">
<section class="panel task-browser">
<div class="section-title">
<h2>Task List</h2>
</div>
<div class="filters">
<label>
Device
<select v-model="deviceFilter" @change="applyTaskFilters">
<option value="">All devices</option>
<option v-for="device in devices" :key="device.id" :value="device.id">
{{ displayDeviceName(device) }}
</option>
</select>
</label>
<label>
Status
<select v-model="statusFilter" @change="applyTaskFilters">
<option value="">All statuses</option>
<option v-for="statusName in taskStatuses" :key="statusName" :value="statusName">
{{ statusName }}
</option>
</select>
</label>
</div>
<div v-if="!tasks.length" class="empty-state compact">
<ListChecks :size="30" aria-hidden="true" />
<span>No tasks match the current filters.</span>
</div>
<button
v-for="task in tasks"
v-else
:key="task.id"
class="task-row"
:class="{ selected: selectedTask?.id === task.id }"
type="button"
@click="openTask(task.id)"
>
<span class="task-goal">{{ task.goal }}</span>
<span class="task-meta">
{{ findDeviceName(task.device_id) }} / {{ formatDate(task.created_at) }}
</span>
<span class="status-pill" :class="task.status">{{ task.status }}</span>
</button>
</section>
<section class="panel timeline-panel">
<div class="section-title">
<h2>Task Detail</h2>
<span v-if="selectedTask" class="status-pill" :class="selectedTask.status">
{{ selectedTask.status }}
</span>
</div>
<div v-if="!selectedTask" class="empty-state">
<ListChecks :size="34" aria-hidden="true" />
<span>Select a task to inspect its timeline.</span>
</div>
<div v-else class="task-detail">
<dl class="detail-grid">
<div>
<dt>Goal</dt>
<dd>{{ selectedTask.goal }}</dd>
</div>
<div>
<dt>Device</dt>
<dd>{{ findDeviceName(selectedTask.device_id) }}</dd>
</div>
<div>
<dt>Updated</dt>
<dd>{{ formatDate(selectedTask.updated_at) }}</dd>
</div>
<div v-if="selectedTask.failure_reason">
<dt>Failure</dt>
<dd>{{ selectedTask.failure_reason }}</dd>
</div>
</dl>
<div class="timeline-controls">
<button
class="icon-button"
type="button"
title="Previous step"
:disabled="selectedStepIndex === 0"
@click="previousStep"
>
<ChevronLeft :size="18" aria-hidden="true" />
</button>
<span>{{ timeline.length ? selectedStepIndex + 1 : 0 }} / {{ timeline.length }}</span>
<button
class="icon-button"
type="button"
title="Next step"
:disabled="selectedStepIndex >= timeline.length - 1"
@click="nextStep"
>
<ChevronRight :size="18" aria-hidden="true" />
</button>
</div>
<div v-if="!currentStep" class="empty-state compact">
<span>No timeline records captured.</span>
</div>
<div v-else class="timeline-stage">
<div class="screenshot-frame">
<img
v-if="currentStep.image_base64"
:src="`data:image/png;base64,${currentStep.image_base64}`"
alt="Task step screenshot"
/>
<span v-else>No screenshot</span>
</div>
<div class="step-data">
<div>
<h3>Tool Call</h3>
<pre>{{ prettyJson(currentStep.tool_call) }}</pre>
</div>
<div>
<h3>Result</h3>
<pre>{{ prettyJson(currentStep.result) }}</pre>
</div>
</div>
</div>
</div>
</section>
</section>
<section v-if="activeView === 'config'" class="config-layout">
<section class="panel">
<div class="section-title">
<h2>Device Configuration</h2>
</div>
<form class="form-grid" @submit.prevent="submitDevice">
<label>
Name
<input v-model="deviceForm.name" type="text" placeholder="Desk iPhone" />
</label>
<label>
Driver
<select v-model="deviceForm.driver_type">
<option value="wda">wda</option>
</select>
</label>
<label>
Server URL
<input v-model="deviceForm.server_url" type="url" />
</label>
<label>
UDID
<input v-model="deviceForm.udid" type="text" />
</label>
<label>
WDA local port
<input v-model="deviceForm.wda_local_port" type="number" min="1" />
</label>
<button class="icon-text-button submit-button" type="submit">
<Plus :size="17" aria-hidden="true" />
<span>Add Device</span>
</button>
</form>
<p v-if="deviceError" class="alert error">{{ deviceError }}</p>
<ul class="device-list managed">
<li v-for="device in devices" :key="device.id" class="device-row">
<div>
<strong>{{ displayDeviceName(device) }}</strong>
<span>{{ device.id }}</span>
</div>
<button
class="icon-button danger"
type="button"
title="Remove device"
@click="removeDevice(device)"
>
<Trash2 :size="17" aria-hidden="true" />
</button>
</li>
</ul>
</section>
<section class="panel">
<div class="section-title">
<h2>Runtime Parameters</h2>
</div>
<form class="settings-form" @submit.prevent="saveConfig">
<label>
Max steps
<input v-model.number="maxSteps" type="number" min="1" />
</label>
<button class="icon-text-button" type="submit">
<Save :size="17" aria-hidden="true" />
<span>Save</span>
</button>
</form>
<p v-if="configError" class="alert error">{{ configError }}</p>
<p v-if="configSaved" class="alert success">{{ configSaved }}</p>
</section>
</section>
</main>
</div>
</template>
+97
View File
@@ -0,0 +1,97 @@
import type {
Device,
RegisterDevicePayload,
RuntimeConfig,
TaskRecord,
TimelineRecord,
} from "./types";
const configuredBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined;
export const API_BASE_URL = (configuredBaseUrl || "http://127.0.0.1:8000").replace(
/\/$/,
"",
);
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
...init,
headers: {
Accept: "application/json",
...(init.body ? { "Content-Type": "application/json" } : {}),
...init.headers,
},
});
if (!response.ok) {
let message = `${response.status} ${response.statusText}`;
try {
const payload = (await response.json()) as { detail?: unknown };
if (typeof payload.detail === "string") {
message = payload.detail;
} else if (payload.detail) {
message = JSON.stringify(payload.detail);
}
} catch {
message = await response.text();
}
throw new Error(message);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export function listDevices(): Promise<Device[]> {
return request<Device[]>("/console/devices");
}
export function registerDevice(payload: RegisterDevicePayload): Promise<Device> {
return request<Device>("/console/devices", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function unregisterDevice(deviceId: string): Promise<void> {
return request<void>(`/console/devices/${encodeURIComponent(deviceId)}`, {
method: "DELETE",
});
}
export function listTasks(filters: {
deviceId?: string;
status?: string;
}): Promise<TaskRecord[]> {
const params = new URLSearchParams();
if (filters.deviceId) {
params.set("device_id", filters.deviceId);
}
if (filters.status) {
params.set("status", filters.status);
}
const query = params.toString();
return request<TaskRecord[]>(`/console/tasks${query ? `?${query}` : ""}`);
}
export function getTask(taskId: string): Promise<TaskRecord> {
return request<TaskRecord>(`/console/tasks/${encodeURIComponent(taskId)}`);
}
export function getTimeline(taskId: string): Promise<TimelineRecord[]> {
return request<TimelineRecord[]>(
`/console/tasks/${encodeURIComponent(taskId)}/timeline`,
);
}
export function getConfig(): Promise<RuntimeConfig> {
return request<RuntimeConfig>("/console/config");
}
export function updateConfig(payload: RuntimeConfig): Promise<RuntimeConfig> {
return request<RuntimeConfig>("/console/config", {
method: "PUT",
body: JSON.stringify(payload),
});
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./style.css";
createApp(App).mount("#app");
+552
View File
@@ -0,0 +1,552 @@
:root {
color: #202124;
background: #f6f7f9;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
letter-spacing: 0;
}
button,
input,
select {
font: inherit;
letter-spacing: 0;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.app-shell {
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 24px;
border-right: 1px solid #d9dde5;
background: #ffffff;
padding: 20px 16px;
}
.brand {
display: grid;
grid-template-columns: 32px minmax(0, 1fr);
align-items: center;
gap: 10px;
}
.brand strong,
.brand span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.brand strong {
font-size: 16px;
}
.brand span {
color: #667085;
font-size: 12px;
}
.nav-list {
display: grid;
gap: 8px;
}
.nav-button,
.icon-text-button,
.icon-button,
.task-row {
border: 1px solid #d4d9e2;
border-radius: 8px;
background: #ffffff;
color: #202124;
}
.nav-button,
.icon-text-button {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 38px;
padding: 8px 11px;
}
.nav-button {
width: 100%;
justify-content: flex-start;
}
.nav-button.active {
border-color: #2f7c67;
background: #e7f4ef;
color: #1f5f4e;
}
.workspace {
min-width: 0;
padding: 22px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.topbar h1 {
margin: 0;
font-size: 24px;
line-height: 1.2;
}
.topbar p {
margin: 4px 0 0;
color: #667085;
font-size: 13px;
}
.view-grid,
.config-layout {
display: grid;
gap: 16px;
}
.tasks-layout {
display: grid;
grid-template-columns: minmax(300px, 420px) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.metric,
.panel {
border: 1px solid #d9dde5;
border-radius: 8px;
background: #ffffff;
}
.metric {
padding: 14px;
}
.metric-label {
display: block;
margin-bottom: 8px;
color: #667085;
font-size: 12px;
}
.metric strong {
font-size: 26px;
}
.panel {
padding: 16px;
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.section-title h2 {
margin: 0;
font-size: 16px;
line-height: 1.3;
}
.empty-state {
display: grid;
place-items: center;
gap: 10px;
min-height: 170px;
border: 1px dashed #c8ced8;
border-radius: 8px;
color: #667085;
text-align: center;
padding: 22px;
}
.empty-state.compact {
min-height: 88px;
}
.device-list {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.device-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
min-height: 58px;
border: 1px solid #e2e6ec;
border-radius: 8px;
padding: 10px 12px;
}
.device-row strong,
.device-row span,
.task-goal,
.task-meta {
overflow-wrap: anywhere;
}
.device-row span {
display: block;
color: #667085;
font-size: 12px;
}
.row-meta {
display: flex;
align-items: center;
gap: 8px;
}
.driver-label,
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
border-radius: 999px;
padding: 3px 9px;
font-size: 12px;
font-weight: 650;
}
.driver-label {
background: #eef0f4;
color: #444b56;
}
.status-pill.idle,
.status-pill.completed {
background: #e5f4ec;
color: #1f6b4a;
}
.status-pill.busy,
.status-pill.running {
background: #e8f1fb;
color: #275b8d;
}
.status-pill.created,
.status-pill.cancelled {
background: #f0edf8;
color: #67508f;
}
.status-pill.offline,
.status-pill.failed,
.status-pill.error {
background: #fdebea;
color: #a43c37;
}
.filters,
.form-grid,
.settings-form,
.detail-grid {
display: grid;
gap: 12px;
}
.filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 12px;
}
label {
display: grid;
gap: 6px;
color: #475467;
font-size: 12px;
font-weight: 650;
}
input,
select {
width: 100%;
min-height: 38px;
border: 1px solid #cbd2dc;
border-radius: 8px;
background: #ffffff;
color: #202124;
padding: 8px 10px;
}
.task-browser {
max-height: calc(100vh - 96px);
overflow: auto;
}
.task-row {
display: grid;
width: 100%;
grid-template-columns: minmax(0, 1fr) auto;
gap: 4px 10px;
margin-bottom: 8px;
padding: 11px;
text-align: left;
}
.task-row.selected {
border-color: #2f7c67;
box-shadow: 0 0 0 2px #d9efe8;
}
.task-goal {
font-weight: 650;
}
.task-meta {
color: #667085;
font-size: 12px;
}
.task-row .status-pill {
grid-row: 1 / span 2;
grid-column: 2;
align-self: center;
}
.detail-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0 0 14px;
}
.detail-grid div {
border: 1px solid #e2e6ec;
border-radius: 8px;
padding: 10px;
}
.detail-grid dt {
margin-bottom: 4px;
color: #667085;
font-size: 12px;
}
.detail-grid dd {
margin: 0;
overflow-wrap: anywhere;
}
.timeline-controls {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.icon-button {
display: inline-grid;
place-items: center;
width: 36px;
height: 36px;
padding: 0;
}
.icon-button.danger {
color: #a43c37;
}
.timeline-stage {
display: grid;
grid-template-columns: minmax(220px, 360px) minmax(0, 1fr);
gap: 14px;
}
.screenshot-frame {
display: grid;
place-items: center;
min-height: 360px;
border: 1px solid #d9dde5;
border-radius: 8px;
background: #111827;
color: #e5e7eb;
overflow: hidden;
}
.screenshot-frame img {
display: block;
width: 100%;
height: 100%;
max-height: 520px;
object-fit: contain;
}
.step-data {
display: grid;
gap: 12px;
}
.step-data h3 {
margin: 0 0 6px;
font-size: 14px;
}
pre {
max-height: 248px;
overflow: auto;
margin: 0;
border: 1px solid #e2e6ec;
border-radius: 8px;
background: #f9fafb;
padding: 10px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: end;
}
.submit-button {
align-self: end;
}
.managed {
margin-top: 14px;
}
.settings-form {
grid-template-columns: minmax(140px, 240px) auto;
align-items: end;
justify-content: start;
}
.alert {
margin: 12px 0 0;
border-radius: 8px;
padding: 10px 12px;
font-size: 13px;
}
.alert.error {
background: #fdebea;
color: #a43c37;
}
.alert.success {
background: #e5f4ec;
color: #1f6b4a;
}
.spin {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 980px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
position: sticky;
top: 0;
z-index: 2;
border-right: 0;
border-bottom: 1px solid #d9dde5;
}
.nav-list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.tasks-layout,
.timeline-stage {
grid-template-columns: 1fr;
}
.task-browser {
max-height: none;
}
}
@media (max-width: 680px) {
.workspace {
padding: 14px;
}
.topbar,
.section-title,
.device-row {
align-items: stretch;
}
.topbar,
.device-row,
.settings-form {
flex-direction: column;
grid-template-columns: 1fr;
}
.metrics,
.filters,
.form-grid,
.detail-grid {
grid-template-columns: 1fr;
}
.nav-button {
justify-content: center;
}
.brand {
grid-template-columns: 32px minmax(0, 1fr);
}
}
+48
View File
@@ -0,0 +1,48 @@
export type DeviceStatus = "idle" | "busy" | "offline" | "error";
export interface Device {
id: string;
name: string | null;
status: DeviceStatus;
driver_type: string;
connection_info: Record<string, unknown>;
}
export type TaskStatus =
| "created"
| "running"
| "completed"
| "failed"
| "cancelled";
export interface TaskRecord {
id: string;
goal: string;
device_id: string;
status: TaskStatus;
created_at: string;
updated_at: string;
completed_at: string | null;
failure_reason: string | null;
}
export interface TimelineRecord {
index: number;
scene: Record<string, unknown>;
prompt: string;
tool_call: Record<string, unknown>;
result: Record<string, unknown>;
timestamp: string;
screenshot_path?: string | null;
image_base64?: string;
}
export interface RuntimeConfig {
max_steps: number;
}
export interface RegisterDevicePayload {
driver_type: string;
name?: string | null;
connection_info: Record<string, unknown>;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src/**/*.ts", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
});