Files
llama.cpp/tools/ui/src/lib/stores/conversations/index.svelte.ts
T
Aleksander Grygier fe235f4343 ui: Replace per-conversation MCP overrides with per-conversation tool policy (#27745)
* ui: replace per-conversation MCP overrides with per-conversation tool policy

MCP server enabled state is now global (server.enabled); per-conversation
control moves to disabled tool keys and categories seeded into each new
conversation. Aligns the add sheet with the dropdown options and flattens
MCP tool groups in the tools submenu.

Assisted-by: pi

* ui: keep tool policy migration running when defaults parse fails

A corrupt disabledToolKeys localStorage entry no longer aborts the
migration; it falls through with empty defaults so legacy MCP server
overrides still get converted.

Assisted-by: pi

* ui: fall back to global defaults when agentic flow has no tool policy

Passing empty disabled sets bypassed the global defaults and could
enable tools for callers that do not pass a policy yet.

Assisted-by: pi

* ui: align preferences section headers with their methods

The Reasoning Effort and Working Directory headers sat above tool
policy methods; move them above setCwd and setReasoningEffort. Also
clarify the disabled tools JSDoc: existing rows with an unset field
have an empty policy, defaults apply only when there is no active
conversation.

Assisted-by: pi

* ui: gate MCP server avatars on conversation tool policy

Servers whose tools are disabled for the current conversation (MCP
category or server-scoped key) no longer show as enabled for the chat.

Assisted-by: pi

* ui: drop unused MCP category toggle from tools panel hook

Per-conversation MCP control is server-granular; no component renders
a whole-category toggle, so remove the dead API.

Assisted-by: pi

* ui: skip MCP init when flow policy disables the MCP category

Resolve the effective tool policy before deciding whether to
initialize MCP so flows that will not send any MCP tools skip the
init work. Callers without a policy keep falling back to global
defaults.

Assisted-by: pi

* chore: format

* ui: restore reasoning section in mobile add sheet

The sheet rewrite dropped it; the desktop dropdown still has it.
MCP Prompts and Resources stay out of the sheet on purpose.

Assisted-by: pi

* ui: clear MCP server group key in enableAllToolsForServer

The group key disables every tool of the server regardless of
per-tool keys, so re-enabling a server from Settings did nothing
while it was set.

Assisted-by: pi

* ui: skip MCP init when no policy-enabled server remains

Extends the category-level check: the flow also skips MCP init when
every globally-enabled server has its server-scoped group key
disabled in the tool policy.

Assisted-by: pi

* ui: make Settings tools tab edit defaults with category toggles

Adds per-category checkboxes and a caption stating the tab applies
to new conversations; tool picks inside a chat only affect that
chat.

Assisted-by: pi

* ui: gate cwd picker and mention picker on effective tool policy

Both checked the global disabled set directly, so a conversation
that disabled file_search still showed search as available.

Assisted-by: pi

* ui: clean up tool key helpers and store docs

Documents getEnabledToolsForLLM properly, unstacks the JSDoc at
isEntryEnabled, makes setToolEnabled persist like setCategoryEnabled
(toggleTool now delegates to it), and routes the serverId-less MCP
branch of toolKey through getMcpServerToolsKey so both key formats
come from one place. Preferences banner comments become plain
comments so they no longer read as class member docs.

Assisted-by: pi

* ui: indeterminate group checkboxes and inert grayed rows

A category that is on with nothing enabled under it now shows the
mixed checkbox state instead of a checked box next to 0/N. Rows
grayed out by a disabled parent no longer stay clickable behind
opacity.

Assisted-by: pi

* ui: gate MCP prompt and resource capabilities on tool policy

hasPromptsCapability and hasResourcesCapability accept an optional
set of usable server ids; ChatFormActions resolves it from global
enablement minus the active conversation's policy. Restores the
per-chat gating the old mcpServerOverrides provided; callers without
arguments keep global behavior.

Assisted-by: pi

* ui: remove unmounted MCP submenu component

Never rendered anywhere; its entries are duplicates (prompts and
resources live in the attachment menu, servers in the add menu and
sheet) that would need capability wiring maintained for nothing.

Assisted-by: pi

* ui: fix model information dialog width on all screen sizes

The dialog sets container-type: inline-size, so auto width ignores
its contents and collapses to padding. Give it an explicit viewport
width on mobile and cap at 60rem on desktop.

Assisted-by: pi

* ui: scroll wide chat template in model information dialog

Long unbreakable Jinja tokens blew out the table and dialog width;
the block now scrolls horizontally instead of stretching.

Assisted-by: pi

* ui: use fixed table layout in model information dialog

Auto table layout sizes columns to content min-content, so the chat
template's long lines kept inflating the dialog despite the scroll
wrapper. Fixed layout pins the first column and gives the value
column a definite width the wrapper can scroll within. min-w-0 on
the grid item guards the same path on the grid side.

Assisted-by: pi

* ui: make model information dialog full-screen on mobile

Matches the settings dialog pattern: full viewport below md,
calc-sized and capped at 60rem on desktop.

Assisted-by: pi

* ui: stack chat template row in model information dialog

Label above the block in a single full-width cell, so the template
gets the whole table width and its horizontal scroll is usable on
narrow screens.

Assisted-by: pi

* ui: scroll model information header with the content

The base dialog header is sticky; this dialog overrides it to
relative so the title and description scroll away with the body.
relative keeps the header as the close button's containing block.

Assisted-by: pi

* ui: replace literal comment text in sheet group snippet

A // line inside the Svelte snippet rendered as visible text; use an
HTML comment.

Assisted-by: pi

* ui: let indeterminate state win over checked in group checkboxes

The checkbox indicator snippet renders the check icon whenever
checked, so the mixed state never showed. Pass the checked prop
as false while indeterminate.

Assisted-by: pi

* ui: initialize only policy-enabled MCP servers for a flow

ensureInitialized accepts an optional server id set; the agentic
flow passes the servers its tool policy leaves usable, so servers
disabled for the conversation no longer get connected. Callers
without arguments keep the global behavior.

Assisted-by: pi

* ui: derive group checkbox state in useToolsPanel

Moves the mixed-state derivation out of the submenu and sheet
snippets into one getGroupCheckState accessor; the snippets just
consume checked and indeterminate.

Assisted-by: pi

* ui: gate /prompt command on the conversation tool policy

The slash command's availability now follows the same rule as the
agentic flow instead of the global capability check, so it disables
itself when the conversation's policy leaves no usable MCP server.

Assisted-by: pi

* ui: remove dead MCP prompt menu trigger chain

The /prompt slash command is the surviving trigger; the menu-button
path (onMcpPromptClick, hasMcpPromptsSupport, showMcpPromptButton,
the MCP_PROMPT attachment item and its unrendered item arrays) has
no consumer left. Message display for inserted prompts is untouched.

Assisted-by: pi

* ui: render dash for mixed-state group checkboxes

The accessor refactor dropped the checked-and-not-indeterminate
guard, so the category-on flag won and the dash never showed. The
tooltip keeps using the raw parent flag since clicking a mixed
group still disables it.

Assisted-by: pi

* ui: fix group checkbox sticking checked after disable

Clicking a mixed-state group box let bits-ui optimistically flip
its internal checked flag; the derived checked prop did not change
across the transition (both mixed and off map to checked=false),
so Svelte never applied the settled value and the check icon stuck
while the count already read 0/7.

Pass the parent flag as checked and the mix as indeterminate, so
every group toggle changes checked; render the dash on top of a
checked box for the mixed state.

Assisted-by: pi

* fix: UI for Model Information dialog

* ui: keep MCP connections stable across policy switches

ensureInitialized folds the policy into its config signature, so
alternating two conversations with different policies tore down and
reconnected every server with health checks included. Tool collection
already filters by the flow policy, so initialize every
settings-enabled server instead and never pass a policy into the MCP
config. The duplicated policy-server check becomes one accessor on
ConversationPreferences.

Assisted-by: pi

* ui: remove dead MCP resources menu trigger chain

Same shape as the earlier prompt trigger cleanup: nothing renders the
MCP resources menu button, and the only live entry into resource
browsing is Settings > MCP Servers plus the attachment resource
picker. Drop onMcpResourcesClick, hasMcpResourcesSupport,
MCP_RESOURCES_CLICK, the AttachmentItemVisibleWhen enum and
hasResourcesCapability; the resources display, browser and picker
components are untouched.

Assisted-by: pi
2026-08-27 13:08:01 +02:00

756 lines
23 KiB
TypeScript

/**
* conversationsStore - Conversation lifecycle, persistence and navigation
*
* Owns conversation CRUD, message tree navigation, import/export and title
* management, persisted through DatabaseService. Per-chat options (MCP
* overrides, reasoning effort, cwd) live in ConversationPreferences,
* composed as {@link ConversationsStore.preferences}.
*/
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { ROUTES } from '$lib/constants';
import { MessageRole } from '$lib/enums';
import { ConversationTransferService } from '$lib/services/conversation-transfer.service';
import { DatabaseService } from '$lib/services/database.service';
import { MigrationService } from '$lib/services/migration.service';
import { RouterService } from '$lib/services/router.service';
// direct imports between stores, not via the barrel, to avoid circular deps
import {
ConversationPreferences,
type ConversationsPreferencesHost
} from '$lib/stores/conversations/preferences.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
import { tabsStore } from '$lib/stores/tabs.svelte';
import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils';
import { SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
class ConversationsStore implements ConversationsPreferencesHost {
/** Currently active conversation */
activeConversation = $state<DatabaseConversation | null>(null);
/** Messages in the active conversation (filtered by currNode path) */
activeMessages = $state<DatabaseMessage[]>([]);
/** List of all conversations */
conversations = $state<DatabaseConversation[]>([]);
/** Whether the store has been initialized */
isInitialized = $state(false);
/** Per-chat options (MCP overrides, reasoning effort, cwd), composed here. */
private _preferences = new ConversationPreferences(this);
/**
* Listeners notified with the ids of conversations that were deleted.
* Lets dependent stores (e.g. agenticStore) drop per-conversation state
* without introducing a circular import back into this store.
*/
private conversationDeletionListeners = new Set<(convIds: string[]) => void>();
/** In-flight init run; shared by concurrent callers, reset on failure to allow retry */
private initPromise: Promise<void> | null = null;
/**
* Memo of the last findMessageIndex() lookup. Streaming calls it once per
* chunk for the same message, so a validated cache hit keeps that O(1)
* instead of a linear scan of activeMessages on every token.
*/
private lastMessageIndex: { id: string; index: number } | null = null;
get preferences() {
return this._preferences;
}
/**
* Adds a message to the active messages array
*/
addMessageToActive(message: DatabaseMessage): void {
this.activeMessages.push(message);
}
/**
* Applies a field update to a conversation row, mirroring it into both the
* conversations list and the active conversation when it is the target.
* Shared by the rename/pin/preferences flows so no caller can forget to
* mirror one side.
*/
applyConversationUpdate(id: string, updates: Partial<DatabaseConversation>): void {
const convIndex = this.conversations.findIndex((c) => c.id === id);
if (convIndex !== -1) {
const target = this.conversations[convIndex] as unknown as Record<string, unknown>;
for (const [key, value] of Object.entries(updates)) {
if (target[key] !== value) target[key] = value;
}
}
if (this.activeConversation?.id === id) {
this.activeConversation = { ...this.activeConversation, ...updates };
}
}
/**
* Derives a conversation title from its first message content and applies
* it, honoring the title-generation setting. Shared by every flow that
* edits or creates the first user message.
*/
async applyTitleFromContent(convId: string, content: string): Promise<void> {
await this.updateConversationName(
convId,
generateConversationTitle(content, Boolean(settingsStore.config.titleGenerationUseFirstLine))
);
}
/**
* Deletes multiple conversations in sequence.
* Mirrors deleteConversation() per-id; navigates to the new-chat screen only
* if the currently-open chat was among the deleted ones.
* @param convIds - Conversation IDs to delete
*/
async bulkDeleteConversations(convIds: string[]): Promise<void> {
if (convIds.length === 0) return;
try {
const idsToRemove = new SvelteSet(convIds);
// Collect all descendants recursively so the local cache stays consistent
// even when deleteWithForks is omitted.
const queue = [...convIds];
while (queue.length > 0) {
const parentId = queue.pop()!;
for (const c of this.conversations) {
if (c.forkedFromConversationId === parentId && !idsToRemove.has(c.id)) {
idsToRemove.add(c.id);
queue.push(c.id);
}
}
}
const activeWasDeleted =
this.activeConversation !== null && idsToRemove.has(this.activeConversation.id);
await DatabaseService.bulkDeleteConversations([...idsToRemove]);
this.conversations = this.conversations.filter((c) => !idsToRemove.has(c.id));
this.notifyConversationsDeleted([...idsToRemove]);
if (activeWasDeleted) {
const activeId = this.activeConversation!.id;
tabsStore.removeTabs([...idsToRemove].filter((id) => id !== activeId));
this.clearActiveConversation();
await tabsStore.close(activeId, activeId);
} else {
tabsStore.removeTabs([...idsToRemove]);
}
toast.success(
idsToRemove.size === 1
? 'Conversation deleted'
: `${idsToRemove.size} conversations deleted`
);
} catch (error) {
console.error('Failed to bulk delete conversations:', error);
toast.error('Failed to delete conversations');
}
}
/**
* Bundles the given conversations into a single zip archive and triggers a
* browser download (one JSONL file per conversation).
* @param convIds - Conversation IDs to export
*/
async bulkExportConversations(convIds: string[]): Promise<void> {
if (convIds.length === 0) return;
try {
const fetched = await DatabaseService.getConversationsWithMessages(convIds);
const activeId = this.activeConversation?.id;
const overridden = fetched.get(activeId ?? '');
if (overridden && activeId) {
overridden.conv = { ...this.activeConversation! };
}
const exported = [...fetched.values()];
if (exported.length === 0) {
toast.error('No conversations to export');
return;
}
ConversationTransferService.downloadConversationsArchive(exported);
toast.success(
exported.length === 1
? 'Conversation exported'
: `${exported.length} conversations exported`
);
} catch (error) {
console.error('Failed to bulk export conversations:', error);
toast.error('Failed to export conversations');
}
}
/**
* Toggles the pinned state of each conversation individually.
* Mixed-pin selections are intentionally not normalised here; the bulk
* action UI surfaces them as a disabled mixed-state instead.
* @param convIds - Conversation IDs to toggle
*/
async bulkToggleConversationPin(convIds: string[]): Promise<void> {
if (convIds.length === 0) return;
try {
const updates = await DatabaseService.bulkToggleConversationPins(convIds);
const activeId = this.activeConversation?.id;
if (activeId && updates.has(activeId)) {
this.activeConversation = {
...this.activeConversation!,
pinned: updates.get(activeId)!
};
}
for (let i = 0; i < this.conversations.length; i++) {
const newPinned = updates.get(this.conversations[i].id);
if (newPinned !== undefined) this.conversations[i].pinned = newPinned;
}
toast.success(
convIds.length === 1
? 'Conversation pin toggled'
: `Updated pin state for ${convIds.length} conversations`
);
} catch (error) {
console.error('Failed to bulk toggle pin:', error);
toast.error('Failed to update pin state');
}
}
/**
* Clears the active conversation and messages.
*/
clearActiveConversation(): void {
this.activeConversation = null;
this.activeMessages = [];
// reload defaults so new chats inherit persisted state
this.preferences.resetPending();
}
/**
* Creates a new conversation and navigates to it
* @param name - Optional name for the conversation
* @returns The ID of the created conversation
*/
async createConversation(name?: string): Promise<string> {
const conversationName = name || `Chat ${new Date().toLocaleString()}`;
// The tool policy is seeded from the current defaults: edits made inside
// the conversation afterwards live on its row and do not flow back into
// the defaults. Working directory picked on the new-chat screen gets
// threaded in here too, then cleared so it doesn't bleed onto subsequent
// new chats.
const conversation = await DatabaseService.createConversation(conversationName, {
cwd: this.preferences.pendingCwd ?? undefined,
reasoningEffort: this.preferences.pendingReasoningEffort,
...this.preferences.getToolPolicySnapshot()
});
this.preferences.pendingCwd = null;
this.conversations = [conversation, ...this.conversations];
this.activeConversation = conversation;
this.activeMessages = [];
await goto(RouterService.chat(conversation.id));
return conversation.id;
}
/**
* Deletes all conversations and their messages
*/
async deleteAll(): Promise<void> {
try {
const allConversations = await DatabaseService.getAllConversations();
const allIds = allConversations.map((c) => c.id);
await DatabaseService.bulkDeleteConversations(allIds);
this.clearActiveConversation();
this.conversations = [];
tabsStore.clear();
this.notifyConversationsDeleted(allIds);
toast.success('All conversations deleted');
await goto(ROUTES.START);
} catch (error) {
console.error('Failed to delete all conversations:', error);
toast.error('Failed to delete conversations');
}
}
/**
* Deletes a conversation and all its messages
* @param convId - The conversation ID to delete
*/
async deleteConversation(convId: string, options?: { deleteWithForks?: boolean }): Promise<void> {
try {
await DatabaseService.deleteConversation(convId, options);
if (options?.deleteWithForks) {
// Collect all descendants recursively
const idsToRemove = new SvelteSet([convId]);
const queue = [convId];
while (queue.length > 0) {
const parentId = queue.pop()!;
for (const c of this.conversations) {
if (c.forkedFromConversationId === parentId && !idsToRemove.has(c.id)) {
idsToRemove.add(c.id);
queue.push(c.id);
}
}
}
this.conversations = this.conversations.filter((c) => !idsToRemove.has(c.id));
if (this.activeConversation && idsToRemove.has(this.activeConversation.id)) {
const activeId = this.activeConversation.id;
tabsStore.removeTabs([...idsToRemove].filter((id) => id !== activeId));
this.clearActiveConversation();
await tabsStore.close(activeId, activeId);
} else {
tabsStore.removeTabs([...idsToRemove]);
}
this.notifyConversationsDeleted([...idsToRemove]);
} else {
// Reparent direct children to deleted conv's parent (or promote to top-level)
const deletedConv = this.conversations.find((c) => c.id === convId);
const newParent = deletedConv?.forkedFromConversationId;
this.conversations = this.conversations
.filter((c) => c.id !== convId)
.map((c) =>
c.forkedFromConversationId === convId
? { ...c, forkedFromConversationId: newParent }
: c
);
if (this.activeConversation?.id === convId) {
this.clearActiveConversation();
await tabsStore.close(convId, convId);
} else {
tabsStore.removeTabs([convId]);
}
this.notifyConversationsDeleted([convId]);
}
} catch (error) {
console.error('Failed to delete conversation:', error);
}
}
/**
* Downloads a single conversation as a JSONL file, serializing the full message tree.
* @param convId - The conversation ID to download
*/
async downloadConversation(convId: string): Promise<void> {
const conversation =
this.activeConversation?.id === convId
? this.activeConversation
: await DatabaseService.getConversation(convId);
if (!conversation) return;
const messages = await DatabaseService.getConversationMessages(convId);
ConversationTransferService.downloadConversationFile({ conv: conversation, messages });
}
/**
* Finds the index of a message in active messages.
*
* The last lookup is memoized and reused when it still validates against
* the current array (same id at the same position), which covers the
* streaming hot path where the same message is looked up on every chunk
* while the array itself only mutates by field. Any structural change
* (splice, reassignment, reordering) fails validation and falls back to a
* full scan.
*/
findMessageIndex(messageId: string): number {
const last = this.lastMessageIndex;
const messages = this.activeMessages;
if (
last &&
last.id === messageId &&
last.index >= 0 &&
last.index < messages.length &&
messages[last.index]?.id === messageId
) {
return last.index;
}
const index = messages.findIndex((m) => m.id === messageId);
this.lastMessageIndex = { id: messageId, index };
return index;
}
/**
* Forks a conversation at a specific message, creating a new conversation
* containing messages from root up to the target message, then navigates to it.
*
* @param messageId - The message ID to fork at
* @param options - Fork options (name and whether to include attachments)
* @returns The new conversation ID, or null if fork failed
*/
async forkConversation(
messageId: string,
options: { name: string; includeAttachments: boolean }
): Promise<string | null> {
if (!this.activeConversation) return null;
try {
const newConv = await DatabaseService.forkConversation(
this.activeConversation.id,
messageId,
options
);
this.conversations = [newConv, ...this.conversations];
await goto(RouterService.chat(newConv.id));
toast.success('Conversation forked');
return newConv.id;
} catch (error) {
console.error('Failed to fork conversation:', error);
toast.error('Failed to fork conversation');
return null;
}
}
/**
* Gets all messages for a specific conversation
* @param convId - The conversation ID
* @returns Array of messages
*/
async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
return await DatabaseService.getConversationMessages(convId);
}
/**
* Imports conversations from provided data (without file picker)
* @param data - Array of conversation data with messages
* @returns The conversations written to the database and the ones skipped
*/
async importConversationsData(
data: ExportedConversations
): Promise<{ imported: DatabaseConversation[]; skipped: DatabaseConversation[] }> {
const result = await DatabaseService.importConversations(data);
await this.loadConversations();
return result;
}
/**
* Initialize the store by loading conversations from database.
* Safe to call multiple times: concurrent callers share a single run,
* and a failed run can be retried by calling again.
*/
initialize(): Promise<void> {
if (!browser) return Promise.resolve();
if (this.initPromise) return this.initPromise;
this.initPromise = (async () => {
try {
await MigrationService.runAllMigrations();
await this.loadConversations();
this.isInitialized = true;
} catch (error) {
console.error('Failed to initialize conversations:', error);
this.initPromise = null;
}
})();
return this.initPromise;
}
/**
* Loads a specific conversation and its messages
* @param convId - The conversation ID to load
* @returns True if conversation was loaded successfully
*/
async loadConversation(convId: string): Promise<boolean> {
try {
const conversation = await DatabaseService.getConversation(convId);
if (!conversation) {
return false;
}
// Drop any cwd the user drafted on the empty new-chat screen -
// it doesn't belong to this conversation.
this.preferences.pendingCwd = null;
this.activeConversation = conversation;
if (conversation.currNode) {
const allMessages = await DatabaseService.getConversationMessages(convId);
const filteredMessages = filterByLeafNodeId(
allMessages,
conversation.currNode,
false
) as DatabaseMessage[];
this.activeMessages = filteredMessages;
} else {
const messages = await DatabaseService.getConversationMessages(convId);
this.activeMessages = messages;
}
return true;
} catch (error) {
console.error('Failed to load conversation:', error);
return false;
}
}
/**
* Loads all conversations from the database
*/
async loadConversations(): Promise<void> {
const conversations = await DatabaseService.getAllConversations();
this.conversations = conversations;
}
/**
* Navigates to a specific sibling branch by updating currNode and refreshing messages.
* @param siblingId - The sibling message ID to navigate to
*/
async navigateToSibling(siblingId: string): Promise<void> {
if (!this.activeConversation) return;
const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
const currentFirstUserMessage = this.activeMessages.find(
(m) => m.role === MessageRole.USER && m.parent === rootMessage?.id
);
const currentLeafNodeId = findLeafNode(allMessages, siblingId);
await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId);
this.activeConversation = { ...this.activeConversation, currNode: currentLeafNodeId };
await this.refreshActiveMessages();
if (rootMessage && this.activeMessages.length > 0) {
const newFirstUserMessage = this.activeMessages.find(
(m) => m.role === MessageRole.USER && m.parent === rootMessage.id
);
if (
newFirstUserMessage &&
newFirstUserMessage.content.trim() &&
(!currentFirstUserMessage ||
newFirstUserMessage.id !== currentFirstUserMessage.id ||
newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim())
) {
await this.applyTitleFromContent(this.activeConversation.id, newFirstUserMessage.content);
}
}
}
/**
* Registers a listener invoked with the ids of deleted conversations.
* Returns an unsubscribe function.
*/
onConversationsDeleted(listener: (convIds: string[]) => void): () => void {
this.conversationDeletionListeners.add(listener);
return () => this.conversationDeletionListeners.delete(listener);
}
/**
* Start a fresh chat by navigating to the bare `#/` new-chat screen. The
* chat layout opens a new-chat tab for it when Conversation tabs are on.
*/
async openNewChat(): Promise<void> {
this.clearActiveConversation();
await goto(ROUTES.START);
}
/**
* Refreshes active messages based on currNode after branch navigation.
*/
async refreshActiveMessages(): Promise<void> {
if (!this.activeConversation) return;
const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
if (allMessages.length === 0) {
this.activeMessages = [];
return;
}
const leafNodeId =
this.activeConversation.currNode ||
allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id;
const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
this.activeMessages = currentPath;
}
/**
* Removes a message from active messages by index
*/
removeMessageAtIndex(index: number): DatabaseMessage | undefined {
if (index !== -1) {
return this.activeMessages.splice(index, 1)[0];
}
return undefined;
}
/**
* Removes messages from active messages starting at an index
*/
sliceActiveMessages(startIndex: number): void {
this.activeMessages = this.activeMessages.slice(0, startIndex);
}
/**
* Toggles the pinned status of a conversation.
* @param convId - The conversation ID to toggle
* @returns The new pinned status
*/
async toggleConversationPin(convId: string): Promise<boolean> {
try {
const newPinnedState = await DatabaseService.toggleConversationPin(convId);
this.applyConversationUpdate(convId, { pinned: newPinnedState });
return newPinnedState;
} catch (error) {
console.error('Failed to toggle conversation pin:', error);
return false;
}
}
/**
* Updates the name of a conversation.
* @param convId - The conversation ID to update
* @param name - The new name for the conversation
*/
async updateConversationName(convId: string, name: string): Promise<void> {
try {
await DatabaseService.updateConversation(convId, { name });
this.applyConversationUpdate(convId, { name });
} catch (error) {
console.error('Failed to update conversation name:', error);
}
}
/**
* 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(convId?: string): void {
const targetId = convId ?? this.activeConversation?.id;
if (!targetId) return;
const now = Date.now();
const chatIndex = this.conversations.findIndex((c) => c.id === targetId);
if (chatIndex !== -1) {
this.conversations[chatIndex].lastModified = now;
const updatedConv = this.conversations.splice(chatIndex, 1)[0];
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)
);
}
/**
* Updates the current node of the active conversation
* @param nodeId - The new current node ID
*/
async updateCurrentNode(nodeId: string): Promise<void> {
if (!this.activeConversation) return;
await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId);
this.activeConversation = { ...this.activeConversation, currNode: nodeId };
}
/**
* Updates a message at a specific index in active messages
*/
updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void {
const message = index === -1 ? undefined : this.activeMessages[index];
if (!message) return;
// Assign field by field rather than replacing the object. Replacing it
// changes the array slot, which invalidates every consumer that merely
// walks the list - notably ChatMessages.displayMessages, which rebuilds
// entries for every message in the conversation. Deep $state proxies make
// per-field writes fine-grained, so only readers of the changed field wake.
const target = message as unknown as Record<string, unknown>;
for (const [key, value] of Object.entries(updates)) {
if (target[key] !== value) {
target[key] = value;
}
}
}
/**
*
*
* Import & Export
*
*
*/
private notifyConversationsDeleted(convIds: string[]): void {
if (convIds.length === 0) return;
for (const listener of this.conversationDeletionListeners) {
listener(convIds);
}
}
}
export const conversationsStore = new ConversationsStore();