import { mkdir, readdir } from 'node:fs/promises'; import path from 'node:path'; import { atomicWriteJson, readJsonFile } from '../coding-projects/atomic-json'; import type { TeacherRequest, TeacherTopic, TeacherTopicList } from '../../shared/coding-teacher'; import { isTeacherReplyEnvelope, parseTeacherReply } from '../../shared/teacher-reply'; import { TeacherError } from './config-client'; /** Repair only received, completed protocol replies; never resume a model request. */ export function repairCompletedReply(request: TeacherRequest): void { const raw = request.unparsedResponse; if (request.status !== 'completed' || typeof raw !== 'string' || !(request.discussionError || request.replyParseError || request.replyIncomplete) || !isTeacherReplyEnvelope(raw)) return; const parsed = parseTeacherReply(raw); if (request.replyQuickRepliesRejected) { parsed.quickReplies = []; parsed.parseError ??= request.replyParseError; } if (parsed.incomplete) { // Keep the previous text and diagnostics as evidence, but do not present a // legacy half-sentence as a complete reply. request.replyIncomplete = true; return; } const previous = request.response.trim(); // The retained raw data must extend or confirm the existing prose. A stale or // unrelated archive must not replace a useful answer with different content. if (previous && !parsed.reply.trim().startsWith(previous) && previous !== '这次回复没有完整解析,请重试。' && previous !== raw.trim() && !request.replyIncomplete) return; request.response = parsed.reply; request.suggestedQuestions = parsed.quickReplies; delete request.replyIncomplete; if (parsed.parseError) request.replyParseError = parsed.parseError; else { delete request.replyParseError; delete request.discussionError; } // Unlike applying a new reply, reading history never discards the original. } export function teacherTopicId(id: string): string { if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(id)) throw new TeacherError(400, 'teacher_topic_invalid', '智能体话题标识无效。'); return id; } export class TeacherTopicStore { private readonly cache = new Map>(); constructor(readonly directory: string) {} async list(): Promise { await mkdir(this.directory, { recursive: true }); const names = await readdir(this.directory); const topics = await Promise.all( names .filter((name) => name.endsWith('.json') && name !== 'index.json') .map((name) => this.read(name.slice(0, -5))) ); let last: string | null = null; try { last = ( (await readJsonFile(path.join(this.directory, 'index.json'))) as { lastSelectedTopicId: string; } ).lastSelectedTopicId; } catch { /* index is disposable; topic files own history */ } const items = topics .map((topic) => ({ id: topic.id, title: topic.requests.find((request) => request.intent !== 'check-in')?.text.slice(0, 32) || (topic.requests.some((request) => request.intent === 'check-in') ? '和智能体聊聊' : '新话题'), updatedAt: topic.updatedAt, version: topic.version, teacherId: topic.definition.config_id, })) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); return { items, lastSelectedTopicId: items.some((item) => item.id === last) ? last : (items[0]?.id ?? null), }; } async read(id: string): Promise { teacherTopicId(id); let pending = this.cache.get(id); if (!pending) { pending = (async () => { let topic: TeacherTopic; try { topic = (await readJsonFile(path.join(this.directory, id + '.json'))) as TeacherTopic; } catch { throw new TeacherError(404, 'teacher_topic_not_found', '智能体话题不存在或无法读取。'); } if (topic.id !== id || topic.schemaVersion !== 1 || !Array.isArray(topic.requests)) throw new TeacherError(409, 'teacher_topic_invalid', '智能体历史无法读取。'); let recovered = false; // Legacy component fields are opaque archives: never validate, revive or delete them. for (const request of topic.requests) { if (request.status === 'preparing' || request.status === 'running') { request.status = 'interrupted'; request.error = '应用已重启,本次回复中断。'; recovered = true; } } if (recovered) { topic.revision++; await this.save(topic); } // Apply display repair after restart recovery is saved, so reading a // mixed topic does not incidentally persist completed-reply repairs. for (const request of topic.requests) repairCompletedReply(request); return topic; })(); this.cache.set(id, pending); pending.catch(() => this.cache.delete(id)); } return await pending; } async save(topic: TeacherTopic, _requestId?: string) { await mkdir(this.directory, { recursive: true }); const saved = { ...topic }; delete saved.unsaved; await atomicWriteJson(path.join(this.directory, teacherTopicId(topic.id) + '.json'), saved); topic.unsaved = false; this.cache.set(topic.id, Promise.resolve(topic)); } async select(id: string) { await atomicWriteJson(path.join(this.directory, 'index.json'), { lastSelectedTopicId: teacherTopicId(id), }); } }