Studio: Chat thread autosave persistence (#5256)
* fix: chat thread autosave persistence * fix: guard autosave deletion race * fix: let run-start autosave persist chats * fix: scope chat autosave to event thread * fix: clean up tombstoned chat append rows
This commit is contained in:
parent
456a49a350
commit
4d9a6ac63a
5 changed files with 151 additions and 17 deletions
|
|
@ -105,6 +105,9 @@ async function buildIndex(): Promise<ChatSearchItem[]> {
|
|||
const arr = byThreadId.get(tid);
|
||||
if (arr) merged.push(...arr);
|
||||
}
|
||||
if (merged.length === 0) {
|
||||
continue;
|
||||
}
|
||||
merged.sort((a, b) => b.createdAt - a.createdAt);
|
||||
|
||||
let preview = "";
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import { db, useLiveQuery } from "../db";
|
||||
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
|
||||
import type { ThreadRecord } from "../types";
|
||||
import { markChatThreadDeleted } from "../utils/chat-thread-tombstones";
|
||||
|
||||
export interface SidebarItem {
|
||||
type: "single" | "compare";
|
||||
|
|
@ -80,6 +81,7 @@ export async function deleteChatItem(
|
|||
// Stop any in-flight streams before deleting, so the model doesn't keep
|
||||
// generating against a thread that no longer exists.
|
||||
for (const id of threadIds) cancelIfRunning(id);
|
||||
for (const id of threadIds) markChatThreadDeleted(id);
|
||||
|
||||
await db.transaction("rw", db.threads, db.messages, async () => {
|
||||
for (const id of threadIds) {
|
||||
|
|
|
|||
|
|
@ -16,19 +16,32 @@ import {
|
|||
WebSpeechDictationAdapter,
|
||||
type unstable_RemoteThreadListAdapter,
|
||||
useAui,
|
||||
useAuiEvent,
|
||||
useAuiState,
|
||||
useLocalRuntime,
|
||||
unstable_useRemoteThreadListRuntime as useRemoteThreadListRuntime,
|
||||
} from "@assistant-ui/react";
|
||||
import { createAssistantStream } from "assistant-stream";
|
||||
import mammoth from "mammoth";
|
||||
import { type ReactElement, type ReactNode, useEffect, useMemo } from "react";
|
||||
import {
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { extractText, getDocumentProxy } from "unpdf";
|
||||
import { authFetch } from "@/features/auth";
|
||||
import { createOpenAIStreamAdapter } from "./api/chat-adapter";
|
||||
import { db } from "./db";
|
||||
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
|
||||
import type { MessageRecord, ModelType } from "./types";
|
||||
import {
|
||||
isChatThreadDeleted,
|
||||
markChatThreadDeleted,
|
||||
} from "./utils/chat-thread-tombstones";
|
||||
import { syncExportedRepositoryToDexie } from "./utils/delete-thread-message";
|
||||
|
||||
const DEFAULT_SUGGESTIONS = [
|
||||
{
|
||||
|
|
@ -383,6 +396,55 @@ function toThreadMessage(m: MessageRecord): ThreadMessage {
|
|||
};
|
||||
}
|
||||
|
||||
async function ensureThreadRecord({
|
||||
threadId,
|
||||
modelType,
|
||||
pairId,
|
||||
}: {
|
||||
threadId: string;
|
||||
modelType: ModelType;
|
||||
pairId?: string;
|
||||
}): Promise<void> {
|
||||
if (isChatThreadDeleted(threadId)) {
|
||||
return;
|
||||
}
|
||||
const existing = await db.threads.get(threadId);
|
||||
if (existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentModelId =
|
||||
useChatRuntimeStore.getState().params.checkpoint ?? "";
|
||||
const record = {
|
||||
id: threadId,
|
||||
title: "New Chat",
|
||||
modelType,
|
||||
modelId: currentModelId,
|
||||
pairId,
|
||||
archived: false,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
await db.threads.add(record);
|
||||
} catch (error) {
|
||||
// assistant-ui can issue overlapping first-message persistence calls.
|
||||
// If another call created the same thread while this one was waiting,
|
||||
// treat initialization as successful and let the message write continue.
|
||||
if (await db.threads.get(threadId)) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteThreadRows(threadId: string): Promise<void> {
|
||||
await db.transaction("rw", db.threads, db.messages, async () => {
|
||||
await db.messages.where("threadId").equals(threadId).delete();
|
||||
await db.threads.delete(threadId);
|
||||
});
|
||||
}
|
||||
|
||||
function createDexieAdapter(
|
||||
modelType: ModelType,
|
||||
pairId?: string,
|
||||
|
|
@ -418,17 +480,7 @@ function createDexieAdapter(
|
|||
},
|
||||
|
||||
async initialize(threadId: string) {
|
||||
const currentModelId =
|
||||
useChatRuntimeStore.getState().params.checkpoint ?? "";
|
||||
await db.threads.add({
|
||||
id: threadId,
|
||||
title: "New Chat",
|
||||
modelType,
|
||||
modelId: currentModelId,
|
||||
pairId,
|
||||
archived: false,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
await ensureThreadRecord({ threadId, modelType, pairId });
|
||||
return { remoteId: threadId, externalId: undefined };
|
||||
},
|
||||
|
||||
|
|
@ -445,8 +497,8 @@ function createDexieAdapter(
|
|||
},
|
||||
|
||||
async delete(remoteId: string) {
|
||||
await db.messages.where("threadId").equals(remoteId).delete();
|
||||
await db.threads.delete(remoteId);
|
||||
markChatThreadDeleted(remoteId);
|
||||
await deleteThreadRows(remoteId);
|
||||
},
|
||||
|
||||
async generateTitle(remoteId: string, messages: readonly ThreadMessage[]) {
|
||||
|
|
@ -599,6 +651,10 @@ function ThreadHistoryProvider({
|
|||
|
||||
async append({ parentId, message }: ExportedMessageRepositoryItem) {
|
||||
const { remoteId } = await aui.threadListItem().initialize();
|
||||
if (isChatThreadDeleted(remoteId)) {
|
||||
await deleteThreadRows(remoteId);
|
||||
return;
|
||||
}
|
||||
// Keep single-chat runtime state in sync once a new chat is first
|
||||
// persisted. Compare panes intentionally do not write global activeThreadId.
|
||||
const thread = await db.threads.get(remoteId);
|
||||
|
|
@ -765,6 +821,66 @@ function CancelRegistrar(): ReactElement | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function ThreadDexieAutosave({
|
||||
modelType,
|
||||
pairId,
|
||||
}: {
|
||||
modelType: ModelType;
|
||||
pairId?: string;
|
||||
}): ReactElement | null {
|
||||
const aui = useAui();
|
||||
const saveChainRef = useRef(Promise.resolve());
|
||||
|
||||
const saveThread = useCallback(async (threadId: string): Promise<void> => {
|
||||
const runtime = aui.threads().__internal_getAssistantRuntime?.();
|
||||
if (!runtime) {
|
||||
return;
|
||||
}
|
||||
const exported = runtime.threads.getById(threadId).export();
|
||||
if (exported.messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { remoteId } = await runtime.threads.getItemById(threadId).initialize();
|
||||
if (isChatThreadDeleted(remoteId)) {
|
||||
await deleteThreadRows(remoteId);
|
||||
return;
|
||||
}
|
||||
await syncExportedRepositoryToDexie(remoteId, exported);
|
||||
if (isChatThreadDeleted(remoteId)) {
|
||||
await deleteThreadRows(remoteId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (modelType === "base" && !pairId) {
|
||||
const store = useChatRuntimeStore.getState();
|
||||
const activeThreadId = runtime.threads.getState().mainThreadId;
|
||||
if (activeThreadId === threadId && store.activeThreadId !== remoteId) {
|
||||
store.setActiveThreadId(remoteId);
|
||||
}
|
||||
}
|
||||
}, [aui, modelType, pairId]);
|
||||
|
||||
const queueSave = useCallback((threadId: string): void => {
|
||||
saveChainRef.current = saveChainRef.current
|
||||
.catch(() => {})
|
||||
.then(() => saveThread(threadId))
|
||||
.catch((error) => {
|
||||
console.error("Failed to autosave chat thread", error);
|
||||
});
|
||||
}, [saveThread]);
|
||||
|
||||
useAuiEvent("thread.runEnd", ({ threadId }) => {
|
||||
queueSave(threadId);
|
||||
});
|
||||
|
||||
useAuiEvent("thread.runStart", ({ threadId }) => {
|
||||
queueSave(threadId);
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ChatRuntimeProvider({
|
||||
children,
|
||||
modelType = "base",
|
||||
|
|
@ -797,6 +913,7 @@ export function ChatRuntimeProvider({
|
|||
<ActiveThreadSync
|
||||
enabled={modelType === "base" && !pairId && !newThreadNonce && !initialThreadId}
|
||||
/>
|
||||
<ThreadDexieAutosave modelType={modelType} pairId={pairId} />
|
||||
<CancelRegistrar />
|
||||
{initialThreadId && (
|
||||
<ThreadAutoSwitch
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
const deletedThreadIds = new Set<string>();
|
||||
|
||||
export function markChatThreadDeleted(threadId: string): void {
|
||||
deletedThreadIds.add(threadId);
|
||||
}
|
||||
|
||||
export function isChatThreadDeleted(threadId: string): boolean {
|
||||
return deletedThreadIds.has(threadId);
|
||||
}
|
||||
|
|
@ -19,8 +19,8 @@ import type {
|
|||
* surface area.
|
||||
*/
|
||||
import { MessageRepository } from "@assistant-ui/core/internal";
|
||||
import { db } from "@/features/chat/db";
|
||||
import type { MessageRecord } from "@/features/chat/types";
|
||||
import { db } from "../db";
|
||||
import type { MessageRecord } from "../types";
|
||||
|
||||
function cloneContent(content: ThreadMessage["content"]): ThreadMessage["content"] {
|
||||
if (typeof content === "string") {
|
||||
|
|
@ -74,7 +74,7 @@ function exportedItemToRecord(
|
|||
* Persist the exact message list represented by `exp` for this thread, removing
|
||||
* Dexie rows that are no longer present (e.g. after a delete).
|
||||
*/
|
||||
async function syncExportedRepositoryToDexie(
|
||||
export async function syncExportedRepositoryToDexie(
|
||||
remoteId: string,
|
||||
exp: ExportedMessageRepository,
|
||||
): Promise<void> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue