|
| 1 | +import type { FastifyInstance } from 'fastify'; |
| 2 | +import type { ZodTypeProvider } from 'fastify-type-provider-zod'; |
| 3 | +import { z } from 'zod/v4'; |
| 4 | +import { requirePermission } from '../middleware/rbac.js'; |
| 5 | +import { store } from '../db/index.js'; |
| 6 | +import { getAgent } from '../services/agents.js'; |
| 7 | +import { |
| 8 | + listAgentConversations, |
| 9 | + createAgentConversation, |
| 10 | + validateConversationOwnership, |
| 11 | + deleteAgentConversation, |
| 12 | + renameAgentConversation, |
| 13 | + saveAgentConversationMessage, |
| 14 | + executePrompt, |
| 15 | + isAgentBusy, |
| 16 | +} from '../services/agent-chat.js'; |
| 17 | + |
| 18 | +export async function agentChatRoutes(app: FastifyInstance) { |
| 19 | + const typedApp = app.withTypeProvider<ZodTypeProvider>(); |
| 20 | + |
| 21 | + // List conversations for an agent |
| 22 | + typedApp.get( |
| 23 | + '/api/agents/:id/chat/conversations', |
| 24 | + { |
| 25 | + onRequest: [app.authenticate, requirePermission('settings:read')], |
| 26 | + schema: { |
| 27 | + tags: ['Agent Chat'], |
| 28 | + summary: 'List chat conversations for an agent', |
| 29 | + params: z.object({ id: z.string() }), |
| 30 | + querystring: z.object({ |
| 31 | + limit: z.coerce.number().int().min(1).max(200).default(50), |
| 32 | + offset: z.coerce.number().int().min(0).default(0), |
| 33 | + }), |
| 34 | + }, |
| 35 | + }, |
| 36 | + async (request, reply) => { |
| 37 | + const agent = getAgent(request.params.id); |
| 38 | + if (!agent) return reply.notFound('Agent not found'); |
| 39 | + |
| 40 | + const { limit, offset } = request.query; |
| 41 | + const result = listAgentConversations(request.params.id, limit, offset); |
| 42 | + return reply.send(result); |
| 43 | + }, |
| 44 | + ); |
| 45 | + |
| 46 | + // Create a new conversation for an agent |
| 47 | + typedApp.post( |
| 48 | + '/api/agents/:id/chat/conversations', |
| 49 | + { |
| 50 | + onRequest: [app.authenticate, requirePermission('settings:update')], |
| 51 | + schema: { |
| 52 | + tags: ['Agent Chat'], |
| 53 | + summary: 'Create a new chat conversation for an agent', |
| 54 | + params: z.object({ id: z.string() }), |
| 55 | + body: z.object({ |
| 56 | + subject: z.string().max(200).optional(), |
| 57 | + }), |
| 58 | + }, |
| 59 | + }, |
| 60 | + async (request, reply) => { |
| 61 | + const agent = getAgent(request.params.id); |
| 62 | + if (!agent) return reply.notFound('Agent not found'); |
| 63 | + |
| 64 | + const conv = createAgentConversation(request.params.id, request.body.subject); |
| 65 | + return reply.status(201).send(conv); |
| 66 | + }, |
| 67 | + ); |
| 68 | + |
| 69 | + // Rename a conversation |
| 70 | + typedApp.patch( |
| 71 | + '/api/agents/:id/chat/conversations/:conversationId', |
| 72 | + { |
| 73 | + onRequest: [app.authenticate, requirePermission('settings:update')], |
| 74 | + schema: { |
| 75 | + tags: ['Agent Chat'], |
| 76 | + summary: 'Rename an agent chat conversation', |
| 77 | + params: z.object({ id: z.string(), conversationId: z.string() }), |
| 78 | + body: z.object({ |
| 79 | + subject: z.string().min(1).max(200), |
| 80 | + }), |
| 81 | + }, |
| 82 | + }, |
| 83 | + async (request, reply) => { |
| 84 | + const agent = getAgent(request.params.id); |
| 85 | + if (!agent) return reply.notFound('Agent not found'); |
| 86 | + |
| 87 | + const conv = validateConversationOwnership(request.params.conversationId, request.params.id); |
| 88 | + if (!conv) return reply.notFound('Conversation not found'); |
| 89 | + |
| 90 | + const updated = renameAgentConversation(request.params.conversationId, request.body.subject); |
| 91 | + return reply.send(updated); |
| 92 | + }, |
| 93 | + ); |
| 94 | + |
| 95 | + // Delete a conversation |
| 96 | + typedApp.delete( |
| 97 | + '/api/agents/:id/chat/conversations/:conversationId', |
| 98 | + { |
| 99 | + onRequest: [app.authenticate, requirePermission('settings:update')], |
| 100 | + schema: { |
| 101 | + tags: ['Agent Chat'], |
| 102 | + summary: 'Delete an agent chat conversation and its messages', |
| 103 | + params: z.object({ id: z.string(), conversationId: z.string() }), |
| 104 | + }, |
| 105 | + }, |
| 106 | + async (request, reply) => { |
| 107 | + const agent = getAgent(request.params.id); |
| 108 | + if (!agent) return reply.notFound('Agent not found'); |
| 109 | + |
| 110 | + const conv = validateConversationOwnership(request.params.conversationId, request.params.id); |
| 111 | + if (!conv) return reply.notFound('Conversation not found'); |
| 112 | + |
| 113 | + deleteAgentConversation(request.params.conversationId); |
| 114 | + return reply.status(204).send(); |
| 115 | + }, |
| 116 | + ); |
| 117 | + |
| 118 | + // List chat messages for a specific conversation |
| 119 | + typedApp.get( |
| 120 | + '/api/agents/:id/chat/messages', |
| 121 | + { |
| 122 | + onRequest: [app.authenticate, requirePermission('settings:read')], |
| 123 | + schema: { |
| 124 | + tags: ['Agent Chat'], |
| 125 | + summary: 'List chat messages for an agent conversation', |
| 126 | + params: z.object({ id: z.string() }), |
| 127 | + querystring: z.object({ |
| 128 | + conversationId: z.string(), |
| 129 | + limit: z.coerce.number().int().min(1).max(200).default(100), |
| 130 | + offset: z.coerce.number().int().min(0).default(0), |
| 131 | + }), |
| 132 | + }, |
| 133 | + }, |
| 134 | + async (request, reply) => { |
| 135 | + const agent = getAgent(request.params.id); |
| 136 | + if (!agent) return reply.notFound('Agent not found'); |
| 137 | + |
| 138 | + const conv = validateConversationOwnership(request.query.conversationId, request.params.id); |
| 139 | + if (!conv) return reply.notFound('Conversation not found'); |
| 140 | + |
| 141 | + const all = store |
| 142 | + .find( |
| 143 | + 'messages', |
| 144 | + (r: Record<string, unknown>) => r.conversationId === request.query.conversationId, |
| 145 | + ) |
| 146 | + .sort( |
| 147 | + (a: Record<string, unknown>, b: Record<string, unknown>) => |
| 148 | + new Date(a.createdAt as string).getTime() - |
| 149 | + new Date(b.createdAt as string).getTime(), |
| 150 | + ); |
| 151 | + |
| 152 | + const { limit, offset } = request.query; |
| 153 | + const entries = all.slice(offset, offset + limit); |
| 154 | + return reply.send({ total: all.length, limit, offset, entries }); |
| 155 | + }, |
| 156 | + ); |
| 157 | + |
| 158 | + // Append a message to an agent chat conversation (for agent progress/final updates) |
| 159 | + typedApp.post( |
| 160 | + '/api/agents/:id/chat/messages', |
| 161 | + { |
| 162 | + onRequest: [app.authenticate, requirePermission('messages:send')], |
| 163 | + schema: { |
| 164 | + tags: ['Agent Chat'], |
| 165 | + summary: 'Append a message to an agent chat conversation', |
| 166 | + params: z.object({ id: z.string() }), |
| 167 | + body: z.object({ |
| 168 | + conversationId: z.string(), |
| 169 | + content: z.string().min(1).max(50000), |
| 170 | + isFinal: z.boolean().optional(), |
| 171 | + }), |
| 172 | + }, |
| 173 | + }, |
| 174 | + async (request, reply) => { |
| 175 | + const agent = getAgent(request.params.id); |
| 176 | + if (!agent) return reply.notFound('Agent not found'); |
| 177 | + |
| 178 | + const conv = validateConversationOwnership(request.body.conversationId, request.params.id); |
| 179 | + if (!conv) return reply.notFound('Conversation not found'); |
| 180 | + |
| 181 | + const message = saveAgentConversationMessage({ |
| 182 | + conversationId: request.body.conversationId, |
| 183 | + direction: 'inbound', |
| 184 | + content: request.body.content, |
| 185 | + type: request.body.isFinal ? 'text' : 'system', |
| 186 | + metadata: { |
| 187 | + agentChatUpdate: true, |
| 188 | + isFinal: Boolean(request.body.isFinal), |
| 189 | + }, |
| 190 | + }); |
| 191 | + |
| 192 | + return reply.status(201).send(message); |
| 193 | + }, |
| 194 | + ); |
| 195 | + |
| 196 | + // Send a prompt (SSE streaming) |
| 197 | + typedApp.post( |
| 198 | + '/api/agents/:id/chat/message', |
| 199 | + { |
| 200 | + onRequest: [app.authenticate, requirePermission('settings:update')], |
| 201 | + schema: { |
| 202 | + tags: ['Agent Chat'], |
| 203 | + summary: 'Send a prompt to the agent and stream the response via SSE', |
| 204 | + params: z.object({ id: z.string() }), |
| 205 | + body: z.object({ |
| 206 | + prompt: z.string().min(1).max(50000), |
| 207 | + conversationId: z.string(), |
| 208 | + }), |
| 209 | + }, |
| 210 | + }, |
| 211 | + async (request, reply) => { |
| 212 | + const agent = getAgent(request.params.id); |
| 213 | + if (!agent) return reply.notFound('Agent not found'); |
| 214 | + |
| 215 | + if (isAgentBusy(request.params.id, request.body.conversationId)) { |
| 216 | + return reply.status(409).send({ |
| 217 | + statusCode: 409, |
| 218 | + error: 'Conflict', |
| 219 | + message: 'Agent is already processing a prompt', |
| 220 | + }); |
| 221 | + } |
| 222 | + |
| 223 | + const conv = validateConversationOwnership(request.body.conversationId, request.params.id); |
| 224 | + if (!conv) return reply.notFound('Conversation not found'); |
| 225 | + |
| 226 | + // Set SSE headers |
| 227 | + reply.raw.writeHead(200, { |
| 228 | + 'Content-Type': 'text/event-stream', |
| 229 | + 'Cache-Control': 'no-cache', |
| 230 | + Connection: 'keep-alive', |
| 231 | + 'X-Accel-Buffering': 'no', |
| 232 | + }); |
| 233 | + |
| 234 | + executePrompt(request.params.id, request.body.prompt, request.body.conversationId, { |
| 235 | + onChunk(text) { |
| 236 | + reply.raw.write(`data: ${JSON.stringify(text)}\n\n`); |
| 237 | + }, |
| 238 | + onDone(message) { |
| 239 | + reply.raw.write(`event: done\ndata: ${JSON.stringify({ messageId: message.id })}\n\n`); |
| 240 | + reply.raw.end(); |
| 241 | + }, |
| 242 | + onError(error) { |
| 243 | + reply.raw.write(`event: error\ndata: ${JSON.stringify({ error })}\n\n`); |
| 244 | + reply.raw.end(); |
| 245 | + }, |
| 246 | + }); |
| 247 | + }, |
| 248 | + ); |
| 249 | +} |
0 commit comments