webui: Conversation forking + branching improvements (#21021)
* refactor: Make `DialogConfirmation` extensible with children slot * feat: Add conversation forking logic * feat: Conversation forking UI * feat: Update delete/edit dialogs and logic for forks * refactor: Improve Chat Sidebar UX and add MCP Servers entry * refactor: Cleanup * feat: Update message in place when editing leaf nodes * chore: Cleanup * chore: Cleanup * chore: Cleanup * chore: Cleanup * chore: Cleanup * chore: Cleanup * refactor: Post-review improvements * chore: update webui build output * test: Update Storybook test * chore: update webui build output * chore: update webui build output
This commit is contained in:
committed by
GitHub
parent
b0f0dd3e51
commit
51a84efc53
@@ -1,5 +1,6 @@
|
||||
import Dexie, { type EntityTable } from 'dexie';
|
||||
import { findDescendantMessages, uuid } from '$lib/utils';
|
||||
import { findDescendantMessages, uuid, filterByLeafNodeId } from '$lib/utils';
|
||||
import type { McpServerOverride } from '$lib/types/database';
|
||||
|
||||
class LlamacppDatabase extends Dexie {
|
||||
conversations!: EntityTable<DatabaseConversation, string>;
|
||||
@@ -173,8 +174,47 @@ export class DatabaseService {
|
||||
*
|
||||
* @param id - Conversation ID
|
||||
*/
|
||||
static async deleteConversation(id: string): Promise<void> {
|
||||
static async deleteConversation(
|
||||
id: string,
|
||||
options?: { deleteWithForks?: boolean }
|
||||
): Promise<void> {
|
||||
await db.transaction('rw', [db.conversations, db.messages], async () => {
|
||||
if (options?.deleteWithForks) {
|
||||
// Recursively collect all descendant IDs
|
||||
const idsToDelete: string[] = [];
|
||||
const queue = [id];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const parentId = queue.pop()!;
|
||||
const children = await db.conversations
|
||||
.filter((c) => c.forkedFromConversationId === parentId)
|
||||
.toArray();
|
||||
|
||||
for (const child of children) {
|
||||
idsToDelete.push(child.id);
|
||||
queue.push(child.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const forkId of idsToDelete) {
|
||||
await db.conversations.delete(forkId);
|
||||
await db.messages.where('convId').equals(forkId).delete();
|
||||
}
|
||||
} else {
|
||||
// Reparent direct children to deleted conv's parent
|
||||
const conv = await db.conversations.get(id);
|
||||
const newParent = conv?.forkedFromConversationId;
|
||||
const directChildren = await db.conversations
|
||||
.filter((c) => c.forkedFromConversationId === id)
|
||||
.toArray();
|
||||
|
||||
for (const child of directChildren) {
|
||||
await db.conversations.update(child.id, {
|
||||
forkedFromConversationId: newParent ?? undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await db.conversations.delete(id);
|
||||
await db.messages.where('convId').equals(id).delete();
|
||||
});
|
||||
@@ -364,4 +404,88 @@ export class DatabaseService {
|
||||
return { imported: importedCount, skipped: skippedCount };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Forking
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Forks a conversation at a specific message, creating a new conversation
|
||||
* containing all messages from the root up to (and including) the target message.
|
||||
*
|
||||
* @param sourceConvId - The source conversation ID
|
||||
* @param atMessageId - The message ID to fork at (the new conversation ends here)
|
||||
* @param options - Fork options (name and whether to include attachments)
|
||||
* @returns The newly created conversation
|
||||
*/
|
||||
static async forkConversation(
|
||||
sourceConvId: string,
|
||||
atMessageId: string,
|
||||
options: { name: string; includeAttachments: boolean }
|
||||
): Promise<DatabaseConversation> {
|
||||
return await db.transaction('rw', [db.conversations, db.messages], async () => {
|
||||
const sourceConv = await db.conversations.get(sourceConvId);
|
||||
if (!sourceConv) {
|
||||
throw new Error(`Source conversation ${sourceConvId} not found`);
|
||||
}
|
||||
|
||||
const allMessages = await db.messages.where('convId').equals(sourceConvId).toArray();
|
||||
|
||||
const pathMessages = filterByLeafNodeId(allMessages, atMessageId, true) as DatabaseMessage[];
|
||||
if (pathMessages.length === 0) {
|
||||
throw new Error(`Could not resolve message path to ${atMessageId}`);
|
||||
}
|
||||
|
||||
const idMap = new Map<string, string>();
|
||||
|
||||
for (const msg of pathMessages) {
|
||||
idMap.set(msg.id, uuid());
|
||||
}
|
||||
|
||||
const newConvId = uuid();
|
||||
const clonedMessages: DatabaseMessage[] = pathMessages.map((msg) => {
|
||||
const newId = idMap.get(msg.id)!;
|
||||
const newParent = msg.parent ? (idMap.get(msg.parent) ?? null) : null;
|
||||
const newChildren = msg.children
|
||||
.filter((childId: string) => idMap.has(childId))
|
||||
.map((childId: string) => idMap.get(childId)!);
|
||||
|
||||
return {
|
||||
...msg,
|
||||
id: newId,
|
||||
convId: newConvId,
|
||||
parent: newParent,
|
||||
children: newChildren,
|
||||
extra: options.includeAttachments ? msg.extra : undefined
|
||||
};
|
||||
});
|
||||
|
||||
const lastClonedMessage = clonedMessages[clonedMessages.length - 1];
|
||||
const newConv: DatabaseConversation = {
|
||||
id: newConvId,
|
||||
name: options.name,
|
||||
lastModified: Date.now(),
|
||||
currNode: lastClonedMessage.id,
|
||||
forkedFromConversationId: sourceConvId,
|
||||
mcpServerOverrides: sourceConv.mcpServerOverrides
|
||||
? sourceConv.mcpServerOverrides.map((o: McpServerOverride) => ({
|
||||
serverId: o.serverId,
|
||||
enabled: o.enabled
|
||||
}))
|
||||
: undefined
|
||||
};
|
||||
|
||||
await db.conversations.add(newConv);
|
||||
|
||||
for (const msg of clonedMessages) {
|
||||
await db.messages.add(msg);
|
||||
}
|
||||
|
||||
return newConv;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user