Merge latest main and preserve teacher upgrade safeguards
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:42:04 +08:00
28 changed files with 1435 additions and 93 deletions

View File

@@ -63,12 +63,12 @@ export function teacherHistoryMessages(history: TeacherRequest[]): TeacherSource
text: [...(request.projectId ? ['当时的项目:' + (request.projectName ?? request.projectId)] : []),
...request.references.map(ref => '明确引用:\n' + ref.text), request.text].join('\n\n'),
}]),
{
...(request.replyIncomplete ? [] : [{
id: 'teacher:' + request.id + ':assistant', role: 'assistant' as const,
text: [request.response, ...(request.suggestedQuestions?.length
? ['可以接着聊的问题:\n' + request.suggestedQuestions.map(text => '- ' + text).join('\n')]
: [])].join('\n\n'),
},
}]),
]);
}
// A bounded text estimate for the native model path, not an exact tokenizer or
@@ -113,7 +113,7 @@ export function compileTeacherContext(
const current: TeacherModelMessage = {
role: intent === 'check-in' ? 'system' : 'user',
content: intent === 'check-in'
? '本轮是一次项目进展提醒,不是用户提问。按照已配置的人设和职责,结合来源操作对话中已完成的文字和咨询历史回应,表达方式遵循已配置的要求,不要求用户立即回答。仅依据已有证据,不重复上次提醒,不声称实际运行或试玩过作品。'
? '本轮入口:程序触发的项目进展检查,学生没有在本轮主动提问。回应方式遵循当前智能体的云端配置。'
: [
...references.map(
(ref) =>
@@ -123,10 +123,10 @@ export function compileTeacherContext(
ref.text
),
'当前问题:\n' + question,
...(intent === 'suggestions' || intent === 'guided-help'
? [
'本轮学生希望你帮忙找到交流起点。依据当前项目、来源操作对话和咨询历史,推荐一个具体切入点,并说明为何值得从这里聊;快捷回复围绕这个切入点帮助学生接话。没有可用上下文时,坦诚从构思切入,不假定学生已完成功能,不编造作品表现或项目进展。',
]
...(intent === 'suggestions'
? ['本轮入口:学生通过“帮我看看”主动请求帮助,尚未提出具体问题。']
: intent === 'guided-help'
? ['本轮入口:学生主动求助,表示暂时说不清想问什么。']
: []),
].join('\n\n'),
};

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[];
@@ -19,8 +19,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 {
@@ -65,17 +70,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); }
@@ -114,7 +125,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

@@ -1,20 +1,32 @@
import type { TeacherRequest } from '../../shared/coding-teacher';
import { parseTeacherReply } from '../../shared/teacher-reply';
const MAX_CURRENT_QUICK_REPLIES = 3;
export function teacherReplyInstructions(): string {
return '本轮界面接收一个 JSON 对象 {"reply":string,"quickReplies":string[]}。reply 是回复正文,可使用 Markdown;quickReplies 是可选的后续回复建议,没有建议时用空数组。只使用这两个字段。依据已配置职责帮助学生整理想法、理解关系、承接已确认的共识;区分学生已确认内容与智能体建议、待定想法。回复的语言、风格、长度、前缀和建议内容遵循当前智能体的云端配置。';
return `本轮界面接收一个 JSON 对象 {"reply":string,"quickReplies":string[]}。reply 是回复正文,可使用 Markdown;quickReplies 是可选的快捷回复,最多 ${MAX_CURRENT_QUICK_REPLIES} 条,每条为非空字符串,没有快捷回复时用空数组。只使用这两个字段。字符串中的英文双引号、反斜杠和换行必须按 JSON 规则转义,确保整个对象是合法 JSON。回复的内容、语言、风格、长度和前缀遵循当前智能体的云端配置。`;
}
/** The original response remains available for diagnosis when parsing is incomplete. */
export function applyTeacherReply(request: TeacherRequest, raw: string): void {
const parsed = parseTeacherReply(raw);
// 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;
else delete request.replyIncomplete;
if (parsed.parseError) {
request.replyParseError = parsed.parseError;
request.unparsedResponse = raw;
} else {
delete request.replyParseError;
delete request.unparsedResponse;
delete request.discussionError;
}
if (parsed.parseError || parsed.recovered) request.unparsedResponse = raw;
else delete request.unparsedResponse;
}

View File

@@ -479,11 +479,13 @@ export class CodingTeacherService {
const previous = topic.requests.at(-1);
const reuse = previous && !previous.origin && previous.projectId === scope.projectId
&& previous.sourceConversationId === input.sourceConversationId
&& previous.presentation === 'reply-v1'
&& 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.
// history only when a new scope, release, or reply protocol starts another
// segment. Older checkpoints may still contain retired component instructions.
topic.conversation!.runtimeThreadId = reuse || randomUUID();
topic.conversation!.segmentTurns = reuse ? topic.conversation!.segmentTurns + 1 : 1;
}

View File

@@ -1,9 +1,44 @@
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 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', '智能体话题标识无效。');
@@ -60,16 +95,20 @@ export class TeacherTopicStore {
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)
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);