Merge project conversations and cloud coding teacher into main

This commit is contained in:
2026-09-22 13:54:50 +08:00
45 changed files with 2987 additions and 1137 deletions

View File

@@ -1,4 +1,6 @@
import { accessSync, constants } from 'node:fs';
import { CodingTeacherService } from '../coding-teacher/service';
import { readCodingConversationHistory } from '../coding-projects/conversation-history';
import path from 'node:path';
import type { AgentBrowserModule } from '../agent-browser';
import type { AgentBrowserSnapshot } from '../../shared/agent-browser';
@@ -290,6 +292,16 @@ export function createCodingComposition(
let plugins: CodingProjectPluginService | undefined;
let previewDataSession: PreviewDataSessionManager | undefined;
const projects = new CodingProjectService(projectStore, {
getDefaultModel: async () => {
const provider = getProviderService();
const accountId = await provider.getDefaultAccountId();
const accounts = await provider.listAccounts();
const account = accounts.find((item) => item.id === accountId && item.enabled);
if (!account?.model) return null;
const model = { accountId: account.id, modelId: account.model, thinkingLevel: 'off' as const, reasoningChoice: { mode: 'default' as const } };
selectPiProviderModel(buildPiProviderCatalog({ accounts }), model);
return model;
},
createConversationStore: conversationStoreForProject,
onResourcesChanged: async (project) => {
runtime?.markResourcesStale();
@@ -501,6 +513,8 @@ export function createCodingComposition(
: {}),
});
const conversations = new CodingConversationService(projects, runtime, {
readHistory: (projectId, conversation) => readCodingConversationHistory(options.paths.userDataDir, projectId, conversation),
onDelete: (projectId, conversationId) => teacher.removeSource(projectId, conversationId),
archiveSession: async ({ projectId, sessionKey }) => {
await archivePiConversationSession({
userDataDir: options.paths.userDataDir,
@@ -552,7 +566,12 @@ export function createCodingComposition(
previewDataSession?.handleAgentBrowserLifecycle(event);
})
: () => undefined;
const teacher = new CodingTeacherService({
projects, runtime, userDataDir: options.paths.userDataDir,
acquireLease: (id) => options.acquireBackgroundLease?.({ id, kind: 'coding-run' }) ?? (() => undefined),
});
return {
teacher,
gameAudio: gameAudioDelivery,
attachments,
dataService,
@@ -583,6 +602,7 @@ export function createCodingComposition(
await agentServer.stop();
},
async shutdown() {
await teacher.dispose();
conversations.dispose();
gameResourceDelivery.dispose();
gameAudioDelivery.dispose();

View File

@@ -66,6 +66,7 @@ export interface CodingProductHost {
}
export interface CodingProductComposition {
teacher?: import('../coding-teacher/service').CodingTeacherService;
gameAudio?: Pick<GameAudioDeliveryCoordinator, 'readSavedOutput'>;
attachments: CodingAttachmentStore;
dataService: DataServiceOperations;

View File

@@ -20,6 +20,7 @@ import { handleCodingFileRoutes } from './routes/coding-files';
import { handleCodingAttachmentRoutes } from './routes/coding-attachments';
import { handleCodingProjectRoutes } from './routes/coding-projects';
import { handleCodingConversationRoutes } from './routes/coding-conversations';
import { handleCodingTeacherRoutes } from './routes/coding-teacher';
import { handleCodingPluginRoutes } from './routes/coding-plugins';
import { handlePluginMarketplaceRoutes } from './routes/plugin-marketplace';
import { handleDevicePackageRoutes } from './routes/device-packages';
@@ -55,6 +56,7 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [
handleDevicePackageRoutes,
handleCodingPluginRoutes,
handleCodingConversationRoutes,
handleCodingTeacherRoutes,
handleCodingFileRoutes,
handleSettingsRoutes,
handleProviderRoutes,

View File

@@ -0,0 +1,145 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { HostApiContext } from '../context';
import {
flushStreamingHeaders,
parseJsonBody,
sendJson,
writeStreamingChunk,
} from '../route-utils';
import { TeacherError } from '../../coding-teacher/config-client';
import type { TeacherScope } from '../../coding-teacher/service';
import type { TeacherSend } from '../../../shared/coding-teacher';
import { takeTeacherPreviewRevision } from '../../main/app-deep-link';
export async function handleCodingTeacherRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext
): Promise<boolean> {
const source = url.pathname.match(
/^\/api\/coding\/projects\/([^/]+)\/conversations\/([^/]+)\/teacher-topics(?:\/([^/]+))?(?:\/(messages|events|save|requests\/([^/]+)\/cancel))?$/
);
const preview = url.pathname.match(
/^\/api\/coding\/teacher-preview\/topics(?:\/([^/]+))?(?:\/(messages|events|save|requests\/([^/]+)\/cancel))?$/
);
const config = url.pathname === '/api/coding/teacher/config';
const draft = url.pathname === '/api/coding/teacher-preview';
const pending = url.pathname === '/api/coding/teacher-preview/pending-link';
if (!source && !preview && !config && !draft && !pending) return false;
if ((config || draft || pending) && req.method !== 'GET') {
sendJson(res, 405, { error: '不支持此操作。' });
return true;
}
if (pending && req.method === 'GET') {
sendJson(res, 200, { draftRevision: takeTeacherPreviewRevision() });
return true;
}
const service = ctx.codingProducts?.teacher;
if (!service) {
sendJson(res, 503, { error: '老师服务暂不可用。', code: 'teacher_unavailable' });
return true;
}
try {
if (config && req.method === 'GET') {
sendJson(res, 200, await service.definition());
return true;
}
if (draft && req.method === 'GET') {
const revision = Number(url.searchParams.get('draftRevision'));
if (!Number.isSafeInteger(revision) || revision < 1)
throw new TeacherError(400, 'teacher_preview_invalid', '草稿版本无效。');
sendJson(res, 200, await service.previewDefinition(revision));
return true;
}
const scope: TeacherScope = source
? { projectId: decodeURIComponent(source[1]), sourceId: decodeURIComponent(source[2]) }
: { projectId: 'preview', sourceId: 'preview' };
const id = source?.[3] ?? preview?.[1],
action = source?.[4] ?? preview?.[2],
requestId = source?.[5] ?? preview?.[3];
if (!id && req.method === 'GET') {
sendJson(res, 200, await service.list(scope));
return true;
}
if (!id && req.method === 'POST') {
const body = await parseJsonBody<{ draftRevision?: number; sampleContext?: string }>(req);
sendJson(res, 201, await service.create(scope, body.draftRevision, body.sampleContext));
return true;
}
if (id && !action && req.method === 'GET') {
sendJson(res, 200, await service.read(scope, id));
return true;
}
if (id && action === 'messages' && req.method === 'POST') {
sendJson(res, 202, await service.send(scope, id, await parseJsonBody<TeacherSend>(req)));
return true;
}
if (id && action === 'save' && req.method === 'POST') {
sendJson(res, 200, await service.save(scope, id));
return true;
}
if (id && requestId && req.method === 'POST') {
sendJson(res, 200, await service.cancel(scope, id, requestId));
return true;
}
if (id && action === 'events' && req.method === 'GET') {
let closed = false;
const buffered: unknown[] = [];
let started = false;
const close = await service.subscribe(scope, id, (topic) => {
if (!started) {
buffered.push(topic);
return;
}
if (!closed)
void writeStreamingChunk(
res,
'event: snapshot\ndata: ' + JSON.stringify(topic) + '\n\n'
).catch(() => {
closed = true;
});
});
let timer: ReturnType<typeof setInterval> | undefined;
const cleanup = () => {
closed = true;
if (timer) clearInterval(timer);
close();
};
req.once('close', cleanup);
res.once('close', cleanup);
try {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'private, no-store');
res.setHeader('Connection', 'keep-alive');
flushStreamingHeaders(res);
started = true;
for (const topic of buffered) {
if (!await writeStreamingChunk(res, 'event: snapshot\ndata: ' + JSON.stringify(topic) + '\n\n')) {
cleanup(); return true;
}
}
timer = setInterval(() => {
if (!closed) void writeStreamingChunk(res, ': keepalive\n\n').then(written => {
if (!written) cleanup();
}).catch(cleanup);
}, 15000);
} catch {
cleanup();
res.end();
}
return true;
}
sendJson(res, 405, { error: '不支持此操作。' });
} catch (error) {
if (error instanceof TeacherError)
sendJson(res, error.status, { error: error.message, code: error.code });
else
sendJson(res, 500, {
error: '老师操作失败,请保留当前内容后重试。',
code: 'teacher_operation_failed',
});
}
return true;
}