feat(coding): add core host api composition
This commit is contained in:
176
electron/api/coding-composition.ts
Normal file
176
electron/api/coding-composition.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import path from 'node:path';
|
||||
import type { AgentBrowserModule } from '../agent-browser';
|
||||
import { CodingAttachmentStore } from '../coding-projects/attachment-store';
|
||||
import { createCodingConversationStore } from '../coding-projects/conversation-store';
|
||||
import { CodingProjectService } from '../coding-projects/project-service';
|
||||
import {
|
||||
createCodingProjectStore,
|
||||
type CodingProjectStorage,
|
||||
} from '../coding-projects/project-store';
|
||||
import { CodingConversationService } from '../coding-runtime/conversation-service';
|
||||
import { PiManagedExtensionHost } from '../coding-runtime/pi/extension-host';
|
||||
import { PiManagedInputRevisionCoordinator } from '../coding-runtime/pi/managed-input-revision';
|
||||
import { PiProductTools } from '../coding-runtime/pi/product-tools';
|
||||
import {
|
||||
buildPiProviderCatalog,
|
||||
resolvePiProviderCredentialFromSecretStore,
|
||||
selectPiProviderModel,
|
||||
} from '../coding-runtime/pi/provider-config';
|
||||
import {
|
||||
createPiManagedWorkerOpener,
|
||||
PiConversationRuntime,
|
||||
} from '../coding-runtime/pi/runtime';
|
||||
import { PiSessionRegistry } from '../coding-runtime/pi/session-registry';
|
||||
import { createPiManagedSubagentChildOpener } from '../coding-runtime/pi/subagent-child';
|
||||
import { PiSubagentScheduler } from '../coding-runtime/pi/subagent';
|
||||
import { PiProcessBudget, PiWorkerPool } from '../coding-runtime/pi/worker-pool';
|
||||
import { getProviderService } from '../services/providers/provider-service';
|
||||
import { createCodingProductHost, type CodingProductComposition } from './coding-product-services';
|
||||
|
||||
export interface CodingCompositionPaths {
|
||||
executablePath: string;
|
||||
cliPath: string;
|
||||
userDataDir: string;
|
||||
bundledSkillsDir: string;
|
||||
}
|
||||
|
||||
export interface CreateCodingCompositionOptions {
|
||||
storage: CodingProjectStorage;
|
||||
browser: AgentBrowserModule;
|
||||
paths: CodingCompositionPaths;
|
||||
localProxyCredential?: string;
|
||||
}
|
||||
|
||||
export function resolveCodingPiRuntimePaths(input: {
|
||||
isPackaged: boolean;
|
||||
resourcesPath: string;
|
||||
appPath: string;
|
||||
executablePath: string;
|
||||
}): Pick<CodingCompositionPaths, 'executablePath' | 'cliPath'> {
|
||||
return {
|
||||
executablePath: input.executablePath,
|
||||
cliPath: input.isPackaged
|
||||
? path.join(input.resourcesPath, 'pi-runtime', 'dist', 'cli.js')
|
||||
: path.join(
|
||||
input.appPath,
|
||||
'node_modules',
|
||||
'@earendil-works',
|
||||
'pi-coding-agent',
|
||||
'dist',
|
||||
'cli.js',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCodingComposition(
|
||||
options: CreateCodingCompositionOptions,
|
||||
): CodingProductComposition {
|
||||
const projectStore = createCodingProjectStore(options.storage);
|
||||
const attachments = new CodingAttachmentStore(
|
||||
path.join(options.paths.userDataDir, 'coding-runtime', 'attachments'),
|
||||
);
|
||||
const productTools = new PiProductTools({
|
||||
browser: options.browser,
|
||||
attachments,
|
||||
bundledSkillsDir: options.paths.bundledSkillsDir,
|
||||
});
|
||||
const extensionHost = new PiManagedExtensionHost();
|
||||
extensionHost.configureProductTools(productTools);
|
||||
const registry = new PiSessionRegistry({ projectStore });
|
||||
const revisions = new PiManagedInputRevisionCoordinator();
|
||||
const processBudget = new PiProcessBudget();
|
||||
const loadProviderInput = async () => ({
|
||||
accounts: await getProviderService().listAccounts(),
|
||||
modelSummaries: [],
|
||||
});
|
||||
const workerPool = new PiWorkerPool({
|
||||
processBudget,
|
||||
revisionCoordinator: revisions,
|
||||
openWorker: createPiManagedWorkerOpener({
|
||||
registry,
|
||||
executablePath: options.paths.executablePath,
|
||||
cliPath: options.paths.cliPath,
|
||||
userDataDir: options.paths.userDataDir,
|
||||
bundledSkillsDir: options.paths.bundledSkillsDir,
|
||||
loadProviderInput,
|
||||
resolveCredential: resolvePiProviderCredentialFromSecretStore,
|
||||
...(options.localProxyCredential
|
||||
? { getLocalProxyCredential: async () => options.localProxyCredential }
|
||||
: {}),
|
||||
extensionHost,
|
||||
}),
|
||||
});
|
||||
const childOpener = createPiManagedSubagentChildOpener({
|
||||
projectStore,
|
||||
executablePath: options.paths.executablePath,
|
||||
cliPath: options.paths.cliPath,
|
||||
userDataDir: options.paths.userDataDir,
|
||||
bundledSkillsDir: options.paths.bundledSkillsDir,
|
||||
extensionHost,
|
||||
loadProviderInput,
|
||||
resolveCredential: resolvePiProviderCredentialFromSecretStore,
|
||||
getRevision: () => revisions.current,
|
||||
...(options.localProxyCredential
|
||||
? { getLocalProxyCredential: async () => options.localProxyCredential }
|
||||
: {}),
|
||||
});
|
||||
const subagents = new PiSubagentScheduler({
|
||||
openChild: childOpener,
|
||||
processBudget,
|
||||
reclaimProcessCapacity: (signal) => workerPool.reclaimIdleWorker(signal),
|
||||
});
|
||||
const runtime = new PiConversationRuntime({
|
||||
pool: workerPool,
|
||||
registry,
|
||||
extensionHost,
|
||||
subagentScheduler: subagents,
|
||||
resolveModel: async (model) => selectPiProviderModel(
|
||||
buildPiProviderCatalog(await loadProviderInput()),
|
||||
model,
|
||||
),
|
||||
resolveImages: async (refs) => await Promise.all(refs.map(async ({ attachmentId }) => {
|
||||
const record = await attachments.read(attachmentId);
|
||||
return {
|
||||
type: 'image',
|
||||
data: record.data.toString('base64'),
|
||||
mimeType: record.mime,
|
||||
};
|
||||
})),
|
||||
});
|
||||
const projects = new CodingProjectService(projectStore, {
|
||||
onResourcesChanged: async (project) => {
|
||||
runtime.markResourcesStale();
|
||||
const conversations = await createCodingConversationStore(project.path).read()
|
||||
.then((file) => file.conversations)
|
||||
.catch(() => []);
|
||||
for (const conversation of conversations) registry.forget(conversation.id);
|
||||
},
|
||||
onProjectDeactivated: async (project) => {
|
||||
const conversations = await createCodingConversationStore(project.path).read()
|
||||
.then((file) => file.conversations)
|
||||
.catch(() => []);
|
||||
await Promise.allSettled([
|
||||
options.browser.close(project.path),
|
||||
...conversations.map(({ id }) => runtime.dispose(id)),
|
||||
]);
|
||||
},
|
||||
});
|
||||
const conversations = new CodingConversationService(projects, runtime);
|
||||
const host = createCodingProductHost({
|
||||
projects,
|
||||
productTools,
|
||||
listPiCommands: (conversationId) => conversations.listLiveCommands(conversationId),
|
||||
});
|
||||
return {
|
||||
attachments,
|
||||
productTools,
|
||||
projects,
|
||||
conversations,
|
||||
runtime,
|
||||
host,
|
||||
async shutdown() {
|
||||
await subagents.close();
|
||||
await runtime.shutdown();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,10 +7,15 @@ import type {
|
||||
ProductCodingSkill,
|
||||
ProductPiCommandInput,
|
||||
} from '../../shared/coding-product-tools';
|
||||
import { createCodingConversationStore } from '../coding-projects/conversation-store';
|
||||
import type { CodingAttachmentStore } from '../coding-projects/attachment-store';
|
||||
import { readCodingProjectConfigV2 } from '../coding-projects/project-config';
|
||||
import { CodingProjectFileService } from '../coding-projects/project-files';
|
||||
import {
|
||||
CodingProjectServiceError,
|
||||
type CodingProjectService,
|
||||
} from '../coding-projects/project-service';
|
||||
import type { CodingConversationService } from '../coding-runtime/conversation-service';
|
||||
import type { CodingConversationRuntime } from '../coding-runtime/contracts';
|
||||
import type { PiProductTools } from '../coding-runtime/pi/product-tools';
|
||||
|
||||
export interface ActiveCodingProject {
|
||||
@@ -31,7 +36,11 @@ export interface CodingProductHost {
|
||||
export interface CodingProductComposition {
|
||||
attachments: CodingAttachmentStore;
|
||||
productTools: PiProductTools;
|
||||
projects: CodingProjectService;
|
||||
conversations: CodingConversationService;
|
||||
runtime: CodingConversationRuntime;
|
||||
host: CodingProductHost;
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
export class CodingProductHostError extends Error {
|
||||
@@ -45,7 +54,7 @@ export class CodingProductHostError extends Error {
|
||||
}
|
||||
|
||||
export interface CodingProductHostOptions {
|
||||
getActiveProject(): Promise<ActiveCodingProject | null>;
|
||||
projects: Pick<CodingProjectService, 'getActiveProject' | 'findActiveConversation'>;
|
||||
productTools: PiProductTools;
|
||||
files?: CodingProjectFileService;
|
||||
listPiCommands?(conversationId: string): Promise<unknown>;
|
||||
@@ -77,7 +86,7 @@ export function createCodingProductHost(options: CodingProductHostOptions): Codi
|
||||
const files = options.files ?? new CodingProjectFileService();
|
||||
|
||||
async function activeProject(): Promise<ActiveCodingProject> {
|
||||
const project = await options.getActiveProject();
|
||||
const project = await options.projects.getActiveProject();
|
||||
if (!project) {
|
||||
throw new CodingProductHostError(
|
||||
409,
|
||||
@@ -118,15 +127,17 @@ export function createCodingProductHost(options: CodingProductHostOptions): Codi
|
||||
project: ActiveCodingProject;
|
||||
skillIds: readonly string[];
|
||||
}> {
|
||||
const project = await activeProject();
|
||||
const conversation = await createCodingConversationStore(project.path).get(conversationId);
|
||||
if (!conversation) {
|
||||
throw new CodingProductHostError(
|
||||
404,
|
||||
'CODING_CONVERSATION_NOT_FOUND',
|
||||
'Coding Conversation does not exist',
|
||||
);
|
||||
let context;
|
||||
try {
|
||||
context = await options.projects.findActiveConversation(conversationId);
|
||||
} catch (error) {
|
||||
if (error instanceof CodingProjectServiceError
|
||||
&& (error.status === 404 || error.status === 409)) {
|
||||
throw new CodingProductHostError(error.status, error.code, error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const { project, conversation } = context;
|
||||
return {
|
||||
project,
|
||||
skillIds: await selectedSkillIds(project.path, conversation.agentId),
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
export function shouldUseLoopbackHostApi(path: string, method = 'GET'): boolean {
|
||||
const normalizedMethod = method.toUpperCase();
|
||||
const pathname = path.split('?', 1)[0] || path;
|
||||
if (pathname === '/api/events' || pathname === '/api/opencode/events') return true;
|
||||
if (pathname === '/api/events'
|
||||
|| pathname === '/api/opencode/events'
|
||||
|| pathname === '/api/coding/events') return true;
|
||||
if (pathname.startsWith('/api/ai-proxy/')) return true;
|
||||
if (pathname.includes('/events') && pathname.startsWith('/api/image-workspace/')) return true;
|
||||
if (
|
||||
|
||||
@@ -18,6 +18,8 @@ import { handleFileRoutes } from './routes/files';
|
||||
import { handleMeowaGameAssetsRoutes } from './routes/meowa-game-assets';
|
||||
import { handleAgentBrowserRoutes } from './routes/agent-browser';
|
||||
import { handleCodingFileRoutes } from './routes/coding-files';
|
||||
import { handleCodingProjectRoutes } from './routes/coding-projects';
|
||||
import { handleCodingConversationRoutes } from './routes/coding-conversations';
|
||||
|
||||
export type HostApiRouteHandler = (
|
||||
req: IncomingMessage,
|
||||
@@ -43,6 +45,8 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [
|
||||
handleWorksRoutes,
|
||||
handleAgentBrowserRoutes,
|
||||
handleUserSyncRoutes,
|
||||
handleCodingProjectRoutes,
|
||||
handleCodingConversationRoutes,
|
||||
handleCodingFileRoutes,
|
||||
handleOpencodeRoutes,
|
||||
handleSettingsRoutes,
|
||||
|
||||
218
electron/api/routes/coding-conversations.ts
Normal file
218
electron/api/routes/coding-conversations.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
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']);
|
||||
|
||||
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: 'Coding services are unavailable',
|
||||
});
|
||||
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.seq}`,
|
||||
)) 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',
|
||||
),
|
||||
});
|
||||
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;
|
||||
}
|
||||
115
electron/api/routes/coding-projects.ts
Normal file
115
electron/api/routes/coding-projects.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { ProjectType } from '../../../shared/project-config';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson, sendNoContent } from '../route-utils';
|
||||
import { sendCodingRouteError } from './coding-route-errors';
|
||||
|
||||
function isProjectRoute(pathname: string): boolean {
|
||||
return pathname === '/api/coding/projects'
|
||||
|| pathname.startsWith('/api/coding/projects/');
|
||||
}
|
||||
|
||||
export async function handleCodingProjectRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (!isProjectRoute(url.pathname)) return false;
|
||||
const projects = ctx.codingProducts?.projects;
|
||||
const conversations = ctx.codingProducts?.conversations;
|
||||
if (!projects || !conversations) {
|
||||
sendJson(res, 503, {
|
||||
success: false,
|
||||
code: 'CODING_CORE_UNAVAILABLE',
|
||||
error: 'Coding services are unavailable',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (url.pathname === '/api/coding/projects' && req.method === 'GET') {
|
||||
const [items, activeProject] = await Promise.all([
|
||||
projects.listProjects(),
|
||||
projects.getActiveProject(),
|
||||
]);
|
||||
sendJson(res, 200, { projects: items, activeProjectId: activeProject?.id ?? null });
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/projects/open' && req.method === 'POST') {
|
||||
const body = await parseJsonBody<{ projectPath?: string }>(req);
|
||||
sendJson(res, 200, { project: await projects.openProject(body.projectPath ?? '') });
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/projects/create' && req.method === 'POST') {
|
||||
const body = await parseJsonBody<{
|
||||
projectPath?: string;
|
||||
parentPath?: string;
|
||||
projectName?: string;
|
||||
projectType?: ProjectType;
|
||||
}>(req);
|
||||
sendJson(res, 201, { snapshot: await projects.createProject(body) });
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/projects/remove' && req.method === 'POST') {
|
||||
const body = await parseJsonBody<{ projectId?: string }>(req);
|
||||
await projects.removeProject(body.projectId ?? '');
|
||||
sendNoContent(res);
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/projects/active' && req.method === 'GET') {
|
||||
sendJson(res, 200, { project: await projects.getActiveProject() });
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/projects/active' && req.method === 'POST') {
|
||||
const body = await parseJsonBody<{ projectId?: string }>(req);
|
||||
sendJson(res, 200, { project: await projects.setActiveProject(body.projectId ?? '') });
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/projects/config' && req.method === 'GET') {
|
||||
sendJson(res, 200, {
|
||||
snapshot: await projects.getConfig(url.searchParams.get('projectId')?.trim() || undefined),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/projects/config' && req.method === 'PUT') {
|
||||
const body = await parseJsonBody<{ projectId?: string; config?: unknown }>(req);
|
||||
sendJson(res, 200, {
|
||||
snapshot: await projects.saveConfig(body.projectId ?? '', body.config),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/projects/knowledge' && req.method === 'POST') {
|
||||
const body = await parseJsonBody<{
|
||||
projectId?: string;
|
||||
fileName?: string;
|
||||
contentBase64?: string;
|
||||
}>(req);
|
||||
sendJson(res, 201, {
|
||||
knowledgeFiles: await projects.addKnowledgeFile({
|
||||
projectId: body.projectId ?? '',
|
||||
fileName: body.fileName ?? '',
|
||||
contentBase64: body.contentBase64 ?? '',
|
||||
}),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/projects/conversations' && req.method === 'GET') {
|
||||
sendJson(res, 200, {
|
||||
conversations: await conversations.listConversations(
|
||||
url.searchParams.get('projectId')?.trim() || undefined,
|
||||
),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (url.pathname === '/api/coding/projects/conversations' && req.method === 'POST') {
|
||||
const body = await parseJsonBody<{ projectId?: string; agentId?: unknown; title?: unknown }>(req);
|
||||
sendJson(res, 201, { conversation: await conversations.createConversation(body) });
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
sendCodingRouteError(res, error);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
42
electron/api/routes/coding-route-errors.ts
Normal file
42
electron/api/routes/coding-route-errors.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ServerResponse } from 'node:http';
|
||||
import { CodingProjectServiceError } from '../../coding-projects/project-service';
|
||||
import { CodingConversationServiceError } from '../../coding-runtime/conversation-service';
|
||||
import { sendJson } from '../route-utils';
|
||||
|
||||
export function sendCodingRouteError(res: ServerResponse, error: unknown): void {
|
||||
if (error instanceof CodingProjectServiceError || error instanceof CodingConversationServiceError) {
|
||||
sendJson(res, error.status, {
|
||||
success: false,
|
||||
code: error.code,
|
||||
error: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (error instanceof SyntaxError) {
|
||||
sendJson(res, 400, {
|
||||
success: false,
|
||||
code: 'CODING_REQUEST_INVALID',
|
||||
error: 'Request JSON is invalid',
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(res, 500, {
|
||||
success: false,
|
||||
code: 'CODING_REQUEST_FAILED',
|
||||
error: 'Coding request failed',
|
||||
});
|
||||
}
|
||||
|
||||
export function decodeRouteId(value: string): string {
|
||||
try {
|
||||
const decoded = decodeURIComponent(value).trim();
|
||||
if (!decoded || decoded.length > 128 || decoded.includes('/')) throw new Error();
|
||||
return decoded;
|
||||
} catch {
|
||||
throw new CodingConversationServiceError(
|
||||
400,
|
||||
'CODING_CONVERSATION_REQUEST_INVALID',
|
||||
'Route identifier is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -268,6 +268,7 @@ async function fetchCurrentUserModelConfig(accessToken: string): Promise<Importe
|
||||
}
|
||||
|
||||
async function refreshRunningRuntimeAfterProviderChange(ctx: HostApiContext): Promise<void> {
|
||||
ctx.codingProducts?.conversations.markProviderStale();
|
||||
if (ctx.opencodeManager.getStatus().state === 'stopped') return;
|
||||
|
||||
try {
|
||||
@@ -398,6 +399,9 @@ export async function importCurrentUserModelConfig(
|
||||
signal.throwIfAborted();
|
||||
|
||||
await providerService.setDefaultAccount(NIANCODE_USER_MODEL_ACCOUNT_ID);
|
||||
if (!runtimeRefreshRequired || options.runtimeRefresh === 'defer') {
|
||||
ctx.codingProducts?.conversations.markProviderStale();
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
if (armStoppedApplyForNextFresh) {
|
||||
lease.markRefreshPending();
|
||||
|
||||
Reference in New Issue
Block a user