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:
23
web/.gitignore
vendored
Normal file
23
web/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
1
web/.npmrc
Normal file
1
web/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
10
web/.prettierignore
Normal file
10
web/.prettierignore
Normal file
@@ -0,0 +1,10 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
|
||||
# Miscellaneous
|
||||
/static/
|
||||
/drizzle/
|
||||
16
web/.prettierrc
Normal file
16
web/.prettierrc
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tailwindStylesheet": "./src/app.css"
|
||||
}
|
||||
38
web/README.md
Normal file
38
web/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
11
web/drizzle.config.ts
Normal file
11
web/drizzle.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/lib/server/db/schema.ts',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: { url: process.env.DATABASE_URL },
|
||||
verbose: true,
|
||||
strict: true
|
||||
});
|
||||
25
web/drizzle/0002_fearless_greymalkin.sql
Normal file
25
web/drizzle/0002_fearless_greymalkin.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE "apikey" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text,
|
||||
"start" text,
|
||||
"prefix" text,
|
||||
"key" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"refill_interval" integer,
|
||||
"refill_amount" integer,
|
||||
"last_refill_at" timestamp,
|
||||
"enabled" boolean DEFAULT true,
|
||||
"rate_limit_enabled" boolean DEFAULT true,
|
||||
"rate_limit_time_window" integer DEFAULT 86400000,
|
||||
"rate_limit_max" integer DEFAULT 10,
|
||||
"request_count" integer DEFAULT 0,
|
||||
"remaining" integer,
|
||||
"last_request" timestamp,
|
||||
"expires_at" timestamp,
|
||||
"created_at" timestamp NOT NULL,
|
||||
"updated_at" timestamp NOT NULL,
|
||||
"permissions" text,
|
||||
"metadata" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "apikey" ADD CONSTRAINT "apikey_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
793
web/drizzle/meta/0002_snapshot.json
Normal file
793
web/drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,793 @@
|
||||
{
|
||||
"id": "14e39517-17ec-4e0a-a053-4707f3269d32",
|
||||
"prevId": "94b88cb8-1ed6-4475-b2d6-d1bf80b7b669",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.account": {
|
||||
"name": "account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"account_user_id_user_id_fk": {
|
||||
"name": "account_user_id_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.agent_session": {
|
||||
"name": "agent_session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"device_id": {
|
||||
"name": "device_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"goal": {
|
||||
"name": "goal",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'running'"
|
||||
},
|
||||
"steps_used": {
|
||||
"name": "steps_used",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"agent_session_user_id_user_id_fk": {
|
||||
"name": "agent_session_user_id_user_id_fk",
|
||||
"tableFrom": "agent_session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"agent_session_device_id_device_id_fk": {
|
||||
"name": "agent_session_device_id_device_id_fk",
|
||||
"tableFrom": "agent_session",
|
||||
"tableTo": "device",
|
||||
"columnsFrom": [
|
||||
"device_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.agent_step": {
|
||||
"name": "agent_step",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"step_number": {
|
||||
"name": "step_number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"screen_hash": {
|
||||
"name": "screen_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"reasoning": {
|
||||
"name": "reasoning",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"result": {
|
||||
"name": "result",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"timestamp": {
|
||||
"name": "timestamp",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"agent_step_session_id_agent_session_id_fk": {
|
||||
"name": "agent_step_session_id_agent_session_id_fk",
|
||||
"tableFrom": "agent_step",
|
||||
"tableTo": "agent_session",
|
||||
"columnsFrom": [
|
||||
"session_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.apikey": {
|
||||
"name": "apikey",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"start": {
|
||||
"name": "start",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"prefix": {
|
||||
"name": "prefix",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"refill_interval": {
|
||||
"name": "refill_interval",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refill_amount": {
|
||||
"name": "refill_amount",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"last_refill_at": {
|
||||
"name": "last_refill_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"rate_limit_enabled": {
|
||||
"name": "rate_limit_enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"rate_limit_time_window": {
|
||||
"name": "rate_limit_time_window",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 86400000
|
||||
},
|
||||
"rate_limit_max": {
|
||||
"name": "rate_limit_max",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 10
|
||||
},
|
||||
"request_count": {
|
||||
"name": "request_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"remaining": {
|
||||
"name": "remaining",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"last_request": {
|
||||
"name": "last_request",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"permissions": {
|
||||
"name": "permissions",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"apikey_user_id_user_id_fk": {
|
||||
"name": "apikey_user_id_user_id_fk",
|
||||
"tableFrom": "apikey",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.device": {
|
||||
"name": "device",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"last_seen": {
|
||||
"name": "last_seen",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'offline'"
|
||||
},
|
||||
"device_info": {
|
||||
"name": "device_info",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"device_user_id_user_id_fk": {
|
||||
"name": "device_user_id_user_id_fk",
|
||||
"tableFrom": "device",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.llm_config": {
|
||||
"name": "llm_config",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"api_key": {
|
||||
"name": "api_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"llm_config_user_id_user_id_fk": {
|
||||
"name": "llm_config_user_id_user_id_fk",
|
||||
"tableFrom": "llm_config",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"session_token_unique": {
|
||||
"name": "session_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.verification": {
|
||||
"name": "verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,13 @@
|
||||
"when": 1771317679056,
|
||||
"tag": "0001_opposite_klaw",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1771326794177,
|
||||
"tag": "0002_fearless_greymalkin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
41
web/eslint.config.js
Normal file
41
web/eslint.config.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { includeIgnoreFile } from '@eslint/compat';
|
||||
import js from '@eslint/js';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import globals from 'globals';
|
||||
import ts from 'typescript-eslint';
|
||||
import svelteConfig from './svelte.config.js';
|
||||
|
||||
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||
|
||||
export default defineConfig(
|
||||
includeIgnoreFile(gitignorePath),
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs.recommended,
|
||||
prettier,
|
||||
...svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node }
|
||||
},
|
||||
rules: {
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
'no-undef': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
extraFileExtensions: ['.svelte'],
|
||||
parser: ts.parser,
|
||||
svelteConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
3
web/src/app.css
Normal file
3
web/src/app.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
||||
18
web/src/app.d.ts
vendored
Normal file
18
web/src/app.d.ts
vendored
Normal 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
11
web/src/app.html
Normal 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
7
web/src/demo.spec.ts
Normal 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
17
web/src/hooks.server.ts
Normal 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 });
|
||||
};
|
||||
@@ -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();
|
||||
12
web/src/routes/+layout.svelte
Normal file
12
web/src/routes/+layout.svelte
Normal 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?.()}
|
||||
@@ -6,6 +6,7 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
redirect(307, '/login');
|
||||
}
|
||||
return {
|
||||
user: locals.user
|
||||
user: locals.user,
|
||||
sessionToken: locals.session?.token ?? ''
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">←</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)} · {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>
|
||||
|
||||
32
web/src/routes/login/+page.svelte
Normal file
32
web/src/routes/login/+page.svelte
Normal 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>
|
||||
13
web/src/routes/page.svelte.spec.ts
Normal file
13
web/src/routes/page.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
35
web/src/routes/signup/+page.svelte
Normal file
35
web/src/routes/signup/+page.svelte
Normal 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>
|
||||
3
web/static/robots.txt
Normal file
3
web/static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
19
web/tsconfig.json
Normal file
19
web/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
37
web/vite.config.ts
Normal file
37
web/vite.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import devtoolsJson from 'vite-plugin-devtools-json';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit(), devtoolsJson()],
|
||||
test: {
|
||||
expect: { requireAssertions: true },
|
||||
projects: [
|
||||
{
|
||||
extends: './vite.config.ts',
|
||||
test: {
|
||||
name: 'client',
|
||||
environment: 'browser',
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: 'playwright',
|
||||
instances: [{ browser: 'chromium' }]
|
||||
},
|
||||
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
|
||||
exclude: ['src/lib/server/**'],
|
||||
setupFiles: ['./vitest-setup-client.ts']
|
||||
}
|
||||
},
|
||||
{
|
||||
extends: './vite.config.ts',
|
||||
test: {
|
||||
name: 'server',
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
2
web/vitest-setup-client.ts
Normal file
2
web/vitest-setup-client.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="@vitest/browser/matchers" />
|
||||
/// <reference types="@vitest/browser/providers/playwright" />
|
||||
Reference in New Issue
Block a user