feat(chat): add local-first conversation workspace

This commit is contained in:
2026-08-13 15:55:15 +08:00
parent d1d50d722c
commit 8991f78f9f
43 changed files with 5450 additions and 592 deletions
+79
View File
@@ -0,0 +1,79 @@
import {splitStreamingMarkdown} from "./streaming-markdown";
export type IncrementalMarkdownBlock<T> = T & {
source: string;
type: string;
index: number;
stable: boolean;
};
export type IncrementalMarkdownResult<T> = {
blocks: Array<IncrementalMarkdownBlock<T>>;
parsed: number;
reused: number;
};
type IncrementalMarkdownState<T> = {
stableSource: string;
stableBlocks: Array<IncrementalMarkdownBlock<T>>;
nextIndex: number;
};
export class IncrementalMarkdownCache<T> {
private readonly states = new Map<string, IncrementalMarkdownState<T>>();
render(
messageId: string,
value: string,
renderBlock: (source: string, type: string, index: number, stable: boolean) => T,
onSplit?: (durationMs: number) => void,
complete = false
): IncrementalMarkdownResult<T> {
let state = this.states.get(messageId);
if (!state || !value.startsWith(state.stableSource)) {
state = {stableSource: "", stableBlocks: [], nextIndex: 0};
this.states.set(messageId, state);
}
if (value === state.stableSource) return {blocks: state.stableBlocks, parsed: 0, reused: state.stableBlocks.length};
const tail = value.slice(state.stableSource.length);
const startedAt = performance.now();
const split = splitStreamingMarkdown(tail, complete);
onSplit?.(performance.now() - startedAt);
const renderedTail = split.blocks.map((block, offset) => ({
...renderBlock(block.source, block.type, state!.nextIndex + offset, block.stable),
source: block.source,
type: block.type,
index: state!.nextIndex + offset,
stable: block.stable
}));
const newlyStable = renderedTail.filter((_block, index) => split.blocks[index].end <= split.stableOffset);
const previouslyStableCount = state.stableBlocks.length;
if (split.stableOffset > 0) {
state.stableSource += tail.slice(0, split.stableOffset);
state.stableBlocks.push(...newlyStable);
state.nextIndex += newlyStable.length;
}
return {
blocks: [...state.stableBlocks.slice(0, state.stableBlocks.length - newlyStable.length), ...renderedTail],
parsed: renderedTail.length,
reused: previouslyStableCount
};
}
delete(messageId: string) {
this.states.delete(messageId);
}
move(fromMessageId: string, toMessageId: string) {
if (fromMessageId === toMessageId) return;
const state = this.states.get(fromMessageId);
if (!state) return;
this.states.set(toMessageId, state);
this.states.delete(fromMessageId);
}
has(messageId: string) {
return this.states.has(messageId);
}
}