feat(chat): add local-first conversation workspace
This commit is contained in:
+139
-99
@@ -1,15 +1,50 @@
|
||||
import type {Conversation, ConversationSummary, StoredChatMessage} from "./conversation-types";
|
||||
import type {
|
||||
Conversation,
|
||||
ConversationRefState,
|
||||
ConversationSummary,
|
||||
MessageCompletion,
|
||||
MessageOrigin,
|
||||
RepositoryFetch,
|
||||
StoredChatMessage
|
||||
} from "./conversation-types";
|
||||
import type {GenerationSettings} from "./generation-settings";
|
||||
import {
|
||||
applyRepositoryFetch,
|
||||
applyRepositoryPushResults,
|
||||
cacheConversation,
|
||||
cacheConversationSummaries,
|
||||
commitLocalMessage,
|
||||
createLocalConversation,
|
||||
deleteLocalConversation,
|
||||
listCachedObjectIds,
|
||||
listPendingConversationChanges,
|
||||
loadCachedConversation,
|
||||
loadCachedConversationSummaries,
|
||||
moveLocalConversationHead,
|
||||
queueConversationChange,
|
||||
removeCachedConversation,
|
||||
removePendingConversationChange
|
||||
queueLocalRefUpdate,
|
||||
removePendingConversationChange,
|
||||
repositoryPushPayload
|
||||
} from "./offline-history";
|
||||
import {repositoryPushBatches} from "./repository-push-batches";
|
||||
|
||||
const chatBasePath = "/chat";
|
||||
const chatApi = (pathname: string) => `${chatBasePath}${pathname}`;
|
||||
|
||||
export type MessageCommitInput = {
|
||||
id: string;
|
||||
expectedHeadId: string | null;
|
||||
parentMessageId: string | null;
|
||||
role: StoredChatMessage["role"];
|
||||
parts: StoredChatMessage["parts"];
|
||||
origin: MessageOrigin;
|
||||
completion: MessageCompletion;
|
||||
createdAt: string;
|
||||
completedAt: string;
|
||||
metadata?: StoredChatMessage["metadata"];
|
||||
providerId?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
class ConversationHttpError extends Error {
|
||||
constructor(message: string, readonly status: number) {
|
||||
@@ -33,127 +68,132 @@ async function conversationRequest<T>(path: string, init?: RequestInit) {
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
function isNetworkFailure(error: unknown) {
|
||||
return error instanceof TypeError || (typeof navigator !== "undefined" && !navigator.onLine);
|
||||
export function listConversationHistory() {
|
||||
return loadCachedConversationSummaries();
|
||||
}
|
||||
|
||||
export async function listConversationHistory() {
|
||||
try {
|
||||
const payload = await conversationRequest<{conversations: ConversationSummary[]}>("/api/conversations");
|
||||
await cacheConversationSummaries(payload.conversations);
|
||||
return payload.conversations;
|
||||
} catch (error) {
|
||||
if (!isNetworkFailure(error)) throw error;
|
||||
const cached = await loadCachedConversationSummaries();
|
||||
if (cached.length === 0) throw error;
|
||||
return cached;
|
||||
}
|
||||
export async function createConversationHistory(
|
||||
providerId: string,
|
||||
model: string,
|
||||
generationSettings: GenerationSettings,
|
||||
name: string,
|
||||
headMessageId: string | null = null,
|
||||
messages: StoredChatMessage[] = []
|
||||
) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const conversation: Conversation = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
headMessageId,
|
||||
upstreamHeadMessageId: null,
|
||||
providerId,
|
||||
model,
|
||||
generationSettings,
|
||||
headVersion: 0,
|
||||
metadataVersion: 0,
|
||||
messageCount: messages.length,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
messages
|
||||
};
|
||||
return createLocalConversation(conversation);
|
||||
}
|
||||
|
||||
export async function createConversationHistory(providerId: string, model: string, generationSettings: GenerationSettings) {
|
||||
const payload = await conversationRequest<{conversation: Conversation}>("/api/conversations", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({providerId, model, generationSettings})
|
||||
});
|
||||
export async function getConversationHistory(id: string) {
|
||||
const cached = await loadCachedConversation(id);
|
||||
if (!cached) throw new Error("Local conversation is unavailable; fetch may still be in progress");
|
||||
return cached;
|
||||
}
|
||||
|
||||
export async function updateConversationHistory(
|
||||
id: string,
|
||||
providerId: string,
|
||||
model: string,
|
||||
generationSettings: GenerationSettings,
|
||||
name?: string
|
||||
) {
|
||||
const cached = await getConversationHistory(id);
|
||||
const updated: Conversation = {
|
||||
...cached,
|
||||
providerId,
|
||||
model,
|
||||
generationSettings,
|
||||
...(name === undefined ? {} : {name}),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
return queueLocalRefUpdate(updated);
|
||||
}
|
||||
|
||||
export async function commitConversationMessage(conversationId: string, input: MessageCommitInput) {
|
||||
const cached = await getConversationHistory(conversationId);
|
||||
if (cached.headMessageId !== input.expectedHeadId || input.parentMessageId !== input.expectedHeadId) throw new Error("Local conversation head changed");
|
||||
const message: StoredChatMessage = {
|
||||
id: input.id,
|
||||
parentMessageId: input.parentMessageId,
|
||||
role: input.role,
|
||||
parts: input.parts,
|
||||
origin: input.origin,
|
||||
completion: input.completion,
|
||||
createdAt: input.createdAt,
|
||||
completedAt: input.completedAt,
|
||||
...(input.metadata ? {metadata: input.metadata} : {})
|
||||
};
|
||||
return commitLocalMessage(conversationId, message);
|
||||
}
|
||||
|
||||
export function moveConversationHead(conversationId: string, headMessageId: string | null) {
|
||||
return moveLocalConversationHead(conversationId, headMessageId);
|
||||
}
|
||||
|
||||
// Compatibility for queued operations produced by an older client.
|
||||
export async function saveConversationHistory(id: string, providerId: string, model: string, messages: StoredChatMessage[]) {
|
||||
const body = JSON.stringify({providerId, model, messages});
|
||||
const payload = await conversationRequest<{conversation: Conversation}>(chatApi(`/api/conversations/${encodeURIComponent(id)}`), {method: "PUT", body});
|
||||
await cacheConversation(payload.conversation);
|
||||
return payload.conversation;
|
||||
}
|
||||
|
||||
export async function getConversationHistory(id: string) {
|
||||
try {
|
||||
const payload = await conversationRequest<{conversation: Conversation}>(`/api/conversations/${encodeURIComponent(id)}`);
|
||||
await cacheConversation(payload.conversation);
|
||||
return payload.conversation;
|
||||
} catch (error) {
|
||||
if (!isNetworkFailure(error)) throw error;
|
||||
const cached = await loadCachedConversation(id);
|
||||
if (!cached) throw error;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateConversationHistory(id: string, providerId: string, model: string, generationSettings: GenerationSettings) {
|
||||
const body = JSON.stringify({providerId, model, generationSettings});
|
||||
try {
|
||||
return await conversationRequest<{updated: true}>(`/api/conversations/${encodeURIComponent(id)}`, {method: "PATCH", body});
|
||||
} catch (error) {
|
||||
if (!isNetworkFailure(error)) throw error;
|
||||
const cached = await loadCachedConversation(id);
|
||||
if (cached) await cacheConversation({...cached, providerId, model, generationSettings, updatedAt: new Date().toISOString()});
|
||||
await queueConversationChange({conversationId: id, method: "PATCH", body});
|
||||
return {updated: true as const};
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveConversationHistory(id: string, providerId: string, model: string, messages: StoredChatMessage[]) {
|
||||
const body = JSON.stringify({providerId, model, messages});
|
||||
try {
|
||||
const payload = await conversationRequest<{conversation: Conversation}>(`/api/conversations/${encodeURIComponent(id)}`, {method: "PUT", body});
|
||||
await cacheConversation(payload.conversation);
|
||||
return payload.conversation;
|
||||
} catch (error) {
|
||||
if (!isNetworkFailure(error)) throw error;
|
||||
const cached = await loadCachedConversation(id);
|
||||
if (!cached) throw error;
|
||||
const firstUserText = messages.find((message) => message.role === "user")?.parts
|
||||
.filter((part) => part.type === "text" && typeof part.text === "string")
|
||||
.map((part) => String(part.text)).join(" ").replace(/\s+/g, " ").trim();
|
||||
const updated: Conversation = {
|
||||
...cached,
|
||||
title: firstUserText?.slice(0, 48) || cached.title,
|
||||
providerId,
|
||||
model,
|
||||
messages,
|
||||
messageCount: messages.length,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
await cacheConversation(updated);
|
||||
await queueConversationChange({conversationId: id, method: "PUT", body});
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteConversationHistory(id: string) {
|
||||
try {
|
||||
await conversationRequest<void>(`/api/conversations/${encodeURIComponent(id)}`, {method: "DELETE"});
|
||||
} catch (error) {
|
||||
if (!isNetworkFailure(error)) throw error;
|
||||
await queueConversationChange({conversationId: id, method: "DELETE"});
|
||||
}
|
||||
await removeCachedConversation(id);
|
||||
await deleteLocalConversation(id);
|
||||
}
|
||||
|
||||
export async function flushPendingConversationChanges() {
|
||||
const pending = await listPendingConversationChanges();
|
||||
for (const change of pending) {
|
||||
const requestPath = change.requestPath || `/api/conversations/${encodeURIComponent(change.conversationId)}`;
|
||||
try {
|
||||
await conversationRequest<void>(`/api/conversations/${encodeURIComponent(change.conversationId)}`, {
|
||||
method: change.method,
|
||||
body: change.body
|
||||
});
|
||||
await conversationRequest<void>(chatApi(requestPath), {method: change.method, body: change.body});
|
||||
await removePendingConversationChange(change.cacheKey);
|
||||
} catch (error) {
|
||||
if (error instanceof ConversationHttpError && change.method === "DELETE" && error.status === 404) {
|
||||
await removePendingConversationChange(change.cacheKey);
|
||||
continue;
|
||||
}
|
||||
if (isNetworkFailure(error)) break;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function synchronizeOfflineConversationHistory(summaries?: ConversationSummary[]) {
|
||||
const history = summaries || (await conversationRequest<{conversations: ConversationSummary[]}>("/api/conversations")).conversations;
|
||||
await cacheConversationSummaries(history);
|
||||
let cursor = 0;
|
||||
const worker = async () => {
|
||||
while (cursor < history.length) {
|
||||
const summary = history[cursor++];
|
||||
const payload = await conversationRequest<{conversation: Conversation}>(`/api/conversations/${encodeURIComponent(summary.id)}`);
|
||||
await cacheConversation(payload.conversation);
|
||||
export async function synchronizeConversationRepository() {
|
||||
await flushPendingConversationChanges();
|
||||
const push = await repositoryPushPayload();
|
||||
let conflicts = 0;
|
||||
if (push.refs.length || push.objects.length) {
|
||||
for (const batch of repositoryPushBatches(push)) {
|
||||
const pushed = await conversationRequest<{
|
||||
refs: Array<{conversationId: string; status: "ok" | "conflict"; ref: ConversationRefState | null}>;
|
||||
}>(chatApi("/api/sync/push"), {method: "POST", body: JSON.stringify(batch)});
|
||||
await applyRepositoryPushResults(pushed.refs);
|
||||
conflicts += pushed.refs.filter((result) => result.status === "conflict").length;
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({length: Math.min(4, history.length)}, () => worker()));
|
||||
return history;
|
||||
}
|
||||
const haveObjectIds = await listCachedObjectIds();
|
||||
const fetched = await conversationRequest<RepositoryFetch>(chatApi("/api/sync/fetch"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({haveObjectIds})
|
||||
});
|
||||
await applyRepositoryFetch(fetched);
|
||||
return {summaries: await loadCachedConversationSummaries(), fetchedAt: fetched.fetchedAt, conflicts};
|
||||
}
|
||||
|
||||
export const synchronizeOfflineConversationHistory = synchronizeConversationRepository;
|
||||
|
||||
Reference in New Issue
Block a user