diff --git a/HOOKS.md b/HOOKS.md deleted file mode 100644 index b47a2e4f..00000000 --- a/HOOKS.md +++ /dev/null @@ -1,46 +0,0 @@ -## Authentication - -src/apps/officer-web/Screens/Authentication/ForgotPassword/useResetPassword.ts -src/apps/officer-web/Screens/Authentication/VerifyScreen/useVerifyScreen.ts - -## Files - -src/apps/officer-web/Screens/Dashboard/Files/state/usePinnedFiles.ts -src/apps/officer-web/Screens/Dashboard/Files/state/useRecentFiles.ts - -## Officer-web State - -src/apps/officer-web/state/useChatGroups.ts -src/apps/officer-web/state/useChatSessions.ts -src/apps/officer-web/state/useInitialData.ts -src/apps/officer-web/state/useLandingPage.ts -src/apps/officer-web/state/useModels.ts -src/apps/officer-web/state/usePlans.ts -src/apps/officer-web/state/useProjectsState.ts -src/apps/officer-web/state/useRecentModels.ts -src/apps/officer-web/state/useResources.ts -src/apps/officer-web/state/useServerSettings.ts -src/apps/officer-web/state/useSettings.ts -src/apps/officer-web/state/useThemeSync.ts -src/apps/officer-web/state/useUserState.ts -src/apps/officer-web/state/useWorkspacesState.ts - -## Chat (apps/Chat) - -src/workspaces/apps/Chat/useChatSessions.ts -src/workspaces/apps/Chat/useChatSession.ts -src/workspaces/apps/Chat/usePi.ts -src/workspaces/apps/Chat/useSlashCommands.ts - -## Other Workspaces - -src/workspaces/apps/CodeEditor/useEditorState.ts -src/workspaces/apps/FileBrowser/useFiles.ts -src/workspaces/apps/FileBrowser/useTasks.ts -src/workspaces/components/DataTable/useFixedHeightPagination.ts -src/workspaces/components/ui/hooks/use-mobile.tsx -src/workspaces/components/ui/hooks/use-toast.ts -src/workspaces/components/ui/use-toast.ts -src/workspaces/i18n/src/useTranslation.ts -src/workspaces/injector/use-client.ts -Done! diff --git a/OFFICERDEV_BACKEND.md b/OFFICERDEV_BACKEND.md deleted file mode 100644 index d62c21a4..00000000 --- a/OFFICERDEV_BACKEND.md +++ /dev/null @@ -1,1179 +0,0 @@ -# Officer.dev Backend Architecture - -## Executive Summary - -Officer is a full-stack AI-assisted personal backend and frontend for life management. The backend is a **Bun-based monorepo** using: -- **Hono** framework for REST API (port 5000) -- **PostgreSQL** with Drizzle ORM for persistent data -- **WebSockets** for real-time terminal and AI chat functionality -- **Multi-workspace** shared libraries for code reuse -- **Node.js PTY sidecars** for terminal emulation -- **Pi coding agent integration** for AI-powered development assistance - -This document covers the backend architecture, data flow, and technical patterns. - ---- - -## Table of Contents - -1. [Architecture Overview](#architecture-overview) -2. [Technology Stack](#technology-stack) -3. [Directory Structure](#directory-structure) -4. [Core Server](#core-server) -5. [API Architecture](#api-architecture) -6. [Database Design](#database-design) -7. [WebSocket Services](#websocket-services) -8. [Shared Workspaces](#shared-workspaces) -9. [Error Handling](#error-handling) -10. [Development & Deployment](#development--deployment) -11. [Key Features](#key-features) - ---- - -## Architecture Overview - -### High-Level Architecture - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Web Browser (Frontend) │ -└──────────────┬──────────────────────────────────────────────┘ - │ - ├─ HTTP REST API (port 5000) - ├─ WebSocket: Terminal (ws://api:5000/api/terminal/ws) - ├─ WebSocket: Pi Chat (ws://api:5000/api/pi/chat/ws) - └─ Dev Server Proxy (ws://api:5000/api/dev-server-proxy/*) - │ -┌──────────────▼──────────────────────────────────────────────┐ -│ Bun Server (src/server.tsx + Hono Router) │ -├─────────────────────────────────────────────────────────────┤ -│ Middleware: │ -│ - CORS handler │ -│ - User authentication (JWT + token blacklist) │ -│ - Request body parser │ -│ │ -│ Routes: │ -│ - /api/auth → Authentication (login, signup) │ -│ - /api/plans → Plans management │ -│ - /api/skills → Skills (Pi integration) │ -│ - /api/tasks → Tasks & processes │ -│ - /api/upload → File uploads │ -│ - /api/workspaces → Workspace management │ -│ - /api/settings → User settings │ -│ - /api/dev-server → Local dev server routing │ -│ - WebSocket handlers → Real-time communication │ -└──────────────┬──────────────────────────────────────────────┘ - │ - ┌──────┴──────────────────────┐ - │ │ - ▼ ▼ -┌──────────────────┐ ┌──────────────────────┐ -│ PostgreSQL DB │ │ Node.js PTY Sidecar │ -│ (officer_db) │ │ (Terminal Emulation)│ -│ │ │ │ -│ Users, Plans, │ │ xterm-js │ -│ Skills, Tasks, │ │ node-pty │ -│ Sessions, etc │ │ Docker container │ -└──────────────────┘ └──────────────────────┘ -``` - -### Request/Response Flow - -``` -Client Request - ↓ -Server Router (Bun) - ↓ -Authentication Middleware - ↓ -Protected/Public Handler - ↓ -Database Query (Drizzle ORM) OR External Service - ↓ -Response (JSON/WebSocket) - ↓ -Client -``` - ---- - -## Technology Stack - -### Core Runtime & Framework -- **Bun** (v1.30+) - Fast JavaScript runtime with built-in tooling -- **TypeScript** (v5.9+) - Strict mode enabled -- **Hono** (v4.11+) - Lightweight, type-safe web framework -- **Node.js** - For PTY sidecar processes - -### Database & ORM -- **PostgreSQL** - Primary persistent data store -- **Drizzle ORM** (v0.45+) - Type-safe SQL query builder -- **Drizzle Kit** (v0.31+) - Schema management and migrations - -### Real-Time Communication -- **WebSockets** (native Bun) - Bidirectional communication -- **Redis** (optional) - Session/cache storage - -### Authentication & Security -- **JWT** - Token-based authentication -- **Argon2** - Password hashing (bcrypt alternative) -- **SimpleWebAuthn** - WebAuthn/FIDO2 passkey support -- **Token Blacklist** - Revocation tracking - -### AI & Automation -- **@anthropic-ai/claude-agent-sdk** - Claude integration -- **Pi Coding Agent** - CLI-based AI development assistant -- **Nodemailer** - Email notifications -- **Googleapis** - Google integration for services - -### Utilities -- **node-pty** - Cross-platform pseudoterminal support -- **Cron** (v4.3+) - Scheduled task execution -- **Date-fns** (v4.1+) - Date manipulation -- **Zod** (v4.2+) - Schema validation - ---- - -## Directory Structure - -### Complete Backend Layout - -``` -monorepo/ -├── src/ -│ ├── server.tsx # Main Bun server entry point -│ │ -│ ├── servers/ # Backend core -│ │ ├── bootstrap.ts # Server initialization -│ │ ├── hono.ts # Hono app & router setup -│ │ ├── jwt.ts # JWT token handling -│ │ ├── custom-errors.ts # Custom error classes -│ │ ├── create-router.ts # Router factory with context -│ │ ├── data-path.ts # Path management -│ │ │ -│ │ ├── _middlewares/ # Shared middleware -│ │ │ ├── user-middleware.ts # Auth verification -│ │ │ └── body-parser.ts # Request parsing -│ │ │ -│ │ └── api/ # API routes -│ │ ├── auth/ # Authentication routes -│ │ │ ├── signin.ts # Login endpoint -│ │ │ ├── signup.ts # Registration endpoint -│ │ │ ├── signout.ts # Logout endpoint -│ │ │ ├── verify.ts # Email verification -│ │ │ ├── passkey-router.ts # WebAuthn endpoints -│ │ │ ├── reset-password.ts # Password reset -│ │ │ └── auth.ts # Core auth logic -│ │ │ -│ │ ├── terminal/ # Terminal WebSocket -│ │ │ ├── websocket.ts # WS handler -│ │ │ └── pty-sidecar.mjs # PTY subprocess -│ │ │ -│ │ ├── pi/ # Pi coding agent integration -│ │ │ ├── websocket.ts # Chat WS handler -│ │ │ ├── rest.ts # REST endpoints -│ │ │ ├── pi-bridge.ts # Pi SDK bridge -│ │ │ ├── storage.ts # Chat session storage -│ │ │ └── session-manager.ts # Session management -│ │ │ -│ │ ├── dev-server/ # Local dev server routing -│ │ │ └── router.ts # Dev server proxy -│ │ │ -│ │ ├── plans/ # Plans management -│ │ ├── skills/ # Skills routes -│ │ ├── tasks/ # Tasks & processes -│ │ ├── workspaces/ # Workspace management -│ │ ├── settings/ # User settings -│ │ ├── upload/ # File upload -│ │ ├── file-browser/ # File browser API -│ │ ├── scrape/ # Web scraping -│ │ ├── sessions/ # Chat sessions -│ │ ├── task-logs/ # Task execution logs -│ │ ├── server-settings/ # System configuration -│ │ └── users/ # User management -│ │ -│ ├── databases/ # Data layer -│ │ └── officer_db/ # Main database package -│ │ ├── drizzle.config.ts # Drizzle configuration -│ │ ├── migrations/ # Database migrations -│ │ ├── src/ -│ │ │ ├── schema.ts # Table definitions -│ │ │ ├── types.ts # Inferred types -│ │ │ └── index.ts # Exports -│ │ └── package.json # Workspace manifest -│ │ -│ └── workspaces/ # Shared libraries -│ ├── components/ # React components -│ ├── hooks/ # React hooks -│ ├── helpers/ # Utility functions -│ ├── types/ # Shared types -│ ├── state/ # State management -│ ├── config/ # Configuration -│ ├── definitions/ # Constants -│ ├── emailer/ # Email service -│ ├── injector/ # Dependency injection -│ └── i18n/ # Internationalization -│ -├── package.json # Root workspace config -├── bunfig.toml # Bun configuration -├── tsconfig.json # TypeScript config -└── .env # Environment variables -``` - ---- - -## Core Server - -### Entry Point: `src/server.tsx` - -The main server file initializes the Bun HTTP server and handles: -1. Route registration (static files, API, WebSockets) -2. WebSocket upgrade logic and authentication -3. Dev server proxy for HMR and local dev environments -4. PI coding agent installation/verification - -#### Key Responsibilities - -```typescript -// 1. Static file serving -'/static/*' → public/ directory files - -// 2. HTML fallback for SPA routing -'/' and '/*' → officer-web app - -// 3. API routing -'/api/*' → honoServer (REST endpoints) - -// 4. WebSocket routing with auth -'/api/terminal/ws' → Terminal WebSocket -'/api/pi/chat/ws' → Pi Chat WebSocket - -// 5. Dev server proxy -'/api/dev-server-proxy/*' → Proxied WS for local dev servers -``` - -#### WebSocket Authentication Pattern - -```typescript -async function upgradeWs(req: Request, server: any, provider: 'terminal' | 'pi') { - // 1. Extract JWT token from query params - const token = new URL(req.url).searchParams.get('token'); - - // 2. Verify token and extract user - const user = await verify(token); - - // 3. Check token blacklist (for revoked tokens) - const blacklisted = await officerdb.query.TokenBlacklist.findFirst({...}); - - // 4. Extract WS-specific parameters - const sessionId = url.searchParams.get('sessionId'); - const cwd = url.searchParams.get('cwd'); - const cols = url.searchParams.get('cols'); // terminal dimensions - - // 5. Upgrade connection with authenticated data - server.upgrade(req, { data: { userId, email, role, provider, ... } }); -} -``` - -### Hono Server: `src/servers/hono.ts` - -Hono is a lightweight web framework perfect for edge computing and Bun. - -#### Server Initialization - -```typescript -const honoServer = new Hono<{ Variables: HonoVariables }>(); - -honoServer.use(cors({ origin: '*', allowMethods: [...] })); - -// Public routes -honoServer.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' })); -honoServer.route('/api/auth', authRouter); -honoServer.route('/api/server-settings', serverSettingsRouter); - -// Protected routes -const protectedRouter = createRouter(); -protectedRouter.use(bodyParser()); -protectedRouter.use(userMiddleware); - -protectedRouter.route('/plans', plansRouter); -protectedRouter.route('/tasks', tasksRouter); -// ... other routes - -honoServer.route('/api', protectedRouter); - -// Error handling -honoServer.onError((error, ctx) => { - if (error instanceof CustomError) { - return ctx.json(error.returnValue, error.statusCode); - } - return ctx.text('Internal Server Error', 500); -}); -``` - -#### Hono Variables Context - -```typescript -type HonoVariables = { - userId: number; - email: string; - role: 'admin' | 'user'; - // Accessible in all routes via ctx.get('userId') -}; -``` - ---- - -## API Architecture - -### Route Organization - -The API is organized by feature domain: - -``` -/api/ -├── auth/ # User authentication (public) -│ ├── POST /signin -│ ├── POST /signup -│ ├── POST /signout -│ ├── GET /verify -│ ├── POST /passkeys (WebAuthn) -│ └── ... -│ -├── plans/ # Plans management (protected) -├── skills/ # Skills endpoints -├── tasks/ # Task operations -├── workspaces/ # Workspace operations -├── settings/ # User preferences -├── upload/ # File uploads -├── file-browser/ # File system access -├── terminal/ws # Terminal emulation (WS) -├── pi/ # AI assistant integration -│ ├── /ws # Chat (WebSocket) -│ └── /rest # REST endpoints -│ -└── dev-server/ # Local dev server routing -``` - -### Router Creation Pattern: `src/servers/create-router.ts` - -```typescript -// Factory function for creating contextualized routers -export function createRouter() { - return new Hono<{ Variables: HonoVariables }>(); -} - -// Usage in route handlers: -const router = createRouter(); -router.get('/endpoint', (ctx) => { - const userId = ctx.get('userId'); - // Route logic -}); -``` - -### Error Handling: `src/servers/custom-errors.ts` - -```typescript -class CustomError extends Error { - constructor( - public statusCode: number, - message: string, - public returnValue?: Record | string, - ) { - super(message); - } -} - -// Usage patterns -throw new CustomError(400, 'Bad request', { error: 'Invalid input' }); -throw new CustomError(401, 'Unauthorized'); -throw new CustomError(500, 'Internal error', { error: 'Database failed' }); -``` - ---- - -## Database Design - -### Primary Database: `officer_db` - -Located in `src/databases/officer_db/`, this is the main persistent data store. - -#### Drizzle Configuration - -```typescript -// drizzle.config.ts -import { defineConfig } from 'drizzle-kit'; - -export default defineConfig({ - schema: './src/schema.ts', - out: './migrations', - dialect: 'postgresql', - dbCredentials: { - url: process.env.DATABASE_URL!, - }, -}); -``` - -#### Schema Structure - -The schema defines all tables and their relationships: - -```typescript -// src/databases/officer_db/src/schema.ts - -// Core tables -export const Users = pgTable('users', { - id: serial('id').primaryKey(), - email: text('email').unique().notNull(), - password: text('password'), - role: text('role').$type<'admin' | 'user'>().default('user'), - verified: boolean('verified').default(false), - createdAt: timestamp('created_at').defaultNow(), - updatedAt: timestamp('updated_at').defaultNow(), -}); - -export const Plans = pgTable('plans', { - id: serial('id').primaryKey(), - userId: integer('user_id').references(() => Users.id), - title: text('title').notNull(), - description: text('description'), - // ... other fields -}); - -export const Skills = pgTable('skills', { - id: serial('id').primaryKey(), - userId: integer('user_id').references(() => Users.id), - name: text('name').notNull(), - content: text('content'), - // ... other fields -}); - -// ... many more tables for tasks, sessions, workspaces, etc. - -// Relations -export const usersRelations = relations(Users, ({ many }) => ({ - plans: many(Plans), - skills: many(Skills), -})); -``` - -#### Type Generation - -Types are automatically inferred from schema: - -```typescript -// src/databases/officer_db/src/types.ts - -// Simple table types -export type User = typeof Users.$inferSelect; -export type UserInsert = typeof Users.$inferInsert; - -// Extended types for API responses -export type UserWithRelations = User & { - plans: Plan[]; - skills: Skill[]; -}; -``` - -#### Database Operations in Routes - -```typescript -import { officerdb, eq, Users } from 'officerdb'; - -// Simple query -const user = await officerdb.query.Users.findFirst({ - where: eq(Users.id, userId), -}); - -// Query with relations -const user = await officerdb.query.Users.findFirst({ - where: eq(Users.id, userId), - with: { - plans: true, - skills: true, - }, -}); - -// Insert with returning -const [newUser] = await officerdb.insert(Users).values({ - email: 'user@example.com', - password: hashedPassword, -}).returning(); - -// Update -await officerdb.update(Users).set({ - verified: true, -}).where(eq(Users.id, userId)); -``` - -#### Token Blacklist Table - -```typescript -export const TokenBlacklist = pgTable('token_blacklist', { - id: serial('id').primaryKey(), - jti: text('jti').unique().notNull(), // JWT ID - expiresAt: timestamp('expires_at').notNull(), - createdAt: timestamp('created_at').defaultNow(), -}); - -// Used for token revocation (logout, password change, etc.) -``` - -### Database Migrations - -Migrations are generated by Drizzle and stored in `src/databases/officer_db/migrations/`: - -```bash -# Generate new migration -bun run db:gen - -# Apply migrations -bun run db:push - -# Open Drizzle Studio for visual management -bun run db:studio -``` - ---- - -## WebSocket Services - -### 1. Terminal WebSocket: `src/servers/api/terminal/` - -Provides an interactive terminal emulator in the browser using xterm.js. - -#### Architecture - -``` -Browser (xterm.js) - ↓ WebSocket -Officer Server (websocket.ts) - ↓ IPC/Stdio -PTY Sidecar (pty-sidecar.mjs) - ↓ Shell Process -System Shell (bash/zsh) -``` - -#### Handler: `websocket.ts` - -```typescript -export const terminalWebsocket = { - // Connection established - open(ws: ServerWebSocket) { - const { userId, cwd, cols, rows, sandboxed } = ws.data; - - // 1. Spawn or reuse PTY sidecar - // 2. Set terminal dimensions - // 3. Send initial prompt - }, - - // Client sends data (typing, etc.) - message(ws: ServerWebSocket, raw: string | Buffer) { - // 1. Parse message type (input, resize, etc.) - // 2. Forward to PTY process - }, - - // Connection closed - close(ws: ServerWebSocket) { - // 1. Kill PTY process - // 2. Cleanup resources - }, -}; -``` - -#### PTY Sidecar: `pty-sidecar.mjs` - -A Node.js subprocess that manages the actual pseudoterminal: - -```javascript -// Uses node-pty for cross-platform terminal support -const pty = require('node-pty'); - -const term = pty.spawn('bash', [], { - name: 'xterm-color', - cols: 120, - rows: 40, - cwd: process.env.CWD, -}); - -// stdout → send to client -term.on('data', (data) => { - process.stdout.write(JSON.stringify({ type: 'output', data })); -}); - -// stdin from client → write to terminal -process.stdin.on('data', (chunk) => { - const msg = JSON.parse(chunk); - if (msg.type === 'input') term.write(msg.data); - if (msg.type === 'resize') term.resize(msg.cols, msg.rows); -}); -``` - -#### Sandboxed vs. Real Terminals - -- **Sandboxed**: Limited to specific directories, no system access -- **Real**: Full system access from user's working directory - -### 2. Pi Coding Agent WebSocket: `src/servers/api/pi/` - -Integrates the Pi coding agent for AI-assisted development. - -#### Architecture - -``` -Browser (Chat UI) - ↓ WebSocket -Officer Server (pi/websocket.ts) - ↓ SDK -Pi Coding Agent (pi-bridge.ts) - ↓ RPC/Tools -LLM API (Claude, GPT, etc.) -``` - -#### Components - -**websocket.ts**: WebSocket message handler -- Routes incoming chat messages to Pi -- Streams responses back to client -- Manages session state - -**pi-bridge.ts**: SDK integration layer -```typescript -export async function createPiSession( - cwd: string, - userEmail: string, - sessionId?: string, -) { - // 1. Initialize Pi SDK - const { session } = await createAgentSession({ - cwd, - model: selectedModel, - tools: createCodingTools(cwd), - }); - - // 2. Subscribe to events - session.subscribe((event) => { - // Stream events back to client via WS - }); - - // 3. Return session for messaging - return session; -} -``` - -**rest.ts**: REST endpoints for Pi operations -- Create new sessions -- List sessions -- Get session details -- Export conversations - -**storage.ts**: Chat session persistence -```typescript -// Store conversation history in database -interface PiSession { - id: string; - userId: number; - cwd: string; - model: string; - createdAt: timestamp; - messages: PiMessage[]; -} - -// Retrieve from database when resuming -``` - -**session-manager.ts**: Session lifecycle management - -#### Message Flow - -``` -Client: "ls -la" - → WebSocket to Officer - → Pi process stdin - → Pi executes bash tool - → Tool output - → Stream back to client -``` - -#### Integration with Tools - -Pi can execute: -- `read` - Read files -- `bash` - Run shell commands -- `edit` - Modify files -- `write` - Create files -- Custom tools via extensions - ---- - -## Shared Workspaces - -Shared code is organized in `src/workspaces/` as Bun workspace packages: - -### Key Workspaces - -**types/** - TypeScript type definitions -- Centralized, exported via `'types'` alias -- Shared by frontend and backend -- Database types imported and re-exported - -**helpers/** - Utility functions -- Formatters, validators, converters -- Pure functions with no side effects - -**hooks/** - React hooks -- Frontend-only -- `useAuth`, `useQuery`, `useMutation`, etc. - -**state/** - State management -- Global state stores -- Context providers -- Zustand/React Context usage - -**components/** - React components -- UI components (buttons, inputs, etc.) -- Complex feature components -- Reusable across apps - -**config/** - Configuration -- Constants -- Environment-specific settings - -**definitions/** - Enum and constant definitions -- User roles -- Status values -- Feature flags - -**emailer/** - Email service -```typescript -// Nodemailer-based email sending -export async function sendEmail(to: string, subject: string, html: string) { - const transporter = nodemailer.createTransport({...}); - return transporter.sendMail({ to, subject, html }); -} -``` - -**injector/** - Dependency injection -- Service locator pattern -- Configuration management - -**i18n/** - Internationalization -- Multi-language support -- Locale management - -**sounds/** - Audio assets -- Notification sounds -- UI feedback audio - -**widgets/** - Complex UI widgets -- Feature-rich components -- Composed from basic components - -### Workspace Configuration - -Each workspace has a `package.json`: - -```json -{ - "name": "components", - "version": "0.1.0", - "type": "module", - "exports": { - ".": "./index.ts" - }, - "main": "./index.ts" -} -``` - -### Import Pattern in Code - -```typescript -// In any app (frontend/backend) -import { User, Plan } from 'types'; -import { formatDate } from 'helpers'; -import { useAuth } from 'hooks'; -import { useGlobalState } from 'state'; -import { Button } from 'components'; -``` - ---- - -## Error Handling - -### Error Classification - -```typescript -// Custom error hierarchy -class CustomError extends Error { - constructor( - public statusCode: number, - message: string, - public returnValue?: any, - ) {} -} - -// Usage patterns -throw new CustomError(400, 'Bad request', { field: 'email', error: 'Invalid' }); -throw new CustomError(401, 'Unauthorized'); -throw new CustomError(403, 'Forbidden'); -throw new CustomError(404, 'Not found', { resource: 'Plan' }); -throw new CustomError(409, 'Conflict', { error: 'Email already exists' }); -throw new CustomError(500, 'Internal server error'); -``` - -### Middleware Error Handling - -```typescript -honoServer.onError((error, ctx) => { - // CustomError → formatted response - if (error instanceof CustomError) { - return ctx.json(error.returnValue || error.message, error.statusCode); - } - - // Unexpected error → 500 - console.error('Unexpected error:', error.message); - return ctx.text('Internal Server Error', 500); -}); -``` - -### Try/Catch Pattern - -```typescript -// In route handlers -router.post('/endpoint', async (ctx) => { - try { - const body = await ctx.req.json(); - - // Validation - if (!body.email) { - throw new CustomError(400, 'Email required', { field: 'email' }); - } - - // Database operation - const user = await officerdb.insert(Users).values({...}).returning(); - if (!user) throw new CustomError(500, 'Failed to create user'); - - return ctx.json({ success: true, user }); - } catch (error) { - // Re-throw CustomErrors, let middleware handle - if (error instanceof CustomError) throw error; - - // Unexpected error - console.error('Route error:', error); - throw new CustomError(500, 'Internal server error'); - } -}); -``` - ---- - -## Authentication System - -### JWT Flow - -``` -User Login - ↓ -Verify credentials (email + password) - ↓ -Generate JWT token - { - "sub": userId, - "email": email, - "role": "user", - "iat": timestamp, - "exp": timestamp, - "jti": unique-token-id - } - ↓ -Return token to client - ↓ -Client stores in localStorage - ↓ -Client sends in Authorization header - ↓ -Server verifies signature & expiration - ↓ -Grant access -``` - -### Token Verification: `src/servers/jwt.ts` - -```typescript -export async function verify(token: string) { - try { - // 1. Verify signature and expiration - const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload; - - // 2. Check token blacklist (for revoked tokens) - if (decoded.jti) { - const blacklisted = await officerdb.query.TokenBlacklist.findFirst({ - where: eq(TokenBlacklist.jti, decoded.jti), - }); - if (blacklisted) return null; // Token revoked - } - - return { id: decoded.sub, email: decoded.email, role: decoded.role }; - } catch { - return null; // Invalid token - } -} -``` - -### Logout with Token Blacklist - -```typescript -router.post('/signout', async (ctx) => { - const token = ctx.req.header('Authorization')?.replace('Bearer ', ''); - const decoded = jwt.decode(token) as JWTPayload; - - // Add to blacklist - await officerdb.insert(TokenBlacklist).values({ - jti: decoded.jti, - expiresAt: new Date(decoded.exp * 1000), - }); - - return ctx.json({ success: true }); -}); -``` - -### WebAuthn/Passkey Support - -```typescript -// Registration -router.post('/passkeys/register/options', async (ctx) => { - const user = ctx.get('userId'); - const options = await generateRegistrationOptions({ - rpID: 'officer.dev', - rpName: 'Officer', - userID: Buffer.from(String(user.id)), - userName: user.email, - }); - return ctx.json(options); -}); - -// Verification -router.post('/passkeys/register/verify', async (ctx) => { - const credential = await ctx.req.json(); - const verification = await verifyRegistrationResponse({ - credential, - expectedOrigin: 'https://officer.dev', - expectedRPID: 'officer.dev', - }); - - if (verification.verified) { - // Store passkey - await officerdb.insert(Passkeys).values({...}); - } - return ctx.json({ verified: verification.verified }); -}); -``` - ---- - -## Development & Deployment - -### Development Server - -```bash -# Start with hot reload -bun dev - -# Server runs on http://localhost:5000 -# Frontend available at http://localhost:5000/ -``` - -### Database Operations - -```bash -# Generate migration from schema changes -bun run db:gen - -# Apply pending migrations -bun run db:push - -# Open Drizzle Studio (UI for database) -bun run db:studio -``` - -### Build & Production - -```bash -# Full build -bun run build - -# Production server -NODE_ENV=production bun src/server.tsx - -# Runs on port specified by PORT env var (default 5000) -``` - -### Environment Variables - -```bash -# .env -DATABASE_URL=postgresql://user:pass@localhost:5432/officer -JWT_SECRET=your-secret-key -NODE_ENV=development -PORT=5000 -``` - ---- - -## Key Features - -### 1. Real-Time Terminal - -- Interactive shell in browser -- File operations -- Code execution -- Multi-session support -- Sandboxed mode option - -### 2. AI-Powered Development with Pi - -- Chat interface with Claude -- File reading/writing capabilities -- Shell command execution -- Session persistence -- Model selection - -### 3. Task Management - -- Create and track tasks -- Task dependencies -- Scheduled execution (cron) -- Task logs and history -- Process management - -### 4. Workspace Management - -- Multi-workspace support -- Project organization -- Settings per workspace -- Resource management - -### 5. File Management - -- File browser with preview -- Upload support (50GB max) -- Web scraping -- Export/import - -### 6. Skills System - -- Pi agent skills -- Custom task templates -- Reusable automation - -### 7. Settings & Configuration - -- User preferences -- System configuration -- Integration settings -- Resource limits - ---- - -## Performance Considerations - -### Connection Pooling -PostgreSQL connections are managed by Drizzle ORM with configurable pool size. - -### Caching -- Redis support for session data (optional) -- Database query result caching -- Static asset caching - -### Rate Limiting -- Per-user API limits (todo) -- WebSocket message throttling -- File upload size limits (50GB max) - -### Database Optimization -- Proper indexing on foreign keys -- Query optimization with relations -- Pagination for large datasets - ---- - -## Security - -### CORS -```typescript -honoServer.use(cors({ - origin: '*', - allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], - allowHeaders: ['Content-Type', 'Authorization'], -})); -``` - -### JWT Security -- Secret stored in env variables -- Token expiration enforced -- Token blacklist for revocation -- jti (JWT ID) for tracking - -### Password Security -- Argon2 hashing (modern, secure) -- No plain-text storage -- Email verification required -- Password reset via email - -### WebAuthn/FIDO2 -- Hardware key support -- Phishing-resistant -- No passwords stored for passkeys - ---- - -## Monitoring & Logging - -### Current Logging -- Console.log for debugging -- Error stack traces printed to console - -### Future Monitoring (Signoz Integration) -- Distributed tracing -- Performance monitoring -- Error tracking -- Log aggregation - ---- - -## Testing - -### Current Test Setup -- `@testing-library/react` for component tests -- `@playwright/test` for E2E tests -- `happy-dom` for DOM testing - -### Test Command -```bash -# Not yet fully configured -bun test -``` - ---- - -## Related Documentation - -- **Frontend Architecture**: See `OFFICERDEV_FRONTEND.md` -- **CONVENTIONS.md**: Detailed code patterns and style guide -- **CLAUDE.md**: Project overview and patterns -- **Each folder CLAUDE.md**: Feature-specific documentation - ---- - -## Summary - -Officer's backend is a modern, type-safe Bun monorepo with: -- RESTful API using Hono framework -- Real-time capabilities via WebSockets -- PostgreSQL persistence with Drizzle ORM -- AI integration through Pi coding agent -- Modular architecture with shared workspaces -- Strong TypeScript support throughout -- Robust authentication with JWT and WebAuthn - -The architecture prioritizes developer experience, type safety, and maintainability while providing powerful real-time and AI-powered features to users. diff --git a/OFFICERDEV_FRONTEND.md b/OFFICERDEV_FRONTEND.md deleted file mode 100644 index b0168bec..00000000 --- a/OFFICERDEV_FRONTEND.md +++ /dev/null @@ -1,1565 +0,0 @@ -# Officer.dev Frontend Architecture - -## Executive Summary - -Officer's frontend is a modern React 19 SPA (Single Page Application) built with: -- **React 19** with automatic compiler optimizations (no useCallback/useMemo needed) -- **React Router 7** for client-side navigation -- **React Query** for server state management -- **Tailwind CSS 4** with custom components for styling -- **Shadcn/ui** component library base -- **WebSockets** for real-time terminal and AI chat -- **Modular architecture** with shared component and hook libraries - -This document covers the frontend architecture, component patterns, state management, and development practices. - ---- - -## Table of Contents - -1. [Architecture Overview](#architecture-overview) -2. [Technology Stack](#technology-stack) -3. [Directory Structure](#directory-structure) -4. [Routing & Navigation](#routing--navigation) -5. [Component Architecture](#component-architecture) -6. [State Management](#state-management) -7. [API Communication](#api-communication) -8. [Real-Time Features](#real-time-features) -9. [Styling & Theming](#styling--theming) -10. [Performance & Optimization](#performance--optimization) -11. [Development Workflow](#development-workflow) - ---- - -## Architecture Overview - -### High-Level Architecture - -``` -┌──────────────────────────────────────────────────────────┐ -│ Browser Application │ -├──────────────────────────────────────────────────────────┤ -│ │ -│ App.tsx (Router Setup) │ -│ ↓ │ -│ BrowserRouter │ -│ ├─ Authentication Routes (public) │ -│ │ └─ Landing, SignIn, SignUp, ForgotPassword │ -│ │ │ -│ └─ Dashboard Routes (protected) │ -│ ├─ HomeScreen │ -│ ├─ SettingsPages (Profile, System, Resources) │ -│ ├─ Automation │ -│ ├─ SessionListPage (Chat with Pi) │ -│ ├─ PlansScreen │ -│ ├─ FilesScreen │ -│ ├─ CodeEditorScreen │ -│ ├─ SkillsScreen │ -│ ├─ TasksScreen │ -│ ├─ ProcessesScreen │ -│ ├─ TaskLogsScreen │ -│ ├─ WorkspacesScreen │ -│ ├─ ProjectListScreen │ -│ ├─ ProjectScreen │ -│ └─ TerminalScreen │ -│ │ -│ ↓ Shared State/Context │ -│ │ -│ ├─ AuthContext (useAuth) │ -│ ├─ GlobalState (useGlobalState) │ -│ ├─ ServerSettings (useServerSettings) │ -│ └─ ReactQuery (useQuery, useMutation) │ -│ │ -│ ↓ WebSocket Connections │ -│ │ -│ ├─ Terminal WebSocket │ -│ │ └─ Interactive shell in xterm.js │ -│ │ │ -│ └─ Pi Chat WebSocket │ -│ └─ Real-time streaming chat with Claude │ -│ │ -└──────────────────────────────────────────────────────────┘ - ↓ HTTP/REST & WebSocket - ┌────────────────┐ - │ Officer API │ - │ (port 5000) │ - └────────────────┘ -``` - -### Data Flow - -``` -User Interaction (Click, Type, etc.) - ↓ -Component Event Handler - ↓ -State Update (useState/Context/Query) - ↓ -API Call (HTTP or WebSocket) - ↓ -Server Processing - ↓ -Response - ↓ -Update Component State - ↓ -Re-render - ↓ -Updated UI -``` - ---- - -## Technology Stack - -### Core Frontend Framework -- **React 19** - Latest with automatic compiler optimizations -- **React DOM 19** - DOM rendering -- **React Router 7** - Client-side routing (file-based patterns support) -- **TypeScript 5.9** - Strict mode enabled - -### State Management -- **React Context** - Component tree data sharing -- **React Query (TanStack)** - Server state, caching, synchronization -- **Custom hooks** - Encapsulated business logic -- **Zustand** - Optional lightweight state (if used) - -### UI & Styling -- **Tailwind CSS 4** - Utility-first CSS framework -- **Shadcn/ui** - Accessible component library base -- **Radix UI** - Headless components for accessibility -- **Lucide React** - Icon library -- **Tabler Icons** - Additional icons -- **Class Variance Authority (CVA)** - Component style variations - -### Real-Time Communication -- **WebSocket API** - Native browser WebSocket -- **Json RPC** - Message protocol over WebSocket - -### Code Editor & Terminal -- **Monaco Editor** (@monaco-editor/react) - VS Code-like editor -- **xterm.js** (@xterm/xterm) - Terminal emulator -- **@uiw/react-textarea-code-editor** - Simple code editing - -### Data Visualization & Rich Content -- **Recharts** - Chart library -- **React Markdown** - Markdown rendering -- **Shiki** - Syntax highlighting -- **HTML2Canvas** - Screenshot capture -- **React Three Fiber** - 3D rendering (if used) - -### Form & Validation -- **React Hook Form** - Form state management -- **Zod** - Schema validation -- **@hookform/resolvers** - Form validation resolvers - -### UI Components & Utilities -- **Sonner** - Toast notifications -- **Vaul** - Drawer component -- **React Resizable Panels** - Resizable layout panels -- **React Virtual** - Virtual scrolling -- **Input OTP** - OTP input component -- **React Spinners** - Loading animations -- **React CountUp** - Animated numbers - -### Authentication -- **@simplewebauthn/browser** - WebAuthn/passkey support -- **@react-oauth/google** - Google OAuth integration -- **JWT Decode** - Token decoding - -### External Services -- **Googleapis** - Google API integration -- **Nodemailer** (backend) - Email sending - -### Development & Build Tools -- **Bun** - Runtime and package manager -- **Vite** - Build tool and dev server (if used) -- **Playwright** - E2E testing -- **Testing Library** - Component testing utilities -- **Happy DOM** - DOM testing - ---- - -## Directory Structure - -### Frontend Layout - -``` -src/ -├── apps/ -│ └── officer-web/ # Main dashboard app -│ ├── App.tsx # Root component & routing -│ ├── frontend.tsx # Vite entry point (if applicable) -│ ├── index.html # HTML template -│ │ -│ ├── Screens/ # Page-level components -│ │ ├── Authentication/ # Auth screens (public) -│ │ │ ├── LandingPage.tsx -│ │ │ ├── VerifyScreen.tsx -│ │ │ ├── SignInScreen.tsx -│ │ │ ├── SignUpScreen.tsx -│ │ │ ├── ForgotPassword.tsx -│ │ │ ├── ResetPassword.tsx -│ │ │ └── SignoutScreen.tsx -│ │ │ -│ │ └── Dashboard/ # Dashboard screens (protected) -│ │ ├── HomeScreen.tsx -│ │ ├── SettingsPages/ -│ │ │ ├── ProfileSettings.tsx -│ │ │ ├── SystemSettings.tsx -│ │ │ └── ResourceSettings.tsx -│ │ ├── Automation.tsx -│ │ ├── SessionListPage.tsx (Pi Chat) -│ │ ├── PlansScreen.tsx -│ │ ├── FilesScreen.tsx -│ │ ├── CodeEditorScreen.tsx -│ │ ├── SkillsScreen.tsx -│ │ ├── TasksScreen.tsx -│ │ ├── ProcessesScreen.tsx -│ │ ├── TaskLogsScreen.tsx -│ │ ├── WorkspacesScreen.tsx -│ │ ├── WorkspaceScreen.tsx -│ │ ├── ProjectListScreen.tsx -│ │ ├── ProjectScreen.tsx -│ │ └── TerminalScreen.tsx -│ │ -│ ├── state/ # Component state -│ │ ├── useAuth.ts # Authentication state -│ │ ├── useInitialData.ts # Initial data loading -│ │ └── useServerSettings.ts # Server configuration -│ │ -│ ├── lib/ # Client utilities -│ │ ├── api.ts # API client -│ │ └── websocket.ts # WebSocket utilities -│ │ -│ ├── styles/ # Global styles -│ │ ├── global.css -│ │ └── tailwind.css -│ │ -│ └── locales/ # Translations -│ ├── en.json -│ └── ... -│ -└── workspaces/ # Shared libraries - ├── components/ # Reusable React components - │ ├── ui/ # Basic UI components - │ │ ├── button.tsx - │ │ ├── input.tsx - │ │ ├── dialog.tsx - │ │ ├── dropdown-menu.tsx - │ │ ├── select.tsx - │ │ ├── tabs.tsx - │ │ ├── card.tsx - │ │ └── ...more - │ │ - │ ├── Complex/ # Feature components - │ │ ├── DataTable/ - │ │ ├── MarkdownEditor.tsx - │ │ ├── ColorPicker.tsx - │ │ ├── CommandBlock.tsx - │ │ └── ... - │ │ - │ ├── Workspace/ # Workspace-specific - │ ├── Dialogs/ # Dialog components - │ ├── ErrorDialogs/ # Error handling UI - │ ├── Logos/ # Logo variants - │ └── package.json - │ - ├── hooks/ # Custom React hooks - │ ├── useAuth.ts # Auth state hook - │ ├── useClient.ts # API client hook - │ ├── useQuery.ts # Data fetching - │ ├── useMutation.ts # Data mutation - │ └── ... - │ - ├── helpers/ # Utility functions - │ ├── formatters.ts # Date, number formatting - │ ├── validators.ts # Input validation - │ ├── converters.ts # Type conversions - │ └── ... - │ - ├── state/ # Global state - │ ├── useGlobalState.ts # Global state hook - │ └── ... - │ - ├── types/ # Type definitions - │ └── index.ts # Export all types - │ - ├── config/ # Configuration - │ ├── constants.ts - │ └── env.ts - │ - ├── definitions/ # Enums and constants - │ ├── roles.ts - │ ├── statuses.ts - │ └── ... - │ - ├── widgets/ # Complex UI widgets - │ └── ... - │ - └── i18n/ # Internationalization - └── ... -``` - ---- - -## Routing & Navigation - -### React Router Setup: `App.tsx` - -```typescript -import { BrowserRouter, Routes, Route, Navigate } from 'react-router'; -import { useAuth } from 'hooks/useAuth'; -import { useServerSettings } from 'state/useServerSettings'; - -export function App() { - const { isLoading, isAuthenticated } = useAuth(); - const { onboardingComplete, isLoading: isServerSettingsLoading } = useServerSettings(); - - // Show nothing while loading auth state - if (isLoading || isServerSettingsLoading) return null; - - return ( - - {!isAuthenticated && ( - - - } /> - } /> - } /> - } /> - } /> - - - )} - - {isAuthenticated && onboardingComplete && ( - - - {/* Home & Settings */} - } /> - } /> - } /> - } /> - - {/* Features */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - {/* Workspaces & Projects */} - } /> - } /> - } /> - } /> - } /> - - {/* Development */} - } /> - - {/* Auth */} - } /> - - {/* Fallback */} - } /> - - - )} - - ); -} -``` - -### Layout Components - -**AuthenticationLayout** - Wrapper for public pages -```typescript -export const AuthenticationLayout = ({ children }: Props) => { - return ( -
- {children} -
- ); -}; -``` - -**DashboardLayout** - Wrapper for protected pages -```typescript -export const DashboardLayout = ({ children }: Props) => { - return ( -
- -
-
-
- {children} -
-
-
- ); -}; -``` - ---- - -## Component Architecture - -### Component Patterns - -#### 1. Functional Components with Props - -All components are functional components using React 19: - -```typescript -type ButtonProps = { - children: React.ReactNode; - onClick: () => void; - variant?: 'primary' | 'secondary' | 'outline'; - size?: 'sm' | 'md' | 'lg'; - disabled?: boolean; -}; - -export const Button = ({ - children, - onClick, - variant = 'primary', - size = 'md', - disabled = false, -}: ButtonProps) => { - return ( - - ); -}; -``` - -#### 2. Component Organization - -Complex components follow a structured pattern: - -```typescript -// Directory structure -ComponentName/ -├── index.tsx # Exports the component -├── ComponentName.tsx # Main implementation -├── hooks/ # Local hooks -│ └── useComponentState.ts -├── types.ts # Component types -└── utils.ts # Helper functions - -// index.tsx -export { ComponentName } from './ComponentName'; -export type { ComponentNameProps } from './types'; - -// ComponentName.tsx -import { useComponentState } from './hooks/useComponentState'; -import type { ComponentNameProps } from './types'; - -export const ComponentName = ({ prop1, prop2 }: ComponentNameProps) => { - const { state, actions } = useComponentState(); - - return ( -
- {/* Render */} -
- ); -}; -``` - -#### 3. React 19 Patterns - -**NO useCallback - React 19's compiler handles optimization:** - -```typescript -// ❌ BAD - Unnecessary useCallback -export const Form = () => { - const handleSubmit = useCallback((data) => { - api.post('/data', data); - }, []); - - return ; -}; - -// ✅ GOOD - Plain function -export const Form = () => { - const handleSubmit = (data) => { - api.post('/data', data); - }; - - return ; -}; -``` - -**NO useMemo - Compiler handles memoization:** - -```typescript -// ❌ BAD - Unnecessary useMemo -export const List = ({ items, filter }) => { - const filtered = useMemo( - () => items.filter(i => i.type === filter), - [items, filter] - ); - - return
    {filtered.map(i =>
  • {i.name}
  • )}
