import { randomUUID } from 'node:crypto'; import { readdir, rm } from 'node:fs/promises'; import path from 'node:path'; import { EventEmitter } from 'node:events'; import type { CodingProjectService } from '../coding-projects/project-service'; import type { CodingConversationRuntime } from '../coding-runtime/contracts'; import type { TeacherDefinition, TeacherReference, TeacherSend, TeacherSourceContext, TeacherTopic, } from '../../shared/coding-teacher'; import { currentTeacherAccount, assertTeacherAccount, teacherAvailability, teacherVersion, teacherPreview, TeacherError, type TeacherAccount, } from './config-client'; import { TeacherTopicStore, teacherTopicId } from './store'; import { compileTeacherContext } from './context'; import { prepareTeacherModel } from './model-runner'; import { readTeacherSource } from './source-reader'; import { subscribeWorksSquareSession } from '../services/works-square-session'; export interface TeacherScope { projectId: string; sourceId: string; } interface PreviewTopic extends TeacherTopic { sampleContext?: string; } export interface TeacherServiceOptions { projects: CodingProjectService; runtime: CodingConversationRuntime; userDataDir: string; acquireLease?(id: string): () => void; account?: typeof currentTeacherAccount; assertAccount?: typeof assertTeacherAccount; availability?: typeof teacherAvailability; version?: typeof teacherVersion; preview?: typeof teacherPreview; prepareModel?: typeof prepareTeacherModel; readSource?(scope: TeacherScope): Promise; } export class CodingTeacherService { private readonly stores = new Map(); private readonly tails = new Map>(); private readonly active = new Map< string, { account: TeacherAccount; controller: AbortController } >(); private readonly finishes = new Map>(); private readonly deletingSources = new Set(); private readonly events = new EventEmitter(); private readonly unsubscribe: () => void; private readonly account: typeof currentTeacherAccount; private readonly assertAccount: typeof assertTeacherAccount; constructor(private readonly options: TeacherServiceOptions) { this.account = options.account ?? currentTeacherAccount; this.assertAccount = options.assertAccount ?? assertTeacherAccount; this.unsubscribe = subscribeWorksSquareSession(() => { for (const run of this.active.values()) { try { this.assertAccount(run.account); } catch { run.controller.abort(); } } }); } async previewDefinition(revision: number) { const account = await this.account(); return await (this.options.preview ?? teacherPreview)(account, revision); } async definition() { const account = await this.account(); const status = await (this.options.availability ?? teacherAvailability)(account); const published = status.published_version ? await (this.options.version ?? teacherVersion)(account, status.published_version) : null; return { ...status, definition: published?.payload ?? null }; } private async scopedStore( account: TeacherAccount, scope: TeacherScope ): Promise { if (this.deletingSources.has(scope.projectId + ':' + scope.sourceId)) throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。'); let directory: string; if (scope.projectId === 'preview') { directory = path.join(this.options.userDataDir, 'teacher-preview', account.id); } else { const project = await this.options.projects.getProject(scope.projectId); if (!(await this.options.projects.conversationStore(project.path).get(scope.sourceId))) throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。'); directory = path.join( project.path, '.makelore', 'teacher-conversations', account.id, teacherTopicId(scope.sourceId) ); } let store = this.stores.get(directory); if (!store) { store = new TeacherTopicStore(directory); this.stores.set(directory, store); } return store; } private key(account: TeacherAccount, scope: TeacherScope, id: string) { return account.id + ':' + scope.projectId + ':' + scope.sourceId + ':' + id; } private async serialize(key: string, operation: () => Promise): Promise { const previous = this.tails.get(key) ?? Promise.resolve(); const next = previous.catch(() => undefined).then(operation); this.tails.set(key, next); try { return await next; } finally { if (this.tails.get(key) === next) this.tails.delete(key); } } async list(scope: TeacherScope) { const account = await this.account(); return await (await this.scopedStore(account, scope)).list(); } async create( scope: TeacherScope, draftRevision?: number, sampleContext = '' ): Promise { const account = await this.account(); const store = await this.scopedStore(account, scope); let definition: TeacherDefinition, version: number; if (scope.projectId === 'preview') { if ( !Number.isSafeInteger(draftRevision) || draftRevision! < 1 || typeof sampleContext !== 'string' || sampleContext.length > 12000 ) throw new TeacherError(400, 'teacher_preview_invalid', '试聊参数无效。'); definition = (await (this.options.preview ?? teacherPreview)(account, draftRevision!)) .payload; version = 0; } else { const status = await (this.options.availability ?? teacherAvailability)(account); if (!status.enabled || !status.published_version) throw new TeacherError(409, 'teacher_disabled', '老师暂未开放。'); const published = await (this.options.version ?? teacherVersion)( account, status.published_version ); definition = published.payload; version = published.version; } this.assertAccount(account); const now = new Date().toISOString(); const topic: PreviewTopic = { schemaVersion: 1, revision: 0, id: randomUUID(), accountId: account.id, projectId: scope.projectId, sourceConversationId: scope.sourceId, definition, version, createdAt: now, updatedAt: now, requests: [], ...(scope.projectId === 'preview' ? { draftRevision, sampleContext } : {}), }; await store.save(topic); await store.select(topic.id); return structuredClone(topic); } private async readOwned(account: TeacherAccount, scope: TeacherScope, id: string) { const store = await this.scopedStore(account, scope); const topic = (await store.read(id)) as PreviewTopic; if ( topic.accountId !== account.id || topic.projectId !== scope.projectId || topic.sourceConversationId !== scope.sourceId ) throw new TeacherError(404, 'teacher_topic_not_found', '老师话题不存在。'); return { store, topic }; } async read(scope: TeacherScope, id: string) { const account = await this.account(); const { store, topic } = await this.readOwned(account, scope, id); await store.select(id); return structuredClone(topic); } async send(scope: TeacherScope, id: string, input: TeacherSend): Promise { teacherTopicId(input.requestId); if (typeof input.text !== 'string' || !input.text.trim() || input.text.length > 6000) throw new TeacherError(422, 'teacher_question_invalid', '请输入 1–6000 字的问题。'); const refs = input.references ?? []; if ( !Array.isArray(refs) || refs.length > 20 || refs.some( (ref) => !ref || !['message', 'code'].includes(ref.kind) || typeof ref.text !== 'string' || (ref.path !== undefined && typeof ref.path !== 'string') ) || refs.reduce((n, ref) => n + ref.text.length, 0) > 12000 ) throw new TeacherError(422, 'teacher_reference_invalid', '引用内容无效或超过 12000 字。'); const account = await this.account(), key = this.key(account, scope, id); return await this.serialize(key, async () => { const { store, topic } = await this.readOwned(account, scope, id); const existing = topic.requests.find((request) => request.id === input.requestId); if (existing) { if ( existing.text !== input.text || JSON.stringify(existing.references) !== JSON.stringify(refs) ) throw new TeacherError(409, 'teacher_request_conflict', '同一请求标识不能用于不同问题。'); return structuredClone(topic); } if ( this.active.has(key) || topic.requests.some( (request) => request.status === 'preparing' || request.status === 'running' ) ) throw new TeacherError(409, 'teacher_topic_busy', '请等待当前回复完成,或先停止。'); if (topic.draftRevision) { await (this.options.preview ?? teacherPreview)(account, topic.draftRevision); } else { const available = await (this.options.availability ?? teacherAvailability)(account); if (!available.enabled) throw new TeacherError(409, 'teacher_disabled', '老师已停用,历史仍可查看。'); } const source: TeacherSourceContext = scope.projectId === 'preview' ? { messages: topic.sampleContext ? [{ id: 'preview', role: 'user', text: topic.sampleContext }] : [], cursor: { workerGeneration: 0, seq: 0 }, capturedAt: new Date().toISOString(), } : await (this.options.readSource?.(scope) ?? readTeacherSource( this.options.projects, this.options.runtime, this.options.userDataDir, scope.projectId, scope.sourceId )); const references: TeacherReference[] = refs.map((ref) => { if ( ref.kind === 'message' && !source.messages.some( (message) => message.id === ref.messageId && message.text.includes(ref.text) ) ) throw new TeacherError( 422, 'teacher_reference_invalid', '选中的消息不属于当前完整会话,请重新引用。' ); return structuredClone(ref); }); if (!topic.definition.system_prompt.trim()) throw new TeacherError(422, 'teacher_definition_invalid', '请先配置老师的系统提示词。'); const model = await (this.options.prepareModel ?? prepareTeacherModel)( account, topic.definition ); const compiled = compileTeacherContext( topic.definition, source, topic.requests, input.text, references, model.inputLimit ); const request = { id: input.requestId, text: input.text, references, createdAt: new Date().toISOString(), sourceCursor: source.cursor, sourceCapturedAt: source.capturedAt, includedSourceMessageIds: compiled.includedSourceMessageIds, omittedMessages: compiled.omittedMessages, status: 'preparing' as const, response: '', }; topic.requests.push(request); topic.updatedAt = request.createdAt; topic.revision++; try { await store.save(topic); } catch (error) { topic.requests.pop(); throw error; } if (this.deletingSources.has(scope.projectId + ':' + scope.sourceId)) throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。'); const controller = new AbortController(); this.active.set(key, { account, controller }); const release = this.options.acquireLease?.(key) ?? (() => undefined); const finish = async () => { const current = topic.requests.at(-1)!; try { this.assertAccount(account); if (controller.signal.aborted) throw controller.signal.reason; current.status = 'running'; topic.revision++; this.events.emit(key, structuredClone(topic)); current.usage = await model.run(compiled.messages, controller.signal, (delta) => { current.response += delta; topic.revision++; this.events.emit(key, structuredClone(topic)); }); current.status = controller.signal.aborted ? 'cancelled' : 'completed'; } catch (error) { current.status = controller.signal.aborted ? 'cancelled' : 'failed'; current.error = controller.signal.aborted ? '已停止回复,部分内容可能不完整。' : error instanceof TeacherError ? error.message : '老师回复失败,已保留本次问题与收到的内容。'; } finally { topic.updatedAt = new Date().toISOString(); topic.revision++; try { await store.save(topic); } catch { topic.unsaved = true; } this.active.delete(key); release(); this.events.emit(key, structuredClone(topic)); } }; const completion = finish(); this.finishes.set(key, completion); void completion.finally(() => this.finishes.delete(key)); return structuredClone(topic); }); } async cancel(scope: TeacherScope, id: string, requestId: string) { const account = await this.account(); const { topic } = await this.readOwned(account, scope, id); if (topic.requests.at(-1)?.id === requestId) this.active.get(this.key(account, scope, id))?.controller.abort(); return structuredClone(topic); } async save(scope: TeacherScope, id: string) { const account = await this.account(); const { store, topic } = await this.readOwned(account, scope, id); if (this.active.has(this.key(account, scope, id))) throw new TeacherError(409, 'teacher_topic_busy', '回复结束后再保存。'); await this.serialize(this.key(account, scope, id), () => store.save(topic)); return structuredClone(topic); } async subscribe(scope: TeacherScope, id: string, onTopic: (topic: TeacherTopic) => void) { const account = await this.account(); const key = this.key(account, scope, id); const { topic } = await this.readOwned(account, scope, id); const listener = (next: TeacherTopic) => { try { this.assertAccount(account); onTopic(next); } catch { this.events.off(key, listener); } }; this.events.on(key, listener); onTopic(structuredClone(topic)); return () => this.events.off(key, listener); } async removeSource(projectId: string, sourceId: string) { const project = await this.options.projects.getProject(projectId); const root = path.join(project.path, '.makelore', 'teacher-conversations'); this.deletingSources.add(projectId + ':' + sourceId); await Promise.allSettled( [...this.tails.entries()] .filter(([key]) => key.includes(':' + projectId + ':' + sourceId + ':')) .map(([, promise]) => promise) ); for (const [key, run] of this.active) if (key.includes(':' + projectId + ':' + sourceId + ':')) run.controller.abort(); await Promise.allSettled( [...this.finishes.entries()] .filter(([key]) => key.includes(':' + projectId + ':' + sourceId + ':')) .map(([, promise]) => promise) ); const accounts = await readdir(root, { withFileTypes: true }).catch(() => []); // Account directories are verified Works UUIDs; the source is a stored conversation UUID. for (const entry of accounts) if (entry.isDirectory() && /^[0-9a-f-]{36}$/i.test(entry.name)) { const directory = path.join(root, entry.name, teacherTopicId(sourceId)); const pending = [...this.tails.entries()] .filter(([key]) => key.includes(':' + projectId + ':' + sourceId + ':')) .map(([, promise]) => promise); await Promise.allSettled(pending); await rm(directory, { recursive: true, force: true }); this.stores.delete(directory); } } async dispose() { this.unsubscribe(); for (const run of this.active.values()) run.controller.abort(); await Promise.allSettled(this.finishes.values()); this.events.removeAllListeners(); } }