- 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
|
# API
|
||||||
PORT=3000
|
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
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
# Auth
|
# 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 Fastify from 'fastify';
|
||||||
import { authRoutes } from './routes/auth.ts';
|
import { authRoutes } from './routes/auth.ts';
|
||||||
|
import { chatRoutes } from './routes/chat.ts';
|
||||||
import { sessionRoutes } from './routes/sessions.ts';
|
import { sessionRoutes } from './routes/sessions.ts';
|
||||||
import { siteRoutes } from './routes/sites.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 { PairingService } from './services/pairing-service.ts';
|
||||||
import { InMemorySessionStore, type SessionStore } from './services/session-store.ts';
|
import { InMemorySessionStore, type SessionStore } from './services/session-store.ts';
|
||||||
import { InMemorySiteStore, type SiteStore } from './services/site-store.ts';
|
import { InMemorySiteStore, type SiteStore } from './services/site-store.ts';
|
||||||
@@ -14,6 +17,8 @@ export type BuildAppOptions = {
|
|||||||
siteStore?: SiteStore;
|
siteStore?: SiteStore;
|
||||||
pairingService?: PairingService;
|
pairingService?: PairingService;
|
||||||
sandboxManager?: SandboxManager;
|
sandboxManager?: SandboxManager;
|
||||||
|
llmClient?: LlmClient;
|
||||||
|
toolExecutor?: ToolExecutor;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function buildApp(opts: BuildAppOptions = {}) {
|
export async function buildApp(opts: BuildAppOptions = {}) {
|
||||||
@@ -27,5 +32,6 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
|||||||
await authRoutes(app, userStore, sessionStore);
|
await authRoutes(app, userStore, sessionStore);
|
||||||
await sessionRoutes(app, opts.sandboxManager);
|
await sessionRoutes(app, opts.sandboxManager);
|
||||||
await siteRoutes(app, { sessionStore, siteStore, pairingService });
|
await siteRoutes(app, { sessionStore, siteStore, pairingService });
|
||||||
|
await chatRoutes(app, { sessionStore, llmClient: opts.llmClient, toolExecutor: opts.toolExecutor });
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-1
@@ -1,12 +1,23 @@
|
|||||||
import Docker from 'dockerode';
|
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 { Pool } from 'pg';
|
||||||
import { buildApp } from './app.ts';
|
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 { DockerodeClient } from './sandbox/dockerode-client.ts';
|
||||||
import { ImageManager } from './sandbox/image-manager.ts';
|
import { ImageManager } from './sandbox/image-manager.ts';
|
||||||
import { PostgresUserStore } from './services/postgres-user-store.ts';
|
import { PostgresUserStore } from './services/postgres-user-store.ts';
|
||||||
import { SandboxManager } from './services/sandbox-manager.ts';
|
import { SandboxManager } from './services/sandbox-manager.ts';
|
||||||
import { InMemoryUserStore } from './services/user-store.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 port = Number(process.env.PORT ?? 3000);
|
||||||
|
|
||||||
const userStore = process.env.DATABASE_URL
|
const userStore = process.env.DATABASE_URL
|
||||||
@@ -26,6 +37,19 @@ const sandboxManager =
|
|||||||
})
|
})
|
||||||
: undefined;
|
: 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' });
|
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];
|
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() {
|
export function useChat() {
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [status, setStatus] = useState<ChatStatus>('idle');
|
const [status, setStatus] = useState<ChatStatus>('idle');
|
||||||
@@ -17,18 +23,21 @@ export function useChat() {
|
|||||||
const nextHeading = extractHeading(text);
|
const nextHeading = extractHeading(text);
|
||||||
setMessages((prev) => [...prev, { id: crypto.randomUUID(), role: 'user', text }]);
|
setMessages((prev) => [...prev, { id: crypto.randomUUID(), role: 'user', text }]);
|
||||||
setStatus('working');
|
setStatus('working');
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
||||||
setMessages((prev) => [
|
try {
|
||||||
...prev,
|
const res = await fetch('/chat', {
|
||||||
{
|
method: 'POST',
|
||||||
id: crypto.randomUUID(),
|
headers: { 'Content-Type': 'application/json' },
|
||||||
role: 'agent',
|
body: JSON.stringify({ message: text }),
|
||||||
text:
|
});
|
||||||
nextHeading !== undefined
|
if (!res.ok) throw new Error('chat unavailable');
|
||||||
? `Done — I updated the homepage heading to “${nextHeading}”. Preview it below.`
|
const body = (await res.json()) as { reply?: string };
|
||||||
: 'Done — your change is ready to preview.',
|
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) {
|
if (nextHeading !== undefined) {
|
||||||
setHeading(nextHeading);
|
setHeading(nextHeading);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/auth': 'http://localhost:3000',
|
'/auth': 'http://localhost:3000',
|
||||||
|
'/chat': 'http://localhost:3000',
|
||||||
|
'/sites': 'http://localhost:3000',
|
||||||
|
'/health': 'http://localhost:3000',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
Reference in New Issue
Block a user