feat: implement PI core chat timeline

This commit is contained in:
2026-08-24 00:34:03 +08:00
parent 54d8bb2e4a
commit 612135f911
40 changed files with 3881 additions and 119 deletions

View File

@@ -10,12 +10,13 @@ import { AppError } from '@/lib/error-model';
import {
getCodingConversationSnapshot,
openCodingConversationEvents,
recoverCodingConversation,
submitCodingConversationPrompt,
type SubmitCodingConversationPromptInput,
} from '@/lib/coding-conversations';
import type {
CodingConversationDraft,
CodingConversationPatchEvent,
CodingConversationPatchBatchEvent,
CodingConversationSnapshotEvent,
CodingConversationSummary,
CodingDraftAttachment,
@@ -46,13 +47,16 @@ export interface SubmitCodingPromptInput {
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>;
createId(kind: 'request' | 'node'): string;
preparationTimeoutMs: number;
}
export interface CodingConversationStoreState {
@@ -63,15 +67,18 @@ export interface CodingConversationStoreState {
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, recovering?: boolean): 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;
applyPatchEvent(event: CodingConversationPatchEvent): void;
applyPatchBatchEvent(event: CodingConversationPatchBatchEvent): void;
}
const DEFAULT_DRAFT: CodingConversationDraft = {
@@ -269,39 +276,134 @@ function defaultDependencies(): CodingConversationStoreDependencies {
getSnapshot: getCodingConversationSnapshot,
openEvents: openCodingConversationEvents,
submitPrompt: submitCodingConversationPrompt,
recover: recoverCodingConversation,
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)
));
}
export function createCodingConversationStore(
dependencies: Partial<CodingConversationStoreDependencies> = {},
): StoreApi<CodingConversationStoreState> {
const deps = { ...defaultDependencies(), ...dependencies };
const snapshotLoads = new Map<string, Promise<ConversationSnapshot>>();
const recoveryPatches = new Map<string, CodingConversationPatchEvent[]>();
const recoveryBatches = new Map<string, CodingConversationPatchBatchEvent[]>();
let eventSource: EventSource | null = null;
let connectFlight: Promise<void> | null = null;
let connectionGeneration = 0;
let store!: StoreApi<CodingConversationStoreState>;
const bufferRecoveryPatch = (event: CodingConversationPatchEvent) => {
const buffered = recoveryPatches.get(event.conversationId) ?? [];
const bufferRecoveryBatch = (event: CodingConversationPatchBatchEvent) => {
const buffered = recoveryBatches.get(event.conversationId) ?? [];
if (buffered.some((candidate) => (
candidate.workerGeneration === event.workerGeneration && candidate.seq === event.seq
candidate.workerGeneration === event.workerGeneration
&& candidate.fromSeq === event.fromSeq
&& candidate.toSeq === event.toSeq
))) return;
recoveryPatches.set(
recoveryBatches.set(
event.conversationId,
[...buffered, event].sort((left, right) => (
left.workerGeneration - right.workerGeneration || left.seq - right.seq
left.workerGeneration - right.workerGeneration || left.fromSeq - right.fromSeq
)),
);
};
const replayRecoveryPatches = (conversationId: string) => {
const buffered = recoveryPatches.get(conversationId);
const replayRecoveryBatches = (conversationId: string) => {
const buffered = recoveryBatches.get(conversationId);
if (!buffered?.length) return;
recoveryPatches.delete(conversationId);
for (const event of buffered) store.getState().applyPatchEvent(event);
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);
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) {
store.setState((state) => {
const current = state.entriesByConversationId[conversationId] ?? emptyEntry();
return {
entriesByConversationId: {
...state.entriesByConversationId,
[conversationId]: {
...current,
loadState: 'recovering',
error: 'Conversation patch batch is not continuous',
},
},
};
});
return;
}
recoveryBatches.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) => ({
@@ -313,6 +415,37 @@ export function createCodingConversationStore(
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();
@@ -327,10 +460,12 @@ export function createCodingConversationStore(
};
});
const entry = get().entriesByConversationId[conversationId];
if (!entry?.reducer.snapshot || entry.reducer.invalidation) {
await get().loadSnapshot(conversationId, Boolean(entry?.reducer.invalidation));
}
await get().connectEvents();
const snapshotFlight = !entry?.reducer.snapshot
|| entry.reducer.invalidation
|| entry.loadState !== 'live'
? get().loadSnapshot(conversationId, Boolean(entry?.reducer.invalidation))
: Promise.resolve(entry.reducer.snapshot);
await Promise.all([snapshotFlight, get().connectEvents()]);
},
loadSnapshot(conversationId, recovering = false) {
@@ -349,7 +484,10 @@ export function createCodingConversationStore(
},
};
});
const flight = deps.getSnapshot(conversationId)
const flight = withPreparationDeadline(
deps.getSnapshot(conversationId),
deps.preparationTimeoutMs,
)
.then((snapshot) => {
get().applySnapshotEvent({
type: 'snapshot',
@@ -376,12 +514,26 @@ export function createCodingConversationStore(
.finally(() => {
if (snapshotLoads.get(conversationId) === flight) snapshotLoads.delete(conversationId);
const entry = get().entriesByConversationId[conversationId];
if (!entry?.reducer.invalidation) replayRecoveryPatches(conversationId);
if (!entry?.reducer.invalidation) replayRecoveryBatches(conversationId);
});
snapshotLoads.set(conversationId, flight);
return flight;
},
async recoverConversation(conversationId) {
set((state) => {
const entry = state.entriesByConversationId[conversationId] ?? emptyEntry();
return {
entriesByConversationId: {
...state.entriesByConversationId,
[conversationId]: { ...entry, loadState: 'recovering', error: null },
},
};
});
await deps.recover(conversationId);
await get().loadSnapshot(conversationId, true);
},
async connectEvents() {
if (eventSource) return;
if (connectFlight) return await connectFlight;
@@ -403,9 +555,9 @@ export function createCodingConversationStore(
set({ connectionState: 'error', globalError: 'Conversation 事件快照无法读取。' });
}
});
source.addEventListener('patch', (event) => {
source.addEventListener('patch-batch', (event) => {
try {
get().applyPatchEvent(parse<CodingConversationPatchEvent>(event));
get().applyPatchBatchEvent(parse<CodingConversationPatchBatchEvent>(event));
} catch {
set({ connectionState: 'error', globalError: 'Conversation 事件更新无法读取。' });
}
@@ -531,6 +683,43 @@ export function createCodingConversationStore(
});
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,
@@ -649,15 +838,28 @@ export function createCodingConversationStore(
};
});
if (!snapshotLoads.has(event.conversationId)) {
replayRecoveryPatches(event.conversationId);
replayRecoveryBatches(event.conversationId);
}
},
applyPatchEvent(event) {
if (event.type !== 'patch') return;
const currentBeforePatch = get().entriesByConversationId[event.conversationId];
if (currentBeforePatch?.reducer.invalidation) {
bufferRecoveryPatch(event);
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, true).catch(() => undefined);
}
return;
}
if (recovering
|| !currentSnapshot
|| event.workerGeneration !== currentSnapshot.cursor.workerGeneration
|| event.fromSeq !== currentSnapshot.cursor.seq + 1) {
bufferRecoveryBatch(event);
if (!snapshotLoads.has(event.conversationId)) {
void get().loadSnapshot(event.conversationId, true).catch(() => undefined);
}
@@ -666,25 +868,43 @@ export function createCodingConversationStore(
let recover = false;
set((state) => {
const current = state.entriesByConversationId[event.conversationId] ?? emptyEntry();
const reducer = reduceConversationPatch(current.reducer, event as ConversationPatchEnvelope);
let reducer = current.reducer;
for (const item of event.items) {
reducer = reduceConversationPatch(reducer, {
conversationId: event.conversationId,
workerGeneration: event.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;
recover = Boolean(reducer.invalidation);
const incomingMessage = event.patch.op === 'message.upsert' ? event.patch.node : null;
const incomingMessages = event.items.flatMap((item) => (
item.patch.op === 'message.upsert' ? [item.patch.node] : []
));
const unread = state.selectedConversationId !== event.conversationId
&& incomingMessage?.role === 'assistant'
&& incomingMessages.some((message) => message.role === 'assistant')
? true
: current.unread;
const entry: CodingConversationEntry = {
...current,
reducer,
loadState: reducer.invalidation ? 'recovering' : 'live',
error: reducer.invalidation?.reason ?? null,
loadState: 'live',
error: null,
unread,
};
const requests = state.requestsByConversationId[event.conversationId] ?? {};
const nextRequests = incomingMessage?.clientRequestId
const reconciledRequestIds = new Set(incomingMessages.flatMap((message) => (
message.clientRequestId ? [message.clientRequestId] : []
)));
const nextRequests = reconciledRequestIds.size > 0
? Object.fromEntries(
Object.entries(requests).filter(([id]) => id !== incomingMessage.clientRequestId),
Object.entries(requests).filter(([id]) => !reconciledRequestIds.has(id)),
)
: requests;
return {