Merge remote main preserving project chats and reply recovery
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 20:51:33 +08:00
54 changed files with 3189 additions and 1713 deletions

View File

@@ -9,7 +9,6 @@ import type {
TeacherCheckInInput,
TeacherCheckInResult,
TeacherDefinition,
TeacherDiscussionAction,
TeacherReference,
TeacherRequest,
TeacherSend,
@@ -35,8 +34,7 @@ import { compileTeacherContext } from './context';
import { prepareTeacherModel } from './model-runner';
import { prepareCloudTeacher } from './cloud-runner';
import { readTeacherSource } from './source-reader';
import { parseTeacherSuggestions } from './suggestions';
import { applyDiscussionReply, discussionInstructions, editDiscussion, validateDiscussionContext } from './discussion';
import { applyTeacherReply, teacherReplyInstructions } from './reply';
import { subscribeWorksSquareSession } from '../services/works-square-session';
export interface TeacherScope {
@@ -419,11 +417,7 @@ export class CodingTeacherService {
): Promise<TeacherTopic> {
teacherTopicId(input.requestId);
const intent = input.intent === undefined ? 'question' : input.intent;
if (input.presentation !== undefined && input.presentation !== 'discussion-v1')
throw new TeacherError(422, 'teacher_presentation_invalid', '智能体的显示方式无效。');
if (input.discussion !== undefined && input.presentation !== 'discussion-v1')
throw new TeacherError(422, 'teacher_discussion_invalid', '讨论方式无效。');
const structuredReply = input.presentation === 'discussion-v1' && (intent === 'question' || intent === 'guided-help');
const structuredReply = intent !== 'check-in';
if (!['question', 'suggestions', 'guided-help', 'check-in'].includes(intent) || (intent === 'check-in' && !checkIn))
throw new TeacherError(422, 'teacher_intent_invalid', '提问方式无效,请重新打开智能体后再试。');
if (intent !== 'question' && (
@@ -462,9 +456,7 @@ export class CodingTeacherService {
(scope.agentId && existing.projectId !== scope.projectId) ||
(existing.intent ?? 'question') !== intent ||
JSON.stringify(existing.references) !== JSON.stringify(refs) ||
(existing.sourceConversationId ?? undefined) !== (input.sourceConversationId ?? undefined) ||
existing.presentation !== input.presentation ||
JSON.stringify(existing.discussionContext) !== JSON.stringify(input.discussion)
(existing.sourceConversationId ?? undefined) !== (input.sourceConversationId ?? undefined)
)
throw new TeacherError(409, 'teacher_request_conflict', '同一请求标识不能用于不同问题。');
return structuredClone(topic);
@@ -485,19 +477,19 @@ export class CodingTeacherService {
topic.version = selected.version;
topic.projectId = scope.projectId;
topic.sourceConversationId = 'project';
topic.discussion = topic.conversation!.discussions[scope.projectId];
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;
}
const discussionContext = validateDiscussionContext(topic, input.discussion);
if (topic.draftRevision) {
await (this.options.preview ?? teacherPreview)(account, topic.draftRevision);
} else if (topic.definition.runtime !== 'yuxi' && topic.definition.config_id) {
@@ -584,7 +576,7 @@ export class CodingTeacherService {
})
: await (this.options.prepareModel ?? prepareTeacherModel)(account, topic.definition,
access,
{ finalOnly: structuredReply || intent === 'suggestions' });
{ finalOnly: structuredReply });
if (checkIn) this.assertAccount(account);
const compiled = compileTeacherContext(
topic.definition,
@@ -594,7 +586,7 @@ export class CodingTeacherService {
references,
model.inputLimit,
intent,
structuredReply ? discussionInstructions(topic, discussionContext) : undefined,
structuredReply ? teacherReplyInstructions() : undefined,
scope.projectId !== 'preview',
model.measureInput
);
@@ -617,8 +609,7 @@ export class CodingTeacherService {
teacherVersion: topic.version, runtimeThreadId: topic.conversation!.runtimeThreadId } : {}),
id: input.requestId,
intent,
...(input.presentation ? { presentation: input.presentation } : {}),
...(discussionContext ? { discussionContext } : {}),
...(structuredReply ? { presentation: 'reply-v1' as const } : {}),
...(input.sourceConversationId ? { sourceConversationId: input.sourceConversationId } : {}),
...(checkIn ? { checkInSourceFingerprint: checkIn.fingerprint } : {}),
text: input.text,
@@ -650,13 +641,13 @@ export class CodingTeacherService {
const release = this.options.acquireLease?.(key) ?? (() => undefined);
const finish = async () => {
const current = topic.requests.find(request => request.id === input.requestId)!;
let structuredText = '';
try {
this.assertAccount(account);
if (controller.signal.aborted) throw controller.signal.reason;
current.status = 'running';
topic.revision++;
this.events.emit(key, structuredClone(topic));
let structuredText = '';
current.usage = await model.run(compiled.messages, controller.signal, (delta) => {
if (structuredReply) { structuredText += delta; return; }
current.response += delta;
@@ -664,12 +655,7 @@ export class CodingTeacherService {
this.events.emit(key, structuredClone(topic));
});
this.assertAccount(account);
if (!controller.signal.aborted && structuredReply) applyDiscussionReply(topic, current, structuredText);
if (!controller.signal.aborted && intent === 'suggestions') {
const suggestions = parseTeacherSuggestions(current.response);
current.response = suggestions.intro;
current.suggestedQuestions = suggestions.questions;
}
if (!controller.signal.aborted && structuredReply) applyTeacherReply(current, structuredText);
current.status = controller.signal.aborted ? 'cancelled' : 'completed';
} catch (error) {
current.status = controller.signal.aborted ? 'cancelled' : 'failed';
@@ -679,6 +665,9 @@ export class CodingTeacherService {
? error.message
: '智能体回复失败,已保留本次问题与收到的内容。';
} finally {
// Keep received bytes inert even when an aborted runner resolves normally.
if (structuredReply && current.status !== 'completed' && structuredText)
current.unparsedResponse = structuredText;
if (intent === 'suggestions' && current.status !== 'completed') {
current.response = '';
delete current.suggestedQuestions;
@@ -686,7 +675,6 @@ export class CodingTeacherService {
topic.updatedAt = new Date().toISOString();
topic.revision++;
try {
if (topic.conversation && topic.discussion) topic.conversation.discussions[scope.projectId] = topic.discussion;
await store.save(topic, current.id);
} catch {
topic.unsaved = true;
@@ -702,31 +690,6 @@ export class CodingTeacherService {
return structuredClone(topic);
});
}
async updateDiscussion(scope: TeacherScope, id: string, input: TeacherDiscussionAction) {
const account = await this.account();
this.assertWritable(scope);
const key = this.key(account, scope, id);
return await this.serialize(key, async () => {
const { store, topic } = await this.readOwned(account, scope, id);
if (this.active.has(key) || topic.requests.some(request => ['preparing', 'running'].includes(request.status)))
throw new TeacherError(409, 'teacher_topic_busy', '请等待智能体回复,或先停止。');
const next = structuredClone(topic);
if (next.conversation) {
if (!input.projectId) throw new TeacherError(422, 'teacher_project_required', '请选择讨论所属的项目。');
if (input.projectId !== scope.projectId) throw new TeacherError(422, 'teacher_project_mismatch', '讨论不属于当前项目。');
await this.options.projects.getProject(input.projectId);
next.discussion = next.conversation.discussions[input.projectId];
}
editDiscussion(next, input);
if (next.conversation && next.discussion) next.conversation.discussions[input.projectId!] = next.discussion;
next.revision++;
next.updatedAt = new Date().toISOString();
this.assertAccount(account);
await store.save(next, scope.agentId ? '' : undefined);
this.events.emit(key, structuredClone(next));
return structuredClone(next);
});
}
async cancel(scope: TeacherScope, id: string, requestId: string) {
const account = await this.account();
const { topic } = await this.readOwned(account, scope, id);