mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-17 20:31:47 +02:00
ui : add backend management settings UI
Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { Pencil, Server, Trash2 } from '@lucide/svelte';
|
||||
import { DialogConfirmation } from '$lib/components/app/dialogs';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import type { Backend, BackendProtocol } from '$lib/types';
|
||||
|
||||
const PROTOCOL_LABELS: Record<BackendProtocol, string> = {
|
||||
anthropic: 'Anthropic',
|
||||
'llama.cpp': 'llama.cpp',
|
||||
openai: 'OpenAI'
|
||||
};
|
||||
|
||||
interface Props {
|
||||
backend: Backend;
|
||||
isLocal?: boolean;
|
||||
onDelete?: () => void;
|
||||
onEdit?: () => void;
|
||||
onToggle?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
let { backend, isLocal = false, onDelete, onEdit, onToggle }: Props = $props();
|
||||
|
||||
let showDelete = $state(false);
|
||||
let protocolLabel = $derived(PROTOCOL_LABELS[backend.protocol]);
|
||||
let displayUrl = $derived(backend.baseUrl || 'This server');
|
||||
</script>
|
||||
|
||||
<Card.Root class="!gap-3 bg-muted/30 p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Server class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium">{backend.name}</p>
|
||||
|
||||
<p class="truncate text-xs text-muted-foreground">{displayUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="shrink-0 rounded-md border px-2 py-0.5 text-[0.7rem] text-muted-foreground">
|
||||
{protocolLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
{#if isLocal}
|
||||
<span class="text-xs text-muted-foreground">Built-in</span>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch checked={backend.enabled} onCheckedChange={(value) => onToggle?.(value)} />
|
||||
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{backend.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<Button aria-label="Edit backend" onclick={() => onEdit?.()} size="sm" variant="ghost">
|
||||
<Pencil class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
aria-label="Delete backend"
|
||||
onclick={() => (showDelete = true)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Root>
|
||||
|
||||
<DialogConfirmation
|
||||
bind:open={showDelete}
|
||||
confirmText="Delete"
|
||||
description="This removes the backend and its stored API key."
|
||||
onCancel={() => (showDelete = false)}
|
||||
onConfirm={() => {
|
||||
showDelete = false;
|
||||
onDelete?.();
|
||||
}}
|
||||
title="Delete backend?"
|
||||
variant="destructive"
|
||||
/>
|
||||
@@ -0,0 +1,152 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, ChevronRight } from '@lucide/svelte';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { DEFAULT_BACKEND_CHAT_PATH, DEFAULT_BACKEND_MODELS_PATH } from '$lib/constants';
|
||||
import type { Backend, BackendProtocol } from '$lib/types';
|
||||
|
||||
const PROTOCOL_OPTIONS: Array<{ label: string; value: BackendProtocol }> = [
|
||||
{ label: 'OpenAI-compatible', value: 'openai' },
|
||||
{ label: 'Anthropic-compatible', value: 'anthropic' },
|
||||
{ label: 'llama.cpp (llama-server)', value: 'llama.cpp' }
|
||||
];
|
||||
|
||||
interface Props {
|
||||
backend: Backend;
|
||||
id: string;
|
||||
onChange: (patch: Partial<Backend>) => void;
|
||||
urlError?: string | null;
|
||||
}
|
||||
|
||||
let { backend, id, onChange, urlError = null }: Props = $props();
|
||||
|
||||
let showAdvanced = $state(false);
|
||||
|
||||
let protocolLabel = $derived(
|
||||
PROTOCOL_OPTIONS.find((option) => option.value === backend.protocol)?.label ?? ''
|
||||
);
|
||||
let isAnthropic = $derived(backend.protocol === 'anthropic');
|
||||
</script>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<div>
|
||||
<label class="mb-2 block text-xs font-medium select-none" for="backend-url-{id}">
|
||||
Base URL <span class="text-destructive">*</span>
|
||||
</label>
|
||||
|
||||
<Input
|
||||
class={urlError ? 'border-destructive' : ''}
|
||||
id="backend-url-{id}"
|
||||
oninput={(e) => onChange({ baseUrl: e.currentTarget.value })}
|
||||
placeholder="https://api.example.com"
|
||||
type="url"
|
||||
value={backend.baseUrl}
|
||||
/>
|
||||
|
||||
{#if urlError}
|
||||
<p class="mt-1.5 text-xs text-destructive">{urlError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-xs font-medium select-none" for="backend-name-{id}">
|
||||
Display name
|
||||
</label>
|
||||
|
||||
<Input
|
||||
id="backend-name-{id}"
|
||||
oninput={(e) => onChange({ name: e.currentTarget.value })}
|
||||
placeholder="Name shown in the model selector"
|
||||
type="text"
|
||||
value={backend.name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="mb-2 block text-xs font-medium select-none">API format</span>
|
||||
|
||||
<Select.Root
|
||||
onValueChange={(value) => onChange({ protocol: value as BackendProtocol })}
|
||||
type="single"
|
||||
value={backend.protocol}
|
||||
>
|
||||
<Select.Trigger class="w-full">{protocolLabel}</Select.Trigger>
|
||||
|
||||
<Select.Content>
|
||||
{#each PROTOCOL_OPTIONS as option (option.value)}
|
||||
<Select.Item label={option.label} value={option.value}>{option.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-xs font-medium select-none" for="backend-key-{id}">
|
||||
API key
|
||||
</label>
|
||||
|
||||
<Input
|
||||
autocomplete="off"
|
||||
id="backend-key-{id}"
|
||||
oninput={(e) => onChange({ apiKey: e.currentTarget.value || undefined })}
|
||||
placeholder="Optional"
|
||||
type="password"
|
||||
value={backend.apiKey ?? ''}
|
||||
/>
|
||||
|
||||
<p class="mt-1.5 text-xs text-muted-foreground">
|
||||
{#if isAnthropic}
|
||||
Sent as the x-api-key header.
|
||||
{:else}
|
||||
Sent as a Bearer token.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Collapsible.Root bind:open={showAdvanced}>
|
||||
<Collapsible.Trigger
|
||||
class="flex items-center gap-1 text-xs font-medium text-muted-foreground select-none hover:text-foreground"
|
||||
>
|
||||
{#if showAdvanced}
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
{:else}
|
||||
<ChevronRight class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
|
||||
Advanced
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content>
|
||||
<div class="mt-3 grid gap-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-xs font-medium select-none" for="backend-chat-path-{id}">
|
||||
Chat completions path
|
||||
</label>
|
||||
|
||||
<Input
|
||||
id="backend-chat-path-{id}"
|
||||
oninput={(e) => onChange({ chatPath: e.currentTarget.value || undefined })}
|
||||
placeholder={DEFAULT_BACKEND_CHAT_PATH}
|
||||
type="text"
|
||||
value={backend.chatPath ?? ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-xs font-medium select-none" for="backend-models-path-{id}">
|
||||
Models path
|
||||
</label>
|
||||
|
||||
<Input
|
||||
id="backend-models-path-{id}"
|
||||
oninput={(e) => onChange({ modelsPath: e.currentTarget.value || undefined })}
|
||||
placeholder={DEFAULT_BACKEND_MODELS_PATH}
|
||||
type="text"
|
||||
value={backend.modelsPath ?? ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script lang="ts">
|
||||
import BackendForm from './BackendForm.svelte';
|
||||
import { CheckCircle2, Loader2, XCircle } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { BACKEND_ID_PREFIX, BACKEND_PRESETS } from '$lib/constants';
|
||||
import { BackendsService } from '$lib/services';
|
||||
import type { BackendTestResult } from '$lib/services/backends.service';
|
||||
import { backendsStore } from '$lib/stores';
|
||||
import type { Backend, BackendPreset } from '$lib/types';
|
||||
import { uuid } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
backend?: Backend | null;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
let { backend = null, onOpenChange, open = $bindable(false) }: Props = $props();
|
||||
|
||||
let draft = $state<Backend>(createBackend());
|
||||
let selectedPresetId = $state<string | null>(null);
|
||||
let testResult = $state<BackendTestResult | null>(null);
|
||||
let testing = $state(false);
|
||||
|
||||
let isEdit = $derived(backend !== null);
|
||||
let urlError = $derived.by(() => {
|
||||
const url = draft.baseUrl.trim();
|
||||
|
||||
if (!url) return 'Base URL is required';
|
||||
|
||||
try {
|
||||
new URL(url);
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return 'Invalid URL format';
|
||||
}
|
||||
});
|
||||
let canSave = $derived(!urlError && draft.name.trim().length > 0);
|
||||
|
||||
// reset the draft each time the dialog opens
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
|
||||
draft = backend ? { ...backend } : createBackend();
|
||||
selectedPresetId = null;
|
||||
testResult = null;
|
||||
testing = false;
|
||||
});
|
||||
|
||||
function createBackend(): Backend {
|
||||
return {
|
||||
baseUrl: '',
|
||||
enabled: true,
|
||||
id: uuid() || `${BACKEND_ID_PREFIX}-${Date.now()}`,
|
||||
name: '',
|
||||
protocol: 'openai'
|
||||
};
|
||||
}
|
||||
|
||||
function applyPreset(preset: BackendPreset) {
|
||||
selectedPresetId = preset.id;
|
||||
draft = {
|
||||
...draft,
|
||||
baseUrl: preset.baseUrl,
|
||||
chatPath: preset.chatPath,
|
||||
modelsPath: preset.modelsPath,
|
||||
name: preset.id === 'custom' ? '' : preset.name,
|
||||
protocol: preset.protocol
|
||||
};
|
||||
testResult = null;
|
||||
}
|
||||
|
||||
function handleChange(patch: Partial<Backend>) {
|
||||
draft = { ...draft, ...patch };
|
||||
testResult = null;
|
||||
}
|
||||
|
||||
function handleOpenChange(value: boolean) {
|
||||
open = value;
|
||||
onOpenChange?.(value);
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
if (urlError) return;
|
||||
|
||||
testing = true;
|
||||
testResult = null;
|
||||
|
||||
try {
|
||||
testResult = await BackendsService.test(draft);
|
||||
} finally {
|
||||
testing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!canSave) return;
|
||||
|
||||
const next: Backend = { ...draft, name: draft.name.trim() || hostOf(draft.baseUrl) };
|
||||
|
||||
if (isEdit && backend) {
|
||||
backendsStore.updateBackend(backend.id, next);
|
||||
} else {
|
||||
backendsStore.addBackend(next);
|
||||
}
|
||||
|
||||
handleOpenChange(false);
|
||||
}
|
||||
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
|
||||
function hostOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root onOpenChange={handleOpenChange} {open}>
|
||||
<Dialog.Content class="max-w-2xl!">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{isEdit ? 'Edit backend' : 'Add backend'}</Dialog.Title>
|
||||
|
||||
<Dialog.Description>Connect an OpenAI- or Anthropic-compatible endpoint.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if !isEdit}
|
||||
<div class="grid grid-cols-2 gap-2 pt-2 sm:grid-cols-3">
|
||||
{#each BACKEND_PRESETS as preset (preset.id)}
|
||||
<Button
|
||||
class="justify-start"
|
||||
onclick={() => applyPreset(preset)}
|
||||
size="sm"
|
||||
variant={selectedPresetId === preset.id ? 'secondary' : 'outline'}
|
||||
>
|
||||
{preset.name}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form class="contents" onsubmit={handleSubmit}>
|
||||
<div class="py-4">
|
||||
<BackendForm backend={draft} id="backend" onChange={handleChange} {urlError} />
|
||||
</div>
|
||||
|
||||
{#if testing || testResult}
|
||||
<div class="flex items-center gap-2 pb-2 text-xs">
|
||||
{#if testing}
|
||||
<Loader2 class="h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
|
||||
<span class="text-muted-foreground">Testing connection...</span>
|
||||
{:else if testResult?.ok}
|
||||
<CheckCircle2 class="h-3.5 w-3.5 shrink-0 text-emerald-500" />
|
||||
|
||||
<span class="text-muted-foreground">
|
||||
Connected.
|
||||
{testResult.modelCount ?? 0}
|
||||
model{(testResult.modelCount ?? 0) === 1 ? '' : 's'} available.
|
||||
</span>
|
||||
{:else}
|
||||
<XCircle class="h-3.5 w-3.5 shrink-0 text-destructive" />
|
||||
|
||||
<span class="text-destructive">{testResult?.error ?? 'Connection failed'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button onclick={() => handleOpenChange(false)} size="sm" variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button disabled={testing} onclick={handleTest} size="sm" type="button" variant="outline">
|
||||
Test connection
|
||||
</Button>
|
||||
|
||||
<Button disabled={!canSave} size="sm" type="submit">
|
||||
{isEdit ? 'Save' : 'Add'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { Plus } from '@lucide/svelte';
|
||||
import { BackendCard, DialogBackendForm } from '$lib/components/app/backends';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Empty from '$lib/components/ui/empty';
|
||||
import { backendsStore } from '$lib/stores';
|
||||
import type { Backend } from '$lib/types';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className }: Props = $props();
|
||||
|
||||
let isAdding = $state(false);
|
||||
let editing = $state<Backend | null>(null);
|
||||
|
||||
function handleAdd() {
|
||||
editing = null;
|
||||
isAdding = true;
|
||||
}
|
||||
|
||||
function handleEdit(backend: Backend) {
|
||||
editing = backend;
|
||||
isAdding = true;
|
||||
}
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
isAdding = open;
|
||||
|
||||
if (!open) {
|
||||
editing = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div in:fade={{ duration: 150 }} class={['flex flex-col gap-4', className]}>
|
||||
<DialogBackendForm bind:open={isAdding} backend={editing} onOpenChange={handleOpenChange} />
|
||||
|
||||
<BackendCard backend={backendsStore.local} isLocal />
|
||||
|
||||
{#each backendsStore.external as backend (backend.id)}
|
||||
<BackendCard
|
||||
{backend}
|
||||
onDelete={() => backendsStore.removeBackend(backend.id)}
|
||||
onEdit={() => handleEdit(backend)}
|
||||
onToggle={(enabled) => backendsStore.updateBackend(backend.id, { enabled })}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<Empty.Root class="border">
|
||||
<Empty.Header>
|
||||
<Empty.Media variant="icon">
|
||||
<Plus />
|
||||
</Empty.Media>
|
||||
|
||||
<Empty.Title>Add another backend</Empty.Title>
|
||||
|
||||
<Empty.Description>Connect an OpenAI- or Anthropic-compatible endpoint.</Empty.Description>
|
||||
</Empty.Header>
|
||||
|
||||
<Empty.Content>
|
||||
<Button onclick={handleAdd} size="sm">
|
||||
<Plus />
|
||||
|
||||
Add Backend
|
||||
</Button>
|
||||
</Empty.Content>
|
||||
</Empty.Root>
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as BackendCard } from './BackendCard.svelte';
|
||||
export { default as BackendForm } from './BackendForm.svelte';
|
||||
export { default as DialogBackendForm } from './DialogBackendForm.svelte';
|
||||
export { default as SettingsBackends } from './SettingsBackends.svelte';
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './actions';
|
||||
export * from './backends';
|
||||
export * from './badges';
|
||||
export * from './chat';
|
||||
export * from './content';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { RefreshCw } from '@lucide/svelte';
|
||||
import { SettingsBackends } from '$lib/components/app/backends';
|
||||
import {
|
||||
SettingsChatDesktopSidebar,
|
||||
SettingsChatFields,
|
||||
@@ -151,6 +152,8 @@
|
||||
<SettingsChatToolsTab />
|
||||
{:else if currentSection.slug === SETTINGS_SECTION_SLUGS.IMPORT_EXPORT}
|
||||
<SettingsChatImportExportTab />
|
||||
{:else if currentSection.slug === SETTINGS_SECTION_SLUGS.BACKENDS}
|
||||
<SettingsBackends />
|
||||
{:else if currentSection.fields}
|
||||
<div class="space-y-6">
|
||||
<SettingsChatFields
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Monitor,
|
||||
Moon,
|
||||
PencilRuler,
|
||||
Server,
|
||||
SlidersVertical,
|
||||
Sun
|
||||
} from '@lucide/svelte';
|
||||
@@ -28,6 +29,7 @@ import type {
|
||||
/** Settings sections — slug is the routing identity, title is the display label. */
|
||||
export const SETTINGS_SECTIONS = {
|
||||
AGENTIC: { slug: 'agentic', title: 'Agentic' },
|
||||
BACKENDS: { slug: 'backends', title: 'Backends' },
|
||||
DEVELOPER: { slug: 'developer', title: 'Developer' },
|
||||
DISPLAY: { slug: 'display', title: 'Display' },
|
||||
GENERAL: { slug: 'general', title: 'General' },
|
||||
@@ -38,6 +40,7 @@ export const SETTINGS_SECTIONS = {
|
||||
|
||||
export const SETTINGS_SECTION_SLUGS = {
|
||||
AGENTIC: SETTINGS_SECTIONS.AGENTIC.slug,
|
||||
BACKENDS: SETTINGS_SECTIONS.BACKENDS.slug,
|
||||
DEVELOPER: SETTINGS_SECTIONS.DEVELOPER.slug,
|
||||
DISPLAY: SETTINGS_SECTIONS.DISPLAY.slug,
|
||||
GENERAL: SETTINGS_SECTIONS.GENERAL.slug,
|
||||
@@ -48,6 +51,7 @@ export const SETTINGS_SECTION_SLUGS = {
|
||||
|
||||
export const SETTINGS_SECTION_TITLES = {
|
||||
AGENTIC: SETTINGS_SECTIONS.AGENTIC.title,
|
||||
BACKENDS: SETTINGS_SECTIONS.BACKENDS.title,
|
||||
DEVELOPER: SETTINGS_SECTIONS.DEVELOPER.title,
|
||||
DISPLAY: SETTINGS_SECTIONS.DISPLAY.title,
|
||||
GENERAL: SETTINGS_SECTIONS.GENERAL.title,
|
||||
@@ -188,7 +192,15 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
|
||||
key: SETTINGS_KEYS.MAX_IMAGE_RESOLUTION,
|
||||
label: 'Maximum image resolution (megapixels)',
|
||||
type: SettingsFieldType.INPUT
|
||||
},
|
||||
}
|
||||
],
|
||||
slug: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
title: SETTINGS_SECTION_TITLES.GENERAL
|
||||
},
|
||||
// Backends (non-UI config object)
|
||||
{
|
||||
icon: Server,
|
||||
settings: [
|
||||
{
|
||||
defaultValue: '[]',
|
||||
help: 'Configure external API backends as a JSON list. The local backend is always available.',
|
||||
@@ -198,8 +210,8 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
|
||||
type: SettingsFieldType.INPUT
|
||||
}
|
||||
],
|
||||
slug: SETTINGS_SECTION_SLUGS.GENERAL,
|
||||
title: SETTINGS_SECTION_TITLES.GENERAL
|
||||
slug: SETTINGS_SECTION_SLUGS.BACKENDS,
|
||||
title: SETTINGS_SECTION_TITLES.BACKENDS
|
||||
},
|
||||
// Display
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user