From ef3d79c428b5f34f8b0a8d82d330bb93d1e553fd Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 14 Oct 2026 12:20:11 -0700 Subject: [PATCH] Add persistent agent messaging and shared chat bubbles Connect Sybil to the Pi root conversation with authenticated REST and WebSocket APIs, persistent read state, and a dedicated agent tab. Keep optimistic messages stable through delivery and retries, and animate arrivals without losing the reader's scroll position. Share bubble styling with regular chats and remove the obsolete admission sender. Reject malformed upstream receipts and WebSocket upgrade requests, with regression coverage and updated API documentation. Validated with 51 web tests, 44 server tests, both production builds, desktop/mobile bubble comparisons, and 11 browser animation and scrolling checks. --- dist/default.conf | 8 +- docs/api/rest.md | 70 +++ docs/api/streaming-chat.md | 61 +- server/README.md | 23 + server/package-lock.json | 33 ++ server/package.json | 2 + .../migration.sql | 5 + server/prisma/schema.prisma | 8 + server/src/agent.ts | 171 ++++++ server/src/env.ts | 2 + server/src/index.ts | 4 +- server/tests/agent.test.ts | 184 ++++++ web/public/character-still.png | Bin 0 -> 41747 bytes web/src/App.tsx | 71 ++- .../components/agent/agent-conversation.css | 538 ++++++++++++++++++ .../components/agent/agent-conversation.tsx | 350 ++++++++++++ .../components/chat/chat-attachment-list.tsx | 2 +- .../components/chat/chat-messages-panel.tsx | 8 +- web/src/hooks/use-agent-room.ts | 229 ++++++++ web/src/index.css | 34 +- web/src/lib/agent-optimistic-outbox.ts | 166 ++++++ web/src/lib/agent-room-presentation.ts | 120 ++++ web/src/lib/agent-room.ts | 216 +++++++ web/src/lib/api.ts | 8 + web/src/lib/sidebar-selection.ts | 7 +- web/tests/agent-optimistic-outbox.test.mjs | 139 +++++ web/tests/agent-room-presentation.test.mjs | 100 ++++ web/tests/agent-room.test.mjs | 210 +++++++ web/tests/sidebar-selection.test.mjs | 8 + 29 files changed, 2752 insertions(+), 25 deletions(-) create mode 100644 server/prisma/migrations/20261014000000_add_agent_read_state/migration.sql create mode 100644 server/src/agent.ts create mode 100644 server/tests/agent.test.ts create mode 100644 web/public/character-still.png create mode 100644 web/src/components/agent/agent-conversation.css create mode 100644 web/src/components/agent/agent-conversation.tsx create mode 100644 web/src/hooks/use-agent-room.ts create mode 100644 web/src/lib/agent-optimistic-outbox.ts create mode 100644 web/src/lib/agent-room-presentation.ts create mode 100644 web/src/lib/agent-room.ts create mode 100644 web/tests/agent-optimistic-outbox.test.mjs create mode 100644 web/tests/agent-room-presentation.test.mjs create mode 100644 web/tests/agent-room.test.mjs diff --git a/dist/default.conf b/dist/default.conf index cab311c..0f7d6f8 100644 --- a/dist/default.conf +++ b/dist/default.conf @@ -1,3 +1,8 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + "" close; +} + server { listen 80; server_name _; @@ -19,7 +24,8 @@ server { proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Connection ""; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; } location = /sw.js { diff --git a/docs/api/rest.md b/docs/api/rest.md index 256d201..51d438f 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -562,3 +562,73 @@ Behavior notes: ``` For streaming contracts, see `docs/api/streaming-chat.md`. + +## Persistent Agent Room + +The Agent tab connects to one persistent Pi root conversation. Pi owns its messages, +background work, and history; these endpoints do not create ordinary Sybil chats or +copy the transcript into the Sybil message database. All endpoints use normal Sybil +REST authentication. The server connects privately to `PI_AGENT_BASE_URL` (default +`http://pi-durable:8788`) using `PI_AGENT_API_KEY`. The Pi key never reaches browsers. + +### `GET /v1/agent` + +Returns the Pi room descriptor: `id`, `title`, `type: "persistent-agent"`, `model`, +`protocol: "pi-room.v1"`, `activity`, and `websocket: "/v1/agent/ws"`. The socket path +is relative to the API base, so web deployments use `/api/v1/agent/ws`. Missing Pi +configuration returns 503; an unreachable Pi server or malformed upstream JSON +returns 502. + +### `GET /v1/agent/messages` + +Query parameters: optional `after` or `before` (mutually exclusive nonnegative +numeric cursor strings), and `limit` (1–200). Without a cursor, returns recent +history. `after` catches up forward; `before` loads earlier history. + +```json +{ + "conversationId": "1", + "messages": [{ "id": "outbound:43", "createdAt": "2026-10-14T18:00:00.000Z", "role": "assistant", "content": "The task finished.", "name": "Pi", "metadata": { "kind": "message", "entryId": 43, "status": "complete" } }], + "cursor": "43", + "beforeCursor": "43", + "hasMore": false +} +``` + +Messages use `user`, `assistant`, and `tool` roles. Tools use +`metadata.kind: "tool_call"` and stable IDs; merge by message `id`, since later +pages can update initiated tools to completed or failed. Internal worker reports, +event prompts, and model reasoning are excluded. Cursors refer to underlying +durable entries and can advance without visible messages. Use returned cursors; +do not derive them from message IDs. `limit` bounds source entries, not displayed +messages. + +### `POST /v1/agent/messages` + +Body: `{ "content": "Check the oven", "requestId": "client-generated-uuid" }`. +Content must contain non-whitespace text and is limited to 40,000 characters; +`requestId` is required (1–256 characters). Preserve the ID when retrying uncertain +network results. The proxy fixes `channel: "sybil"`, `whenBusy: "followUp"`, and +`wait: false`. + +Returns Pi's `202 Accepted` receipt, including `id` (submission ID), `status`, and +`messageId` such as `inbound:sybil:client-generated-uuid`. This is admission, not +completion: watch the room for answers, including unsolicited later replies. +Multiple requests can be accepted while Pi works. Attachments are not yet supported. +The proxy verifies successful receipts contain a positive submission ID, a known +status (`queued`, `placed`, `done`, or `unanswered`), and the expected `messageId`. +Invalid receipts return 502; delivery remains uncertain, so retry using the original +`requestId` to avoid creating a duplicate message. + +### `GET /v1/agent/read-state` +### `PUT /v1/agent/read-state` + +GET returns `{ "cursor": "43", "updatedAt": "2026-10-14T18:00:00.000Z" }`. +Initially: `{ "cursor": "0", "updatedAt": null }`. PUT takes +`{ "cursor": "43" }` and returns the same shape. The cursor is shared across this +personal installation and persisted in SQLite. Atomic, monotonic updates prevent +a stale browser from lowering a cursor saved by another browser. Advance it only +after showing the corresponding history; poll it to reconcile unread indicators +across clients. Agent REST responses use `Cache-Control: no-store`. + +See the persistent Agent WebSocket section in `streaming-chat.md` for live updates. diff --git a/docs/api/streaming-chat.md b/docs/api/streaming-chat.md index e520cde..0962adc 100644 --- a/docs/api/streaming-chat.md +++ b/docs/api/streaming-chat.md @@ -1,6 +1,6 @@ # Streaming Chat API Contract -This document defines the server-sent events (SSE) contract for chat completions and Quick Questions. +This document defines the server-sent events (SSE) contract for chat completions and Quick Questions, and the WebSocket contract for the persistent Agent room. Endpoint: - `POST /v1/chat-completions/stream` @@ -275,3 +275,62 @@ event: done data: {"type":"done","text":"Hello world"} ``` + +## Persistent Agent WebSocket + +`GET /v1/agent/ws` upgrades to a continuous WebSocket room subscription. Behind the +web proxy use `/api/v1/agent/ws`, with `ws:` or `wss:` matching the page. It remains +connected after each assistant turn and receives later background results. Sending +messages uses `POST /v1/agent/messages`. + +The browser must send this first frame within five seconds: + +```json +{ "type": "auth", "token": "Sybil token", "after": "43" } +``` + +In open mode, use `token: null`. Omit `after` for recent history; on reconnect supply +the last successfully merged cursor. Invalid credentials close with 4401, malformed +initial frames with 4400. Malformed upgrade URLs and query parameters are rejected +with HTTP 400; unknown upgrade paths return HTTP 404. Never put tokens in URLs. +Sybil adds its private Pi credential only to the upstream Authorization header; +browser credentials are not forwarded to Pi. + +Native `pi-room.v1` frames are forwarded after authentication: + +```json +{ "type": "ready", "protocol": "pi-room.v1", "conversationId": "1", "model": "..." } +``` + +```json +{ + "type": "messages", + "conversationId": "1", + "messages": [{ "id": "outbound:44", "role": "assistant", "content": "The result is ready.", "createdAt": "2026-10-14T18:00:00.000Z", "metadata": { "kind": "message", "entryId": 44, "status": "complete" } }], + "cursor": "44", + "beforeCursor": "44", + "hasMore": false +} +``` + +```json +{ "type": "activity", "conversationId": "1", "activity": { "state": "thinking", "isThinking": true, "queued": 0, "backgroundWorkers": 0, "tools": [], "queuedMessages": [] } } +``` + +Activity is forwarded from Pi and also describes tools, queued messages, durable +waiting, errors, and workers. Use `isThinking` for the thinking indicator and +`backgroundWorkers` for ongoing delegated work; no model reasoning is exposed. Tools arrive as `role: "tool"` messages +with `metadata.kind: "tool_call"`, matching Sybil's tool renderer. REST pages and +socket messages are upserts keyed by stable `id`. Save each returned cursor after +applying its entire frame, even when it contains no visible messages. + +Clients may send `{ "type": "ping" }` (receiving `{ "type": "pong" }`) or +`{ "type": "subscribe", "after": "44" }` to change the replay cursor. Transport +ping/pong heartbeats detect dead connections. Other writes belong on REST. +Errors use `{ "type": "error", "error": "..." }`. Upstream loss closes with 1011 +or 1012; slow consumers may receive 1013. Reconnect with backoff and the last merged +cursor. Missing Pi configuration closes with 4503. + +The proxy closes upstream sockets when browsers leave or Sybil shuts down. +History remains durable in Pi. Shared read position is separate from delivery: +update `GET`/`PUT /v1/agent/read-state` only when messages have actually been viewed. diff --git a/server/README.md b/server/README.md index 67f156f..3232f2e 100644 --- a/server/README.md +++ b/server/README.md @@ -106,3 +106,26 @@ When `chatId` is provided to completion endpoints, you can send full conversatio - User accounts + per-device API keys - Postgres support + migrations for prod - Attachments + embeddings + semantic search + +## Persistent Pi Agent + +Configure the backend with: + +```dotenv +PI_AGENT_BASE_URL=http://pi-durable:8788 +PI_AGENT_API_KEY= +``` + +The base URL defaults to `http://pi-durable:8788` and has no `/v1` suffix. In Docker, +attach Sybil's **server** container and Pi to the shared `agents` network so that +`pi-durable` resolves privately. Supply the key through a private server environment +file; it is never a `VITE_*` variable or browser setting. Keep the existing Sybil +`ADMIN_TOKEN` authentication for clients. + +The Agent tab uses `/v1/agent`, `/v1/agent/messages`, and `/v1/agent/ws` to proxy the +same persistent Pi root conversation across devices. Pi remains the source of +truth for messages and background replies. Sybil stores only the shared, +monotonically increasing read cursor in SQLite through `/v1/agent/read-state`; +its migration is applied automatically at startup. The bundled nginx configuration +forwards WebSocket upgrades. See `docs/api/rest.md` and +`docs/api/streaming-chat.md` at the repository root for the client contracts. diff --git a/server/package-lock.json b/server/package-lock.json index 0ed9cd4..bb5b031 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -22,10 +22,12 @@ "openai": "^6.16.0", "pino-pretty": "^13.1.3", "prisma": "^6.6.0", + "ws": "8.22.0", "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^25.0.10", + "@types/ws": "8.18.1", "tsx": "^4.21.0", "typescript": "^5.9.3" } @@ -876,6 +878,16 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -2241,6 +2253,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yaml": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", diff --git a/server/package.json b/server/package.json index ab59cba..672af21 100644 --- a/server/package.json +++ b/server/package.json @@ -31,10 +31,12 @@ "openai": "^6.16.0", "pino-pretty": "^13.1.3", "prisma": "^6.6.0", + "ws": "8.22.0", "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^25.0.10", + "@types/ws": "8.18.1", "tsx": "^4.21.0", "typescript": "^5.9.3" } diff --git a/server/prisma/migrations/20261014000000_add_agent_read_state/migration.sql b/server/prisma/migrations/20261014000000_add_agent_read_state/migration.sql new file mode 100644 index 0000000..27fac62 --- /dev/null +++ b/server/prisma/migrations/20261014000000_add_agent_read_state/migration.sql @@ -0,0 +1,5 @@ +CREATE TABLE "AgentReadState" ( + "id" TEXT NOT NULL PRIMARY KEY, + "cursor" TEXT NOT NULL DEFAULT '0', + "updatedAt" DATETIME NOT NULL +); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 08800bb..38d3048 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -215,3 +215,11 @@ model ProjectItem { @@index([chatId]) @@index([searchId]) } + +// Shared personal read position for the external persistent Pi room. +// Messages remain in Pi's durable transcript, not duplicated in Sybil Chat. +model AgentReadState { + id String @id + cursor String @default("0") + updatedAt DateTime @updatedAt +} diff --git a/server/src/agent.ts b/server/src/agent.ts new file mode 100644 index 0000000..7b7dddf --- /dev/null +++ b/server/src/agent.ts @@ -0,0 +1,171 @@ +import type { IncomingMessage } from 'node:http'; +import type { Duplex } from 'node:stream'; +import type { FastifyInstance } from 'fastify'; +import { WebSocket, WebSocketServer } from 'ws'; +import { z } from 'zod'; +import { requireAdmin } from './auth.js'; +import { prisma } from './db.js'; +import { env } from './env.js'; + +const Cursor = z.string().regex(/^\d{1,16}$/).refine(value => Number.isSafeInteger(Number(value)), 'Invalid cursor') + .transform(value => String(Number(value))); +const PageQuery = z.object({ after: Cursor.optional(), before: Cursor.optional(), limit: z.coerce.number().int().min(1).max(200).optional() }) + .refine(value => !(value.after && value.before), 'Use after or before, not both'); +const MessageBody = z.object({ content: z.string().min(1).max(40_000).refine(value => value.trim().length > 0, 'content is required'), requestId: z.string().min(1).max(256) }); +const ReadBody = z.object({ cursor: Cursor }); +const AuthFrame = z.object({ type: z.literal('auth'), token: z.string().nullable().optional(), after: Cursor.optional() }); +const SubscribeFrame = z.object({ type: z.literal('subscribe'), after: Cursor.optional() }); +const SubmissionReceipt = z.object({ id: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + status: z.enum(['queued', 'placed', 'done', 'unanswered']), messageId: z.string() }); + +type AgentOptions = { baseUrl?: string; apiKey?: string }; +const parse = (schema: z.ZodType, value: unknown): T => { + const result = schema.safeParse(value); + if (!result.success) throw Object.assign(new Error(result.error.issues.map(issue => issue.message).join('; ')), { statusCode: 400 }); + return result.data; +}; + +async function readState() { + const value = await prisma.agentReadState.findUnique({ where: { id: 'root' } }); + return { cursor: value?.cursor || '0', updatedAt: value?.updatedAt.toISOString() || null }; +} + +/** Pi owns the conversation; Sybil stores only its shared personal read cursor. */ +export async function registerAgentRoutes(app: FastifyInstance, options: AgentOptions = {}) { + const baseUrl = options.baseUrl || env.PI_AGENT_BASE_URL; + const apiKey = options.apiKey ?? env.PI_AGENT_API_KEY; + const proxyJson = async (path: string, method = 'GET', body?: unknown) => { + if (!apiKey) throw app.httpErrors.serviceUnavailable('Pi agent is not configured'); + let response: Response; + try { + response = await fetch(new URL(path, baseUrl), { method, + headers: { Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15_000), + }); + } catch { throw app.httpErrors.badGateway('Pi agent is unavailable'); } + let data: unknown; + try { data = await response.json(); } + catch { throw app.httpErrors.badGateway('Pi agent returned invalid JSON'); } + return { status: response.status, data }; + }; + + app.get('/v1/agent', async (req, reply) => { + reply.header('Cache-Control', 'no-store'); + requireAdmin(req); + const result = await proxyJson('/api/conversations/root'); + reply.code(result.status); + if (result.status !== 200) return result.data; + return { ...result.data as object, websocket: '/v1/agent/ws' }; + }); + app.get('/v1/agent/messages', async (req, reply) => { + reply.header('Cache-Control', 'no-store'); + requireAdmin(req); + const query = parse(PageQuery, req.query); + const params = new URLSearchParams(); + for (const [name, value] of Object.entries(query)) if (value !== undefined) params.set(name, String(value)); + const result = await proxyJson(`/api/conversations/root/messages?${params}`); + reply.code(result.status); + return result.data; + }); + app.post('/v1/agent/messages', { bodyLimit: 256 * 1024 }, async (req, reply) => { + reply.header('Cache-Control', 'no-store'); + requireAdmin(req); + const body = parse(MessageBody, req.body); + const result = await proxyJson('/api/conversations/root/messages', 'POST', { ...body, channel: 'sybil', whenBusy: 'followUp', wait: false }); + if (result.status >= 200 && result.status < 300) { + const receipt = SubmissionReceipt.safeParse(result.data); + if (!receipt.success || receipt.data.messageId !== `inbound:sybil:${body.requestId}`) { + throw app.httpErrors.badGateway('Pi agent returned an invalid message receipt'); + } + } + reply.code(result.status); + return result.data; + }); + app.get('/v1/agent/read-state', async (req, reply) => { + reply.header('Cache-Control', 'no-store'); + requireAdmin(req); + return readState(); + }); + app.put('/v1/agent/read-state', async (req, reply) => { + reply.header('Cache-Control', 'no-store'); + requireAdmin(req); + const { cursor } = parse(ReadBody, req.body); + // One SQLite statement makes concurrent tabs monotonic. Never write an older + // cursor over one saved by another connection between a read and an update. + await prisma.$executeRaw`INSERT INTO "AgentReadState" ("id", "cursor", "updatedAt") VALUES ('root', ${cursor}, CURRENT_TIMESTAMP) + ON CONFLICT("id") DO UPDATE SET + "cursor" = CASE WHEN CAST(excluded."cursor" AS INTEGER) > CAST("AgentReadState"."cursor" AS INTEGER) THEN excluded."cursor" ELSE "AgentReadState"."cursor" END, + "updatedAt" = CASE WHEN CAST(excluded."cursor" AS INTEGER) > CAST("AgentReadState"."cursor" AS INTEGER) THEN CURRENT_TIMESTAMP ELSE "AgentReadState"."updatedAt" END`; + return readState(); + }); + + attachAgentSocket(app, baseUrl, apiKey); +} + +function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey?: string) { + const sockets = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 }); + const cleanup = new Set<() => void>(); + const upgrade = (request: IncomingMessage, socket: Duplex, head: Buffer) => { + let url: URL; + try { url = new URL(request.url || '/', 'http://localhost'); } + catch { socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); return; } + if (url.pathname !== '/v1/agent/ws') { socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n'); return; } + // Credentials must never enter URLs and access logs. + if (url.search) { socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); return; } + sockets.handleUpgrade(request, socket, head, browser => { + let upstream: WebSocket | undefined; + let authenticated = false, disposed = false, alive = true; + const send = (value: unknown) => { + if (browser.readyState !== WebSocket.OPEN) return; + if (browser.bufferedAmount > 2 * 1024 * 1024) { browser.close(1013, 'Slow consumer; reconnect with cursor'); return; } + browser.send(typeof value === 'string' ? value : JSON.stringify(value)); + }; + const authTimeout = setTimeout(() => browser.close(4401, 'Authentication required'), 5000); + const heartbeat = setInterval(() => { if (!alive) browser.terminate(); else { alive = false; browser.ping(); } }, 25_000); + browser.on('pong', () => { alive = true; }); + const dispose = () => { + if (disposed) return; + disposed = true; clearTimeout(authTimeout); clearInterval(heartbeat); cleanup.delete(dispose); + upstream?.terminate(); browser.terminate(); + }; + cleanup.add(dispose); + browser.on('close', dispose); browser.on('error', dispose); + browser.on('message', (raw, isBinary) => { + try { + if (isBinary) throw new Error('JSON text frames are required'); + const frame = JSON.parse(raw.toString()); + if (!authenticated) { + const auth = parse(AuthFrame, frame); + if (env.ADMIN_TOKEN && auth.token !== env.ADMIN_TOKEN) { browser.close(4401, 'Invalid token'); return; } + if (!apiKey) { browser.close(4503, 'Pi agent is not configured'); return; } + authenticated = true; clearTimeout(authTimeout); + const target = new URL('/api/ws', baseUrl); target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:'; + upstream = new WebSocket(target, { headers: { Authorization: `Bearer ${apiKey}` }, handshakeTimeout: 10_000, maxPayload: 16 * 1024 * 1024 }); + upstream.on('open', () => upstream!.send(JSON.stringify({ type: 'subscribe', ...(auth.after === undefined ? {} : { after: auth.after }) }))); + upstream.on('message', (data, binary) => { + if (binary) { browser.close(1011, 'Invalid Pi frame'); return; } + send(data.toString()); + }); + upstream.on('close', () => { if (!disposed) browser.close(1012, 'Pi agent disconnected; reconnect with cursor'); }); + upstream.on('error', () => { send({ type: 'error', error: 'Pi agent is unavailable' }); browser.close(1011, 'Pi agent is unavailable'); }); + return; + } + if (frame.type === 'ping') { send({ type: 'pong' }); return; } + const subscribe = parse(SubscribeFrame, frame); + if (upstream?.readyState !== WebSocket.OPEN) { send({ type: 'error', error: 'Pi agent is still connecting' }); return; } + upstream.send(JSON.stringify(subscribe)); + } catch (error) { + if (!authenticated) browser.close(4400, 'Invalid authentication frame'); + else send({ type: 'error', error: error instanceof Error ? error.message : 'Invalid frame' }); + } + }); + }); + }; + app.server.on('upgrade', upgrade); + app.addHook('preClose', async () => { + app.server.off('upgrade', upgrade); + for (const stop of cleanup) stop(); + for (const client of sockets.clients) client.terminate(); + sockets.close(); + }); +} diff --git a/server/src/env.ts b/server/src/env.ts index be586dd..dfdaa69 100644 --- a/server/src/env.ts +++ b/server/src/env.ts @@ -70,6 +70,8 @@ const EnvSchema = z.object({ HERMES_AGENT_API_BASE_URL: HermesAgentApiBaseUrlSchema, HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema, HERMES_AGENT_MODEL: OptionalTrimmedStringSchema, + PI_AGENT_BASE_URL: z.string().url().default("http://pi-durable:8788"), + PI_AGENT_API_KEY: OptionalTrimmedStringSchema, EXA_API_KEY: z.string().optional(), BRAVE_SEARCH_API_KEY: OptionalTrimmedStringSchema, diff --git a/server/src/index.ts b/server/src/index.ts index e79e9d1..5476231 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -6,6 +6,7 @@ import sensible from "@fastify/sensible"; import { env } from "./env.js"; import { ensureDatabaseReady } from "./db-init.js"; import { startModelCatalogRefreshLoop, warmModelCatalog } from "./llm/model-catalog.js"; +import { registerAgentRoutes } from "./agent.js"; import { registerRoutes } from "./routes.js"; const app = Fastify({ @@ -26,7 +27,7 @@ const stopModelCatalogRefreshLoop = startModelCatalogRefreshLoop(app.log); await app.register(cors, { origin: true, credentials: true, - methods: ["GET", "HEAD", "POST", "DELETE", "OPTIONS"], + methods: ["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"], }); await app.register(swagger, { @@ -86,6 +87,7 @@ app.addHook("onClose", async () => { }); await registerRoutes(app); +await registerAgentRoutes(app); await app.listen({ port: env.PORT, host: env.HOST }); app.log.info(`listening on http://${env.HOST}:${env.PORT}`); diff --git a/server/tests/agent.test.ts b/server/tests/agent.test.ts new file mode 100644 index 0000000..9745515 --- /dev/null +++ b/server/tests/agent.test.ts @@ -0,0 +1,184 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { createConnection } from 'node:net'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import Fastify from 'fastify'; +import sensible from '@fastify/sensible'; +import { WebSocket, WebSocketServer } from 'ws'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const directory = mkdtempSync(join(tmpdir(), 'sybil-agent-')); +process.env.DATABASE_URL = `file:${join(directory, 'agent.db')}`; +process.env.ADMIN_TOKEN = 'sybil-test-token-0123456789'; +execFileSync(process.execPath, [join(root, 'node_modules/prisma/build/index.js'), 'migrate', 'deploy'], { + cwd: root, env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: '1' }, stdio: 'pipe', +}); +const [{ registerAgentRoutes }, { prisma }, { env }] = await Promise.all([ + import('../src/agent.js'), import('../src/db.js'), import('../src/env.js'), +]); +const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` }; +const privateKey = 'pi-private-key-for-test'; +const requests: { method?: string; url?: string; body: any }[] = []; +const accepted = new Map(); +let upstreamReply: { status: number; body: string } | undefined; +let connections = 0; +const subscriptions: any[] = []; +const upstream = createServer(async (req, res) => { + assert.equal(req.headers.authorization, `Bearer ${privateKey}`); + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : null; + requests.push({ method: req.method, url: req.url, body }); + res.setHeader('Content-Type', 'application/json'); + if (upstreamReply) { res.statusCode = upstreamReply.status; res.end(upstreamReply.body); return; } + if (req.method === 'POST') { + const id = accepted.get(body.requestId) || accepted.size + 1; accepted.set(body.requestId, id); + res.statusCode = 202; res.end(JSON.stringify({ id, status: 'queued', messageId: `inbound:sybil:${body.requestId}` })); + } else if (req.url?.includes('/messages')) { + res.end(JSON.stringify({ conversationId: '1', messages: [{ id: 'outbound:43', role: 'assistant', content: 'Later result' }], cursor: '43', beforeCursor: '43', hasMore: false })); + } else res.end(JSON.stringify({ id: '1', type: 'persistent-agent', title: 'Pi', websocket: '/api/ws', protocol: 'pi-room.v1', activity: { thinking: true } })); +}); +const sourceSockets = new WebSocketServer({ noServer: true }); +upstream.on('upgrade', (req, socket, head) => { + assert.equal(req.headers.authorization, `Bearer ${privateKey}`); + assert.equal(req.url, '/api/ws'); + connections++; + sourceSockets.handleUpgrade(req, socket, head, ws => { + ws.on('message', raw => { + const frame = JSON.parse(raw.toString()); subscriptions.push(frame); + ws.send(JSON.stringify({ type: 'ready', protocol: 'pi-room.v1', conversationId: '1' })); + ws.send(JSON.stringify({ type: 'messages', conversationId: '1', messages: [{ id: 'outbound:43', role: 'assistant', content: 'Later result' }], cursor: '43', hasMore: false })); + ws.send(JSON.stringify({ type: 'activity', conversationId: '1', activity: { thinking: true, responding: true } })); + }); + }); +}); +upstream.listen(0, '127.0.0.1'); await once(upstream, 'listening'); +const upstreamAddress = upstream.address() as { port: number }; +const app = Fastify({ logger: false }); await app.register(sensible); +await registerAgentRoutes(app, { baseUrl: `http://127.0.0.1:${upstreamAddress.port}`, apiKey: privateKey }); +await app.listen({ port: 0, host: '127.0.0.1' }); +const address = app.server.address() as { port: number }; +const wsUrl = `ws://127.0.0.1:${address.port}/v1/agent/ws`; + +test.after(async () => { + await app.close(); + for (const socket of sourceSockets.clients) socket.terminate(); + sourceSockets.close(); + await new Promise(resolve => upstream.close(() => resolve())); + await prisma.$disconnect(); + rmSync(directory, { recursive: true, force: true }); +}); + +async function eventually(check: () => boolean) { + for (let i = 0; i < 100; i++) { if (check()) return; await new Promise(resolve => setTimeout(resolve, 10)); } + assert.fail('Expected condition was not reached'); +} + +test('REST authenticates, proxies the shared room, and retains stable message IDs without revealing Pi credentials', async () => { + assert.equal((await app.inject({ url: '/v1/agent' })).statusCode, 401); + const descriptor = await app.inject({ url: '/v1/agent', headers: auth }); + assert.equal(descriptor.statusCode, 200); + assert.equal(descriptor.json().websocket, '/v1/agent/ws'); + assert.ok(!descriptor.body.includes(privateKey)); + const page = await app.inject({ url: '/v1/agent/messages?after=42&limit=20', headers: auth }); + assert.equal(page.json().messages[0].role, 'assistant'); + assert.match(requests.at(-1)!.url!, /after=42&limit=20/); + const input = { content: ' Keep watching\n', requestId: 'same-client-message' }; + const first = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: input }); + const second = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: input }); + assert.equal(first.statusCode, 202); assert.deepEqual(first.json(), second.json()); + assert.deepEqual(requests.at(-1)!.body, { ...input, channel: 'sybil', whenBusy: 'followUp', wait: false }); + assert.equal((await prisma.chat.count()), 0, 'Pi messages are not copied into ordinary Sybil chats'); + assert.equal((await app.inject({ url: '/v1/agent/messages?after=2&before=3', headers: auth })).statusCode, 400); + assert.equal((await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: { content: 'Hello' } })).statusCode, 400); +}); + +test('the read cursor stays monotonic under concurrent tabs and survives reconnecting to SQLite', async () => { + const initial = await app.inject({ url: '/v1/agent/read-state', headers: auth }); + assert.equal(initial.json().cursor, '0'); + assert.equal(initial.headers['cache-control'], 'no-store'); + await Promise.all(['900', '4', '100', '12'].map(cursor => app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor } }))); + assert.equal((await app.inject({ url: '/v1/agent/read-state', headers: auth })).json().cursor, '900'); + await prisma.$disconnect(); + assert.equal((await app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor: '5' } })).json().cursor, '900'); + assert.equal((await app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor: '-1' } })).statusCode, 400); +}); + +test('malformed upstream JSON and unconfirmed message receipts return 502, preserving safe retries', async () => { + const payload = { content: 'One durable message', requestId: 'invalid-receipt-retry' }; + try { + upstreamReply = { status: 200, body: '{truncated' }; + for (const url of ['/v1/agent', '/v1/agent/messages']) { + const response = await app.inject({ url, headers: auth }); + assert.equal(response.statusCode, 502); + assert.equal(response.headers['cache-control'], 'no-store'); + } + for (const body of ['{truncated', '{}', 'null', JSON.stringify({ id: 1, status: 'queued', messageId: 'wrong-message' }), + JSON.stringify({ id: 1, status: 'unknown', messageId: `inbound:sybil:${payload.requestId}` })]) { + upstreamReply = { status: 202, body }; + const response = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload }); + assert.equal(response.statusCode, 502, body); + assert.equal(response.headers['cache-control'], 'no-store'); + } + } finally { upstreamReply = undefined; } + const retry = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload }); + assert.equal(retry.statusCode, 202); + assert.equal(retry.json().messageId, `inbound:sybil:${payload.requestId}`); +}); + +test('malformed and unmatched upgrade targets close without crashing or opening Pi connections', async () => { + const before = connections; + const requestUpgrade = (target: string): Promise => new Promise((resolve, reject) => { + let response = ''; + const socket = createConnection({ host: '127.0.0.1', port: address.port }, () => { + socket.write(`GET ${target} HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n`); + }); + socket.setTimeout(1000, () => socket.destroy(new Error('Upgrade rejection did not close the socket'))); + socket.on('data', chunk => { response += chunk.toString(); }); + socket.on('error', reject); + socket.on('end', () => { socket.destroy(); resolve(response); }); + }); + assert.match(await requestUpgrade('http://['), /^HTTP\/1\.1 400 /); + assert.match(await requestUpgrade('/v1/not-an-agent/ws'), /^HTTP\/1\.1 404 /); + assert.match(await requestUpgrade('/v1/agent/ws?token=forbidden'), /^HTTP\/1\.1 400 /); + assert.equal(connections, before); + assert.equal((await app.inject({ url: '/v1/agent', headers: auth })).statusCode, 200); +}); + +test('WebSocket first-frame authentication gates the private connection and forwards resumable native frames', async () => { + const before = connections; + const denied = new WebSocket(wsUrl); await once(denied, 'open'); + denied.send(JSON.stringify({ type: 'auth', token: 'wrong' })); + const [code] = await once(denied, 'close'); + assert.equal(code, 4401); assert.equal(connections, before); + const browser = new WebSocket(wsUrl); + const frames: any[] = []; browser.on('message', raw => frames.push(JSON.parse(raw.toString()))); + await once(browser, 'open'); browser.send(JSON.stringify({ type: 'auth', token: env.ADMIN_TOKEN, after: '42' })); + await eventually(() => frames.some(frame => frame.type === 'activity')); + assert.deepEqual(subscriptions.at(-1), { type: 'subscribe', after: '42' }); + assert.deepEqual(frames.map(frame => frame.type), ['ready', 'messages', 'activity']); + assert.equal(frames[1].messages[0].content, 'Later result'); + assert.equal(frames[2].activity.thinking, true); + assert.ok(!JSON.stringify(frames).includes(privateKey)); + browser.send(JSON.stringify({ type: 'subscribe', after: '43' })); + await eventually(() => subscriptions.at(-1)?.after === '43'); + browser.close(); await once(browser, 'close'); + await eventually(() => sourceSockets.clients.size === 0); +}); + +test('open-mode WebSocket authentication accepts null and still keeps the Pi token server-side', async () => { + const saved = env.ADMIN_TOKEN; env.ADMIN_TOKEN = undefined; + try { + const browser = new WebSocket(wsUrl); const frames: any[] = []; + browser.on('message', raw => frames.push(JSON.parse(raw.toString()))); + await once(browser, 'open'); browser.send(JSON.stringify({ type: 'auth', token: null, after: '0' })); + await eventually(() => frames.some(frame => frame.type === 'ready')); + browser.close(); await once(browser, 'close'); + } finally { env.ADMIN_TOKEN = saved; } +}); diff --git a/web/public/character-still.png b/web/public/character-still.png new file mode 100644 index 0000000000000000000000000000000000000000..073668ac13b7e2f637167192ee967924b6567b77 GIT binary patch literal 41747 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%0A;U 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[]); const [searches, setSearches] = useState([]); @@ -1515,6 +1522,8 @@ export default function App() { selectedItem?.kind === "search" && (!!selectedSearchRunState || !!activeRuns.searches[selectedItem.id]); const isSearchMode = draftKind ? draftKind === "search" : selectedItem?.kind === "search"; + const isAgentMode = draftKind === null && selectedItem?.kind === "agent"; + const isAgentBusy = !!agentRoom.activity?.isThinking || (agentRoom.activity?.backgroundWorkers ?? 0) > 0; const isSearchRunning = !!selectedSearchIsActive; const isSendingActiveChat = draftKind !== "search" && !!selectedChatIsActive; const isActiveSelectionSending = isSearchMode ? isSearchRunning : isSendingActiveChat; @@ -1577,9 +1586,10 @@ export default function App() { setSelectedSearch(null); return; } - if (!selectedItem) { + if (!selectedItem || selectedItem.kind === "agent") { setSelectedChat(null); setSelectedSearch(null); + setIsLoadingSelection(false); return; } @@ -1739,6 +1749,7 @@ export default function App() { if (draftKind === "chat") return draftChatTitle.trim() || "New chat"; if (draftKind === "search") return "New search"; if (!selectedItem) return "Sybil"; + if (selectedItem.kind === "agent") return "Sybil Agent"; if (selectedItem.kind === "chat") { if (selectedChat?.id === selectedItem.id) return getChatTitle(selectedChat, selectedChat.messages); if (selectedChatSummary) return getChatTitle(selectedChatSummary); @@ -1751,6 +1762,7 @@ export default function App() { const pageTitle = useMemo(() => { if (draftKind || !selectedItem) return "Sybil"; + if (selectedItem.kind === "agent") return "Sybil Agent — Sybil"; if (selectedItem.kind === "chat") { if (selectedChat?.id === selectedItem.id) return `${getChatTitle(selectedChat, selectedChat.messages)} — Sybil`; if (selectedChatSummary) return `${getChatTitle(selectedChatSummary)} — Sybil`; @@ -1925,7 +1937,7 @@ export default function App() { }; const selectAdjacentSidebarItem = (direction: -1 | 1) => { - if (!orderedSidebarItems.length) return; + const navigationItems: SidebarSelection[] = [{ kind: "agent", id: "root" }, ...orderedSidebarItems]; setError(null); setContextMenu(null); @@ -1934,15 +1946,14 @@ export default function App() { setIsMobileSidebarOpen(false); const current = selectedItemRef.current; const currentIndex = current - ? orderedSidebarItems.findIndex((item) => item.kind === current.kind && item.id === current.id) + ? navigationItems.findIndex((item) => item.kind === current.kind && item.id === current.id) : -1; - const fallbackIndex = direction > 0 ? 0 : orderedSidebarItems.length - 1; + const fallbackIndex = direction > 0 ? 0 : navigationItems.length - 1; const nextIndex = currentIndex < 0 ? fallbackIndex - : Math.min(orderedSidebarItems.length - 1, Math.max(0, currentIndex + direction)); - const nextItem = orderedSidebarItems[nextIndex]; - const target: SidebarSelection = { kind: nextItem.kind, id: nextItem.id }; + : Math.min(navigationItems.length - 1, Math.max(0, currentIndex + direction)); + const target = navigationItems[nextIndex]; rememberHistoryResult(target); selectedItemRef.current = target; setSelectedItem(target); @@ -3501,7 +3512,7 @@ export default function App() { @@ -3537,6 +3548,42 @@ export default function App() { Shift {primaryShortcutModifier} J +
+ {isAgentMode ? ( + setIsMobileSidebarOpen(true)} isVisible={!isMobileSidebarOpen && !isQuickQuestionOpen} /> + ) : <>
+ }
{contextMenu ? ( diff --git a/web/src/components/agent/agent-conversation.css b/web/src/components/agent/agent-conversation.css new file mode 100644 index 0000000..49bbcf9 --- /dev/null +++ b/web/src/components/agent/agent-conversation.css @@ -0,0 +1,538 @@ +.agent-room { + height:100%; + min-height:0; + min-width:0; + display:flex; + flex-direction:column; + background:radial-gradient(ellipse at 80% 0%,hsl(266 80% 30%/.12),transparent 42%),hsl(237 43% 6%/.72); + color:hsl(258 36% 96%); +} + +.agent-room__body { + position:relative; + display:flex; + flex:1; + min-height:0; + min-width:0; +} + +.agent-room__scroll { + width:100%; + overflow:auto; + overscroll-behavior:contain; + scrollbar-gutter:stable; + outline:none; + overflow-anchor:none; +} + +.agent-room__scroll:focus-visible { + outline:1px solid hsl(264 80% 74%/.48); + outline-offset:-2px; +} + +.agent-room__timeline { + display:flex; + flex-direction:column; + justify-content:flex-end; + gap:15px; + max-width:848px; + min-height:100%; + margin:0 auto; + padding:24px 30px 22px; + box-sizing:border-box; +} + +.agent-room__entry { + flex-shrink:0; + min-width:0; +} + +.agent-room__day { + display:flex; + align-items:center; + justify-content:center; + gap:18px; + margin:17px 0 24px; + color:hsl(255 19% 64%); + font-size:10px; + letter-spacing:.05em; +} + +.agent-room__day:before,.agent-room__day:after { + content:""; + height:1px; + width:48px; + background:linear-gradient(90deg,transparent,hsl(260 48% 65%/.19)); +} + +.agent-room__day:after { + transform:rotate(180deg); +} + +.agent-room__message { + display:flex; + align-items:flex-end; + gap:9px; + min-width:0; + max-width:89%; +} + +.agent-room__message[data-animation="enter"] { + animation:agent-room-message-enter 240ms cubic-bezier(.2,.7,.25,1) both; +} + +.agent-room__message.is-own { + margin-left:auto; + justify-content:flex-end; + max-width:78%; +} + +.agent-room__message-avatar { + display:grid; + place-items:center; + flex:none; + align-self:flex-end; + width:26px; + height:26px; + margin-bottom:23px; + border:1px solid hsl(267 75% 73%/.2); + border-radius:9px; + background:hsl(261 52% 20%/.56); + color:hsl(269 72% 80%); + overflow:hidden; +} + +.agent-room__message-avatar img { + display:block; + width:100%; + height:100%; + object-fit:cover; +} + +.agent-room__message-content { + min-width:0; +} + +.agent-room__plain-message { + margin:0; + white-space:pre-wrap; +} + +.agent-room__message-meta { + display:flex; + align-items:center; + gap:7px; + margin:5px 3px 0; + color:hsl(253 18% 55%); + font-size:9px; + line-height:1.4; +} + +.agent-room__message.is-own .agent-room__message-meta { + justify-content:flex-end; +} + +.agent-room__message-meta>time:before { + content:"·"; + margin-right:7px; + opacity:.6; +} + +.agent-room__delivery { + display:inline-flex; + align-items:center; + gap:3px; + color:hsl(272 52% 77%); + white-space:nowrap; +} + +.agent-room__delivery.is-error { + color:hsl(349 70% 78%); +} + +.agent-room__delivery button { + border:0; + padding:0 0 0 3px; + background:transparent; + color:inherit; + font:inherit; + text-decoration:underline; + cursor:pointer; +} + +.agent-room .md-content p { + margin-top:.55em; + margin-bottom:.55em; +} + +.agent-room .md-content>:first-child { + margin-top:0; +} + +.agent-room .md-content>:last-child { + margin-bottom:0; +} + +.agent-room .md-content pre { + max-width:100%; + font-size:12px; +} + +.agent-room .md-content h1 { + font-size:1.25em; +} + +.agent-room .md-content h2 { + font-size:1.15em; +} + +.agent-room .md-content h3 { + font-size:1.05em; +} + +.agent-room .md-content a { + color:hsl(189 79% 75%); +} + +.agent-room__tool { + margin:0 0 0 35px; + max-width:calc(89% - 35px); + border:1px solid hsl(256 35% 47%/.23); + border-radius:11px; + background:hsl(246 32% 12%/.56); + color:hsl(255 26% 75%); + font-size:11px; +} + +.agent-room__tool summary { + display:flex; + align-items:center; + gap:9px; + min-height:37px; + padding:8px 11px; + cursor:pointer; + list-style:none; +} + +.agent-room__tool summary::-webkit-details-marker { + display:none; +} + +.agent-room__tool-icon { + display:grid; + place-items:center; + color:hsl(267 69% 76%); +} + +.agent-room__tool-title { + min-width:0; + flex:1; + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap; + text-transform:capitalize; +} + +.agent-room__tool-state { + color:hsl(254 18% 51%); + font-size:9px; +} + +.agent-room__tool.is-running .agent-room__tool-state { + color:hsl(184 54% 72%); +} + +.agent-room__tool.is-failed .agent-room__tool-state { + color:hsl(351 71% 76%); +} + +.agent-room__tool-chevron { + color:hsl(255 20% 52%); + transition:transform .15s; +} + +.agent-room__tool[open] .agent-room__tool-chevron { + transform:rotate(180deg); +} + +.agent-room__tool-body { + padding:0 11px 11px; +} + +.agent-room__tool-body pre { + max-height:210px; + overflow:auto; + white-space:pre-wrap; + overflow-wrap:anywhere; + font:10px/1.6 ui-monospace,monospace; + margin:8px 0 0; + padding:9px; + border-radius:6px; + background:hsl(239 42% 6%/.7); +} + +.agent-room__typing,.agent-room__waiting { + display:flex; + align-items:center; + gap:9px; + flex:none; + min-height:20px; + margin-left:36px; + color:hsl(254 22% 63%); + font-size:11px; +} + +.agent-room__typing-dots { + display:flex; + align-items:center; + gap:3px; + padding:6px 8px; + border-radius:9px; + background:hsl(258 43% 22%/.42); +} + +.agent-room__typing-dots i { + width:3px; + height:3px; + border-radius:50%; + background:hsl(268 72% 78%); + animation:agent-room-dot 1.4s ease-in-out infinite; +} + +.agent-room__typing-dots i:nth-child(2) { + animation-delay:.16s; +} + +.agent-room__typing-dots i:nth-child(3) { + animation-delay:.32s; +} + +.agent-room__worker-count { + padding:2px 5px; + border-radius:5px; + background:hsl(265 58% 27%/.3); + color:hsl(268 58% 76%); + font-size:9px; +} + +.agent-room__jump { + position:absolute; + z-index:2; + left:50%; + bottom:14px; + transform:translateX(-50%); + display:flex; + align-items:center; + gap:7px; + white-space:nowrap; + padding:9px 14px; + border:1px solid hsl(266 76% 72%/.43); + border-radius:99px; + background:hsl(256 46% 20%/.96); + color:hsl(263 60% 93%); + box-shadow:0 5px 24px hsl(240 70% 2%/.5); + font-size:11px; + cursor:pointer; + backdrop-filter:blur(14px); +} + +.agent-room__jump:hover { + background:hsl(261 48% 27%); +} + +.agent-room__composer-area { + flex:none; + border-top:1px solid hsl(253 40% 40%/.14); + padding:16px 30px max(13px,var(--safe-area-bottom)); + background:linear-gradient(180deg,hsl(240 41% 7%/.72),hsl(240 43% 7%/.97)); +} + +.agent-room__composer-inner { + max-width:788px; + margin:0 auto; +} + +.agent-room__composer { + display:flex; + align-items:flex-end; + gap:10px; + padding:7px 8px 7px 5px; + border:1px solid hsl(263 51% 58%/.3); + border-radius:17px; + background:hsl(247 35% 13%/.9); + box-shadow:inset 0 1px 0 hsl(266 80% 92%/.04),0 6px 24px hsl(239 80% 2%/.12); + transition:border-color .15s,box-shadow .15s; +} + +.agent-room__composer:focus-within { + border-color:hsl(265 74% 69%/.65); + box-shadow:0 0 0 3px hsl(266 69% 55%/.08); +} + +.agent-room__composer textarea { + display:block; + flex:1; + min-width:0; + min-height:46px; + max-height:156px; + margin:0; + resize:none; + overflow:auto; + border:0; + outline:0; + background:transparent; + padding:13px 11px; + font-size:14px; + line-height:20px; + color:hsl(259 44% 96%); + box-shadow:none; +} + +.agent-room__composer textarea::placeholder { + color:hsl(255 23% 53%); +} + +.agent-room__send { + display:grid; + place-items:center; + flex:none; + width:38px; + height:38px; + margin-bottom:4px; + border:1px solid hsl(265 86% 79%/.32); + border-radius:11px; + background:linear-gradient(140deg,hsl(264 80% 59%),hsl(270 75% 49%)); + color:hsl(265 65% 98%); + box-shadow:0 3px 12px hsl(264 75% 45%/.22); + cursor:pointer; + transition:filter .15s,opacity .15s; +} + +.agent-room__send:hover:not(:disabled) { + filter:brightness(1.17); +} + +.agent-room__send:disabled { + opacity:.34; + box-shadow:none; + cursor:default; +} + +.agent-room__error { + display:flex; + align-items:center; + gap:9px; + margin-bottom:11px; + padding:10px 12px; + border:1px solid hsl(350 62% 62%/.2); + border-radius:11px; + background:hsl(340 45% 22%/.22); + color:hsl(349 70% 85%); + font-size:11px; +} + +.agent-room__error>span { + min-width:0; + flex:1; +} + +.agent-room__error>svg { + flex:none; +} + +.agent-room__error button { + flex:none; + border:1px solid hsl(350 57% 66%/.28); + border-radius:6px; + padding:4px 8px; + color:hsl(349 66% 90%); + font-size:10px; + cursor:pointer; +} + +.agent-room__empty { + flex:none; + margin:auto; + max-width:360px; + padding:48px 15px; + text-align:center; + color:hsl(254 25% 65%); +} + +.agent-room button:focus-visible,.agent-room summary:focus-visible { + outline:2px solid hsl(188 82% 71%); + outline-offset:3px; +} + +@keyframes agent-room-dot { + 0%,70%,100% { + opacity:.35; + transform:translateY(0); + } + 35% { + opacity:1; + transform:translateY(-2px); + } +} + +@keyframes agent-room-message-enter { + from { + opacity:0; + transform:translateY(10px); + } + to { + opacity:1; + transform:translateY(0); + } +} + +@media(max-width:767px) { + .agent-room__timeline { + padding:17px 14px 20px; + gap:14px; + } + .agent-room__message { + max-width:96%; + gap:7px; + } + .agent-room__message.is-own { + max-width:87%; + } + .agent-room__message-avatar { + width:23px; + height:23px; + border-radius:8px; + } + .agent-room__tool { + margin-left:30px; + max-width:calc(96% - 30px); + } + .agent-room__typing,.agent-room__waiting { + margin-left:30px; + font-size:10px; + } + .agent-room__composer-area { + padding:12px 13px max(11px,var(--safe-area-bottom)); + } + .agent-room__composer textarea { + font-size:16px; + line-height:22px; + } + .agent-room__empty { + max-width:300px; + } +} + +@media(prefers-reduced-motion:reduce) { + .agent-room__message[data-animation="enter"] { + animation:none; + } + .agent-room__typing-dots i { + animation:none; + opacity:.7; + } + .agent-room * { + scroll-behavior:auto!important; + transition:none!important; + } +} diff --git a/web/src/components/agent/agent-conversation.tsx b/web/src/components/agent/agent-conversation.tsx new file mode 100644 index 0000000..06a3c8c --- /dev/null +++ b/web/src/components/agent/agent-conversation.tsx @@ -0,0 +1,350 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; +import { ArrowDown, ChevronDown, Clock3, LoaderCircle, Menu, RefreshCw, SendHorizontal, WifiOff, Wrench } from "lucide-preact"; +import type { Message } from "@/lib/api"; +import type { AgentRoomHookResult } from "@/hooks/use-agent-room"; +import { MarkdownContent } from "@/components/markdown/markdown-content"; +import { Button } from "@/components/ui/button"; +import "./agent-conversation.css"; + +type Props = { + room: AgentRoomHookResult; + onOpenSidebar: () => void; + /** False while a mobile navigation overlay covers this pane. */ + isVisible?: boolean; +}; + +type ReadingAnchor = { id: string; offset: number }; +const DRAFT_KEY = "sybil.pi-root.draft"; +const timeFormat = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }); +const dayFormat = new Intl.DateTimeFormat(undefined, { weekday: "short", month: "short", day: "numeric" }); + +function savedDraft() { + try { return sessionStorage.getItem(DRAFT_KEY) || ""; } catch { return ""; } +} +function saveDraft(value: string) { + try { if (value) sessionStorage.setItem(DRAFT_KEY, value); else sessionStorage.removeItem(DRAFT_KEY); } catch { /* A blocked browser store does not prevent chatting. */ } +} +function metadata(message: Message): Record { + return message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata) + ? message.metadata as Record : {}; +} +function dateOf(value: string) { + const date = new Date(value); + return Number.isNaN(date.getTime()) || date.getFullYear() < 2000 ? null : date; +} +function dayKey(date: Date | null) { + return date ? `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` : "older"; +} +function dayLabel(date: Date | null) { + if (!date) return "Earlier"; + const today = new Date(); + if (dayKey(date) === dayKey(today)) return "Today"; + today.setDate(today.getDate() - 1); + return dayKey(date) === dayKey(today) ? "Yesterday" : dayFormat.format(date); +} +function toolName(value: unknown) { + return typeof value === "string" ? value.replace(/_/g, " ") : "Tool"; +} + +function AgentToolCard({ message }: { message: Message }) { + const info = metadata(message); + const active = info.status === "initiated"; + const failed = info.status === "failed"; + const title = toolName(info.toolName || message.name); + const preview = typeof info.resultPreview === "string" ? info.resultPreview : typeof info.error === "string" ? info.error : ""; + return ( +
+ + + {title} + {active ? "Running" : failed ? "Failed" : "Done"} + +
+ {info.args && typeof info.args === "object" ?
{JSON.stringify(info.args, null, 2)}
: null} + {preview ?
{preview}
: null} + {!info.args && !preview ?

{active ? "This tool is still running." : "No additional details."}

: null} +
+
+ ); +} + +function AgentBubble({ message, animate, onRetry }: { message: Message; animate: boolean; onRetry: (id: string) => void }) { + const own = message.role === "user"; + const info = metadata(message); + const status = typeof info.status === "string" ? info.status : "complete"; + const failed = status === "failed" || status === "unconfirmed"; + const date = dateOf(message.createdAt); + return ( +
+ {!own ? : null} +
+
+ {own ?

{message.content}

: } +
+
+ {own ? "You" : "Sybil"} + {date ? : null} + {own && status === "sending" ? Sending… : null} + {own && status === "queued" ? : null} + {own && failed ? + {status === "unconfirmed" ? "Unconfirmed" : "Not sent"} + + : null} +
+
+
+ ); +} + +export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Props) { + const [draft, setDraft] = useState(savedDraft); + const draftRef = useRef(draft); + const seenMessageIdsRef = useRef(new Set(room.messages.map(message => message.id))); + const enteringMessageIdsRef = useRef(new Set()); + const animatedEntryIdsRef = useRef(new Set()); + const entryAnimationsRef = useRef(new Set()); + const typingHeightRef = useRef(0); + const historyReadyRef = useRef(!room.isLoading); + const [atBottom, setAtBottom] = useState(true); + const scrollerRef = useRef(null); + const timelineRef = useRef(null); + const textareaRef = useRef(null); + const pinnedRef = useRef(true); + const restoredScrollTopRef = useRef(null); + const anchorRef = useRef(null); + const markReadRef = useRef(room.markRead); + const visibleRef = useRef(isVisible); + const loadingRef = useRef(room.isLoading); + const readFrameRef = useRef(null); + const mountedRef = useRef(true); + markReadRef.current = room.markRead; + visibleRef.current = isVisible; + loadingRef.current = room.isLoading; + + const distanceFromBottom = () => { + const element = scrollerRef.current; + return element ? element.scrollHeight - element.scrollTop - element.clientHeight : Infinity; + }; + const scheduleRead = () => { + if (readFrameRef.current !== null) cancelAnimationFrame(readFrameRef.current); + readFrameRef.current = requestAnimationFrame(() => { + readFrameRef.current = null; + if (mountedRef.current && visibleRef.current && !loadingRef.current && entryAnimationsRef.current.size === 0 && document.visibilityState === "visible" && document.hasFocus() && distanceFromBottom() <= 24) { + markReadRef.current(); + } + }); + }; + const rememberAnchor = () => { + const element = scrollerRef.current; + if (!element || pinnedRef.current) { anchorRef.current = null; return; } + const top = element.getBoundingClientRect().top; + // Anchor to layout, independently of the bubble's entrance transform. + const node = Array.from(element.querySelectorAll("[data-agent-entry-id]")).find(item => item.getBoundingClientRect().bottom > top + 8); + anchorRef.current = node ? { id: node.dataset.agentEntryId!, offset: node.getBoundingClientRect().top - top } : null; + }; + const restoreViewport = () => { + const element = scrollerRef.current; + if (!element) return; + if (pinnedRef.current) element.scrollTop = element.scrollHeight; + else if (anchorRef.current) { + const anchor = anchorRef.current; + const node = Array.from(element.querySelectorAll("[data-agent-entry-id]")).find(item => item.dataset.agentEntryId === anchor.id); + if (node) element.scrollTop += node.getBoundingClientRect().top - element.getBoundingClientRect().top - anchor.offset; + } + restoredScrollTopRef.current = element.scrollTop; + rememberAnchor(); + scheduleRead(); + }; + const jumpToLatest = () => { + pinnedRef.current = true; + anchorRef.current = null; + setAtBottom(true); + if (scrollerRef.current) { + scrollerRef.current.scrollTop = scrollerRef.current.scrollHeight; + restoredScrollTopRef.current = scrollerRef.current.scrollTop; + } + scheduleRead(); + }; + + useLayoutEffect(() => { + const timeline = timelineRef.current; + if (!timeline) return; + const gap = parseFloat(getComputedStyle(timeline).rowGap) || 0; + const typing = timeline.querySelector(".agent-room__typing"); + // Transfer the departing indicator's space to the reply before it grows. + // Removing that space first would briefly move the transcript downward. + let replacedTypingHeight = typing ? 0 : typingHeightRef.current; + typingHeightRef.current = typing ? typing.offsetHeight + (typing.previousElementSibling ? gap : 0) : 0; + const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + for (const entry of timeline.querySelectorAll("[data-agent-entry-id]")) { + const id = entry.dataset.agentEntryId!; + if (animatedEntryIdsRef.current.has(id) || !enteringMessageIdsRef.current.has(id)) continue; + animatedEntryIdsRef.current.add(id); + if (reduceMotion) continue; + const height = entry.getBoundingClientRect().height; + const spacing = entry.previousElementSibling ? gap : 0; + const isReply = Boolean(entry.querySelector(".agent-room__message.is-agent")); + const initialHeight = isReply ? Math.min(replacedTypingHeight, height + spacing) : 0; + if (isReply) replacedTypingHeight = 0; + // Include the inter-message gap in the reveal. Clipping keeps the bubble's + // positive-Y transform from prematurely extending the scrollable area. + entry.style.overflow = "clip"; + const animation = entry.animate([ + { height: `${initialHeight}px`, marginTop: `${-spacing}px` }, + { height: `${height}px`, marginTop: "0px" }, + ], { duration: 240, easing: "cubic-bezier(.2,.7,.25,1)" }); + entryAnimationsRef.current.add(animation); + animation.onfinish = () => { + entry.style.overflow = ""; + entryAnimationsRef.current.delete(animation); + restoreViewport(); + }; + } + }, [room.messages, room.isTyping]); + + useLayoutEffect(() => { + restoreViewport(); + }, [room.messages, room.isTyping, room.activity?.state, room.isLoading, isVisible]); + + useEffect(() => { + mountedRef.current = true; + const observer = new ResizeObserver(restoreViewport); + if (scrollerRef.current) observer.observe(scrollerRef.current); + if (timelineRef.current) observer.observe(timelineRef.current); + const onVisibility = () => scheduleRead(); + window.addEventListener("focus", onVisibility); + document.addEventListener("visibilitychange", onVisibility); + return () => { + mountedRef.current = false; + observer.disconnect(); + for (const animation of entryAnimationsRef.current) animation.cancel(); + entryAnimationsRef.current.clear(); + window.removeEventListener("focus", onVisibility); + document.removeEventListener("visibilitychange", onVisibility); + if (readFrameRef.current !== null) cancelAnimationFrame(readFrameRef.current); + }; + }, []); + + useLayoutEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + textarea.style.height = "0px"; + textarea.style.height = `${Math.min(156, Math.max(46, textarea.scrollHeight))}px`; + }, [draft]); + + const updateDraft = (value: string) => { + draftRef.current = value; + saveDraft(value); + setDraft(value); + }; + + const retryMessage = (id: string) => { + // Admission errors remain attached to their own bubble. + void room.retryMessage(id).catch(() => {}); + }; + const submit = () => { + const content = draftRef.current.trim(); + if (!content) return; + const delivery = room.sendMessage(content); + updateDraft(""); + jumpToLatest(); + void delivery.catch(() => {}); + textareaRef.current?.focus(); + }; + + const messages = useMemo(() => { + let previousDay = ""; + for (const message of room.messages) { + if (!seenMessageIdsRef.current.has(message.id) && historyReadyRef.current && !room.isLoading && message.role !== "tool") { + enteringMessageIdsRef.current.add(message.id); + } + seenMessageIdsRef.current.add(message.id); + } + if (!room.isLoading) historyReadyRef.current = true; + return room.messages.filter(message => message.role !== "system").map(message => { + const date = dateOf(message.createdAt), day = dayKey(date), separator = previousDay !== day; + previousDay = day; + return ( +
+ {separator ?
{dayLabel(date)}
: null} + {message.role === "tool" ? : } +
+ ); + }); + }, [room.messages, room.isLoading, room.retryMessage]); + + const activity = room.activity; + const running = room.isTyping; + const waiting = activity?.state === "waiting"; + const connected = room.connection === "connected"; + const connectionLabel = connected ? "Connected" : room.connection === "offline" ? "Offline" : room.connection === "reconnecting" ? "Reconnecting" : "Connecting"; + const activeTool = activity?.tools?.find(tool => tool.status !== "done"); + const workingLabel = activeTool ? `Using ${toolName(activeTool.name)}` + : activity?.isThinking ? "Sybil is thinking" : (activity?.backgroundWorkers || 0) > 0 ? "Working in the background" : ""; + + return ( +
+
+
+ +

Sybil Agent

+
+
+ {connectionLabel} + {!connected ? : null} +
+
+ +
+
{ + // A scroll event from our last resize correction can arrive after + // the next animation frame has already grown a tall reply. That + // temporary bottom distance does not mean the reader scrolled up. + if (restoredScrollTopRef.current !== null && scrollerRef.current + && Math.abs(scrollerRef.current.scrollTop - restoredScrollTopRef.current) < 1) return; + const bottom = distanceFromBottom() <= 48; + pinnedRef.current = bottom; + setAtBottom(current => current === bottom ? current : bottom); + rememberAnchor(); + if (bottom) scheduleRead(); + }} + > +
+ {room.isLoading && !messages.length ?
: null} + {messages} + {running ?
{workingLabel}{(activity?.backgroundWorkers || 0) > 1 ? {activity!.backgroundWorkers} workers : null}
: null} + {!running && waiting && activity?.wait ?
Checking back at {timeFormat.format(new Date(activity.wait.due))}
: null} +
+
+ {!atBottom ? : null} +
+ +
+
+ {room.error ?
{room.error}
: null} +
{ event.preventDefault(); submit(); }}> +