18 KiB
18 KiB
Chat with Pi - Flow Documentation
This document describes the full-stack architecture and data flow of the Chat app in Officer.dev, which uses Pi (an AI coding agent) as the backend.
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ FRONTEND │
│ ┌──────────────┐ ┌────────────────┐ ┌───────────────┐ │
│ │ InputArea │───▶│ usePiChat │───▶│ useChatWebSocket│ │
│ │ (user input) │ │ (hook) │ │ (WS connection)│ │
│ └──────────────┘ └───────┬────────┘ └───────┬────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌────────────────┐ ┌───────────────┐ │
│ │ MessageList │◀───│ handleMessage │ │
│ │ (UI updates) │ │ (event parser)│ │
│ └────────────────┘ └───────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ WebSocket
▼
┌─────────────────────────────────────────────────────────────────┐
│ BACKEND │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ server.tsx │ │
│ │ '/api/pi/chat/ws' → upgradeWs() → piWebsocket handler │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ websocket.ts (piWebsocket) │ │
│ │ │ │
│ │ message() ──┬──▶ handleChat() ──▶ SessionManager │ │
│ │ │ │ │ │
│ │ │ ▼ │ │
│ │ │ pi-bridge.ts │ │
│ │ │ (spawn Pi subprocess) │ │
│ │ │ │ │ │
│ │ │ ▼ │ │
│ │ │ Bun.spawn(['pi', '--mode', │ │
│ │ │ 'rpc', ...]) │ │
│ │ │ │ │ │
│ │ │ ▼ │ │
│ │ │ Parse JSON from stdout │ │
│ │ │ │ │ │
│ │ └────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ sendToClient() ──▶ WebSocket JSON │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ storage.ts │ │
│ │ Save to: ~/.pi-sessions/<sessionId>/ │ │
│ │ - meta.json (session metadata) │ │
│ │ - messages.json (message history) │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
▲
│
Pi subprocess
(AI Agent)
Directory Structure
Frontend
src/workspaces/officerdev/src/
├── apps/
│ ├── Chat/
│ │ ├── index.ts # Main entry, exports
│ │ ├── types.ts # TypeScript types
│ │ ├── ChatPanelWrapper.tsx # Main wrapper component
│ │ ├── ChatList.tsx # Session list sidebar
│ │ ├── ChatLauncher.tsx # Launch chat
│ │ ├── EmbeddableChat.tsx # Embeddable version
│ │ ├── components/
│ │ │ ├── InputArea.tsx # User input
│ │ │ ├── MessageList.tsx # Message display
│ │ │ ├── MessageBubble.tsx # Individual message
│ │ │ ├── ToolActivity.tsx # Tool execution UI
│ │ │ ├── QuestionActivity.tsx # Question prompt UI
│ │ │ ├── ModelSelector.tsx # Model dropdown
│ │ │ ├── AttachButton.tsx # File attachment
│ │ │ ├── AttachmentList.tsx # Attached files
│ │ │ └── WebpageDialog.tsx # URL preview
│ │ ├── hooks/
│ │ │ └── useChat.ts # Main chat hook
│ │ ├── useAttachments.ts # File attachment handling
│ │ ├── useAudioRecording.ts # Voice input
│ │ └── useSlashCommands.ts # /commands handling
│ │
│ └── ChatHistory/ # Session management UI
│
├── hooks/
│ ├── usePiChat.ts # Main chat hook
│ └── ...
│
└── state/
└── useChatSessions.ts # Session state management
Backend
src/
├── server.tsx # Main server entry
│ # - WebSocket upgrade
│ # - Route registration
│ # - Pi installation check
│
└── servers/
├── hono.ts # Hono REST server
│
└── api/
├── pi/
│ ├── types.ts # TypeScript types
│ ├── websocket.ts # WebSocket handler
│ ├── session-manager.ts # Session lifecycle
│ ├── pi-bridge.ts # Pi subprocess spawner
│ ├── storage.ts # Disk persistence
│ ├── rest.ts # REST API endpoints
│ ├── logger.ts # Logging
│ └── README.md # Original Pi API docs
│
└── ... (other API routes)
Type Definitions
Client Messages (Frontend → Backend)
// Start new chat
type ClientMessage =
| { type: 'chat'; prompt: string; sessionId?: string; model?: string; cwd?: string; groupSlug?: string; attachmentIds?: string[] }
| { type: 'resume'; sessionId: string } // Resume existing session
| { type: 'stop' }; // Stop generation
Server Messages (Backend → Frontend)
type ServerMessage =
| { type: 'session:init'; sessionId: string; model: string; cwd: string }
| { type: 'assistant:delta'; text: string } // Streaming token
| { type: 'assistant:text'; text: string } // Complete message
| { type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown> }
| { type: 'tool:result'; toolCallId: string; output: string; isError: boolean }
| { type: 'result'; sessionId: string; cost: MessageCost }
| { type: 'sync:messages'; sessionId: string; messages: Message[]; isGenerating: boolean; streamingText: string }
| { type: 'error'; message: string; errorCode?: string }
| { type: 'stopped' };
Internal Types
type Message = {
id: string;
timestamp: number;
role: 'user' | 'assistant' | 'tool';
text?: string;
model?: string;
cost?: MessageCost;
toolCallId?: string;
toolName?: string;
toolInput?: Record<string, unknown>;
output?: string;
isError?: boolean;
};
type SessionMeta = {
id: string;
title: string;
model: string;
cwd: string;
createdAt: number;
updatedAt: number;
messageCount: number;
cost: MessageCost;
groupSlug?: string | null;
};
type MessageCost = {
inputTokens: number;
outputTokens: number;
totalUSD: number;
};
REST API Endpoints
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/pi/models |
List available AI models |
| POST | /api/pi/sessions |
List all sessions |
| GET | /api/pi/sessions/:id |
Get session with messages |
| PATCH | /api/pi/sessions/:id |
Update session (rename) |
| DELETE | /api/pi/sessions/:id |
Delete session |
| GET | /api/pi/sessions/search?q= |
Search sessions |
| POST | /api/pi/groups |
Create group |
| GET | /api/pi/groups |
List groups |
| PATCH | /api/pi/groups/:slug |
Update group |
| DELETE | /api/pi/groups/:slug |
Delete group |
| POST | /api/pi/sessions/:id/move |
Move session between groups |
Data Flow Details
1. Starting a New Chat
User enters message
↓
usePiChat.sendPrompt()
↓
useChatWebSocket.send({ type: 'chat', prompt, ... })
↓
WebSocket connection to /api/pi/chat/ws
↓
server.tsx: upgradeWs() - JWT verification
↓
websocket.ts: handleChat()
↓
SessionManager.getOrCreate(sessionId, email, cwd, model)
↓
pi-bridge.ts: spawnPi(cwd, model, onEvent)
↓
Bun.spawn(['pi', '--mode', 'rpc', '--no-extensions', ...])
↓
send prompt to Pi via stdin
↓
Pi process streams JSON events to stdout
↓
pi-bridge parses events → onEvent(PiEvent)
↓
websocket.ts createEventHandler() → sendToClient()
↓
useChatWebSocket.onMessage() → usePiChat.handleMessage()
↓
React state update → UI renders
2. Resuming a Session
User navigates to /chat/:sessionId
↓
usePiChat mounts (with initialSessionId)
↓
useEffect triggers: getSession(initialSessionId)
↓
REST API: GET /api/pi/sessions/:sessionId
↓
storage.ts: loadSession() reads from disk
↓
Messages loaded into state
↓
WebSocket connects (for new messages)
↓
websocket.ts: handleResume()
↓
Spawns fresh Pi process
↓
Sends 'sync:messages' to client
3. Pi Event Parsing
Pi stdout JSON line:
{
"type": "message_update",
"assistantMessageEvent": {
"type": "text_delta",
"delta": "Hello"
}
}
↓
pi-bridge.ts: parsePiEvent()
↓
Converted to PiEvent:
{ type: 'delta', text: 'Hello' }
↓
Event handler:
- 'delta' → append to streamBuffer, send 'assistant:delta'
- 'text' → commit streamBuffer, send 'assistant:text'
- 'tool:start' → send 'tool:start', add tool message
- 'tool:result' → send 'tool:result', update tool message
- 'result' → save session, send 'result'
Session Storage
Directory Structure
~/.pi-sessions/
├── <sessionId>/
│ ├── meta.json # Session metadata
│ └── messages.json # Message history
│
└── @<groupSlug>/ # Grouped sessions
├── .group-meta.json
├── <sessionId1>/
│ ├── meta.json
│ └── messages.json
└── <sessionId2>/
meta.json
{
"id": "session-uuid",
"title": "First prompt...",
"model": "opencode/big-pickle",
"cwd": "/home/user",
"createdAt": 1700000000000,
"updatedAt": 1700000000000,
"messageCount": 10,
"cost": {
"inputTokens": 5000,
"outputTokens": 2000,
"totalUSD": 0.05
},
"groupSlug": null
}
messages.json
{
"messages": [
{ "id": "...", "timestamp": 1700000000000, "role": "user", "text": "Hello" },
{ "id": "...", "timestamp": 1700000000001, "role": "assistant", "text": "Hi!" },
{ "id": "...", "timestamp": 1700000000002, "role": "tool", "toolName": "Bash", "toolInput": {...}, "output": "..." }
]
}
Authentication
WebSocket Authentication
- Client connects:
ws://host/api/pi/chat/ws?token=<JWT> server.tsx:upgradeWs()extracts token from URLjwt.ts:verify(token)validates JWT- Check token blacklist (logout/revocation)
- Extract user info:
{ userId, email, role } - Attach to WebSocket data:
ws.data = { userId, email, provider: 'pi', ... }
REST Authentication
- Bearer token in
Authorizationheader hono.tsusesuserMiddlewareto verify- User object attached to request context:
ctx.get('user')
Session Management
SessionManager (in-memory)
class SessionManager {
private sessions = new Map<string, UserSession>();
private userSessions = new Map<string, string[]>(); // email → sessionIds
getOrCreate(sessionId, email, cwd, model, groupSlug): UserSession
getSession(sessionId): UserSession | null
attachWs(sessionId, ws): void
detachWs(sessionId): void
setIdleTimeout(sessionId, timeoutMs): void // Auto-cleanup after 1 hour
deleteSession(sessionId): void
}
UserSession Object
type UserSession = {
sessionId: string;
email: string;
cwd: string;
model: string;
piProcess: Subprocess | null; // Bun subprocess
ws: WebSocket | null; // Connected client
lastActivity: number;
idleTimer: Timer | null;
streamBuffer: string; // Uncommitted streaming text
isGenerating: boolean;
systemContextSent: boolean;
messages: Message[];
meta: SessionMeta;
};
Pi Subprocess
Spawn Command
const args = [
'pi',
'--mode', 'rpc',
'--no-extensions',
'--no-skills',
'--no-prompt-templates',
'--no-themes',
'--model', model // e.g., 'opencode/big-pickle'
];
Bun.spawn(args, {
cwd,
stdin: 'pipe',
stdout: 'pipe', // JSON event stream
stderr: 'pipe', // Debug logs
});
RPC Protocol
// Send prompt (stdin):
{ "type": "prompt", "id": "request-uuid", "message": "Hello" }
{ "type": "abort", "id": "request-uuid" }
// Receive events (stdout):
{ "type": "message_update", "assistantMessageEvent": { "type": "text_delta", "delta": "..." } }
{ "type": "message_end" }
{ "type": "tool_execution_start", "toolCallId": "...", "toolName": "Bash", "args": {...} }
{ "type": "tool_execution_end", "toolCallId": "...", "result": "...", "isError": false }
{ "type": "agent_end", "cost": {...} }
Key Files Reference
| File | Purpose |
|---|---|
server.tsx |
Main server, WebSocket upgrade, route setup |
servers/api/pi/websocket.ts |
WebSocket message handling |
servers/api/pi/session-manager.ts |
Session lifecycle in memory |
servers/api/pi/pi-bridge.ts |
Spawn and communicate with Pi |
servers/api/pi/storage.ts |
Disk persistence |
servers/api/pi/rest.ts |
REST API endpoints |
servers/api/pi/types.ts |
Shared types |
workspaces/officerdev/src/hooks/usePiChat.ts |
Main frontend hook |
workspaces/hooks/src/useChatWebSocket.ts |
WebSocket connection |
workspaces/officerdev/src/apps/Chat/types.ts |
Frontend types |
Configuration
Environment Variables
PORT- Server port (default: 5000)JWT_SECRET- JWT signing secret
Constants
// websocket.ts
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
// storage.ts
const PI_SESSIONS_DIR = '.pi-sessions';
const GROUP_PREFIX = '@';
Error Handling
- WebSocket errors → Logged and sent as
{ type: 'error', message: ... } - Pi process errors →
{ type: 'error', message: errorMsg } - Session not found → HTTP 404 or
SESSION_NOT_FOUNDerror code - Token blacklist check → 401 Unauthorized
Dependencies
Backend
bun- Runtimehono- HTTP frameworkdrizzle-orm- Database (for user auth)
Frontend
react- UI framework@tanstack/react-query- Data fetchinglucide-react- Icons
Last updated: 2024