import type { IncomingMessage, ServerResponse } from 'node:http'; import type { ConversationInteractionResponse } from '../../coding-runtime/contracts'; import { CodingConversationServiceError } from '../../coding-runtime/conversation-service'; import { normalizeProductModelRef } from '../../coding-projects/project-config'; import type { HostApiContext } from '../context'; import { flushStreamingHeaders, parseJsonBody, sendJson, sendNoContent, writeStreamingChunk, } from '../route-utils'; import { decodeRouteId, sendCodingRouteError } from './coding-route-errors'; const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'max']); function invalidRequest(message: string): never { throw new CodingConversationServiceError(400, 'CODING_CONVERSATION_REQUEST_INVALID', message); } function isConversationRoute(pathname: string, method: string | undefined): boolean { if ((pathname === '/api/coding/events' || pathname === '/api/coding/interactions' || pathname === '/api/coding/runtime/diagnostics') && method === 'GET') return true; if (/^\/api\/coding\/interactions\/[^/]+\/respond$/.test(pathname)) return method === 'POST'; const match = pathname.match( /^\/api\/coding\/conversations\/[^/]+(?:\/(snapshot|prompt|abort|model|thinking|compact|fork|recover))?$/, ); if (!match) return false; if (!match[1]) return method === 'GET' || method === 'PATCH' || method === 'DELETE'; if (match[1] === 'snapshot') return method === 'GET'; return method === 'POST'; } async function sendEvent( res: ServerResponse, event: string, data: unknown, id?: string, ): Promise { return await writeStreamingChunk( res, `${id ? `id: ${id}\n` : ''}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`, ); } export async function handleCodingConversationRoutes( req: IncomingMessage, res: ServerResponse, url: URL, ctx: HostApiContext, ): Promise { if (!isConversationRoute(url.pathname, req.method)) return false; const service = ctx.codingProducts?.conversations; if (!service) { sendJson(res, 503, { success: false, code: 'CODING_CORE_UNAVAILABLE', error: '本地编程服务暂时不可用。', }); return true; } try { if (url.pathname === '/api/coding/events' && req.method === 'GET') { const conversationId = url.searchParams.get('conversationId')?.trim() || undefined; const stream = await service.openEventStream(conversationId); res.statusCode = 200; res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); flushStreamingHeaders(res); const close = () => stream.close(); req.once('close', close); res.once('close', close); try { for (const snapshot of stream.snapshots) { if (!await sendEvent(res, 'snapshot', { type: 'snapshot', conversationId: snapshot.conversation.id, workerGeneration: snapshot.cursor.workerGeneration, seq: snapshot.cursor.seq, snapshot, }, `${snapshot.conversation.id}:${snapshot.cursor.workerGeneration}:${snapshot.cursor.seq}`)) return true; } for await (const event of stream.events) { if (!await sendEvent( res, event.type, event, `${event.conversationId}:${event.workerGeneration}:${event.toSeq}`, )) break; } } finally { req.off('close', close); res.off('close', close); stream.close(); if (!res.writableEnded) res.end(); } return true; } if (url.pathname === '/api/coding/interactions' && req.method === 'GET') { sendJson(res, 200, { interactions: await service.listInteractions( url.searchParams.get('conversationId')?.trim() || undefined, ), }); return true; } const interactionMatch = url.pathname.match(/^\/api\/coding\/interactions\/([^/]+)\/respond$/); if (interactionMatch && req.method === 'POST') { const interactionId = decodeRouteId(interactionMatch[1]); const body = await parseJsonBody<{ conversationId?: string; cancelled?: unknown; optionId?: unknown; confirmed?: unknown; value?: unknown; }>(req); const conversationId = typeof body.conversationId === 'string' ? body.conversationId.trim() : ''; let response: ConversationInteractionResponse; if (body.cancelled === true) response = { interactionId, cancelled: true }; else if (typeof body.optionId === 'string') response = { interactionId, optionId: body.optionId }; else if (typeof body.confirmed === 'boolean') response = { interactionId, confirmed: body.confirmed }; else if (typeof body.value === 'string') response = { interactionId, value: body.value }; else invalidRequest('Interaction response is invalid'); await service.respondInteraction(conversationId, response); sendNoContent(res); return true; } if (url.pathname === '/api/coding/runtime/diagnostics' && req.method === 'GET') { sendJson(res, 200, { runtime: service.getDiagnostics() }); return true; } const match = url.pathname.match(/^\/api\/coding\/conversations\/([^/]+)(?:\/(snapshot|prompt|abort|model|thinking|compact|fork|recover))?$/); if (!match) return false; const conversationId = decodeRouteId(match[1]); const action = match[2]; if (!action && req.method === 'GET') { sendJson(res, 200, { conversation: await service.getConversation(conversationId) }); return true; } if (!action && req.method === 'PATCH') { sendJson(res, 200, { conversation: await service.patchConversation( conversationId, await parseJsonBody(req), ), }); return true; } if (!action && req.method === 'DELETE') { await service.deleteConversation(conversationId); sendNoContent(res); return true; } if (action === 'snapshot' && req.method === 'GET') { sendJson(res, 200, { snapshot: await service.getSnapshot(conversationId) }); return true; } if (action === 'prompt' && req.method === 'POST') { const body = await parseJsonBody<{ clientRequestId?: unknown; mode?: unknown; text?: unknown; attachments?: unknown; }>(req); sendJson(res, 202, { acceptance: await service.acceptPrompt({ conversationId, ...body }) }); return true; } if (action === 'abort' && req.method === 'POST') { await service.abort(conversationId); sendNoContent(res); return true; } if (action === 'model' && req.method === 'POST') { const body = await parseJsonBody<{ model?: unknown }>(req); let model; try { model = normalizeProductModelRef(body.model); } catch { invalidRequest('Product model is invalid'); } sendJson(res, 200, { model: await service.setModel(conversationId, model) }); return true; } if (action === 'thinking' && req.method === 'POST') { const body = await parseJsonBody<{ thinkingLevel?: unknown }>(req); if (!THINKING_LEVELS.has(String(body.thinkingLevel))) invalidRequest('Thinking level is invalid'); sendJson(res, 200, { model: await service.setThinking( conversationId, body.thinkingLevel as 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'max', ), }); return true; } if (action === 'compact' && req.method === 'POST') { await service.compact(conversationId); sendNoContent(res); return true; } if (action === 'recover' && req.method === 'POST') { await service.recover(conversationId); sendNoContent(res); return true; } if (action === 'fork' && req.method === 'POST') { const body = await parseJsonBody<{ sourceEntryId?: unknown }>(req); const sourceEntryId = typeof body.sourceEntryId === 'string' && body.sourceEntryId.trim() ? body.sourceEntryId.trim() : undefined; sendJson(res, 201, { conversation: await service.fork(conversationId, sourceEntryId) }); return true; } } catch (error) { sendCodingRouteError(res, error); return true; } return false; }