fix(chat): reconcile reply recovery with continuous agent conversations
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
鲨鱼辣椒
2026-09-28 18:14:12 +08:00
parent 44b16f8fbd
commit cafebca057
13 changed files with 159 additions and 34 deletions

View File

@@ -5,9 +5,9 @@ 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[];
@@ -18,8 +18,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 {
@@ -64,17 +69,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); }
@@ -113,7 +124,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;

View File

@@ -13,9 +13,10 @@ export function applyTeacherReply(request: TeacherRequest, raw: string): void {
// Apply the current UI contract only to new replies; archived replies keep
// their original cards through the compatibility parser and topic store.
if (parsed.quickReplies.length > MAX_CURRENT_QUICK_REPLIES) {
request.replyQuickRepliesRejected = true;
parsed.quickReplies = [];
parsed.parseError ??= '快捷回复超过当前界面最多 3 条的限制,正文已保留,快捷回复未展示。';
}
} else delete request.replyQuickRepliesRejected;
request.response = parsed.reply;
request.suggestedQuestions = parsed.quickReplies;
if (parsed.incomplete) request.replyIncomplete = true;

View File

@@ -6,12 +6,16 @@ import { isTeacherReplyEnvelope, parseTeacherReply } from '../../shared/teacher-
import { TeacherError } from './config-client';
/** Repair only received, completed protocol replies; never resume a model request. */
function repairCompletedReply(request: TeacherRequest): void {
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.