feat: add DB persistence, real-time WebSocket, goal preprocessor, and Android companion app

- Add device/session/step DB persistence in server agent loop
- Add goal preprocessor for compound goals (e.g., "open YouTube and search X")
- Add step-level logging to agent loop
- Fix dashboard WebSocket auth (direct DB token lookup instead of auth.api)
- Fix web layout to use locals.session.token instead of cookie
- Add dashboard-ws.svelte.ts WebSocket store with auto-reconnect
- Rewrite devices page with direct DB queries and real-time updates
- Add device detail page with live step display and session history
- Add Android companion app resources, themes, and screen capture consent

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sanju Sivalingam
2026-02-17 20:12:41 +05:30
parent ea707af83e
commit c395f9d83e
101 changed files with 8824 additions and 82 deletions

3
web/src/app.css Normal file
View File

@@ -0,0 +1,3 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';

18
web/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,18 @@
import type { Session, User } from 'better-auth';
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
session: Session;
user: User;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

11
web/src/app.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

7
web/src/demo.spec.ts Normal file
View File

@@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});

17
web/src/hooks.server.ts Normal file
View File

@@ -0,0 +1,17 @@
import { svelteKitHandler } from 'better-auth/svelte-kit';
import { auth } from '$lib/server/auth';
import { building } from '$app/environment';
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const session = await auth.api.getSession({
headers: event.request.headers
});
if (session) {
event.locals.session = session.session;
event.locals.user = session.user;
}
return svelteKitHandler({ event, resolve, auth, building });
};

View File

@@ -1,17 +1,59 @@
import { query, getRequestEvent } from '$app/server';
import { env } from '$env/dynamic/private';
const SERVER_URL = env.SERVER_URL || 'http://localhost:8080';
import { db } from '$lib/server/db';
import { device, agentSession, agentStep } from '$lib/server/db/schema';
import { eq, desc, and } from 'drizzle-orm';
export const listDevices = query(async () => {
const { request } = getRequestEvent();
const { locals } = getRequestEvent();
if (!locals.user) return [];
const res = await fetch(`${SERVER_URL}/devices`, {
headers: {
cookie: request.headers.get('cookie') ?? ''
}
});
const devices = await db
.select()
.from(device)
.where(eq(device.userId, locals.user.id))
.orderBy(desc(device.lastSeen));
if (!res.ok) return [];
return res.json();
return devices.map((d) => ({
deviceId: d.id,
name: d.name,
status: d.status,
deviceInfo: d.deviceInfo,
lastSeen: d.lastSeen?.toISOString() ?? d.createdAt.toISOString()
}));
});
export const listDeviceSessions = query(async (deviceId: string) => {
const { locals } = getRequestEvent();
if (!locals.user) return [];
const sessions = await db
.select()
.from(agentSession)
.where(and(eq(agentSession.deviceId, deviceId), eq(agentSession.userId, locals.user.id)))
.orderBy(desc(agentSession.startedAt))
.limit(50);
return sessions;
});
export const listSessionSteps = query(async (deviceId: string, sessionId: string) => {
const { locals } = getRequestEvent();
if (!locals.user) return [];
// Verify session belongs to user
const sess = await db
.select()
.from(agentSession)
.where(and(eq(agentSession.id, sessionId), eq(agentSession.userId, locals.user.id)))
.limit(1);
if (sess.length === 0) return [];
const steps = await db
.select()
.from(agentStep)
.where(eq(agentStep.sessionId, sessionId))
.orderBy(agentStep.stepNumber);
return steps;
});

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

1
web/src/lib/index.ts Normal file
View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -0,0 +1,12 @@
import { object, string, pipe, minLength, email } from 'valibot';
export const signupSchema = object({
name: pipe(string(), minLength(4)),
email: pipe(string(), minLength(1), email()),
password: pipe(string(), minLength(8))
});
export const loginSchema = object({
email: pipe(string(), minLength(1), email()),
password: pipe(string(), minLength(8))
});

View File

