224 lines
8.7 KiB
TypeScript
224 lines
8.7 KiB
TypeScript
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
import { parseManagedReasoningChoice } from '../../../shared/managed-model-capabilities';
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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.type === 'conversation.metadata-changed' ? undefined
|
|
: `${event.conversationId}:${event.workerGeneration}:${event.type === 'snapshot' ? event.seq : 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; reasoningChoice?: unknown }>(req);
|
|
const reasoningChoice = body.reasoningChoice === undefined ? undefined : parseManagedReasoningChoice(body.reasoningChoice);
|
|
if (reasoningChoice === null) invalidRequest('Reasoning choice is invalid');
|
|
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',
|
|
reasoningChoice ?? undefined,
|
|
),
|
|
});
|
|
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;
|
|
}
|