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 0000000..073668a Binary files /dev/null and b/web/public/character-still.png differ diff --git a/web/src/App.tsx b/web/src/App.tsx index 7b150e7..6eb731b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/ho import type { TargetedTouchEvent } from "preact"; import { Check, + Bot, ChevronDown, GitFork, Globe2, @@ -25,6 +26,7 @@ import { AuthScreen } from "@/components/auth/auth-screen"; import { ChatAttachmentList } from "@/components/chat/chat-attachment-list"; import { ChatComposer } from "@/components/chat/chat-composer"; import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel"; +import { AgentConversation } from "@/components/agent/agent-conversation"; import { SearchResultsPanel } from "@/components/search/search-results-panel"; import { SearchMatchLabel } from "@/components/search/search-match-label"; import { SybilCharacter } from "@/components/sybil-character"; @@ -67,6 +69,7 @@ import { type WorkspaceItem, } from "@/lib/api"; import { useSessionAuth } from "@/hooks/use-session-auth"; +import { useAgentRoom } from "@/hooks/use-agent-room"; import { useWorkspaceSearch } from "@/hooks/use-workspace-search"; import { getChatModelSelection, @@ -81,6 +84,7 @@ import { retainSelectedHistoryItem, resolveSidebarSelectionAfterRefresh, type SidebarSelection, + type HistorySelection, } from "@/lib/sidebar-selection"; import { buildQuickQuestionRequest, pickQuickQuestionModel, QUICK_QUESTION_DEFAULT_MODEL } from "@/lib/quick-question"; import { @@ -96,7 +100,7 @@ import { import { cn } from "@/lib/utils"; type DraftSelectionKind = "chat" | "search"; -type SidebarItem = SidebarSelection & { +type SidebarItem = HistorySelection & { title: string; searchSnippet: string | null; parentChatId: string | null; @@ -140,6 +144,7 @@ type RefreshCollectionsOptions = { function readSidebarSelectionFromUrl(): SidebarSelection | null { if (typeof window === "undefined") return null; const params = new URLSearchParams(window.location.search); + if (params.get("agent") === "root") return { kind: "agent", id: "root" }; const chatId = params.get("chat")?.trim(); if (chatId) { return { kind: "chat", id: chatId }; @@ -156,8 +161,9 @@ function buildWorkspaceUrl(selection: SidebarSelection | null) { const params = new URLSearchParams(window.location.search); params.delete("chat"); params.delete("search"); + params.delete("agent"); if (selection) { - params.set(selection.kind === "chat" ? "chat" : "search", selection.id); + params.set(selection.kind, selection.id); } const query = params.toString(); return `${window.location.pathname}${query ? `?${query}` : ""}`; @@ -964,6 +970,7 @@ export default function App() { handleSignIn, logout, } = useSessionAuth(); + const agentRoom = useAgentRoom(isAuthenticated); const [chats, setChats] = useState([]); 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(); }}> +