feat: integrate pixel teacher presence and resilient classroom preview

This commit is contained in:
鲨鱼辣椒 committed 2026-09-23 18:23:53 +08:00
1 parent 12d800ec51
commit 7951cca700
60 files changed
+3313 -180

No files matched your search

+54 -9
View File
@@ -52,6 +52,8 @@ export interface SubmitCodingPromptInput {
text?: string;
attachments?: CodingDraftAttachment[];
prepareAttachments?(): Promise<CodingDraftAttachment[]>;
/** Internal recovery uses durable conversation context, never the student's draft. */
preserveDraft?: boolean;
}
interface CodingConversationStoreDependencies {
@@ -167,18 +169,38 @@ function requestIdsInSnapshot(snapshot: ConversationSnapshot): Set<string> {
function withoutReconciledRequests(
requests: Record<string, CodingPromptRequestState> | undefined,
snapshot: ConversationSnapshot,
reconciled = requestIdsInSnapshot(snapshot),
): Record<string, CodingPromptRequestState> {
const reconciled = requestIdsInSnapshot(snapshot);
const requestUncertaintySettled = snapshot.run.status === 'idle' || snapshot.run.status === 'error';
if (!requests || (reconciled.size === 0 && !requestUncertaintySettled)) return requests ?? {};
return Object.fromEntries(
Object.entries(requests).filter(([clientRequestId, request]) => (
!reconciled.has(clientRequestId)
&& !(request.status === 'uncertain' && requestUncertaintySettled)
&& !acceptedRunHasSettled(request, snapshot)
)),
);
}
function acceptedRunHasSettled(request: CodingPromptRequestState, snapshot: ConversationSnapshot): boolean {
return request.status === 'accepted' && Boolean(request.runId)
&& request.runId === snapshot.run.runId && taskHasSettled(snapshot);
}
function withoutSettledOptimisticNodes(
reducer: ConversationReducerState,
requests: Record<string, CodingPromptRequestState> | undefined,
): ConversationReducerState {
const snapshot = reducer.snapshot;
if (!snapshot || !requests) return reducer;
const nodes = snapshot.nodes.filter((node) => {
if (node.kind !== 'message' || node.status !== 'optimistic' || !node.clientRequestId) return true;
const request = requests[node.clientRequestId];
return !request || !acceptedRunHasSettled(request, snapshot);
});
return nodes.length === snapshot.nodes.length ? reducer : replaceConversationSnapshot(reducer, { ...snapshot, nodes });
}
function withReconciledOptimisticNodeIds(
snapshot: ConversationSnapshot,
requests: Record<string, CodingPromptRequestState> | undefined,
@@ -784,7 +806,7 @@ export function createCodingConversationStore(
),
draftsByConversationId: {
...state.draftsByConversationId,
[input.conversationId]: { text: '', attachments: [], revision: clearedRevision },
...(input.preserveDraft ? {} : { [input.conversationId]: { text: '', attachments: [], revision: clearedRevision } }),
},
requestsByConversationId: {
...state.requestsByConversationId,
@@ -846,14 +868,28 @@ export function createCodingConversationStore(
const current = requests[clientRequestId];
if (!current) return state;
const nextRequests = { ...requests };
const entry = state.entriesByConversationId[input.conversationId];
let reducer = entry?.reducer;
// Queued messages are projected by the authoritative queue; only a
// prompt has an optimistic node awaiting clientRequestId reconciliation.
if (current.mode === 'prompt') {
nextRequests[clientRequestId] = { ...current, status: 'accepted' };
const accepted: CodingPromptRequestState = { ...current, status: 'accepted', runId: acceptance.runId };
if (reducer?.snapshot && acceptedRunHasSettled(accepted, reducer.snapshot)) {
delete nextRequests[clientRequestId];
reducer = withoutSettledOptimisticNodes(reducer, { [clientRequestId]: accepted });
} else {
nextRequests[clientRequestId] = accepted;
}
} else {
delete nextRequests[clientRequestId];
}
return {
...(entry && reducer !== entry.reducer ? {
entriesByConversationId: {
...state.entriesByConversationId,
[input.conversationId]: { ...entry, reducer: reducer! },
},
} : {}),
requestsByConversationId: {
...state.requestsByConversationId,
[input.conversationId]: nextRequests,
@@ -869,7 +905,7 @@ export function createCodingConversationStore(
const requests = state.requestsByConversationId[input.conversationId] ?? {};
const currentRequest = requests[clientRequestId] ?? request;
const currentDraft = state.draftsByConversationId[input.conversationId] ?? DEFAULT_DRAFT;
const canRestore = currentDraft.revision === clearedRevision
const canRestore = !input.preserveDraft && currentDraft.revision === clearedRevision
&& !currentDraft.text
&& currentDraft.attachments.length === 0;
const reducer = uncertain
@@ -937,7 +973,10 @@ export function createCodingConversationStore(
reconciledSnapshot,
);
const reducer = withUnreconciledOptimisticNodes(
replaceConversationSnapshot(current.reducer, reconciledSnapshot),
withoutSettledOptimisticNodes(
replaceConversationSnapshot(current.reducer, reconciledSnapshot),
state.requestsByConversationId[event.conversationId],
),
requests,
);
const entry: CodingConversationEntry = {
@@ -1022,6 +1061,16 @@ export function createCodingConversationStore(
}
if (reducer === current.reducer) return state;
applied = true;
const requests = state.requestsByConversationId[applicableEvent.conversationId] ?? {};
// The reduced snapshot also contains Renderer optimistic nodes. Only
// request ids delivered in this Main patch batch count as receipt evidence.
const authoritativeRequestIds = new Set(applicableEvent.items.flatMap(({ patch }) => (
patch.op === 'message.upsert' && patch.node.clientRequestId ? [patch.node.clientRequestId] : []
)));
const nextRequests = reducer.snapshot
? withoutReconciledRequests(requests, reducer.snapshot, authoritativeRequestIds)
: requests;
reducer = withoutSettledOptimisticNodes(reducer, requests);
const unread = state.selectedConversationId !== applicableEvent.conversationId
&& newlyNeedsUserAttention(current.reducer.snapshot, reducer.snapshot)
? true
@@ -1033,10 +1082,6 @@ export function createCodingConversationStore(
error: null,
unread,
};
const requests = state.requestsByConversationId[applicableEvent.conversationId] ?? {};
const nextRequests = reducer.snapshot
? withoutReconciledRequests(requests, reducer.snapshot)
: requests;
const requestsChanged = Object.keys(nextRequests).length !== Object.keys(requests).length;
return {
entriesByConversationId: {