ui: IndexedDB and Conversations data fixes (#26278)
* fix: single-flight conversations store init * refactor: remove unused legacy-migration util * fix: make createSystemMessage transactional * fix: delete message branches cascading on edit/regenerate * fix: stop stamping lastModified on conversation metadata updates * fix: count cascaded forks in bulk delete toast, bulkify deleteAll * refactor: drop redundant conversation list respreads * refactor: create conversation in a single write * fix: use table constant in toggleConversationPin * fix: keep the system message placeholder out of the edit form * fix: keep focus in the system message editor after opening it * fix: focus the main chat form after submitting a system message * fix: update timestamp of the correct conversation on stream completion
This commit is contained in:
+17
-2
@@ -48,6 +48,9 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let dropdownOpen = $state(false);
|
let dropdownOpen = $state(false);
|
||||||
|
// The system message action moves focus to the message editor, so the menu
|
||||||
|
// must not restore focus to the trigger on close
|
||||||
|
let suppressCloseAutoFocus = false;
|
||||||
|
|
||||||
function handleMcpSettingsClick() {
|
function handleMcpSettingsClick() {
|
||||||
dropdownOpen = false;
|
dropdownOpen = false;
|
||||||
@@ -96,7 +99,16 @@
|
|||||||
</Tooltip.Content>
|
</Tooltip.Content>
|
||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
|
|
||||||
<DropdownMenu.Content align="start" class="w-52">
|
<DropdownMenu.Content
|
||||||
|
align="start"
|
||||||
|
class="w-52"
|
||||||
|
onCloseAutoFocus={(e) => {
|
||||||
|
if (suppressCloseAutoFocus) {
|
||||||
|
suppressCloseAutoFocus = false;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ChatFormActionAddReasoningSubmenu />
|
<ChatFormActionAddReasoningSubmenu />
|
||||||
|
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
@@ -148,7 +160,10 @@
|
|||||||
|
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
class="flex cursor-pointer items-center gap-2"
|
class="flex cursor-pointer items-center gap-2"
|
||||||
onclick={onSystemPromptClick}
|
onclick={() => {
|
||||||
|
suppressCloseAutoFocus = true;
|
||||||
|
onSystemPromptClick?.();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MessageSquare class={ICON_CLASS_DEFAULT} />
|
<MessageSquare class={ICON_CLASS_DEFAULT} />
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { getChatActionsContext, setMessageEditContext } from '$lib/contexts';
|
import { getChatActionsContext, setMessageEditContext } from '$lib/contexts';
|
||||||
import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
|
import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
|
||||||
|
import { isMobile } from '$lib/stores/viewport.svelte';
|
||||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||||
import { DatabaseService } from '$lib/services/database.service';
|
import { DatabaseService } from '$lib/services/database.service';
|
||||||
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
|
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
|
||||||
@@ -46,7 +47,14 @@
|
|||||||
assistantMessages: number;
|
assistantMessages: number;
|
||||||
messageTypes: string[];
|
messageTypes: string[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
let editedContent = $derived(message.content);
|
// The system message placeholder must never surface as editable content; keeping
|
||||||
|
// it in the derived (not just in handleEdit) guards against prop invalidation
|
||||||
|
// reverting the override while editing
|
||||||
|
let editedContent = $derived(
|
||||||
|
message.role === MessageRole.SYSTEM && message.content === SYSTEM_MESSAGE_PLACEHOLDER
|
||||||
|
? ''
|
||||||
|
: message.content
|
||||||
|
);
|
||||||
|
|
||||||
let rawEditContent = $derived.by(() => {
|
let rawEditContent = $derived.by(() => {
|
||||||
if (message.role !== MessageRole.ASSISTANT) return undefined;
|
if (message.role !== MessageRole.ASSISTANT) return undefined;
|
||||||
@@ -265,6 +273,12 @@
|
|||||||
chatActions.navigateToSibling(siblingId);
|
chatActions.navigateToSibling(siblingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After the system message flow ends, hand focus to the main chat form
|
||||||
|
function focusMainChatForm() {
|
||||||
|
if (isMobile.current) return;
|
||||||
|
document.querySelector<HTMLTextAreaElement>('.chat-screen-form-wrapper textarea')?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSaveEdit() {
|
async function handleSaveEdit() {
|
||||||
if (message.role === MessageRole.SYSTEM) {
|
if (message.role === MessageRole.SYSTEM) {
|
||||||
// System messages: update in place without branching
|
// System messages: update in place without branching
|
||||||
@@ -276,6 +290,8 @@
|
|||||||
isEditing = false;
|
isEditing = false;
|
||||||
if (conversationDeleted) {
|
if (conversationDeleted) {
|
||||||
goto(ROUTES.START);
|
goto(ROUTES.START);
|
||||||
|
} else {
|
||||||
|
focusMainChatForm();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -285,6 +301,7 @@
|
|||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
conversationsStore.updateMessageAtIndex(index, { content: newContent });
|
conversationsStore.updateMessageAtIndex(index, { content: newContent });
|
||||||
}
|
}
|
||||||
|
focusMainChatForm();
|
||||||
} else if (message.role === MessageRole.USER) {
|
} else if (message.role === MessageRole.USER) {
|
||||||
const finalExtras = await getMergedExtras();
|
const finalExtras = await getMergedExtras();
|
||||||
chatActions.editWithBranching(message, editedContent.trim(), finalExtras);
|
chatActions.editWithBranching(message, editedContent.trim(), finalExtras);
|
||||||
|
|||||||
@@ -106,15 +106,23 @@
|
|||||||
onFileRemove?.(fileId);
|
onFileRemove?.(fileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-focus must not steal focus already claimed elsewhere (e.g. the system
|
||||||
|
// message editor opened just before a navigation)
|
||||||
|
function focusFormUnlessCaptured() {
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) return;
|
||||||
|
chatFormRef?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!isMobile.current) {
|
if (!isMobile.current) {
|
||||||
setTimeout(() => chatFormRef?.focus(), 100);
|
setTimeout(focusFormUnlessCaptured, 100);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
afterNavigate((navigation) => {
|
afterNavigate((navigation) => {
|
||||||
if (navigation?.from != null && !isMobile.current) {
|
if (navigation?.from != null && !isMobile.current) {
|
||||||
setTimeout(() => chatFormRef?.focus(), 100);
|
setTimeout(focusFormUnlessCaptured, 100);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -127,7 +135,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (previousIsLoading && !isLoading) {
|
if (previousIsLoading && !isLoading) {
|
||||||
setTimeout(() => chatFormRef?.focus(), 10);
|
setTimeout(focusFormUnlessCaptured, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
previousIsLoading = isLoading;
|
previousIsLoading = isLoading;
|
||||||
|
|||||||
@@ -31,14 +31,19 @@ export class DatabaseService {
|
|||||||
* Creates a new conversation.
|
* Creates a new conversation.
|
||||||
*
|
*
|
||||||
* @param name - Name of the conversation
|
* @param name - Name of the conversation
|
||||||
|
* @param fields - Optional extra fields (e.g. reasoningEffort)
|
||||||
* @returns The created conversation
|
* @returns The created conversation
|
||||||
*/
|
*/
|
||||||
static async createConversation(name: string): Promise<DatabaseConversation> {
|
static async createConversation(
|
||||||
|
name: string,
|
||||||
|
fields?: Partial<Omit<DatabaseConversation, 'id' | 'name' | 'lastModified'>>
|
||||||
|
): Promise<DatabaseConversation> {
|
||||||
const conversation: DatabaseConversation = {
|
const conversation: DatabaseConversation = {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
name,
|
name,
|
||||||
lastModified: Date.now(),
|
lastModified: Date.now(),
|
||||||
currNode: ''
|
currNode: '',
|
||||||
|
...fields
|
||||||
};
|
};
|
||||||
|
|
||||||
await db[IDXDB_TABLES.conversations].add(conversation);
|
await db[IDXDB_TABLES.conversations].add(conversation);
|
||||||
@@ -137,7 +142,7 @@ export class DatabaseService {
|
|||||||
* @param systemPrompt - The system prompt content (must be non-empty)
|
* @param systemPrompt - The system prompt content (must be non-empty)
|
||||||
* @param parentId - Parent message ID (typically the root message)
|
* @param parentId - Parent message ID (typically the root message)
|
||||||
* @returns The created system message
|
* @returns The created system message
|
||||||
* @throws Error if systemPrompt is empty
|
* @throws Error if systemPrompt is empty or the parent message does not exist
|
||||||
*/
|
*/
|
||||||
static async createSystemMessage(
|
static async createSystemMessage(
|
||||||
convId: string,
|
convId: string,
|
||||||
@@ -149,6 +154,12 @@ export class DatabaseService {
|
|||||||
throw new Error('Cannot create system message with empty content');
|
throw new Error('Cannot create system message with empty content');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return await db.transaction('rw', db[IDXDB_TABLES.messages], async () => {
|
||||||
|
const parentMessage = await db[IDXDB_TABLES.messages].get(parentId);
|
||||||
|
if (!parentMessage) {
|
||||||
|
throw new Error(`Parent message ${parentId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
const systemMessage: DatabaseMessage = {
|
const systemMessage: DatabaseMessage = {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
convId,
|
convId,
|
||||||
@@ -161,15 +172,12 @@ export class DatabaseService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await db[IDXDB_TABLES.messages].add(systemMessage);
|
await db[IDXDB_TABLES.messages].add(systemMessage);
|
||||||
|
|
||||||
const parentMessage = await db[IDXDB_TABLES.messages].get(parentId);
|
|
||||||
if (parentMessage) {
|
|
||||||
await db[IDXDB_TABLES.messages].update(parentId, {
|
await db[IDXDB_TABLES.messages].update(parentId, {
|
||||||
children: [...parentMessage.children, systemMessage.id]
|
children: [...parentMessage.children, systemMessage.id]
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return systemMessage;
|
return systemMessage;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -442,7 +450,8 @@ export class DatabaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates a conversation.
|
* Updates a conversation. `lastModified` is never stamped implicitly;
|
||||||
|
* pass it in `updates` to bump the conversation in recency ordering.
|
||||||
*
|
*
|
||||||
* @param id - Conversation ID
|
* @param id - Conversation ID
|
||||||
* @param updates - Partial updates to apply
|
* @param updates - Partial updates to apply
|
||||||
@@ -452,10 +461,7 @@ export class DatabaseService {
|
|||||||
id: string,
|
id: string,
|
||||||
updates: Partial<Omit<DatabaseConversation, 'id'>>
|
updates: Partial<Omit<DatabaseConversation, 'id'>>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await db[IDXDB_TABLES.conversations].update(id, {
|
await db[IDXDB_TABLES.conversations].update(id, updates);
|
||||||
...updates,
|
|
||||||
lastModified: Date.now()
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -473,7 +479,7 @@ export class DatabaseService {
|
|||||||
* @returns The new pinned status
|
* @returns The new pinned status
|
||||||
*/
|
*/
|
||||||
static async toggleConversationPin(id: string): Promise<boolean> {
|
static async toggleConversationPin(id: string): Promise<boolean> {
|
||||||
const conversation = await db.conversations.get(id);
|
const conversation = await db[IDXDB_TABLES.conversations].get(id);
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
throw new Error(`Conversation ${id} not found`);
|
throw new Error(`Conversation ${id} not found`);
|
||||||
}
|
}
|
||||||
@@ -497,7 +503,6 @@ export class DatabaseService {
|
|||||||
const result = new Map<string, boolean>();
|
const result = new Map<string, boolean>();
|
||||||
if (cleanIds.length === 0) return result;
|
if (cleanIds.length === 0) return result;
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
await db.transaction('rw', db[IDXDB_TABLES.conversations], async () => {
|
await db.transaction('rw', db[IDXDB_TABLES.conversations], async () => {
|
||||||
const convs = await db[IDXDB_TABLES.conversations].bulkGet(cleanIds);
|
const convs = await db[IDXDB_TABLES.conversations].bulkGet(cleanIds);
|
||||||
const updates: DatabaseConversation[] = [];
|
const updates: DatabaseConversation[] = [];
|
||||||
@@ -505,7 +510,7 @@ export class DatabaseService {
|
|||||||
const conv = convs[i];
|
const conv = convs[i];
|
||||||
if (!conv) continue;
|
if (!conv) continue;
|
||||||
const newPinned = !conv.pinned;
|
const newPinned = !conv.pinned;
|
||||||
updates.push({ ...conv, pinned: newPinned, lastModified: now });
|
updates.push({ ...conv, pinned: newPinned });
|
||||||
result.set(cleanIds[i], newPinned);
|
result.set(cleanIds[i], newPinned);
|
||||||
}
|
}
|
||||||
if (updates.length === 0) return;
|
if (updates.length === 0) return;
|
||||||
|
|||||||
@@ -1658,7 +1658,8 @@ class ChatStore {
|
|||||||
generateConversationTitle(newContent, Boolean(config().titleGenerationUseFirstLine))
|
generateConversationTitle(newContent, Boolean(config().titleGenerationUseFirstLine))
|
||||||
);
|
);
|
||||||
const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex + 1);
|
const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex + 1);
|
||||||
for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id);
|
if (messagesToRemove.length > 0)
|
||||||
|
await DatabaseService.deleteMessageCascading(activeConv.id, messagesToRemove[0].id);
|
||||||
conversationsStore.sliceActiveMessages(messageIndex + 1);
|
conversationsStore.sliceActiveMessages(messageIndex + 1);
|
||||||
conversationsStore.updateConversationTimestamp();
|
conversationsStore.updateConversationTimestamp();
|
||||||
this.setChatLoading(activeConv.id, true);
|
this.setChatLoading(activeConv.id, true);
|
||||||
@@ -1690,7 +1691,7 @@ class ChatStore {
|
|||||||
const { index: messageIndex } = result;
|
const { index: messageIndex } = result;
|
||||||
try {
|
try {
|
||||||
const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex);
|
const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex);
|
||||||
for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id);
|
await DatabaseService.deleteMessageCascading(activeConv.id, messagesToRemove[0].id);
|
||||||
conversationsStore.sliceActiveMessages(messageIndex);
|
conversationsStore.sliceActiveMessages(messageIndex);
|
||||||
conversationsStore.updateConversationTimestamp();
|
conversationsStore.updateConversationTimestamp();
|
||||||
this.setChatLoading(activeConv.id, true);
|
this.setChatLoading(activeConv.id, true);
|
||||||
@@ -2037,7 +2038,7 @@ class ChatStore {
|
|||||||
timings
|
timings
|
||||||
});
|
});
|
||||||
|
|
||||||
conversationsStore.updateConversationTimestamp();
|
conversationsStore.updateConversationTimestamp(msg.convId);
|
||||||
|
|
||||||
this.setChatLoading(msg.convId, false);
|
this.setChatLoading(msg.convId, false);
|
||||||
this.clearChatStreaming(msg.convId);
|
this.clearChatStreaming(msg.convId);
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ class ConversationsStore {
|
|||||||
| ((messageId: string, updates: Partial<DatabaseMessage>) => void)
|
| ((messageId: string, updates: Partial<DatabaseMessage>) => void)
|
||||||
| null = null;
|
| null = null;
|
||||||
|
|
||||||
|
/** In-flight init run; shared by concurrent callers, reset on failure to allow retry */
|
||||||
|
private initPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
*
|
*
|
||||||
@@ -121,19 +124,25 @@ class ConversationsStore {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the store by loading conversations from database.
|
* Initialize the store by loading conversations from database.
|
||||||
* Must be called once after app startup.
|
* Safe to call multiple times: concurrent callers share a single run,
|
||||||
|
* and a failed run can be retried by calling again.
|
||||||
*/
|
*/
|
||||||
async init(): Promise<void> {
|
init(): Promise<void> {
|
||||||
if (!browser) return;
|
if (!browser) return Promise.resolve();
|
||||||
if (this.isInitialized) return;
|
if (this.initPromise) return this.initPromise;
|
||||||
|
|
||||||
|
this.initPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
await MigrationService.runAllMigrations();
|
await MigrationService.runAllMigrations();
|
||||||
await this.loadConversations();
|
await this.loadConversations();
|
||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize conversations:', error);
|
console.error('Failed to initialize conversations:', error);
|
||||||
|
this.initPromise = null;
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return this.initPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -237,15 +246,11 @@ class ConversationsStore {
|
|||||||
*/
|
*/
|
||||||
async createConversation(name?: string): Promise<string> {
|
async createConversation(name?: string): Promise<string> {
|
||||||
const conversationName = name || `Chat ${new Date().toLocaleString()}`;
|
const conversationName = name || `Chat ${new Date().toLocaleString()}`;
|
||||||
const conversation = await DatabaseService.createConversation(conversationName);
|
|
||||||
|
|
||||||
// No MCP override list is seeded: getAllMcpServerOverrides resolves
|
// No MCP override list is seeded: getAllMcpServerOverrides resolves
|
||||||
// servers without a per-conversation override to `mcpServers[i].enabled`,
|
// servers without a per-conversation override to `mcpServers[i].enabled`,
|
||||||
// and only explicit toggles are stored on the conversation.
|
// and only explicit toggles are stored on the conversation.
|
||||||
|
const conversation = await DatabaseService.createConversation(conversationName, {
|
||||||
// Inherit the global reasoning default into the new conversation
|
|
||||||
conversation.reasoningEffort = this.pendingReasoningEffort;
|
|
||||||
await DatabaseService.updateConversation(conversation.id, {
|
|
||||||
reasoningEffort: this.pendingReasoningEffort
|
reasoningEffort: this.pendingReasoningEffort
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -358,10 +363,7 @@ class ConversationsStore {
|
|||||||
async deleteAll(): Promise<void> {
|
async deleteAll(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const allConversations = await DatabaseService.getAllConversations();
|
const allConversations = await DatabaseService.getAllConversations();
|
||||||
|
await DatabaseService.bulkDeleteConversations(allConversations.map((c) => c.id));
|
||||||
for (const conv of allConversations) {
|
|
||||||
await DatabaseService.deleteConversation(conv.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.clearActiveConversation();
|
this.clearActiveConversation();
|
||||||
this.conversations = [];
|
this.conversations = [];
|
||||||
@@ -412,7 +414,9 @@ class ConversationsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
convIds.length === 1 ? 'Conversation deleted' : `${convIds.length} conversations deleted`
|
idsToRemove.size === 1
|
||||||
|
? 'Conversation deleted'
|
||||||
|
: `${idsToRemove.size} conversations deleted`
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to bulk delete conversations:', error);
|
console.error('Failed to bulk delete conversations:', error);
|
||||||
@@ -443,7 +447,6 @@ class ConversationsStore {
|
|||||||
const newPinned = updates.get(this.conversations[i].id);
|
const newPinned = updates.get(this.conversations[i].id);
|
||||||
if (newPinned !== undefined) this.conversations[i].pinned = newPinned;
|
if (newPinned !== undefined) this.conversations[i].pinned = newPinned;
|
||||||
}
|
}
|
||||||
this.conversations = [...this.conversations];
|
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
convIds.length === 1
|
convIds.length === 1
|
||||||
@@ -552,7 +555,6 @@ class ConversationsStore {
|
|||||||
|
|
||||||
if (convIndex !== -1) {
|
if (convIndex !== -1) {
|
||||||
this.conversations[convIndex].name = name;
|
this.conversations[convIndex].name = name;
|
||||||
this.conversations = [...this.conversations];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.activeConversation?.id === convId) {
|
if (this.activeConversation?.id === convId) {
|
||||||
@@ -576,7 +578,6 @@ class ConversationsStore {
|
|||||||
|
|
||||||
if (convIndex !== -1) {
|
if (convIndex !== -1) {
|
||||||
this.conversations[convIndex].pinned = newPinnedState;
|
this.conversations[convIndex].pinned = newPinnedState;
|
||||||
this.conversations = [...this.conversations];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.activeConversation?.id === convId) {
|
if (this.activeConversation?.id === convId) {
|
||||||
@@ -591,18 +592,33 @@ class ConversationsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates conversation lastModified timestamp and moves it to top of list
|
* Marks a conversation as recently active: stamps lastModified (persisted)
|
||||||
|
* and moves it to the top of the list. Only message-activity flows call
|
||||||
|
* this; metadata updates (rename, pin, settings) do not.
|
||||||
|
*
|
||||||
|
* @param convId - Conversation that produced the activity, defaults to the active one
|
||||||
*/
|
*/
|
||||||
updateConversationTimestamp(): void {
|
updateConversationTimestamp(convId?: string): void {
|
||||||
if (!this.activeConversation) return;
|
const targetId = convId ?? this.activeConversation?.id;
|
||||||
|
if (!targetId) return;
|
||||||
|
|
||||||
const chatIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
|
const now = Date.now();
|
||||||
|
|
||||||
|
const chatIndex = this.conversations.findIndex((c) => c.id === targetId);
|
||||||
|
|
||||||
if (chatIndex !== -1) {
|
if (chatIndex !== -1) {
|
||||||
this.conversations[chatIndex].lastModified = Date.now();
|
this.conversations[chatIndex].lastModified = now;
|
||||||
const updatedConv = this.conversations.splice(chatIndex, 1)[0];
|
const updatedConv = this.conversations.splice(chatIndex, 1)[0];
|
||||||
this.conversations = [updatedConv, ...this.conversations];
|
this.conversations = [updatedConv, ...this.conversations];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.activeConversation?.id === targetId) {
|
||||||
|
this.activeConversation = { ...this.activeConversation, lastModified: now };
|
||||||
|
}
|
||||||
|
|
||||||
|
DatabaseService.updateConversation(targetId, { lastModified: now }).catch((error) =>
|
||||||
|
console.error('Failed to update conversation timestamp:', error)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -773,7 +789,6 @@ class ConversationsStore {
|
|||||||
if (convIndex !== -1) {
|
if (convIndex !== -1) {
|
||||||
this.conversations[convIndex].mcpServerOverrides =
|
this.conversations[convIndex].mcpServerOverrides =
|
||||||
newOverrides.length > 0 ? newOverrides : undefined;
|
newOverrides.length > 0 ? newOverrides : undefined;
|
||||||
this.conversations = [...this.conversations];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -837,7 +852,6 @@ class ConversationsStore {
|
|||||||
const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
|
const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
|
||||||
if (convIndex !== -1) {
|
if (convIndex !== -1) {
|
||||||
this.conversations[convIndex].reasoningEffort = effort;
|
this.conversations[convIndex].reasoningEffort = effort;
|
||||||
this.conversations = [...this.conversations];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,364 +0,0 @@
|
|||||||
/**
|
|
||||||
* @deprecated Legacy migration utility — remove at some point in the future once all users have migrated to the new structured agentic message format.
|
|
||||||
*
|
|
||||||
* Converts old marker-based agentic messages to the new structured format
|
|
||||||
* with separate messages per turn.
|
|
||||||
*
|
|
||||||
* Old format: Single assistant message with markers in content:
|
|
||||||
* <<<reasoning_content_start>>>...<<<reasoning_content_end>>>
|
|
||||||
* <<<AGENTIC_TOOL_CALL_START>>>...<<<AGENTIC_TOOL_CALL_END>>>
|
|
||||||
*
|
|
||||||
* New format: Separate messages per turn:
|
|
||||||
* - assistant (content + reasoningContent + toolCalls)
|
|
||||||
* - tool (toolCallId + content)
|
|
||||||
* - assistant (next turn)
|
|
||||||
* - ...
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { LEGACY_AGENTIC_REGEX, LEGACY_REASONING_TAGS } from '$lib/constants';
|
|
||||||
import { DatabaseService } from '$lib/services/database.service';
|
|
||||||
import { MessageRole, MessageType } from '$lib/enums';
|
|
||||||
import type { DatabaseMessage } from '$lib/types/database';
|
|
||||||
|
|
||||||
const MIGRATION_DONE_KEY = 'llama-ui-migration-v2-done';
|
|
||||||
/** @deprecated Use {@link MIGRATION_DONE_KEY} instead */
|
|
||||||
const DEPRECATED_MIGRATION_DONE_KEY = 'llama-webui-migration-v2-done';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Part of legacy migration — remove with the migration module.
|
|
||||||
* Check if migration has been performed.
|
|
||||||
*/
|
|
||||||
export function isMigrationNeeded(): boolean {
|
|
||||||
try {
|
|
||||||
// Check new key first, fall back to deprecated old key
|
|
||||||
if (localStorage.getItem(MIGRATION_DONE_KEY)) return false;
|
|
||||||
if (localStorage.getItem(DEPRECATED_MIGRATION_DONE_KEY)) {
|
|
||||||
// Migrate to new key
|
|
||||||
try {
|
|
||||||
localStorage.setItem(MIGRATION_DONE_KEY, String(Date.now()));
|
|
||||||
localStorage.removeItem(DEPRECATED_MIGRATION_DONE_KEY);
|
|
||||||
} catch {
|
|
||||||
// Ignore storage errors
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark migration as done.
|
|
||||||
*/
|
|
||||||
function markMigrationDone(): void {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(MIGRATION_DONE_KEY, String(Date.now()));
|
|
||||||
} catch {
|
|
||||||
// Ignore localStorage errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a message has legacy markers in its content.
|
|
||||||
*/
|
|
||||||
function hasLegacyMarkers(message: DatabaseMessage): boolean {
|
|
||||||
if (!message.content) return false;
|
|
||||||
return LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test(message.content);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract reasoning content from legacy marker format.
|
|
||||||
*/
|
|
||||||
function extractLegacyReasoning(content: string): { reasoning: string; cleanContent: string } {
|
|
||||||
let reasoning = '';
|
|
||||||
let cleanContent = content;
|
|
||||||
|
|
||||||
// Extract all reasoning blocks
|
|
||||||
const re = new RegExp(LEGACY_AGENTIC_REGEX.REASONING_EXTRACT.source, 'g');
|
|
||||||
let match;
|
|
||||||
while ((match = re.exec(content)) !== null) {
|
|
||||||
reasoning += match[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove reasoning tags from content
|
|
||||||
cleanContent = cleanContent
|
|
||||||
.replace(new RegExp(LEGACY_AGENTIC_REGEX.REASONING_BLOCK.source, 'g'), '')
|
|
||||||
.replace(LEGACY_AGENTIC_REGEX.REASONING_OPEN, '');
|
|
||||||
|
|
||||||
return { reasoning, cleanContent };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse legacy content with tool call markers into structured turns.
|
|
||||||
*/
|
|
||||||
interface ParsedTurn {
|
|
||||||
textBefore: string;
|
|
||||||
toolCalls: Array<{
|
|
||||||
name: string;
|
|
||||||
args: string;
|
|
||||||
result: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseLegacyToolCalls(content: string): ParsedTurn[] {
|
|
||||||
const turns: ParsedTurn[] = [];
|
|
||||||
const regex = new RegExp(LEGACY_AGENTIC_REGEX.COMPLETED_TOOL_CALL.source, 'g');
|
|
||||||
|
|
||||||
let lastIndex = 0;
|
|
||||||
let currentTurn: ParsedTurn = { textBefore: '', toolCalls: [] };
|
|
||||||
let match;
|
|
||||||
|
|
||||||
while ((match = regex.exec(content)) !== null) {
|
|
||||||
const textBefore = content.slice(lastIndex, match.index).trim();
|
|
||||||
|
|
||||||
// If there's text between tool calls and we already have tool calls,
|
|
||||||
// that means a new turn started (text after tool results = new LLM turn)
|
|
||||||
if (textBefore && currentTurn.toolCalls.length > 0) {
|
|
||||||
turns.push(currentTurn);
|
|
||||||
currentTurn = { textBefore, toolCalls: [] };
|
|
||||||
} else if (textBefore && currentTurn.toolCalls.length === 0) {
|
|
||||||
currentTurn.textBefore = textBefore;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentTurn.toolCalls.push({
|
|
||||||
name: match[1],
|
|
||||||
args: match[2],
|
|
||||||
result: match[3].replace(/^\n+|\n+$/g, '')
|
|
||||||
});
|
|
||||||
|
|
||||||
lastIndex = match.index + match[0].length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Any remaining text after the last tool call
|
|
||||||
const remainingText = content.slice(lastIndex).trim();
|
|
||||||
|
|
||||||
if (currentTurn.toolCalls.length > 0) {
|
|
||||||
turns.push(currentTurn);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there's text after all tool calls, it's the final assistant response
|
|
||||||
if (remainingText) {
|
|
||||||
// Remove any partial/open markers
|
|
||||||
const cleanRemaining = remainingText
|
|
||||||
.replace(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '')
|
|
||||||
.trim();
|
|
||||||
if (cleanRemaining) {
|
|
||||||
turns.push({ textBefore: cleanRemaining, toolCalls: [] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no tool calls found at all, return the original content as a single turn
|
|
||||||
if (turns.length === 0) {
|
|
||||||
turns.push({ textBefore: content.trim(), toolCalls: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
return turns;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrate a single conversation's messages from legacy format to new format.
|
|
||||||
*/
|
|
||||||
async function migrateConversation(convId: string): Promise<number> {
|
|
||||||
const allMessages = await DatabaseService.getConversationMessages(convId);
|
|
||||||
let migratedCount = 0;
|
|
||||||
|
|
||||||
for (const message of allMessages) {
|
|
||||||
if (message.role !== MessageRole.ASSISTANT) continue;
|
|
||||||
if (!hasLegacyMarkers(message)) {
|
|
||||||
// Still check for reasoning-only markers (no tool calls)
|
|
||||||
if (message.content?.includes(LEGACY_REASONING_TAGS.START)) {
|
|
||||||
const { reasoning, cleanContent } = extractLegacyReasoning(message.content);
|
|
||||||
await DatabaseService.updateMessage(message.id, {
|
|
||||||
content: cleanContent.trim(),
|
|
||||||
reasoningContent: reasoning || undefined
|
|
||||||
});
|
|
||||||
migratedCount++;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Has agentic markers - full migration needed
|
|
||||||
const { reasoning, cleanContent } = extractLegacyReasoning(message.content);
|
|
||||||
const turns = parseLegacyToolCalls(cleanContent);
|
|
||||||
|
|
||||||
// Parse existing toolCalls JSON to try to match IDs
|
|
||||||
let existingToolCalls: Array<{
|
|
||||||
id: string;
|
|
||||||
function?: { name: string; arguments: string };
|
|
||||||
}> = [];
|
|
||||||
if (message.toolCalls) {
|
|
||||||
try {
|
|
||||||
existingToolCalls = JSON.parse(message.toolCalls);
|
|
||||||
} catch {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// First turn uses the existing message
|
|
||||||
const firstTurn = turns[0];
|
|
||||||
if (!firstTurn) continue;
|
|
||||||
|
|
||||||
// Match tool calls from the first turn to existing IDs
|
|
||||||
const firstTurnToolCalls = firstTurn.toolCalls.map((tc, i) => {
|
|
||||||
const existing =
|
|
||||||
existingToolCalls.find((e) => e.function?.name === tc.name) || existingToolCalls[i];
|
|
||||||
return {
|
|
||||||
id: existing?.id || `legacy_tool_${i}`,
|
|
||||||
type: 'function' as const,
|
|
||||||
function: { name: tc.name, arguments: tc.args }
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the existing message for the first turn
|
|
||||||
await DatabaseService.updateMessage(message.id, {
|
|
||||||
content: firstTurn.textBefore,
|
|
||||||
reasoningContent: reasoning || undefined,
|
|
||||||
toolCalls: firstTurnToolCalls.length > 0 ? JSON.stringify(firstTurnToolCalls) : ''
|
|
||||||
});
|
|
||||||
|
|
||||||
let currentParentId = message.id;
|
|
||||||
let toolCallIdCounter = existingToolCalls.length;
|
|
||||||
|
|
||||||
// Create tool result messages for the first turn
|
|
||||||
for (let i = 0; i < firstTurn.toolCalls.length; i++) {
|
|
||||||
const tc = firstTurn.toolCalls[i];
|
|
||||||
const toolCallId = firstTurnToolCalls[i]?.id || `legacy_tool_${i}`;
|
|
||||||
|
|
||||||
const toolMsg = await DatabaseService.createMessageBranch(
|
|
||||||
{
|
|
||||||
convId,
|
|
||||||
type: MessageType.TEXT,
|
|
||||||
role: MessageRole.TOOL,
|
|
||||||
content: tc.result,
|
|
||||||
toolCallId,
|
|
||||||
timestamp: message.timestamp + i + 1,
|
|
||||||
toolCalls: '',
|
|
||||||
children: []
|
|
||||||
},
|
|
||||||
currentParentId
|
|
||||||
);
|
|
||||||
currentParentId = toolMsg.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create messages for subsequent turns
|
|
||||||
for (let turnIdx = 1; turnIdx < turns.length; turnIdx++) {
|
|
||||||
const turn = turns[turnIdx];
|
|
||||||
|
|
||||||
const turnToolCalls = turn.toolCalls.map((tc, i) => {
|
|
||||||
const idx = toolCallIdCounter + i;
|
|
||||||
const existing = existingToolCalls[idx];
|
|
||||||
return {
|
|
||||||
id: existing?.id || `legacy_tool_${idx}`,
|
|
||||||
type: 'function' as const,
|
|
||||||
function: { name: tc.name, arguments: tc.args }
|
|
||||||
};
|
|
||||||
});
|
|
||||||
toolCallIdCounter += turn.toolCalls.length;
|
|
||||||
|
|
||||||
// Create assistant message for this turn
|
|
||||||
const assistantMsg = await DatabaseService.createMessageBranch(
|
|
||||||
{
|
|
||||||
convId,
|
|
||||||
type: MessageType.TEXT,
|
|
||||||
role: MessageRole.ASSISTANT,
|
|
||||||
content: turn.textBefore,
|
|
||||||
timestamp: message.timestamp + turnIdx * 100,
|
|
||||||
toolCalls: turnToolCalls.length > 0 ? JSON.stringify(turnToolCalls) : '',
|
|
||||||
children: [],
|
|
||||||
model: message.model
|
|
||||||
},
|
|
||||||
currentParentId
|
|
||||||
);
|
|
||||||
currentParentId = assistantMsg.id;
|
|
||||||
|
|
||||||
// Create tool result messages for this turn
|
|
||||||
for (let i = 0; i < turn.toolCalls.length; i++) {
|
|
||||||
const tc = turn.toolCalls[i];
|
|
||||||
const toolCallId = turnToolCalls[i]?.id || `legacy_tool_${toolCallIdCounter + i}`;
|
|
||||||
|
|
||||||
const toolMsg = await DatabaseService.createMessageBranch(
|
|
||||||
{
|
|
||||||
convId,
|
|
||||||
type: MessageType.TEXT,
|
|
||||||
role: MessageRole.TOOL,
|
|
||||||
content: tc.result,
|
|
||||||
toolCallId,
|
|
||||||
timestamp: message.timestamp + turnIdx * 100 + i + 1,
|
|
||||||
toolCalls: '',
|
|
||||||
children: []
|
|
||||||
},
|
|
||||||
currentParentId
|
|
||||||
);
|
|
||||||
currentParentId = toolMsg.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-parent any children of the original message to the last created message
|
|
||||||
// (the original message's children list was the next user message or similar)
|
|
||||||
if (message.children.length > 0 && currentParentId !== message.id) {
|
|
||||||
for (const childId of message.children) {
|
|
||||||
// Skip children we just created (they were already properly parented)
|
|
||||||
const child = allMessages.find((m) => m.id === childId);
|
|
||||||
if (!child) continue;
|
|
||||||
// Only re-parent non-tool messages that were original children
|
|
||||||
if (child.role !== MessageRole.TOOL) {
|
|
||||||
await DatabaseService.updateMessage(childId, { parent: currentParentId });
|
|
||||||
// Add to new parent's children
|
|
||||||
const newParent = await DatabaseService.getConversationMessages(convId).then((msgs) =>
|
|
||||||
msgs.find((m) => m.id === currentParentId)
|
|
||||||
);
|
|
||||||
if (newParent && !newParent.children.includes(childId)) {
|
|
||||||
await DatabaseService.updateMessage(currentParentId, {
|
|
||||||
children: [...newParent.children, childId]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Clear re-parented children from the original message
|
|
||||||
await DatabaseService.updateMessage(message.id, { children: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
migratedCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return migratedCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Part of legacy migration — remove with the migration module.
|
|
||||||
* Run the full migration across all conversations.
|
|
||||||
* This should be called once at app startup if migration is needed.
|
|
||||||
*/
|
|
||||||
export async function runLegacyMigration(): Promise<void> {
|
|
||||||
if (!isMigrationNeeded()) return;
|
|
||||||
|
|
||||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
|
|
||||||
console.log('[Migration] Starting legacy message format migration...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const conversations = await DatabaseService.getAllConversations();
|
|
||||||
let totalMigrated = 0;
|
|
||||||
|
|
||||||
for (const conv of conversations) {
|
|
||||||
const count = await migrateConversation(conv.id);
|
|
||||||
totalMigrated += count;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
|
|
||||||
if (totalMigrated > 0) {
|
|
||||||
console.log(
|
|
||||||
`[Migration] Migrated ${totalMigrated} messages across ${conversations.length} conversations`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log('[Migration] No legacy messages found, marking as done');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
markMigrationDone();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Migration] Failed to migrate legacy messages:', error);
|
|
||||||
// Still mark as done to avoid infinite retry loops
|
|
||||||
markMigrationDone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user