feat: implement PI core chat timeline
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user