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:
@@ -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;
|
||||
});
|
||||
|
||||
1
web/src/lib/assets/favicon.svg
Normal file
1
web/src/lib/assets/favicon.svg
Normal 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
1
web/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
12
web/src/lib/schema/auth.ts
Normal file
12
web/src/lib/schema/auth.ts
Normal 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))
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
10
web/src/lib/server/db/index.ts
Normal file
10
web/src/lib/server/db/index.ts
Normal 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 });
|
||||
@@ -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')
|
||||
|
||||
117
web/src/lib/stores/dashboard-ws.svelte.ts
Normal file
117
web/src/lib/stores/dashboard-ws.svelte.ts
Normal 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();
|
||||
Reference in New Issue
Block a user