assistant-cloud
Version:
Cloud integration for assistant-ui
137 lines (126 loc) • 4.16 kB
text/typescript
import type { ReadonlyJSONObject } from "assistant-stream/utils";
import type { AssistantCloud } from "./AssistantCloud";
/**
* Shared persistence logic for cloud message storage.
*
* Handles ID mapping (local → remote) and parent_id chaining for both:
* - AssistantCloudThreadHistoryAdapter (assistant-ui runtime)
* - useCloudChat (standalone AI SDK hook)
*
* The promise-based ID resolution handles concurrent appends — if message B's
* parent is message A, and A is still being created, we await A's promise
* to get its remote ID before creating B.
*/
export class CloudMessagePersistence {
private idMapping: Record<string, string | Promise<string>> = {};
private getCloud: () => AssistantCloud;
constructor(cloud: AssistantCloud);
constructor(getCloud: () => AssistantCloud);
constructor(cloud: AssistantCloud | (() => AssistantCloud)) {
this.getCloud = typeof cloud === "function" ? cloud : () => cloud;
}
/**
* Persist a message to the cloud.
*
* @param threadId - Remote thread ID
* @param messageId - Local message ID (used for tracking)
* @param parentId - Local parent message ID (or null for first message)
* @param format - Message format (e.g., "aui/v0", "ai-sdk/v6")
* @param content - Message content (format-specific)
*/
async append(
threadId: string,
messageId: string,
parentId: string | null,
format: string,
content: ReadonlyJSONObject,
): Promise<void> {
const cloud = this.getCloud();
// Resolve parent's remote ID if it exists (may be a promise if concurrent)
const resolvedParentId = parentId
? ((await this.idMapping[parentId]) ?? parentId)
: null;
const task = cloud.threads.messages
.create(threadId, {
parent_id: resolvedParentId,
format,
content,
})
.then(({ message_id }) => {
this.idMapping[messageId] = message_id;
return message_id;
})
.catch((err) => {
// Only delete if we're still the active task (avoids clobbering a retry)
if (this.idMapping[messageId] === task) {
delete this.idMapping[messageId];
}
throw err;
});
// Store the promise immediately so concurrent appends can await it
this.idMapping[messageId] = task;
return task.then(() => {});
}
/**
* Update an already-persisted message in the cloud.
*/
async update(
threadId: string,
messageId: string,
_format: string,
content: ReadonlyJSONObject,
): Promise<void> {
const cloud = this.getCloud();
const remoteId = await this.getRemoteId(messageId);
if (!remoteId) {
console.warn(
`Skipping update for message ${messageId}: no remote id is mapped.`,
);
return;
}
await cloud.threads.messages.update(threadId, remoteId, { content });
}
/**
* Check if a message has been persisted (or is currently being persisted).
*/
isPersisted(messageId: string): boolean {
return messageId in this.idMapping;
}
/**
* Get the remote ID for a local message ID (resolved).
* Returns undefined if not persisted.
*/
async getRemoteId(messageId: string): Promise<string | undefined> {
const entry = this.idMapping[messageId];
if (!entry) return undefined;
return entry;
}
/**
* Load messages from the cloud and populate the ID mapping.
*
* The ID mapping is populated so that `isPersisted()` returns true for
* loaded messages, preventing re-persistence of already-stored messages.
*
* @param threadId - Remote thread ID
* @param format - Optional format filter
* @returns Array of cloud messages
*/
async load(threadId: string, format?: string) {
const cloud = this.getCloud();
const { messages } = await cloud.threads.messages.list(
threadId,
format ? { format } : undefined,
);
// Populate ID mapping so isPersisted() recognizes loaded messages
for (const m of messages) {
this.idMapping[m.id] = m.id;
}
return messages;
}
/**
* Reset the ID mapping (call when switching threads).
*/
reset() {
this.idMapping = {};
}
}