Merge remote main preserving project chats and reply recovery
This commit is contained in:
@@ -5,13 +5,14 @@ import type { TeacherDefinition, TeacherHistoryPage, TeacherRequest, TeacherTopi
|
||||
import { excerptTeacherText, teacherHistoryMessages } from './context';
|
||||
import { atomicWriteJson, readJsonFile } from '../coding-projects/atomic-json';
|
||||
import { TeacherError } from './config-client';
|
||||
import { teacherTopicId } from './store';
|
||||
import { repairCompletedReply, teacherTopicId } from './store';
|
||||
|
||||
interface TurnIndex { id: string; createdAt: string; origin?: string; messages: ReturnType<typeof teacherHistoryMessages> }
|
||||
interface TurnIndex { replyProjectionVersion?: 1; id: string; createdAt: string; origin?: string; messages: ReturnType<typeof teacherHistoryMessages> }
|
||||
interface Manifest {
|
||||
topic: Omit<TeacherTopic, 'requests'>;
|
||||
turns: TurnIndex[];
|
||||
importedDiscussions?: Record<string, string>;
|
||||
/** Retained verbatim when present in older manifests; no longer imported or interpreted. */
|
||||
importedDiscussions?: unknown;
|
||||
importedGlobal?: boolean;
|
||||
}
|
||||
const PAGE_SIZE = 50;
|
||||
@@ -19,8 +20,13 @@ const isMissing = (error: unknown) => (error as NodeJS.ErrnoException)?.code ===
|
||||
const originKey = (request: TeacherRequest) => request.origin
|
||||
? [request.origin.projectId, request.origin.topicId, request.origin.requestId].join(':') : undefined;
|
||||
const order = (a: TurnIndex, b: TurnIndex) => a.createdAt.localeCompare(b.createdAt) || (a.origin ?? a.id).localeCompare(b.origin ?? b.id);
|
||||
const indexTurn = (turn: TeacherRequest): TurnIndex => ({ id: turn.id, createdAt: turn.createdAt, origin: originKey(turn),
|
||||
messages: teacherHistoryMessages([turn]).map(message => ({ ...message, text: excerptTeacherText(message.text, 180).replaceAll('\n', ' ') })) });
|
||||
const indexTurn = (turn: TeacherRequest): TurnIndex => {
|
||||
const projection = structuredClone(turn);
|
||||
repairCompletedReply(projection);
|
||||
return { id: turn.id, createdAt: turn.createdAt, origin: originKey(turn), replyProjectionVersion: 1,
|
||||
messages: teacherHistoryMessages([projection]).map(message => ({ ...message,
|
||||
text: excerptTeacherText(message.text, 180).replaceAll('\n', ' ') })) };
|
||||
};
|
||||
|
||||
/** A small index and one atomic file per turn. Only the latest page is held live. */
|
||||
export class TeacherConversationStore {
|
||||
@@ -66,17 +72,23 @@ export class TeacherConversationStore {
|
||||
turn.status = 'interrupted';
|
||||
turn.error = '应用已重启,本次回复中断。';
|
||||
await atomicWriteJson(this.turnPath(turn.id), turn);
|
||||
const index = manifest.turns.findIndex(item => item.id === turn.id);
|
||||
if (index >= 0) manifest.turns[index] = indexTurn(turn);
|
||||
recovered = true;
|
||||
}
|
||||
if (recovered) {
|
||||
this.live.revision++;
|
||||
await this.save(this.live);
|
||||
// Only restart metadata changes here; completed display repairs stay in memory.
|
||||
await this.save(this.live, '');
|
||||
}
|
||||
}
|
||||
private async turn(id: string) {
|
||||
const turn = await readJsonFile(this.turnPath(id)) as TeacherRequest;
|
||||
if (turn.id !== id || !Array.isArray(turn.references))
|
||||
throw new TeacherError(409, 'teacher_history_invalid', '智能体消息无法读取,请保留本机记录。');
|
||||
repairCompletedReply(turn);
|
||||
const index = this.manifest?.turns.findIndex(item => item.id === turn.id) ?? -1;
|
||||
if (this.manifest && index >= 0) this.manifest.turns[index] = indexTurn(turn);
|
||||
return turn;
|
||||
}
|
||||
async exists() { await this.load(); return Boolean(this.manifest); }
|
||||
@@ -88,7 +100,7 @@ export class TeacherConversationStore {
|
||||
schemaVersion: 1, revision: 0, id: randomUUID(), accountId: this.accountId,
|
||||
projectId: this.projectId, sourceConversationId: 'project', definition: structuredClone(definition), version,
|
||||
createdAt: now, updatedAt: now, requests: [],
|
||||
conversation: { agentId: this.agentId, segmentTurns: 0, discussions: {} },
|
||||
conversation: { agentId: this.agentId, segmentTurns: 0 },
|
||||
};
|
||||
// The manifest is durable before any turn file, so orphan recovery knows its owner.
|
||||
const manifest: Manifest = { topic: { ...topic }, turns: [] };
|
||||
@@ -115,7 +127,11 @@ export class TeacherConversationStore {
|
||||
const entries = structuredClone(this.manifest?.turns ?? []);
|
||||
const owners = new Map(entries.flatMap(turn => turn.messages.map(message => [message.id, turn.id] as const)));
|
||||
return {
|
||||
messages: entries.flatMap(turn => turn.messages),
|
||||
// Older indexes may contain the unsafe quoted prefix. Keep them lazy:
|
||||
// expose an ID for reading, not an unverified assistant excerpt.
|
||||
messages: entries.flatMap(turn => turn.messages.map(message =>
|
||||
turn.replyProjectionVersion === 1 || message.role !== 'assistant' ? message
|
||||
: { ...message, text: '(按消息 ID 读取完整回复)' })),
|
||||
read: async (id: string) => {
|
||||
const owner = owners.get(id);
|
||||
return owner ? teacherHistoryMessages([await this.turn(owner)]).find(message => message.id === id) : undefined;
|
||||
@@ -134,7 +150,11 @@ export class TeacherConversationStore {
|
||||
const start = Math.max(0, end - Math.min(PAGE_SIZE, Math.max(1, limit)));
|
||||
const requests = await Promise.all(turns.slice(start, end).map(turn =>
|
||||
this.live?.requests.find(live => live.id === turn.id) ?? this.turn(turn.id)));
|
||||
return { requests: structuredClone(requests), before: start > 0 ? turns[start].id : null };
|
||||
// Newly imported turns can still be live before they have passed through turn().
|
||||
// Repair the returned view too, keeping archived bytes unchanged.
|
||||
const projection = structuredClone(requests);
|
||||
projection.forEach(repairCompletedReply);
|
||||
return { requests: projection, before: start > 0 ? turns[start].id : null };
|
||||
}
|
||||
async save(topic: TeacherTopic, requestId?: string) {
|
||||
const next = this.writes.catch(() => undefined).then(() => this.persist(topic, requestId));
|
||||
@@ -226,15 +246,6 @@ export class TeacherConversationStore {
|
||||
await this.save(topic, turn.id);
|
||||
topic.requests = topic.requests.slice(-PAGE_SIZE);
|
||||
}
|
||||
// Retain an empty project's discussion too, without assigning unowned turns to it.
|
||||
const discussion = old.topic.conversation.discussions[this.projectId];
|
||||
if (discussion) {
|
||||
const topic = await this.ensure(old.topic.definition, old.topic.version);
|
||||
if (!topic.conversation!.discussions[this.projectId]) {
|
||||
topic.conversation!.discussions[this.projectId] = structuredClone(discussion);
|
||||
(this.manifest!.importedDiscussions ??= {})[this.projectId] = old.topic.updatedAt;
|
||||
}
|
||||
}
|
||||
if (this.manifest) {
|
||||
if (seen) await this.markSeen(seen);
|
||||
this.manifest.importedGlobal = true;
|
||||
@@ -280,12 +291,8 @@ export class TeacherConversationStore {
|
||||
await this.save(topic, turn.id);
|
||||
topic.requests = topic.requests.slice(-PAGE_SIZE);
|
||||
}
|
||||
if (old.discussion && (!topic.conversation!.discussions[project.id]
|
||||
|| old.updatedAt > (this.manifest!.importedDiscussions?.[project.id] ?? ''))) {
|
||||
(this.manifest!.importedDiscussions ??= {})[project.id] = old.updatedAt;
|
||||
topic.conversation!.discussions[project.id] = structuredClone(old.discussion);
|
||||
await this.save(topic, '');
|
||||
}
|
||||
// Retired component state stays in the untouched legacy topic. Import
|
||||
// its conversation turns without creating active per-project components.
|
||||
}
|
||||
}
|
||||
if (this.manifest) await this.recent();
|
||||
|
||||
Reference in New Issue
Block a user