200 lines
6.5 KiB
TypeScript
200 lines
6.5 KiB
TypeScript
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,
|
|
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) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
async function conversationRequest<T>(path: string, init?: RequestInit) {
|
|
const response = await fetch(path, {
|
|
cache: "no-store",
|
|
...init,
|
|
headers: {
|
|
"Accept": "application/json",
|
|
...(init?.body ? {"Content-Type": "application/json"} : {}),
|
|
...(init?.headers || {})
|
|
}
|
|
});
|
|
if (response.status === 204) return undefined as T;
|
|
const payload = await response.json();
|
|
if (!response.ok) throw new ConversationHttpError(payload.error || `HTTP ${response.status}`, response.status);
|
|
return payload as T;
|
|
}
|
|
|
|
export function listConversationHistory() {
|
|
return loadCachedConversationSummaries();
|
|
}
|
|
|
|
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 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 deleteConversationHistory(id: string) {
|
|
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>(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;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
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;
|