788 lines
39 KiB
TypeScript
788 lines
39 KiB
TypeScript
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();
|
||
}
|
||
}
|