1077 lines
38 KiB
TypeScript
1077 lines
38 KiB
TypeScript
import { useStore } from 'zustand';
|
|
import { createStore, type StoreApi } from 'zustand/vanilla';
|
|
import {
|
|
createConversationReducerState,
|
|
reduceConversationPatch,
|
|
replaceConversationSnapshot,
|
|
type ConversationReducerState,
|
|
} from '../../shared/coding-conversation-reducer';
|
|
import { AppError } from '@/lib/error-model';
|
|
import { queueCodingConversationSessionSync } from '@/lib/agent-session-sync';
|
|
import {
|
|
getCodingConversationSnapshot,
|
|
openCodingConversationEvents,
|
|
recoverCodingConversation,
|
|
submitCodingConversationPrompt,
|
|
type SubmitCodingConversationPromptInput,
|
|
} from '@/lib/coding-conversations';
|
|
import type {
|
|
CodingConversationDraft,
|
|
CodingConversationPatchBatchEvent,
|
|
CodingConversationSnapshotEvent,
|
|
CodingConversationSummary,
|
|
CodingDraftAttachment,
|
|
CodingPromptRequestState,
|
|
ConversationMessageNode,
|
|
ConversationPatchEnvelope,
|
|
ConversationSnapshot,
|
|
PromptAcceptance,
|
|
PromptMode,
|
|
} from '@/types/coding-conversation';
|
|
|
|
export type CodingConversationLoadState =
|
|
| 'empty'
|
|
| 'loading'
|
|
| 'live'
|
|
| 'recovering'
|
|
| 'error';
|
|
|
|
export type CodingConversationSnapshotLoadMode = 'loading' | 'recovering' | 'silent';
|
|
|
|
export interface CodingConversationEntry {
|
|
reducer: ConversationReducerState;
|
|
loadState: CodingConversationLoadState;
|
|
error: string | null;
|
|
unread: boolean;
|
|
}
|
|
|
|
export interface SubmitCodingPromptInput {
|
|
conversationId: string;
|
|
mode: PromptMode;
|
|
text?: string;
|
|
attachments?: CodingDraftAttachment[];
|
|
prepareAttachments?(): Promise<CodingDraftAttachment[]>;
|
|
}
|
|
|
|
interface CodingConversationStoreDependencies {
|
|
getSnapshot(conversationId: string): Promise<ConversationSnapshot>;
|
|
openEvents(conversationId?: string): Promise<EventSource>;
|
|
submitPrompt(input: SubmitCodingConversationPromptInput): Promise<PromptAcceptance>;
|
|
recover(conversationId: string): Promise<void>;
|
|
queueSettledSessionSync(snapshot: ConversationSnapshot): void;
|
|
createId(kind: 'request' | 'node'): string;
|
|
preparationTimeoutMs: number;
|
|
}
|
|
|
|
export interface CodingConversationStoreState {
|
|
selectedConversationId: string | null;
|
|
entriesByConversationId: Record<string, CodingConversationEntry>;
|
|
summariesByConversationId: Record<string, CodingConversationSummary>;
|
|
draftsByConversationId: Record<string, CodingConversationDraft>;
|
|
requestsByConversationId: Record<string, Record<string, CodingPromptRequestState>>;
|
|
connectionState: 'disconnected' | 'connecting' | 'live' | 'reconnecting' | 'error';
|
|
globalError: string | null;
|
|
primeConversation(snapshot: ConversationSnapshot): void;
|
|
clearConversationSelection(): void;
|
|
selectConversation(conversationId: string): Promise<void>;
|
|
loadSnapshot(
|
|
conversationId: string,
|
|
mode?: CodingConversationSnapshotLoadMode,
|
|
): Promise<ConversationSnapshot>;
|
|
recoverConversation(conversationId: string): Promise<void>;
|
|
connectEvents(): Promise<void>;
|
|
disconnectEvents(): void;
|
|
setDraft(conversationId: string, text: string, attachments?: CodingDraftAttachment[]): void;
|
|
markUnread(conversationId: string, unread: boolean): void;
|
|
submitPrompt(input: SubmitCodingPromptInput): Promise<PromptAcceptance>;
|
|
applySnapshotEvent(event: CodingConversationSnapshotEvent): void;
|
|
applyPatchBatchEvent(event: CodingConversationPatchBatchEvent): void;
|
|
}
|
|
|
|
const DEFAULT_DRAFT: CodingConversationDraft = {
|
|
text: '',
|
|
attachments: [],
|
|
revision: 0,
|
|
};
|
|
const EMPTY_REQUESTS: Record<string, CodingPromptRequestState> = {};
|
|
|
|
function cloneDraft(draft: CodingConversationDraft): CodingConversationDraft {
|
|
return {
|
|
text: draft.text,
|
|
attachments: draft.attachments.map((attachment) => ({ ...attachment })),
|
|
revision: draft.revision,
|
|
};
|
|
}
|
|
|
|
function emptyEntry(): CodingConversationEntry {
|
|
return {
|
|
reducer: createConversationReducerState(),
|
|
loadState: 'empty',
|
|
error: null,
|
|
unread: false,
|
|
};
|
|
}
|
|
|
|
function errorDetails(error: unknown): { code?: string; message: string } {
|
|
if (error instanceof AppError) {
|
|
return {
|
|
...(typeof error.details?.backendCode === 'string'
|
|
? { code: error.details.backendCode }
|
|
: {}),
|
|
message: error.message,
|
|
};
|
|
}
|
|
return { message: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
|
|
function summaryOf(entry: CodingConversationEntry): CodingConversationSummary | null {
|
|
const snapshot = entry.reducer.snapshot;
|
|
if (!snapshot) return null;
|
|
return {
|
|
conversationId: snapshot.conversation.id,
|
|
title: snapshot.conversation.title,
|
|
runStatus: snapshot.run.status,
|
|
runError: snapshot.run.error ?? null,
|
|
workerStatus: snapshot.worker.status,
|
|
model: snapshot.conversation.model,
|
|
unread: entry.unread,
|
|
};
|
|
}
|
|
|
|
function summariesWithEntry(
|
|
summaries: Record<string, CodingConversationSummary>,
|
|
entry: CodingConversationEntry,
|
|
): Record<string, CodingConversationSummary> {
|
|
const next = summaryOf(entry);
|
|
if (!next) return summaries;
|
|
const current = summaries[next.conversationId];
|
|
if (current
|
|
&& current.title === next.title
|
|
&& current.runStatus === next.runStatus
|
|
&& current.runError === next.runError
|
|
&& current.workerStatus === next.workerStatus
|
|
&& current.model === next.model
|
|
&& current.unread === next.unread) {
|
|
return summaries;
|
|
}
|
|
return { ...summaries, [next.conversationId]: next };
|
|
}
|
|
|
|
function requestIdsInSnapshot(snapshot: ConversationSnapshot): Set<string> {
|
|
return new Set(snapshot.nodes.flatMap((node) => (
|
|
node.kind === 'message' && node.clientRequestId ? [node.clientRequestId] : []
|
|
)));
|
|
}
|
|
|
|
function withoutReconciledRequests(
|
|
requests: Record<string, CodingPromptRequestState> | undefined,
|
|
snapshot: ConversationSnapshot,
|
|
): 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)
|
|
)),
|
|
);
|
|
}
|
|
|
|
function withReconciledOptimisticNodeIds(
|
|
snapshot: ConversationSnapshot,
|
|
requests: Record<string, CodingPromptRequestState> | undefined,
|
|
): ConversationSnapshot {
|
|
if (!requests) return snapshot;
|
|
let changed = false;
|
|
const nodes = snapshot.nodes.map((node) => {
|
|
if (node.kind !== 'message' || !node.clientRequestId) return node;
|
|
const nodeId = requests[node.clientRequestId]?.nodeId;
|
|
if (!nodeId || node.id === nodeId) return node;
|
|
changed = true;
|
|
return { ...node, id: nodeId };
|
|
});
|
|
return changed ? { ...snapshot, nodes } : snapshot;
|
|
}
|
|
|
|
function optimisticNode(
|
|
nodeId: string,
|
|
clientRequestId: string,
|
|
draft: CodingConversationDraft,
|
|
): ConversationMessageNode {
|
|
return {
|
|
kind: 'message',
|
|
id: nodeId,
|
|
clientRequestId,
|
|
role: 'user',
|
|
status: 'optimistic',
|
|
blocks: [
|
|
...(draft.text ? [{
|
|
kind: 'text' as const,
|
|
id: `${nodeId}:text`,
|
|
text: draft.text,
|
|
status: 'complete' as const,
|
|
}] : []),
|
|
...draft.attachments.map((attachment, index) => ({
|
|
kind: 'image' as const,
|
|
id: `${nodeId}:attachment:${index}`,
|
|
attachmentId: attachment.attachmentId,
|
|
mime: attachment.mime,
|
|
})),
|
|
],
|
|
};
|
|
}
|
|
|
|
function withOptimisticNode(
|
|
reducer: ConversationReducerState,
|
|
node: ConversationMessageNode,
|
|
): ConversationReducerState {
|
|
const snapshot = reducer.snapshot;
|
|
if (!snapshot) return reducer;
|
|
return replaceConversationSnapshot(reducer, {
|
|
...snapshot,
|
|
nodes: [...snapshot.nodes, node],
|
|
});
|
|
}
|
|
|
|
function withRejectedNode(
|
|
reducer: ConversationReducerState,
|
|
nodeId: string | undefined,
|
|
): ConversationReducerState {
|
|
const snapshot = reducer.snapshot;
|
|
if (!snapshot || !nodeId) return reducer;
|
|
return replaceConversationSnapshot(reducer, {
|
|
...snapshot,
|
|
nodes: snapshot.nodes.map((node) => (
|
|
node.kind === 'message' && node.id === nodeId
|
|
? { ...node, status: 'error' as const }
|
|
: node
|
|
)),
|
|
});
|
|
}
|
|
|
|
function withUnreconciledOptimisticNodes(
|
|
reducer: ConversationReducerState,
|
|
requests: Record<string, CodingPromptRequestState>,
|
|
): ConversationReducerState {
|
|
const snapshot = reducer.snapshot;
|
|
if (!snapshot) return reducer;
|
|
const existingRequestIds = requestIdsInSnapshot(snapshot);
|
|
const nodes = Object.values(requests).flatMap((request) => (
|
|
request.nodeId
|
|
&& request.status !== 'rejected'
|
|
&& !existingRequestIds.has(request.clientRequestId)
|
|
? [optimisticNode(request.nodeId, request.clientRequestId, request.submittedDraft)]
|
|
: []
|
|
));
|
|
if (nodes.length === 0) return reducer;
|
|
return replaceConversationSnapshot(reducer, {
|
|
...snapshot,
|
|
nodes: [...snapshot.nodes, ...nodes],
|
|
});
|
|
}
|
|
|
|
function snapshotIsOlder(
|
|
current: ConversationSnapshot | null,
|
|
incoming: ConversationSnapshot,
|
|
): boolean {
|
|
if (!current) return false;
|
|
if (incoming.cursor.workerGeneration !== current.cursor.workerGeneration) {
|
|
return incoming.cursor.workerGeneration < current.cursor.workerGeneration;
|
|
}
|
|
return incoming.cursor.seq < current.cursor.seq;
|
|
}
|
|
|
|
function defaultDependencies(): CodingConversationStoreDependencies {
|
|
return {
|
|
getSnapshot: getCodingConversationSnapshot,
|
|
openEvents: openCodingConversationEvents,
|
|
submitPrompt: submitCodingConversationPrompt,
|
|
recover: recoverCodingConversation,
|
|
queueSettledSessionSync: queueCodingConversationSessionSync,
|
|
createId: () => crypto.randomUUID(),
|
|
preparationTimeoutMs: 10_000,
|
|
};
|
|
}
|
|
|
|
function withPreparedOptimisticNode(
|
|
reducer: ConversationReducerState,
|
|
nodeId: string | undefined,
|
|
clientRequestId: string,
|
|
draft: CodingConversationDraft,
|
|
): ConversationReducerState {
|
|
const snapshot = reducer.snapshot;
|
|
if (!snapshot || !nodeId) return reducer;
|
|
return replaceConversationSnapshot(reducer, {
|
|
...snapshot,
|
|
nodes: snapshot.nodes.map((node) => (
|
|
node.kind === 'message' && node.id === nodeId
|
|
? optimisticNode(nodeId, clientRequestId, draft)
|
|
: node
|
|
)),
|
|
});
|
|
}
|
|
|
|
function withPreparationDeadline<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
return new Promise<T>((resolve, reject) => {
|
|
timer = setTimeout(() => {
|
|
reject(new Error('本地 Agent 准备超时,请重试。'));
|
|
}, timeoutMs);
|
|
promise.then(resolve, reject);
|
|
}).finally(() => {
|
|
if (timer !== undefined) clearTimeout(timer);
|
|
});
|
|
}
|
|
|
|
function validPatchBatchRange(event: CodingConversationPatchBatchEvent): boolean {
|
|
if (event.type !== 'patch-batch'
|
|
|| !event.conversationId
|
|
|| !Number.isSafeInteger(event.workerGeneration)
|
|
|| event.workerGeneration < 0
|
|
|| !Array.isArray(event.items)
|
|
|| event.items.length === 0
|
|
|| event.fromSeq !== event.items[0]?.seq
|
|
|| event.toSeq !== event.items.at(-1)?.seq) {
|
|
return false;
|
|
}
|
|
return event.items.every((item, index) => (
|
|
item !== null
|
|
&& typeof item === 'object'
|
|
&& Number.isSafeInteger(item.seq)
|
|
&& item.seq >= 0
|
|
&& (index === 0 || item.seq === event.items[index - 1].seq + 1)
|
|
));
|
|
}
|
|
|
|
function completesOrdinaryPrompt(
|
|
event: CodingConversationPatchBatchEvent,
|
|
current: ConversationSnapshot,
|
|
): boolean {
|
|
let mode = current.run.mode;
|
|
let observedActiveRun = current.run.status !== 'idle';
|
|
for (const item of event.items) {
|
|
if (item.patch.op !== 'run.state') continue;
|
|
if (item.patch.run.mode) mode = item.patch.run.mode;
|
|
if (item.patch.run.status !== 'idle') observedActiveRun = true;
|
|
if (observedActiveRun
|
|
&& mode === 'prompt'
|
|
&& item.patch.run.status === 'idle'
|
|
&& item.patch.run.terminalReason === 'completed') {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function taskHasSettled(snapshot: ConversationSnapshot): boolean {
|
|
return snapshot.run.status === 'error'
|
|
|| (snapshot.run.status === 'idle' && snapshot.run.terminalReason !== undefined);
|
|
}
|
|
|
|
function newlyNeedsUserAttention(
|
|
current: ConversationSnapshot | null,
|
|
next: ConversationSnapshot | null,
|
|
): boolean {
|
|
if (!next) return false;
|
|
const currentPendingInteractions = new Set(
|
|
current?.pendingInteractions
|
|
.filter((interaction) => interaction.status === 'pending')
|
|
.map((interaction) => interaction.id) ?? [],
|
|
);
|
|
if (next.pendingInteractions.some((interaction) => (
|
|
interaction.status === 'pending' && !currentPendingInteractions.has(interaction.id)
|
|
))) {
|
|
return true;
|
|
}
|
|
if (!taskHasSettled(next)) return false;
|
|
return !current
|
|
|| !taskHasSettled(current)
|
|
|| current.run.runId !== next.run.runId
|
|
|| current.run.terminalReason !== next.run.terminalReason;
|
|
}
|
|
|
|
export function createCodingConversationStore(
|
|
dependencies: Partial<CodingConversationStoreDependencies> = {},
|
|
): StoreApi<CodingConversationStoreState> {
|
|
const deps = { ...defaultDependencies(), ...dependencies };
|
|
const snapshotLoads = new Map<string, Promise<ConversationSnapshot>>();
|
|
const recoveryBatches = new Map<string, CodingConversationPatchBatchEvent[]>();
|
|
const recoveryRefreshKeys = new Map<string, string>();
|
|
let eventSource: EventSource | null = null;
|
|
let connectFlight: Promise<void> | null = null;
|
|
let connectionGeneration = 0;
|
|
let store!: StoreApi<CodingConversationStoreState>;
|
|
|
|
const bufferRecoveryBatch = (event: CodingConversationPatchBatchEvent) => {
|
|
const buffered = recoveryBatches.get(event.conversationId) ?? [];
|
|
if (buffered.some((candidate) => (
|
|
candidate.workerGeneration === event.workerGeneration
|
|
&& candidate.fromSeq === event.fromSeq
|
|
&& candidate.toSeq === event.toSeq
|
|
))) return;
|
|
recoveryBatches.set(
|
|
event.conversationId,
|
|
[...buffered, event].sort((left, right) => (
|
|
left.workerGeneration - right.workerGeneration || left.fromSeq - right.fromSeq
|
|
)),
|
|
);
|
|
};
|
|
|
|
const replayRecoveryBatches = (conversationId: string) => {
|
|
const buffered = recoveryBatches.get(conversationId);
|
|
if (!buffered?.length) return;
|
|
const snapshot = selectCodingConversationSnapshot(conversationId)(store.getState());
|
|
if (!snapshot) return;
|
|
const itemsBySeq = new Map<number, CodingConversationPatchBatchEvent['items'][number]>();
|
|
for (const batch of buffered) {
|
|
if (batch.workerGeneration !== snapshot.cursor.workerGeneration) continue;
|
|
for (const item of batch.items) {
|
|
if (item.seq > snapshot.cursor.seq && !itemsBySeq.has(item.seq)) {
|
|
itemsBySeq.set(item.seq, item);
|
|
}
|
|
}
|
|
}
|
|
const items = [...itemsBySeq.values()].sort((left, right) => left.seq - right.seq);
|
|
if (items.length === 0) {
|
|
recoveryBatches.delete(conversationId);
|
|
recoveryRefreshKeys.delete(conversationId);
|
|
return;
|
|
}
|
|
const continuous = items[0].seq === snapshot.cursor.seq + 1
|
|
&& items.every((item, index) => index === 0 || item.seq === items[index - 1].seq + 1);
|
|
if (!continuous) {
|
|
const lastSeq = items.at(-1)?.seq ?? items[0].seq;
|
|
const refreshKey = [
|
|
snapshot.cursor.workerGeneration,
|
|
snapshot.cursor.seq,
|
|
items[0].seq,
|
|
lastSeq,
|
|
].join(':');
|
|
const willRefresh = recoveryRefreshKeys.get(conversationId) !== refreshKey;
|
|
store.setState((state) => {
|
|
const current = state.entriesByConversationId[conversationId] ?? emptyEntry();
|
|
return {
|
|
entriesByConversationId: {
|
|
...state.entriesByConversationId,
|
|
[conversationId]: {
|
|
...current,
|
|
loadState: willRefresh ? 'recovering' : 'error',
|
|
error: 'Conversation patch batch is not continuous',
|
|
},
|
|
},
|
|
};
|
|
});
|
|
if (willRefresh) {
|
|
recoveryRefreshKeys.set(conversationId, refreshKey);
|
|
queueMicrotask(() => {
|
|
if (!snapshotLoads.has(conversationId)) {
|
|
void store.getState().loadSnapshot(conversationId, 'recovering').catch(() => undefined);
|
|
}
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
recoveryBatches.delete(conversationId);
|
|
recoveryRefreshKeys.delete(conversationId);
|
|
store.getState().applyPatchBatchEvent({
|
|
type: 'patch-batch',
|
|
conversationId,
|
|
workerGeneration: snapshot.cursor.workerGeneration,
|
|
fromSeq: items[0].seq,
|
|
toSeq: items.at(-1)?.seq ?? items[0].seq,
|
|
items,
|
|
});
|
|
};
|
|
|
|
store = createStore<CodingConversationStoreState>((set, get) => ({
|
|
selectedConversationId: null,
|
|
entriesByConversationId: {},
|
|
summariesByConversationId: {},
|
|
draftsByConversationId: {},
|
|
requestsByConversationId: {},
|
|
connectionState: 'disconnected',
|
|
globalError: null,
|
|
|
|
primeConversation(snapshot) {
|
|
const conversationId = snapshot.conversation.id;
|
|
set((state) => {
|
|
const current = state.entriesByConversationId[conversationId];
|
|
if (current?.loadState === 'live' || current?.reducer.invalidation) return state;
|
|
const entry: CodingConversationEntry = {
|
|
...(current ?? emptyEntry()),
|
|
reducer: replaceConversationSnapshot(
|
|
current?.reducer ?? createConversationReducerState(),
|
|
snapshot,
|
|
),
|
|
loadState: current?.loadState ?? 'empty',
|
|
error: null,
|
|
};
|
|
return {
|
|
entriesByConversationId: {
|
|
...state.entriesByConversationId,
|
|
[conversationId]: entry,
|
|
},
|
|
summariesByConversationId: summariesWithEntry(
|
|
state.summariesByConversationId,
|
|
entry,
|
|
),
|
|
};
|
|
});
|
|
},
|
|
|
|
clearConversationSelection() {
|
|
set({ selectedConversationId: null });
|
|
},
|
|
|
|
async selectConversation(conversationId) {
|
|
set((state) => {
|
|
const current = state.entriesByConversationId[conversationId] ?? emptyEntry();
|
|
const entry = current.unread ? { ...current, unread: false } : current;
|
|
return {
|
|
selectedConversationId: conversationId,
|
|
entriesByConversationId: {
|
|
...state.entriesByConversationId,
|
|
[conversationId]: entry,
|
|
},
|
|
summariesByConversationId: summariesWithEntry(state.summariesByConversationId, entry),
|
|
};
|
|
});
|
|
const entry = get().entriesByConversationId[conversationId];
|
|
const snapshotFlight = !entry?.reducer.snapshot
|
|
|| entry.reducer.invalidation
|
|
|| entry.loadState !== 'live'
|
|
? get().loadSnapshot(
|
|
conversationId,
|
|
entry?.reducer.invalidation ? 'recovering' : 'loading',
|
|
)
|
|
: Promise.resolve(entry.reducer.snapshot);
|
|
await Promise.all([snapshotFlight, get().connectEvents()]);
|
|
},
|
|
|
|
loadSnapshot(conversationId, mode = 'loading') {
|
|
const prior = snapshotLoads.get(conversationId);
|
|
if (prior) return prior;
|
|
if (mode !== 'silent') {
|
|
set((state) => {
|
|
const entry = state.entriesByConversationId[conversationId] ?? emptyEntry();
|
|
return {
|
|
entriesByConversationId: {
|
|
...state.entriesByConversationId,
|
|
[conversationId]: {
|
|
...entry,
|
|
loadState: mode,
|
|
error: null,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
}
|
|
const flight = withPreparationDeadline(
|
|
deps.getSnapshot(conversationId),
|
|
deps.preparationTimeoutMs,
|
|
)
|
|
.then((snapshot) => {
|
|
get().applySnapshotEvent({
|
|
type: 'snapshot',
|
|
conversationId,
|
|
workerGeneration: snapshot.cursor.workerGeneration,
|
|
seq: snapshot.cursor.seq,
|
|
snapshot,
|
|
});
|
|
return snapshot;
|
|
})
|
|
.catch((error) => {
|
|
if (mode !== 'silent') {
|
|
const failure = errorDetails(error);
|
|
set((state) => {
|
|
const entry = state.entriesByConversationId[conversationId] ?? emptyEntry();
|
|
return {
|
|
entriesByConversationId: {
|
|
...state.entriesByConversationId,
|
|
[conversationId]: { ...entry, loadState: 'error', error: failure.message },
|
|
},
|
|
};
|
|
});
|
|
}
|
|
throw error;
|
|
})
|
|
.finally(() => {
|
|
if (snapshotLoads.get(conversationId) === flight) snapshotLoads.delete(conversationId);
|
|
const entry = get().entriesByConversationId[conversationId];
|
|
if (!entry?.reducer.invalidation) replayRecoveryBatches(conversationId);
|
|
});
|
|
snapshotLoads.set(conversationId, flight);
|
|
return flight;
|
|
},
|
|
|
|
async recoverConversation(conversationId) {
|
|
recoveryRefreshKeys.delete(conversationId);
|
|
set((state) => {
|
|
const entry = state.entriesByConversationId[conversationId] ?? emptyEntry();
|
|
return {
|
|
entriesByConversationId: {
|
|
...state.entriesByConversationId,
|
|
[conversationId]: { ...entry, loadState: 'recovering', error: null },
|
|
},
|
|
};
|
|
});
|
|
try {
|
|
await deps.recover(conversationId);
|
|
await get().loadSnapshot(conversationId, 'recovering');
|
|
} catch (error) {
|
|
const failure = errorDetails(error);
|
|
set((state) => {
|
|
const entry = state.entriesByConversationId[conversationId] ?? emptyEntry();
|
|
return {
|
|
entriesByConversationId: {
|
|
...state.entriesByConversationId,
|
|
[conversationId]: { ...entry, loadState: 'error', error: failure.message },
|
|
},
|
|
};
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
async connectEvents() {
|
|
if (eventSource) return;
|
|
if (connectFlight) return await connectFlight;
|
|
const generation = ++connectionGeneration;
|
|
set({ connectionState: 'connecting', globalError: null });
|
|
let flight: Promise<void>;
|
|
flight = deps.openEvents()
|
|
.then((source) => {
|
|
if (generation !== connectionGeneration) {
|
|
source.close();
|
|
return;
|
|
}
|
|
eventSource = source;
|
|
const parse = <T,>(event: Event): T => JSON.parse((event as MessageEvent<string>).data) as T;
|
|
source.addEventListener('snapshot', (event) => {
|
|
try {
|
|
get().applySnapshotEvent(parse<CodingConversationSnapshotEvent>(event));
|
|
} catch {
|
|
set({ connectionState: 'error', globalError: 'Conversation 事件快照无法读取。' });
|
|
}
|
|
});
|
|
source.addEventListener('patch-batch', (event) => {
|
|
try {
|
|
get().applyPatchBatchEvent(parse<CodingConversationPatchBatchEvent>(event));
|
|
} catch {
|
|
set({ connectionState: 'error', globalError: 'Conversation 事件更新无法读取。' });
|
|
}
|
|
});
|
|
source.onopen = () => {
|
|
if (eventSource === source) set({ connectionState: 'live', globalError: null });
|
|
};
|
|
source.onerror = () => {
|
|
if (eventSource === source) {
|
|
set({
|
|
connectionState: 'reconnecting',
|
|
globalError: 'Conversation 事件连接中断,正在重连。',
|
|
});
|
|
}
|
|
};
|
|
})
|
|
.catch((error) => {
|
|
if (generation !== connectionGeneration) return;
|
|
set({ connectionState: 'error', globalError: errorDetails(error).message });
|
|
throw error;
|
|
})
|
|
.finally(() => {
|
|
if (connectFlight === flight) connectFlight = null;
|
|
});
|
|
connectFlight = flight;
|
|
await flight;
|
|
},
|
|
|
|
disconnectEvents() {
|
|
connectionGeneration += 1;
|
|
eventSource?.close();
|
|
eventSource = null;
|
|
connectFlight = null;
|
|
set({ connectionState: 'disconnected', globalError: null });
|
|
},
|
|
|
|
setDraft(conversationId, text, attachments = []) {
|
|
set((state) => {
|
|
const current = state.draftsByConversationId[conversationId] ?? DEFAULT_DRAFT;
|
|
return {
|
|
draftsByConversationId: {
|
|
...state.draftsByConversationId,
|
|
[conversationId]: {
|
|
text,
|
|
attachments: attachments.map((attachment) => ({ ...attachment })),
|
|
revision: current.revision + 1,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
markUnread(conversationId, unread) {
|
|
set((state) => {
|
|
const current = state.entriesByConversationId[conversationId] ?? emptyEntry();
|
|
if (current.unread === unread) return state;
|
|
const entry = { ...current, unread };
|
|
return {
|
|
entriesByConversationId: {
|
|
...state.entriesByConversationId,
|
|
[conversationId]: entry,
|
|
},
|
|
summariesByConversationId: summariesWithEntry(state.summariesByConversationId, entry),
|
|
};
|
|
});
|
|
},
|
|
|
|
async submitPrompt(input) {
|
|
let entry = get().entriesByConversationId[input.conversationId];
|
|
if (!entry?.reducer.snapshot || entry.reducer.invalidation) {
|
|
await get().loadSnapshot(
|
|
input.conversationId,
|
|
entry?.reducer.invalidation ? 'recovering' : 'loading',
|
|
);
|
|
entry = get().entriesByConversationId[input.conversationId];
|
|
}
|
|
if (!entry?.reducer.snapshot) throw new Error('Conversation snapshot is unavailable');
|
|
|
|
const existingDraft = get().draftsByConversationId[input.conversationId] ?? DEFAULT_DRAFT;
|
|
const submittedDraft: CodingConversationDraft = {
|
|
text: input.text ?? existingDraft.text,
|
|
attachments: (input.attachments ?? existingDraft.attachments).map((attachment) => ({ ...attachment })),
|
|
revision: existingDraft.revision,
|
|
};
|
|
const clientRequestId = deps.createId('request');
|
|
const nodeId = input.mode === 'prompt' ? deps.createId('node') : undefined;
|
|
const clearedRevision = existingDraft.revision + 1;
|
|
const request: CodingPromptRequestState = {
|
|
clientRequestId,
|
|
mode: input.mode,
|
|
...(nodeId ? { nodeId } : {}),
|
|
status: 'pending',
|
|
submittedDraft: cloneDraft(submittedDraft),
|
|
};
|
|
|
|
set((state) => {
|
|
const currentEntry = state.entriesByConversationId[input.conversationId] ?? emptyEntry();
|
|
const reducer = nodeId
|
|
? withOptimisticNode(
|
|
currentEntry.reducer,
|
|
optimisticNode(nodeId, clientRequestId, submittedDraft),
|
|
)
|
|
: currentEntry.reducer;
|
|
const nextEntry = { ...currentEntry, reducer, error: null };
|
|
return {
|
|
entriesByConversationId: {
|
|
...state.entriesByConversationId,
|
|
[input.conversationId]: nextEntry,
|
|
},
|
|
summariesByConversationId: summariesWithEntry(
|
|
state.summariesByConversationId,
|
|
nextEntry,
|
|
),
|
|
draftsByConversationId: {
|
|
...state.draftsByConversationId,
|
|
[input.conversationId]: { text: '', attachments: [], revision: clearedRevision },
|
|
},
|
|
requestsByConversationId: {
|
|
...state.requestsByConversationId,
|
|
[input.conversationId]: {
|
|
...(state.requestsByConversationId[input.conversationId] ?? {}),
|
|
[clientRequestId]: request,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
try {
|
|
if (input.prepareAttachments) {
|
|
submittedDraft.attachments = (await input.prepareAttachments())
|
|
.map((attachment) => ({ ...attachment }));
|
|
set((state) => {
|
|
const currentEntry = state.entriesByConversationId[input.conversationId] ?? emptyEntry();
|
|
const requests = state.requestsByConversationId[input.conversationId] ?? {};
|
|
const currentRequest = requests[clientRequestId];
|
|
if (!currentRequest) return state;
|
|
const reducer = withPreparedOptimisticNode(
|
|
currentEntry.reducer,
|
|
nodeId,
|
|
clientRequestId,
|
|
submittedDraft,
|
|
);
|
|
const nextEntry = { ...currentEntry, reducer };
|
|
return {
|
|
entriesByConversationId: {
|
|
...state.entriesByConversationId,
|
|
[input.conversationId]: nextEntry,
|
|
},
|
|
summariesByConversationId: summariesWithEntry(
|
|
state.summariesByConversationId,
|
|
nextEntry,
|
|
),
|
|
requestsByConversationId: {
|
|
...state.requestsByConversationId,
|
|
[input.conversationId]: {
|
|
...requests,
|
|
[clientRequestId]: {
|
|
...currentRequest,
|
|
submittedDraft: cloneDraft(submittedDraft),
|
|
},
|
|
},
|
|
},
|
|
};
|
|
});
|
|
}
|
|
const acceptance = await deps.submitPrompt({
|
|
conversationId: input.conversationId,
|
|
clientRequestId,
|
|
mode: input.mode,
|
|
text: submittedDraft.text,
|
|
attachments: submittedDraft.attachments.map(({ attachmentId }) => ({ attachmentId })),
|
|
});
|
|
set((state) => {
|
|
const requests = state.requestsByConversationId[input.conversationId] ?? {};
|
|
const current = requests[clientRequestId];
|
|
if (!current) return state;
|
|
return {
|
|
requestsByConversationId: {
|
|
...state.requestsByConversationId,
|
|
[input.conversationId]: {
|
|
...requests,
|
|
[clientRequestId]: { ...current, status: 'accepted' },
|
|
},
|
|
},
|
|
};
|
|
});
|
|
return acceptance;
|
|
} catch (error) {
|
|
const failure = errorDetails(error);
|
|
const uncertain = failure.code === 'CODING_REQUEST_UNCERTAIN';
|
|
set((state) => {
|
|
const currentEntry = state.entriesByConversationId[input.conversationId] ?? emptyEntry();
|
|
const requests = state.requestsByConversationId[input.conversationId] ?? {};
|
|
const currentRequest = requests[clientRequestId] ?? request;
|
|
const currentDraft = state.draftsByConversationId[input.conversationId] ?? DEFAULT_DRAFT;
|
|
const canRestore = currentDraft.revision === clearedRevision
|
|
&& !currentDraft.text
|
|
&& currentDraft.attachments.length === 0;
|
|
const reducer = uncertain
|
|
? currentEntry.reducer
|
|
: withRejectedNode(currentEntry.reducer, nodeId);
|
|
const nextEntry = {
|
|
...currentEntry,
|
|
reducer,
|
|
};
|
|
return {
|
|
entriesByConversationId: {
|
|
...state.entriesByConversationId,
|
|
[input.conversationId]: nextEntry,
|
|
},
|
|
summariesByConversationId: summariesWithEntry(
|
|
state.summariesByConversationId,
|
|
nextEntry,
|
|
),
|
|
draftsByConversationId: canRestore
|
|
? {
|
|
...state.draftsByConversationId,
|
|
[input.conversationId]: {
|
|
...cloneDraft(submittedDraft),
|
|
revision: clearedRevision + 1,
|
|
},
|
|
}
|
|
: state.draftsByConversationId,
|
|
requestsByConversationId: {
|
|
...state.requestsByConversationId,
|
|
[input.conversationId]: {
|
|
...requests,
|
|
[clientRequestId]: {
|
|
...currentRequest,
|
|
status: uncertain ? 'uncertain' : 'rejected',
|
|
...(failure.code ? { errorCode: failure.code } : {}),
|
|
errorMessage: failure.message,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
applySnapshotEvent(event) {
|
|
const snapshot = event.snapshot;
|
|
if (event.type !== 'snapshot'
|
|
|| event.conversationId !== snapshot.conversation.id
|
|
|| event.workerGeneration !== snapshot.cursor.workerGeneration
|
|
|| event.seq !== snapshot.cursor.seq) {
|
|
return;
|
|
}
|
|
set((state) => {
|
|
const current = state.entriesByConversationId[event.conversationId] ?? emptyEntry();
|
|
if (!current.reducer.invalidation && snapshotIsOlder(current.reducer.snapshot, snapshot)) {
|
|
return state;
|
|
}
|
|
const reconciledSnapshot = withReconciledOptimisticNodeIds(
|
|
snapshot,
|
|
state.requestsByConversationId[event.conversationId],
|
|
);
|
|
const requests = withoutReconciledRequests(
|
|
state.requestsByConversationId[event.conversationId],
|
|
reconciledSnapshot,
|
|
);
|
|
const reducer = withUnreconciledOptimisticNodes(
|
|
replaceConversationSnapshot(current.reducer, reconciledSnapshot),
|
|
requests,
|
|
);
|
|
const entry: CodingConversationEntry = {
|
|
...current,
|
|
reducer,
|
|
loadState: reducer.invalidation ? 'error' : 'live',
|
|
error: reducer.invalidation?.reason ?? null,
|
|
unread: state.selectedConversationId === event.conversationId ? false : current.unread,
|
|
};
|
|
return {
|
|
entriesByConversationId: {
|
|
...state.entriesByConversationId,
|
|
[event.conversationId]: entry,
|
|
},
|
|
summariesByConversationId: summariesWithEntry(state.summariesByConversationId, entry),
|
|
requestsByConversationId: {
|
|
...state.requestsByConversationId,
|
|
[event.conversationId]: requests,
|
|
},
|
|
};
|
|
});
|
|
if (!snapshotLoads.has(event.conversationId)) {
|
|
replayRecoveryBatches(event.conversationId);
|
|
}
|
|
},
|
|
|
|
applyPatchBatchEvent(event) {
|
|
const currentBeforeBatch = get().entriesByConversationId[event.conversationId];
|
|
const currentSnapshot = currentBeforeBatch?.reducer.snapshot;
|
|
if (currentSnapshot && event.workerGeneration < currentSnapshot.cursor.workerGeneration) return;
|
|
const validRange = validPatchBatchRange(event);
|
|
const recovering = currentBeforeBatch?.loadState === 'recovering'
|
|
|| Boolean(currentBeforeBatch?.reducer.invalidation);
|
|
if (!validRange) {
|
|
if (!snapshotLoads.has(event.conversationId)) {
|
|
void get().loadSnapshot(event.conversationId, 'recovering').catch(() => undefined);
|
|
}
|
|
return;
|
|
}
|
|
let applicableEvent = event;
|
|
if (currentSnapshot
|
|
&& event.workerGeneration === currentSnapshot.cursor.workerGeneration
|
|
&& event.fromSeq <= currentSnapshot.cursor.seq) {
|
|
const unseenItems = event.items.filter((item) => item.seq > currentSnapshot.cursor.seq);
|
|
if (unseenItems.length === 0) return;
|
|
applicableEvent = {
|
|
...event,
|
|
fromSeq: unseenItems[0]!.seq,
|
|
toSeq: unseenItems.at(-1)!.seq,
|
|
items: unseenItems,
|
|
};
|
|
}
|
|
if (recovering
|
|
|| !currentSnapshot
|
|
|| applicableEvent.workerGeneration !== currentSnapshot.cursor.workerGeneration
|
|
|| applicableEvent.fromSeq !== currentSnapshot.cursor.seq + 1) {
|
|
bufferRecoveryBatch(applicableEvent);
|
|
if (!snapshotLoads.has(applicableEvent.conversationId)) {
|
|
void get().loadSnapshot(applicableEvent.conversationId, 'recovering').catch(() => undefined);
|
|
}
|
|
return;
|
|
}
|
|
const shouldSyncSettledSession = completesOrdinaryPrompt(applicableEvent, currentSnapshot);
|
|
let recover = false;
|
|
let applied = false;
|
|
set((state) => {
|
|
const current = state.entriesByConversationId[applicableEvent.conversationId] ?? emptyEntry();
|
|
let reducer = current.reducer;
|
|
for (const item of applicableEvent.items) {
|
|
reducer = reduceConversationPatch(reducer, {
|
|
conversationId: applicableEvent.conversationId,
|
|
workerGeneration: applicableEvent.workerGeneration,
|
|
...(item.runId ? { runId: item.runId } : {}),
|
|
seq: item.seq,
|
|
at: item.at,
|
|
patch: item.patch,
|
|
} satisfies ConversationPatchEnvelope);
|
|
if (reducer.invalidation) {
|
|
recover = true;
|
|
return state;
|
|
}
|
|
}
|
|
if (reducer === current.reducer) return state;
|
|
applied = true;
|
|
const unread = state.selectedConversationId !== applicableEvent.conversationId
|
|
&& newlyNeedsUserAttention(current.reducer.snapshot, reducer.snapshot)
|
|
? true
|
|
: current.unread;
|
|
const entry: CodingConversationEntry = {
|
|
...current,
|
|
reducer,
|
|
loadState: 'live',
|
|
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: {
|
|
...state.entriesByConversationId,
|
|
[applicableEvent.conversationId]: entry,
|
|
},
|
|
summariesByConversationId: summariesWithEntry(state.summariesByConversationId, entry),
|
|
requestsByConversationId: !requestsChanged
|
|
? state.requestsByConversationId
|
|
: {
|
|
...state.requestsByConversationId,
|
|
[applicableEvent.conversationId]: nextRequests,
|
|
},
|
|
};
|
|
});
|
|
if (recover) {
|
|
void get().loadSnapshot(applicableEvent.conversationId, 'recovering').catch(() => undefined);
|
|
} else if (applied && shouldSyncSettledSession) {
|
|
const settledSnapshot = selectCodingConversationSnapshot(applicableEvent.conversationId)(get());
|
|
if (settledSnapshot) deps.queueSettledSessionSync(settledSnapshot);
|
|
}
|
|
},
|
|
}));
|
|
return store;
|
|
}
|
|
|
|
export const codingConversationStore = createCodingConversationStore();
|
|
|
|
export function useCodingConversationStore<T>(
|
|
selector: (state: CodingConversationStoreState) => T,
|
|
): T {
|
|
return useStore(codingConversationStore, selector);
|
|
}
|
|
|
|
export const selectCodingConversationSnapshot = (conversationId: string) => (
|
|
state: CodingConversationStoreState,
|
|
): ConversationSnapshot | null => (
|
|
state.entriesByConversationId[conversationId]?.reducer.snapshot ?? null
|
|
);
|
|
|
|
export const selectCodingConversationSummary = (conversationId: string) => (
|
|
state: CodingConversationStoreState,
|
|
): CodingConversationSummary | null => state.summariesByConversationId[conversationId] ?? null;
|
|
|
|
export const selectCodingConversationDraft = (conversationId: string) => (
|
|
state: CodingConversationStoreState,
|
|
): CodingConversationDraft => state.draftsByConversationId[conversationId] ?? DEFAULT_DRAFT;
|
|
|
|
export const selectCodingConversationRequests = (conversationId: string) => (
|
|
state: CodingConversationStoreState,
|
|
): Record<string, CodingPromptRequestState> => (
|
|
state.requestsByConversationId[conversationId] ?? EMPTY_REQUESTS
|
|
);
|