; -}; - -// ✅ GOOD - Direct calculation -export const List = ({ items, filter }) => { - const filtered = items.filter(i => i.type === filter); - - return
    {filtered.map(i =>
  • {i.name}
  • )}
; -}; -``` - -**Avoid stale closures - access object properties directly:** - -```typescript -// ❌ BAD - Destructuring creates stale references -export const Editor = ({ state }) => { - const { content, save } = state; - - useEffect(() => { - const timer = setTimeout(() => { - save(content); // 'content' is stale! - }, 1000); - return () => clearTimeout(timer); - }, [content, save]); -}; - -// ✅ GOOD - Direct property access -export const Editor = ({ state }) => { - useEffect(() => { - const timer = setTimeout(() => { - state.save(state.content); // Always fresh - }, 1000); - return () => clearTimeout(timer); - }, [state]); -}; -``` - -#### 4. Keyboard Event Handling - -Always prevent default for game/editor controls: - -```typescript -export const CodeEditor = () => { - const handleKeyDown = (ev: KeyboardEvent) => { - // Prevent Space from scrolling page - if (ev.code === 'Space') { - ev.preventDefault(); - // Handle space key - } - - // Prevent Escape from closing dialogs - if (ev.key === 'Escape') { - ev.preventDefault(); - // Handle escape - } - }; - - return
{/* ... */}
; -}; -``` - -#### 5. Button Focus Management - -Buttons retain focus after clicking, interfering with keyboard shortcuts: - -```typescript -export const Toolbar = () => { - return ( - <> - - - ); -}; -``` - ---- - -## State Management - -### Authentication State: `useAuth` - -```typescript -// Location: workspaces/hooks/useAuth.ts - -export const useAuth = () => { - const [isLoading, setIsLoading] = useState(true); - const [isAuthenticated, setIsAuthenticated] = useState(false); - const [user, setUser] = useState(null); - const [token, setToken] = useState( - () => localStorage.getItem('token') - ); - - // Load auth state on mount - useEffect(() => { - const load = async () => { - if (!token) { - setIsAuthenticated(false); - setIsLoading(false); - return; - } - - try { - // Verify token validity - const response = await fetch('/api/auth/verify', { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (response.ok) { - const user = await response.json(); - setUser(user); - setIsAuthenticated(true); - } else { - setIsAuthenticated(false); - localStorage.removeItem('token'); - } - } catch { - setIsAuthenticated(false); - } finally { - setIsLoading(false); - } - }; - - load(); - }, [token]); - - const login = async (email: string, password: string) => { - const response = await fetch('/api/auth/signin', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }), - }); - - if (response.ok) { - const { token, user } = await response.json(); - localStorage.setItem('token', token); - setToken(token); - setUser(user); - setIsAuthenticated(true); - return { success: true }; - } - - return { success: false, error: await response.text() }; - }; - - const logout = async () => { - try { - await fetch('/api/auth/signout', { - method: 'POST', - headers: { Authorization: `Bearer ${token}` }, - }); - } catch { - // Ignore errors during logout - } finally { - localStorage.removeItem('token'); - setToken(null); - setUser(null); - setIsAuthenticated(false); - } - }; - - return { - isLoading, - isAuthenticated, - user, - token, - login, - logout, - }; -}; - -// Usage in components -export const Dashboard = () => { - const { user, logout } = useAuth(); - - return ( -
-

