- 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
This commit is contained in:
+3
-1
@@ -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
|
||||
|
||||
@@ -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<LlmResponse> {
|
||||
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<string> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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.';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+25
-1
@@ -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' });
|
||||
|
||||
@@ -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<void> {
|
||||
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 });
|
||||
});
|
||||
}
|
||||
+21
-12
@@ -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<ChatMessage[]>([]);
|
||||
const [status, setStatus] = useState<ChatStatus>('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);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user