Files
makelore/electron/api/routes/coding-conversations.ts

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;
}