Welcome, {user?.email}

- -
- ); -}; -``` - -### Server Settings State: `useServerSettings` - -```typescript -export const useServerSettings = () => { - const [isLoading, setIsLoading] = useState(true); - const [onboardingComplete, setOnboardingComplete] = useState(false); - const [plugins, setPlugins] = useState([]); - const [settings, setSettings] = useState(null); - - useEffect(() => { - const load = async () => { - try { - const response = await fetch('/api/server-settings'); - if (response.ok) { - const data = await response.json(); - setOnboardingComplete(data.onboardingComplete); - setPlugins(data.plugins); - setSettings(data.settings); - } - } catch { - console.error('Failed to load server settings'); - } finally { - setIsLoading(false); - } - }; - - load(); - }, []); - - return { - isLoading, - onboardingComplete, - plugins, - settings, - }; -}; -``` - -### Global State: `useGlobalState` - -For component-tree-wide state, use Context: - -```typescript -type GlobalContextType = { - theme: 'light' | 'dark'; - setTheme: (theme: 'light' | 'dark') => void; - sidebarOpen: boolean; - setSidebarOpen: (open: boolean) => void; -}; - -const GlobalContext = createContext(null); - -export const GlobalProvider = ({ children }: { children: React.ReactNode }) => { - const [theme, setTheme] = useState<'light' | 'dark'>(() => { - const saved = localStorage.getItem('theme'); - return (saved as 'light' | 'dark') || 'dark'; - }); - - const [sidebarOpen, setSidebarOpen] = useState(true); - - useEffect(() => { - localStorage.setItem('theme', theme); - document.documentElement.classList.toggle('dark', theme === 'dark'); - }, [theme]); - - return ( - - {children} - - ); -}; - -export const useGlobalState = () => { - const context = useContext(GlobalContext); - if (!context) { - throw new Error('useGlobalState must be used within GlobalProvider'); - } - return context; -}; -``` - -### React Query for Server State - -```typescript -// useQueryPlan.ts -export const useQueryPlan = (id: number) => { - return useQuery({ - queryKey: ['plans', id], - queryFn: async () => { - const response = await fetch(`/api/plans/${id}`, { - headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }, - }); - if (!response.ok) throw new Error('Failed to load plan'); - return response.json(); - }, - }); -}; - -// useMutationCreatePlan.ts -export const useMutationCreatePlan = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (data: CreatePlanInput) => { - const response = await fetch('/api/plans', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${localStorage.getItem('token')}`, - }, - body: JSON.stringify(data), - }); - if (!response.ok) throw new Error('Failed to create plan'); - return response.json(); - }, - onSuccess: () => { - // Invalidate related queries - queryClient.invalidateQueries({ queryKey: ['plans'] }); - }, - }); -}; - -// In component -export const CreatePlanForm = () => { - const { mutate, isPending } = useMutationCreatePlan(); - - const handleSubmit = (data: CreatePlanInput) => { - mutate(data); - }; - - return ( -
- {/* Form fields */} - -
- ); -}; -``` - ---- - -## API Communication - -### Client Initialization: `src/apps/officer-web/lib/api.ts` - -```typescript -// Centralized API client -const API_BASE = import.meta.env.VITE_API_URL || '/api'; - -export const apiClient = { - async fetch( - endpoint: string, - options: RequestInit = {}, - ): Promise { - const token = localStorage.getItem('token'); - - return fetch(`${API_BASE}${endpoint}`, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...(token && { Authorization: `Bearer ${token}` }), - ...options.headers, - }, - }); - }, - - async get(endpoint: string): Promise { - const response = await this.fetch(endpoint); - if (!response.ok) throw new Error(`GET ${endpoint} failed`); - return response.json(); - }, - - async post(endpoint: string, body?: any): Promise { - const response = await this.fetch(endpoint, { - method: 'POST', - body: body ? JSON.stringify(body) : undefined, - }); - if (!response.ok) throw new Error(`POST ${endpoint} failed`); - return response.json(); - }, - - async put(endpoint: string, body?: any): Promise { - const response = await this.fetch(endpoint, { - method: 'PUT', - body: body ? JSON.stringify(body) : undefined, - }); - if (!response.ok) throw new Error(`PUT ${endpoint} failed`); - return response.json(); - }, - - async delete(endpoint: string): Promise { - const response = await this.fetch(endpoint, { method: 'DELETE' }); - if (!response.ok) throw new Error(`DELETE ${endpoint} failed`); - return response.json(); - }, -}; - -// Usage -const user = await apiClient.get('/users/me'); -await apiClient.post('/plans', { title: 'New Plan' }); -``` - ---- - -## Real-Time Features - -### Terminal WebSocket: `TerminalScreen.tsx` - -```typescript -import { XTerm } from '@xterm/xterm'; -import { FitAddon } from '@xterm/addon-fit'; -import '@xterm/xterm/css/xterm.css'; - -export const TerminalScreen = () => { - const terminalRef = useRef(null); - const xtermRef = useRef(null); - const wsRef = useRef(null); - - useEffect(() => { - const term = new XTerm({ - cols: 120, - rows: 40, - theme: { background: '#0f172a', foreground: '#e2e8f0' }, - }); - - const fitAddon = new FitAddon(); - term.loadAddon(fitAddon); - - // Mount terminal - if (terminalRef.current) { - term.open(terminalRef.current); - fitAddon.fit(); - } - - xtermRef.current = term; - - // Connect WebSocket - const token = localStorage.getItem('token'); - const cwd = '/home/user'; // Or from settings - - const ws = new WebSocket( - `ws://localhost:5000/api/terminal/ws?token=${token}&cwd=${encodeURIComponent(cwd)}&cols=120&rows=40` - ); - - ws.onopen = () => { - console.log('Terminal connected'); - }; - - ws.onmessage = (event) => { - const message = JSON.parse(event.data); - - if (message.type === 'output') { - term.write(message.data); // Display output - } else if (message.type === 'error') { - console.error('Terminal error:', message.error); - } - }; - - ws.onerror = () => { - term.write('\r\nConnection error\r\n'); - }; - - ws.onclose = () => { - term.write('\r\nDisconnected\r\n'); - }; - - // Send input from terminal - term.onData((data) => { - ws.send(JSON.stringify({ type: 'input', data })); - }); - - wsRef.current = ws; - - // Handle resize - const handleResize = () => { - fitAddon.fit(); - const { cols, rows } = term; - ws.send(JSON.stringify({ type: 'resize', cols, rows })); - }; - - window.addEventListener('resize', handleResize); - - // Cleanup - return () => { - window.removeEventListener('resize', handleResize); - ws.close(); - term.dispose(); - }; - }, []); - - return
; -}; -``` - -### Pi Chat WebSocket: `SessionListPage.tsx` - -```typescript -export const SessionListPage = ({ sessionId, isNew }: Props) => { - const [messages, setMessages] = useState([]); - const [input, setInput] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const wsRef = useRef(null); - - // Connect to Pi chat WebSocket - useEffect(() => { - const token = localStorage.getItem('token'); - const url = `ws://localhost:5000/api/pi/chat/ws?token=${token}&sessionId=${sessionId}`; - - const ws = new WebSocket(url); - - ws.onopen = () => { - console.log('Pi chat connected'); - }; - - ws.onmessage = (event) => { - const message = JSON.parse(event.data); - - switch (message.type) { - case 'message_update': - // Stream response text - if (message.event.type === 'text_delta') { - setMessages(prev => { - const last = prev[prev.length - 1]; - if (last && last.role === 'assistant') { - return [ - ...prev.slice(0, -1), - { ...last, content: last.content + message.event.delta }, - ]; - } - return prev; - }); - } - break; - - case 'tool_execution_start': - setMessages(prev => [ - ...prev, - { role: 'tool', toolName: message.toolName, content: 'Executing...' }, - ]); - break; - - case 'agent_end': - setIsLoading(false); - break; - } - }; - - ws.onerror = (error) => { - console.error('Pi chat error:', error); - setIsLoading(false); - }; - - wsRef.current = ws; - - return () => ws.close(); - }, [sessionId]); - - // Send message - const handleSendMessage = () => { - if (!input.trim() || !wsRef.current) return; - - const userMessage = input; - setInput(''); - setIsLoading(true); - - // Add user message to UI - setMessages(prev => [ - ...prev, - { role: 'user', content: userMessage }, - ]); - - // Send to Pi - wsRef.current.send(JSON.stringify({ - type: 'prompt', - text: userMessage, - })); - }; - - return ( -
-
- {messages.map((msg, i) => ( -
-
- {msg.content} -
-
- ))} - {isLoading &&
Loading...
} -
- -
-
- setInput(e.target.value)} - onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()} - placeholder="Ask Pi anything..." - disabled={isLoading} - className="flex-1 px-4 py-2 border rounded" - /> - -
-
-
- ); -}; -``` - ---- - -## Styling & Theming - -### Tailwind CSS Integration - -Officer uses Tailwind CSS 4 with a custom configuration: - -```javascript -// tailwind.config.js -export default { - content: [ - './src/**/*.{ts,tsx}', - ], - theme: { - extend: { - colors: { - // Custom color palette if needed - }, - spacing: { - // Custom spacing - }, - animation: { - // Custom animations - }, - }, - }, - plugins: [ - require('tailwindcss-animate'), - ], -}; -``` - -### Shadcn/ui Components - -Reusable UI components from shadcn/ui: - -```typescript -// Button component wrapper -import { Button as ShadcnButton } from '@/components/ui/button'; - -export const Button = (props) => ( - -); - -// Usage - -``` - -### Custom Theming - -Support for light/dark themes: - -```typescript -// Global theme management -export const useTheme = () => { - const { theme, setTheme } = useGlobalState(); - - useEffect(() => { - document.documentElement.classList.toggle('dark', theme === 'dark'); - localStorage.setItem('theme', theme); - }, [theme]); - - return { theme, setTheme }; -}; - -// In component -export const ThemeToggle = () => { - const { theme, setTheme } = useTheme(); - - return ( - - ); -}; -``` - ---- - -## Performance & Optimization - -### Image Optimization - -Use Next Image component or lazy loading: - -```typescript -import { lazy, Suspense } from 'react'; - -// Lazy load heavy components -const CodeEditor = lazy(() => import('./CodeEditor')); - -export const FeaturePage = () => { - return ( - Loading...
}> - - - ); -}; -``` - -### Virtual Scrolling for Large Lists - -Use React Virtual for efficient rendering: - -```typescript -import { useVirtualizer } from '@tanstack/react-virtual'; - -export const VirtualList = ({ items }: { items: Item[] }) => { - const parentRef = useRef(null); - - const virtualizer = useVirtualizer({ - count: items.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 50, - }); - - return ( -
-
- {virtualizer.getVirtualItems().map(virtualItem => ( -
- {items[virtualItem.index]?.name} -
- ))} -
-
- ); -}; -``` - -### Code Splitting with React Router - -Routes are automatically code-split: - -```typescript -// Lazy load screen components -const HomeScreen = lazy(() => import('./HomeScreen')); -const SettingsScreen = lazy(() => import('./SettingsScreen')); - -}> - - } /> - } /> - - -``` - -### React Query Caching - -Automatic caching and background updates: - -```typescript -// Data is cached per queryKey -const { data: plans } = useQuery({ - queryKey: ['plans'], // Cached - queryFn: fetchPlans, - staleTime: 1000 * 60 * 5, // 5 minutes - gcTime: 1000 * 60 * 10, // 10 minutes (was cacheTime) -}); - -// Background refetch when window regains focus -useQuery({ - queryKey: ['plans'], - queryFn: fetchPlans, - refetchOnWindowFocus: true, -}); -``` - ---- - -## Development Workflow - -### Development Server - -```bash -# Start Bun dev server with hot reload -bun dev - -# Runs on http://localhost:5000/ -# Frontend and backend both reload on file changes -``` - -### Environment Variables - -```bash -# .env (client-side, public) -VITE_API_URL=http://localhost:5000/api -``` - -### TypeScript Type Checking - -```bash -# Run TypeScript compiler -tsc --noEmit - -# Included in build process -bun run build -``` - -### Code Formatting - -```bash -# Format all files -bun format - -# Format specific files -bun format:check - -# Prettier config in .prettierrc -{ - "semi": true, - "singleQuote": true, - "trailingComma": "all", - "printWidth": 120 -} -``` - -### Component Development - -When creating new features: - -1. **Create component structure** -``` -src/apps/officer-web/Screens/MyFeature/ -├── index.tsx # Exports -├── MyFeatureScreen.tsx # Component -├── hooks/ -│ └── useMyFeature.ts -├── types.ts -└── utils.ts -``` - -2. **Define types** -```typescript -// types.ts -export type MyFeatureProps = { - onSubmit: (data: FormData) => void; - disabled?: boolean; -}; -``` - -3. **Implement component** -```typescript -// MyFeatureScreen.tsx -import { useMyFeature } from './hooks/useMyFeature'; -import type { MyFeatureProps } from './types'; - -export const MyFeatureScreen = ({ onSubmit, disabled }: MyFeatureProps) => { - const { state, actions } = useMyFeature(); - - return (/* JSX */); -}; -``` - -4. **Export** -```typescript -// index.tsx -export { MyFeatureScreen } from './MyFeatureScreen'; -export type { MyFeatureProps } from './types'; -``` - ---- - -## Integration Points - -### API Endpoints Used - -- **Auth**: `/api/auth/*` (signin, signup, verify, etc.) -- **Plans**: `/api/plans` (GET, POST, PUT, DELETE) -- **Skills**: `/api/skills` (GET, POST) -- **Tasks**: `/api/tasks` (CRUD operations) -- **Files**: `/api/file-browser` (navigation, upload) -- **Settings**: `/api/user` (preferences) -- **Terminal WS**: `/api/terminal/ws` (real-time shell) -- **Pi Chat WS**: `/api/pi/chat/ws` (real-time AI assistant) - -### State Flow from Server to UI - -``` -Database - ↓ -API Response - ↓ -React Query Cache - ↓ -Component State - ↓ -Rendered UI -``` - ---- - -## Debugging - -### Browser DevTools - -```javascript -// Log component props -console.log('Props:', props); - -// Log state updates -console.log('State changed:', newState); - -// React DevTools browser extension -// Inspect component tree and props -``` - -### React Query Devtools - -```typescript -// Installed: @tanstack/react-query-devtools - -import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; - -export function App() { - return ( - <> - {/* App content */} - - - ); -} -``` - ---- - -## Common Patterns - -### Loading States - -```typescript -export const DataComponent = () => { - const { data, isLoading, error } = useQuery({ - queryKey: ['data'], - queryFn: fetchData, - }); - - if (isLoading) return ; - if (error) return ; - if (!data) return ; - - return ; -}; -``` - -### Form Handling with React Hook Form - -```typescript -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; - -const schema = z.object({ - email: z.string().email('Invalid email'), - password: z.string().min(8, 'Min 8 chars'), -}); - -type FormData = z.infer; - -export const LoginForm = () => { - const { register, handleSubmit, formState: { errors } } = useForm({ - resolver: zodResolver(schema), - }); - - const onSubmit = async (data: FormData) => { - await apiClient.post('/auth/signin', data); - }; - - return ( -
- - {errors.email && {errors.email.message}} - - - {errors.password && {errors.password.message}} - - -
- ); -}; -``` - -### Toast Notifications - -```typescript -import { toast } from 'sonner'; - -// Success -toast.success('Operation completed!'); - -// Error -toast.error('Something went wrong', { - description: 'Please try again later', -}); - -// Custom -toast.custom((t) => ( -
Custom notification
-)); -``` - ---- - -## Related Documentation - -- **Backend Architecture**: See `OFFICERDEV_BACKEND.md` -- **CONVENTIONS.md**: Detailed code patterns and style -- **CLAUDE.md**: Project overview -- **apps/officer-web/CLAUDE.md**: Frontend-specific patterns - ---- - -## Summary - -Officer's frontend is a modern React 19 SPA featuring: -- Type-safe component architecture -- Real-time WebSocket communication -- Server state management with React Query -- Beautiful UI with Tailwind CSS and shadcn/ui -- Strong React patterns without useCallback/useMemo -- Comprehensive authentication and authorization -- Responsive design for all devices -- AI-powered development assistance via Pi -- Interactive terminal emulation - -The architecture prioritizes developer experience, type safety, and performance while providing a rich, interactive user experience for life management and AI-assisted development. diff --git a/SETUP_ANALYSIS.md b/SETUP_ANALYSIS.md deleted file mode 100644 index 02b4d632..00000000 --- a/SETUP_ANALYSIS.md +++ /dev/null @@ -1,310 +0,0 @@ -# Officer Setup Flow Analysis - -## Overview -The setup.sh script installs all dependencies for Officer, but there are several issues that can cause problems, especially with nvm Node.js environments. - -## Setup Flow - -``` -1. Detect package manager (apt/pacman/brew) - ↓ -2. Install core system packages (git, zip, curl, zsh, build-essential, etc.) - ↓ -3. Install archive utilities (7z, unrar) - ↓ -4. Install ffmpeg - ↓ -5. Configure sudoers for service user - ↓ -6. Install Node.js 22 (or warn if not found) - ↓ -7. Configure npm global prefix (~/.npm-global) - ↓ -8. Install Bun - ↓ -9. Install Go 1.23.6 - ↓ -10. Install Rust - ↓ -11. Install PulseAudio (for audio) - ↓ -12. Build cliamp from source (Go music player) - ↓ -13. Install Neovim - ↓ -14. Install terminal tools (starship, oh-my-zsh, eza, lazygit) - ↓ -15. Install yt-dlp (optional) - ↓ -16. Install npm global packages (Pi, Claude Code, pm2) - ↓ -17. Run bun install (project dependencies) - ↓ -18. Setup remote desktop (XFCE + VNC) - ↓ -19. Setup PTY sidecar (systemd service) - ↓ -20. Verification -``` - -## Issues Found - -### 1. **nvm Node.js Not Properly Documented** - -**Location:** `scripts/setup.sh` (line ~238) - -**Problem:** -```bash -if has node; then - NODE_VER=$(node -v 2>/dev/null | tr -d 'v') - NODE_MAJOR=$(echo "$NODE_VER" | cut -d. -f1) - if [ "$NODE_MAJOR" = "22" ]; then - skip "node v$NODE_VER" - else - warn "Node $NODE_VER found but v22 is required" - warn "Use nvm: nvm install 22 && nvm use 22" - fi -else - warn "Node.js not found — install v22 via nvm:" - warn " curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash" - warn " nvm install 22" -fi -``` - -**Issue:** -- Only warns about nvm but doesn't ensure it's sourced in the current shell -- When installing pm2 and other npm global packages, nvm might not be available -- When services (pm2, systemd) later run, they won't have nvm initialized - -**Impact:** -- Users install nvm, run setup.sh in that shell, but when pm2/systemd runs later, it uses the wrong node or no node - ---- - -### 2. **PTY Sidecar Service Uses Unbounded `which node`** - -**Location:** `scripts/setup-pty-sidecar.sh` (line ~16) - -**Problem:** -```bash -NODE_BIN="$(which node)" -``` - -**Issue:** -- If nvm is not sourced in the current shell, `which node` returns nothing or the system node -- The systemd service will run with the wrong node binary -- When systemd runs, nvm environment is not available anyway - -**Impact:** -- PTY sidecar service fails to start or runs with wrong node -- Terminal functionality breaks in Officer - ---- - -### 3. **PM2/Ecosystem Config Doesn't Handle nvm** - -**Location:** `ecosystem.config.cjs` - -**Problem:** -```javascript -module.exports = { - apps: [ - { - name: 'officer', - script: 'bun', - args: 'start', - watch: false, - }, - ], -}; -``` - -**Issue:** -- No environment setup for nvm -- PM2 runs with whatever node is in system PATH -- If user installed node via nvm, PM2 won't find it -- This is why you had to restart the server after installing nvm - -**Impact:** -- Officer server fails to start after fresh nvm installation -- No clear error message about nvm not being available - ---- - -### 4. **No Documentation on Node Installation Methods** - -**Location:** `scripts/setup.sh` (lines 238-250) - -**Problem:** -- Script warns about nvm but doesn't explain the workflow -- No mention of snap node incompatibility -- No mention of system apt/NodeSource installation -- No guidance on which method to use when - -**Impact:** -- Users can choose any installation method -- Some methods (snap) don't work with Officer -- New issues arise from incompatible setups - ---- - -### 5. **Pi Installation Doesn't Validate nvm Environment** - -**Location:** `scripts/setup.sh` (lines ~408-420) - -**Problem:** -```bash -if has pi; then - skip "pi (@mariozechner/pi-coding-agent)" -else - npm install -g @mariozechner/pi-coding-agent - if has pi; then ok "pi installed"; else warn "pi install failed"; fi -fi -``` - -**Issue:** -- Installs pi with `npm install -g`, but npm might be different than later shells -- No validation that pi works (should test `pi --list-models`) -- No check that Pi was installed to the right npm location - -**Impact:** -- Pi appears installed but fails at runtime when shell environment differs - ---- - -## Fixes Required - -### Fix 1: Source nvm Before Installing Global Packages - -```bash -# At the start of setup.sh, after detecting package manager -echo "" -echo "── Node.js Environment ──" - -# Check if nvm needs to be sourced -if [ -s "$HOME/.nvm/nvm.sh" ]; then - source "$HOME/.nvm/nvm.sh" - nvm use 22 || nvm install 22 - ok "nvm activated: $(node -v)" -elif ! has node; then - fail "Node.js not found and nvm not installed" - fail "Install nvm first: curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash" - exit 1 -fi -``` - -### Fix 2: Update PTY Sidecar Setup to Use Correct Node - -```bash -# In scripts/setup-pty-sidecar.sh -if [ -s "$HOME/.nvm/nvm.sh" ]; then - source "$HOME/.nvm/nvm.sh" - nvm use 22 2>/dev/null || true -fi - -NODE_BIN="$(which node)" -if [ ! -f "$NODE_BIN" ]; then - echo "ERROR: Node.js not found in PATH" - exit 1 -fi -``` - -### Fix 3: Update PM2 Ecosystem Config - -```javascript -module.exports = { - apps: [ - { - name: 'officer', - script: 'bun', - args: 'start', - watch: false, - // Source nvm before running - exec_mode: 'cluster', - instances: 1, - env: { - NODE_ENV: 'production', - // This helps systemd find the right node - NVM_DIR: '$HOME/.nvm', - }, - // For systemd service, use a wrapper script - }, - ], -}; -``` - -Or better: Create a wrapper script for PM2: - -```bash -#!/bin/bash -# bin/start.sh -set -euo pipefail - -# Source nvm if available -if [ -s "$HOME/.nvm/nvm.sh" ]; then - source "$HOME/.nvm/nvm.sh" -fi - -# Now start Officer -NODE_ENV=production bun src/server.tsx -``` - -Then in ecosystem.config.cjs: -```javascript -{ - name: 'officer', - script: 'bin/start.sh', - // ... -} -``` - -### Fix 4: Validate Pi Installation - -```bash -# After installing Pi -if has pi; then - if pi --list-models > /dev/null 2>&1; then - ok "pi installed and working" - else - warn "pi installed but --list-models failed" - warn "Try: nvm use && npm install -g @mariozechner/pi-coding-agent" - fi -else - warn "pi install failed" -fi -``` - -### Fix 5: Add Setup Documentation - -Create `SETUP_GUIDE.md` with clear instructions on: -1. Choose ONE Node installation method (recommend nvm) -2. Source nvm in shell before running setup.sh -3. Setup.sh will validate node and npm are available -4. Services (PM2, systemd) will inherit nvm environment - ---- - -## Recommendations - -1. **Make nvm sourcing automatic** at the start of setup.sh -2. **Add environment wrapper script** for PM2 that sources nvm -3. **Document the three Node installation options** with pros/cons: - - nvm (recommended, flexible versions) - - NodeSource (system package, simple) - - apt (if available in repo) - - ❌ snap (broken, don't use) -4. **Validate Pi works** before marking setup complete -5. **Create a post-setup check script** that verifies everything works - ---- - -## Current Workaround - -If setup.sh already ran with snap node: -1. Remove snap: `sudo snap remove node` -2. Install nvm: `curl -o- ... | bash` (reload shell) -3. Install node: `nvm install 22 && nvm use 22` -4. Reinstall pm2 packages: `npm install -g pm2` -5. Restart pm2/officer - -This is what you just did! diff --git a/docs/per-user-api-keys.md b/docs/per-user-api-keys.md deleted file mode 100644 index 244e56e0..00000000 --- a/docs/per-user-api-keys.md +++ /dev/null @@ -1,92 +0,0 @@ -# Per-User API Keys — Architecture Analysis - -## Current State - -- Single shared `auth.json` at `~/.pi/agent/auth.json` (server-side) -- Pi CLI supports `--api-key` flag — we already use this in `pi-bridge.ts` via `resolveApiKeyForModel()` -- User settings have unused fields: `ai.enabledModels`, `ai.enabledProviders`, `ai.disabledProviders` -- 13 providers supported: anthropic, openai, google, groq, mistral, xai, openrouter, minimax, huggingface, azure-openai-responses, opencode, zai, cerebras - -## Architecture - -### Layer 1: Key Resolution - -**Precedence:** User key > System key > fail - -- Store user keys in `user_settings.ai.apiKeys` (JSONB column, no new DB table needed) -- Format: `{ "ai": { "apiKeys": { "openai": "sk-...", "anthropic": "sk-ant-..." } } }` -- `resolveApiKeyForModel()` in `pi-bridge.ts` checks user keys first, falls back to system `auth.json` -- Key passed to Pi via `--api-key` CLI flag (already implemented for system keys) -- Keys never touch the container filesystem — all resolution is server-side - -### Layer 2: Model Visibility - -- System access policy defines base available providers/models -- User's own provider keys unlock additional providers -- Existing `enabledProviders`/`enabledModels` settings fields drive filtering -- Model listing becomes a union: system models + user's provider models - -## Key Design Decisions - -1. **No new DB table** — use existing `user_settings` JSONB column -2. **No per-user auth.json files** — all key resolution server-side via `--api-key` flag -3. **Eventually remove PI_CONFIG_DIR mount** — auth.json in container no longer needed for keys (still needed for local providers/models.json) -4. **Model listing = union** of system + user models - -## What This Enables - -- Admin deploys with their keys → all users can chat -- User adds their own key → gets access to that provider -- User's key takes priority (user pays for their own usage) -- No credential leakage (keys in DB, passed as CLI args, never on container filesystem) - -## Files to Change - -| File | Change | -|------|--------| -| `src/servers/api/pi/pi-bridge.ts` | Update `resolveApiKeyForModel()` to check user settings first | -| `src/servers/api/settings/settings.ts` | API endpoint for saving/deleting user API keys (encrypted at rest ideally) | -| `src/workspaces/state/src/useSettings.ts` | Add `ai.apiKeys` to `UserSettings` type | -| `src/servers/api/pi/list-models.ts` | Union system + user provider models | -| `src/workspaces/officerdev/src/hooks/usePiModels.ts` | Filter models based on user's available providers | -| `src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/AIModels.tsx` | UI for managing per-user API keys | -| `src/servers/api/server-settings/pi-mono.ts` | Distinguish admin vs user key management | - -## Deep-Dive: Pi CLI & Auth - -### CLI Flags -- `--provider` — force provider -- `--model` — force model (format: `provider/model-name`) -- `--api-key` — force API key (takes precedence over auth.json and env vars) -- `--system-prompt` — system prompt - -### Supported Environment Variables -`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `GROQ_API_KEY`, `MISTRAL_API_KEY`, `XAI_API_KEY`, `OPENROUTER_API_KEY`, etc. - -### auth.json Format -```json -{ - "provider_id": { - "type": "api_key", - "key": "sk-..." - } -} -``` - -### Key Resolution Precedence (inside Pi) -`--api-key` flag > auth.json entry > environment variable > built-in providers - -### Pi Spawn Flow (pi-bridge.ts) -1. Determine model from user settings or default -2. `resolveApiKeyForModel(model)` extracts provider from `provider/model-name` -3. Looks up key in auth.json (currently system-level only) -4. Passes `--api-key ` when spawning Pi process -5. Container never sees raw credentials - -## Implementation Steps - -1. **Add key storage** — extend `UserSettings` type, add API endpoint for CRUD -2. **Update key resolution** — `resolveApiKeyForModel(model, userId?)` checks user DB first -3. **Update model listing** — merge system models with user-unlocked provider models -4. **Build settings UI** — API key input per provider in AI settings page -5. **Remove PI_CONFIG_DIR mount** (later) — once models.json is also handled server-side diff --git a/event-handler-instances.md b/event-handler-instances.md deleted file mode 100644 index ca374c6f..00000000 --- a/event-handler-instances.md +++ /dev/null @@ -1,361 +0,0 @@ -# Event Handler Instances: `{() => handler(arg)}` Pattern - -Found 167 instances across the codebase. - ---- - -## Workspaces / Shared Components - -### `src/workspaces/components/MarkdownEditor.tsx` -- Line 48: `onClick={() => setShowPreview(false)}` -- Line 52: `onClick={() => setShowPreview(true)}` - -### `src/workspaces/components/SearchInput.tsx` -- Line 49: `onClick={() => handleSearch('')}` - -### `src/workspaces/components/ErrorDialogs/CustomError.tsx` -- Line 25: `onClick={() => onOpenChange(false)}` - -### `src/workspaces/components/ErrorDialogs/ForbiddenDialog.tsx` -- Line 27: `onClick={() => onOpenChange(false)}` - -### `src/workspaces/components/ErrorDialogs/UnauthorizedDialog.tsx` -- Line 29: `onClick={() => onOpenChange(false)}` - -### `src/workspaces/components/ErrorDialogs/ServerError.tsx` -- Line 25: `onClick={() => onOpenChange(false)}` - -### `src/workspaces/components/DataTable/PaginationBar.tsx` -- Line 83: `onClick={() => changePage(1)}` -- Line 93: `onClick={() => changePage(currentPage - 1)}` -- Line 103: `onClick={() => changePage(currentPage + 1)}` -- Line 113: `onClick={() => changePage(pageCount)}` - -### `src/workspaces/components/DataTable/DataTable.tsx` -- Line 89: `onClick={() => sortBy(sortKey as Extract | undefined)}` - -### `src/workspaces/components/ui/mode-toggle.tsx` -- Line 16: `onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}` - ---- - -## Editor App - -### `src/apps/editor/app/src/EditorV2/index.tsx` -- Line 73: `onCollapse={() => setLeftCollapsed(true)}` -- Line 74: `onExpand={() => setLeftCollapsed(false)}` -- Line 97: `onCollapse={() => setRightCollapsed(true)}` -- Line 98: `onExpand={() => setRightCollapsed(false)}` - -### `src/apps/editor/app/src/EditorV2/ScreenshotDialog.tsx` -- Line 114: `onClick={() => setView('list')}` -- Line 131: `onClick={() => setView('editor')}` - -### `src/apps/editor/app/src/EditorV2/Header/index.tsx` -- Line 110: `onClick={() => setDevice(d)}` -- Line 134: `onClick={() => setDevice(d)}` - -### `src/apps/editor/app/src/EditorV2/Header/GlobalCodeMenu.tsx` -- Line 32: `onClick={() => openEditor('js')}` -- Line 33: `onClick={() => openEditor('css')}` - -### `src/apps/editor/app/src/EditorV2/Header/ElementSelector.tsx` -- Line 59: `onClick={() => setIsOpen(false)}` - -### `src/apps/editor/app/src/EditorV2/RightSidebar/ElementInfo.tsx` -- Line 23: `setTimeout(() => setCopiedHierarchical(false), 2000)` -- Line 31: `setTimeout(() => setCopiedClassBased(false), 2000)` - -### `src/apps/editor/app/src/EditorV2/RightSidebar/Breadcrumb.tsx` -- Line 60: `onClick={() => selectMember(index)}` -- Line 100: `onClick={() => selectMember(index)}` - -### `src/apps/editor/app/src/EditorV2/RightSidebar/fields/PropertyColor.tsx` -- Line 29: `return () => clearInterval(interval)` - -### `src/apps/editor/app/src/EditorV2/LeftSidebar/HierarchyTab.tsx` -- Line 25: `onClick={() => onSelect(index)}` - -### `src/apps/editor/app/src/EditorV2/LeftSidebar/ChangesTab.tsx` -- Line 46: `onMouseEnter={() => handleMouseEnter(change)}` -- Line 48: `onClick={() => selectByChange(change)}` - -### `src/apps/editor/app/src/EditorV2/Preview/index.tsx` -- Line 118: `onClick={() => setInteractive(!interactive)}` -- Line 132: `onClick={() => setMoveAnywhere(!moveAnywhere)}` - -### `src/apps/editor/app/src/Editor/CodeEditorWindow/index.tsx` -- Line 31: `onClick={() => setIsFullscreen(false)}` -- Line 33: `onClick={() => setIsFullscreen(true)}` - -### `src/apps/editor/app/src/Editor/ContextMenu/index.tsx` -- Line 20: `onClick={() => setIsContextOpen(false)}` -- Line 26: `onClick={() => openEditor('html')}` - -### `src/apps/editor/app/src/Editor/BottomToolbar/GlobalCode/index.tsx` -- Line 22: `onClick={() => openEditor('js')}` -- Line 27: `onClick={() => openEditor('css')}` - -### `src/apps/editor/app/src/Editor/BottomToolbar/VariantSelector/index.tsx` -- Line 44: `onClick={() => setIsOpen(true)}` - -### `src/apps/editor/app/src/Editor/BottomToolbar/VariantSelector/CreateNewVariant.tsx` -- Line 23: `onClose={() => setIsOpen(false)}` - -### `src/apps/editor/app/src/Editor/BottomToolbar/QuerySelector/index.tsx` -- Line 49: `onClick={() => setIsOpen(false)}` - -### `src/apps/editor/app/src/Editor/BottomToolbar/DeviceSelector/index.tsx` -- Line 23: `onSelect={() => setDevice(device)}` - -### `src/apps/editor/app/src/Editor/BottomToolbar/MoveAnywhere/index.tsx` -- Line 12: `onClick={() => setMoveAnywhere((curr) => !curr)}` - -### `src/apps/editor/app/src/Editor/BottomToolbar/InteractivitySelector/index.tsx` -- Line 13: `onClick={() => setInteractive((curr) => !curr)}` - -### `src/apps/editor/app/src/Editor/BottomToolbar/Changes/ChangesList.tsx` -- Line 26: `onMouseEnter={() => selectByChange(change)}` -- Line 48: `onClick={() => removeChange(change)}` - -### `src/apps/editor/app/src/Editor/BottomToolbar/Screenshot/ScreenshotList.tsx` -- Line 38: `onMouseEnter={() => setHovered(screenshot.id)}` -- Line 39: `onMouseLeave={() => setHovered(null)}` -- Line 56: `onClick={() => setEditing(null)}` -- Line 64: `onClick={() => setEditing(screenshot.id)}` -- Line 68: `onClick={() => setWantsToDelete(screenshot.id)}` -- Line 75: `onConfirm={() => onDeleteConfirm(screenshot.id)}` -- Line 76: `onClose={() => setWantsToDelete(null)}` - -### `src/apps/editor/app/src/Editor/BottomToolbar/Screenshot/Controls.tsx` -- Line 87: `onClick={() => handleUpload(comment)}` - -### `src/apps/editor/app/src/Editor/BottomToolbar/Screenshot/index.tsx` -- Line 38: `onClick={() => setView('list')}` -- Line 47: `onClick={() => setView('upload')}` - -### `src/apps/editor/extension/src/Auth/SigninPage.tsx` -- Line 18: `setTimeout(() => navigate('/'), 100)` - -### `src/apps/editor/extension/src/Screens/ExperimentList/ExperimentVariantList/SearchFilter.tsx` -- Line 22: `onClick={() => onSearchChange('')}` - ---- - -## Dashboard App - -### `src/apps/dashboard/hooks/use-toast.tsx` -- Line 143: `const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })` - -### `src/apps/dashboard/Screens/AdminTools/TestExperiments/TestExperiments.tsx` -- Line 38: `onClick={() => setShowDuplicateModal(true)}` -- Line 48: `onClose={() => setShowDuplicateModal(false)}` - -### `src/apps/dashboard/Screens/Dashboard/Monitor/TrendTable.tsx` -- Line 99: `onToggle={() => toggleRow(`trend-${exp.id}`)}` -- Line 114: `onClick={() => setCurrentPage(1)}` -- Line 120: `onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}` -- Line 128: `onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}` -- Line 136: `onClick={() => setCurrentPage(totalPages)}` - -### `src/apps/dashboard/Screens/Dashboard/Monitor/GroupedExperimentsTable.tsx` -- Line 75: `onToggle={() => toggleRow(`grouped-${exp.id}`)}` -- Line 91: `onClick={() => setCurrentPage(1)}` -- Line 97: `onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}` -- Line 105: `onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}` -- Line 113: `onClick={() => setCurrentPage(totalPages)}` - -### `src/apps/dashboard/Screens/Dashboard/Home/ExperimentsOverviewTableMobile.tsx` -- Line 23: `onClick={() => sort('id')}` -- Line 28: `onClick={() => sort('status')}` -- Line 34: `onClick={() => sort('sessions')}` - -### `src/apps/dashboard/Screens/Dashboard/Home/ConfirmDialogs.tsx` -- Line 36: `onOpenChange={() => setDialogAction(null)}` - -### `src/apps/dashboard/Screens/Dashboard/Home/RowActions.tsx` -- Line 31: `onClick={() => navigate(`/experiments/${id}`)}` -- Line 32: `onClick={() => setDialogAction('duplicate')}` -- Line 34: `onClick={() => setDialogAction('archive')}` -- Line 37: `onClick={() => setDialogAction('delete')}` - -### `src/apps/dashboard/Screens/Dashboard/Clients/index.tsx` -- Line 76: `onClick={() => setIsCreating(true)}` -- Line 108: `onClick={() => setIsCreating(true)}` -- Line 119: `onClick={() => setOrganization(value as number)}` -- Line 146: `onClick={() => handleSaveEdit(item.id)}` -- Line 163: `onClick={() => handleEditName(item.id, item.friendlyName ?? '')}` - -### `src/apps/dashboard/Screens/Dashboard/Experiments/Details/Timeline.tsx` -- Line 111: `onClick={() => setMobileOpen((v) => !v)}` - -### `src/apps/dashboard/Screens/Dashboard/Experiments/Library/ActionModals.tsx` -- Line 37: `onCancel={() => setDialogAction(null)}` - -### `src/apps/dashboard/Screens/Dashboard/Experiments/Library/TopHeader.tsx` -- Line 44: `onClick={() => setIsCreating(true)}` -- Line 69: `onClick={() => setIsCreating(true)}` - -### `src/apps/dashboard/Screens/Dashboard/Experiments/Library/ActionsCell.tsx` -- Line 29: `onClick={() => navigate(`/experiments/${id}`)}` -- Line 30: `onClick={() => setDialogAction('duplicate')}` -- Line 32: `onClick={() => setDialogAction('archive')}` -- Line 35: `onClick={() => setDialogAction('delete')}` -- Line 44: `onClose={() => setDialogAction(null)}` - -### `src/apps/dashboard/Screens/Dashboard/Experiments/Library/CreateExperimentModal.tsx` -- Line 88: `onCancel={() => setIsCreating(false)}` - -### `src/apps/dashboard/Screens/Dashboard/Experiments/Details/Header.tsx` -- Line 86: `onClick={() => setDialogAction('duplicate')}` -- Line 89: `onClick={() => setDialogAction('archive')}` -- Line 92: `onClick={() => setDialogAction('delete')}` -- Line 199: `onCancel={() => setDialogAction(null)}` -- Line 214: `onCancel={() => setDialogAction(null)}` -- Line 232: `onCancel={() => setDialogAction(null)}` -- Line 246: `onCancel={() => setDialogAction(null)}` - -### `src/apps/dashboard/Screens/Dashboard/Settings/Billing/index.tsx` -- Line 137: `onClick={() => createCheckoutSession(+price.id)}` - -### `src/apps/dashboard/Screens/Dashboard/Settings/AgencyTeam/index.tsx` -- Line 125: `onClick={() => setInviteDialogOpen(true)}` -- Line 136: `onClick={() => setInviteDialogOpen(true)}` -- Line 176: `onClick={() => resendInvite(item.email)}` -- Line 217: `() => unblockUser(item.id)` -- Line 230: `() => blockUser(item.id)` -- Line 239: `onClick={() => resendInvite(item.email)}` -- Line 247: `() => deleteUser(item.id)` -- Line 303: `onClick={() => setInviteDialogOpen(false)}` -- Line 315: `onCancel={() => setConfirmDialog({ ...confirmDialog, open: false })}` - -### `src/apps/dashboard/Screens/Dashboard/Settings/OrganizationTeam/index.tsx` -- Line 163: `onClick={() => setInviteDialogOpen(true)}` -- Line 176: `onClick={() => setInviteDialogOpen(true)}` -- Line 217: `onClick={() => resendInvite(item.email)}` -- Line 259: `() => unblockUser(item.id)` -- Line 272: `() => blockUser(item.id)` -- Line 281: `onClick={() => resendInvite(item.email)}` -- Line 289: `() => deleteUser(item.id)` -- Line 361: `onClick={() => setInviteDialogOpen(false)}` -- Line 373: `onCancel={() => setConfirmDialog({ ...confirmDialog, open: false })}` - -### `src/apps/dashboard/Screens/Dashboard/Experiments/Details/ExperimentSettings/VariantsConfig.tsx` -- Line 57: `onClick={() => setShowAddVariant(true)}` -- Line 167: `onClose={() => setEditingVariant(null)}` -- Line 176: `onClose={() => setRenamingVariant(null)}` -- Line 180: `onClose={() => setShowAddVariant(false)}` -- Line 193: `onCancel={() => setDeletingVariant(null)}` - -### `src/apps/dashboard/Screens/Dashboard/Experiments/Details/ExperimentSettings/CookieTargeting.tsx` -- Line 50: `onClick={() => deleteCookieTargeting(item.id)}` - -### `src/apps/dashboard/Screens/Dashboard/Experiments/Details/ExperimentSettings/DeviceTargeting.tsx` -- Line 54: `onClick={() => deleteDeviceTargeting(device.id)}` -- Line 65: `onClick={() => setIsAddOpen(true)}` - -### `src/apps/dashboard/Screens/Dashboard/Experiments/Details/ExperimentSettings/GoalsConfig.tsx` -- Line 85: `onCancel={() => setIsDialogOpen(false)}` -- Line 97: `onCheckedChange={() => toggleGoal(tag.name)}` -- Line 113: `onCheckedChange={() => toggleGoal(tag.name)}` - -### `src/apps/dashboard/Screens/Dashboard/Experiments/Details/ExperimentSettings/UrlTargeting.tsx` -- Line 95: `onClick={() => openEditDialog(item)}` -- Line 98: `onClick={() => deleteUrlTargeting(item.id)}` -- Line 119: `onCancel={() => setEditItem(null)}` - -### `src/apps/dashboard/Screens/Dashboard/Experiments/Details/ExperimentSettings/EditWeightsModal.tsx` -- Line 25: `useState(() => String(Math.round((currentWeight || 0) / 100)))` - -### `src/apps/dashboard/Screens/Dashboard/Experiments/Details/ExperimentInfo/Description.tsx` -- Line 44: `onClick={() => setIsEditing(true)}` - -### `src/apps/dashboard/Screens/Dashboard/Experiments/Details/ExperimentStatistics/StatisticsTable.tsx` -- Line 209: `onClick={() => setWantsToDeploy(item)}` -- Line 227: `onCancel={() => setWantsToDeploy(null)}` - -### `src/apps/dashboard/Screens/Dashboard/Experiments/Details/ExperimentInfo/Screenshots/ScreenshotItem.tsx` -- Line 62: `onClick={() => setIsOpen(true)}` -- Line 65: `onLoad={() => setImageLoaded(true)}` -- Line 90: `onClick={() => setEditing(null)}` -- Line 107: `onClick={() => setWantsToDelete(true)}` -- Line 114: `onClose={() => setIsOpen(false)}` -- Line 119: `onCancel={() => setWantsToDelete(false)}` - -### `src/apps/dashboard/Screens/Dashboard/Experiments/Details/ExperimentInfo/Screenshots/ScreenshotList.tsx` -- Line 49: `onMouseEnter={() => setHovered(screenshot.id)}` -- Line 49: `onMouseLeave={() => setHovered(null)}` - -### `src/apps/dashboard/Screens/Dashboard/Websites/List/index.tsx` -- Line 71: `onClick={() => setIsCreating(true)}` -- Line 100: `onClick={() => setIsCreating(true)}` -- Line 112: `onClick={() => setWebsite(value as number)}` -- Line 167: `onClick={() => refreshPropertyTags(item)}` -- Line 190: `onClick={() => recheckPermissions(item.ganPropertyId!)}` -- Line 220: `onClick={() => handleDelete(value as number)}` - -### `src/apps/dashboard/Screens/Dashboard/Websites/List/ServerContainerUrl.tsx` -- Line 18: `setTimeout(() => setNewServerContainerUrl(null), 400)` -- Line 45: `onClick={() => setNewServerContainerUrl(null)}` -- Line 53: `onClick={() => setNewServerContainerUrl(website.serverContainerUrl || '')}` - -### `src/apps/dashboard/Screens/Dashboard/Websites/List/ScriptsModal.tsx` -- Line 18: `setTimeout(() => setCopiedText(null), 3000)` -- Line 23: `onClick={() => setIsOpen(true)}` -- Line 74: `onClick={() => setIsOpen(false)}` - -### `src/apps/dashboard/Screens/Dashboard/Websites/GoogleAnalytics/GoogleAnalyticsScreen.tsx` -- Line 91: `onClick={() => refresh(item.email)}` - -### `src/apps/dashboard/Screens/Dashboard/DashboardLayout/index.tsx` -- Line 17: `useGlobal('PAGE_TITLE', () => getPageTitle(location.pathname, MENU_LINKS))` - -### `src/apps/dashboard/Screens/Dashboard/DashboardLayout/GlobalAlerts/index.tsx` -- Line 14: `onClose={() => dismiss('Extension')}` -- Line 15: `onClose={() => dismiss('Passkeys')}` - -### `src/apps/dashboard/Screens/Dashboard/DashboardLayout/AppSidebar/Navigation.tsx` -- Line 61: `onClick={() => setExpandedMenuItem(expandedMenuItem === item.key ? '' : item.key)}` - -### `src/apps/dashboard/Screens/Dashboard/DashboardLayout/AppSidebar/AccountWebsiteSelectors.tsx` -- Line 42: `onClick={() => setOpenPanel('account')}` -- Line 55: `onClick={() => setOpenPanel('website')}` -- Line 315: `onClick={() => chooseOptionValue(option.value as number)}` - ---- - -## Summary by Pattern Type - -### 1. Boolean State Setters (e.g., `setState(true/false)`) -Most common pattern - 60+ instances - -### 2. Value State Setters (e.g., `setState(someValue)`) -~40 instances - -### 3. Handlers with Arguments (e.g., `handleClick(id)`) -~35 instances - -### 4. Callbacks to null (e.g., `setItem(null)`) -~25 instances - -### 5. Navigation (e.g., `navigate(path)`) -~5 instances - -### 6. setTimeout callbacks -~5 instances - ---- - -## High Priority (Inside loops/maps) - -These create N function instances per render: - -- `src/apps/editor/app/src/Editor/BottomToolbar/Screenshot/ScreenshotList.tsx:38-76` -- `src/apps/editor/app/src/EditorV2/LeftSidebar/ChangesTab.tsx:46-48` -- `src/apps/editor/app/src/EditorV2/RightSidebar/Breadcrumb.tsx:60,100` -- `src/apps/editor/app/src/EditorV2/LeftSidebar/HierarchyTab.tsx:25` -- `src/apps/dashboard/Screens/Dashboard/Experiments/Details/ExperimentInfo/Screenshots/ScreenshotList.tsx:49` -- `src/apps/dashboard/Screens/Dashboard/Settings/AgencyTeam/index.tsx` (multiple in table rows) -- `src/apps/dashboard/Screens/Dashboard/Settings/OrganizationTeam/index.tsx` (multiple in table rows) -- `src/apps/dashboard/Screens/Dashboard/Websites/List/index.tsx` (multiple in table rows) -- `src/apps/dashboard/Screens/Dashboard/Clients/index.tsx` (multiple in table rows)