feat(coding): keep one ongoing conversation per delivered agent

This commit is contained in:
2026-09-28 14:19:02 +08:00
parent e54f3d234d
commit 7aa81da616
23 changed files with 1205 additions and 160 deletions

View File

@@ -11,9 +11,11 @@ import type {
TeacherDefinition,
TeacherDiscussionAction,
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 {
@@ -27,6 +29,8 @@ import {
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 { prepareCloudTeacher } from './cloud-runner';
@@ -36,6 +40,7 @@ import { applyDiscussionReply, discussionInstructions, editDiscussion, validateD
import { subscribeWorksSquareSession } from '../services/works-square-session';
export interface TeacherScope {
agentId?: string;
projectId: string;
sourceId: string;
role?: LegacyConsultationRole;
@@ -59,6 +64,7 @@ export interface TeacherServiceOptions {
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<
@@ -99,10 +105,103 @@ export class CodingTeacherService {
async catalog() {
return (this.options.catalog ?? teacherCatalog)(await this.account());
}
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, agentId: string) {
if (!agentId || agentId.length > 128 || !/^[a-zA-Z0-9_-]+$/.test(agentId))
throw new TeacherError(400, 'teacher_agent_invalid', '智能体标识无效。');
const key = account.id + ':' + agentId;
let store = this.conversations.get(key);
if (!store) {
store = new TeacherConversationStore(path.join(this.options.userDataDir, 'agent-conversations', account.id, agentId), account.id, agentId);
this.conversations.set(key, store);
}
return store;
}
private async importConversations(account: TeacherAccount, agentId: string) {
const store = this.conversationStore(account, agentId);
if (![...this.active.keys()].some(key => key.startsWith(account.id + ':agent:' + agentId + ':'))) {
for (const project of await this.options.projects.listProjects()) {
this.assertAccount(account);
await store.importProject(project);
}
}
return store;
}
async conversation(agentId: string, before?: string) {
const account = await this.account();
return this.serialize(account.id + ':agent:' + agentId + ':acceptance', async () => {
const store = await this.importConversations(account, 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, 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(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, 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> {
): Promise<TeacherTopicStore | TeacherConversationStore> {
if (scope.agentId) return this.conversationStore(account, scope.agentId);
if (this.deletingSources.has(scope.projectId + ':' + scope.sourceId))
throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。');
let directory: string;
@@ -128,9 +227,11 @@ export class CodingTeacherService {
return store;
}
private key(account: TeacherAccount, scope: TeacherScope, id: string) {
if (scope.agentId) return account.id + ':agent:' + scope.agentId + ':' + id;
return account.id + ':' + scope.projectId + ':' + scope.sourceId + ':' + (scope.role ?? 'teacher') + ':' + id;
}
private acceptanceKey(account: TeacherAccount, scope: TeacherScope) {
if (scope.agentId) return account.id + ':agent:' + scope.agentId + ':acceptance';
return account.id + ':' + scope.projectId + ':teacher-acceptance';
}
private async serialize<T>(key: string, operation: () => Promise<T>): Promise<T> {
@@ -146,7 +247,7 @@ export class CodingTeacherService {
async list(scope: TeacherScope) {
const account = await this.account();
const current = await (await this.scopedStore(account, scope)).list();
if (scope.sourceId !== 'project' || scope.role === 'friend') return current;
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)) };
@@ -216,8 +317,8 @@ export class CodingTeacherService {
const topic = (await store.read(id)) as PreviewTopic;
if (
topic.accountId !== account.id ||
topic.projectId !== scope.projectId ||
topic.sourceConversationId !== scope.sourceId ||
(scope.agentId ? topic.conversation?.agentId !== scope.agentId :
topic.projectId !== scope.projectId || topic.sourceConversationId !== scope.sourceId) ||
(topic.role ?? 'teacher') !== (scope.role ?? 'teacher')
)
throw new TeacherError(404, 'teacher_topic_not_found', '智能体话题不存在。');
@@ -347,12 +448,17 @@ export class CodingTeacherService {
throw new TeacherError(422, 'teacher_reference_invalid', '引用内容无效或超过 12000 字。');
const key = this.key(account, scope, id);
return await this.serialize(key, async () => {
const { store, topic } = await this.readOwned(account, scope, id);
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 = topic.requests.find((request) => request.id === input.requestId);
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 ||
(scope.agentId && existing.projectId !== scope.projectId) ||
(existing.intent ?? 'question') !== intent ||
JSON.stringify(existing.references) !== JSON.stringify(refs) ||
(existing.sourceConversationId ?? undefined) !== (input.sourceConversationId ?? undefined) ||
@@ -369,6 +475,27 @@ export class CodingTeacherService {
)
)
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';
topic.discussion = topic.conversation!.discussions[scope.projectId];
const previous = topic.requests.at(-1);
const reuse = previous && !previous.origin && previous.projectId === scope.projectId
&& previous.sourceConversationId === input.sourceConversationId
&& 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 starts another segment.
topic.conversation!.runtimeThreadId = reuse || randomUUID();
topic.conversation!.segmentTurns = reuse ? topic.conversation!.segmentTurns + 1 : 1;
}
const discussionContext = validateDiscussionContext(topic, input.discussion);
if (topic.draftRevision) {
await (this.options.preview ?? teacherPreview)(account, topic.draftRevision);
@@ -432,20 +559,21 @@ export class CodingTeacherService {
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.at(-1)!;
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.at(-1)!.cloudRequestId = cloudRequestId;
await store.save(topic);
topic.requests.find(request => request.id === input.requestId)!.cloudRequestId = cloudRequestId;
await store.save(topic, input.requestId);
}, undefined, activity => {
const current = topic.requests.at(-1)!;
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);
@@ -460,7 +588,7 @@ export class CodingTeacherService {
const compiled = compileTeacherContext(
topic.definition,
source,
isCloud ? [] : topic.requests,
isCloud && (!scope.agentId || !newConversationSegment) ? [] : topic.requests,
input.text,
references,
model.inputLimit,
@@ -482,7 +610,10 @@ export class CodingTeacherService {
throw new TeacherError(409, 'teacher_source_archived', '来源会话已归档。');
this.assertAccount(account);
}
const request = {
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,
...(input.presentation ? { presentation: input.presentation } : {}),
@@ -504,7 +635,7 @@ export class CodingTeacherService {
topic.updatedAt = request.createdAt;
topic.revision++;
try {
await store.save(topic);
await store.save(topic, request.id);
} catch (error) {
topic.requests.pop();
throw error;
@@ -517,7 +648,7 @@ export class CodingTeacherService {
this.active.set(key, { account, controller, sourceId, projectId: scope.projectId });
const release = this.options.acquireLease?.(key) ?? (() => undefined);
const finish = async () => {
const current = topic.requests.at(-1)!;
const current = topic.requests.find(request => request.id === input.requestId)!;
try {
this.assertAccount(account);
if (controller.signal.aborted) throw controller.signal.reason;
@@ -554,7 +685,8 @@ export class CodingTeacherService {
topic.updatedAt = new Date().toISOString();
topic.revision++;
try {
await store.save(topic);
if (topic.conversation && topic.discussion) topic.conversation.discussions[scope.projectId] = topic.discussion;
await store.save(topic, current.id);
} catch {
topic.unsaved = true;
}
@@ -578,11 +710,17 @@ export class CodingTeacherService {
if (this.active.has(key) || topic.requests.some(request => ['preparing', 'running'].includes(request.status)))
throw new TeacherError(409, 'teacher_topic_busy', '请等待智能体回复,或先停止。');
const next = structuredClone(topic);
if (next.conversation) {
if (!input.projectId) throw new TeacherError(422, 'teacher_project_required', '请选择讨论所属的项目。');
await this.options.projects.getProject(input.projectId);
next.discussion = next.conversation.discussions[input.projectId];
}
editDiscussion(next, input);
if (next.conversation && next.discussion) next.conversation.discussions[input.projectId!] = next.discussion;
next.revision++;
next.updatedAt = new Date().toISOString();
this.assertAccount(account);
await store.save(next);
await store.save(next, scope.agentId ? '' : undefined);
this.events.emit(key, structuredClone(next));
return structuredClone(next);
});
@@ -590,7 +728,7 @@ export class CodingTeacherService {
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)
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);
}