import { createHash, 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 { LegacyConsultationRole, TeacherCheckInInput, TeacherCheckInResult, TeacherDefinition, TeacherReference, TeacherRequest, TeacherSend, TeacherSourceContext, TeacherTopic, TeacherTopicList, } from '../../shared/coding-teacher'; import { TEACHER_CHECK_IN_INTERVAL_MS, TEACHER_UNCHANGED_CHECK_IN_INTERVAL_MS } from '../../shared/coding-teacher'; import { currentTeacherAccount, assertTeacherAccount, teacherAvailability, teacherVersion, teacherPreview, teacherCatalog, teacherCatalogRevision, TeacherError, type TeacherAccount, } from './config-client'; import { TeacherTopicStore, teacherTopicId } from './store'; import { TeacherConversationStore } from './conversation-store'; import { readJsonFile } from '../coding-projects/atomic-json'; import { compileTeacherContext } from './context'; import { prepareTeacherModel } from './model-runner'; import { teacherModelCatalog, validateTeacherModelId } from './model-catalog'; import { prepareCloudTeacher } from './cloud-runner'; import { readTeacherSource } from './source-reader'; import { applyTeacherReply, teacherReplyInstructions } from './reply'; import { subscribeWorksSquareSession } from '../services/works-square-session'; export interface TeacherScope { agentId?: string; projectId: string; sourceId: string; role?: LegacyConsultationRole; } 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; prepareCloud?: typeof prepareCloudTeacher; catalog?: typeof teacherCatalog; models?: typeof teacherModelCatalog; readSource?(scope: TeacherScope): Promise; } export class CodingTeacherService { private readonly conversations = new Map(); private readonly stores = new Map(); private readonly tails = new Map>(); private readonly active = new Map< string, { account: TeacherAccount; controller: AbortController; sourceId?: string; projectId: string } >(); 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 }; } async models(runtime: 'local' | 'yuxi') { return (this.options.models ?? teacherModelCatalog)(await this.account(), runtime); } async catalog() { return (this.options.catalog ?? teacherCatalog)(await this.account()); } async catalogRevision() { return teacherCatalogRevision(); } async legacyHistory(projectId: string, selection?: { id: string; sourceId: string; friend: boolean }) { const account = await this.account(); const project = await this.options.projects.getProject(projectId); const directory = (friend: boolean) => path.join(project.path, '.makelore', friend ? 'friend-conversations' : 'teacher-conversations', account.id); const read = async (friend: boolean, source: string, id: string) => { const topic = await readJsonFile(path.join(directory(friend), source === 'project' ? source : teacherTopicId(source), teacherTopicId(id) + '.json')) as TeacherTopic; if (topic.accountId !== account.id || topic.projectId !== projectId) throw new TeacherError(404, 'teacher_topic_not_found', '旧记录不存在。'); this.assertAccount(account); return topic; }; if (selection) return read(selection.friend, selection.sourceId, selection.id); const items: TeacherTopicList['items'] = []; for (const friend of [false, true]) { let scopes: string[]; try { scopes = await readdir(directory(friend)); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; throw error; } for (const source of scopes.filter(value => value === 'project' || /^[0-9a-f-]{36}$/i.test(value))) { for (const name of await readdir(path.join(directory(friend), source))) { if (!/^[0-9a-f-]{36}\.json$/i.test(name)) continue; const topic = await read(friend, source, name.slice(0, -5)); items.push({ id: topic.id, title: topic.requests.find(turn => turn.text)?.text.slice(0, 48) || topic.definition.name, updatedAt: topic.updatedAt, version: topic.version, teacherId: topic.definition.config_id, legacySourceId: source, ...(friend ? { legacyRole: 'friend' as const } : {}) }); } } } this.assertAccount(account); return { items: items.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)), lastSelectedTopicId: null }; } private conversationStore(account: TeacherAccount, projectId: string, agentId: string) { if (!agentId || agentId.length > 128 || !/^[a-zA-Z0-9_-]+$/.test(agentId)) throw new TeacherError(400, 'teacher_agent_invalid', '智能体标识无效。'); teacherTopicId(projectId); const key = account.id + ':' + projectId + ':' + agentId; let store = this.conversations.get(key); if (!store) { store = new TeacherConversationStore(path.join(this.options.userDataDir, 'agent-conversations', account.id, agentId, 'projects', projectId), account.id, agentId, projectId); this.conversations.set(key, store); } return store; } private async importConversations(account: TeacherAccount, projectId: string, agentId: string) { const project = await this.options.projects.getProject(projectId); const store = this.conversationStore(account, projectId, agentId); if (![...this.active.keys()].some(key => key.startsWith(account.id + ':agent:' + projectId + ':' + agentId + ':'))) { this.assertAccount(account); await store.importGlobal(path.join(this.options.userDataDir, 'agent-conversations', account.id, agentId)); await store.importProject(project); } return store; } async conversation(projectId: string, agentId: string, before?: string) { const account = await this.account(); return this.serialize(this.acceptanceKey(account, { projectId, agentId, sourceId: 'project' }), async () => { const store = await this.importConversations(account, projectId, agentId); if (!await store.exists()) return { topic: null, before: null }; const topic = await store.read(); const page = await store.page(before); this.assertAccount(account); return { topic: { ...structuredClone(topic), requests: page.requests }, before: page.before }; }); } async sendConversation(agentId: string, input: TeacherSend) { if (!input.projectId) throw new TeacherError(422, 'teacher_project_required', '请先选择本次提问的项目。'); const account = await this.account(); const scope: TeacherScope = { projectId: input.projectId, sourceId: 'project', agentId }; return this.serialize(this.acceptanceKey(account, scope), async () => { const store = await this.importConversations(account, scope.projectId, agentId); if (!await store.exists()) { const catalog = await (this.options.catalog ?? teacherCatalog)(account); const selected = catalog.items.find(item => item.teacher_id === agentId); if (!selected) throw new TeacherError(409, 'teacher_disabled', '智能体已停用,历史仍可查看。'); this.assertAccount(account); await store.ensure(selected.definition, selected.version); } const topic = await store.read(); if (!this.active.has(this.key(account, scope, topic.id))) await store.recent(); return this.sendRequest(account, scope, topic.id, input); }); } async markConversationSeen(projectId: string, agentId: string, requestId: string) { const account = await this.account(); const scope = { agentId, projectId, sourceId: 'project' }; return this.serialize(this.acceptanceKey(account, scope), async () => { const store = this.conversationStore(account, projectId, agentId); this.assertAccount(account); await store.markSeen(requestId); const topic = await store.read(); this.events.emit(this.key(account, scope, topic.id), structuredClone(topic)); return structuredClone(topic); }); } private async scopedStore( account: TeacherAccount, scope: TeacherScope ): Promise { if (scope.agentId) return this.conversationStore(account, scope.projectId, scope.agentId); 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 (scope.sourceId !== 'project' && !(await this.options.projects.conversationStore(project.path).get(scope.sourceId))) throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。'); directory = path.join( project.path, '.makelore', scope.role === 'friend' ? 'friend-conversations' : 'teacher-conversations', account.id, scope.sourceId === 'project' ? 'project' : 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) { if (scope.agentId) return account.id + ':agent:' + scope.projectId + ':' + scope.agentId + ':' + id; return account.id + ':' + scope.projectId + ':' + scope.sourceId + ':' + (scope.role ?? 'teacher') + ':' + id; } private acceptanceKey(account: TeacherAccount, scope: TeacherScope) { if (scope.agentId) return this.key(account, scope, 'acceptance'); return account.id + ':' + scope.projectId + ':teacher-acceptance'; } 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(); const current = await (await this.scopedStore(account, scope)).list(); if (scope.agentId || scope.sourceId !== 'project' || scope.role === 'friend') return current; const legacy = await (await this.scopedStore(account, { ...scope, role: 'friend' })).list(); return { ...current, items: [...current.items, ...legacy.items.map(item => ({ ...item, legacyRole: 'friend' as const }))] .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) }; } async create( scope: TeacherScope, draftRevision?: number, sampleContext = '', teacherVersionNumber?: number, ): Promise { this.assertWritable(scope); 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 if (teacherVersionNumber !== undefined) { if (!Number.isSafeInteger(teacherVersionNumber) || teacherVersionNumber < 1) throw new TeacherError(422, 'teacher_version_invalid', '智能体版本无效。'); const catalog = await (this.options.catalog ?? teacherCatalog)(account); const selected = catalog.items.find(item => item.version === teacherVersionNumber); if (!selected) throw new TeacherError(409, 'teacher_disabled', '该智能体暂未开放,请刷新后选择。'); definition = selected.definition; version = selected.version; } 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 || (scope.agentId ? topic.conversation?.agentId !== scope.agentId : topic.sourceConversationId !== scope.sourceId) || (topic.role ?? 'teacher') !== (scope.role ?? 'teacher') ) throw new TeacherError(404, 'teacher_topic_not_found', '智能体话题不存在。'); return { store, topic }; } async read(scope: TeacherScope, id: string, select = true) { const account = await this.account(); const { store, topic } = await this.readOwned(account, scope, id); if (select) await store.select(id); return structuredClone(topic); } private assertWritable(scope: TeacherScope, topic?: TeacherTopic) { if (scope.role === 'friend' || topic?.role === 'friend') throw new TeacherError(409, 'consultation_legacy_read_only', '这是旧版内置朋友的话题,仅供查看。请选择已下发的智能体新建话题。'); } async send(scope: TeacherScope, id: string, input: TeacherSend): Promise { this.assertWritable(scope); // Background turns must pass the project-wide cooldown and source checks. if (input.intent === 'check-in') throw new TeacherError(422, 'teacher_intent_invalid', '主动关心只能由项目智能体检查发起。'); const account = await this.account(); if (scope.projectId !== 'preview') return await this.serialize(this.acceptanceKey(account, scope), () => this.sendRequest(account, scope, id, input)); return await this.sendRequest(account, scope, id, input); } async checkIn(scope: TeacherScope, input: TeacherCheckInInput): Promise { this.assertWritable(scope); if (scope.projectId === 'preview' || scope.sourceId !== 'project') throw new TeacherError(422, 'teacher_intent_invalid', '主动关心只适用于项目里的智能体。'); teacherTopicId(input.requestId); teacherTopicId(input.sourceConversationId); const account = await this.account(); return await this.serialize(this.acceptanceKey(account, scope), async () => { this.assertAccount(account); const store = await this.scopedStore(account, scope); const list = await store.list(); const topics = await Promise.all(list.items.map(async (item) => (await this.readOwned(account, scope, item.id)).topic)); for (const topic of topics) { const existing = topic.requests.find((request) => request.id === input.requestId); if (!existing) continue; if (existing.intent !== 'check-in' || existing.sourceConversationId !== input.sourceConversationId) throw new TeacherError(409, 'teacher_request_conflict', '同一请求标识不能用于不同问题。'); this.assertAccount(account); return { topic: structuredClone(topic) }; } if ([...this.active.values()].some((run) => run.account.id === account.id && run.projectId === scope.projectId) || topics.some((topic) => topic.requests.some((request) => ['preparing', 'running'].includes(request.status)))) return { topic: null, skipped: 'busy' }; const checks = topics.flatMap((topic) => topic.requests.filter((request) => request.intent === 'check-in')); if (checks.some((request) => Date.now() - Date.parse(request.createdAt) < TEACHER_CHECK_IN_INTERVAL_MS)) return { topic: null, skipped: 'cooldown' }; if (this.deletingSources.has(scope.projectId + ':' + input.sourceConversationId)) throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。'); const project = await this.options.projects.getProject(scope.projectId); const conversation = await this.options.projects.conversationStore(project.path).get(input.sourceConversationId); if (!conversation) throw new TeacherError(404, 'teacher_source_not_found', '来源会话不属于当前项目。'); if (conversation.archivedAt) return { topic: null, skipped: 'archived' }; const available = await (this.options.availability ?? teacherAvailability)(account); this.assertAccount(account); if (!available.enabled || !available.published_version) return { topic: null, skipped: 'disabled' }; const source = await (this.options.readSource?.({ ...scope, sourceId: input.sourceConversationId }) ?? readTeacherSource(this.options.projects, this.options.runtime, this.options.userDataDir, scope.projectId, input.sourceConversationId)); this.assertAccount(account); const selectedTopic = topics.find((candidate) => candidate.id === list.lastSelectedTopicId); // Student discussion is progress too; proactive replies themselves must not // change this digest and cause another identical check-in five minutes later. const discussion = (selectedTopic?.requests ?? []) .filter((request) => request.intent !== 'check-in' && request.status === 'completed') .map(({ id, text, response }) => [id, text, response]) .sort((a, b) => a[0].localeCompare(b[0])); if (!source.messages.some((message) => message.text.trim()) && discussion.length === 0) return { topic: null, skipped: 'no-context' }; const fingerprint = createHash('sha256') .update(JSON.stringify({ source: source.messages.map(({ id, role, text }) => [id, role, text]), discussion })) .digest('hex'); if (checks.some((request) => request.sourceConversationId === input.sourceConversationId && request.status === 'completed' && request.checkInSourceFingerprint === fingerprint && Date.now() - Date.parse(request.createdAt) < TEACHER_UNCHANGED_CHECK_IN_INTERVAL_MS)) return { topic: null, skipped: 'unchanged' }; const topic = selectedTopic ?? await this.create(scope); return { topic: await this.sendRequest(account, scope, topic.id, { ...input, intent: 'check-in', text: '', }, { source, fingerprint }), }; }); } private async sendRequest( account: TeacherAccount, scope: TeacherScope, id: string, input: TeacherSend, checkIn?: { source: TeacherSourceContext; fingerprint: string } ): Promise { teacherTopicId(input.requestId); const modelId = validateTeacherModelId(input.modelId); const intent = input.intent === undefined ? 'question' : input.intent; const structuredReply = intent !== 'check-in'; if (!['question', 'suggestions', 'guided-help', 'check-in'].includes(intent) || (intent === 'check-in' && !checkIn)) throw new TeacherError(422, 'teacher_intent_invalid', '提问方式无效,请重新打开智能体后再试。'); if (intent !== 'question' && ( scope.projectId === 'preview' || scope.sourceId !== 'project' )) throw new TeacherError(422, 'teacher_intent_invalid', '这种提问方式只适用于项目里的智能体。'); if (input.sourceConversationId !== undefined) teacherTopicId(input.sourceConversationId); if (typeof input.text !== 'string' || (intent !== 'check-in' && !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 key = this.key(account, scope, id); return await this.serialize(key, async () => { const { store, topic: stored } = await this.readOwned(account, scope, id); if (this.active.has(key) && !stored.requests.some(request => ['preparing', 'running'].includes(request.status))) await this.finishes.get(key); const topic = scope.agentId ? structuredClone(stored) : stored; this.assertWritable(scope, topic); const existing = store instanceof TeacherConversationStore ? await store.findRequest(input.requestId) : topic.requests.find((request) => request.id === input.requestId); if (existing) { if ( existing.text !== input.text || existing.modelId !== modelId || (scope.agentId && existing.projectId !== scope.projectId) || (existing.intent ?? 'question') !== intent || JSON.stringify(existing.references) !== JSON.stringify(refs) || (existing.sourceConversationId ?? undefined) !== (input.sourceConversationId ?? undefined) ) 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', '请等待当前回复完成,或先停止。'); let newConversationSegment = false; if (scope.agentId) { const selected = (await (this.options.catalog ?? teacherCatalog)(account)).items.find(item => item.teacher_id === scope.agentId); if (!selected) throw new TeacherError(409, 'teacher_disabled', '智能体已停用,历史仍可查看。'); this.assertAccount(account); topic.definition = structuredClone(selected.definition); topic.version = selected.version; topic.projectId = scope.projectId; topic.sourceConversationId = 'project'; const previous = topic.requests.at(-1); const reuse = previous && !previous.origin && previous.projectId === scope.projectId && previous.sourceConversationId === input.sourceConversationId && previous.modelId === modelId && previous.presentation === 'reply-v1' && previous.teacherVersion === selected.version && previous.runtimeThreadId; newConversationSegment = !reuse; // Yuxi binds both project and Pi source to a thread. Preserve its native // summary middleware within a segment; explicitly carry bounded public // history only when a new scope, release, or reply protocol starts another // segment. Older checkpoints may still contain retired component instructions. topic.conversation!.runtimeThreadId = reuse || randomUUID(); topic.conversation!.segmentTurns = reuse ? topic.conversation!.segmentTurns + 1 : 1; } if (topic.draftRevision) { await (this.options.preview ?? teacherPreview)(account, topic.draftRevision); } else if (topic.definition.runtime !== 'yuxi' && topic.definition.config_id) { const catalog = await (this.options.catalog ?? teacherCatalog)(account); if (!catalog.items.some(item => item.teacher_id === topic.definition.config_id)) throw new TeacherError(409, 'teacher_disabled', '智能体已停用,历史仍可查看。'); } else if (topic.definition.runtime !== 'yuxi') { const available = await (this.options.availability ?? teacherAvailability)(account); if (!available.enabled) throw new TeacherError(409, 'teacher_disabled', '智能体已停用,历史仍可查看。'); } const sourceId = scope.sourceId === 'project' ? input.sourceConversationId : scope.sourceId; if (sourceId && this.deletingSources.has(scope.projectId + ':' + sourceId)) throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。'); if (scope.sourceId === 'project' && sourceId) { const project = await this.options.projects.getProject(scope.projectId); const conversation = await this.options.projects.conversationStore(project.path).get(sourceId); if (!conversation) throw new TeacherError(404, 'teacher_source_not_found', '来源会话不属于当前项目。'); if (checkIn && conversation.archivedAt) throw new TeacherError(409, 'teacher_source_archived', '来源会话已归档。'); } const source: TeacherSourceContext = checkIn ? checkIn.source : scope.projectId === 'preview' ? { messages: topic.sampleContext ? [{ id: 'preview', role: 'user', text: topic.sampleContext }] : [], cursor: { workerGeneration: 0, seq: 0 }, capturedAt: new Date().toISOString(), } : !sourceId ? { messages: [], cursor: { workerGeneration: 0, seq: 0 }, capturedAt: new Date().toISOString() } : await (this.options.readSource?.({ ...scope, sourceId }) ?? readTeacherSource( this.options.projects, this.options.runtime, this.options.userDataDir, scope.projectId, 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); }); const isCloud = topic.definition.runtime === 'yuxi'; if (modelId) { const models = await (this.options.models ?? teacherModelCatalog)(account, isCloud ? 'yuxi' : 'local'); if (!models.supported || !models.models.some(item => item.id === modelId)) throw new TeacherError(422, 'teacher_model_unavailable', models.message ?? '这个模型暂不可用,请重新选择。'); this.assertAccount(account); } const modelDefinition = modelId ? { ...topic.definition, model: { model_id: modelId, reasoning_choice: { mode: 'default' as const } } } : topic.definition; if (!isCloud && !topic.definition.system_prompt.trim()) throw new TeacherError(422, 'teacher_definition_invalid', '请先配置智能体的系统提示词。'); const access = scope.projectId === 'preview' ? undefined : { projectPath: (await this.options.projects.getProject(scope.projectId)).path, source, history: topic.requests, ...(store instanceof TeacherConversationStore ? { archive: await store.history() } : {}), assertCurrent: () => this.assertAccount(account), }; const model = isCloud && access ? (this.options.prepareCloud ?? prepareCloudTeacher)(account, topic, input.requestId, access, (progress) => { const current = topic.requests.find(request => request.id === input.requestId)!; if (current.progress === progress) return; current.progress = progress; topic.revision++; this.events.emit(key, structuredClone(topic)); }, async cloudRequestId => { topic.requests.find(request => request.id === input.requestId)!.cloudRequestId = cloudRequestId; await store.save(topic, input.requestId); }, undefined, activity => { const current = topic.requests.find(request => request.id === input.requestId)!; const activities = current.toolActivity ??= []; const index = activities.findIndex(item => item.id === activity.id); if (index < 0) activities.push(activity); else activities[index] = activity; topic.revision++; this.events.emit(key, structuredClone(topic)); }) : await (this.options.prepareModel ?? prepareTeacherModel)(account, modelDefinition, access, { finalOnly: structuredReply }); if (checkIn) this.assertAccount(account); const compiled = compileTeacherContext( topic.definition, source, isCloud && (!scope.agentId || !newConversationSegment) ? [] : topic.requests, input.text, references, model.inputLimit, intent, structuredReply ? teacherReplyInstructions() : undefined, scope.projectId !== 'preview', model.measureInput ); // Reading context and resolving model credentials can yield while a source // is being deleted. Project consultations must recheck the actual source. if (sourceId && this.deletingSources.has(scope.projectId + ':' + sourceId)) throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。'); if (checkIn && sourceId) { const project = await this.options.projects.getProject(scope.projectId); const conversation = await this.options.projects.conversationStore(project.path).get(sourceId); if (!conversation) throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。'); if (conversation.archivedAt) throw new TeacherError(409, 'teacher_source_archived', '来源会话已归档。'); this.assertAccount(account); } const request: TeacherRequest = { ...(scope.agentId ? { projectId: scope.projectId, projectName: (await this.options.projects.getProject(scope.projectId)).name, teacherVersion: topic.version, runtimeThreadId: topic.conversation!.runtimeThreadId } : {}), id: input.requestId, intent, ...(structuredReply ? { presentation: 'reply-v1' as const } : {}), ...(input.sourceConversationId ? { sourceConversationId: input.sourceConversationId } : {}), ...(checkIn ? { checkInSourceFingerprint: checkIn.fingerprint } : {}), text: input.text, references, createdAt: new Date().toISOString(), ...(modelId ? { modelId } : {}), sourceCursor: source.cursor, sourceCapturedAt: source.capturedAt, includedSourceMessageIds: compiled.includedSourceMessageIds, omittedMessages: compiled.omittedMessages, truncatedMessages: compiled.truncatedMessages, status: 'preparing' as const, response: '', }; topic.requests.push(request); topic.updatedAt = request.createdAt; topic.revision++; try { await store.save(topic, request.id); } catch (error) { topic.requests.pop(); throw error; } const controller = new AbortController(); // If deletion began during persistence, retain a cancelled request rather // than leaving a permanently preparing request in the project topic. if (sourceId && this.deletingSources.has(scope.projectId + ':' + sourceId)) controller.abort(); this.active.set(key, { account, controller, sourceId, projectId: scope.projectId }); const release = this.options.acquireLease?.(key) ?? (() => undefined); const finish = async () => { const current = topic.requests.find(request => request.id === input.requestId)!; let structuredText = ''; 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) => { if (structuredReply) { structuredText += delta; return; } current.response += delta; topic.revision++; this.events.emit(key, structuredClone(topic)); }); this.assertAccount(account); if (!controller.signal.aborted && structuredReply) applyTeacherReply(current, structuredText); 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 { // Keep received bytes inert even when an aborted runner resolves normally. if (structuredReply && current.status !== 'completed' && structuredText) current.unparsedResponse = structuredText; if (intent === 'suggestions' && current.status !== 'completed') { current.response = ''; delete current.suggestedQuestions; } current.finishedAt = new Date().toISOString(); topic.updatedAt = current.finishedAt; topic.revision++; try { await store.save(topic, current.id); } 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.some(request => request.id === requestId && ['preparing', 'running'].includes(request.status))) 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()] // A project-level topic can be preparing a request for this source even // though its serialization key uses "project" instead of the source id. .filter(([key]) => key.includes(':' + projectId + ':')) .map(([, promise]) => promise) ); const affectedKeys = [...this.active.entries()] .filter(([, run]) => run.projectId === projectId && run.sourceId === sourceId) .map(([key, run]) => { run.controller.abort(); return key; }); await Promise.allSettled(affectedKeys.map((key) => this.finishes.get(key))); 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(); } }