Files
makelore/electron/coding-teacher/service.ts
2026-09-29 20:40:09 +08:00

788 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<TeacherSourceContext>;
}
export class CodingTeacherService {
private readonly conversations = new Map<string, TeacherConversationStore>();
private readonly stores = new Map<string, TeacherTopicStore>();
private readonly tails = new Map<string, Promise<unknown>>();
private readonly active = new Map<
string,
{ account: TeacherAccount; controller: AbortController; sourceId?: string; projectId: string }
>();
private readonly finishes = new Map<string, Promise<void>>();
private readonly deletingSources = new Set<string>();
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<TeacherTopicStore | TeacherConversationStore> {
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<T>(key: string, operation: () => Promise<T>): Promise<T> {
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<TeacherTopic> {
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<TeacherTopic> {
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<TeacherCheckInResult> {
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<TeacherTopic> {
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();
}
}