From 060b181e712144eb4bed64b955a06c77bc0e0cef Mon Sep 17 00:00:00 2001 From: SinachPat Date: Mon, 17 Aug 2026 17:19:33 +0100 Subject: [PATCH] feat: wire chat to the agent loop (POST /chat) - api: /chat route runs runAgent (session-required, 503 when unconfigured) - api: index.ts builds OpenRouterLlmClient + WpRestExecutor from env; loads .env - web: useChat calls /chat with mock fallback; proxy /chat+/sites+/health - .env.example: DATABASE_URL empty by default (in-memory dev) - 111 unit tests green; smoke-tested signup+chat --- .env.example | 4 +- api/__tests__/routes/chat.test.ts | 77 +++++++++++++++++++++++++++++++ api/src/agents/system-prompt.ts | 5 ++ api/src/app.ts | 6 +++ api/src/index.ts | 26 ++++++++++- api/src/routes/chat.ts | 38 +++++++++++++++ web/src/hooks/useChat.ts | 33 ++++++++----- web/vite.config.ts | 3 ++ 8 files changed, 178 insertions(+), 14 deletions(-) create mode 100644 api/__tests__/routes/chat.test.ts create mode 100644 api/src/agents/system-prompt.ts create mode 100644 api/src/routes/chat.ts diff --git a/.env.example b/.env.example index f68cbcd..7afae36 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,9 @@ # API PORT=3000 -DATABASE_URL=postgres://wursor:wursor@localhost:5432/wursor +# Set DATABASE_URL only when Postgres is running (see infrastructure/DEVOPS.md). +# Leave empty for the in-memory dev store. +DATABASE_URL= REDIS_URL=redis://localhost:6379 # Auth diff --git a/api/__tests__/routes/chat.test.ts b/api/__tests__/routes/chat.test.ts new file mode 100644 index 0000000..4b9b14d --- /dev/null +++ b/api/__tests__/routes/chat.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { buildApp } from '../../src/app.ts'; +import type { LlmClient, LlmResponse } from '../../src/agents/llm-client.ts'; +import type { ToolExecutor } from '../../src/agents/tool-executor.ts'; + +class FakeLlm implements LlmClient { + async complete(): Promise { + return { choices: [{ message: { role: 'assistant', content: 'Done', tool_calls: undefined } }] }; + } +} + +class FakeExecutor implements ToolExecutor { + async execute() { + return { result: 'ok' }; + } +} + +async function signup(app: FastifyInstance): Promise { + const res = await app.inject({ + method: 'POST', + url: '/auth/signup', + payload: { email: `a-${Date.now()}@example.com`, password: 'password123' }, + }); + return res.json().sessionToken as string; +} + +describe('POST /chat', () => { + it('requires a session', async () => { + const app = await buildApp({ llmClient: new FakeLlm(), toolExecutor: new FakeExecutor() }); + const res = await app.inject({ method: 'POST', url: '/chat', payload: { message: 'hi' } }); + expect(res.statusCode).toBe(401); + }); + + it('runs the agent and returns the reply', async () => { + const app = await buildApp({ llmClient: new FakeLlm(), toolExecutor: new FakeExecutor() }); + const token = await signup(app); + + const res = await app.inject({ + method: 'POST', + url: '/chat', + headers: { authorization: `Bearer ${token}` }, + payload: { message: 'Change the heading' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().reply).toBe('Done'); + }); + + it('returns 503 when the agent is not configured', async () => { + const app = await buildApp(); + const token = await signup(app); + + const res = await app.inject({ + method: 'POST', + url: '/chat', + headers: { authorization: `Bearer ${token}` }, + payload: { message: 'hi' }, + }); + + expect(res.statusCode).toBe(503); + }); + + it('returns 400 for an empty message', async () => { + const app = await buildApp({ llmClient: new FakeLlm(), toolExecutor: new FakeExecutor() }); + const token = await signup(app); + + const res = await app.inject({ + method: 'POST', + url: '/chat', + headers: { authorization: `Bearer ${token}` }, + payload: { message: ' ' }, + }); + + expect(res.statusCode).toBe(400); + }); +}); diff --git a/api/src/agents/system-prompt.ts b/api/src/agents/system-prompt.ts new file mode 100644 index 0000000..2a359fe --- /dev/null +++ b/api/src/agents/system-prompt.ts @@ -0,0 +1,5 @@ +export const AGENT_SYSTEM_PROMPT = + 'You are an expert WordPress agent working inside a sandboxed copy of the user\u2019s site. ' + + 'Use the provided tools to complete the request. Work step by step: read what you need, make the ' + + 'changes, and verify. Never touch anything outside the sandbox. When finished, reply with a one-line ' + + 'summary of what you changed.'; diff --git a/api/src/app.ts b/api/src/app.ts index 7b9427f..ac84bff 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -1,7 +1,10 @@ import Fastify from 'fastify'; import { authRoutes } from './routes/auth.ts'; +import { chatRoutes } from './routes/chat.ts'; import { sessionRoutes } from './routes/sessions.ts'; import { siteRoutes } from './routes/sites.ts'; +import type { LlmClient } from './agents/llm-client.ts'; +import type { ToolExecutor } from './agents/tool-executor.ts'; import { PairingService } from './services/pairing-service.ts'; import { InMemorySessionStore, type SessionStore } from './services/session-store.ts'; import { InMemorySiteStore, type SiteStore } from './services/site-store.ts'; @@ -14,6 +17,8 @@ export type BuildAppOptions = { siteStore?: SiteStore; pairingService?: PairingService; sandboxManager?: SandboxManager; + llmClient?: LlmClient; + toolExecutor?: ToolExecutor; }; export async function buildApp(opts: BuildAppOptions = {}) { @@ -27,5 +32,6 @@ export async function buildApp(opts: BuildAppOptions = {}) { await authRoutes(app, userStore, sessionStore); await sessionRoutes(app, opts.sandboxManager); await siteRoutes(app, { sessionStore, siteStore, pairingService }); + await chatRoutes(app, { sessionStore, llmClient: opts.llmClient, toolExecutor: opts.toolExecutor }); return app; } diff --git a/api/src/index.ts b/api/src/index.ts index f417d26..2cd671a 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,12 +1,23 @@ import Docker from 'dockerode'; +import { dirname, join } from 'node:path'; +import { loadEnvFile } from 'node:process'; +import { fileURLToPath } from 'node:url'; import { Pool } from 'pg'; import { buildApp } from './app.ts'; +import { OpenRouterLlmClient } from './agents/openrouter-client.ts'; +import { WpRestExecutor } from './agents/wp-executor.ts'; import { DockerodeClient } from './sandbox/dockerode-client.ts'; import { ImageManager } from './sandbox/image-manager.ts'; import { PostgresUserStore } from './services/postgres-user-store.ts'; import { SandboxManager } from './services/sandbox-manager.ts'; import { InMemoryUserStore } from './services/user-store.ts'; +try { + loadEnvFile(join(dirname(fileURLToPath(import.meta.url)), '..', '..', '.env')); +} catch { + // no .env — rely on the ambient environment +} + const port = Number(process.env.PORT ?? 3000); const userStore = process.env.DATABASE_URL @@ -26,6 +37,19 @@ const sandboxManager = }) : undefined; -const app = await buildApp({ userStore, sandboxManager }); +const llmClient = process.env.OPENROUTER_API_KEY + ? new OpenRouterLlmClient({ apiKey: process.env.OPENROUTER_API_KEY, model: process.env.OPENROUTER_MODEL }) + : undefined; + +const toolExecutor = + process.env.WP_URL !== undefined && process.env.WP_USER !== undefined && process.env.WP_APP_PASSWORD !== undefined + ? new WpRestExecutor({ + baseUrl: process.env.WP_URL, + username: process.env.WP_USER, + appPassword: process.env.WP_APP_PASSWORD, + }) + : undefined; + +const app = await buildApp({ userStore, sandboxManager, llmClient, toolExecutor }); await app.listen({ port, host: '0.0.0.0' }); diff --git a/api/src/routes/chat.ts b/api/src/routes/chat.ts new file mode 100644 index 0000000..4c872c1 --- /dev/null +++ b/api/src/routes/chat.ts @@ -0,0 +1,38 @@ +import type { FastifyInstance } from 'fastify'; +import { runAgent } from '../agents/agent-loop.ts'; +import { AGENT_SYSTEM_PROMPT } from '../agents/system-prompt.ts'; +import type { LlmClient } from '../agents/llm-client.ts'; +import type { ToolExecutor } from '../agents/tool-executor.ts'; +import { requireSession } from '../middleware/auth.ts'; +import type { SessionStore } from '../services/session-store.ts'; + +export type ChatRoutesDeps = { + sessionStore: SessionStore; + llmClient?: LlmClient; + toolExecutor?: ToolExecutor; + maxRounds?: number; +}; + +type ChatBody = { + message?: string; +}; + +export async function chatRoutes(app: FastifyInstance, deps: ChatRoutesDeps): Promise { + app.post('/chat', { preHandler: requireSession(deps.sessionStore) }, async (request, reply) => { + if (deps.llmClient === undefined || deps.toolExecutor === undefined) { + return reply.status(503).send({ error: 'agent_not_configured' }); + } + const { message } = request.body as ChatBody; + if (message === undefined || message.trim() === '') { + return reply.status(400).send({ error: 'invalid_message' }); + } + + const result = await runAgent(deps.llmClient, deps.toolExecutor, { + systemPrompt: AGENT_SYSTEM_PROMPT, + userPrompt: message, + maxRounds: deps.maxRounds ?? 12, + }); + + return reply.send({ reply: result.finalContent, toolCalls: result.toolCalls, halted: result.halted }); + }); +} diff --git a/web/src/hooks/useChat.ts b/web/src/hooks/useChat.ts index a0ab57b..db80408 100644 --- a/web/src/hooks/useChat.ts +++ b/web/src/hooks/useChat.ts @@ -8,6 +8,12 @@ function extractHeading(text: string): string | undefined { return match?.[1]; } +function mockReply(text: string, heading: string | undefined): string { + return heading !== undefined + ? `Done — I updated the homepage heading to “${heading}”. Preview it below.` + : 'Done — your change is ready to preview.'; +} + export function useChat() { const [messages, setMessages] = useState([]); const [status, setStatus] = useState('idle'); @@ -17,18 +23,21 @@ export function useChat() { const nextHeading = extractHeading(text); setMessages((prev) => [...prev, { id: crypto.randomUUID(), role: 'user', text }]); setStatus('working'); - await new Promise((resolve) => setTimeout(resolve, 1200)); - setMessages((prev) => [ - ...prev, - { - id: crypto.randomUUID(), - role: 'agent', - text: - nextHeading !== undefined - ? `Done — I updated the homepage heading to “${nextHeading}”. Preview it below.` - : 'Done — your change is ready to preview.', - }, - ]); + + try { + const res = await fetch('/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: text }), + }); + if (!res.ok) throw new Error('chat unavailable'); + const body = (await res.json()) as { reply?: string }; + setMessages((prev) => [...prev, { id: crypto.randomUUID(), role: 'agent', text: body.reply ?? 'Done' }]); + } catch { + await new Promise((resolve) => setTimeout(resolve, 1200)); + setMessages((prev) => [...prev, { id: crypto.randomUUID(), role: 'agent', text: mockReply(text, nextHeading) }]); + } + if (nextHeading !== undefined) { setHeading(nextHeading); } diff --git a/web/vite.config.ts b/web/vite.config.ts index 7cc9211..590a9a5 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -6,6 +6,9 @@ export default defineConfig({ server: { proxy: { '/auth': 'http://localhost:3000', + '/chat': 'http://localhost:3000', + '/sites': 'http://localhost:3000', + '/health': 'http://localhost:3000', }, }, test: {