This commit is contained in:
Timothy Jaeryang Baek 2026-10-25 15:09:48 +09:00
parent c596d40bd4
commit 08bfaa4be6
30 changed files with 1034 additions and 635 deletions

View file

@ -14,7 +14,7 @@ from urllib.parse import urlparse
import redis
import requests
from authlib.integrations.starlette_client import OAuth
from pydantic import BaseModel
from pydantic import BaseModel, StrictBool, TypeAdapter
from open_webui.env import (
USE_SLIM,
@ -1745,6 +1745,12 @@ except Exception as e:
DEFAULT_INTERFACE_SETTINGS = default_interface_settings if isinstance(default_interface_settings, dict) else {}
try:
UI_VISIBILITY = TypeAdapter(dict[str, StrictBool]).validate_json(os.getenv('UI_VISIBILITY', '{}'))
except Exception as e:
log.exception(f'Error loading UI_VISIBILITY: {e}')
UI_VISIBILITY = {}
DEFAULT_USER_ROLE = os.getenv('DEFAULT_USER_ROLE', 'pending')
DEFAULT_GROUP_ID = os.getenv('DEFAULT_GROUP_ID', '')
@ -3191,6 +3197,7 @@ DEFAULT_CONFIG = {
'ui.default_models': DEFAULT_MODELS,
'ui.default_pinned_models': DEFAULT_PINNED_MODELS,
'ui.default_interface_settings': DEFAULT_INTERFACE_SETTINGS,
'ui.visibility': UI_VISIBILITY,
'ui.i18n': {},
'ui.prompt_suggestions': DEFAULT_PROMPT_SUGGESTIONS,
'ui.prompt_suggestions_i18n': DEFAULT_PROMPT_SUGGESTIONS_I18N,

View file

@ -2366,6 +2366,7 @@ async def get_app_config(request: Request):
'ui.default_models',
'ui.default_pinned_models',
'ui.default_interface_settings',
'ui.visibility',
'ui.i18n',
'ui.prompt_suggestions',
'ui.prompt_suggestions_i18n',
@ -2532,6 +2533,7 @@ async def get_app_config(request: Request):
},
'ui': {
'default_interface_settings': config.get('ui.default_interface_settings'),
'visibility': config.get('ui.visibility'),
'pending_user_overlay_title': config.get('ui.pending_user_overlay_title'),
'pending_user_overlay_content': config.get('ui.pending_user_overlay_content'),
'response_watermark': config.get('ui.watermark'),

View file

@ -31,7 +31,7 @@ from open_webui.utils.tools import (
set_terminal_servers,
set_tool_servers,
)
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, StrictBool
router = APIRouter()
@ -127,6 +127,24 @@ async def get_config_namespace(namespace: str, user=Depends(get_admin_user)):
return await Config.get_namespace(namespace)
class InterfaceVisibilityForm(BaseModel):
visibility: dict[str, StrictBool]
@router.post('/interface/visibility', response_model=InterfaceVisibilityForm)
async def set_interface_visibility(request: Request, form_data: InterfaceVisibilityForm, user=Depends(get_admin_user)):
await Config.upsert({'ui.visibility': form_data.visibility})
await publish_event(
request,
EVENTS.CONFIG_UPDATED,
actor=user,
subject_id='interface',
subject_type='config',
data={'keys': ['ui.visibility']},
)
return form_data
############################
# Connections Config
############################

View file

@ -1,5 +1,22 @@
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import type { Banner } from '$lib/types';
import type { Visibility } from '$lib/utils/ui-visibility';
export const setInterfaceVisibility = async (
token: string,
visibility: Visibility
): Promise<Visibility> => {
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/interface/visibility`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ visibility })
});
if (!res.ok) throw (await res.json()).detail;
return (await res.json()).visibility;
};
export const importConfig = async (token: string, config: object) => {
let error = null;

View file

@ -89,6 +89,10 @@
let keyboardIndex = 0;
let plot: HTMLDivElement;
let chartWidth = 0;
let legendExpanded = false;
$: legendColumns = Math.max(1, Math.floor((chartWidth + 16) / 256));
$: legendLimit = legendColumns * 2 - 1;
$: legendIds = legendExpanded ? ids : ids.slice(0, legendLimit);
let tooltip: Instance | undefined;
let tooltipContent: HTMLDivElement;
let tooltipId = '';
@ -145,22 +149,27 @@
: [0, series.length - 1]
)
].filter((i) => i >= 0 && i < series.length);
$: lanes = visibleIds.map((id) => {
$: lanes = (view === 'activity' ? visibleIds : []).map((id) => {
const values = series.map((point) => point.models[id] ?? 0);
return { id, peak: Math.max(0, ...values), values };
});
$: stacks = series.map((point) => visibleIds.filter((id) => (point.models[id] ?? 0) > 0));
$: stacks =
view === 'volume'
? series.map((point) => visibleIds.filter((id) => (point.models[id] ?? 0) > 0))
: [];
$: activityPositions = series.map((point) => (point.time - start) / span);
const name = (id: string) => modelNames[id] || id;
const number = (value: number) =>
new Intl.NumberFormat($i18n.language, { notation: 'compact', maximumFractionDigits: 1 }).format(
value
);
$: numberFormat = new Intl.NumberFormat($i18n.language, {
notation: 'compact',
maximumFractionDigits: 1
});
const number = (value: number) => numberFormat.format(value);
const dateLabel = (date: string) =>
dayjs(date)
.locale($i18n.language)
.format(granularity === 'daily' ? 'MMM D' : 'MMM D, HH:mm');
const position = (time: number) => ((time - start + step / 2) / span) * 100;
function navigate(event: KeyboardEvent, index: number) {
async function navigate(event: KeyboardEvent, index: number) {
const buttons = plot.querySelectorAll<HTMLButtonElement>('button[data-point]');
const destinations: Record<string, number> = {
ArrowRight: index + 1,
@ -171,12 +180,74 @@
if (view === 'activity') {
destinations.ArrowDown = index + series.length;
destinations.ArrowUp = index - series.length;
destinations.End = lanes.length * series.length - 1;
}
const next = destinations[event.key];
if (next === undefined) return;
event.preventDefault();
keyboardIndex = Math.max(0, Math.min(buttons.length - 1, next));
buttons[keyboardIndex]?.focus();
keyboardIndex = Math.max(
0,
Math.min((view === 'activity' ? lanes.length * series.length : buttons.length) - 1, next)
);
if (view === 'activity') {
const row = Math.floor(keyboardIndex / series.length);
await tick();
buttons[row]?.focus();
showTooltip({ currentTarget: buttons[row] }, keyboardIndex % series.length, lanes[row].id);
} else buttons[keyboardIndex]?.focus();
}
// One canvas and focus target per lane avoids a DOM element for every model/date pair.
function drawActivity(
canvas: HTMLCanvasElement,
options: {
lane: { id: string; peak: number; values: number[] };
positions: number[];
cellWidth: number;
width: number;
}
) {
function draw() {
const { lane, positions, cellWidth } = options;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
const scale = window.devicePixelRatio || 1;
canvas.width = Math.round(width * scale);
canvas.height = Math.round(height * scale);
const context = canvas.getContext('2d');
if (!context) return;
context.scale(scale, scale);
context.fillStyle = modelColor(lane.id);
lane.values.forEach((value, index) => {
if (value <= 0) return;
context.globalAlpha = 0.25 + (0.75 * value) / lane.peak;
context.fillRect(positions[index] * width, 0, cellWidth * width, height);
});
}
draw();
return {
update(next: typeof options) {
options = next;
draw();
}
};
}
function hoverActivity(event: MouseEvent, id: string) {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
const time = start + Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)) * span;
let low = 0;
let high = series.length - 1;
while (low < high) {
const mid = Math.ceil((low + high) / 2);
if (series[mid].time <= time) low = mid;
else high = mid - 1;
}
if (time >= series[low].time + step && time < start + span) {
hideTooltip();
return;
}
if (active !== low || activeModel !== id) showTooltip(event, low, id);
}
onMount(() => {
@ -188,7 +259,7 @@
placement: 'right-start',
offset: [0, 12],
popperOptions: {
modifiers: [{ name: 'flip', options: { fallbackPlacements: ['left-start'] } }]
modifiers: [{ name: 'flip', options: { fallbackPlacements: ['left-start', 'top'] } }]
},
duration: 0,
interactive: true,
@ -203,7 +274,9 @@
const rect = (anchor ?? plot).getBoundingClientRect();
const height = tooltip?.popper.offsetHeight || tooltipContent.offsetHeight;
return new DOMRect(
rect.x + rect.width / 2,
rect.x +
rect.width *
(view === 'activity' && active !== null ? position(series[active].time) / 100 : 0.5),
plot.getBoundingClientRect().top - height / 2,
0,
0
@ -214,7 +287,7 @@
return () => tooltip?.destroy();
});
async function showTooltip(
event: MouseEvent | FocusEvent,
event: { currentTarget: EventTarget | null },
index: number,
modelId: string | null = null
) {
@ -410,6 +483,8 @@
{:else}
<div class="space-y-1">
{#each lanes as lane, row (lane.id)}
{@const index =
Math.floor(keyboardIndex / series.length) === row ? keyboardIndex % series.length : 0}
<div class="grid grid-cols-[7rem_minmax(0,1fr)] gap-2 items-center">
<button
type="button"
@ -418,41 +493,34 @@
aria-pressed={selectedModel === lane.id}
on:click={() => isolate(lane.id)}>{name(lane.id)}</button
>
<div class="relative h-6 bg-gray-100/70 dark:bg-gray-850/40">
{#each series as point, index (point.date)}
{@const cellIndex = row * series.length + index}
<button
type="button"
data-point
class="absolute inset-y-0 focus-visible:outline focus-visible:outline-1 focus-visible:outline-gray-400"
style:left={`${position(point.time)}%`}
style:width={`${(100 * step) / span}%`}
style:transform="translateX(-50%)"
tabindex={keyboardIndex === cellIndex ? 0 : -1}
aria-label={`${name(lane.id)}, ${dateLabel(point.date)}: ${lane.values[index].toLocaleString($i18n.language)} ${metricLabel}`}
aria-describedby={active === index && activeModel === lane.id
? tooltipId
: undefined}
on:mouseenter={(event) => showTooltip(event, index, lane.id)}
on:mouseleave={leaveTooltip}
on:focus={(event) => {
keyboardIndex = cellIndex;
showTooltip(event, index, lane.id);
}}
on:blur={hideTooltip}
on:click={(event) => showTooltip(event, index, lane.id)}
on:keydown={(event) => navigate(event, cellIndex)}
>
<span
class="block absolute inset-0"
style:background-color={modelColor(lane.id)}
style:opacity={lane.values[index] > 0
? 0.25 + (0.75 * lane.values[index]) / lane.peak
: 0}
></span>
</button>
{/each}
</div>
<button
type="button"
data-point
class="relative h-6 bg-gray-100/70 dark:bg-gray-850/40 focus-visible:outline focus-visible:outline-1 focus-visible:outline-gray-400"
tabindex={Math.floor(keyboardIndex / series.length) === row ? 0 : -1}
aria-label={`${name(lane.id)}, ${dateLabel(series[index].date)}: ${lane.values[index].toLocaleString($i18n.language)} ${metricLabel}`}
aria-describedby={activeModel === lane.id ? tooltipId : undefined}
on:mousemove={(event) => hoverActivity(event, lane.id)}
on:mouseleave={leaveTooltip}
on:focus={(event) => {
keyboardIndex = row * series.length + index;
showTooltip(event, index, lane.id);
}}
on:blur={hideTooltip}
on:click={(event) => hoverActivity(event, lane.id)}
on:keydown={(event) => navigate(event, row * series.length + index)}
>
<canvas
class="absolute inset-0 w-full h-full pointer-events-none"
aria-hidden="true"
use:drawActivity={{
lane,
positions: activityPositions,
cellWidth: step / span,
width: chartWidth
}}
></canvas>
</button>
</div>
{/each}
</div>
@ -489,16 +557,19 @@
>
{/each}
</div>
<div class="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[0.6875rem]">
<div
class="mt-2 grid items-center gap-x-4 gap-y-1 text-[0.6875rem]"
style:grid-template-columns={`repeat(${legendColumns}, minmax(0, 1fr))`}
>
<button
type="button"
class="py-1"
class="py-1 text-left"
class:text-gray-900={selectedModel === null}
class:dark:text-gray-200={selectedModel === null}
aria-pressed={selectedModel === null}
on:click={() => isolate(null)}>{$i18n.t('All models')}</button
>
{#each ids as id (id)}
{#each legendIds as id (id)}
<button
type="button"
class="flex min-w-0 max-w-full items-center gap-1.5 py-1 text-left hover:text-gray-900 dark:hover:text-gray-100"
@ -510,7 +581,7 @@
<span class="size-2 shrink-0" style:background-color={modelColor(id)}></span>
<span class="max-w-40 truncate" title={name(id)}>{name(id)}</span>
<span
class="w-14 shrink-0 text-right tabular-nums text-gray-400 dark:text-gray-500"
class="ml-auto w-14 shrink-0 text-right tabular-nums text-gray-400 dark:text-gray-500"
title={(hovered ? (hoveredModels?.[id] ?? 0) : (totals[id] ?? 0)).toLocaleString(
$i18n.language
)}>{number(hovered ? (hoveredModels?.[id] ?? 0) : (totals[id] ?? 0))}</span
@ -518,6 +589,15 @@
</button>
{/each}
</div>
{#if ids.length > legendLimit}
<button
type="button"
class="mt-1 py-1 text-[0.6875rem] hover:text-gray-900 dark:hover:text-gray-100"
aria-expanded={legendExpanded}
on:click={() => (legendExpanded = !legendExpanded)}
>{legendExpanded ? $i18n.t('Show less') : $i18n.t('Show more')}</button
>
{/if}
<p class="mt-1 text-[0.6875rem] text-gray-400 dark:text-gray-500">
{view === 'activity'
? $i18n.t('Intensity relative to each model’s peak. Blank means no recorded usage.')

View file

@ -16,6 +16,9 @@
import AdminSettingRow from './AdminSettingRow.svelte';
import AdminSettingSection from './AdminSettingSection.svelte';
import { config as appConfig } from '$lib/stores';
import { setInterfaceVisibility } from '$lib/apis/configs';
import type { Visibility } from '$lib/utils/ui-visibility';
import VisibilitySettings from './Interface/Visibility.svelte';
export let saveHandler: () => void;
@ -58,6 +61,7 @@
};
let toolSearchAlwaysLoaded = '';
let showTaskParameters = false;
let visibility: Visibility = { ...($appConfig?.ui?.visibility ?? {}) };
const configuredParams = (params: Record<string, any> = {}) =>
Object.fromEntries(
@ -73,7 +77,7 @@
};
try {
[taskConfig, chatConfig] = await Promise.all([
[taskConfig, chatConfig, visibility] = await Promise.all([
updateTaskConfig(localStorage.token, taskConfigPayload),
updateChatConfig(localStorage.token, {
...chatConfig,
@ -81,7 +85,8 @@
.split(',')
.map((item) => item.trim())
.filter((item) => item !== '')
})
}),
setInterfaceVisibility(localStorage.token, visibility)
]);
} catch (error) {
toast.error(
@ -94,6 +99,7 @@
current
? {
...current,
ui: { ...current.ui, visibility },
features: {
...current.features,
enable_context_compaction: chatConfig.ENABLE_CONTEXT_COMPACTION,
@ -193,7 +199,8 @@
</h2>
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5">
<AdminSettingSection title={$i18n.t('settings.admin.interface.sections.tasks.title')} first>
<VisibilitySettings bind:visibility />
<AdminSettingSection title={$i18n.t('settings.admin.interface.sections.tasks.title')}>
<div>
<div class="mb-2">
<div class="text-xs text-gray-600 dark:text-gray-400">

View file

@ -0,0 +1,62 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { Writable } from 'svelte/store';
import type { i18n as I18n } from 'i18next';
import {
CONTROL_GROUPS,
CONTROL_LABELS,
type ControlId,
type Visibility
} from '$lib/utils/ui-visibility';
import ModelSettingsSection from '$lib/components/workspace/Models/ModelSettingsSection.svelte';
import ModelSettingToggle from '$lib/components/workspace/Models/ModelSettingToggle.svelte';
import AdminSettingSection from '../AdminSettingSection.svelte';
import AdminSettingRow from '../AdminSettingRow.svelte';
const i18n: Writable<I18n> = getContext('i18n');
export let visibility: Visibility = {};
let expanded = false;
const controls = Object.keys(CONTROL_LABELS) as ControlId[];
const setVisible = (id: ControlId, visible: boolean) => {
visibility = { ...visibility };
if (visible) delete visibility[id];
else visibility[id] = false;
};
</script>
<AdminSettingSection title={$i18n.t('Visibility')} first>
<AdminSettingRow
let:labelId
label={$i18n.t('settings.admin.interface.visibility.title')}
description={$i18n.t('settings.admin.interface.visibility.description')}
>
<button
id={`${labelId}-control`}
type="button"
class="focus-ring text-xs text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
aria-expanded={expanded}
aria-controls="interface-visible-controls"
on:click={() => (expanded = !expanded)}
>
{expanded ? $i18n.t('Hide') : $i18n.t('Show')}
</button>
</AdminSettingRow>
<div id="interface-visible-controls" hidden={!expanded}>
<div class="space-y-0.5">
{#each CONTROL_GROUPS as [prefix, label]}
<ModelSettingsSection label={$i18n.t(label)}>
<div class="grid grid-cols-1 gap-x-6 sm:grid-cols-2 lg:grid-cols-3">
{#each controls.filter((id) => id.startsWith(prefix)) as id}
<ModelSettingToggle
label={$i18n.t(CONTROL_LABELS[id])}
checked={visibility[id] !== false}
on:change={(event) => setVisible(id, event.detail)}
/>
{/each}
</div>
</ModelSettingsSection>
{/each}
</div>
</div>
</AdminSettingSection>

View file

@ -14,7 +14,8 @@
selectedTerminalId,
settings,
terminalServers,
terminalSkills
terminalSkills,
visibleControls
} from '$lib/stores';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ChatBubbleDotted from '$lib/components/icons/ChatBubbleDotted.svelte';
@ -60,19 +61,19 @@
...(canCompact && 'compact'.startsWith(query.toLowerCase())
? [{ type: 'command', data: { id: 'compact' } }]
: []),
...(canFork && 'fork'.startsWith(query.toLowerCase())
...(canFork && $visibleControls['chat.fork'] && 'fork'.startsWith(query.toLowerCase())
? [{ type: 'command', data: { id: 'fork' } }]
: []),
...(canStatus && 'status'.startsWith(query.toLowerCase())
...(canStatus && $visibleControls['chat.status'] && 'status'.startsWith(query.toLowerCase())
? [{ type: 'command', data: { id: 'status' } }]
: []),
...('model'.startsWith(query.toLowerCase())
...($visibleControls['chat.model_command'] && 'model'.startsWith(query.toLowerCase())
? [{ type: 'command', data: { id: 'model' } }]
: []),
...('settings'.startsWith(query.toLowerCase())
...($visibleControls['chat.settings_command'] && 'settings'.startsWith(query.toLowerCase())
? [{ type: 'command', data: { id: 'settings' } }]
: []),
...(canCompare && 'compare'.startsWith(query.toLowerCase())
...(canCompare && $visibleControls['chat.compare'] && 'compare'.startsWith(query.toLowerCase())
? [{ type: 'command', data: { id: 'compare' } }]
: []),
...(hasChatContent &&

View file

@ -2,7 +2,7 @@
import { getContext, onMount, tick } from 'svelte';
import { fly } from 'svelte/transition';
import { config, user, tools as _tools, mobile, knowledge } from '$lib/stores';
import { config, user, tools as _tools, mobile, knowledge, visibleControls } from '$lib/stores';
import { getKnowledgeBases } from '$lib/apis/knowledge';
import { createPicker } from '$lib/utils/google-drive-picker';
@ -214,7 +214,7 @@
</button>
</Tooltip>
{#if visionCapableModels.length > 0}
{#if visionCapableModels.length > 0 && $visibleControls['chat.capture']}
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
@ -250,90 +250,96 @@
</Tooltip>
{/if}
<button
role="menuitem"
type="button"
class="flex w-full gap-2 items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl text-left"
on:click={async () => {
show = false;
await tick();
onCompare();
}}
>
<svg
class="size-3.5 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M12 4v16M5.5 12h4M14.5 12h4M16.5 10v4" />
</svg>
<span>{$i18n.t('Compare text')}</span>
</button>
<Tooltip
content={!webUploadEnabled
? $i18n.t('You do not have permission to upload web content.')
: ''}
className="w-full"
>
{#if $visibleControls['chat.compare']}
<button
role="menuitem"
class="flex w-full gap-2 items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl text-left {!webUploadEnabled
? 'opacity-50'
: ''}"
type="button"
on:click={() => {
if (webUploadEnabled) {
showAttachWebpageModal = true;
show = false;
}
class="flex w-full gap-2 items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl text-left"
on:click={async () => {
show = false;
await tick();
onCompare();
}}
>
<GlobeAlt />
<div class="line-clamp-1">{$i18n.t('Attach Webpage')}</div>
<svg
class="size-3.5 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M12 4v16M5.5 12h4M14.5 12h4M16.5 10v4" />
</svg>
<span>{$i18n.t('Compare text')}</span>
</button>
</Tooltip>
{/if}
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
: !fileUploadEnabled
? $i18n.t('You do not have permission to upload files.')
{#if $visibleControls['chat.attach_webpage']}
<Tooltip
content={!webUploadEnabled
? $i18n.t('You do not have permission to upload web content.')
: ''}
className="w-full"
>
<button
role="menuitem"
class="flex gap-2 w-full items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl text-left {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
if (fileUploadEnabled) {
tab = 'files';
}
}}
className="w-full"
>
<DocumentArrowUp />
<button
role="menuitem"
class="flex w-full gap-2 items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl text-left {!webUploadEnabled
? 'opacity-50'
: ''}"
type="button"
on:click={() => {
if (webUploadEnabled) {
showAttachWebpageModal = true;
show = false;
}
}}
>
<GlobeAlt />
<div class="line-clamp-1">{$i18n.t('Attach Webpage')}</div>
</button>
</Tooltip>
{/if}
<div class="flex items-center w-full justify-between">
<div class="line-clamp-1">
{$i18n.t('Attach Files')}
{#if $visibleControls['chat.attach_files']}
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
: !fileUploadEnabled
? $i18n.t('You do not have permission to upload files.')
: ''}
className="w-full"
>
<button
role="menuitem"
class="flex gap-2 w-full items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal select-none cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl text-left {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
if (fileUploadEnabled) {
tab = 'files';
}
}}
>
<DocumentArrowUp />
<div class="flex items-center w-full justify-between">
<div class="line-clamp-1">
{$i18n.t('Attach Files')}
</div>
<div class="text-gray-500">
<ChevronRight />
</div>
</div>
</button>
</Tooltip>
{/if}
<div class="text-gray-500">
<ChevronRight />
</div>
</div>
</button>
</Tooltip>
{#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))}
{#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true)) && $visibleControls['chat.attach_notes']}
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
@ -368,71 +374,75 @@
</Tooltip>
{/if}
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
: !fileUploadEnabled
? $i18n.t('You do not have permission to upload files.')
: ''}
className="w-full"
>
<button
role="menuitem"
class="flex gap-2 w-full items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl text-left {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
if (fileUploadEnabled) {
tab = 'knowledge';
}
}}
{#if $visibleControls['chat.attach_knowledge']}
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
: !fileUploadEnabled
? $i18n.t('You do not have permission to upload files.')
: ''}
className="w-full"
>
<Database />
<button
role="menuitem"
class="flex gap-2 w-full items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl text-left {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
if (fileUploadEnabled) {
tab = 'knowledge';
}
}}
>
<Database />
<div class="flex items-center w-full justify-between">
<div class=" line-clamp-1">
{$i18n.t('Attach Knowledge')}
<div class="flex items-center w-full justify-between">
<div class=" line-clamp-1">
{$i18n.t('Attach Knowledge')}
</div>
<div class="text-gray-500">
<ChevronRight />
</div>
</div>
</button>
</Tooltip>
{/if}
<div class="text-gray-500">
<ChevronRight />
</div>
</div>
</button>
</Tooltip>
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
: !fileUploadEnabled
? $i18n.t('You do not have permission to upload files.')
: ''}
className="w-full"
>
<button
role="menuitem"
class="flex gap-2 w-full items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl text-left {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
if (fileUploadEnabled) {
tab = 'chats';
}
}}
{#if $visibleControls['chat.reference_chats']}
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
: !fileUploadEnabled
? $i18n.t('You do not have permission to upload files.')
: ''}
className="w-full"
>
<ClockRotateRight />
<button
role="menuitem"
class="flex gap-2 w-full items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl text-left {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
if (fileUploadEnabled) {
tab = 'chats';
}
}}
>
<ClockRotateRight />
<div class="flex items-center w-full justify-between">
<div class=" line-clamp-1">
{$i18n.t('Reference Chats')}
</div>
<div class="flex items-center w-full justify-between">
<div class=" line-clamp-1">
{$i18n.t('Reference Chats')}
</div>
<div class="text-gray-500">
<ChevronRight />
<div class="text-gray-500">
<ChevronRight />
</div>
</div>
</div>
</button>
</Tooltip>
</button>
</Tooltip>
{/if}
{#if fileUploadEnabled}
{#if $config?.features?.enable_google_drive_integration}

View file

@ -4,7 +4,7 @@
import { getContext, onMount, tick, onDestroy } from 'svelte';
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
import { config, pyodideWorker as pyodideWorkerStore } from '$lib/stores';
import { config, pyodideWorker as pyodideWorkerStore, visibleControls } from '$lib/stores';
import { createPyodideWorker } from '$lib/pyodide/createPyodideWorker';
import { executeCode } from '$lib/apis/utils';
@ -544,7 +544,7 @@
on:click={copyCode}>{copied ? $i18n.t('Copied') : $i18n.t('Copy')}</button
>
{#if preview && ['html', 'svg'].includes(lang)}
{#if preview && ['html', 'svg'].includes(lang) && $visibleControls['chat.code_preview']}
<button
class="run-code-button flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-lg px-2.5 text-xs font-normal text-gray-600 transition hover:bg-gray-100 hover:text-gray-900 disabled:opacity-60 dark:text-gray-400 dark:hover:bg-gray-850 dark:hover:text-white"
on:click={previewCode}

View file

@ -2,7 +2,7 @@
import { onMount, tick, getContext } from 'svelte';
import { createEventDispatcher } from 'svelte';
import { mobile, models, settings } from '$lib/stores';
import { mobile, models, settings, visibleControls } from '$lib/stores';
import { generateMoACompletion } from '$lib/apis';
import { updateChatById } from '$lib/apis/chats';
@ -437,7 +437,7 @@
{/if}
</div>
{#if isLastMessage}
{#if isLastMessage && $visibleControls['chat.merge']}
<div class=" shrink-0 text-gray-600 dark:text-gray-500 mt-1">
<Tooltip content={$i18n.t('Merge Responses')} placement="bottom">
<button

View file

@ -27,7 +27,8 @@
settings,
temporaryChatEnabled,
TTSWorker,
user
user,
visibleControls
} from '$lib/stores';
import { synthesizeOpenAISpeech } from '$lib/apis/audio';
import { imageGenerations } from '$lib/apis/images';
@ -1191,7 +1192,7 @@
</Tooltip>
{/if}
{#if message.usage}
{#if message.usage && $visibleControls['chat.usage']}
{@const usage = JSON.stringify(message.usage, null, 2)
.replace(/"([^(")"]+)":/g, '$1:')
.slice(1, -1)
@ -1247,7 +1248,7 @@
</Tooltip>
{/if}
{#if $user && message.done && forkHandler && ($user?.role === 'admin' || ($user?.permissions?.chat?.import ?? true))}
{#if $user && message.done && forkHandler && ($user?.role === 'admin' || ($user?.permissions?.chat?.import ?? true)) && $visibleControls['chat.fork']}
<Tooltip content={$i18n.t('Fork chat')} placement="bottom">
<button
aria-label={$i18n.t('Fork chat')}

View file

@ -9,7 +9,7 @@
import PinSlash from '$lib/components/icons/PinSlash.svelte';
import Link from '$lib/components/icons/Link.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte';
import { config, pinnedModels, settings, showSettings, user } from '$lib/stores';
import { config, pinnedModels, settings, showSettings, user, visibleControls } from '$lib/stores';
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
const i18n = getContext('i18n');
@ -105,32 +105,34 @@
<hr class="border-gray-50 dark:border-gray-800/30 mx-1 my-0.5" />
{/if}
<button
type="button"
aria-pressed={$pinnedModels.includes(model?.id)}
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
{#if $visibleControls['models.pin']}
<button
type="button"
aria-pressed={$pinnedModels.includes(model?.id)}
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
pinModelHandler(model?.id);
show = false;
}}
>
{#if $pinnedModels.includes(model?.id)}
<PinSlash className="size-3.5" />
{:else}
<Pin className="size-3.5" />
{/if}
<div class="flex items-center">
pinModelHandler(model?.id);
show = false;
}}
>
{#if $pinnedModels.includes(model?.id)}
{$i18n.t('Hide from Sidebar')}
<PinSlash className="size-3.5" />
{:else}
{$i18n.t('Keep in Sidebar')}
<Pin className="size-3.5" />
{/if}
</div>
</button>
<div class="flex items-center">
{#if $pinnedModels.includes(model?.id)}
{$i18n.t('Hide from Sidebar')}
{:else}
{$i18n.t('Keep in Sidebar')}
{/if}
</div>
</button>
{/if}
<button
type="button"

View file

@ -31,7 +31,8 @@
temporaryChatEnabled,
settings,
config,
showSettings
showSettings,
visibleControls
} from '$lib/stores';
import { toast } from 'svelte-sonner';
import { capitalizeFirstLetter, sanitizeResponseContent, splitStream } from '$lib/utils';
@ -1277,7 +1278,7 @@
{/each}
</div>
{#if showSetDefault}
{#if showSetDefault && $visibleControls['models.set_default']}
<div
class="flex shrink-0 items-center justify-end px-2 leading-none {($settings?.highContrastMode ??
false)

View file

@ -19,7 +19,7 @@
getArchivedChatList,
unarchiveAllChats
} from '$lib/apis/chats';
import { chatId, showSettings, user } from '$lib/stores';
import { chatId, showSettings, user, visibleControls } from '$lib/stores';
import { refreshChatList, refreshSidebar } from '$lib/stores/chatList';
import { formatNumber } from '$lib/utils';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
@ -244,23 +244,25 @@
<div slot="content">
<DropdownMenu className="min-w-[10.625rem] shadow-sm">
<button
class="flex h-[1.6875rem] w-full cursor-pointer select-none items-center gap-2 rounded-lg bg-transparent px-2 text-xs hover:text-gray-900 disabled:cursor-default disabled:opacity-30 dark:hover:text-gray-100"
disabled={loading || chatCount === 0}
type="button"
on:click={() => {
showUnarchiveAllConfirmDialog = true;
}}
>
{#if loading}
<Spinner className="size-3.5 shrink-0" />
{:else}
<UndoAction className="size-3.5 shrink-0" strokeWidth="1.5" />
{/if}
<div class="min-w-0 flex-1 truncate text-left">
{$i18n.t('settings.personal.archivedChats.unarchiveAll.label')}
</div>
</button>
{#if $visibleControls['chat.archive']}
<button
class="flex h-[1.6875rem] w-full cursor-pointer select-none items-center gap-2 rounded-lg bg-transparent px-2 text-xs hover:text-gray-900 disabled:cursor-default disabled:opacity-30 dark:hover:text-gray-100"
disabled={loading || chatCount === 0}
type="button"
on:click={() => {
showUnarchiveAllConfirmDialog = true;
}}
>
{#if loading}
<Spinner className="size-3.5 shrink-0" />
{:else}
<UndoAction className="size-3.5 shrink-0" strokeWidth="1.5" />
{/if}
<div class="min-w-0 flex-1 truncate text-left">
{$i18n.t('settings.personal.archivedChats.unarchiveAll.label')}
</div>
</button>
{/if}
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.export ?? true)}
<button
@ -395,18 +397,20 @@
class="flex shrink-0 items-center justify-end text-gray-500 dark:text-gray-500"
role="cell"
>
<Tooltip content={$i18n.t('Unarchive Chat')}>
<button
class="rounded-lg p-1 text-gray-400 transition-colors hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
aria-label={$i18n.t('Unarchive Chat')}
on:click={() => {
unarchiveHandler(chat.id);
}}
>
<UndoAction className="size-3.5" strokeWidth="1.5" />
</button>
</Tooltip>
{#if $visibleControls['chat.archive']}
<Tooltip content={$i18n.t('Unarchive Chat')}>
<button
class="rounded-lg p-1 text-gray-400 transition-colors hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
aria-label={$i18n.t('Unarchive Chat')}
on:click={() => {
unarchiveHandler(chat.id);
}}
>
<UndoAction className="size-3.5" strokeWidth="1.5" />
</button>
</Tooltip>
{/if}
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.delete ?? true)}
<Tooltip content={$i18n.t('Delete Chat')}>
<button

View file

@ -2,7 +2,7 @@
import { toast } from 'svelte-sonner';
import { createEventDispatcher, onMount, getContext } from 'svelte';
import { user, settings, config } from '$lib/stores';
import { user, settings, config, visibleControls } from '$lib/stores';
import { getVoices as _getVoices } from '$lib/apis/audio';
import Switch from '$lib/components/common/Switch.svelte';
@ -188,19 +188,21 @@
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5">
<UserSettingSection title={$i18n.t('settings.personal.audio.sections.sttSettings.title')} first>
{#if $config.audio.stt.engine !== 'web'}
<UserSettingRow
label={$i18n.t('settings.personal.audio.speechToTextEngine.label')}
description={$i18n.t('settings.personal.audio.speechToTextEngine.description')}
>
<SettingsSelect
bind:value={STTEngine}
ariaLabel={$i18n.t('settings.personal.audio.speechToTextEngine.label')}
placeholder={$i18n.t('Select an engine')}
{#if $visibleControls['settings.speech_to_text_engine']}
<UserSettingRow
label={$i18n.t('settings.personal.audio.speechToTextEngine.label')}
description={$i18n.t('settings.personal.audio.speechToTextEngine.description')}
>
<option value="">{$i18n.t('Default')}</option>
<option value="web">{$i18n.t('Web API')}</option>
</SettingsSelect>
</UserSettingRow>
<SettingsSelect
bind:value={STTEngine}
ariaLabel={$i18n.t('settings.personal.audio.speechToTextEngine.label')}
placeholder={$i18n.t('Select an engine')}
>
<option value="">{$i18n.t('Default')}</option>
<option value="web">{$i18n.t('Web API')}</option>
</SettingsSelect>
</UserSettingRow>
{/if}
<UserSettingRow
label={$i18n.t('settings.personal.audio.language.label')}
@ -244,19 +246,21 @@
</UserSettingSection>
<UserSettingSection title={$i18n.t('settings.personal.audio.sections.ttsSettings.title')}>
<UserSettingRow
label={$i18n.t('settings.personal.audio.textToSpeechEngine.label')}
description={$i18n.t('settings.personal.audio.textToSpeechEngine.description')}
>
<SettingsSelect
bind:value={TTSEngine}
ariaLabel={$i18n.t('settings.personal.audio.textToSpeechEngine.label')}
placeholder={$i18n.t('Select an engine')}
{#if $visibleControls['settings.text_to_speech_engine']}
<UserSettingRow
label={$i18n.t('settings.personal.audio.textToSpeechEngine.label')}
description={$i18n.t('settings.personal.audio.textToSpeechEngine.description')}
>
<option value="">{$i18n.t('Default')}</option>
<option value="browser-kokoro">{$i18n.t('Kokoro.js (Browser)')}</option>
</SettingsSelect>
</UserSettingRow>
<SettingsSelect
bind:value={TTSEngine}
ariaLabel={$i18n.t('settings.personal.audio.textToSpeechEngine.label')}
placeholder={$i18n.t('Select an engine')}
>
<option value="">{$i18n.t('Default')}</option>
<option value="browser-kokoro">{$i18n.t('Kokoro.js (Browser)')}</option>
</SettingsSelect>
</UserSettingRow>
{/if}
{#if TTSEngine === 'browser-kokoro'}
<UserSettingRow

View file

@ -3,7 +3,7 @@
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { user } from '$lib/stores';
import { user, visibleControls } from '$lib/stores';
import { refreshSidebar } from '$lib/stores/chatList';
import { archiveAllChats, deleteAllChats, getAllChats, importChats } from '$lib/apis/chats';
@ -215,22 +215,24 @@
</button>
</UserSettingRow>
<UserSettingRow
let:labelId
label={$i18n.t('settings.personal.dataControls.archiveAllChats.label')}
description={$i18n.t('settings.personal.dataControls.archiveAllChats.description')}
>
<button
id={`${labelId}-control`}
class={actionButtonClass}
on:click={() => {
showArchiveConfirmDialog = true;
}}
type="button"
{#if $visibleControls['chat.archive']}
<UserSettingRow
let:labelId
label={$i18n.t('settings.personal.dataControls.archiveAllChats.label')}
description={$i18n.t('settings.personal.dataControls.archiveAllChats.description')}
>
{$i18n.t('settings.personal.dataControls.archiveAll.label')}
</button>
</UserSettingRow>
<button
id={`${labelId}-control`}
class={actionButtonClass}
on:click={() => {
showArchiveConfirmDialog = true;
}}
type="button"
>
{$i18n.t('settings.personal.dataControls.archiveAll.label')}
</button>
</UserSettingRow>
{/if}
{#if canManageChats({ user: $user, config: null }, 'delete')}
<UserSettingRow

View file

@ -9,7 +9,7 @@
import { getContext, tick } from 'svelte';
import type { Writable } from 'svelte/store';
import { toast } from 'svelte-sonner';
import { config, mobile, models, settings, user } from '$lib/stores';
import { config, mobile, models, settings, user, visibleControls } from '$lib/stores';
import type { SettingsModalRequest } from '$lib/stores';
import { getUserSettings, updateUserSettings } from '$lib/apis/users';
import { getBackendConfig, getModels as _getModels } from '$lib/apis';
@ -417,6 +417,10 @@
);
}
if (tab.id === 'usage') {
return $visibleControls['settings.usage'];
}
if (tab.id === 'personalization') {
return (
$config?.features?.enable_memories &&

View file

@ -7,7 +7,7 @@
canUseWebSearch,
isSettingsAdmin
} from '$lib/utils/settings-access';
import { config, settings, terminalServers, user } from '$lib/stores';
import { config, settings, terminalServers, user, visibleControls } from '$lib/stores';
import { onMount, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import { updateUserInfo } from '$lib/apis/users';
@ -662,32 +662,34 @@
</p>
</div>
<div>
<div class={settingRowClass}>
<label
id="allow-user-location-label"
class={settingLabelClass}
for="allow-user-location-label-control"
>
{$i18n.t('settings.personal.interface.allowUserLocation.label')}
</label>
{#if $visibleControls['settings.user_location']}
<div>
<div class={settingRowClass}>
<label
id="allow-user-location-label"
class={settingLabelClass}
for="allow-user-location-label-control"
>
{$i18n.t('settings.personal.interface.allowUserLocation.label')}
</label>
<div class={settingControlClass}>
<Switch
ariaLabelledbyId="allow-user-location-label"
tooltip={true}
bind:state={userLocation}
inherited={isDefaultSetting('userLocation')}
on:change={() => {
toggleUserLocation();
}}
/>
<div class={settingControlClass}>
<Switch
ariaLabelledbyId="allow-user-location-label"
tooltip={true}
bind:state={userLocation}
inherited={isDefaultSetting('userLocation')}
on:change={() => {
toggleUserLocation();
}}
/>
</div>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('settings.personal.interface.allowUserLocation.description')}
</p>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('settings.personal.interface.allowUserLocation.description')}
</p>
</div>
{/if}
<div>
<div class={settingRowClass}>
@ -916,28 +918,34 @@
</p>
</div>
<div>
<div class={settingRowClass}>
<label id="chat-bubble-ui-label" class={settingLabelClass} for="chat-bubble-ui-label-control">
{$i18n.t('settings.personal.interface.chatBubbleUi.label')}
</label>
{#if $visibleControls['settings.chat_bubble']}
<div>
<div class={settingRowClass}>
<label
id="chat-bubble-ui-label"
class={settingLabelClass}
for="chat-bubble-ui-label-control"
>
{$i18n.t('settings.personal.interface.chatBubbleUi.label')}
</label>
<div class={settingControlClass}>
<Switch
tooltip={true}
ariaLabelledbyId="chat-bubble-ui-label"
bind:state={chatBubble}
inherited={isDefaultSetting('chatBubble')}
on:change={() => {
saveSettings({ chatBubble });
}}
/>
<div class={settingControlClass}>
<Switch
tooltip={true}
ariaLabelledbyId="chat-bubble-ui-label"
bind:state={chatBubble}
inherited={isDefaultSetting('chatBubble')}
on:change={() => {
saveSettings({ chatBubble });
}}
/>
</div>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('settings.personal.interface.chatBubbleUi.description')}
</p>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('settings.personal.interface.chatBubbleUi.description')}
</p>
</div>
{/if}
{#if !chatBubble}
<div>
@ -968,32 +976,34 @@
</div>
{/if}
<div>
<div class={settingRowClass}>
<label
id="widescreen-mode-label"
class={settingLabelClass}
for="widescreen-mode-label-control"
>
{$i18n.t('settings.personal.interface.widescreenMode.label')}
</label>
{#if $visibleControls['settings.widescreen']}
<div>
<div class={settingRowClass}>
<label
id="widescreen-mode-label"
class={settingLabelClass}
for="widescreen-mode-label-control"
>
{$i18n.t('settings.personal.interface.widescreenMode.label')}
</label>
<div class={settingControlClass}>
<Switch
ariaLabelledbyId="widescreen-mode-label"
tooltip={true}
bind:state={widescreenMode}
inherited={isDefaultSetting('widescreenMode')}
on:change={() => {
saveSettings({ widescreenMode });
}}
/>
<div class={settingControlClass}>
<Switch
ariaLabelledbyId="widescreen-mode-label"
tooltip={true}
bind:state={widescreenMode}
inherited={isDefaultSetting('widescreenMode')}
on:change={() => {
saveSettings({ widescreenMode });
}}
/>
</div>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('settings.personal.interface.widescreenMode.description')}
</p>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('settings.personal.interface.widescreenMode.description')}
</p>
</div>
{/if}
{#if canUseTemporaryChats({ user: $user, config: $config })}
<div>
@ -1615,47 +1625,49 @@
</p>
</div>
<div>
<div class={settingRowClass}>
<label
id="floating-action-buttons-label"
class={settingLabelClass}
for="floating-action-buttons-label-control"
>
{$i18n.t('settings.personal.interface.floatingQuickActions.label')}
</label>
{#if $visibleControls['settings.floating_action_buttons']}
<div>
<div class={settingRowClass}>
<label
id="floating-action-buttons-label"
class={settingLabelClass}
for="floating-action-buttons-label-control"
>
{$i18n.t('settings.personal.interface.floatingQuickActions.label')}
</label>
<div class={settingControlClass}>
{#if showFloatingActionButtons}
<button
class={actionButtonClass}
type="button"
aria-label={$i18n.t(
'settings.personal.interface.openModalToManageFloatingQuickActions.label'
)}
on:click={() => {
showManageFloatingActionButtonsModal = true;
<div class={settingControlClass}>
{#if showFloatingActionButtons}
<button
class={actionButtonClass}
type="button"
aria-label={$i18n.t(
'settings.personal.interface.openModalToManageFloatingQuickActions.label'
)}
on:click={() => {
showManageFloatingActionButtonsModal = true;
}}
>
{$i18n.t('Manage')}
</button>
{/if}
<Switch
ariaLabelledbyId="floating-action-buttons-label"
tooltip={true}
bind:state={showFloatingActionButtons}
inherited={isDefaultSetting('showFloatingActionButtons')}
on:change={() => {
saveSettings({ showFloatingActionButtons });
}}
>
{$i18n.t('Manage')}
</button>
{/if}
<Switch
ariaLabelledbyId="floating-action-buttons-label"
tooltip={true}
bind:state={showFloatingActionButtons}
inherited={isDefaultSetting('showFloatingActionButtons')}
on:change={() => {
saveSettings({ showFloatingActionButtons });
}}
/>
/>
</div>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('Show the floating quick-action toolbar in chat.')}
</p>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('Show the floating quick-action toolbar in chat.')}
</p>
</div>
{/if}
{#if canUseWebSearch({ user: $user, config: $config })}
<div>
@ -1723,28 +1735,30 @@
</p>
</div>
<div>
<div class={settingRowClass}>
<label id="rich-input-label" class={settingLabelClass} for="rich-input-label-control">
{$i18n.t('settings.personal.interface.richTextInputForChat.label')}
</label>
{#if $visibleControls['settings.rich_text_input']}
<div>
<div class={settingRowClass}>
<label id="rich-input-label" class={settingLabelClass} for="rich-input-label-control">
{$i18n.t('settings.personal.interface.richTextInputForChat.label')}
</label>
<div class={settingControlClass}>
<Switch
tooltip={true}
ariaLabelledbyId="rich-input-label"
bind:state={richTextInput}
inherited={isDefaultSetting('richTextInput')}
on:change={() => {
saveSettings({ richTextInput });
}}
/>
<div class={settingControlClass}>
<Switch
tooltip={true}
ariaLabelledbyId="rich-input-label"
bind:state={richTextInput}
inherited={isDefaultSetting('richTextInput')}
on:change={() => {
saveSettings({ richTextInput });
}}
/>
</div>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('settings.personal.interface.richTextInputForChat.description')}
</p>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('settings.personal.interface.richTextInputForChat.description')}
</p>
</div>
{/if}
{#if $config?.features?.enable_autocomplete_generation}
<div>
@ -1831,167 +1845,179 @@
</div>
{/if}
<div>
<div class={settingRowClass}>
<label id="paste-large-label" class={settingLabelClass} for="paste-large-label-control">
{$i18n.t('settings.personal.interface.pasteLargeTextAsFile.label')}
</label>
{#if $visibleControls['settings.large_text_as_file']}
<div>
<div class={settingRowClass}>
<label id="paste-large-label" class={settingLabelClass} for="paste-large-label-control">
{$i18n.t('settings.personal.interface.pasteLargeTextAsFile.label')}
</label>
<div class={settingControlClass}>
<Switch
tooltip={true}
ariaLabelledbyId="paste-large-label"
bind:state={largeTextAsFile}
inherited={isDefaultSetting('largeTextAsFile')}
on:change={() => {
saveSettings({ largeTextAsFile });
}}
/>
<div class={settingControlClass}>
<Switch
tooltip={true}
ariaLabelledbyId="paste-large-label"
bind:state={largeTextAsFile}
inherited={isDefaultSetting('largeTextAsFile')}
on:change={() => {
saveSettings({ largeTextAsFile });
}}
/>
</div>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('settings.personal.interface.pasteLargeTextAsFile.description')}
</p>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('settings.personal.interface.pasteLargeTextAsFile.description')}
</p>
</div>
{/if}
<div class={sectionHeadingClass}>
{$i18n.t('settings.personal.interface.sections.artifacts.title')}
</div>
<div>
<div class={settingRowClass}>
<label
id="detect-artifacts-label"
class={settingLabelClass}
for="detect-artifacts-label-control"
>
{$i18n.t('settings.personal.interface.detectArtifactsAutomatically.label')}
</label>
{#if $visibleControls['settings.detect_artifacts']}
<div>
<div class={settingRowClass}>
<label
id="detect-artifacts-label"
class={settingLabelClass}
for="detect-artifacts-label-control"
>
{$i18n.t('settings.personal.interface.detectArtifactsAutomatically.label')}
</label>
<div class={settingControlClass}>
<Switch
ariaLabelledbyId="detect-artifacts-label"
tooltip={true}
bind:state={detectArtifacts}
inherited={isDefaultSetting('detectArtifacts')}
on:change={() => {
saveSettings({ detectArtifacts });
}}
/>
<div class={settingControlClass}>
<Switch
ariaLabelledbyId="detect-artifacts-label"
tooltip={true}
bind:state={detectArtifacts}
inherited={isDefaultSetting('detectArtifacts')}
on:change={() => {
saveSettings({ detectArtifacts });
}}
/>
</div>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('settings.personal.interface.detectArtifactsAutomatically.description')}
</p>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('settings.personal.interface.detectArtifactsAutomatically.description')}
</p>
</div>
{/if}
<div>
<div class={settingRowClass}>
<label
id="iframe-sandbox-allow-scripts-label"
class={settingLabelClass}
for="iframe-sandbox-allow-scripts-label-control"
>
{$i18n.t('settings.personal.interface.iframeSandboxAllowScripts.label')}
</label>
{#if $visibleControls['settings.iframe_sandbox_allow_scripts']}
<div>
<div class={settingRowClass}>
<label
id="iframe-sandbox-allow-scripts-label"
class={settingLabelClass}
for="iframe-sandbox-allow-scripts-label-control"
>
{$i18n.t('settings.personal.interface.iframeSandboxAllowScripts.label')}
</label>
<div class={settingControlClass}>
<Switch
ariaLabelledbyId="iframe-sandbox-allow-scripts-label"
tooltip={true}
bind:state={iframeSandboxAllowScripts}
inherited={isDefaultSetting('iframeSandboxAllowScripts')}
on:change={() => {
saveSettings({ iframeSandboxAllowScripts });
}}
/>
<div class={settingControlClass}>
<Switch
ariaLabelledbyId="iframe-sandbox-allow-scripts-label"
tooltip={true}
bind:state={iframeSandboxAllowScripts}
inherited={isDefaultSetting('iframeSandboxAllowScripts')}
on:change={() => {
saveSettings({ iframeSandboxAllowScripts });
}}
/>
</div>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('settings.personal.interface.iframeSandboxAllowScripts.description')}
</p>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('settings.personal.interface.iframeSandboxAllowScripts.description')}
</p>
</div>
{/if}
<div>
<div class={settingRowClass}>
<label
id="iframe-sandbox-allow-same-origin-label"
class={settingLabelClass}
for="iframe-sandbox-allow-same-origin-label-control"
>
{$i18n.t('settings.personal.interface.iframeSandboxAllowSameOrigin.label')}
</label>
{#if $visibleControls['settings.iframe_sandbox_allow_same_origin']}
<div>
<div class={settingRowClass}>
<label
id="iframe-sandbox-allow-same-origin-label"
class={settingLabelClass}
for="iframe-sandbox-allow-same-origin-label-control"
>
{$i18n.t('settings.personal.interface.iframeSandboxAllowSameOrigin.label')}
</label>
<div class={settingControlClass}>
<Switch
ariaLabelledbyId="iframe-sandbox-allow-same-origin-label"
tooltip={true}
bind:state={iframeSandboxAllowSameOrigin}
inherited={isDefaultSetting('iframeSandboxAllowSameOrigin')}
on:change={() => {
saveSettings({ iframeSandboxAllowSameOrigin });
}}
/>
<div class={settingControlClass}>
<Switch
ariaLabelledbyId="iframe-sandbox-allow-same-origin-label"
tooltip={true}
bind:state={iframeSandboxAllowSameOrigin}
inherited={isDefaultSetting('iframeSandboxAllowSameOrigin')}
on:change={() => {
saveSettings({ iframeSandboxAllowSameOrigin });
}}
/>
</div>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('settings.personal.interface.iframeSandboxAllowSameOrigin.description')}
</p>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('settings.personal.interface.iframeSandboxAllowSameOrigin.description')}
</p>
</div>
{/if}
<div>
<div class={settingRowClass}>
<label
id="iframe-sandbox-allow-forms-label"
class={settingLabelClass}
for="iframe-sandbox-allow-forms-label-control"
>
{$i18n.t('settings.personal.interface.iframeSandboxAllowForms.label')}
</label>
{#if $visibleControls['settings.iframe_sandbox_allow_forms']}
<div>
<div class={settingRowClass}>
<label
id="iframe-sandbox-allow-forms-label"
class={settingLabelClass}
for="iframe-sandbox-allow-forms-label-control"
>
{$i18n.t('settings.personal.interface.iframeSandboxAllowForms.label')}
</label>
<div class={settingControlClass}>
<Switch
ariaLabelledbyId="iframe-sandbox-allow-forms-label"
tooltip={true}
bind:state={iframeSandboxAllowForms}
inherited={isDefaultSetting('iframeSandboxAllowForms')}
on:change={() => {
saveSettings({ iframeSandboxAllowForms });
}}
/>
<div class={settingControlClass}>
<Switch
ariaLabelledbyId="iframe-sandbox-allow-forms-label"
tooltip={true}
bind:state={iframeSandboxAllowForms}
inherited={isDefaultSetting('iframeSandboxAllowForms')}
on:change={() => {
saveSettings({ iframeSandboxAllowForms });
}}
/>
</div>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('settings.personal.interface.iframeSandboxAllowForms.description')}
</p>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('settings.personal.interface.iframeSandboxAllowForms.description')}
</p>
</div>
{/if}
<div>
<div class={settingRowClass}>
<label
id="iframe-sandbox-allow-downloads-label"
class={settingLabelClass}
for="iframe-sandbox-allow-downloads-label-control"
>
{$i18n.t('settings.personal.interface.iframeSandboxAllowDownloads.label')}
</label>
{#if $visibleControls['settings.iframe_sandbox_allow_downloads']}
<div>
<div class={settingRowClass}>
<label
id="iframe-sandbox-allow-downloads-label"
class={settingLabelClass}
for="iframe-sandbox-allow-downloads-label-control"
>
{$i18n.t('settings.personal.interface.iframeSandboxAllowDownloads.label')}
</label>
<div class={settingControlClass}>
<Switch
ariaLabelledbyId="iframe-sandbox-allow-downloads-label"
tooltip={true}
bind:state={iframeSandboxAllowDownloads}
inherited={isDefaultSetting('iframeSandboxAllowDownloads')}
on:change={() => {
saveSettings({ iframeSandboxAllowDownloads });
}}
/>
<div class={settingControlClass}>
<Switch
ariaLabelledbyId="iframe-sandbox-allow-downloads-label"
tooltip={true}
bind:state={iframeSandboxAllowDownloads}
inherited={isDefaultSetting('iframeSandboxAllowDownloads')}
on:change={() => {
saveSettings({ iframeSandboxAllowDownloads });
}}
/>
</div>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('settings.personal.interface.iframeSandboxAllowDownloads.description')}
</p>
</div>
<p class={settingDescriptionClass}>
{$i18n.t('settings.personal.interface.iframeSandboxAllowDownloads.description')}
</p>
</div>
{/if}
{#if canUseCalls({ user: $user, config: $config })}
<div class={sectionHeadingClass}>

View file

@ -18,7 +18,8 @@
settings,
folders,
showEmbeds,
artifactContents
artifactContents,
visibleControls
} from '$lib/stores';
import { getChatById } from '$lib/apis/chats';
@ -389,7 +390,7 @@
<hr class="border-gray-50/30 dark:border-gray-800/30 mx-1 my-0.5" />
{/if}
{#if ($artifactContents ?? []).length > 0}
{#if ($artifactContents ?? []).length > 0 && $visibleControls['chat.artifacts']}
<button
draggable="false"
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] cursor-pointer select-none hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
@ -517,18 +518,20 @@
</DropdownSub>
{/if}
<button
draggable="false"
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] cursor-pointer select-none hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={() => {
archiveChatHandler();
}}
>
<ArchiveBox className="size-3.5" strokeWidth="1.5" />
<div class="flex items-center">
{chat?.archived ? $i18n.t('Unarchive') : $i18n.t('Archive')}
</div>
</button>
{#if $visibleControls['chat.archive']}
<button
draggable="false"
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] cursor-pointer select-none hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={() => {
archiveChatHandler();
}}
>
<ArchiveBox className="size-3.5" strokeWidth="1.5" />
<div class="flex items-center">
{chat?.archived ? $i18n.t('Unarchive') : $i18n.t('Archive')}
</div>
</button>
{/if}
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.delete ?? true)}
<button
@ -543,12 +546,14 @@
</button>
{/if}
<hr class="border-gray-50/30 dark:border-gray-800/30 mx-1 my-0.5" />
{#if $visibleControls['chat.tags']}
<hr class="border-gray-50/30 dark:border-gray-800/30 mx-1 my-0.5" />
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="flex max-h-28 overflow-y-auto px-2 py-1" on:click={(e) => e.preventDefault()}>
<Tags chatId={chat.id} />
</div>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="flex max-h-28 overflow-y-auto px-2 py-1" on:click={(e) => e.preventDefault()}>
<Tags chatId={chat.id} />
</div>
{/if}
{/if}
</DropdownMenu>
</div>

View file

@ -24,6 +24,7 @@
config,
isApp,
visiblePinnedModels,
visibleControls,
selectedFolder,
WEBUI_NAME,
sidebarWidth
@ -1867,17 +1868,19 @@
>{$i18n.t('{{count}} selected', { count: $selectedChatIds.size })}</span
>
<div class="flex items-center gap-0.5">
<Tooltip content={$i18n.t('Archive')}>
<button
type="button"
aria-label={$i18n.t('Archive selected chats')}
disabled={$selectionBusy}
class="flex size-6 items-center justify-center rounded-md hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-900 dark:hover:text-gray-100 disabled:opacity-40"
on:click={() => applySelectedChatAction('archive', [...$selectedChatIds])}
>
<ArchiveBoxIcon className="size-3.5" strokeWidth="1.7" />
</button>
</Tooltip>
{#if $visibleControls['chat.archive']}
<Tooltip content={$i18n.t('Archive')}>
<button
type="button"
aria-label={$i18n.t('Archive selected chats')}
disabled={$selectionBusy}
class="flex size-6 items-center justify-center rounded-md hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-900 dark:hover:text-gray-100 disabled:opacity-40"
on:click={() => applySelectedChatAction('archive', [...$selectedChatIds])}
>
<ArchiveBoxIcon className="size-3.5" strokeWidth="1.7" />
</button>
</Tooltip>
{/if}
{#if canDeleteChats}
<Tooltip content={$i18n.t('Delete')}>
<button

View file

@ -15,6 +15,7 @@
</script>
<script lang="ts">
import { visibleControls } from '$lib/stores';
import { toast } from 'svelte-sonner';
import dayjs, { getDayjsLocale } from '$lib/dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
@ -815,18 +816,20 @@
</div>
{:else if shiftKey && mouseOver && !chatSelection}
<div class=" flex items-center self-center space-x-1.5">
<Tooltip content={$i18n.t('Archive')} className="flex items-center">
<button
class="flex size-5 items-center justify-center self-center dark:hover:text-white transition disabled:cursor-not-allowed"
disabled={archiving}
on:click={() => {
archiveChatHandler(id);
}}
type="button"
>
<ArchiveBoxIcon className="size-3.5" strokeWidth="1.7" />
</button>
</Tooltip>
{#if $visibleControls['chat.archive']}
<Tooltip content={$i18n.t('Archive')} className="flex items-center">
<button
class="flex size-5 items-center justify-center self-center dark:hover:text-white transition disabled:cursor-not-allowed"
disabled={archiving}
on:click={() => {
archiveChatHandler(id);
}}
type="button"
>
<ArchiveBoxIcon className="size-3.5" strokeWidth="1.7" />
</button>
</Tooltip>
{/if}
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.delete ?? true)}
<Tooltip content={$i18n.t('Delete')}>

View file

@ -14,7 +14,7 @@
getChatPinnedStatusById,
toggleChatPinnedStatusById
} from '$lib/apis/chats';
import { chats, folders, settings, theme, user } from '$lib/stores';
import { chats, folders, settings, theme, user, visibleControls } from '$lib/stores';
import { createMessagesList } from '$lib/utils';
import { getOutputText } from '$lib/components/chat/Messages/structuredOutput';
import ArchiveBoxIcon from '$lib/components/icons/ArchiveBox.svelte';
@ -497,19 +497,21 @@
</DropdownSub>
{/if}
<button
role="menuitem"
draggable="false"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
on:click={() => {
archiveChatHandler();
}}
>
<ArchiveBoxIcon className="size-3.5" strokeWidth="1.7" />
<div class="flex items-center">
{archived ? $i18n.t('Unarchive') : $i18n.t('Archive')}
</div>
</button>
{#if $visibleControls['chat.archive']}
<button
role="menuitem"
draggable="false"
class="flex h-[1.6875rem] gap-2 items-center rounded-xl px-2 text-[0.8125rem] cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-900 w-full"
on:click={() => {
archiveChatHandler();
}}
>
<ArchiveBoxIcon className="size-3.5" strokeWidth="1.7" />
<div class="flex items-center">
{archived ? $i18n.t('Unarchive') : $i18n.t('Archive')}
</div>
</button>
{/if}
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.delete ?? true)}
<button

View file

@ -2,7 +2,7 @@
import { getContext } from 'svelte';
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
import { config } from '$lib/stores';
import { config, visibleControls } from '$lib/stores';
import ModelSettingToggle from './ModelSettingToggle.svelte';
import ModelSettingsSection from './ModelSettingsSection.svelte';
@ -143,7 +143,7 @@
{$i18n.t('Work')}
-->
<div class="grid grid-cols-1 gap-x-6 sm:grid-cols-2 lg:grid-cols-3">
{#each allTools as key}
{#each allTools.filter((key) => $visibleControls[`model_editor.tools.${key}`]) as key}
<ModelSettingToggle
label={toolLabels[key].label}
description={toolLabels[key].description}

View file

@ -2,7 +2,7 @@
import { getContext } from 'svelte';
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
import { config } from '$lib/stores';
import { config, visibleControls } from '$lib/stores';
import ModelSettingsSection from './ModelSettingsSection.svelte';
import ModelSettingToggle from './ModelSettingToggle.svelte';
import BuiltinTools from './BuiltinTools.svelte';
@ -72,16 +72,16 @@
export let defaultFeatureIds: string[] = [];
export let builtinTools: Record<string, boolean> = {};
export let disabled = false;
const featureKeys: Capability[] = ['web_search', 'image_generation', 'code_interpreter'];
const featureKeys = ['web_search', 'image_generation', 'code_interpreter'] as const;
$: hasTerminal =
$config?.features?.enable_terminal_servers || $config?.features?.enable_direct_integrations;
$: visibleCapabilities = (Object.keys(capabilityLabels) as Capability[]).filter(
$: availableCapabilities = (Object.keys(capabilityLabels) as Capability[]).filter(
(key) =>
(key !== 'file_context' || capabilities.file_upload) &&
(key !== 'terminal' || hasTerminal) &&
(capabilityLabels[key].enabled ?? true)
);
$: enabledCapabilityIds = visibleCapabilities.filter((key) => capabilities[key]);
$: enabledCapabilityIds = availableCapabilities.filter((key) => capabilities[key]);
$: availableFeatures = featureKeys.filter((key) => enabledCapabilityIds.includes(key));
$: enabledDefaultIds = availableFeatures.filter((key) => defaultFeatureIds.includes(key));
const setDefault = (key: string, enabled: boolean) => {
@ -110,7 +110,7 @@
>{/if}
</span>
<div class="grid grid-cols-1 gap-x-6 sm:grid-cols-2 lg:grid-cols-3">
{#each visibleCapabilities as key}
{#each availableCapabilities.filter((key) => $visibleControls[`model_editor.capabilities.${key}`]) as key}
<ModelSettingToggle
label={capabilityLabels[key].label}
description={capabilityLabels[key].description}
@ -129,7 +129,7 @@
{$i18n.t('settings.admin.models.defaultFeatures.codeInterpreter.label')}
{$i18n.t('settings.admin.models.defaultFeatures.codeInterpreter.description')}
-->
{#if availableFeatures.length}
{#if availableFeatures.some((key) => $visibleControls[`model_editor.defaults.${key}`])}
<ModelSettingsSection
label={$i18n.t('settings.admin.models.defaultFeatures.title')}
description={$i18n.t('Choose which available features start enabled in new chats.')}
@ -149,7 +149,7 @@
{$i18n.t('Start enabled in new chats')}
</p>
<div class="grid grid-cols-1 gap-x-6 sm:grid-cols-2 lg:grid-cols-3">
{#each availableFeatures as key}
{#each availableFeatures.filter((key) => $visibleControls[`model_editor.defaults.${key}`]) as key}
<ModelSettingToggle
label={capabilityLabels[key].label}
checked={defaultFeatureIds.includes(key)}

View file

@ -16,7 +16,13 @@
import Pin from '$lib/components/icons/Pin.svelte';
import PinSlash from '$lib/components/icons/PinSlash.svelte';
import { config, user as currentUser, pinnedModels, settings } from '$lib/stores';
import {
config,
user as currentUser,
pinnedModels,
settings,
visibleControls
} from '$lib/stores';
import Link from '$lib/components/icons/Link.svelte';
import { resolveLocalizedModelName } from '$lib/utils/localizedContent';
@ -147,24 +153,26 @@
</button>
{/if}
<button
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => runAndClose(() => pinModelHandler(model?.id))}
>
{#if $pinnedModels.includes(model?.id)}
<PinSlash />
{:else}
<Pin />
{/if}
<div class="flex items-center">
{#if $visibleControls['models.pin']}
<button
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => runAndClose(() => pinModelHandler(model?.id))}
>
{#if $pinnedModels.includes(model?.id)}
{$i18n.t('Hide from Sidebar')}
<PinSlash />
{:else}
{$i18n.t('Keep in Sidebar')}
<Pin />
{/if}
</div>
</button>
<div class="flex items-center">
{#if $pinnedModels.includes(model?.id)}
{$i18n.t('Hide from Sidebar')}
{:else}
{$i18n.t('Keep in Sidebar')}
{/if}
</div>
</button>
{/if}
{#if writeAccess}
<button

View file

@ -3002,6 +3002,8 @@
"settings.admin.interface.taskModel.description": "Choose fallback models for background tasks. Current Model follows the active chat model.",
"settings.admin.interface.taskModel.label": "Task Model",
"settings.admin.interface.taskModelParameters.label": "Task Model Parameters",
"settings.admin.interface.visibility.description": "Choose which controls appear for users. Hiding a control keeps its current setting and underlying feature available. Administrators always see available controls.",
"settings.admin.interface.visibility.title": "Visible controls",
"settings.admin.interface.title": "Interface",
"settings.admin.interface.titleGeneration.description": "Allow automatic names for new chats.",
"settings.admin.interface.titleGeneration.label": "Title Generation",

View file

@ -1,3 +1,9 @@
import {
CONTROL_LABELS,
isControlVisible,
type ControlId,
type Visibility
} from '$lib/utils/ui-visibility';
import { APP_NAME } from '$lib/constants';
import { type Writable, derived, writable } from 'svelte/store';
import type { ModelConfig } from '$lib/apis';
@ -128,6 +134,17 @@ export const visiblePinnedModels = derived([pinnedModels, models], ([$pinnedMode
)
);
export const visibleControls = derived(
[config, user],
([$config, $user]) =>
Object.fromEntries(
(Object.keys(CONTROL_LABELS) as ControlId[]).map((id) => [
id,
isControlVisible(id, { config: $config, user: $user })
])
) as Record<ControlId, boolean>
);
export const audioQueue = writable<AudioQueue | null>(null);
export const chatRequestQueues: Writable<
Record<string, { id: string; prompt: string; files: any[]; error?: string }[]>
@ -420,6 +437,7 @@ type Config = {
};
ui?: {
default_interface_settings?: Record<string, unknown>;
visibility?: Visibility;
pending_user_overlay_title?: string;
pending_user_overlay_content?: string;
response_watermark?: string;

View file

@ -1,3 +1,4 @@
import { CONTROL_LABELS, isControlVisible, type ControlId } from './ui-visibility';
import type { SettingsAccessContext } from './settings-search';
export const canEditSystemPrompt = ({ user }: SettingsAccessContext) =>
@ -30,13 +31,25 @@ export const canRegenerateResponses = ({ user }: SettingsAccessContext) =>
user?.role === 'admin' || (user?.permissions?.chat?.regenerate_response ?? true);
export const isSettingsAdmin = ({ user }: SettingsAccessContext) => user?.role === 'admin';
/** Only permission-gated text needs an explicit rule. Provider/disclosure state is irrelevant. */
/** Search follows the same permissions and visibility as the rendered controls. */
export function canSearchSetting(
key: string,
tabId: string,
context: SettingsAccessContext
): boolean {
const stem = key.replace(/\.(label|description|title)$/, '');
if (
/^settings\.personal\.(dataControls\.archiveAll(Chats)?|archivedChats\.unarchiveAll)$/.test(
stem
)
)
return isControlVisible('chat.archive', context);
if (key.startsWith('settings.personal.')) {
const control = (Object.keys(CONTROL_LABELS) as ControlId[]).find(
(id) => CONTROL_LABELS[id].replace(/\.(label|title)$/, '') === stem
);
if (control && !isControlVisible(control, context)) return false;
}
if (stem === 'settings.personal.general.sections.systemPrompt')
return canEditSystemPrompt(context);
if (

View file

@ -0,0 +1,97 @@
// Stable control IDs describe user actions or preferences, not component locations.
export const CONTROL_LABELS = {
'model_editor.capabilities.vision': 'settings.admin.models.capabilities.vision.label',
'model_editor.capabilities.file_upload': 'settings.admin.models.capabilities.fileUpload.label',
'model_editor.capabilities.file_context': 'settings.admin.models.capabilities.fileContext.label',
'model_editor.capabilities.web_search': 'settings.admin.models.capabilities.webSearch.label',
'model_editor.capabilities.image_generation':
'settings.admin.models.capabilities.imageGeneration.label',
'model_editor.capabilities.code_interpreter':
'settings.admin.models.capabilities.codeInterpreter.label',
'model_editor.capabilities.terminal': 'settings.admin.models.capabilities.terminal.label',
'model_editor.capabilities.usage': 'settings.admin.models.capabilities.usage.label',
'model_editor.capabilities.citations': 'settings.admin.models.capabilities.citations.label',
'model_editor.capabilities.status_updates':
'settings.admin.models.capabilities.statusUpdates.label',
'model_editor.capabilities.memory': 'settings.admin.models.capabilities.memory.label',
'model_editor.capabilities.builtin_tools':
'settings.admin.models.capabilities.builtinTools.label',
'model_editor.defaults.web_search': 'settings.admin.models.capabilities.webSearch.label',
'model_editor.defaults.image_generation':
'settings.admin.models.capabilities.imageGeneration.label',
'model_editor.defaults.code_interpreter':
'settings.admin.models.capabilities.codeInterpreter.label',
'model_editor.tools.time': 'settings.admin.models.builtinTools.time.label',
'model_editor.tools.user_input': 'settings.admin.models.builtinTools.userInput.label',
'model_editor.tools.memory': 'settings.admin.models.builtinTools.memory.label',
'model_editor.tools.chats': 'settings.admin.models.builtinTools.chats.label',
'model_editor.tools.notes': 'settings.admin.models.builtinTools.notes.label',
'model_editor.tools.knowledge': 'settings.admin.models.builtinTools.knowledge.label',
'model_editor.tools.files': 'settings.admin.models.builtinTools.files.label',
'model_editor.tools.channels': 'settings.admin.models.builtinTools.channels.label',
'model_editor.tools.notifications': 'settings.admin.models.builtinTools.notifications.label',
'model_editor.tools.web_search': 'settings.admin.models.builtinTools.webSearch.label',
'model_editor.tools.image_generation': 'settings.admin.models.builtinTools.imageGeneration.label',
'model_editor.tools.code_interpreter': 'settings.admin.models.builtinTools.codeInterpreter.label',
'model_editor.tools.tasks': 'settings.admin.models.builtinTools.tasks.label',
'model_editor.tools.automations': 'settings.admin.models.builtinTools.automations.label',
'model_editor.tools.calendar': 'settings.admin.models.builtinTools.calendar.label',
'model_editor.tools.subagents': 'settings.admin.models.builtinTools.subagents.label',
'model_editor.tools.skills': 'settings.admin.models.builtinTools.skills.label',
'chat.capture': 'Capture',
'chat.compare': 'Compare text',
'chat.attach_webpage': 'Attach Webpage',
'chat.attach_files': 'Attach Files',
'chat.attach_notes': 'Attach Notes',
'chat.attach_knowledge': 'Attach Knowledge',
'chat.reference_chats': 'Reference Chats',
'chat.status': '/status',
'chat.model_command': '/model',
'chat.settings_command': '/settings',
'chat.usage': 'settings.admin.models.capabilities.usage.label',
'chat.merge': 'Merge Responses',
'chat.code_preview': 'Preview',
'chat.fork': 'Fork chat',
'chat.artifacts': 'Artifacts',
'chat.archive': 'Archive',
'chat.tags': 'Tags',
'models.set_default': 'Set as default',
'models.pin': 'Keep in Sidebar',
'settings.usage': 'settings.personal.usage.title',
'settings.speech_to_text_engine': 'settings.personal.audio.speechToTextEngine.label',
'settings.text_to_speech_engine': 'settings.personal.audio.textToSpeechEngine.label',
'settings.user_location': 'settings.personal.interface.allowUserLocation.label',
'settings.chat_bubble': 'settings.personal.interface.chatBubbleUi.label',
'settings.widescreen': 'settings.personal.interface.widescreenMode.label',
'settings.floating_action_buttons': 'settings.personal.interface.floatingQuickActions.label',
'settings.rich_text_input': 'settings.personal.interface.richTextInputForChat.label',
'settings.large_text_as_file': 'settings.personal.interface.pasteLargeTextAsFile.label',
'settings.detect_artifacts': 'settings.personal.interface.detectArtifactsAutomatically.label',
'settings.iframe_sandbox_allow_scripts':
'settings.personal.interface.iframeSandboxAllowScripts.label',
'settings.iframe_sandbox_allow_same_origin':
'settings.personal.interface.iframeSandboxAllowSameOrigin.label',
'settings.iframe_sandbox_allow_forms':
'settings.personal.interface.iframeSandboxAllowForms.label',
'settings.iframe_sandbox_allow_downloads':
'settings.personal.interface.iframeSandboxAllowDownloads.label'
} as const;
export type ControlId = keyof typeof CONTROL_LABELS;
export type Visibility = Partial<Record<ControlId, boolean>>;
export type VisibilityContext = {
user?: { role?: string } | null;
config?: { ui?: { visibility?: Visibility } } | null;
};
export const CONTROL_GROUPS = [
['model_editor.capabilities.', 'settings.admin.models.capabilities.title'],
['model_editor.defaults.', 'settings.admin.models.defaultFeatures.title'],
['model_editor.tools.', 'settings.admin.models.builtinTools.title'],
['chat.', 'Chat'],
['models.', 'Models'],
['settings.', 'Settings']
] as const;
export const isControlVisible = (id: ControlId, { user, config }: VisibilityContext) =>
user?.role === 'admin' || config?.ui?.visibility?.[id] !== false;