feat(coding): keep one ongoing conversation per delivered agent
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user