310 lines
9.1 KiB
TypeScript
310 lines
9.1 KiB
TypeScript
// @generated — DO NOT EDIT. Source: packages/ai/endpoints.ts
|
|
/**
|
|
* HTTP endpoint handlers for AI features.
|
|
*
|
|
* These handlers are provider-agnostic — they work with whatever AIProvider
|
|
* is registered in the provided ProviderRegistry. They're designed to be
|
|
* mounted into any Plannotator server (plan review, code review, annotate).
|
|
*
|
|
* Endpoints:
|
|
* POST /api/ai/session — Create or fork an AI session
|
|
* POST /api/ai/query — Send a message and stream the response
|
|
* POST /api/ai/abort — Abort the current query
|
|
* GET /api/ai/sessions — List active sessions
|
|
* GET /api/ai/capabilities — Check if AI features are available
|
|
*/
|
|
|
|
import type { AIContext, AIMessage, CreateSessionOptions } from "./types.ts";
|
|
import type { ProviderRegistry } from "./provider.ts";
|
|
import type { SessionManager } from "./session-manager.ts";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types for request/response
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface CreateSessionRequest {
|
|
/** The context mode and content for the session. */
|
|
context: AIContext;
|
|
/** Instance ID of the provider to use (optional — uses default if omitted). */
|
|
providerId?: string;
|
|
/** Optional model override. */
|
|
model?: string;
|
|
/** Max agentic turns. */
|
|
maxTurns?: number;
|
|
/** Max budget in USD. */
|
|
maxBudgetUsd?: number;
|
|
/** Reasoning effort (Codex only). */
|
|
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
}
|
|
|
|
export interface QueryRequest {
|
|
/** The session ID to query. */
|
|
sessionId: string;
|
|
/** The user's prompt/question. */
|
|
prompt: string;
|
|
/** Optional context update (e.g., new annotations since session was created). */
|
|
contextUpdate?: string;
|
|
}
|
|
|
|
export interface AbortRequest {
|
|
/** The session ID to abort. */
|
|
sessionId: string;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Handler factory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface AIEndpointDeps {
|
|
/** Provider registry (one per server or shared). */
|
|
registry: ProviderRegistry;
|
|
/** Session manager instance (one per server). */
|
|
sessionManager: SessionManager;
|
|
/** Resolve the current working directory for new AI sessions. */
|
|
getCwd?: () => string;
|
|
}
|
|
|
|
/**
|
|
* Create the route handler map for AI endpoints.
|
|
*
|
|
* Usage in a Bun server:
|
|
* ```ts
|
|
* const aiHandlers = createAIEndpoints({ registry, sessionManager });
|
|
*
|
|
* // In your request handler:
|
|
* if (url.pathname.startsWith('/api/ai/')) {
|
|
* const handler = aiHandlers[url.pathname];
|
|
* if (handler) return handler(req);
|
|
* }
|
|
* ```
|
|
*/
|
|
export function createAIEndpoints(deps: AIEndpointDeps) {
|
|
const { registry, sessionManager, getCwd } = deps;
|
|
|
|
return {
|
|
"/api/ai/capabilities": async (_req: Request) => {
|
|
const defaultEntry = registry.getDefault();
|
|
const providerDetails = registry.list().map(id => {
|
|
const p = registry.get(id)!;
|
|
return {
|
|
id,
|
|
name: p.name,
|
|
capabilities: p.capabilities,
|
|
models: p.models ?? [],
|
|
};
|
|
});
|
|
return Response.json({
|
|
available: !!defaultEntry,
|
|
providers: providerDetails,
|
|
defaultProvider: defaultEntry?.id ?? null,
|
|
});
|
|
},
|
|
|
|
"/api/ai/session": async (req: Request) => {
|
|
if (req.method !== "POST") {
|
|
return new Response("Method not allowed", { status: 405 });
|
|
}
|
|
|
|
const body = (await req.json()) as CreateSessionRequest;
|
|
const { context, providerId, model, maxTurns, maxBudgetUsd, reasoningEffort } = body;
|
|
|
|
if (!context?.mode) {
|
|
return Response.json(
|
|
{ error: "Missing context.mode" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Resolve provider: by ID, or default
|
|
const provider = providerId
|
|
? registry.get(providerId)
|
|
: registry.getDefault()?.provider;
|
|
|
|
if (!provider) {
|
|
return Response.json(
|
|
{ error: providerId ? `Provider "${providerId}" not found` : "No AI provider available" },
|
|
{ status: 503 }
|
|
);
|
|
}
|
|
|
|
try {
|
|
const options: CreateSessionOptions = {
|
|
context,
|
|
cwd: getCwd?.(),
|
|
model,
|
|
maxTurns,
|
|
maxBudgetUsd,
|
|
reasoningEffort,
|
|
};
|
|
|
|
// Fork if parent session is provided AND provider supports it.
|
|
// Providers that can't fork (e.g. Codex) fall back to a fresh
|
|
// session with the full system prompt — no fake history.
|
|
const shouldFork = context.parent && provider.capabilities.fork;
|
|
const session = shouldFork
|
|
? await provider.forkSession(options)
|
|
: await provider.createSession(options);
|
|
|
|
const entry = sessionManager.track(session, context.mode);
|
|
|
|
return Response.json({
|
|
sessionId: session.id,
|
|
parentSessionId: session.parentSessionId,
|
|
mode: context.mode,
|
|
createdAt: entry.createdAt,
|
|
});
|
|
} catch (err) {
|
|
return Response.json(
|
|
{
|
|
error:
|
|
err instanceof Error ? err.message : "Failed to create session",
|
|
},
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
},
|
|
|
|
"/api/ai/query": async (req: Request) => {
|
|
if (req.method !== "POST") {
|
|
return new Response("Method not allowed", { status: 405 });
|
|
}
|
|
|
|
const body = (await req.json()) as QueryRequest;
|
|
const { sessionId, prompt, contextUpdate } = body;
|
|
|
|
if (!sessionId || !prompt) {
|
|
return Response.json(
|
|
{ error: "Missing sessionId or prompt" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const entry = sessionManager.get(sessionId);
|
|
if (!entry) {
|
|
return Response.json(
|
|
{ error: "Session not found" },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
sessionManager.touch(sessionId);
|
|
|
|
// If context update provided, prepend it to the prompt
|
|
const effectivePrompt = contextUpdate
|
|
? `[Context update: the user has made changes since this conversation started]\n${contextUpdate}\n\n${prompt}`
|
|
: prompt;
|
|
|
|
// Set label from first query if not already set
|
|
if (!entry.label) {
|
|
entry.label = prompt.slice(0, 80);
|
|
}
|
|
|
|
// Stream the response using Server-Sent Events (SSE)
|
|
const encoder = new TextEncoder();
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
try {
|
|
for await (const message of entry.session.query(effectivePrompt)) {
|
|
const data = JSON.stringify(message);
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${data}\n\n`)
|
|
);
|
|
}
|
|
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
} catch (err) {
|
|
const errorMsg: AIMessage = {
|
|
type: "error",
|
|
error: err instanceof Error ? err.message : String(err),
|
|
code: "stream_error",
|
|
};
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify(errorMsg)}\n\n`)
|
|
);
|
|
} finally {
|
|
controller.close();
|
|
}
|
|
},
|
|
});
|
|
|
|
return new Response(stream, {
|
|
headers: {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
Connection: "keep-alive",
|
|
},
|
|
});
|
|
},
|
|
|
|
"/api/ai/abort": async (req: Request) => {
|
|
if (req.method !== "POST") {
|
|
return new Response("Method not allowed", { status: 405 });
|
|
}
|
|
|
|
const body = (await req.json()) as AbortRequest;
|
|
const entry = sessionManager.get(body.sessionId);
|
|
if (!entry) {
|
|
return Response.json(
|
|
{ error: "Session not found" },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
entry.session.abort();
|
|
return Response.json({ ok: true });
|
|
},
|
|
|
|
"/api/ai/permission": async (req: Request) => {
|
|
if (req.method !== "POST") {
|
|
return new Response("Method not allowed", { status: 405 });
|
|
}
|
|
|
|
const body = (await req.json()) as {
|
|
sessionId: string;
|
|
requestId: string;
|
|
allow: boolean;
|
|
message?: string;
|
|
};
|
|
|
|
if (!body.sessionId || !body.requestId) {
|
|
return Response.json(
|
|
{ error: "Missing sessionId or requestId" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const entry = sessionManager.get(body.sessionId);
|
|
if (!entry) {
|
|
return Response.json(
|
|
{ error: "Session not found" },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
entry.session.respondToPermission?.(
|
|
body.requestId,
|
|
body.allow,
|
|
body.message
|
|
);
|
|
|
|
return Response.json({ ok: true });
|
|
},
|
|
|
|
"/api/ai/sessions": async (_req: Request) => {
|
|
const entries = sessionManager.list();
|
|
return Response.json(
|
|
entries.map((e) => ({
|
|
sessionId: e.session.id,
|
|
mode: e.mode,
|
|
parentSessionId: e.parentSessionId,
|
|
createdAt: e.createdAt,
|
|
lastActiveAt: e.lastActiveAt,
|
|
isActive: e.session.isActive,
|
|
label: e.label,
|
|
}))
|
|
);
|
|
},
|
|
} as const;
|
|
}
|
|
|
|
export type AIEndpoints = ReturnType<typeof createAIEndpoints>;
|