feat: organize project conversations and add coding teacher side chat

This commit is contained in:
brother7 committed 2026-09-22 12:30:26 +08:00
1 parent 2f82b9f79c
commit 9242a40d87
45 files changed
+2987 -1137

No files matched your search

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