diff --git a/package-lock.json b/package-lock.json index e4707ea..52aaa71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,6 @@ "name": "agent-floor", "version": "0.1.0", "license": "MIT", - "dependencies": { - "electron": "44.7.0" - }, "bin": { "agent-floor": "bin/agent-floor.mjs" }, diff --git a/server/chat.mjs b/server/chat.mjs new file mode 100644 index 0000000..512bcad --- /dev/null +++ b/server/chat.mjs @@ -0,0 +1,74 @@ +// Stream a chat reply from the Anthropic-compatible proxy. +// +// Reads ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY from env at call time, so a +// server started without them can still serve chat once they are configured. +// Returns an async generator of text deltas. + +const DEFAULT_MAX_TOKENS = 4096; + +export function resolveConfig() { + const raw = (process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/+$/, ''); + // Some gateways already carry /v1 in the base; do not ask for /v1/messages twice. + const messagesUrl = /\/v1$/.test(raw) ? `${raw}/messages` : `${raw}/v1/messages`; + return { + messagesUrl, + key: process.env.ANTHROPIC_API_KEY || '', + model: process.env.AGENT_FLOOR_CHAT_MODEL || 'claude-opus-4-5', + }; +} + +export async function* streamChat({ prompt, model }) { + const { messagesUrl, key, model: fallback } = resolveConfig(); + if (!key) throw new Error('chat: ANTHROPIC_API_KEY is not set'); + + const res = await fetch(messagesUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': key, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: model || fallback, + max_tokens: DEFAULT_MAX_TOKENS, + stream: true, + messages: [{ role: 'user', content: prompt }], + }), + }).catch((err) => { + throw Object.assign(new Error(`chat: cannot reach ${messagesUrl}`), { cause: err }); + }); + + if (!res.ok || !res.body) { + const text = await res.text().catch(() => ''); + throw Object.assign(new Error(`chat: HTTP ${res.status} ${text.slice(0, 200)}`), { status: res.status }); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const frames = buffer.split('\n\n'); + buffer = frames.pop() || ''; + for (const frame of frames) { + let event = ''; + let data = ''; + for (const line of frame.split('\n')) { + if (line.startsWith('event:')) event = line.slice(6).trim(); + else if (line.startsWith('data:')) data = line.slice(5).trimStart(); + } + if (event !== 'content_block_delta') continue; + try { + const delta = JSON.parse(data)?.delta; + if (delta?.type === 'text_delta' && delta.text) yield delta.text; + } catch { /* skip malformed frame */ } + } + } + } finally { + reader.cancel().catch(() => {}); + } +} diff --git a/server/index.mjs b/server/index.mjs index 367f6c9..c66c7f5 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -2,6 +2,7 @@ // // POST /events one event or an array of events (see docs/protocol.md) // GET /stream Server-Sent Events: a `snapshot`, then `event` + `effects` +// POST /chat spawn a chat session, stream reply via SSE // GET /state current world as JSON // POST /reset clear the floor // GET /* the built web app (dist/) @@ -10,6 +11,7 @@ import { readFile, stat } from 'node:fs/promises'; import { extname, join, normalize, resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { applyEvent, createState, normalizeEvent, sweep, PROTOCOL_VERSION } from '../shared/protocol.js'; +import { streamChat, resolveConfig } from './chat.mjs'; const here = dirname(fileURLToPath(import.meta.url)); const MAX_BODY = 256 * 1024; @@ -107,6 +109,54 @@ export function startServer({ const loopback = host === '127.0.0.1' || host === 'localhost' || host === '::1'; + // POST /chat { prompt } -> SSE stream: `delta` frames, then `done` or `error`. + // Runs on the same trust level as POST /events: loopback only, no auth. + async function handleChat(req, res) { + let body; + try { body = await readBody(req); } + catch (err) { return json(res, 400, { error: err.message }); } + + const prompt = typeof body?.prompt === 'string' ? body.prompt.trim() : ''; + if (!prompt) return json(res, 400, { error: 'prompt is required' }); + if (prompt.length > 8000) return json(res, 400, { error: 'prompt too long' }); + + const agentId = `chat-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; + const model = typeof body?.model === 'string' ? body.model : resolveConfig().model; + + // The agent takes a desk while it works, then walks out. + ingest({ type: 'join', agent: agentId, name: 'Web Chat', role: 'Agent', team: 'web-chat', source: 'agent-floor', ts: Date.now() }); + ingest({ type: 'status', agent: agentId, status: 'thinking', activity: 'Thinking...', ts: Date.now() }); + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + + let closed = false; + req.on('close', () => { closed = true; }); + + let text = ''; + try { + for await (const chunk of streamChat({ prompt, model })) { + if (closed) return; + text += chunk; + res.write(`event: delta\ndata: ${JSON.stringify(chunk)}\n\n`); + } + if (!closed) res.write(`event: done\ndata: ${JSON.stringify(text || '(no response)')}\n\n`); + ingest({ type: 'status', agent: agentId, status: 'done', activity: 'Finished', ts: Date.now() }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (!closed) res.write(`event: error\ndata: ${JSON.stringify(msg)}\n\n`); + ingest({ type: 'status', agent: agentId, status: 'error', activity: msg.slice(0, 280), ts: Date.now() }); + } finally { + // The desk stays a little while after the reply, then the agent walks out. + setTimeout(() => ingest({ type: 'leave', agent: agentId }), 10000); + res.end(); + } + } + const server = createServer(async (req, res) => { // DNS-rebinding guard: when bound to loopback, only answer loopback hostnames. if (loopback && !/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(req.headers.host || '')) { @@ -143,6 +193,10 @@ export function startServer({ } if (pathname === '/state' && req.method === 'GET') return json(res, 200, state); if (pathname === '/health') return json(res, 200, { ok: true, version: PROTOCOL_VERSION, agents: Object.keys(state.agents).length }); + if (pathname === '/chat' && req.method === 'POST') { + await handleChat(req, res); + return; + } if (pathname === '/reset' && req.method === 'POST') { ingest({ type: 'reset' }); return json(res, 200, { ok: true }); diff --git a/src/core/connection.ts b/src/core/connection.ts index 0bc6493..f117283 100644 --- a/src/core/connection.ts +++ b/src/core/connection.ts @@ -6,7 +6,7 @@ import { DEMO_ONLY } from '../config'; const params = new URLSearchParams(location.search); /** Where the floor server lives. Same origin by default; `?server=` overrides. */ -function serverBase() { +export function serverBase() { const explicit = params.get('server'); if (explicit) return explicit.replace(/\/$/, ''); return ''; diff --git a/src/main.ts b/src/main.ts index 21f335f..f420903 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ import '@fontsource-variable/geist'; import '@fontsource-variable/geist-mono'; import './styles.css'; -import { connect, isDemoRunning, startDemo } from './core/connection'; +import { connect, isDemoRunning, serverBase, startDemo } from './core/connection'; import { store } from './core/store'; import { Hud } from './ui/hud'; import { World } from './world/World'; @@ -62,6 +62,7 @@ async function boot() { startDemo: () => { if (!isDemoRunning()) startDemo(); }, + submitChat, }); function applyTheme(next: ThemeName, persist = false) { @@ -114,4 +115,44 @@ async function boot() { } } +// Web chat: POST a prompt, hand each streamed chunk back as it arrives. +async function submitChat(prompt: string, onDelta: (chunk: string) => void): Promise { + const res = await fetch(`${serverBase()}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt }), + }); + if (!res.ok || !res.body) { + const text = await res.text().catch(() => ''); + throw new Error(text || `HTTP ${res.status}`); + } + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let error = ''; + const readFrame = (frame: string) => { + let event = ''; + let data = ''; + for (const line of frame.split('\n')) { + if (line.startsWith('event:')) event = line.slice(6).trim(); + else if (line.startsWith('data:')) data = line.slice(5).trimStart(); + } + if (!data) return; + if (event === 'delta') { + try { onDelta(JSON.parse(data) as string); } catch { /* skip malformed */ } + } else if (event === 'error') { + try { error = JSON.parse(data) as string; } catch { /* skip malformed */ } + } + }; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const frames = buffer.split('\n\n'); + buffer = frames.pop() || ''; + for (const frame of frames) readFrame(frame); + } + if (error) throw new Error(error); +} + boot(); diff --git a/src/styles.css b/src/styles.css index c0280c5..ea47c53 100644 --- a/src/styles.css +++ b/src/styles.css @@ -157,6 +157,7 @@ button { .topbar, .demo-note, .teams, +.busy, .card, .empty, .hint { @@ -429,6 +430,151 @@ button { box-shadow: 0 0 0 2px var(--glass-strong); } +/* ---------------------------------------------------------------- who is working */ + +.tools .icon-btn { + position: relative; +} + +.busy-badge { + position: absolute; + top: 1px; + right: 1px; + min-width: 15px; + height: 15px; + padding: 0 4px; + display: grid; + place-items: center; + border-radius: 999px; + background: var(--accent); + color: var(--accent-ink); + font: 700 9.5px/1 'Geist Mono Variable', ui-monospace, monospace; + pointer-events: none; +} + +.tools .icon-btn.active { + background: var(--fill-2); +} + +.busy { + position: absolute; + left: 16px; + top: calc(max(16px, env(safe-area-inset-top)) + 58px); + width: 340px; + max-height: min(60dvh, 480px); + display: flex; + flex-direction: column; + padding: 12px 8px 8px; + border-radius: var(--radius-panel); + opacity: 0; + transform: translateX(-24px) scale(0.98); + transform-origin: 0% 0%; + visibility: hidden; + transition: opacity 220ms ease, transform 420ms var(--ease-out), visibility 0s linear 420ms; +} + +.busy.show { + opacity: 1; + transform: none; + visibility: visible; + transition: opacity 220ms ease, transform 420ms var(--ease-out), visibility 0s; +} + +.busy-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 8px 6px; +} + +.busy-head h2 { + margin: 0; + font-size: 14px; + font-weight: 650; +} + +.busy-list { + list-style: none; + margin: 0; + padding: 0; + overflow: auto; +} + +.busy-row { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px; + border: 0; + border-radius: 12px; + background: transparent; + color: var(--text); + text-align: left; + cursor: pointer; + transition: background-color 160ms ease; +} + +.busy-row:hover { + background: var(--fill); +} + +.busy-row.selected { + background: var(--fill-2); +} + +.busy-row .dot { + width: 9px; + height: 9px; + border-radius: 50%; + flex: none; +} + +.busy-who { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 1px; +} + +.busy-who b { + font-size: 13.5px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.busy-meta { + color: var(--text-2); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.busy-when { + flex: none; + color: var(--text-3); + font: 500 11px/1 'Geist Mono Variable', ui-monospace, monospace; +} + +.busy-none { + margin: 0; + padding: 4px 8px 8px; + color: var(--text-2); + font-size: 13px; +} + +@media (max-width: 640px) { + .busy { + right: 16px; + width: auto; + } +} + /* ---------------------------------------------------------------- hint */ @media (max-width: 1180px) { @@ -1123,3 +1269,152 @@ button { .in-app-mac .demo-note { top: 64px; } + +/* ---------------------------------------------------------------- chat panel */ + +.chat { + position: absolute; + right: 16px; + top: calc(max(16px, env(safe-area-inset-top)) + 58px); + width: 340px; + max-height: min(60dvh, 520px); + display: flex; + flex-direction: column; + padding: 12px 8px 8px; + border-radius: var(--radius-panel); + opacity: 0; + transform: translateX(24px) scale(0.98); + transform-origin: 100% 0%; + visibility: hidden; + transition: opacity 220ms ease, transform 420ms var(--ease-out), visibility 0s linear 420ms; +} + +.chat.show { + opacity: 1; + transform: none; + visibility: visible; + transition: opacity 220ms ease, transform 420ms var(--ease-out), visibility 0s; +} + +.chat-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 8px 6px; +} + +.chat-head h2 { + margin: 0; + font-size: 14px; + font-weight: 650; +} + +.chat-close { + padding: 2px 4px; + font-size: 11px; + line-height: 1; +} + +.chat-messages { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; + padding: 4px 2px; + min-height: 0; +} + +.chat-msg { + padding: 8px 10px; + border-radius: 10px; + font-size: 13px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +.chat-msg.user { + align-self: flex-end; + background: var(--accent); + color: var(--accent-ink); +} + +.chat-msg.assistant { + align-self: flex-start; + background: var(--fill-2); + color: var(--text); +} + +.chat-msg.assistant.stream::after { + content: '▋'; + animation: blink 1s step-end infinite; + display: inline; +} + +@keyframes blink { + 50% { opacity: 0; } +} + +.chat-input-row { + display: flex; + gap: 6px; + align-items: flex-end; + padding-top: 6px; + border-top: 1px solid var(--glass-edge); + margin-top: 6px; +} + +.chat-input { + flex: 1; + resize: none; + padding: 6px 8px; + border: 0; + border-radius: 8px; + background: var(--fill); + color: var(--text); + font: inherit; + font-size: 13px; + line-height: 1.4; + min-height: 38px; + max-height: 120px; +} + +.chat-input:focus { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.chat-input::placeholder { + color: var(--muted); +} + +.chat-send { + padding: 6px 8px; + font-size: 11px; +} + +.chat-send:disabled { + opacity: 0.4; +} + +.chat-status { + font-size: 11px; + color: var(--muted); + padding: 4px 2px 0; + min-height: 14px; +} + +.chat-status.error { + color: var(--accent, #f66); +} + +@media (max-width: 640px) { + .chat { + right: 8px; + left: 8px; + width: auto; + top: calc(max(16px, env(safe-area-inset-top)) + 52px); + } +} diff --git a/src/ui/chat.ts b/src/ui/chat.ts new file mode 100644 index 0000000..2d54b17 --- /dev/null +++ b/src/ui/chat.ts @@ -0,0 +1,110 @@ +// Chat panel for the Agent Floor. Lets a browser visitor type a prompt, the +// server streams the reply, and an agent desk appears on the floor while it +// works. + +import { icon } from './icons'; +import { el } from './hud'; + +/** Sends a prompt; `onDelta` is called with each streamed chunk. */ +export type SendChat = (prompt: string, onDelta: (chunk: string) => void) => Promise; + +export class Chat { + private root = el('section', 'chat glass'); + private header = el('div', 'chat-head'); + private title = el('h2', '', 'Web Chat'); + private closeBtn = el('button', 'chat-close', icon('x')) as HTMLButtonElement; + private messages = el('div', 'chat-messages'); + private inputRow = el('div', 'chat-input-row'); + private input = el('textarea', 'chat-input') as HTMLTextAreaElement; + private sendBtn = el('button', 'chat-send', icon('play')) as HTMLButtonElement; + private status = el('div', 'chat-status', ''); + private open = false; + private busy = false; + + constructor(private onSend: SendChat) { + this.closeBtn.type = 'button'; + this.closeBtn.setAttribute('aria-label', 'Close chat'); + this.closeBtn.title = 'Close chat'; + this.closeBtn.addEventListener('click', () => this.toggle(false)); + + this.sendBtn.type = 'button'; + this.sendBtn.setAttribute('aria-label', 'Send'); + this.sendBtn.addEventListener('click', () => this.send()); + + this.input.rows = 2; + this.input.placeholder = 'Type a design task...'; + this.input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + this.send(); + } + }); + + this.header.append(this.title, this.closeBtn); + this.inputRow.append(this.input, this.sendBtn); + this.root.append(this.header, this.messages, this.inputRow, this.status); + } + + get button() { + const b = el('button', 'icon-btn chat-btn', icon('chat')) as HTMLButtonElement; + b.type = 'button'; + b.setAttribute('aria-label', 'Chat with an agent'); + b.title = 'Chat (C)'; + b.addEventListener('click', () => this.toggle()); + return b; + } + + get rootEl() { + return this.root; + } + + get isOpen() { + return this.open; + } + + toggle(force?: boolean) { + this.open = typeof force === 'boolean' ? force : !this.open; + this.root.classList.toggle('show', this.open); + this.button.classList.toggle('active', this.open); + if (this.open) this.input.focus(); + } + + private setStatus(text: string, isError = false) { + this.status.textContent = text; + this.status.classList.toggle('error', isError); + } + + private bubble(cls: string) { + const b = el('div', `chat-msg ${cls}`); + this.messages.append(b); + this.messages.scrollTop = this.messages.scrollHeight; + return b; + } + + private async send() { + if (this.busy) return; + const text = this.input.value.trim(); + if (!text) return; + this.input.value = ''; + this.busy = true; + this.sendBtn.disabled = true; + this.setStatus('thinking...'); + this.bubble('user').textContent = text; + const reply = this.bubble('assistant streaming'); + try { + await this.onSend(text, (chunk) => { + reply.textContent += chunk; + this.messages.scrollTop = this.messages.scrollHeight; + }); + this.setStatus(''); + } catch (err) { + reply.textContent = err instanceof Error ? err.message : String(err); + this.setStatus('failed', true); + } finally { + reply.classList.remove('streaming'); + this.busy = false; + this.sendBtn.disabled = false; + this.input.focus(); + } + } +} diff --git a/src/ui/hud.ts b/src/ui/hud.ts index 0c9a302..f8ef004 100644 --- a/src/ui/hud.ts +++ b/src/ui/hud.ts @@ -3,16 +3,17 @@ import type { ConnectionMode } from '../core/store'; import type { Anchor } from '../world/World'; import { looksFor, shirtFor, sourceLabel, STATUS_COLORS, STATUS_LABEL, teamAccent } from '../world/palette'; import { icon } from './icons'; +import { Chat } from './chat'; import { REPO_URL } from '../config'; -function el(tag: K, cls?: string, html?: string) { +export function el(tag: K, cls?: string, html?: string) { const e = document.createElement(tag); if (cls) e.className = cls; if (html !== undefined) e.innerHTML = html; return e; } -function esc(s: string) { +export function esc(s: string) { return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!); } @@ -47,6 +48,18 @@ export interface HudActions { frameAll(): void; toggleTheme(): void; startDemo(): void; + submitChat(prompt: string, onDelta: (chunk: string) => void): Promise; +} + +/** Statuses that mean "this agent is on the clock right now". */ +const BUSY = new Set(['thinking', 'working', 'waiting', 'error']); +const BUSY_RANK: Record = { waiting: 0, error: 0, working: 1, thinking: 2 }; + +/** Short label for a desk. Subagents show their type; the lead shows its project. */ +function roleOf(a: Agent) { + return (a.role || '').toLowerCase() === (a.name || '').toLowerCase() + ? (a.parent ? a.role : '') + : a.role || (a.parent ? 'Agent' : ''); } const SETUP: Record = { @@ -96,10 +109,16 @@ export class Hud { private modePill = el('div', 'mode'); private demoNote = el('div', 'demo-note glass'); private teamsDock = el('nav', 'teams glass'); + private busyPanel = el('section', 'busy glass'); + private busyBadge = el('span', 'busy-badge'); + private busyOpen = false; + private busyKey = ''; + private chat: Chat; private card = el('aside', 'card glass'); private sheet = el('div', 'sheet-wrap'); private empty = el('div', 'empty glass'); private themeBtn: HTMLButtonElement; + private busyBtn: HTMLButtonElement; private teamsKey = ''; private cardKey = ''; private state: WorldState | null = null; @@ -108,6 +127,7 @@ export class Hud { private mode: ConnectionMode = 'connecting'; constructor(container: HTMLElement, private actions: HudActions) { + this.chat = new Chat(actions.submitChat); this.root.append(this.tagsLayer, this.bubblesLayer); // Top bar @@ -126,6 +146,10 @@ export class Hud { return b; }; btn('cornersOut', 'Show everyone (F)', () => actions.frameAll()); + tools.append(this.chat.button); + const busyBtn = btn('users', 'Who is working (W)', () => this.toggleBusy()); + busyBtn.append(this.busyBadge); + this.busyBtn = busyBtn; this.themeBtn = btn('moon', 'Switch theme (T)', () => actions.toggleTheme()); btn('plug', 'Connect your agents', () => this.openSheet()); const gh = el('a', 'icon-btn', icon('github')) as HTMLAnchorElement; @@ -158,7 +182,7 @@ export class Hud { this.card.setAttribute('aria-live', 'polite'); this.buildSheet(); - this.root.append(top, this.demoNote, this.teamsDock, this.card, this.empty, hint, this.sheet); + this.root.append(top, this.demoNote, this.teamsDock, this.busyPanel, this.chat.rootEl, this.card, this.empty, hint, this.sheet); container.append(this.root); this.bindKeys(); @@ -183,6 +207,7 @@ export class Hud { this.state = state; this.selected = selected; this.renderTeams(); + this.renderBusy(); this.renderCard(); this.updateEmpty(); } @@ -221,6 +246,78 @@ export class Hud { } } + // ---------------------------------------------------------------- who is working + + private busyList(): Agent[] { + const s = this.state!; + return Object.values(s.agents) + .filter((a) => BUSY.has(a.status)) + .sort( + (a, b) => + (BUSY_RANK[a.status] ?? 9) - (BUSY_RANK[b.status] ?? 9) || + (s.teams[a.team]?.order ?? 0) - (s.teams[b.team]?.order ?? 0) || + b.lastSeen - a.lastSeen, + ) + .slice(0, 40); + } + + private toggleBusy() { + this.busyOpen = !this.busyOpen; + this.busyKey = ''; + if (this.busyOpen) this.renderBusy(); + this.busyPanel.classList.toggle('show', this.busyOpen); + this.busyBtn.classList.toggle('active', this.busyOpen); + } + + private closeBusy() { + if (!this.busyOpen) return; + this.busyOpen = false; + this.busyKey = ''; + this.busyPanel.classList.remove('show'); + this.busyBtn.classList.remove('active'); + } + + private renderBusy() { + const s = this.state; + const busy = s ? this.busyList() : []; + const n = busy.length; + this.busyBadge.hidden = n === 0; + this.busyBadge.textContent = String(n); + if (!this.busyOpen || !s) return; + const key = busy.map((a) => `${a.id}:${a.status}:${a.activity}:${a.lastSeen}`).join('|') + '|' + this.selected; + if (key === this.busyKey) return; + this.busyKey = key; + this.busyPanel.innerHTML = ` +
+

