Files
llama.cpp/tools/ui/src/lib/stores/conversations/preferences.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

334 lines
11 KiB
TypeScript

/**
* ConversationPreferences - Per-chat options with global fallback
*
* Owns the options that resolve per conversation: the tool policy (disabled
* categories and tool keys), reasoning effort, and the working directory.
* Tool picks made on the empty new-chat screen edit the global defaults
* directly (they seed every newly created conversation); cwd and reasoning
* effort are buffered as pending state and threaded into the next created
* conversation by the host.
* Created and owned by conversationsStore; the host owns the conversation
* rows these options persist onto.
*/
import { REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY } from '$lib/constants';
import { ReasoningEffort, ToolSource } from '$lib/enums';
import { DatabaseService } from '$lib/services/database.service';
// direct imports between stores, not via the barrel, to avoid circular deps
import { mcpStore } from '$lib/stores/mcp/index.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import type { DatabaseConversation, ToolEntry, ToolGroup } from '$lib/types';
/** Load reasoning effort default from localStorage, DEFAULT defers to the server */
function loadReasoningEffortDefault(): ReasoningEffort {
if (typeof globalThis.localStorage === 'undefined') return ReasoningEffort.DEFAULT;
try {
const raw = localStorage.getItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY);
return (raw as ReasoningEffort) || ReasoningEffort.DEFAULT;
} catch {
return ReasoningEffort.DEFAULT;
}
}
/** Persist reasoning effort default to localStorage */
function saveReasoningEffortDefault(effort: ReasoningEffort): void {
if (typeof globalThis.localStorage === 'undefined') return;
localStorage.setItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY, effort);
}
/**
* The slice of conversationsStore the preferences read and write. Kept narrow
* on purpose so they cannot reach around the host's full surface;
* conversationsStore implements this structurally.
*/
export interface ConversationsPreferencesHost {
activeConversation: DatabaseConversation | null;
conversations: DatabaseConversation[];
applyConversationUpdate(id: string, updates: Partial<DatabaseConversation>): void;
}
/**
* Effective disabled tool keys: the active conversation row, or the global
* defaults when there is no conversation. An existing row with an unset
* field has an empty policy, not a fallback to defaults.
*/
function buildDisabledTools(conv: DatabaseConversation | null): Set<string> {
return new Set(conv ? (conv.disabledTools ?? []) : [...toolsStore.disabledTools]);
}
/**
* Effective disabled tool categories: the active conversation row, or the
* global defaults when there is no conversation. An existing row with an
* unset field has an empty policy, not a fallback to defaults.
*/
function buildDisabledToolCategories(conv: DatabaseConversation | null): Set<ToolSource> {
return new Set(
conv ? (conv.disabledToolCategories ?? []) : [...toolsStore.disabledToolCategories]
);
}
export class ConversationPreferences {
/**
* Working directory picked on the empty new-chat screen, before any
* conversation exists. Consumed by `chatStore.sendMessage()`, which
* records it into chat history as a synthetic message on first send.
* Cleared by `loadConversation` and `clearActiveConversation` so a
* stale pick can't bleed onto an unrelated chat.
*/
pendingCwd = $state<string | null>(null);
/** Global (non-conversation-specific) reasoning effort default */
pendingReasoningEffort = $state<ReasoningEffort>(loadReasoningEffortDefault());
private get _disabledToolCategories(): Set<ToolSource> {
return buildDisabledToolCategories(this.host.activeConversation);
}
// Tool Policy
// getters, not $derived fields: lazy evaluation keeps them off the class
// field initialization order (host is assigned by the constructor), and
// reads of the underlying $state stay tracked in reactive contexts
private get _disabledTools(): Set<string> {
return buildDisabledTools(this.host.activeConversation);
}
constructor(private host: ConversationsPreferencesHost) {}
/** Effective disabled tool categories for the current context, captured at flow start. */
getDisabledToolCategories(): ToolSource[] {
return [...this._disabledToolCategories];
}
/** Effective disabled tool keys for the current context, captured at flow start. */
getDisabledTools(): string[] {
return [...this._disabledTools];
}
/**
* Gets the effective reasoning effort for the active conversation.
* Returns the conversation override if set, otherwise the global default.
* DEFAULT means no override is sent and the server decides.
*/
getReasoningEffort(): ReasoningEffort {
if (this.host.activeConversation) {
if (this.host.activeConversation.reasoningEffort !== undefined) {
return this.host.activeConversation.reasoningEffort;
}
// conversations created before the tri-state store an explicit
// opt-out only as thinkingEnabled = false
if (this.host.activeConversation.thinkingEnabled === false) {
return ReasoningEffort.OFF;
}
}
return this.pendingReasoningEffort;
}
/** Defaults snapshot for seeding a newly created conversation. */
getToolPolicySnapshot(): { disabledTools?: string[]; disabledToolCategories?: ToolSource[] } {
const disabledTools = [...toolsStore.disabledTools];
const disabledToolCategories = [...toolsStore.disabledToolCategories];
return {
disabledToolCategories: disabledToolCategories.length ? disabledToolCategories : undefined,
disabledTools: disabledTools.length ? disabledTools : undefined
};
}
hasEnabledCwdTools(): boolean {
return toolsStore.hasEnabledCwdTools(this._disabledTools, this._disabledToolCategories);
}
isCategoryEnabled(source: ToolSource): boolean {
return !this._disabledToolCategories.has(source);
}
/** Group checkbox state: the category flag, or the server key for MCP groups. */
isGroupChecked(group: ToolGroup): boolean {
return group.source === ToolSource.MCP && group.serverId
? this.isServerToolsEnabled(group.serverId)
: this.isCategoryEnabled(group.source);
}
/** Server-scoped MCP group state: one key disables all of that server's tools. */
isServerToolsEnabled(serverId: string): boolean {
return this.isToolEnabled(toolsStore.getMcpServerToolsKey(serverId));
}
/** Effective state: own key, MCP server group key, and category all on. */
isToolActive(entry: ToolEntry): boolean {
return toolsStore.isEntryEnabled(entry, this._disabledTools, this._disabledToolCategories);
}
/** Own-level state: the tool key itself, ignoring category and server group. */
isToolEnabled(key: string): boolean {
return !this._disabledTools.has(key);
}
/** True when a parent level (category or MCP server group) disables this entry. */
isToolParentDisabled(entry: ToolEntry): boolean {
if (!this.isCategoryEnabled(entry.source)) return true;
return (
entry.source === ToolSource.MCP &&
!!entry.serverId &&
!this.isServerToolsEnabled(entry.serverId)
);
}
/**
* MCP servers usable under the effective policy: globally enabled, url set,
* MCP category on and the server-scoped key not disabled.
*/
policyEnabledServerIds(): string[] {
if (!this.isCategoryEnabled(ToolSource.MCP)) return [];
return mcpStore
.getServers()
.filter(
(server) => server.enabled && server.url.trim() && this.isServerToolsEnabled(server.id)
)
.map((server) => server.id);
}
/** Reload persisted defaults, e.g. when the active conversation is cleared. */
resetPending(): void {
this.pendingReasoningEffort = loadReasoningEffortDefault();
this.pendingCwd = null;
}
// Working Directory
/**
* Sets the working directory for the active conversation. Pass `null` or
* an empty string to clear it, which restores the picker's empty state.
*
* On the empty new-chat screen (no active conversation yet), the value
* is buffered into `pendingCwd` so the user can pick before
* sending the first message; `createConversation()` consumes it.
*
* @param value - Absolute server-side path to the working directory, or null to clear
*/
async setCwd(value: string | null): Promise<void> {
const trimmed = value?.trim() || undefined;
// No chat yet - buffer for the first chat the user creates.
if (!this.host.activeConversation) {
this.pendingCwd = trimmed ?? null;
return;
}
const id = this.host.activeConversation.id;
this.host.applyConversationUpdate(id, {
cwd: trimmed
});
await DatabaseService.updateConversation(id, {
cwd: trimmed
});
this.pendingCwd = null;
}
// Reasoning Effort
/**
* Sets the reasoning effort for the active conversation.
* If no conversation exists, stores the global default.
* @param effort - The effort level ('default' | 'off' | 'low' | 'medium' | 'high' | 'max')
*/
async setReasoningEffort(effort: ReasoningEffort): Promise<void> {
if (!this.host.activeConversation) {
this.pendingReasoningEffort = effort;
saveReasoningEffortDefault(effort);
return;
}
this.host.applyConversationUpdate(this.host.activeConversation.id, {
reasoningEffort: effort
});
await DatabaseService.updateConversation(this.host.activeConversation.id, {
reasoningEffort: effort
});
}
async toggleCategory(source: ToolSource): Promise<void> {
const conv: DatabaseConversation | null = this.host.activeConversation;
if (!conv) {
toolsStore.toggleCategory(source);
return;
}
const next = buildDisabledToolCategories(conv);
if (next.has(source)) next.delete(source);
else next.add(source);
await this.persistDisabledToolCategories(next);
}
async toggleGroup(group: ToolGroup): Promise<void> {
if (group.source === ToolSource.MCP && group.serverId) {
await this.toggleServerTools(group.serverId);
} else {
await this.toggleCategory(group.source);
}
}
async toggleServerTools(serverId: string): Promise<void> {
await this.toggleTool(toolsStore.getMcpServerToolsKey(serverId));
}
async toggleTool(key: string): Promise<void> {
const conv: DatabaseConversation | null = this.host.activeConversation;
if (!conv) {
toolsStore.toggleTool(key);
return;
}
const next = buildDisabledTools(conv);
if (next.has(key)) next.delete(key);
else next.add(key);
await this.persistDisabledTools(next);
}
private async persistDisabledToolCategories(disabled: Set<ToolSource>): Promise<void> {
const conv = this.host.activeConversation;
if (!conv) return;
const disabledToolCategories = disabled.size ? [...disabled] : undefined;
this.host.applyConversationUpdate(conv.id, { disabledToolCategories });
await DatabaseService.updateConversation(conv.id, { disabledToolCategories });
}
private async persistDisabledTools(disabled: Set<string>): Promise<void> {
const conv = this.host.activeConversation;
if (!conv) return;
const disabledTools = disabled.size ? [...disabled] : undefined;
this.host.applyConversationUpdate(conv.id, { disabledTools });
await DatabaseService.updateConversation(conv.id, { disabledTools });
}
}