@@ -4,10 +4,12 @@ import { apiKey } from 'better-auth/plugins';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { db } from './db';
import { getRequestEvent } from '$app/server';
import * as schema from './db/schema';
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: 'pg'
provider: 'pg',
schema
}),
plugins: [sveltekitCookies(getRequestEvent), apiKey()],
emailAndPassword: {

View File

@@ -0,0 +1,10 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
import { env } from '$env/dynamic/private';
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
const client = postgres(env.DATABASE_URL);
export const db = drizzle(client, { schema });

View File

@@ -60,6 +60,32 @@ export const verification = pgTable('verification', {
.notNull()
});
export const apikey = pgTable('apikey', {
id: text('id').primaryKey(),
name: text('name'),
start: text('start'),
prefix: text('prefix'),
key: text('key').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
refillInterval: integer('refill_interval'),
refillAmount: integer('refill_amount'),
lastRefillAt: timestamp('last_refill_at'),
enabled: boolean('enabled').default(true),
rateLimitEnabled: boolean('rate_limit_enabled').default(true),
rateLimitTimeWindow: integer('rate_limit_time_window').default(86400000),
rateLimitMax: integer('rate_limit_max').default(10),
requestCount: integer('request_count').default(0),
remaining: integer('remaining'),
lastRequest: timestamp('last_request'),
expiresAt: timestamp('expires_at'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
permissions: text('permissions'),
metadata: text('metadata')
});
export const llmConfig = pgTable('llm_config', {
id: text('id').primaryKey(),
userId: text('user_id')

View File

@@ -0,0 +1,117 @@
import { env } from '$env/dynamic/public';
const WS_URL = env.PUBLIC_SERVER_WS_URL || 'ws://localhost:8080';
export interface DashboardDevice {
deviceId: string;
name: string;
status: 'online' | 'offline';
}
export interface StepEvent {
sessionId: string;
step: number;
action: Record<string, unknown>;
reasoning: string;
}
export interface GoalStartedEvent {
sessionId: string;
goal: string;
deviceId: string;
}
export interface GoalCompletedEvent {
sessionId: string;
success: boolean;
stepsUsed: number;
}
type MessageHandler = (msg: Record<string, unknown>) => void;
class DashboardWebSocket {
private ws: WebSocket | null = null;
private handlers = new Set<MessageHandler>();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private sessionToken: string | null = null;
connected = $state(false);
connect(sessionToken: string) {
this.sessionToken = sessionToken;
this.doConnect();
}
private doConnect() {
if (!this.sessionToken) return;
if (this.ws?.readyState === WebSocket.OPEN) return;
try {
this.ws = new WebSocket(`${WS_URL}/ws/dashboard`);
} catch {
this.scheduleReconnect();
return;
}
this.ws.onopen = () => {
this.ws?.send(JSON.stringify({ type: 'auth', token: this.sessionToken }));
};
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data) as Record<string, unknown>;
if (msg.type === 'auth_ok') {
this.connected = true;
return;
}
if (msg.type === 'auth_error') {
console.error('[DashboardWS] Auth failed:', msg.message);
return;
}
for (const handler of this.handlers) {
handler(msg);
}
} catch {
// ignore parse errors
}
};
this.ws.onclose = () => {
this.connected = false;
this.scheduleReconnect();
};
this.ws.onerror = () => {
this.connected = false;
};
}
private scheduleReconnect() {
if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.doConnect();
}, 3000);
}
subscribe(handler: MessageHandler) {
this.handlers.add(handler);
return () => this.handlers.delete(handler);
}
disconnect() {
this.sessionToken = null;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.onclose = null;
this.ws.close();
this.ws = null;
}
this.connected = false;
}
}
export const dashboardWs = new DashboardWebSocket();

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
{@render children?.()}

View File

@@ -6,6 +6,7 @@ export const load: LayoutServerLoad = async ({ locals }) => {
redirect(307, '/login');
}
return {
user: locals.user
user: locals.user,
sessionToken: locals.session?.token ?? ''
};
};

View File

@@ -1,7 +1,16 @@
<script lang="ts">
import { signout } from '$lib/api/auth.remote';
import { dashboardWs } from '$lib/stores/dashboard-ws.svelte';
import { onMount } from 'svelte';
let { children, data } = $props();
onMount(() => {
if (data.sessionToken) {
dashboardWs.connect(data.sessionToken);
}
return () => dashboardWs.disconnect();
});
</script>
<div class="flex min-h-screen">

View File

