160 lines
6.1 KiB
TypeScript
160 lines
6.1 KiB
TypeScript
import type {Conversation, ConversationSummary, StoredChatMessage} from "./conversation-types";
|
|
import type {GenerationSettings} from "./generation-settings";
|
|
import {
|
|
cacheConversation,
|
|
cacheConversationSummaries,
|
|
listPendingConversationChanges,
|
|
loadCachedConversation,
|
|
loadCachedConversationSummaries,
|
|
queueConversationChange,
|
|
removeCachedConversation,
|
|
removePendingConversationChange
|
|
} from "./offline-history";
|
|
|
|
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;
|
|
}
|
|
|
|
function isNetworkFailure(error: unknown) {
|
|
return error instanceof TypeError || (typeof navigator !== "undefined" && !navigator.onLine);
|
|
}
|
|
|
|
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) {
|
|
const payload = await conversationRequest<{conversation: Conversation}>("/api/conversations", {
|
|
method: "POST",
|
|
body: JSON.stringify({providerId, model, generationSettings})
|
|
});
|
|
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);
|
|
}
|
|
|
|
export async function flushPendingConversationChanges() {
|
|
const pending = await listPendingConversationChanges();
|
|
for (const change of pending) {
|
|
try {
|
|
await conversationRequest<void>(`/api/conversations/${encodeURIComponent(change.conversationId)}`, {
|
|
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);
|
|
}
|
|
};
|
|
await Promise.all(Array.from({length: Math.min(4, history.length)}, () => worker()));
|
|
return history;
|
|
}
|