Files
makelore/electron/coding-teacher/service.ts

422 lines
16 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 { 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', '请输入 16000 字的问题。');
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();
}
}