@@ -1,7 +1,46 @@
<script lang="ts">
import { listDevices } from '$lib/api/devices.remote';
import { dashboardWs } from '$lib/stores/dashboard-ws.svelte';
import { onMount } from 'svelte';
const devices = await listDevices();
const initialDevices = await listDevices();
let devices = $state(
initialDevices.map((d: Record<string, string>) => ({
deviceId: d.deviceId,
name: d.name,
status: d.status as 'online' | 'offline',
lastSeen: d.lastSeen
}))
);
onMount(() => {
const unsub = dashboardWs.subscribe((msg) => {
if (msg.type === 'device_online') {
const id = msg.deviceId as string;
const name = msg.name as string;
const existing = devices.find((d) => d.deviceId === id);
if (existing) {
existing.status = 'online';
existing.lastSeen = new Date().toISOString();
devices = [...devices];
} else {
devices = [
{ deviceId: id, name, status: 'online', lastSeen: new Date().toISOString() },
...devices
];
}
} else if (msg.type === 'device_offline') {
const id = msg.deviceId as string;
const existing = devices.find((d) => d.deviceId === id);
if (existing) {
existing.status = 'offline';
devices = [...devices];
}
}
});
return unsub;
});
</script>
<h2 class="mb-6 text-2xl font-bold">Devices</h2>
@@ -18,18 +57,22 @@
</div>
{:else}
<div class="space-y-3">
{#each devices as device (device.deviceId)}
{#each devices as d (d.deviceId)}
<a
href="/dashboard/devices/{device.deviceId}"
href="/dashboard/devices/{d.deviceId}"
class="flex items-center justify-between rounded-lg border border-neutral-200 p-4 hover:border-neutral-400"
>
<div>
<p class="font-medium">{device.name}</p>
<p class="font-medium">{d.name}</p>
<p class="text-sm text-neutral-500">
Connected {new Date(device.connectedAt).toLocaleString()}
{d.status === 'online' ? 'Connected now' : `Last seen ${new Date(d.lastSeen).toLocaleString()}`}
</p>
</div>
<span class="inline-block h-2 w-2 rounded-full bg-green-500"></span>
<span
class="inline-block h-2 w-2 rounded-full {d.status === 'online'
? 'bg-green-500'
: 'bg-neutral-300'}"
></span>
</a>
{/each}
</div>

View File

@@ -1,15 +1,35 @@
<script lang="ts">
import { page } from '$app/state';
import { listDeviceSessions } from '$lib/api/devices.remote';
import { dashboardWs } from '$lib/stores/dashboard-ws.svelte';
import { onMount } from 'svelte';
const deviceId = page.params.deviceId!;
// Goal input
let goal = $state('');
let steps = $state<Array<{ step: number; action: string; reasoning: string }>>([]);
let status = $state<'idle' | 'running' | 'completed' | 'failed'>('idle');
// Real-time steps from WebSocket
let currentGoal = $state('');
let steps = $state<Array<{ step: number; action: string; reasoning: string; result?: string }>>([]);
// Session history from DB
interface Session {
id: string;
goal: string;
status: string;
stepsUsed: number;
startedAt: string;
completedAt: string | null;
}
const initialSessions = await listDeviceSessions(deviceId);
let sessions = $state<Session[]>(initialSessions as Session[]);
async function submitGoal() {
if (!goal.trim()) return;
status = 'running';
currentGoal = goal;
steps = [];
const res = await fetch('/api/goals', {
@@ -20,14 +40,61 @@
if (!res.ok) {
status = 'failed';
return;
}
}
onMount(() => {
const unsub = dashboardWs.subscribe((msg) => {
switch (msg.type) {
case 'goal_started': {
if (msg.deviceId === deviceId) {
status = 'running';
currentGoal = msg.goal as string;
steps = [];
}
break;
}
case 'step': {
const action = msg.action as Record<string, unknown>;
const actionStr = action?.action
? `${action.action}${action.coordinates ? `(${(action.coordinates as number[]).join(',')})` : ''}`
: JSON.stringify(action);
steps = [
...steps,
{
step: msg.step as number,
action: actionStr,
reasoning: (msg.reasoning as string) ?? ''
}
];
break;
}
case 'goal_completed': {
const success = msg.success as boolean;
status = success ? 'completed' : 'failed';
// Refresh session history
listDeviceSessions(deviceId).then((s) => {
sessions = s as Session[];
});
break;
}
}
});
return unsub;
});
function formatTime(iso: string) {
return new Date(iso).toLocaleString();
}
</script>
<h2 class="mb-6 text-2xl font-bold">Device: {deviceId.slice(0, 8)}...</h2>
<div class="mb-6 flex items-center gap-3">
<a href="/dashboard/devices" class="text-neutral-400 hover:text-neutral-600">&larr;</a>
<h2 class="text-2xl font-bold">Device: {deviceId.slice(0, 8)}...</h2>
</div>
<div class="max-w-2xl">
<div class="max-w-3xl">
<!-- Goal Input -->
<div class="mb-8 rounded-lg border border-neutral-200 p-6">
<h3 class="mb-3 font-semibold">Send a Goal</h3>
<div class="flex gap-3">
@@ -37,6 +104,7 @@
placeholder="e.g., Open YouTube and search for lofi beats"
class="flex-1 rounded border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none"
disabled={status === 'running'}
onkeydown={(e) => e.key === 'Enter' && submitGoal()}
/>
<button
onclick={submitGoal}
@@ -48,25 +116,73 @@
</div>
</div>
{#if steps.length > 0}
<!-- Live Steps -->
{#if steps.length > 0 || status === 'running'}
<div class="mb-8 rounded-lg border border-neutral-200">
<div class="flex items-center justify-between border-b border-neutral-200 px-6 py-4">
<h3 class="font-semibold">
{currentGoal ? `Goal: ${currentGoal}` : 'Current Run'}
</h3>
{#if status === 'running'}
<span class="flex items-center gap-2 text-sm text-amber-600">
<span class="inline-block h-2 w-2 animate-pulse rounded-full bg-amber-500"></span>
Running
</span>
{:else if status === 'completed'}
<span class="text-sm text-green-600">Completed</span>
{:else if status === 'failed'}
<span class="text-sm text-red-600">Failed</span>
{/if}
</div>
{#if steps.length > 0}
<div class="divide-y divide-neutral-100">
{#each steps as s (s.step)}
<div class="px-6 py-3">
<div class="flex items-baseline gap-2">
<span class="shrink-0 rounded bg-neutral-100 px-1.5 py-0.5 text-xs font-mono text-neutral-500">
{s.step}
</span>
<span class="text-sm font-medium font-mono">{s.action}</span>
</div>
{#if s.reasoning}
<p class="mt-1 text-sm text-neutral-500 pl-8">{s.reasoning}</p>
{/if}
</div>
{/each}
</div>
{:else}
<div class="px-6 py-4 text-sm text-neutral-400">Waiting for first step...</div>
{/if}
</div>
{/if}
<!-- Session History -->
{#if sessions.length > 0}
<div class="rounded-lg border border-neutral-200">
<div class="border-b border-neutral-200 px-6 py-4">
<h3 class="font-semibold">Steps</h3>
<h3 class="font-semibold">Session History</h3>
</div>
<div class="divide-y divide-neutral-100">
{#each steps as step (step.step)}
{#each sessions as sess (sess.id)}
<div class="px-6 py-3">
<p class="text-sm font-medium">Step {step.step}: {step.action}</p>
<p class="text-sm text-neutral-500">{step.reasoning}</p>
<div class="flex items-center justify-between">
<p class="text-sm font-medium">{sess.goal}</p>
<span
class="rounded px-2 py-0.5 text-xs {sess.status === 'completed'
? 'bg-green-50 text-green-700'
: sess.status === 'running'
? 'bg-amber-50 text-amber-700'
: 'bg-red-50 text-red-700'}"
>
{sess.status === 'completed' ? 'Success' : sess.status === 'running' ? 'Running' : 'Failed'}
</span>
</div>
<p class="mt-1 text-xs text-neutral-400">
{formatTime(sess.startedAt)} &middot; {sess.stepsUsed} steps
</p>
</div>
{/each}
</div>
</div>
{/if}
{#if status === 'completed'}
<p class="mt-4 text-sm text-green-600">Goal completed successfully.</p>
{:else if status === 'failed'}
<p class="mt-4 text-sm text-red-600">Goal failed. Please try again.</p>
{/if}
</div>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { login } from '$lib/api/auth.remote';
</script>
<div class="auth">
<h1>Log in</h1>
<form {...login}>
<label>
Email
<input {...login.fields.email.as('email')} />
{#each login.fields.email.issues() ?? [] as issue (issue.message)}
<p class="issue">{issue.message}</p>
{/each}
</label>
<label>
Password
<input {...login.fields.password.as('password')} />
{#each login.fields.password.issues() ?? [] as issue (issue.message)}
<p class="issue">{issue.message}</p>
{/each}
</label>
<p>
Don't have an account?
<a href="/signup">Sign up</a>
</p>
<button type="submit">Login</button>
</form>
</div>

View File

@@ -0,0 +1,13 @@
import { page } from '@vitest/browser/context';
import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
import Page from './+page.svelte';
describe('/+page.svelte', () => {
it('should render h1', async () => {
render(Page);
const heading = page.getByRole('heading', { level: 1 });
await expect.element(heading).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { signup } from '$lib/api/auth.remote';
</script>
<div class="auth">
<h1>Sign up</h1>
<form {...signup}>
<label>
Username
<input {...signup.fields.name.as('text')} />
{#each signup.fields.name.issues() ?? [] as issue (issue.message)}
<p class="issue">{issue.message}</p>
{/each}
</label>
<label>
Email
<input {...signup.fields.email.as('text')} />
{#each signup.fields.email.issues() ?? [] as issue (issue.message)}
<p class="issue">{issue.message}</p>
{/each}
</label>
<label>
Password
<input {...signup.fields.password.as('password')} />
{#each signup.fields.password.issues() ?? [] as issue (issue.message)}
<p class="issue">{issue.message}</p>
{/each}
</label>
<button type="submit">Sign up</button>
</form>
</div>