Add crimata-agent and redesign agentic OS UI
- crimata-agent (TypeScript, port 7702): WebSocket server + HTTP /events
endpoint, Claude tool-use loop (render/remove/call_api/show_cursor_response),
session.start fires on first browser connection to seed the canvas
- OS UI redesigned: light grey canvas (#e8e8e8), no dock bar, cursor dot,
Cmd+Space input bubble above cursor, elements placed by agent
- Canvas elements: app_icon (floating icons with running dot), window (no-chrome
content cards), cursor_response (text + action pills)
- Component system: HTML fragments fetched from /components/{app}/{name}.html,
inline scripts re-executed after injection
- contacts/crimata.json: added icon, defaultComponent, components[], api[]
- dock: apps.h/c/main.c extended to parse and serve components + api from
crimata.json; BUF_SIZE increased for richer JSON output
- ui/components/contacts/list.html + card.html: first component pair
- provision.sh: installs crimata-agent, adds WebSocket + /events + /components
nginx locations, fixes /auth/ prefix stripping
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
44f3192b78
commit
31b5e321a1
19 changed files with 1030 additions and 305 deletions
20
agent/package.json
Normal file
20
agent/package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "crimata-agent",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"express": "^4.18.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/ws": "^8.5.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
155
agent/src/agent.ts
Normal file
155
agent/src/agent.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import WebSocket from 'ws'
|
||||
import { Canvas } from './canvas'
|
||||
import { toolDefinitions } from './tools'
|
||||
import { BusEvent, CanvasElement, InstalledApp, WSMessage } from './types'
|
||||
|
||||
const SERVICE_PORTS: Record<string, number> = {
|
||||
auth: 7700,
|
||||
dock: 7701,
|
||||
agent: 7702,
|
||||
}
|
||||
|
||||
async function fetchApps(): Promise<InstalledApp[]> {
|
||||
try {
|
||||
const res = await fetch('http://localhost:7701/apps')
|
||||
return await res.json() as InstalledApp[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function appPort(appId: string, apps: InstalledApp[]): number {
|
||||
if (SERVICE_PORTS[appId]) return SERVICE_PORTS[appId]
|
||||
return apps.find(a => a.id === appId)?.port ?? 3000
|
||||
}
|
||||
|
||||
function buildSystemPrompt(apps: InstalledApp[]): string {
|
||||
const appList = apps.map(a => {
|
||||
const components = a.components?.length ? a.components.join(', ') : 'none'
|
||||
const apiList = a.api?.map(e => `${e.method} ${e.endpoint} — ${e.description}`).join('; ') ?? ''
|
||||
return ` - ${a.id} (${a.name}, port ${a.port}): components=[${components}] api=[${apiList}]`
|
||||
}).join('\n')
|
||||
|
||||
return `You are the Crimata desktop agent — an agentic window manager for a personal Linux instance.
|
||||
You control a spatial canvas UI by calling tools. Think of yourself as both the OS shell and a helpful assistant.
|
||||
|
||||
Installed apps:
|
||||
${appList || ' (none)'}
|
||||
|
||||
Component naming: "app.component", e.g. "contacts.list", "contacts.card"
|
||||
App icons use type "app_icon", windows use type "window".
|
||||
|
||||
Layout rules:
|
||||
- Place app icons spread naturally across the canvas (not all in one corner)
|
||||
- Open windows adjacent to their app icon
|
||||
- Keep the layout clean and uncluttered
|
||||
|
||||
Behaviour:
|
||||
- On session.start: render all installed app icons on the canvas, then open each app's defaultComponent
|
||||
- On app_icon.clicked: render the app's default window next to the icon
|
||||
- On cursor.input: interpret naturally — open apps, answer questions, rearrange canvas, call APIs
|
||||
- On button.clicked or api events: decide whether to respond or stay silent
|
||||
- Only respond when you have something useful to do — silence is fine`
|
||||
}
|
||||
|
||||
export async function handleEvent(
|
||||
event: BusEvent,
|
||||
canvas: Canvas,
|
||||
ws: WebSocket | null,
|
||||
apiKey: string,
|
||||
): Promise<void> {
|
||||
const apps = await fetchApps()
|
||||
const model = process.env.CLAUDE_MODEL || 'claude-haiku-4-5-20251001'
|
||||
|
||||
const client = new Anthropic({ apiKey })
|
||||
|
||||
const canvasJson = JSON.stringify(canvas.getState(), null, 2)
|
||||
const userMsg = `Canvas:\n${canvasJson}\n\nEvent: ${JSON.stringify(event)}`
|
||||
|
||||
const messages: Anthropic.MessageParam[] = [
|
||||
{ role: 'user', content: userMsg },
|
||||
]
|
||||
|
||||
// Agentic loop — keep going until Claude stops calling tools
|
||||
while (true) {
|
||||
const response = await client.messages.create({
|
||||
model,
|
||||
max_tokens: 2048,
|
||||
system: buildSystemPrompt(apps),
|
||||
tools: toolDefinitions,
|
||||
messages,
|
||||
})
|
||||
|
||||
const toolUses = response.content.filter(b => b.type === 'tool_use')
|
||||
if (toolUses.length === 0) break
|
||||
|
||||
const toolResults: Anthropic.ToolResultBlockParam[] = []
|
||||
|
||||
for (const block of response.content) {
|
||||
if (block.type !== 'tool_use') continue
|
||||
|
||||
const input = block.input as Record<string, unknown>
|
||||
let result: unknown = { ok: true }
|
||||
|
||||
switch (block.name) {
|
||||
case 'render': {
|
||||
const el = input as unknown as CanvasElement
|
||||
canvas.render(el)
|
||||
send(ws, { op: 'render', element: el })
|
||||
break
|
||||
}
|
||||
|
||||
case 'remove': {
|
||||
const id = input.id as string
|
||||
canvas.remove(id)
|
||||
send(ws, { op: 'remove', id })
|
||||
break
|
||||
}
|
||||
|
||||
case 'call_api': {
|
||||
try {
|
||||
const port = appPort(input.app as string, apps)
|
||||
const url = `http://localhost:${port}${input.endpoint as string}`
|
||||
const res = await fetch(url, {
|
||||
method: input.method as string,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: input.body ? JSON.stringify(input.body) : undefined,
|
||||
})
|
||||
result = await res.json()
|
||||
} catch (e: unknown) {
|
||||
result = { error: String(e) }
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'show_cursor_response': {
|
||||
send(ws, {
|
||||
op: 'cursor_response',
|
||||
text: input.text as string | undefined,
|
||||
pills: input.pills as string[] | undefined,
|
||||
position: input.position as { x: number; y: number },
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
toolResults.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: block.id,
|
||||
content: JSON.stringify(result),
|
||||
})
|
||||
}
|
||||
|
||||
messages.push({ role: 'assistant', content: response.content })
|
||||
messages.push({ role: 'user', content: toolResults })
|
||||
|
||||
if (response.stop_reason !== 'tool_use') break
|
||||
}
|
||||
}
|
||||
|
||||
function send(ws: WebSocket | null, msg: WSMessage): void {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(msg))
|
||||
}
|
||||
}
|
||||
26
agent/src/canvas.ts
Normal file
26
agent/src/canvas.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { CanvasElement } from './types'
|
||||
|
||||
export class Canvas {
|
||||
private elements = new Map<string, CanvasElement>()
|
||||
|
||||
render(element: CanvasElement): void {
|
||||
this.elements.set(element.id, element)
|
||||
}
|
||||
|
||||
remove(id: string): void {
|
||||
this.elements.delete(id)
|
||||
}
|
||||
|
||||
update(id: string, patch: Partial<CanvasElement>): void {
|
||||
const el = this.elements.get(id)
|
||||
if (el) this.elements.set(id, { ...el, ...patch })
|
||||
}
|
||||
|
||||
getState(): CanvasElement[] {
|
||||
return Array.from(this.elements.values())
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.elements.clear()
|
||||
}
|
||||
}
|
||||
60
agent/src/index.ts
Normal file
60
agent/src/index.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import express from 'express'
|
||||
import { WebSocketServer, WebSocket } from 'ws'
|
||||
import { createServer } from 'http'
|
||||
import { Canvas } from './canvas'
|
||||
import { handleEvent } from './agent'
|
||||
import { BusEvent } from './types'
|
||||
|
||||
const PORT = 7702
|
||||
const API_KEY = process.env.ANTHROPIC_API_KEY ?? ''
|
||||
|
||||
const app = express()
|
||||
const server = createServer(app)
|
||||
const wss = new WebSocketServer({ server, path: '/ws' })
|
||||
const canvas = new Canvas()
|
||||
|
||||
let activeWs: WebSocket | null = null
|
||||
let sessionStarted = false
|
||||
|
||||
app.use(express.json())
|
||||
|
||||
app.get('/health', (_req, res) => res.json({ status: 'ok' }))
|
||||
|
||||
// POST /events — server-side services emit events to the agent
|
||||
app.post('/events', async (req, res) => {
|
||||
const event: BusEvent = req.body
|
||||
res.json({ ok: true })
|
||||
handleEvent(event, canvas, activeWs, API_KEY).catch(console.error)
|
||||
})
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
activeWs = ws
|
||||
console.log('browser connected')
|
||||
|
||||
// Send current canvas state immediately
|
||||
ws.send(JSON.stringify({ op: 'canvas', elements: canvas.getState() }))
|
||||
|
||||
// Fire session.start on first browser connection to initialise canvas
|
||||
if (!sessionStarted) {
|
||||
sessionStarted = true
|
||||
handleEvent({ type: 'session.start' }, canvas, ws, API_KEY).catch(console.error)
|
||||
}
|
||||
|
||||
ws.on('message', async (raw) => {
|
||||
try {
|
||||
const event: BusEvent = JSON.parse(raw.toString())
|
||||
await handleEvent(event, canvas, ws, API_KEY)
|
||||
} catch (e) {
|
||||
console.error('ws message error:', e)
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('close', () => {
|
||||
if (activeWs === ws) activeWs = null
|
||||
console.log('browser disconnected')
|
||||
})
|
||||
})
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`crimata-agent listening on :${PORT}`)
|
||||
})
|
||||
66
agent/src/tools.ts
Normal file
66
agent/src/tools.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import Anthropic from '@anthropic-ai/sdk'
|
||||
|
||||
export const toolDefinitions: Anthropic.Tool[] = [
|
||||
{
|
||||
name: 'render',
|
||||
description: 'Place or update a component on the canvas. Use stable IDs so re-renders update in place.',
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Unique stable ID, e.g. "contacts-list", "mail-inbox"' },
|
||||
type: { type: 'string', enum: ['app_icon', 'window'] },
|
||||
component: { type: 'string', description: 'App component, e.g. "contacts.list", "contacts.card"' },
|
||||
props: { type: 'object', description: 'Data to pass to the component' },
|
||||
x: { type: 'number', description: 'Canvas X position in pixels' },
|
||||
y: { type: 'number', description: 'Canvas Y position in pixels' },
|
||||
running: { type: 'boolean', description: 'Whether the app is running (for app_icon type)' },
|
||||
},
|
||||
required: ['id', 'type', 'component', 'x', 'y'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'remove',
|
||||
description: 'Remove a component from the canvas.',
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'call_api',
|
||||
description: 'Call a local app or service API on this instance. Returns the JSON response.',
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
app: { type: 'string', description: 'App ID or service: "contacts", "auth", "dock"' },
|
||||
endpoint: { type: 'string', description: 'Path, e.g. "/contacts" or "/contacts/123"' },
|
||||
method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] },
|
||||
body: { type: 'object', description: 'Request body for POST/PUT/PATCH' },
|
||||
},
|
||||
required: ['app', 'endpoint', 'method'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'show_cursor_response',
|
||||
description: 'Show a text response or action pills near the cursor. Use for replying to user input.',
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
text: { type: 'string', description: 'Text to display' },
|
||||
pills: { type: 'array', items: { type: 'string' }, description: 'Action buttons to show' },
|
||||
position: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
x: { type: 'number' },
|
||||
y: { type: 'number' },
|
||||
},
|
||||
required: ['x', 'y'],
|
||||
},
|
||||
},
|
||||
required: ['position'],
|
||||
},
|
||||
},
|
||||
]
|
||||
42
agent/src/types.ts
Normal file
42
agent/src/types.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
export type CanvasElement = {
|
||||
id: string
|
||||
type: 'app_icon' | 'window' | 'cursor_response'
|
||||
component: string // e.g. "contacts", "contacts.list", "generic.form"
|
||||
props?: Record<string, unknown>
|
||||
x: number
|
||||
y: number
|
||||
running?: boolean
|
||||
}
|
||||
|
||||
export type BusEvent = {
|
||||
type: string
|
||||
data?: Record<string, unknown>
|
||||
position?: { x: number; y: number }
|
||||
text?: string
|
||||
}
|
||||
|
||||
export type InstalledApp = {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
port: number
|
||||
running: boolean
|
||||
defaultComponent: string
|
||||
components: string[]
|
||||
api: ApiEndpoint[]
|
||||
}
|
||||
|
||||
export type ApiEndpoint = {
|
||||
name: string
|
||||
description: string
|
||||
method: string
|
||||
endpoint: string
|
||||
params?: Record<string, string>
|
||||
body?: Record<string, string>
|
||||
}
|
||||
|
||||
export type WSMessage =
|
||||
| { op: 'render'; element: CanvasElement }
|
||||
| { op: 'remove'; id: string }
|
||||
| { op: 'canvas'; elements: CanvasElement[] }
|
||||
| { op: 'cursor_response'; text?: string; pills?: string[]; position: { x: number; y: number } }
|
||||
14
agent/tsconfig.json
Normal file
14
agent/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
|
@ -1,7 +1,54 @@
|
|||
{
|
||||
"id": "contacts",
|
||||
"name": "Contacts",
|
||||
"icon": "👤",
|
||||
"port": 3001,
|
||||
"defaultComponent": "contacts.list",
|
||||
"components": ["contacts.list", "contacts.card"],
|
||||
"db": true,
|
||||
"migrate": "npm run migrate"
|
||||
"migrate": "npm run migrate",
|
||||
"api": [
|
||||
{
|
||||
"name": "list_contacts",
|
||||
"description": "Get all contacts ordered by name",
|
||||
"method": "GET",
|
||||
"endpoint": "/contacts"
|
||||
},
|
||||
{
|
||||
"name": "get_contact",
|
||||
"description": "Get a single contact by ID",
|
||||
"method": "GET",
|
||||
"endpoint": "/contacts/:id"
|
||||
},
|
||||
{
|
||||
"name": "create_contact",
|
||||
"description": "Create a new contact (name and domain required)",
|
||||
"method": "POST",
|
||||
"endpoint": "/contacts",
|
||||
"body": {
|
||||
"name": "string",
|
||||
"domain": "string",
|
||||
"avatar_url": "string?",
|
||||
"notes": "string?"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "update_contact",
|
||||
"description": "Partially update a contact by ID",
|
||||
"method": "PATCH",
|
||||
"endpoint": "/contacts/:id",
|
||||
"body": {
|
||||
"name": "string?",
|
||||
"domain": "string?",
|
||||
"avatar_url": "string?",
|
||||
"notes": "string?"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "delete_contact",
|
||||
"description": "Delete a contact by ID",
|
||||
"method": "DELETE",
|
||||
"endpoint": "/contacts/:id"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@
|
|||
#include "apps.h"
|
||||
|
||||
#define MANIFEST_GLOB "/usr/lib/crimata-*/crimata.json"
|
||||
#define MAX_FILE_SIZE 4096
|
||||
#define MAX_FILE_SIZE 16384
|
||||
|
||||
/* Pull a string value out of a flat JSON object (same approach as auth) */
|
||||
/* Pull a string value out of a flat JSON object */
|
||||
static int json_str(const char *json, const char *key, char *out, size_t out_len)
|
||||
{
|
||||
char search[128];
|
||||
|
|
@ -42,6 +42,51 @@ static int json_int(const char *json, const char *key, int *out)
|
|||
return 1;
|
||||
}
|
||||
|
||||
/* Extract a raw JSON array or object value (string-aware bracket matching) */
|
||||
static int json_raw_value(const char *json, const char *key, char *out, size_t out_len)
|
||||
{
|
||||
char search[128];
|
||||
snprintf(search, sizeof(search), "\"%s\"", key);
|
||||
const char *pos = strstr(json, search);
|
||||
if (!pos) return 0;
|
||||
|
||||
pos += strlen(search);
|
||||
while (*pos == ' ' || *pos == ':') pos++;
|
||||
if (*pos != '[' && *pos != '{') return 0;
|
||||
|
||||
char open = *pos;
|
||||
char close = (open == '[') ? ']' : '}';
|
||||
int depth = 0;
|
||||
int in_str = 0;
|
||||
size_t i = 0;
|
||||
|
||||
while (*pos && i < out_len - 1) {
|
||||
char c = *pos;
|
||||
out[i++] = c;
|
||||
|
||||
if (in_str) {
|
||||
if (c == '\\' && *(pos + 1)) {
|
||||
/* escaped char — copy next byte too */
|
||||
pos++;
|
||||
if (i < out_len - 1) out[i++] = *pos;
|
||||
} else if (c == '"') {
|
||||
in_str = 0;
|
||||
}
|
||||
} else {
|
||||
if (c == '"') in_str = 1;
|
||||
else if (c == open) depth++;
|
||||
else if (c == close) {
|
||||
depth--;
|
||||
if (depth == 0) { pos++; break; }
|
||||
}
|
||||
}
|
||||
pos++;
|
||||
}
|
||||
|
||||
out[i] = '\0';
|
||||
return (depth == 0 && i > 0) ? 1 : 0;
|
||||
}
|
||||
|
||||
static int parse_manifest(const char *path, app_t *app)
|
||||
{
|
||||
FILE *f = fopen(path, "r");
|
||||
|
|
@ -54,10 +99,14 @@ static int parse_manifest(const char *path, app_t *app)
|
|||
|
||||
if (!json_str(buf, "id", app->id, sizeof(app->id))) return 0;
|
||||
if (!json_str(buf, "name", app->name, sizeof(app->name))) return 0;
|
||||
json_str(buf, "icon", app->icon, sizeof(app->icon));
|
||||
json_int(buf, "port", &app->port);
|
||||
app->running = 0;
|
||||
|
||||
json_str(buf, "icon", app->icon, sizeof(app->icon));
|
||||
json_str(buf, "defaultComponent", app->default_component, sizeof(app->default_component));
|
||||
json_int(buf, "port", &app->port);
|
||||
json_raw_value(buf, "components", app->components_json, sizeof(app->components_json));
|
||||
json_raw_value(buf, "api", app->api_json, sizeof(app->api_json));
|
||||
|
||||
app->running = 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,14 +2,17 @@
|
|||
#define APPS_H
|
||||
|
||||
#define MAX_APPS 32
|
||||
#define MAX_STR 128
|
||||
#define MAX_STR 256
|
||||
|
||||
typedef struct {
|
||||
char id[MAX_STR];
|
||||
char name[MAX_STR];
|
||||
char icon[MAX_STR];
|
||||
int port;
|
||||
int running; /* populated at request time via systemd check */
|
||||
int running; /* populated at request time via systemd check */
|
||||
char default_component[MAX_STR];
|
||||
char components_json[2048]; /* raw JSON array, e.g. ["contacts.list","contacts.card"] */
|
||||
char api_json[8192]; /* raw JSON array of ApiEndpoint objects */
|
||||
} app_t;
|
||||
|
||||
/* Scan /usr/lib/crimata-*/crimata.json — returns app count */
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
#include "systemd.h"
|
||||
|
||||
#define PORT 7701
|
||||
#define BUF_SIZE 16384
|
||||
#define BUF_SIZE 65536
|
||||
|
||||
/* ── JSON response helper ─────────────────────────────────────────────────── */
|
||||
|
||||
|
|
@ -42,12 +42,29 @@ static enum MHD_Result handle_list(struct MHD_Connection *conn)
|
|||
pos += snprintf(buf + pos, sizeof(buf) - pos, "[");
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
const char *components = apps[i].components_json[0] ? apps[i].components_json : "[]";
|
||||
const char *api = apps[i].api_json[0] ? apps[i].api_json : "[]";
|
||||
|
||||
pos += snprintf(buf + pos, sizeof(buf) - pos,
|
||||
"%s{\"id\":\"%s\",\"name\":\"%s\",\"icon\":\"%s\","
|
||||
"\"port\":%d,\"running\":%s}",
|
||||
"%s{"
|
||||
"\"id\":\"%s\","
|
||||
"\"name\":\"%s\","
|
||||
"\"icon\":\"%s\","
|
||||
"\"port\":%d,"
|
||||
"\"running\":%s,"
|
||||
"\"defaultComponent\":\"%s\","
|
||||
"\"components\":%s,"
|
||||
"\"api\":%s"
|
||||
"}",
|
||||
i > 0 ? "," : "",
|
||||
apps[i].id, apps[i].name, apps[i].icon,
|
||||
apps[i].port, apps[i].running ? "true" : "false");
|
||||
apps[i].id,
|
||||
apps[i].name,
|
||||
apps[i].icon,
|
||||
apps[i].port,
|
||||
apps[i].running ? "true" : "false",
|
||||
apps[i].default_component,
|
||||
components,
|
||||
api);
|
||||
}
|
||||
|
||||
pos += snprintf(buf + pos, sizeof(buf) - pos, "]");
|
||||
|
|
@ -60,7 +77,6 @@ static enum MHD_Result handle_list(struct MHD_Connection *conn)
|
|||
static enum MHD_Result handle_action(struct MHD_Connection *conn,
|
||||
const char *app_id, int start)
|
||||
{
|
||||
/* Verify app exists */
|
||||
app_t apps[MAX_APPS];
|
||||
int count = apps_scan(apps);
|
||||
int found = 0;
|
||||
|
|
@ -108,10 +124,10 @@ static enum MHD_Result handler(void *cls,
|
|||
char app_id[MAX_STR];
|
||||
|
||||
if (strcmp(method, "POST") == 0) {
|
||||
if (sscanf(url, "/apps/%127[^/]/start", app_id) == 1)
|
||||
if (sscanf(url, "/apps/%255[^/]/start", app_id) == 1)
|
||||
return handle_action(conn, app_id, 1);
|
||||
|
||||
if (sscanf(url, "/apps/%127[^/]/stop", app_id) == 1)
|
||||
if (sscanf(url, "/apps/%255[^/]/stop", app_id) == 1)
|
||||
return handle_action(conn, app_id, 0);
|
||||
}
|
||||
|
||||
|
|
|
|||
41
ui/components/contacts/card.html
Normal file
41
ui/components/contacts/card.html
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<div class="contact-card">
|
||||
<div class="cc-avatar" id="cc-avatar">?</div>
|
||||
<h2 id="cc-name">—</h2>
|
||||
<div class="cc-fields" id="cc-fields"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.contact-card { padding: 28px 24px; font-family: -apple-system, sans-serif; text-align: center; width: 280px; }
|
||||
.cc-avatar { width: 64px; height: 64px; background: #e8e8e8; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 26px; font-weight: 600; color: #666; margin: 0 auto 14px; }
|
||||
.contact-card h2 { font-size: 18px; font-weight: 600; color: #1a1a1a; margin-bottom: 14px; }
|
||||
.cc-fields { text-align: left; display: flex; flex-direction: column; gap: 8px; }
|
||||
.cc-field { font-size: 13px; color: #888; }
|
||||
.cc-field span { font-weight: 500; color: #333; }
|
||||
.cc-empty { color: #aaa; font-size: 13px; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const props = window.__componentProps || {}
|
||||
const id = props.id || props.contactId
|
||||
if (!id) {
|
||||
document.getElementById('cc-name').textContent = 'No contact selected'
|
||||
return
|
||||
}
|
||||
fetch(`/apps/contacts/${id}`)
|
||||
.then(r => r.ok ? r.json() : Promise.reject())
|
||||
.then(c => {
|
||||
document.getElementById('cc-avatar').textContent = (c.name || '?')[0].toUpperCase()
|
||||
document.getElementById('cc-name').textContent = c.name || '—'
|
||||
const fields = document.getElementById('cc-fields')
|
||||
fields.innerHTML = [
|
||||
c.domain ? `<div class="cc-field">Domain: <span>${c.domain}</span></div>` : '',
|
||||
c.notes ? `<div class="cc-field">Notes: <span>${c.notes}</span></div>` : '',
|
||||
].join('')
|
||||
if (!fields.innerHTML) fields.innerHTML = '<div class="cc-empty">No details.</div>'
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('cc-name').textContent = 'Contact not found'
|
||||
})
|
||||
})()
|
||||
</script>
|
||||
47
ui/components/contacts/list.html
Normal file
47
ui/components/contacts/list.html
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<div class="contacts-list">
|
||||
<div class="cl-header">
|
||||
<h2>Contacts</h2>
|
||||
<button data-event="new_contact">+ New</button>
|
||||
</div>
|
||||
<div class="cl-items" id="cl-items">
|
||||
<div class="cl-empty">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.contacts-list { padding: 20px 24px; font-family: -apple-system, sans-serif; width: 360px; }
|
||||
.cl-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.cl-header h2 { font-size: 17px; font-weight: 600; color: #1a1a1a; }
|
||||
.cl-header button { background: #1a1a1a; color: white; border: none; border-radius: 8px; padding: 6px 14px; font-size: 12px; font-weight: 500; cursor: pointer; }
|
||||
.cl-header button:hover { background: #333; }
|
||||
.contact-row { display: flex; align-items: center; gap: 12px; padding: 9px 10px; border-radius: 10px; cursor: pointer; transition: background 0.1s; }
|
||||
.contact-row:hover { background: #f5f5f5; }
|
||||
.contact-avatar { width: 34px; height: 34px; background: #e8e8e8; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 600; color: #666; flex-shrink: 0; }
|
||||
.contact-name { font-size: 14px; font-weight: 500; color: #1a1a1a; }
|
||||
.contact-domain { font-size: 12px; color: #999; }
|
||||
.cl-empty { color: #aaa; font-size: 13px; padding: 12px 0; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const list = document.getElementById('cl-items')
|
||||
fetch('/apps/contacts/')
|
||||
.then(r => r.json())
|
||||
.then(contacts => {
|
||||
if (!contacts.length) {
|
||||
list.innerHTML = '<div class="cl-empty">No contacts yet.</div>'
|
||||
return
|
||||
}
|
||||
list.innerHTML = contacts.map(c => `
|
||||
<div class="contact-row" data-id="${c.id}">
|
||||
<div class="contact-avatar">${(c.name || '?')[0].toUpperCase()}</div>
|
||||
<div>
|
||||
<div class="contact-name">${c.name || '—'}</div>
|
||||
<div class="contact-domain">${c.domain || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')
|
||||
})
|
||||
.catch(() => { list.innerHTML = '<div class="cl-empty">Could not load contacts.</div>' })
|
||||
})()
|
||||
</script>
|
||||
|
|
@ -6,12 +6,11 @@
|
|||
<title>Crimata</title>
|
||||
<link rel="stylesheet" href="styles/canvas.css" />
|
||||
<link rel="stylesheet" href="styles/window.css" />
|
||||
<link rel="stylesheet" href="styles/dock.css" />
|
||||
<link rel="stylesheet" href="styles/cursor.css" />
|
||||
<link rel="stylesheet" href="styles/login.css" />
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
</head>
|
||||
<body x-data="os" @keydown.window="handleKeydown">
|
||||
<body x-data="os" @keydown.window="handleKeydown" @mousemove.window="trackCursor">
|
||||
|
||||
<!-- Login overlay (shown until authenticated) -->
|
||||
<div id="login-overlay" x-show="!authed" x-cloak>
|
||||
|
|
@ -37,57 +36,29 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Infinite canvas -->
|
||||
<div id="canvas"
|
||||
@mousedown="startPan"
|
||||
@mousemove="pan"
|
||||
@mouseup="endPan"
|
||||
@mouseleave="endPan"
|
||||
:style="`transform: translate(${offset.x}px, ${offset.y}px)`"
|
||||
>
|
||||
<!-- Windows render here -->
|
||||
<template x-for="win in windows" :key="win.id">
|
||||
<div class="window"
|
||||
:style="`left:${win.x}px; top:${win.y}px; width:${win.w}px; height:${win.h}px; z-index:${win.z}`"
|
||||
@mousedown.stop="focusWindow(win)"
|
||||
>
|
||||
<div class="window-titlebar"
|
||||
@mousedown.stop="startDrag($event, win)"
|
||||
>
|
||||
<span class="window-title" x-text="win.title"></span>
|
||||
<button class="window-close" @click.stop="closeWindow(win)">✕</button>
|
||||
</div>
|
||||
<div class="window-content">
|
||||
<iframe :src="win.url" frameborder="0"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Infinite canvas — elements placed by the agent -->
|
||||
<div id="canvas"></div>
|
||||
|
||||
<!-- Cursor input overlay -->
|
||||
<div id="cursor-input" x-show="inputMode" @click.stop>
|
||||
<!-- Cursor dot -->
|
||||
<div id="cursor-dot" x-show="authed" x-cloak></div>
|
||||
|
||||
<!-- Input bubble (Cmd+Space) — follows cursor -->
|
||||
<div id="cursor-bubble"
|
||||
x-show="inputMode"
|
||||
x-cloak
|
||||
:style="`left:${cursorX}px; top:${cursorY}px`"
|
||||
>
|
||||
<input
|
||||
id="cursor-input-field"
|
||||
id="cursor-input"
|
||||
type="text"
|
||||
x-model="cursorQuery"
|
||||
@keydown.enter="submitQuery"
|
||||
@keydown.escape="exitInputMode"
|
||||
placeholder="Type a command..."
|
||||
placeholder="Ask anything…"
|
||||
x-ref="cursorInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Dock -->
|
||||
<div id="dock">
|
||||
<template x-for="app in installedApps" :key="app.id">
|
||||
<div class="dock-icon" @click="openApp(app)" :title="app.name">
|
||||
<span x-text="app.icon"></span>
|
||||
<span class="dock-dot" x-show="app.running"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div id="dock-user" x-show="currentUser" x-text="currentUser"></div>
|
||||
</div>
|
||||
|
||||
<script src="js/os.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
410
ui/js/os.js
410
ui/js/os.js
|
|
@ -1,17 +1,264 @@
|
|||
/* ── Canvas / WebSocket layer ───────────────────────────────────────────────
|
||||
Manages the agent WebSocket connection and renders canvas elements.
|
||||
This layer is pure vanilla JS — Alpine handles auth + cursor input.
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
let ws = null
|
||||
let canvasDiv = null
|
||||
const elMap = {} // id → DOM element
|
||||
|
||||
function connectWS() {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
ws = new WebSocket(`${proto}://${location.host}/ws`)
|
||||
|
||||
ws.onopen = () => console.log('agent connected')
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data)
|
||||
switch (msg.op) {
|
||||
case 'render': renderEl(msg.element); break
|
||||
case 'remove': removeEl(msg.id); break
|
||||
case 'canvas': msg.elements.forEach(renderEl); break
|
||||
case 'cursor_response': showCursorResponse(msg); break
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('agent disconnected — reconnecting in 2s')
|
||||
setTimeout(connectWS, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
function sendEvent(event) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN)
|
||||
ws.send(JSON.stringify(event))
|
||||
}
|
||||
|
||||
/* ── Element lifecycle ────────────────────────────────────────────────────── */
|
||||
|
||||
function removeEl(id) {
|
||||
const div = elMap[id]
|
||||
if (div) { div.remove(); delete elMap[id] }
|
||||
}
|
||||
|
||||
function renderEl(el) {
|
||||
removeEl(el.id)
|
||||
|
||||
const div = document.createElement('div')
|
||||
div.dataset.elId = el.id
|
||||
div.classList.add('canvas-element', el.type.replace(/_/g, '-'))
|
||||
div.style.left = el.x + 'px'
|
||||
div.style.top = el.y + 'px'
|
||||
|
||||
if (el.type === 'app_icon') {
|
||||
buildAppIcon(div, el)
|
||||
} else if (el.type === 'window') {
|
||||
buildWindow(div, el)
|
||||
}
|
||||
|
||||
canvasDiv.appendChild(div)
|
||||
elMap[el.id] = div
|
||||
makeDraggable(div, el)
|
||||
}
|
||||
|
||||
/* ── App icon ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
function buildAppIcon(div, el) {
|
||||
const icon = el.props?.icon || '📦'
|
||||
const label = el.props?.label || el.props?.name || el.component
|
||||
const dot = el.running ? '<div class="icon-dot"></div>' : ''
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="icon-glyph">${icon}</div>
|
||||
<div class="icon-label">${label}</div>
|
||||
${dot}
|
||||
`
|
||||
|
||||
div.addEventListener('click', () => {
|
||||
sendEvent({
|
||||
type: 'app_icon.clicked',
|
||||
data: { id: el.id, component: el.component },
|
||||
position: { x: el.x, y: el.y },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/* ── Window (content card) ────────────────────────────────────────────────── */
|
||||
|
||||
function buildWindow(div, el) {
|
||||
const content = document.createElement('div')
|
||||
content.className = 'window-content'
|
||||
content.innerHTML = '<div class="loading">Loading…</div>'
|
||||
div.appendChild(content)
|
||||
|
||||
// Apply explicit size if provided
|
||||
if (el.props?.width) div.style.width = el.props.width + 'px'
|
||||
if (el.props?.height) div.style.height = el.props.height + 'px'
|
||||
|
||||
const parts = el.component.split('.')
|
||||
const appId = parts[0]
|
||||
const compName = parts[1] || parts[0]
|
||||
const url = `/components/${appId}/${compName}.html`
|
||||
|
||||
fetch(url)
|
||||
.then(r => r.ok ? r.text() : Promise.reject(r.status))
|
||||
.then(html => {
|
||||
content.innerHTML = html
|
||||
// Make props available to component scripts via window scope
|
||||
window.__componentProps = el.props || {}
|
||||
// Re-execute inline scripts (innerHTML doesn't run scripts)
|
||||
content.querySelectorAll('script').forEach(old => {
|
||||
const s = document.createElement('script')
|
||||
s.textContent = old.textContent
|
||||
old.replaceWith(s)
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
content.innerHTML = `<div class="loading">${el.component}</div>`
|
||||
})
|
||||
|
||||
// Forward data-event button clicks to agent
|
||||
div.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('button[data-event]')
|
||||
if (btn) {
|
||||
sendEvent({
|
||||
type: 'button.clicked',
|
||||
data: { window: el.id, event: btn.dataset.event, value: btn.dataset.value },
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* ── Cursor response ──────────────────────────────────────────────────────── */
|
||||
|
||||
function showCursorResponse(msg) {
|
||||
removeEl('cursor_response')
|
||||
|
||||
const div = document.createElement('div')
|
||||
div.dataset.elId = 'cursor_response'
|
||||
div.classList.add('canvas-element', 'cursor-response')
|
||||
div.style.left = msg.position.x + 'px'
|
||||
div.style.top = msg.position.y + 'px'
|
||||
|
||||
let html = ''
|
||||
if (msg.text) html += `<p>${msg.text}</p>`
|
||||
if (msg.pills?.length) {
|
||||
html += '<div class="response-pills">'
|
||||
msg.pills.forEach(p => { html += `<button data-pill="${p}">${p}</button>` })
|
||||
html += '</div>'
|
||||
}
|
||||
div.innerHTML = html
|
||||
|
||||
div.querySelectorAll('[data-pill]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
sendEvent({ type: 'pill.clicked', data: { text: btn.dataset.pill } })
|
||||
removeEl('cursor_response')
|
||||
})
|
||||
})
|
||||
|
||||
// Dismiss on outside click
|
||||
const dismiss = (e) => {
|
||||
if (!div.contains(e.target)) {
|
||||
removeEl('cursor_response')
|
||||
document.removeEventListener('click', dismiss)
|
||||
}
|
||||
}
|
||||
setTimeout(() => document.addEventListener('click', dismiss), 50)
|
||||
|
||||
canvasDiv.appendChild(div)
|
||||
elMap['cursor_response'] = div
|
||||
}
|
||||
|
||||
/* ── Drag ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function makeDraggable(div, el) {
|
||||
let startX, startY, startL, startT
|
||||
|
||||
const handle = div // drag from anywhere on element
|
||||
|
||||
handle.addEventListener('mousedown', (e) => {
|
||||
// Don't initiate drag from interactive elements inside windows
|
||||
if (el.type === 'window' && (
|
||||
e.target.tagName === 'BUTTON' ||
|
||||
e.target.tagName === 'INPUT' ||
|
||||
e.target.tagName === 'A' ||
|
||||
e.target.tagName === 'SELECT'
|
||||
)) return
|
||||
|
||||
e.preventDefault()
|
||||
startX = e.clientX
|
||||
startY = e.clientY
|
||||
startL = parseInt(div.style.left) || 0
|
||||
startT = parseInt(div.style.top) || 0
|
||||
|
||||
const onMove = (e) => {
|
||||
div.style.left = (startL + e.clientX - startX) + 'px'
|
||||
div.style.top = (startT + e.clientY - startY) + 'px'
|
||||
}
|
||||
const onUp = () => {
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', onUp)
|
||||
}
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onUp)
|
||||
})
|
||||
}
|
||||
|
||||
/* ── Alpine component (auth + cursor input) ─────────────────────────────────
|
||||
Keeps Alpine scope minimal — just auth state and cursor input.
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const cursorDot = document.getElementById('cursor-dot')
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('os', () => ({
|
||||
|
||||
// ── Auth state ────────────────────────────────────────────────────────
|
||||
// Auth
|
||||
authed: false,
|
||||
currentUser: '',
|
||||
loginUsername: '',
|
||||
loginPassword: '',
|
||||
loginError: '',
|
||||
|
||||
// Cursor input
|
||||
inputMode: false,
|
||||
cursorQuery: '',
|
||||
cursorX: 0,
|
||||
cursorY: 0,
|
||||
|
||||
trackCursor(e) {
|
||||
this.cursorX = e.clientX
|
||||
this.cursorY = e.clientY
|
||||
if (cursorDot) {
|
||||
cursorDot.style.left = e.clientX + 'px'
|
||||
cursorDot.style.top = e.clientY + 'px'
|
||||
}
|
||||
},
|
||||
|
||||
async init() {
|
||||
canvasDiv = document.getElementById('canvas')
|
||||
await this.checkAuth()
|
||||
if (this.authed) connectWS()
|
||||
},
|
||||
|
||||
async checkAuth() {
|
||||
const token = sessionStorage.getItem('auth_token')
|
||||
if (!token) return
|
||||
try {
|
||||
const res = await fetch('/auth/me', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
this.authed = true
|
||||
} else {
|
||||
sessionStorage.removeItem('auth_token')
|
||||
}
|
||||
} catch (_) { /* auth service down */ }
|
||||
},
|
||||
|
||||
async submitLogin() {
|
||||
this.loginError = ''
|
||||
try {
|
||||
const res = await fetch('/auth', {
|
||||
const res = await fetch('/auth/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
|
@ -25,124 +272,18 @@ document.addEventListener('alpine:init', () => {
|
|||
return
|
||||
}
|
||||
sessionStorage.setItem('auth_token', data.token)
|
||||
this.currentUser = data.username
|
||||
this.loginPassword = ''
|
||||
this.authed = true
|
||||
await this.loadApps()
|
||||
const bio = this.installedApps.find(a => a.id === 'bio')
|
||||
if (bio) this.openApp(bio)
|
||||
} catch (e) {
|
||||
this.authed = true
|
||||
connectWS()
|
||||
} catch (_) {
|
||||
this.loginError = 'Auth service unreachable'
|
||||
}
|
||||
},
|
||||
|
||||
async checkAuth() {
|
||||
const token = sessionStorage.getItem('auth_token')
|
||||
if (!token) return
|
||||
|
||||
try {
|
||||
const res = await fetch('/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
this.currentUser = data.username
|
||||
this.authed = true
|
||||
} else {
|
||||
sessionStorage.removeItem('auth_token')
|
||||
}
|
||||
} catch (_) {
|
||||
/* auth service down — leave authed=false */
|
||||
}
|
||||
},
|
||||
|
||||
// ── Canvas pan state ──────────────────────────────────────────────────
|
||||
offset: { x: 0, y: 0 },
|
||||
isPanning: false,
|
||||
panStart: { x: 0, y: 0 },
|
||||
|
||||
startPan(e) {
|
||||
if (e.target !== document.getElementById('canvas') &&
|
||||
!e.target.closest('#canvas') ||
|
||||
e.target.closest('.window')) return
|
||||
this.isPanning = true
|
||||
this.panStart = { x: e.clientX - this.offset.x, y: e.clientY - this.offset.y }
|
||||
document.getElementById('canvas').classList.add('panning')
|
||||
},
|
||||
|
||||
pan(e) {
|
||||
if (!this.isPanning) return
|
||||
this.offset = { x: e.clientX - this.panStart.x, y: e.clientY - this.panStart.y }
|
||||
},
|
||||
|
||||
endPan() {
|
||||
this.isPanning = false
|
||||
document.getElementById('canvas').classList.remove('panning')
|
||||
},
|
||||
|
||||
// ── Windows ───────────────────────────────────────────────────────────
|
||||
windows: [],
|
||||
zCounter: 100,
|
||||
|
||||
openApp(app) {
|
||||
// Bring to front if already open
|
||||
const existing = this.windows.find(w => w.appId === app.id)
|
||||
if (existing) { this.focusWindow(existing); return }
|
||||
|
||||
this.windows.push({
|
||||
id: Date.now(),
|
||||
appId: app.id,
|
||||
title: app.name,
|
||||
url: app.url,
|
||||
x: 120 + Math.random() * 200,
|
||||
y: 80 + Math.random() * 100,
|
||||
w: 900,
|
||||
h: 600,
|
||||
z: ++this.zCounter,
|
||||
})
|
||||
},
|
||||
|
||||
focusWindow(win) {
|
||||
win.z = ++this.zCounter
|
||||
},
|
||||
|
||||
closeWindow(win) {
|
||||
this.windows = this.windows.filter(w => w.id !== win.id)
|
||||
},
|
||||
|
||||
// ── Window drag ───────────────────────────────────────────────────────
|
||||
dragging: null,
|
||||
dragStart: { x: 0, y: 0 },
|
||||
|
||||
startDrag(e, win) {
|
||||
this.focusWindow(win)
|
||||
this.dragging = win
|
||||
this.dragStart = { x: e.clientX - win.x, y: e.clientY - win.y }
|
||||
|
||||
const onMove = (e) => {
|
||||
if (!this.dragging) return
|
||||
this.dragging.x = e.clientX - this.dragStart.x
|
||||
this.dragging.y = e.clientY - this.dragStart.y
|
||||
}
|
||||
const onUp = () => {
|
||||
this.dragging = null
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', onUp)
|
||||
}
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onUp)
|
||||
},
|
||||
|
||||
// ── Cursor input mode ─────────────────────────────────────────────────
|
||||
inputMode: false,
|
||||
cursorQuery: '',
|
||||
|
||||
handleKeydown(e) {
|
||||
// ⌘+Space or Ctrl+Space toggles input mode
|
||||
if ((e.metaKey || e.ctrlKey) && e.code === 'Space') {
|
||||
e.preventDefault()
|
||||
this.inputMode ? this.exitInputMode() : this.enterInputMode()
|
||||
return
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -158,46 +299,15 @@ document.addEventListener('alpine:init', () => {
|
|||
},
|
||||
|
||||
submitQuery() {
|
||||
const query = this.cursorQuery.trim().toLowerCase()
|
||||
|
||||
// Check if it matches an installed app name
|
||||
const app = this.installedApps.find(a => a.name.toLowerCase() === query)
|
||||
if (app) { this.openApp(app); this.exitInputMode(); return }
|
||||
|
||||
// Otherwise — send to LLM (placeholder)
|
||||
console.log('LLM query:', query)
|
||||
const text = this.cursorQuery.trim()
|
||||
if (!text) { this.exitInputMode(); return }
|
||||
sendEvent({
|
||||
type: 'cursor.input',
|
||||
text,
|
||||
position: { x: this.cursorX, y: this.cursorY },
|
||||
})
|
||||
this.exitInputMode()
|
||||
},
|
||||
|
||||
// ── Installed apps ────────────────────────────────────────────────────
|
||||
installedApps: [],
|
||||
|
||||
async loadApps() {
|
||||
try {
|
||||
const res = await fetch('/dock/apps')
|
||||
const data = await res.json()
|
||||
this.installedApps = data.map(app => ({
|
||||
...app,
|
||||
url: `/apps/${app.id}`,
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error('dock unreachable:', e)
|
||||
}
|
||||
},
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────
|
||||
async init() {
|
||||
await this.checkAuth()
|
||||
|
||||
if (this.authed) {
|
||||
await this.loadApps()
|
||||
// Refresh running status every 10s
|
||||
setInterval(() => this.loadApps(), 10_000)
|
||||
// Open bio full-screen on load
|
||||
const bio = this.installedApps.find(a => a.id === 'bio')
|
||||
if (bio) this.openApp(bio)
|
||||
}
|
||||
}
|
||||
|
||||
}))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,20 +5,17 @@ html, body {
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #1a1a1a;
|
||||
background: #e8e8e8;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
#canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
will-change: transform;
|
||||
cursor: grab;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#canvas.panning {
|
||||
cursor: grabbing;
|
||||
.canvas-element {
|
||||
position: absolute;
|
||||
user-select: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,39 @@
|
|||
#cursor-input {
|
||||
/* Hide system cursor everywhere */
|
||||
* { cursor: none !important; }
|
||||
|
||||
/* Dot cursor */
|
||||
#cursor-dot {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 99999;
|
||||
z-index: 9999;
|
||||
transition: width 0.1s, height 0.1s;
|
||||
}
|
||||
|
||||
#cursor-input-field {
|
||||
width: 480px;
|
||||
padding: 14px 20px;
|
||||
font-size: 1.1rem;
|
||||
/* Input bubble — anchored to cursor position via inline style */
|
||||
#cursor-bubble {
|
||||
position: fixed;
|
||||
transform: translate(-50%, calc(-100% - 14px));
|
||||
z-index: 9998;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#cursor-input {
|
||||
background: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.4);
|
||||
border-radius: 20px;
|
||||
padding: 9px 18px;
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.14);
|
||||
outline: none;
|
||||
color: #111;
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
#cursor-input-field::placeholder {
|
||||
#cursor-input::placeholder {
|
||||
color: #aaa;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,79 +5,73 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
background: rgba(232, 232, 232, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 40px 48px;
|
||||
width: 360px;
|
||||
padding: 40px 44px;
|
||||
width: 340px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
color: #fff;
|
||||
gap: 14px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.login-card h1 {
|
||||
font-size: 1.4rem;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
color: #1a1a1a;
|
||||
letter-spacing: -0.02em;
|
||||
text-align: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.login-card input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
border: 1.5px solid #e0e0e0;
|
||||
background: #fafafa;
|
||||
color: #1a1a1a;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.login-card input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.login-card input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.55);
|
||||
border-color: #aaa;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.login-card button {
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #111;
|
||||
background: #1a1a1a;
|
||||
color: white;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.login-card button:hover {
|
||||
background: #fff;
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
font-size: 0.85rem;
|
||||
color: #ff6b6b;
|
||||
font-size: 0.83rem;
|
||||
color: #e53e3e;
|
||||
text-align: center;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
#dock-user {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +1,113 @@
|
|||
.window {
|
||||
position: absolute;
|
||||
/* ── App icons ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.canvas-element.app-icon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.5);
|
||||
overflow: hidden;
|
||||
min-width: 300px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.window-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
height: 40px;
|
||||
background: #f5f5f5;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.window-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.window-close {
|
||||
background: none;
|
||||
border: none;
|
||||
gap: 6px;
|
||||
width: 72px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.app-icon .icon-glyph {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: white;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
font-size: 26px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.10);
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
.window-close:hover {
|
||||
background: #ff5f57;
|
||||
color: #fff;
|
||||
.app-icon:hover .icon-glyph {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.window-content {
|
||||
flex: 1;
|
||||
.app-icon .icon-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #444;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
backdrop-filter: blur(6px);
|
||||
white-space: nowrap;
|
||||
max-width: 88px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.window-content iframe {
|
||||
.app-icon .icon-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background: #34c759;
|
||||
border-radius: 50%;
|
||||
margin-top: -3px;
|
||||
}
|
||||
|
||||
/* ── Windows — no chrome, just content cards ──────────────────────────────── */
|
||||
|
||||
.canvas-element.window {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
overflow: hidden;
|
||||
min-width: 320px;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.window .window-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.window .loading {
|
||||
padding: 24px;
|
||||
color: #aaa;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Cursor response bubble ───────────────────────────────────────────────── */
|
||||
|
||||
.canvas-element.cursor-response {
|
||||
background: white;
|
||||
border-radius: 14px;
|
||||
padding: 12px 16px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
|
||||
max-width: 320px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cursor-response p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.response-pills {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.response-pills button {
|
||||
background: #f0f0f0;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
padding: 5px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.response-pills button:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue