import { mkdir, readdir } from 'node:fs/promises'; import path from 'node:path'; import { atomicWriteJson, readJsonFile } from '../coding-projects/atomic-json'; import type { TeacherTopic, TeacherTopicList } from '../../shared/coding-teacher'; import { TeacherError } from './config-client'; import { parseTeacherDiscussionContent } from '../../shared/teacher-discussion'; 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, })) .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; if (topic.discussion) { try { const discussion = topic.discussion; teacherTopicId(discussion.id); if (!Number.isSafeInteger(discussion.revision) || discussion.revision < 1 || !['offered', 'active', 'paused', 'finished'].includes(discussion.status)) throw new Error('Invalid discussion'); discussion.content = parseTeacherDiscussionContent(discussion.content); for (const key of ['previousIdeas', 'previousStructure'] as const) { if (!discussion[key]) continue; try { const previous = parseTeacherDiscussionContent(discussion[key]); if (key === 'previousIdeas' && previous.kind === 'ideas') discussion.previousIdeas = previous; else if (key === 'previousStructure' && previous.kind === 'structure') discussion.previousStructure = previous; else throw new Error('Invalid history'); } catch { delete discussion[key]; recovered = true; } } } catch { delete topic.discussion; recovered = true; } } for (const request of topic.requests) { if (request.discussionSnapshot) { try { request.discussionSnapshot = parseTeacherDiscussionContent(request.discussionSnapshot); } catch { delete request.discussionSnapshot; recovered = true; } } } 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); } return topic; })(); this.cache.set(id, pending); pending.catch(() => this.cache.delete(id)); } return await pending; } async save(topic: TeacherTopic) { 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), }); } }