* server : extend file_glob_search for UI pickers * ui : add per-conversation working directory with picker * ui : add path navigation and search scope to cwd picker Treat path-like queries (starting with / or ~) as directory navigation instead of glob-matching the whole query: search the parent for the last segment, and descend into an exactly-typed directory by listing its children. Show the effective search scope in the footer and auto-search on open so the current directory and its siblings appear immediately. Assisted-by: Claude * db : persist per-call tool cwd on tool result messages * ui : abbreviate tool paths under home with a tilde * ui : show the per-call cwd on exec shell rows * ui : clarify the synthetic cwd message for the model * ui : reuse the trailing cwd row on a repeated pick * ui : don't jump when a cwd row is injected mid-chat * chore: Formatting * refactor: Cleanup comments * ui : unify working directory naming and add a synthetic-message flag * ui : render synthetic cwd rows without a scroll jump * ui : decouple the working directory picker into utils and sub-components * ui : add get_info tool call block * chore: Formatting * refactor: Cleanup * refactor: Cleanup * refactor: Cleanup * fix: UI * server : harden file_glob_search listing (kind enum, timeout, symlink guard, absolute base) * ui : use persisted isSynthetic flag for cwd rows, drop legacy formats * ui : cache picker search, fail visibly on native resolve * ui : escape glob metacharacters in picker search glob * ui : simplify auto-scroll pin * chore: Format * fix: Use `SvelteMap` * refactor: Post-review fixes * ui: accept Windows roots in the working directory picker recognize a drive root (C:) and a UNC share (//host/share) as path navigation, alongside the POSIX root and ~, so a query like D:\repos lists that directory instead of glob-matching it under the home dir split below the root, so a bare drive resolves to its root rather than to a drive-relative prefix rewrite backslashes into forward slashes only when the query carries a Windows root, since a backslash is a legal POSIX filename character paths keep travelling with forward slashes, which is what the server returns and what Windows accepts --------- Co-authored-by: Pascal <admin@serveurperso.com>
692 lines
20 KiB
TypeScript
692 lines
20 KiB
TypeScript
import Dexie, { type EntityTable } from 'dexie';
|
|
import { findDescendantMessages, uuid, filterByLeafNodeId } from '$lib/utils';
|
|
import { IDXDB_TABLES, IDXDB_STORES, STORAGE_APP_NAME } from '$lib/constants';
|
|
import { MessageRole } from '$lib/enums';
|
|
import type { McpServerOverride } from '$lib/types/database';
|
|
import type { ExportedConversation } from '$lib/types/database';
|
|
|
|
class LlamaUiDatabase extends Dexie {
|
|
[IDXDB_TABLES.conversations]!: EntityTable<DatabaseConversation, string>;
|
|
[IDXDB_TABLES.messages]!: EntityTable<DatabaseMessage, string>;
|
|
|
|
constructor() {
|
|
super(STORAGE_APP_NAME);
|
|
|
|
this.version(1).stores(IDXDB_STORES);
|
|
}
|
|
}
|
|
|
|
const db = new LlamaUiDatabase();
|
|
|
|
export class DatabaseService {
|
|
/**
|
|
*
|
|
*
|
|
* Conversations
|
|
*
|
|
*
|
|
*/
|
|
|
|
/**
|
|
* Creates a new conversation.
|
|
*
|
|
* @param name - Name of the conversation
|
|
* @param fields - Optional extra fields (e.g. reasoningEffort)
|
|
* @returns The created conversation
|
|
*/
|
|
static async createConversation(
|
|
name: string,
|
|
fields?: Partial<Omit<DatabaseConversation, 'id' | 'name' | 'lastModified'>>
|
|
): Promise<DatabaseConversation> {
|
|
const conversation: DatabaseConversation = {
|
|
id: uuid(),
|
|
name,
|
|
lastModified: Date.now(),
|
|
currNode: '',
|
|
...fields
|
|
};
|
|
|
|
await db[IDXDB_TABLES.conversations].add(conversation);
|
|
return conversation;
|
|
}
|
|
|
|
/**
|
|
*
|
|
*
|
|
* Messages
|
|
*
|
|
*
|
|
*/
|
|
|
|
/**
|
|
* Creates a new message branch by adding a message and updating parent/child relationships.
|
|
* Also updates the conversation's currNode to point to the new message.
|
|
*
|
|
* @param message - Message to add (without id)
|
|
* @param parentId - Parent message ID to attach to
|
|
* @returns The created message
|
|
*/
|
|
static async createMessageBranch(
|
|
message: Omit<DatabaseMessage, 'id'>,
|
|
parentId: string | null
|
|
): Promise<DatabaseMessage> {
|
|
return await db.transaction(
|
|
'rw',
|
|
[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
|
|
async () => {
|
|
// Handle null parent (root message case)
|
|
if (parentId !== null) {
|
|
const parentMessage = await db[IDXDB_TABLES.messages].get(parentId);
|
|
if (!parentMessage) {
|
|
throw new Error(`Parent message ${parentId} not found`);
|
|
}
|
|
}
|
|
|
|
const newMessage: DatabaseMessage = {
|
|
...message,
|
|
id: uuid(),
|
|
parent: parentId,
|
|
toolCalls: message.toolCalls ?? '',
|
|
children: []
|
|
};
|
|
|
|
await db[IDXDB_TABLES.messages].add(newMessage);
|
|
|
|
// Update parent's children array if parent exists
|
|
if (parentId !== null) {
|
|
const parentMessage = await db[IDXDB_TABLES.messages].get(parentId);
|
|
if (parentMessage) {
|
|
await db[IDXDB_TABLES.messages].update(parentId, {
|
|
children: [...parentMessage.children, newMessage.id]
|
|
});
|
|
}
|
|
}
|
|
|
|
await this.updateConversation(message.convId, {
|
|
currNode: newMessage.id
|
|
});
|
|
|
|
return newMessage;
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Creates a root message for a new conversation.
|
|
* Root messages are not displayed but serve as the tree root for branching.
|
|
*
|
|
* @param convId - Conversation ID
|
|
* @returns The created root message
|
|
*/
|
|
static async createRootMessage(convId: string): Promise<string> {
|
|
const rootMessage: DatabaseMessage = {
|
|
id: uuid(),
|
|
convId,
|
|
type: 'root',
|
|
timestamp: Date.now(),
|
|
role: MessageRole.SYSTEM,
|
|
content: '',
|
|
parent: null,
|
|
toolCalls: '',
|
|
children: []
|
|
};
|
|
|
|
await db[IDXDB_TABLES.messages].add(rootMessage);
|
|
return rootMessage.id;
|
|
}
|
|
|
|
/**
|
|
* Creates a system prompt message for a conversation.
|
|
*
|
|
* @param convId - Conversation ID
|
|
* @param systemPrompt - The system prompt content (must be non-empty)
|
|
* @param parentId - Parent message ID (typically the root message)
|
|
* @returns The created system message
|
|
* @throws Error if systemPrompt is empty or the parent message does not exist
|
|
*/
|
|
static async createSystemMessage(
|
|
convId: string,
|
|
systemPrompt: string,
|
|
parentId: string
|
|
): Promise<DatabaseMessage> {
|
|
const trimmedPrompt = systemPrompt.trim();
|
|
if (!trimmedPrompt) {
|
|
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 = {
|
|
id: uuid(),
|
|
convId,
|
|
type: MessageRole.SYSTEM,
|
|
timestamp: Date.now(),
|
|
role: MessageRole.SYSTEM,
|
|
content: trimmedPrompt,
|
|
parent: parentId,
|
|
children: []
|
|
};
|
|
|
|
await db[IDXDB_TABLES.messages].add(systemMessage);
|
|
await db[IDXDB_TABLES.messages].update(parentId, {
|
|
children: [...parentMessage.children, systemMessage.id]
|
|
});
|
|
|
|
return systemMessage;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Deletes a conversation and all its messages.
|
|
*
|
|
* @param id - Conversation ID
|
|
*/
|
|
static async deleteConversation(
|
|
id: string,
|
|
options?: { deleteWithForks?: boolean }
|
|
): Promise<void> {
|
|
await db.transaction(
|
|
'rw',
|
|
[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.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[IDXDB_TABLES.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[IDXDB_TABLES.conversations].delete(forkId);
|
|
await db[IDXDB_TABLES.messages].where('convId').equals(forkId).delete();
|
|
}
|
|
} else {
|
|
await this.reparentDirectChildren(id);
|
|
}
|
|
|
|
await db[IDXDB_TABLES.conversations].delete(id);
|
|
await db[IDXDB_TABLES.messages].where('convId').equals(id).delete();
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reparents direct children of `parentId` to the nearest surviving
|
|
* ancestor (or promotes them to top-level when the immediate parent was
|
|
* top-level). Walking skips any ancestor listed in `excludeIds`, since
|
|
* those will be deleted in the same batch — leaving a grandchild pointing
|
|
* at an `excludeIds` entry would orphan it. Children whose own id is in
|
|
* `excludeIds` are dropped from the updates (the bulk-delete pass will
|
|
* remove them). `prefetched` may carry a pre-fetched ancestor map to
|
|
* avoid repeat reads inside a bulk transaction.
|
|
*/
|
|
private static async reparentDirectChildren(
|
|
parentId: string,
|
|
excludeIds: ReadonlySet<string> = new Set(),
|
|
prefetched?: ReadonlyMap<string, DatabaseConversation>
|
|
): Promise<void> {
|
|
const conv = prefetched?.get(parentId) ?? (await db[IDXDB_TABLES.conversations].get(parentId));
|
|
if (!conv) return;
|
|
|
|
let newParent = conv.forkedFromConversationId;
|
|
const visited = new Set<string>([parentId]);
|
|
while (newParent && excludeIds.has(newParent)) {
|
|
if (visited.has(newParent)) {
|
|
newParent = undefined;
|
|
break;
|
|
}
|
|
visited.add(newParent);
|
|
const next =
|
|
prefetched?.get(newParent) ?? (await db[IDXDB_TABLES.conversations].get(newParent));
|
|
if (!next) {
|
|
newParent = undefined;
|
|
break;
|
|
}
|
|
newParent = next.forkedFromConversationId;
|
|
}
|
|
|
|
const directChildren = await db[IDXDB_TABLES.conversations]
|
|
.filter((c) => c.forkedFromConversationId === parentId)
|
|
.toArray();
|
|
|
|
const updates: DatabaseConversation[] = [];
|
|
for (const child of directChildren) {
|
|
if (excludeIds.has(child.id)) continue;
|
|
updates.push({ ...child, forkedFromConversationId: newParent });
|
|
}
|
|
if (updates.length === 0) return;
|
|
await db[IDXDB_TABLES.conversations].bulkPut(updates);
|
|
}
|
|
|
|
/**
|
|
* Deletes multiple conversations in a single transaction. Each deleted
|
|
* conversation has its direct children reparented to the nearest surviving
|
|
* ancestor (or promoted to top-level). Children also in `ids` are dropped
|
|
* entirely rather than reparented.
|
|
*
|
|
* @param ids - Conversation IDs to delete
|
|
*/
|
|
static async bulkDeleteConversations(ids: string[]): Promise<void> {
|
|
const cleanIds = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
|
if (cleanIds.length === 0) return;
|
|
const idSet = new Set(cleanIds);
|
|
|
|
await db.transaction(
|
|
'rw',
|
|
[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
|
|
async () => {
|
|
// Pre-load each to-delete conversation so the per-id reparent
|
|
// walk-up doesn't ping-pong the same ancestry chain.
|
|
const prefetched = new Map<string, DatabaseConversation>();
|
|
let frontier = [...cleanIds];
|
|
const requested = new Set<string>(frontier);
|
|
while (frontier.length > 0) {
|
|
const fetched = await db[IDXDB_TABLES.conversations].bulkGet(frontier);
|
|
frontier = [];
|
|
for (let i = 0; i < fetched.length; i++) {
|
|
const conv = fetched[i];
|
|
if (!conv || !conv.id) continue;
|
|
prefetched.set(conv.id, conv);
|
|
const ancestor = conv.forkedFromConversationId;
|
|
if (ancestor && !prefetched.has(ancestor) && !requested.has(ancestor)) {
|
|
frontier.push(ancestor);
|
|
requested.add(ancestor);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const id of cleanIds) {
|
|
await this.reparentDirectChildren(id, idSet, prefetched);
|
|
}
|
|
|
|
await db[IDXDB_TABLES.conversations].bulkDelete(cleanIds);
|
|
await db[IDXDB_TABLES.messages].where('convId').anyOf(cleanIds).delete();
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Deletes a message and removes it from its parent's children array.
|
|
*
|
|
* @param messageId - ID of the message to delete
|
|
*/
|
|
static async deleteMessage(messageId: string): Promise<void> {
|
|
await db.transaction('rw', db[IDXDB_TABLES.messages], async () => {
|
|
const message = await db[IDXDB_TABLES.messages].get(messageId);
|
|
if (!message) return;
|
|
|
|
// Remove this message from its parent's children array
|
|
if (message.parent) {
|
|
const parent = await db[IDXDB_TABLES.messages].get(message.parent);
|
|
if (parent) {
|
|
parent.children = parent.children.filter((childId: string) => childId !== messageId);
|
|
await db[IDXDB_TABLES.messages].put(parent);
|
|
}
|
|
}
|
|
|
|
// Delete the message
|
|
await db[IDXDB_TABLES.messages].delete(messageId);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Deletes a message and all its descendant messages (cascading deletion).
|
|
* This removes the entire branch starting from the specified message.
|
|
*
|
|
* @param conversationId - ID of the conversation containing the message
|
|
* @param messageId - ID of the root message to delete (along with all descendants)
|
|
* @returns Array of all deleted message IDs
|
|
*/
|
|
static async deleteMessageCascading(
|
|
conversationId: string,
|
|
messageId: string
|
|
): Promise<string[]> {
|
|
return await db.transaction('rw', db[IDXDB_TABLES.messages], async () => {
|
|
// Get all messages in the conversation to find descendants
|
|
const allMessages = await db[IDXDB_TABLES.messages]
|
|
.where('convId')
|
|
.equals(conversationId)
|
|
.toArray();
|
|
|
|
// Find all descendant messages
|
|
const descendants = findDescendantMessages(allMessages, messageId);
|
|
const allToDelete = [messageId, ...descendants];
|
|
|
|
// Get the message to delete for parent cleanup
|
|
const message = await db[IDXDB_TABLES.messages].get(messageId);
|
|
if (message && message.parent) {
|
|
const parent = await db[IDXDB_TABLES.messages].get(message.parent);
|
|
if (parent) {
|
|
parent.children = parent.children.filter((childId: string) => childId !== messageId);
|
|
await db[IDXDB_TABLES.messages].put(parent);
|
|
}
|
|
}
|
|
|
|
// Delete all messages in the branch
|
|
await db[IDXDB_TABLES.messages].bulkDelete(allToDelete);
|
|
|
|
return allToDelete;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gets all conversations, sorted by last modified time (newest first).
|
|
*
|
|
* @returns Array of conversations
|
|
*/
|
|
static async getAllConversations(): Promise<DatabaseConversation[]> {
|
|
return await db[IDXDB_TABLES.conversations].orderBy('lastModified').reverse().toArray();
|
|
}
|
|
|
|
/**
|
|
* Gets a conversation by ID.
|
|
*
|
|
* @param id - Conversation ID
|
|
* @returns The conversation if found, otherwise undefined
|
|
*/
|
|
static async getConversation(id: string): Promise<DatabaseConversation | undefined> {
|
|
return await db[IDXDB_TABLES.conversations].get(id);
|
|
}
|
|
|
|
/**
|
|
* Gets all messages in a conversation, sorted by timestamp (oldest first).
|
|
*
|
|
* @param convId - Conversation ID
|
|
* @returns Array of messages in the conversation
|
|
*/
|
|
static async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
|
|
return await db[IDXDB_TABLES.messages].where('convId').equals(convId).sortBy('timestamp');
|
|
}
|
|
|
|
/**
|
|
* Loads multiple conversations with all of their messages in two bulk
|
|
* reads. Missing conversations are silently omitted from the result.
|
|
*
|
|
* @param convIds - Conversation IDs to load
|
|
* @returns Map of id -> { conv, messages }. Messages are sorted ascending by timestamp.
|
|
*/
|
|
static async getConversationsWithMessages(
|
|
convIds: string[]
|
|
): Promise<Map<string, ExportedConversation>> {
|
|
const result = new Map<string, ExportedConversation>();
|
|
const cleanIds = convIds.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
|
if (cleanIds.length === 0) return result;
|
|
|
|
const [convs, allMessages] = await Promise.all([
|
|
db[IDXDB_TABLES.conversations].bulkGet(cleanIds),
|
|
db[IDXDB_TABLES.messages].where('convId').anyOf(cleanIds).toArray()
|
|
]);
|
|
|
|
const messagesByConv = new Map<string, DatabaseMessage[]>();
|
|
for (const msg of allMessages) {
|
|
const bucket = messagesByConv.get(msg.convId);
|
|
if (bucket) bucket.push(msg);
|
|
else messagesByConv.set(msg.convId, [msg]);
|
|
}
|
|
|
|
for (let i = 0; i < cleanIds.length; i++) {
|
|
const conv = convs[i];
|
|
if (!conv) continue;
|
|
const messages = (messagesByConv.get(conv.id) ?? []).sort(
|
|
(a, b) => a.timestamp - b.timestamp
|
|
);
|
|
result.set(conv.id, { conv, messages });
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Updates a conversation. `lastModified` is never stamped implicitly;
|
|
* pass it in `updates` to bump the conversation in recency ordering.
|
|
*
|
|
* @param id - Conversation ID
|
|
* @param updates - Partial updates to apply
|
|
* @returns Promise that resolves when the conversation is updated
|
|
*/
|
|
static async updateConversation(
|
|
id: string,
|
|
updates: Partial<Omit<DatabaseConversation, 'id'>>
|
|
): Promise<void> {
|
|
await db[IDXDB_TABLES.conversations].update(id, updates);
|
|
}
|
|
|
|
/**
|
|
*
|
|
*
|
|
* Navigation
|
|
*
|
|
*
|
|
*/
|
|
|
|
/**
|
|
* Toggles the pinned status of a conversation.
|
|
*
|
|
* @param id - Conversation ID
|
|
* @returns The new pinned status
|
|
*/
|
|
static async toggleConversationPin(id: string): Promise<boolean> {
|
|
const conversation = await db[IDXDB_TABLES.conversations].get(id);
|
|
if (!conversation) {
|
|
throw new Error(`Conversation ${id} not found`);
|
|
}
|
|
const newPinnedState = !conversation.pinned;
|
|
await this.updateConversation(id, { pinned: newPinnedState });
|
|
return newPinnedState;
|
|
}
|
|
|
|
/**
|
|
* Toggles the pinned status of each conversation in `ids` inside a single
|
|
* transaction. Treats `pinned === undefined` as `false`, matching the
|
|
* semantics of {@link toggleConversationPin} where `!undefined` evaluates
|
|
* to `true`. Returns the resulting pinned state for every id that was
|
|
* updated; missing ids are omitted from the map.
|
|
*
|
|
* @param ids - Conversation IDs to toggle
|
|
* @returns Map of id -> new pinned state
|
|
*/
|
|
static async bulkToggleConversationPins(ids: string[]): Promise<Map<string, boolean>> {
|
|
const cleanIds = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
|
const result = new Map<string, boolean>();
|
|
if (cleanIds.length === 0) return result;
|
|
|
|
await db.transaction('rw', db[IDXDB_TABLES.conversations], async () => {
|
|
const convs = await db[IDXDB_TABLES.conversations].bulkGet(cleanIds);
|
|
const updates: DatabaseConversation[] = [];
|
|
for (let i = 0; i < cleanIds.length; i++) {
|
|
const conv = convs[i];
|
|
if (!conv) continue;
|
|
const newPinned = !conv.pinned;
|
|
updates.push({ ...conv, pinned: newPinned });
|
|
result.set(cleanIds[i], newPinned);
|
|
}
|
|
if (updates.length === 0) return;
|
|
await db[IDXDB_TABLES.conversations].bulkPut(updates);
|
|
});
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Updates the conversation's current node (active branch).
|
|
* This determines which conversation path is currently being viewed.
|
|
*
|
|
* @param convId - Conversation ID
|
|
* @param nodeId - Message ID to set as current node
|
|
*/
|
|
static async updateCurrentNode(convId: string, nodeId: string): Promise<void> {
|
|
await this.updateConversation(convId, {
|
|
currNode: nodeId
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Updates a message.
|
|
*
|
|
* @param id - Message ID
|
|
* @param updates - Partial updates to apply
|
|
* @returns Promise that resolves when the message is updated
|
|
*/
|
|
static async updateMessage(
|
|
id: string,
|
|
updates: Partial<Omit<DatabaseMessage, 'id'>>
|
|
): Promise<void> {
|
|
await db[IDXDB_TABLES.messages].update(id, updates);
|
|
}
|
|
|
|
/**
|
|
*
|
|
*
|
|
* Import
|
|
*
|
|
*
|
|
*/
|
|
|
|
/**
|
|
* Imports multiple conversations and their messages.
|
|
* Skips conversations that already exist.
|
|
*
|
|
* @param data - Array of { conv, messages } objects
|
|
* @returns The conversations written to the database and the ones skipped
|
|
*/
|
|
static async importConversations(
|
|
data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[]
|
|
): Promise<{ imported: DatabaseConversation[]; skipped: DatabaseConversation[] }> {
|
|
const imported: DatabaseConversation[] = [];
|
|
const skipped: DatabaseConversation[] = [];
|
|
|
|
return await db.transaction(
|
|
'rw',
|
|
[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
|
|
async () => {
|
|
for (const item of data) {
|
|
const { conv, messages } = item;
|
|
|
|
const existing = await db[IDXDB_TABLES.conversations].get(conv.id);
|
|
if (existing) {
|
|
skipped.push(conv);
|
|
continue;
|
|
}
|
|
|
|
await db[IDXDB_TABLES.conversations].add(conv);
|
|
for (const msg of messages) {
|
|
await db[IDXDB_TABLES.messages].put(msg);
|
|
}
|
|
|
|
imported.push(conv);
|
|
}
|
|
|
|
return { imported, skipped };
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
*
|
|
*
|
|
* 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[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
|
|
async () => {
|
|
const sourceConv = await db[IDXDB_TABLES.conversations].get(sourceConvId);
|
|
if (!sourceConv) {
|
|
throw new Error(`Source conversation ${sourceConvId} not found`);
|
|
}
|
|
|
|
const allMessages = await db[IDXDB_TABLES.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,
|
|
cwd: sourceConv.cwd
|
|
};
|
|
|
|
await db[IDXDB_TABLES.conversations].add(newConv);
|
|
|
|
for (const msg of clonedMessages) {
|
|
await db[IDXDB_TABLES.messages].add(msg);
|
|
}
|
|
|
|
return newConv;
|
|
}
|
|
);
|
|
}
|
|
}
|