Working now

+ +
+ ${ + n + ? `
    ${busy + .map( + (agent) => + `
  • `, + ) + .join('')}
` + : `

Nobody is on the clock. Agents appear here the moment they start.

` + }`; + this.busyPanel.querySelector('.close')!.addEventListener('click', () => this.closeBusy()); + this.busyPanel.querySelectorAll('.busy-row').forEach((b) => { + b.addEventListener('click', (e) => { + e.stopPropagation(); + this.actions.select(b.dataset.id!); + this.closeBusy(); + }); + }); + } + // ---------------------------------------------------------------- agent card private renderCard() { @@ -458,12 +555,16 @@ export class Hud { private bindKeys() { addEventListener('keydown', (e) => { - if (e.target instanceof HTMLInputElement || e.metaKey || e.ctrlKey || e.altKey) return; + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.metaKey || e.ctrlKey || e.altKey) return; if (e.key === 'Escape') { if (this.sheet.classList.contains('show')) this.closeSheet(); + else if (this.busyOpen) this.closeBusy(); + else if (this.chat.isOpen) this.chat.toggle(false); else this.actions.select(null); } else if (e.key === 'f' || e.key === 'F') this.actions.frameAll(); else if (e.key === 't' || e.key === 'T') this.actions.toggleTheme(); + else if (e.key === 'w' || e.key === 'W') this.toggleBusy(); + else if (e.key === 'c' || e.key === 'C') this.chat.toggle(); else if (e.key === ']' || e.key === '[') { const ids = Object.values(this.state?.agents || {}) .sort((a, b) => (this.state!.teams[a.team]?.order ?? 0) - (this.state!.teams[b.team]?.order ?? 0) || a.joinedAt - b.joinedAt) diff --git a/src/ui/icons.ts b/src/ui/icons.ts index 72f5bbe..983fcd3 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -9,8 +9,10 @@ import check from '@phosphor-icons/core/assets/regular/check.svg?raw'; import play from '@phosphor-icons/core/assets/regular/play.svg?raw'; import github from '@phosphor-icons/core/assets/regular/github-logo.svg?raw'; import crosshair from '@phosphor-icons/core/assets/regular/crosshair.svg?raw'; +import users from '@phosphor-icons/core/assets/regular/users.svg?raw'; +import chat from '@phosphor-icons/core/assets/regular/chat-centered-dots.svg?raw'; -export const icons = { x, sun, moon, plug, cornersOut, copy, check, play, github, crosshair }; +export const icons = { x, sun, moon, plug, cornersOut, copy, check, play, github, crosshair, users, chat }; export type IconName = keyof typeof icons; export function icon(name: IconName) { diff --git a/test/chat.test.mjs b/test/chat.test.mjs new file mode 100644 index 0000000..657b1c7 --- /dev/null +++ b/test/chat.test.mjs @@ -0,0 +1,119 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { startServer } from '../server/index.mjs'; + +/** A stand-in for the Anthropic-compatible proxy: replays fixed text deltas. */ +function stubProxy({ chunks = ['Hel', 'lo'], status = 200 } = {}) { + const server = createServer((req, res) => { + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + if (status !== 200) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ error: { message: 'boom' } })); + } + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.write('event: message_start\ndata: {"type":"message_start"}\n\n'); + for (const text of chunks) { + const payload = JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } }); + res.write(`event: content_block_delta\ndata: ${payload}\n\n`); + } + res.end('event: message_stop\ndata: {"type":"message_stop"}\n\n'); + }); + }); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve({ server, url: `http://127.0.0.1:${server.address().port}/v1` })); + }); +} + +/** Read an SSE response into [{ event, data }] frames. */ +async function readFrames(res) { + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + const frames = []; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const parts = buffer.split('\n\n'); + buffer = parts.pop() || ''; + for (const part of parts) { + let event = '', data = ''; + for (const line of part.split('\n')) { + if (line.startsWith('event:')) event = line.slice(6).trim(); + else if (line.startsWith('data:')) data = line.slice(5).trimStart(); + } + if (event) frames.push({ event, data }); + } + } + return frames; +} + +test('POST /chat streams deltas, then done, and puts an agent on the floor', async () => { + const proxy = await stubProxy(); + const saved = { base: process.env.ANTHROPIC_BASE_URL, key: process.env.ANTHROPIC_API_KEY }; + process.env.ANTHROPIC_BASE_URL = proxy.url; + process.env.ANTHROPIC_API_KEY = 'test-key'; + const floor = await startServer({ port: 0, log: () => {} }); + const base = `http://127.0.0.1:${floor.server.address().port}`; + try { + const res = await fetch(`${base}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: 'say hi' }), + }); + assert.equal(res.status, 200); + assert.match(res.headers.get('content-type'), /text\/event-stream/); + + const frames = await readFrames(res); + const deltas = frames.filter((f) => f.event === 'delta').map((f) => JSON.parse(f.data)); + assert.deepEqual(deltas, ['Hel', 'lo'], 'each proxy delta becomes one delta frame'); + const done = frames.find((f) => f.event === 'done'); + assert.ok(done, 'stream ends with a done frame'); + assert.equal(JSON.parse(done.data), 'Hello', 'done carries the whole reply'); + + const agent = Object.values(floor.state.agents).find((a) => a.name === 'Web Chat'); + assert.ok(agent, 'a Web Chat agent took a desk'); + assert.equal(agent.status, 'done'); + } finally { + await floor.close(); + proxy.server.close(); + Object.assign(process.env, saved); + } +}); + +test('POST /chat rejects an empty prompt and reports proxy failures', async () => { + const proxy = await stubProxy({ status: 500 }); + const saved = { base: process.env.ANTHROPIC_BASE_URL, key: process.env.ANTHROPIC_API_KEY }; + process.env.ANTHROPIC_BASE_URL = proxy.url; + process.env.ANTHROPIC_API_KEY = 'test-key'; + const floor = await startServer({ port: 0, log: () => {} }); + const base = `http://127.0.0.1:${floor.server.address().port}`; + try { + const empty = await fetch(`${base}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: ' ' }), + }); + assert.equal(empty.status, 400); + assert.equal((await empty.json()).error, 'prompt is required'); + + const res = await fetch(`${base}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: 'boom?' }), + }); + const frames = await readFrames(res); + const err = frames.find((f) => f.event === 'error'); + assert.ok(err, 'a failing proxy sends an error frame'); + assert.match(JSON.parse(err.data), /HTTP 500/); + const agent = Object.values(floor.state.agents).find((a) => a.name === 'Web Chat'); + assert.equal(agent.status, 'error', 'the desk shows the failure'); + } finally { + await floor.close(); + proxy.server.close(); + Object.assign(process.env, saved); + } +});