feat(dashboard): configurable sidebar — presets, DnD ordering, smart-grouping (#2581)

Integrated into release/v3.8.2
This commit is contained in:
Gi99lin
2026-05-23 00:17:30 +03:00
committed by GitHub
parent 71f0910275
commit a08bf2ab91
13 changed files with 3241 additions and 2584 deletions

56
package-lock.json generated
View File

@@ -13,6 +13,9 @@
"open-sse"
],
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lobehub/icons": "^5.8.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@monaco-editor/react": "^4.7.0",
@@ -664,6 +667,59 @@
"node": ">=20.19.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",

View File

@@ -134,6 +134,9 @@
"build:cli-api": "node --import tsx/esm scripts/cli/generate-api-commands.mjs"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lobehub/icons": "^5.8.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@monaco-editor/react": "^4.7.0",

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Button, Card, Toggle } from "@/shared/components";
import { useTheme } from "@/shared/hooks/useTheme";
import useThemeStore, { COLOR_THEMES } from "@/store/themeStore";
@@ -11,21 +12,11 @@ import {
normalizeComboConfigMode,
type ComboConfigMode,
} from "@/shared/constants/comboConfigMode";
import {
HIDDEN_SIDEBAR_ITEMS_SETTING_KEY,
SIDEBAR_SECTIONS,
SIDEBAR_SETTINGS_UPDATED_EVENT,
getSectionItems,
normalizeHiddenSidebarItems,
type HideableSidebarItemId,
} from "@/shared/constants/sidebarVisibility";
import { PIN_PROVIDER_QUOTA_TO_HOME_KEY } from "@/shared/constants/homeWidgets";
export default function AppearanceTab() {
const { theme, setTheme, isDark } = useTheme();
const { colorTheme, customColor, setColorTheme, setCustomColorTheme } = useThemeStore();
const t = useTranslations("settings");
const tSidebar = useTranslations("sidebar");
const [settings, setSettings] = useState<Record<string, any>>({});
const [loading, setLoading] = useState(true);
const [uploadError, setUploadError] = useState<string | null>(null);
@@ -33,22 +24,13 @@ export default function AppearanceTab() {
const isValidHex = /^#([0-9a-fA-F]{6})$/.test(
customThemeColor.startsWith("#") ? customThemeColor : `#${customThemeColor}`
);
const hiddenSidebarItems = normalizeHiddenSidebarItems(
settings[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]
);
const hiddenSidebarSet = new Set(hiddenSidebarItems);
const comboConfigMode = normalizeComboConfigMode(settings[COMBO_CONFIG_MODE_SETTING_KEY]);
const showCloudflaredTunnel = settings.hideEndpointCloudflaredTunnel !== true;
const showTailscaleFunnel = settings.hideEndpointTailscaleFunnel !== true;
const showNgrokTunnel = settings.hideEndpointNgrokTunnel !== true;
const pinProviderQuotaToHome = settings[PIN_PROVIDER_QUOTA_TO_HOME_KEY] === true;
const showQuickStartOnHome = settings.showQuickStartOnHome !== false; // default on
const showProviderTopologyOnHome = settings.showProviderTopologyOnHome !== false; // default on
const getSettingsLabel = (key: string, fallback: string) =>
typeof t.has === "function" && t.has(key) ? t(key) : fallback;
const getSidebarLabel = (key: string, fallback: string) =>
typeof tSidebar.has === "function" && tSidebar.has(key) ? tSidebar(key) : fallback;
useEffect(() => {
const unsubscribe = useThemeStore.subscribe((state) => {
@@ -74,12 +56,7 @@ export default function AppearanceTab() {
return res.json();
})
.then((data) => {
setSettings({
...data,
[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]: normalizeHiddenSidebarItems(
data[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]
),
});
setSettings(data);
setLoading(false);
})
.catch(() => setLoading(false));
@@ -93,18 +70,7 @@ export default function AppearanceTab() {
body: JSON.stringify({ [key]: value }),
});
if (res.ok) {
setSettings((prev) => ({
...prev,
[key]:
key === HIDDEN_SIDEBAR_ITEMS_SETTING_KEY ? normalizeHiddenSidebarItems(value) : value,
}));
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent(SIDEBAR_SETTINGS_UPDATED_EVENT, {
detail: { [key]: value },
})
);
}
setSettings((prev) => ({ ...prev, [key]: value }));
}
} catch (err) {
console.error("Failed to update", key, err);
@@ -147,23 +113,6 @@ export default function AppearanceTab() {
},
];
const showDebug = settings.debugMode === true;
const sidebarSections = SIDEBAR_SECTIONS.filter(
(section) => section.visibility !== "debug" || showDebug
).map((section) => ({
...section,
title: getSidebarLabel(section.titleKey, section.titleFallback),
items: getSectionItems(section).map((item) => ({ ...item, label: tSidebar(item.i18nKey) })),
}));
const toggleSidebarItem = (itemId: HideableSidebarItemId) => {
const nextHiddenItems = hiddenSidebarSet.has(itemId)
? hiddenSidebarItems.filter((id) => id !== itemId)
: [...hiddenSidebarItems, itemId];
updateSetting(HIDDEN_SIDEBAR_ITEMS_SETTING_KEY, nextHiddenItems);
};
return (
<Card>
<div className="flex items-center gap-3 mb-4">
@@ -389,130 +338,27 @@ export default function AppearanceTab() {
</div>
</div>
{/* Pin Information to Home Page section */}
<div className="pt-4 border-t border-border">
<div className="mb-3">
<p className="font-medium">
{getSettingsLabel("pinProviderQuotaToHome", "Pin Information to Home Page")}
</p>
<p className="text-sm text-text-muted">
Choose which sections to pin to the top of the Home page.
</p>
</div>
{/* Pinned options as a rounded table/list */}
<div className="rounded-lg border border-border bg-surface/40 overflow-hidden">
<div className="divide-y divide-border/70">
{/* Provider Quota Limits */}
<div className="flex items-start justify-between px-4 py-3">
<div>
<p className="font-medium">
{getSettingsLabel("providerQuotaLimits", "Provider Quota Limits")}
</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"providerQuotaLimitsDesc",
"Pin the Provider Quota status container (with Refresh All button) to the top of the Home page."
)}
</p>
</div>
<Toggle
checked={pinProviderQuotaToHome}
onChange={() => updateSetting(PIN_PROVIDER_QUOTA_TO_HOME_KEY, !pinProviderQuotaToHome)}
disabled={loading}
/>
</div>
{/* Quick Start */}
<div className="flex items-start justify-between px-4 py-3">
<div>
<p className="font-medium">
{getSettingsLabel("quickStart", "Quick Start")}
</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"quickStartDesc",
"Show the Quick Start panel on the Home page."
)}
</p>
</div>
<Toggle
checked={showQuickStartOnHome}
onChange={() => updateSetting("showQuickStartOnHome", !showQuickStartOnHome)}
disabled={loading}
/>
</div>
{/* Provider Topology */}
<div className="flex items-start justify-between px-4 py-3">
<div>
<p className="font-medium">
{getSettingsLabel("providerTopology", "Provider Topology")}
</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"providerTopologyDesc",
"Show the Provider Topology on the Home page."
)}
</p>
</div>
<Toggle
checked={showProviderTopologyOnHome}
onChange={() => updateSetting("showProviderTopologyOnHome", !showProviderTopologyOnHome)}
disabled={loading}
/>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-medium">{t("sidebarVisibilityToggle")}</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"sidebarCustomizeLink",
"Customize which items appear in the sidebar, their order, and apply role presets."
)}
</p>
</div>
<Link
href="/dashboard/settings/sidebar"
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border border-border hover:bg-surface/80 hover:border-primary/40 transition-colors text-text-main"
>
<span className="material-symbols-outlined text-[16px]">view_sidebar</span>
{getSettingsLabel("sidebarCustomizeLinkBtn", "Customize")}
</Link>
</div>
</div>
<div className="pt-4 border-t border-border">
<div className="mb-3">
<p className="font-medium">{t("sidebarVisibilityToggle")}</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"sidebarVisibilityDesc",
"Hide any sidebar navigation entry to reduce visual clutter without disabling any features"
)}
</p>
</div>
<div className="flex flex-col gap-4">
{sidebarSections.map((section) => (
<div key={section.id} className="rounded-lg border border-border bg-surface/40">
<div className="px-4 py-3 border-b border-border/70">
<p className="text-xs font-semibold uppercase tracking-wider text-text-muted/70">
{section.title}
</p>
</div>
<div className="divide-y divide-border/70">
{section.items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between gap-4 px-4 py-3"
>
<p className="font-medium">{item.label}</p>
<Toggle
checked={!hiddenSidebarSet.has(item.id)}
onChange={() => toggleSidebarItem(item.id)}
disabled={loading}
/>
</div>
))}
</div>
</div>
))}
</div>
<p className="mt-3 text-xs text-text-muted">
{getSettingsLabel(
"sidebarVisibilityHint",
"Any sidebar section is hidden automatically when all of its entries are hidden"
)}
</p>
</div>
<div className="pt-4 border-t border-border">
<div className="flex items-center justify-between">
<div>

View File

@@ -0,0 +1,613 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Card, Toggle } from "@/shared/components";
import { cn } from "@/shared/utils/cn";
import { useTranslations } from "next-intl";
import {
HIDDEN_SIDEBAR_ITEMS_SETTING_KEY,
SIDEBAR_SECTION_ORDER_KEY,
SIDEBAR_ITEM_ORDER_KEY,
SIDEBAR_PRESET_KEY,
SIDEBAR_SETTINGS_UPDATED_EVENT,
SIDEBAR_SECTIONS,
SIDEBAR_PRESETS,
applySectionOrder,
applyItemOrder,
getSectionItems,
normalizeHiddenSidebarItems,
type HideableSidebarItemId,
type SidebarSectionId,
type SidebarItemOrder,
type SidebarPresetId,
type SidebarSectionDefinition,
type SidebarSectionChild,
type SidebarItemDefinition,
type SidebarItemGroup,
} from "@/shared/constants/sidebarVisibility";
// ─── Sortable section row ──────────────────────────────────────────────────────
interface SortableSectionProps {
section: SidebarSectionDefinition & { title: string };
hiddenSet: Set<HideableSidebarItemId>;
itemOrder: string[];
onToggleItem: (id: HideableSidebarItemId) => void;
onItemReorder: (sectionId: SidebarSectionId, newOrder: string[]) => void;
getLabel: (key: string, fallback: string) => string;
}
function SortableSection({
section,
hiddenSet,
itemOrder,
onToggleItem,
onItemReorder,
getLabel,
}: SortableSectionProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: section.id,
});
const style = { transform: CSS.Transform.toString(transform), transition };
const [expanded, setExpanded] = useState(true);
const allChildren = section.children as SidebarSectionChild[];
const getChildId = (c: SidebarSectionChild) =>
"type" in c && c.type === "group" ? c.id : (c as SidebarItemDefinition).id;
const orderedChildren = applyItemOrder(allChildren, itemOrder);
const childIds = orderedChildren.map(getChildId);
const sensors = useSensors(useSensor(PointerSensor));
const handleItemDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIdx = childIds.indexOf(active.id as string);
const newIdx = childIds.indexOf(over.id as string);
if (oldIdx === -1 || newIdx === -1) return;
onItemReorder(section.id as SidebarSectionId, arrayMove(childIds, oldIdx, newIdx));
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"rounded-lg border border-border bg-surface/40 transition-shadow",
isDragging && "shadow-lg opacity-80"
)}
>
{/* Section header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border/70">
<button
{...listeners}
{...attributes}
className="text-text-muted/40 hover:text-text-muted/80 cursor-grab active:cursor-grabbing touch-none shrink-0"
title="Drag to reorder section"
aria-label="Drag to reorder section"
>
<span className="material-symbols-outlined text-[18px]">drag_indicator</span>
</button>
<button
onClick={() => setExpanded((p) => !p)}
className="flex-1 flex items-center gap-2 text-left"
>
<span className="text-xs font-semibold uppercase tracking-wider text-text-muted/70">
{section.title}
</span>
<span
className={cn(
"material-symbols-outlined text-[14px] text-text-muted/40 transition-transform ml-auto",
expanded && "rotate-180"
)}
>
expand_more
</span>
</button>
</div>
{/* Section children with inner DnD */}
{expanded && (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleItemDragEnd}
>
<SortableContext items={childIds} strategy={verticalListSortingStrategy}>
<div className="divide-y divide-border/70">
{orderedChildren.map((child) => {
if ("type" in child && child.type === "group") {
const group = child as SidebarItemGroup;
return (
<SortableChildRow key={group.id} id={group.id}>
<GroupRow
group={group}
hiddenSet={hiddenSet}
onToggleItem={onToggleItem}
getLabel={getLabel}
/>
</SortableChildRow>
);
}
const item = child as SidebarItemDefinition;
return (
<SortableChildRow key={item.id} id={item.id}>
<ItemRow
item={item}
hiddenSet={hiddenSet}
onToggleItem={onToggleItem}
getLabel={getLabel}
/>
</SortableChildRow>
);
})}
</div>
</SortableContext>
</DndContext>
)}
</div>
);
}
// ─── Sortable child row wrapper ────────────────────────────────────────────────
function SortableChildRow({ id, children }: { id: string; children: React.ReactNode }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
});
const style = { transform: CSS.Transform.toString(transform), transition };
return (
<div
ref={setNodeRef}
style={style}
className={cn("flex items-start gap-2", isDragging && "opacity-60")}
>
<button
{...listeners}
{...attributes}
className="mt-3.5 ml-4 text-text-muted/30 hover:text-text-muted/70 cursor-grab active:cursor-grabbing touch-none shrink-0"
title="Drag to reorder"
aria-label="Drag to reorder"
>
<span className="material-symbols-outlined text-[14px]">drag_indicator</span>
</button>
<div className="flex-1 min-w-0">{children}</div>
</div>
);
}
// ─── Item row ─────────────────────────────────────────────────────────────────
interface ItemRowProps {
item: SidebarItemDefinition;
hiddenSet: Set<HideableSidebarItemId>;
onToggleItem: (id: HideableSidebarItemId) => void;
getLabel: (key: string, fallback: string) => string;
}
// Items that must always remain visible (safety guard)
const PROTECTED_ITEM_IDS = new Set(["settings-sidebar"]);
function ItemRow({ item, hiddenSet, onToggleItem, getLabel }: ItemRowProps) {
const isProtected = PROTECTED_ITEM_IDS.has(item.id);
return (
<div className="flex items-center justify-between gap-4 px-4 py-3">
<div className="flex items-center gap-2 min-w-0">
<span className="material-symbols-outlined text-[16px] text-text-muted/50 shrink-0">
{item.icon}
</span>
<p className="font-medium truncate">{getLabel(item.i18nKey, item.id)}</p>
</div>
{isProtected ? (
<span
className="material-symbols-outlined text-[16px] text-text-muted/40"
title="This item cannot be hidden"
aria-label="Always visible"
>
lock
</span>
) : (
<Toggle checked={!hiddenSet.has(item.id)} onChange={() => onToggleItem(item.id)} />
)}
</div>
);
}
// ─── Group row (items inside group, no sub-DnD) ───────────────────────────────
interface GroupRowProps {
group: SidebarItemGroup;
hiddenSet: Set<HideableSidebarItemId>;
onToggleItem: (id: HideableSidebarItemId) => void;
getLabel: (key: string, fallback: string) => string;
}
function GroupRow({ group, hiddenSet, onToggleItem, getLabel }: GroupRowProps) {
const [open, setOpen] = useState(true);
return (
<div className="w-full">
<button
onClick={() => setOpen((p) => !p)}
className="flex items-center gap-2 px-4 py-2.5 w-full text-left hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
<span
className={cn(
"material-symbols-outlined text-[12px] text-text-muted/40 transition-transform",
open && "rotate-90"
)}
>
chevron_right
</span>
<span className="text-[10px] font-semibold uppercase tracking-widest text-text-muted/50">
{getLabel(group.titleKey, group.titleFallback)}
</span>
<span className="ml-auto text-xs text-text-muted/40">
{group.items.filter((i) => !hiddenSet.has(i.id)).length}/{group.items.length}
</span>
</button>
{open && (
<div className="divide-y divide-border/50 pl-2">
{group.items.map((item) => (
<div key={item.id} className="flex items-center justify-between gap-4 px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<span className="material-symbols-outlined text-[14px] text-text-muted/40 shrink-0">
{item.icon}
</span>
<p className="text-sm font-medium truncate">{getLabel(item.i18nKey, item.id)}</p>
</div>
<Toggle
size="sm"
checked={!hiddenSet.has(item.id)}
onChange={() => onToggleItem(item.id)}
/>
</div>
))}
</div>
)}
</div>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
export default function SidebarTab() {
const t = useTranslations("settings");
const tSidebar = useTranslations("sidebar");
const getLabel = useCallback(
(key: string, fallback: string) =>
typeof tSidebar.has === "function" && tSidebar.has(key) ? tSidebar(key) : fallback,
[tSidebar]
);
const getSettingsLabel = useCallback(
(key: string, fallback: string) =>
typeof t.has === "function" && t.has(key) ? t(key) : fallback,
[t]
);
const [loading, setLoading] = useState(true);
const [hiddenSidebarItems, setHiddenSidebarItems] = useState<HideableSidebarItemId[]>([]);
const [sectionOrder, setSectionOrder] = useState<SidebarSectionId[]>([]);
const [itemOrder, setItemOrder] = useState<SidebarItemOrder>({});
const [activePreset, setActivePreset] = useState<SidebarPresetId | null>(null);
const [confirmPreset, setConfirmPreset] = useState<SidebarPresetId | null>(null);
const [showDebug, setShowDebug] = useState(false);
useEffect(() => {
fetch("/api/settings")
.then((r) => r.json())
.then((data) => {
setHiddenSidebarItems(
normalizeHiddenSidebarItems(data?.[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY])
);
setSectionOrder(
Array.isArray(data?.[SIDEBAR_SECTION_ORDER_KEY]) ? data[SIDEBAR_SECTION_ORDER_KEY] : []
);
setItemOrder(
data?.[SIDEBAR_ITEM_ORDER_KEY] && typeof data[SIDEBAR_ITEM_ORDER_KEY] === "object"
? data[SIDEBAR_ITEM_ORDER_KEY]
: {}
);
setActivePreset(data?.[SIDEBAR_PRESET_KEY] ?? null);
setShowDebug(data?.debugMode === true);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const patch = async (updates: Record<string, unknown>) => {
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
if (res.ok) {
window.dispatchEvent(new CustomEvent(SIDEBAR_SETTINGS_UPDATED_EVENT, { detail: updates }));
} else {
console.error("Failed to update sidebar settings:", res.statusText);
}
} catch (err) {
console.error("Error updating sidebar settings:", err);
}
};
const toggleItem = (id: HideableSidebarItemId) => {
// Protected items can never be hidden
if (PROTECTED_ITEM_IDS.has(id)) return;
const next = hiddenSidebarItems.includes(id)
? hiddenSidebarItems.filter((x) => x !== id)
: [...hiddenSidebarItems, id];
setHiddenSidebarItems(next);
// Any manual change → custom mode
setActivePreset(null);
patch({ [HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]: next, [SIDEBAR_PRESET_KEY]: null });
};
const hiddenSet = new Set(hiddenSidebarItems);
const visibleSections = SIDEBAR_SECTIONS.filter((s) => s.visibility !== "debug" || showDebug).map(
(s) => ({ ...s, title: getLabel(s.titleKey, s.titleFallback) })
);
const orderedSections = applySectionOrder(visibleSections, sectionOrder);
const sectionIds = orderedSections.map((s) => s.id);
const sensors = useSensors(useSensor(PointerSensor));
const handleSectionDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIdx = sectionIds.indexOf(active.id as SidebarSectionId);
const newIdx = sectionIds.indexOf(over.id as SidebarSectionId);
if (oldIdx === -1 || newIdx === -1) return;
const newOrder = arrayMove(sectionIds, oldIdx, newIdx) as SidebarSectionId[];
setSectionOrder(newOrder);
setActivePreset(null);
patch({ [SIDEBAR_SECTION_ORDER_KEY]: newOrder, [SIDEBAR_PRESET_KEY]: null });
};
const handleItemReorder = (sectionId: SidebarSectionId, newOrder: string[]) => {
const next = { ...itemOrder, [sectionId]: newOrder };
setItemOrder(next);
setActivePreset(null);
patch({ [SIDEBAR_ITEM_ORDER_KEY]: next, [SIDEBAR_PRESET_KEY]: null });
};
const applyPreset = (presetId: SidebarPresetId) => {
const preset = SIDEBAR_PRESETS.find((p) => p.id === presetId);
if (!preset) return;
// Ensure protected items are never hidden, even if a preset includes them
const safeHidden = preset.hiddenItems.filter((id) => !PROTECTED_ITEM_IDS.has(id));
setHiddenSidebarItems(safeHidden);
setSectionOrder([]);
setItemOrder({});
setActivePreset(presetId);
setConfirmPreset(null);
patch({
[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]: safeHidden,
[SIDEBAR_SECTION_ORDER_KEY]: [],
[SIDEBAR_ITEM_ORDER_KEY]: {},
[SIDEBAR_PRESET_KEY]: presetId,
});
};
const resetToDefault = () => applyPreset("all");
const presetLabels: Record<SidebarPresetId, string> = {
all: getSettingsLabel("presetAll", "All"),
minimal: getSettingsLabel("presetMinimal", "Minimal"),
developer: getSettingsLabel("presetDeveloper", "Developer"),
admin: getSettingsLabel("presetAdmin", "Admin"),
};
const presetDescriptions: Record<SidebarPresetId, string> = {
all: getSettingsLabel("presetAllDesc", "Show everything"),
minimal: getSettingsLabel("presetMinimalDesc", "Core pages only"),
developer: getSettingsLabel("presetDeveloperDesc", "Dev & proxy tools"),
admin: getSettingsLabel("presetAdminDesc", "Monitoring & audit"),
};
return (
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-500">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
view_sidebar
</span>
</div>
<div>
<h3 className="text-lg font-semibold">
{getSettingsLabel("settingsSidebarTitle", "Sidebar Customization")}
</h3>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"settingsSidebarDesc",
"Control which items appear in the sidebar and their order"
)}
</p>
</div>
</div>
<div className="flex flex-col gap-6">
{/* Presets */}
<div>
<div className="mb-3">
<p className="font-medium">{getSettingsLabel("sidebarPresets", "Presets")}</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"sidebarPresetsDesc",
"Start from a role-based layout. Any change after applying a preset switches to Custom."
)}
</p>
</div>
{/* Active preset badge */}
<div className="mb-3 flex items-center gap-2 text-sm">
<span className="text-text-muted">
{getSettingsLabel("activePresetLabel", "Active:")}
</span>
<span
className={cn(
"px-2 py-0.5 rounded-full text-xs font-medium",
activePreset
? "bg-primary/10 text-primary"
: "bg-surface border border-border text-text-muted"
)}
>
{activePreset
? presetLabels[activePreset]
: getSettingsLabel("presetCustom", "Custom")}
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{SIDEBAR_PRESETS.map((preset) => {
const isActive = activePreset === preset.id;
return (
<button
key={preset.id}
disabled={loading}
onClick={() => {
if (activePreset === preset.id) return;
if (
activePreset !== null ||
hiddenSidebarItems.length > 0 ||
sectionOrder.length > 0
) {
setConfirmPreset(preset.id);
} else {
applyPreset(preset.id);
}
}}
className={cn(
"flex flex-col items-center gap-1.5 p-3 rounded-lg border transition-colors disabled:opacity-60",
isActive
? "border-primary bg-primary/10 text-primary"
: "border-border hover:border-primary/40 bg-surface/40 text-text-main"
)}
>
<span
className="material-symbols-outlined text-[22px]"
style={isActive ? { fontVariationSettings: "'FILL' 1" } : {}}
aria-hidden="true"
>
{preset.icon}
</span>
<span className="text-sm font-semibold">{presetLabels[preset.id]}</span>
<span
className={cn(
"text-[10px] text-center",
isActive ? "text-primary/70" : "text-text-muted"
)}
>
{presetDescriptions[preset.id]}
</span>
</button>
);
})}
</div>
{/* Confirm preset dialog */}
{confirmPreset && (
<div className="mt-3 p-3 rounded-lg border border-amber-500/30 bg-amber-500/5 flex items-center gap-3">
<span className="material-symbols-outlined text-amber-500 text-[18px] shrink-0">
warning
</span>
<p className="text-sm flex-1">
{getSettingsLabel(
"presetConfirmWarning",
`Applying "${presetLabels[confirmPreset]}" will replace your current visibility and order settings.`
)}
</p>
<div className="flex gap-2 shrink-0">
<button
onClick={() => setConfirmPreset(null)}
className="px-3 py-1.5 text-sm rounded-md border border-border hover:bg-surface/80 transition-colors"
>
{getSettingsLabel("cancelLabel", "Cancel")}
</button>
<button
onClick={() => applyPreset(confirmPreset)}
className="px-3 py-1.5 text-sm rounded-md bg-primary text-white hover:bg-primary/90 transition-colors"
>
{getSettingsLabel("applyLabel", "Apply")}
</button>
</div>
</div>
)}
</div>
{/* Visibility & order */}
<div>
<div className="mb-3 flex items-start justify-between gap-4">
<div>
<p className="font-medium">
{getSettingsLabel("sidebarOrder", "Visibility & Order")}
</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"sidebarOrderDesc",
"Toggle items on/off and drag to reorder sections and their entries."
)}
</p>
</div>
<button
onClick={resetToDefault}
disabled={loading}
className="shrink-0 text-sm text-text-muted hover:text-text-main border border-border rounded-md px-3 py-1.5 hover:bg-surface/80 transition-colors disabled:opacity-50"
>
{getSettingsLabel("resetDefault", "Reset to default")}
</button>
</div>
<div className="flex flex-col gap-3">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleSectionDragEnd}
>
<SortableContext items={sectionIds} strategy={verticalListSortingStrategy}>
{orderedSections.map((section) => (
<SortableSection
key={section.id}
section={section}
hiddenSet={hiddenSet}
itemOrder={itemOrder[section.id as SidebarSectionId] ?? []}
onToggleItem={toggleItem}
onItemReorder={handleItemReorder}
getLabel={getLabel}
/>
))}
</SortableContext>
</DndContext>
</div>
<p className="mt-3 text-xs text-text-muted">
{getSettingsLabel(
"sidebarVisibilityHint",
"A sidebar section hides automatically when all of its entries are hidden"
)}
</p>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,7 @@
"use client";
import SidebarTab from "../components/SidebarTab";
export default function SettingsSidebarPage() {
return <SidebarTab />;
}

View File

@@ -842,7 +842,6 @@
"settingsAi": "AI Settings",
"settingsSecurity": "Security",
"settingsFeatureFlags": "Feature Flags",
"settingsAuthz": "Authz",
"settingsRouting": "Routing",
"settingsResilience": "Resilience",
"settingsAdvanced": "Advanced",
@@ -914,16 +913,12 @@
"settingsAdvancedSubtitle": "Power user options",
"settingsSecuritySubtitle": "Auth and encryption",
"settingsFeatureFlagsSubtitle": "Toggle system capabilities",
"settingsAuthzSubtitle": "Route inventory and bypass policy",
"docsSubtitle": "Documentation",
"issuesSubtitle": "Report a bug",
"changelogSubtitle": "Release notes",
"leaderboard": "Leaderboard",
"leaderboardSubtitle": "Top contributors and usage rankings",
"profile": "Profile",
"profileSubtitle": "Your account profile",
"tokens": "Tokens",
"tokensSubtitle": "Token balance and history"
"settingsSidebar": "Sidebar",
"settingsSidebarSubtitle": "Sidebar customization",
"gamificationGroup": "Gamification"
},
"webhooks": {
"title": "Webhooks",
@@ -1296,7 +1291,6 @@
"keyName": "Key Name",
"keyNamePlaceholder": "e.g. Production Key",
"keyNameDesc": "Choose a descriptive name to identify this key's purpose",
"managementAccessDesc": "Allow this API key to manage OmniRoute configuration.",
"keyCreated": "API Key Created",
"keyCreatedSuccess": "Key created successfully!",
"keyCreatedNote": "Copy and store this key now — it won't be shown again.",
@@ -1634,7 +1628,6 @@
"amp": "Use when you want Amp shorthand workflows but still need OmniRoute alias and routing rules enforcement.",
"qwen": "Use when you need Alibaba Qwen Code CLI for coding tasks.",
"hermes": "Use when you need a lightweight terminal-native AI assistant for quick tasks.",
"hermes-agent": "Use when you need Hermes Agent (by Nousresearch) with default, delegation, vision and auxiliary models routed through OmniRoute.",
"custom": "Use for custom tool implementations or generic OpenAI-compatible configurations."
},
"toolDescriptions": {
@@ -1654,7 +1647,6 @@
"qwen": "Alibaba Qwen Code CLI",
"amp": "Sourcegraph Amp coding assistant CLI",
"hermes": "Hermes AI Terminal Assistant",
"hermes-agent": "Hermes Agent (by Nousresearch) - Advanced terminal AI with multi-model support (delegation, vision, compression, etc.)",
"custom": "Generic OpenAI-compatible CLI or SDK configuration generator"
},
"guides": {
@@ -2041,17 +2033,6 @@
"recommendationsApplied": "Recommendations applied to this combo.",
"intelligentPanelTitle": "Intelligent Routing Dashboard",
"intelligentPanelDesc": "Real-time scoring and health status for this auto-routing combo.",
"configOnlyStatus": "Configuration View",
"configOnlyHint": "This panel shows routing inputs only. Live breaker state is available on the Health page.",
"routingInputs": "Routing Inputs",
"routingInputsHint": "Mode pack and weighting stay here; breaker runtime state stays on the Health page.",
"emailVisibilityHint": "Account emails here follow the global privacy toggle.",
"emailVisibilityTooltip": "Use the eye icon to reveal or hide account emails globally across combos, providers and quota screens.",
"manualModel": "Manual model",
"manualModelInvalid": "Enter a model as provider/model.",
"manualModelUnknownProvider": "Unknown provider prefix.",
"builderDynamicAccountShort": "Dynamic account",
"builderNeedValidName": "Define a valid combo name before continuing.",
"statusOverview": "Status Overview",
"normalOperation": "Normal Operation",
"allProvidersHealthy": "Providers are reporting healthy routing conditions.",
@@ -2361,8 +2342,7 @@
"previousPeriod": "Previous Half",
"currentPeriod": "Current Half",
"exportCSV": "Export as CSV",
"exportJSON": "Export as JSON",
"legacyFreeLabel": "Legacy / Free"
"exportJSON": "Export as JSON"
},
"endpoint": {
"title": "API Endpoint",
@@ -3645,12 +3625,6 @@
"geminiCliProjectIdLabel": "Google Cloud Project ID",
"geminiCliProjectIdPlaceholder": "my-gcp-project-id",
"antigravityProjectIdHint": "Optional override for Antigravity Cloud Code requests. Leave blank to use the project discovered during Google OAuth.",
"antigravityClientProfileLabel": "Client profile",
"antigravityClientProfileHint": "Choose which Antigravity client identity OmniRoute presents to the API.",
"antigravityClientProfileIde": "IDE",
"antigravityClientProfileHarness": "Harness / CLI",
"codexFastTierActiveChip": "Codex Fast tier is active",
"tierFast": "Fast",
"antigravityProjectIdLabel": "Google Cloud Project ID",
"antigravityProjectIdPlaceholder": "my-gcp-project-id",
"grokWebCookieHint": "Grok Web Cookie Hint",
@@ -3758,65 +3732,6 @@
"cache": "Cache",
"resilience": "Resilience",
"routingSettingsIntro": "Controls how your requests are routed, transformed, and sent to AI providers.",
"routingOpDropParagraphContainsLabel": "Drop paragraph (contains)",
"routingOpDropParagraphStartsWithLabel": "Drop paragraph (starts with)",
"routingOpReplaceTextLabel": "Replace text",
"routingOpReplaceRegexLabel": "Replace regex",
"routingOpDropBlockContainsLabel": "Drop block (contains)",
"routingOpPrependSystemBlockLabel": "Prepend system block",
"routingOpAppendSystemBlockLabel": "Append system block",
"routingOpInjectBillingHeaderLabel": "Inject billing header",
"routingOpObfuscateWordsLabel": "Obfuscate words (ZWJ)",
"routingOpDropParagraphContainsDesc": "Removes any paragraph (text block split on blank lines) inside the system prompt whose text contains ANY of the listed substrings. Use to strip third-party client fingerprints like 'github.com/anomalyco/opencode' or 'docs.openwebui.com' that Anthropic's classifier flags.",
"routingOpDropParagraphStartsWithDesc": "Removes any paragraph that STARTS WITH one of the listed prefixes. Use for identity lines like 'You are OpenCode' or 'You are Open WebUI' that announce the calling client.",
"routingOpReplaceTextDesc": "Replaces a literal substring with another literal substring. Use for known trigger phrases — e.g. rewrite 'Here is some useful information about the environment you are running in:' to 'Environment context you are running in:' (an empirically-validated trigger phrase).",
"routingOpReplaceRegexDesc": "Replaces text matching a regular expression. Use when you need patterns (character classes, optional whitespace, anchors) instead of literal substrings. A malformed pattern is caught at runtime when the op runs.",
"routingOpDropBlockContainsDesc": "Removes ENTIRE system blocks (not just paragraphs) whose text contains any of the listed substrings. Use when a whole block is fingerprint-bearing and you want it gone — e.g. an injected MCP-server description.",
"routingOpPrependSystemBlockDesc": "Inserts a new text block at the FRONT of the system array. Use to add the SDK identity 'You are a Claude agent, built on Anthropic's Claude Agent SDK.' that Anthropic's classifier expects.",
"routingOpAppendSystemBlockDesc": "Inserts a new text block at the END of the system array. Use for cosmetic additions that don't need to be at position [0].",
"routingOpInjectBillingHeaderDesc": "Prepends the special 'x-anthropic-billing-header: cc_version=...; cc_entrypoint=...; cch=...;' text block that Anthropic's classifier validates. Required for CC bridge relay endpoints; for the native claude provider OmniRoute already injects its own billing line so this op is usually redundant there.",
"routingOpObfuscateWordsDesc": "Inserts a Zero-Width-Joiner character after the first letter of each listed word, so 'opencode' becomes 'o\u200dpencode'. Reads identical to humans but bypasses classifier word matches. Targets system blocks, user/assistant messages, and tool descriptions.",
"routingNeedlesHint": "List of substrings. A paragraph matches if it contains ANY one of them. Add one per line via 'Add entry'.",
"routingPrefixesHint": "List of strings. A paragraph matches if it starts with any one of them (leading whitespace is trimmed before matching).",
"routingCaseSensitiveHint": "When ON, 'OpenCode' and 'opencode' are different strings. When OFF (default), the comparison ignores case.",
"routingMatchLiteralHint": "Exact literal substring to find. No regex syntax — special chars like . * ? are treated as themselves.",
"routingReplacementTextHint": "Replacement string. Leave blank to delete the match. The output preserves surrounding text.",
"routingAllOccurrencesHint": "When ON (default), every instance is replaced. When OFF, only the first match is replaced.",
"routingPatternHint": "JavaScript regex source. Don't wrap in slashes — just 'foo(.*)bar'. Server rejects patterns that fail to compile.",
"routingRegexFlagsHint": "JavaScript regex flags (g = all matches, i = case-insensitive, s = dot matches newline, m = multiline). Default 'g'.",
"routingBlockTextHint": "Full text of the new system block. Use a literal string; the system block stores text only.",
"routingIdempotencyKeyHint": "Optional. If set, the op skips when a block whose text starts with this key is already present. Prevents double-prepend on retries.",
"routingBillingEntrypointHint": "Value injected as 'cc_entrypoint='. Anthropic accepts 'sdk-cli' (Agent SDK), 'cli' (Claude Code CLI), or other documented values.",
"routingBillingVersionFormatHint": "How the 3-char build hash after cc_version= is computed. 'ex-machina' = sha256 of CCH_SALT+chars-from-first-user-msg+version (per-message). 'omniroute-daystamp' = sha256 of YYYY-MM-DD+version (stable per-day).",
"routingBillingCchAlgoHint": "How the 5-char cch= token is computed. 'sha256-first-user' = sha256 of first user message text. 'xxhash64-body' = body-level signing fills it later. 'static-zero' = literal '00000' placeholder.",
"routingObfuscateWordsHint": "Lowercase words to obfuscate. ZWJ insertion is applied case-insensitively, so 'opencode' also matches 'OpenCode' and 'OPENCODE'.",
"routingObfuscateTargetsHint": "Which body regions to scan for the words: system blocks, user/assistant messages, and/or tool descriptions.",
"routingObfuscateTargetsLabel": "Targets",
"routingSummarizeDropParagraphContains": "drop paragraphs containing: {items}",
"routingSummarizeDropParagraphStartsWith": "drop paragraphs starting with: {items}",
"routingSummarizeReplaceText": "replace \"{match}\" → \"{replacement}\"",
"routingSummarizeReplaceRegex": "regex /{pattern}/{flags} → \"{replacement}\"",
"routingSummarizeDropBlockContains": "drop blocks containing: {items}",
"routingSummarizePrependSystemBlock": "prepend block: \"{text}\"",
"routingSummarizeAppendSystemBlock": "append block: \"{text}\"",
"routingSummarizeInjectBillingHeader": "inject billing header (entrypoint={entrypoint}, version={versionFormat}, cch={cchAlgo})",
"routingSummarizeObfuscateWords": "obfuscate {count} word(s) via ZWJ in {targets}",
"routingDefaultAutoVariantLKGP": "Last Known Good Provider",
"routingDefaultAutoVariantLKGPDesc": "Last Known Good Provider",
"routingDefaultAutoVariantCoding": "Quality-first for code",
"routingDefaultAutoVariantCodingDesc": "Quality-first for code",
"routingDefaultAutoVariantFast": "Low-latency routing",
"routingDefaultAutoVariantFastDesc": "Low-latency routing",
"routingDefaultAutoVariantCheap": "Cost-optimized",
"routingDefaultAutoVariantCheapDesc": "Cost-optimized",
"routingDefaultAutoVariantOffline": "High availability",
"routingDefaultAutoVariantOfflineDesc": "High availability",
"routingDefaultAutoVariantSmart": "Best discovery (10% explore)",
"routingDefaultAutoVariantSmartDesc": "Best discovery (10% explore)",
"routingOpSummaryCount": "{count, plural, =0 {no ops} one {# op} other {# ops}}",
"routingOpEnabled": "enabled",
"routingOpDisabled": "disabled",
"routingOpStatusSeparator": " · ",
"resilienceSettingsIntro": "Automatic retry, cooldown, and fallback when providers fail.",
"aiSettingsIntro": "AI-specific settings for thinking budget, model behavior, and compression.",
"systemPrompt": "System Prompt",
@@ -3892,13 +3807,6 @@
"systemTheme": "System Theme",
"debugToggle": "Enable Debug Mode",
"sidebarVisibilityToggle": "Show Sidebar Items",
"pinProviderQuotaToHome": "Pin Information to Home Page",
"providerQuotaLimits": "Provider Quota Limits",
"providerQuotaLimitsDesc": "Pin the Provider Quota status container (with Refresh All button) to the top of the Home page.",
"quickStart": "Quick Start",
"quickStartDesc": "Show the Quick Start panel on the Home page.",
"providerTopology": "Provider Topology",
"providerTopologyDesc": "Show the Provider Topology on the Home page.",
"enableCache": "Enable Cache",
"cacheTTL": "Cache TTL",
"maxCacheSize": "Max Cache Size",
@@ -4578,27 +4486,13 @@
"oneproxySuccess": "Success",
"oneproxyFailed": "Failed",
"routingAntigravitySignatureTitle": "Antigravity Signature Cache Mode",
"routingAntigravitySignatureDesc": "Control whether OmniRoute reuses only stored Gemini thought signatures or accepts validated client-provided signatures in Antigravity-compatible tool-call flows.",
"routingAntigravitySignatureEnabledLabel": "Enabled",
"routingAntigravitySignatureEnabledDesc": "Current behavior. Ignore client-provided signatures and keep using the stored OmniRoute flow.",
"routingAntigravitySignatureBypassLabel": "Bypass",
"routingAntigravitySignatureBypassDesc": "Accept client-provided signatures after lightweight validation and fall back to the stored signature when invalid.",
"routingAntigravitySignatureBypassStrictLabel": "Bypass Strict",
"routingAntigravitySignatureBypassStrictDesc": "Require full protobuf validation before accepting a client-provided signature.",
"routingHeaderFingerprintTitle": "Header fingerprint (per provider)",
"routingServerRejectedSave": "⚠ Server rejected save:",
"routingAddTransformOp": "Add a transform op",
"routingClientCacheControlTitle": "Client Cache Control",
"routingClientCacheControlDesc": "Configure whether OmniRoute preserves client-provided cache_control markers",
"routingClientCacheControlAutoDesc": "For deterministic Claude-compatible flows, preserve client-provided cache_control as-is. If the request has no cache_control, OmniRoute does not inject any bridge-owned markers for CC-compatible third-party proxy compatibility.",
"routingClientCacheControlAlwaysLabel": "Always Preserve",
"routingClientCacheControlAlwaysDesc": "Always forward client-provided cache_control headers to upstream providers as-is.",
"routingClientCacheControlNeverLabel": "Never Preserve",
"routingClientCacheControlNeverDesc": "Always remove client cache_control headers and let OmniRoute manage caching where native provider flows support it.",
"routingZeroConfigTitle": "Zero-Config Auto-Routing",
"routingZeroConfigDesc": "Enable automatic provider selection using the auto/ prefix. When enabled, requests to auto, auto/coding, auto/fast, etc. will dynamically route across all connected providers.",
"routingDefaultAutoVariant": "Default Auto Variant",
"visionBridge": "Vision Bridge",
"routingZeroConfigTitle": "Zero-Config Auto-Routing",
"routingDefaultAutoVariant": "Default Auto Variant",
"visionBridgeModel": "Bridge Model",
"resilienceMaxBackoffSteps": "Max backoff steps",
"resilienceBaseCooldownLabel": "Base cooldown",
@@ -4666,74 +4560,36 @@
"codexFastTierDesc": "Globally inject service_tier=priority for OpenAI Codex requests.",
"codexFastTierHint": "When enabled, OmniRoute adds service_tier=priority to outbound Codex requests for connections that don't already specify a tier. Priority tier requires an OpenAI Enterprise API key or the ChatGPT-auth Codex path; other key types will receive a tier-related error from OpenAI. Per-connection settings on the Codex provider page take precedence.",
"codexFastTierSaveError": "Failed to update Codex Fast Tier setting",
"codexFastTierTierLabel": "Service tier",
"codexFastTierTierPriority": "Priority",
"codexFastTierTierFlex": "Flex",
"codexFastTierTierDefault": "Default",
"codexFastTierModelsLabel": "Fast-tier models",
"codexFastTierModelsHint": "Only checked models receive service_tier when Fast Tier is enabled.",
"codexFastTierModelCheckbox": "Enable Fast Tier for {model}",
"claudeFastModeTitle": "Claude Fast Mode",
"claudeFastModeDesc": "Opt selected Claude requests into Anthropic Fast Mode (speed:\"fast\").",
"claudeFastModeHint": "Anthropic does not officially support Fast Mode for SDK-style clients. When enabled, OmniRoute forwards an X-CPA-Force-Fast-Mode header so a paired CLIProxyAPI build can opt-in spoof the entrypoint. Only the listed Opus models are gated by Anthropic's client-side check. Subscription tier, Max plan, and Fast Mode credit balance are still enforced server-side — Anthropic may return out_of_credits even when the toggle is on.",
"claudeFastModeModelsLabel": "Applied to models ({count})",
"claudeFastModeModelCheckbox": "Enable Fast Mode for {model}",
"claudeFastModeSaveError": "Failed to update Claude Fast Mode setting",
"authz": {
"title": "Authz Inventory",
"description": "5-tier route classification with live bypass policy. Read shows the full taxonomy; mutations re-prompt for the management password.",
"loading": "Loading inventory…",
"loadError": "Failed to load authz inventory",
"tier": {
"LOCAL_ONLY": "Local only",
"ALWAYS_PROTECTED": "Always protected",
"MANAGEMENT": "Management",
"CLIENT_API": "Client API",
"PUBLIC": "Public"
},
"bypass": {
"section": "Manage-scope bypass",
"kill_switch": {
"label": "Bypass kill-switch",
"desc": "Master toggle. When off, no LOCAL_ONLY prefix is reachable from non-loopback regardless of the per-prefix list."
},
"prefix": {
"label": "Bypassable prefixes",
"desc": "LOCAL_ONLY prefixes that manage-scope API keys (or dashboard sessions) may reach from non-loopback hosts.",
"add": "Add prefix",
"placeholder": "/api/mcp/v2/",
"empty": "No prefixes configured. Bypass effectively off."
},
"cli_tools_runtime_note": "Spawn-capable prefix. Compile-time deny; cannot be made bypassable. Shown read-only."
},
"password": {
"prompt": {
"label": "Current password",
"desc": "Re-confirm to apply security-impacting changes."
},
"placeholder": "Current management password",
"cancel": "Cancel",
"submit": "Apply"
},
"save": "Save changes",
"saved": "Authz settings updated",
"pending": "Unsaved changes",
"badge": {
"bypassable": "Bypassable via manage scope",
"strict": "Strict loopback",
"auth_required": "Auth required",
"public": "Public",
"always_protected": "Always protected",
"spawn_capable": "Spawn-capable"
},
"error": {
"PASSWORD_REQUIRED": "Current password required to apply these changes.",
"PASSWORD_MISMATCH": "Current password is incorrect.",
"INSUFFICIENT_SCOPE": "API key lacks the manage scope.",
"BYPASS_PREFIX_NOT_ALLOWED": "One or more prefixes target spawn-capable routes and cannot be bypassed.",
"GENERIC": "Failed to update authz settings."
}
}
"settingsSidebarTitle": "Sidebar Customization",
"settingsSidebarDesc": "Control which items appear in the sidebar and their order",
"sidebarPresets": "Presets",
"sidebarPresetsDesc": "Start from a role-based layout. Any change after applying a preset switches to Custom.",
"presetAll": "All",
"presetAllDesc": "Show everything",
"presetMinimal": "Minimal",
"presetMinimalDesc": "Core pages only",
"presetDeveloper": "Developer",
"presetDeveloperDesc": "Dev & proxy tools",
"presetAdmin": "Admin",
"presetAdminDesc": "Monitoring & audit",
"presetCustom": "Custom",
"activePresetLabel": "Active:",
"presetConfirmWarning": "Applying this preset will replace your current visibility and order settings.",
"cancelLabel": "Cancel",
"applyLabel": "Apply",
"sidebarOrder": "Visibility & Order",
"sidebarOrderDesc": "Toggle items on/off and drag to reorder sections and their entries.",
"sidebarCustomizeLink": "Customize which items appear in the sidebar, their order, and apply role presets.",
"sidebarCustomizeLinkBtn": "Customize",
"resetDefault": "Reset to default",
"settingsSidebar": "Sidebar",
"settingsSidebarSubtitle": "Visibility and order"
},
"contextRtk": {
"title": "RTK Engine",
@@ -4828,8 +4684,6 @@
"enabled": "Enabled",
"autoDetect": "Auto-detect language",
"rulesCount": "{count} rules",
"inputCompressionTitle": "Input compression",
"inputCompressionDesc": "Rewrite chat history with shorter wording. Reduces input tokens by ~50%.",
"analyticsTitle": "Compression Analytics",
"noAnalytics": "No compression analytics yet.",
"outputMode": "Output Mode",
@@ -5231,25 +5085,7 @@
"tierPro": "Pro",
"tierPlus": "Plus",
"tierFree": "Free",
"tierLite": "Lite",
"tierUnknown": "Unknown",
"statTotal": "Total",
"statCritical": "Critical",
"statAlert": "Alert",
"statHealthy": "Healthy",
"filterPurchaseTypeLabel": "Type",
"filterTierLabel": "Tier",
"purchaseAll": "All",
"purchaseOauthSub": "Subscription",
"purchaseOauthFree": "OAuth Free",
"purchaseApiKey": "API Key",
"creditsLabel": "Credits",
"creditBalanceHint": "Remaining balance",
"unlimitedLabel": "Unlimited",
"refreshing": "Refreshing",
"resetsIn": "Resets in",
"editCutoffs": "Edit cutoffs",
"forceRefresh": "Refresh now",
"suiteBuilderSaveFailed": "Failed to save suite",
"clone": "Clone",
"exportSuite": "Export",
@@ -5395,9 +5231,7 @@
"budgetWarnAtPct": "Warn at %",
"quotaAlerts": "Quota alerts",
"quotaTableRefreshing": "⟳ Refreshing...",
"noSpendLast30Days": "No spend in last 30 days",
"updatedShort": "Updated",
"lastRefreshed": "Last refreshed"
"noSpendLast30Days": "No spend in last 30 days"
},
"modals": {
"waitingAuth": "Waiting for Authorization",
@@ -6072,7 +5906,6 @@
"clearError": "Failed to clear cache.",
"unavailable": "Cache unavailable",
"unavailableDesc": "Could not fetch cache statistics. Make sure the server is running.",
"loadingCacheAria": "Loading cache",
"promptCache": "Prompt Cache (Provider-Side)",
"semanticCache": "Semantic Cache",
"promptCacheSectionDesc": "Shows provider-side prompt caching activity from usage history so you can see where cache control is active and how much input reuse you are getting.",
@@ -6412,8 +6245,6 @@
"combo": "Combo",
"keys": "Keys",
"shown": "Shown",
"loadMore": "Load more",
"loadingMore": "Loading more...",
"sortNewest": "Newest",
"sortOldest": "Oldest",
"sortTokensDesc": "Tokens ↓",
@@ -6633,8 +6464,8 @@
"equalSplit": "Equal split",
"save": "Save allocations",
"betaPreviewLabel": "Beta — UI preview.",
"betaConfigSavedPrefix": "Configuration is saved in",
"betaConfigSavedSuffix": "(not yet persisted on the server). Per-request cap enforcement is not yet wired into the proxy pipeline. This screen lets you design and visualize the quota split;",
"betaConfigSavedPrefix": "A configuração é salva em",
"betaConfigSavedSuffix": "(não persiste no servidor ainda). A aplicação dos caps por request ainda não está conectada ao pipeline da proxy. Esta tela permite desenhar e visualizar a divisão de cota;",
"policyLabel": "Policy:"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -93,6 +93,9 @@ export async function getSettings() {
mcpEnabled: false,
a2aEnabled: false,
hiddenSidebarItems: [],
sidebarSectionOrder: [],
sidebarItemOrder: {},
sidebarActivePreset: null,
hideEndpointCloudflaredTunnel: false,
hideEndpointTailscaleFunnel: false,
hideEndpointNgrokTunnel: false,

View File

@@ -14,12 +14,17 @@ import { useTranslations } from "next-intl";
import {
HIDDEN_SIDEBAR_ITEMS_SETTING_KEY,
SIDEBAR_SETTINGS_UPDATED_EVENT,
SIDEBAR_SECTION_ORDER_KEY,
SIDEBAR_ITEM_ORDER_KEY,
SIDEBAR_SECTIONS,
getSectionItems,
normalizeHiddenSidebarItems,
applySectionOrder,
applyItemOrder,
type SidebarSectionId,
type SidebarItemDefinition,
type SidebarItemGroup,
type SidebarItemOrder,
} from "@/shared/constants/sidebarVisibility";
const isE2EMode = process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE === "1";
@@ -70,6 +75,8 @@ export default function Sidebar({
const [isDisconnected, setIsDisconnected] = useState(false);
const [showDebug, setShowDebug] = useState(false);
const [hiddenSidebarItems, setHiddenSidebarItems] = useState<string[]>([]);
const [sidebarSectionOrder, setSidebarSectionOrder] = useState<SidebarSectionId[]>([]);
const [sidebarItemOrder, setSidebarItemOrder] = useState<SidebarItemOrder>({});
const [customAppName, setCustomAppName] = useState<string | null>(null);
const [customLogo, setCustomLogo] = useState<string | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<SidebarSectionId>>(
@@ -116,7 +123,15 @@ export default function Sidebar({
fetch("/api/settings")
.then((res) => res.json())
.then((data) => applySettings(data))
.then((data) => {
applySettings(data);
if (Array.isArray(data?.[SIDEBAR_SECTION_ORDER_KEY])) {
setSidebarSectionOrder(data[SIDEBAR_SECTION_ORDER_KEY] as SidebarSectionId[]);
}
if (data?.[SIDEBAR_ITEM_ORDER_KEY] && typeof data[SIDEBAR_ITEM_ORDER_KEY] === "object") {
setSidebarItemOrder(data[SIDEBAR_ITEM_ORDER_KEY] as SidebarItemOrder);
}
})
.catch(() => {});
const handleSettingsUpdated = (event: Event) => {
@@ -127,6 +142,16 @@ export default function Sidebar({
normalizeHiddenSidebarItems(detail[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY])
);
}
if (SIDEBAR_SECTION_ORDER_KEY in detail && Array.isArray(detail[SIDEBAR_SECTION_ORDER_KEY])) {
setSidebarSectionOrder(detail[SIDEBAR_SECTION_ORDER_KEY] as SidebarSectionId[]);
}
if (
SIDEBAR_ITEM_ORDER_KEY in detail &&
detail[SIDEBAR_ITEM_ORDER_KEY] &&
typeof detail[SIDEBAR_ITEM_ORDER_KEY] === "object"
) {
setSidebarItemOrder(detail[SIDEBAR_ITEM_ORDER_KEY] as SidebarItemOrder);
}
if ("instanceName" in detail) setCustomAppName((detail.instanceName as string) || null);
if ("customLogoBase64" in detail) {
setCustomLogo((detail.customLogoBase64 as string) || null);
@@ -158,17 +183,27 @@ export default function Sidebar({
const hiddenSidebarSet = new Set(hiddenSidebarItems);
const visibleSections = SIDEBAR_SECTIONS.filter(
(section) => section.visibility !== "debug" || showDebug
)
const orderedSections = applySectionOrder(
SIDEBAR_SECTIONS.filter((section) => section.visibility !== "debug" || showDebug),
sidebarSectionOrder
);
const visibleSections = orderedSections
.map((section) => {
const children = section.children
const orderedChildren = applyItemOrder(
section.children,
sidebarItemOrder[section.id as SidebarSectionId] ?? []
);
const children = orderedChildren
.map((child) => {
if ("type" in child && child.type === "group") {
const items = child.items
.map((item) => resolveItem(item, hiddenSidebarSet))
.filter(Boolean) as (SidebarItemDefinition & { label: string })[];
if (items.length === 0) return null;
// Smart-grouping: single visible item → inline flat (no group header)
if (items.length === 1) return items[0];
return {
...child,
title: getSidebarLabel(child.titleKey, child.titleFallback),

View File

@@ -75,7 +75,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
"settings-advanced",
"settings-security",
"settings-feature-flags",
"settings-authz",
"settings-sidebar",
// Help
"docs",
"issues",
@@ -661,11 +661,11 @@ const CONFIGURATION_ITEMS: readonly SidebarItemDefinition[] = [
icon: "flag",
},
{
id: "settings-authz",
href: "/dashboard/settings/authz",
i18nKey: "settingsAuthz",
subtitleKey: "settingsAuthzSubtitle",
icon: "shield_lock",
id: "settings-sidebar",
href: "/dashboard/settings/sidebar",
i18nKey: "settingsSidebar",
subtitleKey: "settingsSidebarSubtitle",
icon: "view_sidebar",
},
];
@@ -763,11 +763,159 @@ export const SIDEBAR_SECTIONS: readonly SidebarSectionDefinition[] = [
},
] as const;
// ─── Settings helpers ─────────────────────────────────────────────────────────
// ─── Ordering & preset setting keys ──────────────────────────────────────────
export const HIDDEN_SIDEBAR_ITEMS_SETTING_KEY = "hiddenSidebarItems";
export const SIDEBAR_SECTION_ORDER_KEY = "sidebarSectionOrder";
export const SIDEBAR_ITEM_ORDER_KEY = "sidebarItemOrder";
export const SIDEBAR_PRESET_KEY = "sidebarActivePreset";
export const SIDEBAR_SETTINGS_UPDATED_EVENT = "omniroute:settings-updated";
// ─── Preset types & definitions ───────────────────────────────────────────────
export type SidebarPresetId = "all" | "minimal" | "developer" | "admin";
export interface SidebarPresetDefinition {
id: SidebarPresetId;
icon: string;
hiddenItems: HideableSidebarItemId[];
}
const MINIMAL_SHOWN: ReadonlySet<HideableSidebarItemId> = new Set([
"home",
"endpoints",
"api-manager",
"providers",
"combos",
"analytics",
"costs",
"logs",
"health",
"settings",
"settings-sidebar",
"docs",
"changelog",
]);
const DEVELOPER_SHOWN: ReadonlySet<HideableSidebarItemId> = new Set([
"home",
"endpoints",
"api-manager",
"providers",
"combos",
"quota",
"context-caveman",
"context-rtk",
"context-combos",
"cli-tools",
"agents",
"api-endpoints",
"analytics",
"analytics-combo-health",
"costs",
"cache",
"logs",
"health",
"runtime",
"translator",
"playground",
"memory",
"skills",
"mcp",
"a2a",
"settings",
"settings-routing",
"settings-resilience",
"settings-sidebar",
"docs",
"issues",
"changelog",
]);
const ADMIN_SHOWN: ReadonlySet<HideableSidebarItemId> = new Set([
"home",
"endpoints",
"api-manager",
"providers",
"combos",
"quota",
"analytics",
"analytics-combo-health",
"analytics-utilization",
"costs",
"costs-pricing",
"costs-budget",
"costs-quota-share",
"cache",
"logs",
"logs-activity",
"health",
"runtime",
"audit",
"audit-mcp",
"audit-a2a",
"settings",
"settings-general",
"settings-routing",
"settings-resilience",
"settings-security",
"settings-feature-flags",
"settings-sidebar",
"docs",
"changelog",
]);
function buildHiddenList(shown: ReadonlySet<HideableSidebarItemId>): HideableSidebarItemId[] {
return HIDEABLE_SIDEBAR_ITEM_IDS.filter((id) => !shown.has(id));
}
export const SIDEBAR_PRESETS: readonly SidebarPresetDefinition[] = [
{ id: "all", icon: "select_all", hiddenItems: [] },
{ id: "minimal", icon: "minimize", hiddenItems: buildHiddenList(MINIMAL_SHOWN) },
{ id: "developer", icon: "code", hiddenItems: buildHiddenList(DEVELOPER_SHOWN) },
{ id: "admin", icon: "admin_panel_settings", hiddenItems: buildHiddenList(ADMIN_SHOWN) },
];
export type SidebarItemOrder = Partial<Record<SidebarSectionId, string[]>>;
// ─── Ordering utilities ───────────────────────────────────────────────────────
export function applySectionOrder(
sections: readonly SidebarSectionDefinition[],
order: SidebarSectionId[]
): SidebarSectionDefinition[] {
if (order.length === 0) return [...sections];
const knownIds = new Set(sections.map((s) => s.id));
const validOrder = order.filter((id) => knownIds.has(id));
const orderMap = new Map(validOrder.map((id, i) => [id, i]));
return [...sections].sort((a, b) => {
const ai = orderMap.get(a.id) ?? validOrder.length + sections.indexOf(a);
const bi = orderMap.get(b.id) ?? validOrder.length + sections.indexOf(b);
return ai - bi;
});
}
export function applyItemOrder(
children: readonly SidebarSectionChild[],
order: string[]
): SidebarSectionChild[] {
if (order.length === 0) return [...children];
const getChildId = (c: SidebarSectionChild): string =>
"type" in c && c.type === "group" ? c.id : (c as SidebarItemDefinition).id;
const knownIds = new Set(children.map(getChildId));
const validOrder = order.filter((id) => knownIds.has(id));
const orderMap = new Map(validOrder.map((id, i) => [id, i]));
return [...children].sort((a, b) => {
const aId = getChildId(a);
const bId = getChildId(b);
const ai = orderMap.get(aId) ?? validOrder.length + children.indexOf(a);
const bi = orderMap.get(bId) ?? validOrder.length + children.indexOf(b);
return ai - bi;
});
}
// ─── Settings helpers ─────────────────────────────────────────────────────────
export function normalizeHiddenSidebarItems(value: unknown): HideableSidebarItemId[] {
if (!Array.isArray(value)) return [];

View File

@@ -8,284 +8,258 @@
import { z } from "zod";
import { COMBO_CONFIG_MODES } from "@/shared/constants/comboConfigMode";
import { MAX_REQUEST_BODY_LIMIT_MB, MIN_REQUEST_BODY_LIMIT_MB } from "@/shared/constants/bodySize";
import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility";
import { HIDEABLE_SIDEBAR_ITEM_IDS, SIDEBAR_SECTIONS } from "@/shared/constants/sidebarVisibility";
import { ACCOUNT_FALLBACK_STRATEGY_VALUES } from "@/shared/constants/routingStrategies";
import { SPAWN_CAPABLE_PREFIXES } from "@/server/authz/routeGuard";
const signatureCacheModeValues = ["enabled", "bypass", "bypass-strict"] as const;
export const updateSettingsSchema = z
.object({
newPassword: z.string().min(1).max(200).optional(),
currentPassword: z.string().max(200).optional(),
theme: z.string().max(50).optional(),
language: z.string().max(10).optional(),
requireLogin: z.boolean().optional(),
enableSocks5Proxy: z.boolean().optional(),
instanceName: z.string().max(100).optional(),
customLogoUrl: z.string().max(2000).optional(),
customLogoBase64: z.string().max(100000).optional(),
customFaviconUrl: z.string().max(2000).optional(),
customFaviconBase64: z.string().max(50000).optional(),
corsOrigins: z.string().max(500).optional(),
cloudUrl: z.string().max(500).optional(),
baseUrl: z.string().max(500).optional(),
setupComplete: z.boolean().optional(),
blockedProviders: z.array(z.string().max(100)).optional(),
hideHealthCheckLogs: z.boolean().optional(),
hideEndpointCloudflaredTunnel: z.boolean().optional(),
hideEndpointTailscaleFunnel: z.boolean().optional(),
hideEndpointNgrokTunnel: z.boolean().optional(),
debugMode: z.boolean().optional(),
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(),
codexServiceTier: z
.object({
enabled: z.boolean().optional(),
tier: z.enum(["default", "priority", "flex"]).optional(),
supportedModels: z.array(z.string().max(200)).max(200).optional(),
})
.optional(),
// Claude Fast Mode: opt-in toggle that asks a paired CLIProxyAPI build
// (claude-fastmode-spoof) to rewrite SDK-shaped entrypoints so requests can
// reach Anthropic Fast Mode (speed:"fast"). Default off; only the listed
// Opus models are gated by the Anthropic binary KT() check. Schema is
// intentionally permissive on supportedModels so additional eligible model
// ids can be enabled without a schema bump.
claudeFastMode: z
.object({
enabled: z.boolean().optional(),
supportedModels: z.array(z.string().max(200)).max(200).optional(),
})
.optional(),
// Routing settings (#134)
fallbackStrategy: z.enum(ACCOUNT_FALLBACK_STRATEGY_VALUES).optional(),
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
stickyRoundRobinLimit: z.number().int().min(0).max(1000).optional(),
requestRetry: z.number().int().min(0).max(10).optional(),
maxRetryIntervalSec: z.number().int().min(0).max(300).optional(),
maxBodySizeMb: z
.number()
.int()
.min(MIN_REQUEST_BODY_LIMIT_MB)
.max(MAX_REQUEST_BODY_LIMIT_MB)
.optional(),
// Auto intent classifier settings (multilingual routing)
intentDetectionEnabled: z.boolean().optional(),
intentSimpleMaxWords: z.number().int().min(1).max(500).optional(),
intentExtraCodeKeywords: z.array(z.string().max(100)).optional(),
intentExtraReasoningKeywords: z.array(z.string().max(100)).optional(),
intentExtraSimpleKeywords: z.array(z.string().max(100)).optional(),
// Protocol toggles (default: disabled)
mcpEnabled: z.boolean().optional(),
mcpTransport: z.enum(["stdio", "sse", "streamable-http"]).optional(),
a2aEnabled: z.boolean().optional(),
wsAuth: z.boolean().optional(),
// CLI Fingerprint compatibility (per-provider)
cliCompatProviders: z.array(z.string().max(100)).optional(),
// CC bridge transforms (issue #2260): config-driven pipeline that normalizes
// system blocks at the Claude Code bridge so any client (OpenCode, Cline,
// Cursor, Continue, raw API) ends up with classifier-correct structure.
ccBridgeTransforms: z
.object({
enabled: z.boolean(),
pipeline: z
.array(
z.discriminatedUnion("kind", [
z.object({
kind: z.literal("drop_paragraph_if_contains"),
needles: z.array(z.string().max(500)).max(50),
caseSensitive: z.boolean().optional(),
}),
z.object({
kind: z.literal("drop_paragraph_if_starts_with"),
prefixes: z.array(z.string().max(500)).max(50),
caseSensitive: z.boolean().optional(),
}),
z.object({
kind: z.literal("replace_text"),
match: z.string().min(1).max(500),
replacement: z.string().max(500),
allOccurrences: z.boolean().optional(),
}),
z.object({
kind: z.literal("replace_regex"),
pattern: z.string().min(1).max(500),
flags: z.string().max(10).optional(),
replacement: z.string().max(500),
}),
z.object({
kind: z.literal("drop_block_if_contains"),
needles: z.array(z.string().max(500)).max(50),
}),
z.object({
kind: z.literal("prepend_system_block"),
text: z.string().min(1).max(2000),
idempotencyKey: z.string().max(100).optional(),
}),
z.object({
kind: z.literal("append_system_block"),
text: z.string().min(1).max(2000),
idempotencyKey: z.string().max(100).optional(),
}),
z.object({
kind: z.literal("inject_billing_header"),
entrypoint: z.string().min(1).max(50),
versionFormat: z.enum(["ex-machina", "omniroute-daystamp"]),
cchAlgo: z.enum(["sha256-first-user", "xxhash64-body", "static-zero"]),
version: z.string().max(50).optional(),
}),
])
)
.max(50),
})
.optional(),
// System Transforms (issue #2260 v2): generic per-provider DSL covering
// native `claude`, `anthropic-compatible-cc-*` bridge, and any other
// provider key. Adds `obfuscate_words` op kind on top of the base set.
systemTransforms: z
.object({
providers: z.record(
z.string().max(100),
z.object({
enabled: z.boolean(),
pipeline: z
.array(
z.discriminatedUnion("kind", [
z.object({
kind: z.literal("drop_paragraph_if_contains"),
needles: z.array(z.string().max(500)).max(50),
caseSensitive: z.boolean().optional(),
}),
z.object({
kind: z.literal("drop_paragraph_if_starts_with"),
prefixes: z.array(z.string().max(500)).max(50),
caseSensitive: z.boolean().optional(),
}),
z.object({
kind: z.literal("replace_text"),
match: z.string().min(1).max(500),
replacement: z.string().max(500),
allOccurrences: z.boolean().optional(),
}),
z.object({
kind: z.literal("replace_regex"),
pattern: z.string().min(1).max(500),
flags: z.string().max(10).optional(),
replacement: z.string().max(500),
}),
z.object({
kind: z.literal("drop_block_if_contains"),
needles: z.array(z.string().max(500)).max(50),
}),
z.object({
kind: z.literal("prepend_system_block"),
text: z.string().min(1).max(2000),
idempotencyKey: z.string().max(100).optional(),
}),
z.object({
kind: z.literal("append_system_block"),
text: z.string().min(1).max(2000),
idempotencyKey: z.string().max(100).optional(),
}),
z.object({
kind: z.literal("inject_billing_header"),
entrypoint: z.string().min(1).max(50),
versionFormat: z.enum(["ex-machina", "omniroute-daystamp"]),
cchAlgo: z.enum(["sha256-first-user", "xxhash64-body", "static-zero"]),
version: z.string().max(50).optional(),
}),
z.object({
kind: z.literal("obfuscate_words"),
words: z.array(z.string().max(100)).max(200),
targets: z
.array(z.enum(["system", "messages", "tools"]))
.max(3)
.optional(),
}),
])
)
.max(50),
})
),
})
.optional(),
// Strip provider/model prefix at proxy layer (e.g. "openai/gpt-4" → "gpt-4")
stripModelPrefix: z.boolean().optional(),
// Cache control preservation mode
alwaysPreserveClientCache: z.enum(["auto", "always", "never"]).optional(),
antigravitySignatureCacheMode: z.enum(signatureCacheModeValues).optional(),
// Adaptive Volume Routing
adaptiveVolumeRouting: z.boolean().optional(),
// Usage token buffer — safety margin added to reported prompt/input token counts.
// Prevents CLI tools from overrunning context windows. Set to 0 to disable.
usageTokenBuffer: z.number().int().min(0).max(50000).optional(),
// Custom CLI agent definitions for ACP
customAgents: z
.array(
z.object({
id: z.string().max(50),
name: z.string().max(100),
binary: z.string().max(200),
versionCommand: z.string().max(300),
providerAlias: z.string().max(50),
spawnArgs: z.array(z.string().max(200)),
protocol: z.enum(["stdio", "http"]),
})
)
.optional(),
// SkillsMP marketplace API key
skillsmpApiKey: z.string().max(200).optional(),
// Active skills provider (single source of truth for skills page)
skillsProvider: z.enum(["skillsmp", "skillssh"]).optional(),
// models.dev sync settings
modelsDevSyncEnabled: z.boolean().optional(),
modelsDevSyncInterval: z.number().int().min(3600000).max(604800000).optional(),
// Vision Bridge settings
visionBridgeEnabled: z.boolean().optional(),
visionBridgeModel: z.string().max(200).optional(),
visionBridgePrompt: z.string().max(5000).optional(),
visionBridgeTimeout: z.number().int().min(1000).max(300000).optional(),
visionBridgeMaxImages: z.number().int().min(1).max(20).optional(),
// Missing settings
lkgpEnabled: z.boolean().optional(),
backgroundDegradation: z.unknown().optional(),
bruteForceProtection: z.boolean().optional(),
// Auto-routing settings
autoRoutingEnabled: z.boolean().optional(),
autoRoutingDefaultVariant: z
.enum(["lkgp", "coding", "fast", "cheap", "offline", "smart"])
.optional(),
// LOCAL_ONLY manage-scope bypass policy (T-011). Kill-switch + per-prefix
// list, both DB-stored and hot-reloaded into `getAuthzBypassSnapshot()` on
// each PATCH. The prefix list MUST NOT include any spawn-capable path —
// enforced here and at runtime (GHSA-fhh6-4qxv-rpqj rationale).
localOnlyManageScopeBypassEnabled: z.boolean().optional(),
localOnlyManageScopeBypassPrefixes: z.array(z.string().startsWith("/")).max(20).optional(),
})
.superRefine((data, ctx) => {
const prefixes = data.localOnlyManageScopeBypassPrefixes;
if (!prefixes) return;
for (let i = 0; i < prefixes.length; i++) {
const prefix = prefixes[i];
// Reject prefixes that are the same as, child of, or PARENT of any
// spawn-capable prefix. The parent case catches e.g. `/api/cli-tools/`
// — a bypass on the parent would grant non-loopback access to the
// spawn-capable `/api/cli-tools/runtime/*` surface via path.startsWith.
if (
SPAWN_CAPABLE_PREFIXES.some(
(spawn) => prefix === spawn || prefix.startsWith(spawn) || spawn.startsWith(prefix)
export const updateSettingsSchema = z.object({
newPassword: z.string().min(1).max(200).optional(),
currentPassword: z.string().max(200).optional(),
theme: z.string().max(50).optional(),
language: z.string().max(10).optional(),
requireLogin: z.boolean().optional(),
enableSocks5Proxy: z.boolean().optional(),
instanceName: z.string().max(100).optional(),
customLogoUrl: z.string().max(2000).optional(),
customLogoBase64: z.string().max(100000).optional(),
customFaviconUrl: z.string().max(2000).optional(),
customFaviconBase64: z.string().max(50000).optional(),
corsOrigins: z.string().max(500).optional(),
cloudUrl: z.string().max(500).optional(),
baseUrl: z.string().max(500).optional(),
setupComplete: z.boolean().optional(),
blockedProviders: z.array(z.string().max(100)).optional(),
hideHealthCheckLogs: z.boolean().optional(),
hideEndpointCloudflaredTunnel: z.boolean().optional(),
hideEndpointTailscaleFunnel: z.boolean().optional(),
hideEndpointNgrokTunnel: z.boolean().optional(),
debugMode: z.boolean().optional(),
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
sidebarSectionOrder: z
.array(z.enum(SIDEBAR_SECTIONS.map((s) => s.id) as [string, ...string[]]))
.optional(),
sidebarItemOrder: z.record(z.string(), z.array(z.string().max(100))).optional(),
sidebarActivePreset: z.enum(["all", "minimal", "developer", "admin"]).nullable().optional(),
comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(),
codexServiceTier: z
.object({
enabled: z.boolean().optional(),
tier: z.enum(["default", "priority", "flex"]).optional(),
supportedModels: z.array(z.string().max(200)).max(200).optional(),
})
.optional(),
// Claude Fast Mode: opt-in toggle that asks a paired CLIProxyAPI build
// (claude-fastmode-spoof) to rewrite SDK-shaped entrypoints so requests can
// reach Anthropic Fast Mode (speed:"fast"). Default off; only the listed
// Opus models are gated by the Anthropic binary KT() check. Schema is
// intentionally permissive on supportedModels so additional eligible model
// ids can be enabled without a schema bump.
claudeFastMode: z
.object({
enabled: z.boolean().optional(),
supportedModels: z.array(z.string().max(200)).max(200).optional(),
})
.optional(),
// Routing settings (#134)
fallbackStrategy: z.enum(ACCOUNT_FALLBACK_STRATEGY_VALUES).optional(),
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
stickyRoundRobinLimit: z.number().int().min(0).max(1000).optional(),
requestRetry: z.number().int().min(0).max(10).optional(),
maxRetryIntervalSec: z.number().int().min(0).max(300).optional(),
maxBodySizeMb: z
.number()
.int()
.min(MIN_REQUEST_BODY_LIMIT_MB)
.max(MAX_REQUEST_BODY_LIMIT_MB)
.optional(),
// Auto intent classifier settings (multilingual routing)
intentDetectionEnabled: z.boolean().optional(),
intentSimpleMaxWords: z.number().int().min(1).max(500).optional(),
intentExtraCodeKeywords: z.array(z.string().max(100)).optional(),
intentExtraReasoningKeywords: z.array(z.string().max(100)).optional(),
intentExtraSimpleKeywords: z.array(z.string().max(100)).optional(),
// Protocol toggles (default: disabled)
mcpEnabled: z.boolean().optional(),
mcpTransport: z.enum(["stdio", "sse", "streamable-http"]).optional(),
a2aEnabled: z.boolean().optional(),
wsAuth: z.boolean().optional(),
// CLI Fingerprint compatibility (per-provider)
cliCompatProviders: z.array(z.string().max(100)).optional(),
// CC bridge transforms (issue #2260): config-driven pipeline that normalizes
// system blocks at the Claude Code bridge so any client (OpenCode, Cline,
// Cursor, Continue, raw API) ends up with classifier-correct structure.
ccBridgeTransforms: z
.object({
enabled: z.boolean(),
pipeline: z
.array(
z.discriminatedUnion("kind", [
z.object({
kind: z.literal("drop_paragraph_if_contains"),
needles: z.array(z.string().max(500)).max(50),
caseSensitive: z.boolean().optional(),
}),
z.object({
kind: z.literal("drop_paragraph_if_starts_with"),
prefixes: z.array(z.string().max(500)).max(50),
caseSensitive: z.boolean().optional(),
}),
z.object({
kind: z.literal("replace_text"),
match: z.string().min(1).max(500),
replacement: z.string().max(500),
allOccurrences: z.boolean().optional(),
}),
z.object({
kind: z.literal("replace_regex"),
pattern: z.string().min(1).max(500),
flags: z.string().max(10).optional(),
replacement: z.string().max(500),
}),
z.object({
kind: z.literal("drop_block_if_contains"),
needles: z.array(z.string().max(500)).max(50),
}),
z.object({
kind: z.literal("prepend_system_block"),
text: z.string().min(1).max(2000),
idempotencyKey: z.string().max(100).optional(),
}),
z.object({
kind: z.literal("append_system_block"),
text: z.string().min(1).max(2000),
idempotencyKey: z.string().max(100).optional(),
}),
z.object({
kind: z.literal("inject_billing_header"),
entrypoint: z.string().min(1).max(50),
versionFormat: z.enum(["ex-machina", "omniroute-daystamp"]),
cchAlgo: z.enum(["sha256-first-user", "xxhash64-body", "static-zero"]),
version: z.string().max(50).optional(),
}),
])
)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["localOnlyManageScopeBypassPrefixes", i],
message: `BYPASS_PREFIX_NOT_ALLOWED: ${prefix} is spawn-capable and cannot be bypassed`,
params: { code: "BYPASS_PREFIX_NOT_ALLOWED", prefix },
});
}
}
});
.max(50),
})
.optional(),
// System Transforms (issue #2260 v2): generic per-provider DSL covering
// native `claude`, `anthropic-compatible-cc-*` bridge, and any other
// provider key. Adds `obfuscate_words` op kind on top of the base set.
systemTransforms: z
.object({
providers: z.record(
z.string().max(100),
z.object({
enabled: z.boolean(),
pipeline: z
.array(
z.discriminatedUnion("kind", [
z.object({
kind: z.literal("drop_paragraph_if_contains"),
needles: z.array(z.string().max(500)).max(50),
caseSensitive: z.boolean().optional(),
}),
z.object({
kind: z.literal("drop_paragraph_if_starts_with"),
prefixes: z.array(z.string().max(500)).max(50),
caseSensitive: z.boolean().optional(),
}),
z.object({
kind: z.literal("replace_text"),
match: z.string().min(1).max(500),
replacement: z.string().max(500),
allOccurrences: z.boolean().optional(),
}),
z.object({
kind: z.literal("replace_regex"),
pattern: z.string().min(1).max(500),
flags: z.string().max(10).optional(),
replacement: z.string().max(500),
}),
z.object({
kind: z.literal("drop_block_if_contains"),
needles: z.array(z.string().max(500)).max(50),
}),
z.object({
kind: z.literal("prepend_system_block"),
text: z.string().min(1).max(2000),
idempotencyKey: z.string().max(100).optional(),
}),
z.object({
kind: z.literal("append_system_block"),
text: z.string().min(1).max(2000),
idempotencyKey: z.string().max(100).optional(),
}),
z.object({
kind: z.literal("inject_billing_header"),
entrypoint: z.string().min(1).max(50),
versionFormat: z.enum(["ex-machina", "omniroute-daystamp"]),
cchAlgo: z.enum(["sha256-first-user", "xxhash64-body", "static-zero"]),
version: z.string().max(50).optional(),
}),
z.object({
kind: z.literal("obfuscate_words"),
words: z.array(z.string().max(100)).max(200),
targets: z
.array(z.enum(["system", "messages", "tools"]))
.max(3)
.optional(),
}),
])
)
.max(50),
})
),
})
.optional(),
// Strip provider/model prefix at proxy layer (e.g. "openai/gpt-4" → "gpt-4")
stripModelPrefix: z.boolean().optional(),
// Cache control preservation mode
alwaysPreserveClientCache: z.enum(["auto", "always", "never"]).optional(),
antigravitySignatureCacheMode: z.enum(signatureCacheModeValues).optional(),
// Adaptive Volume Routing
adaptiveVolumeRouting: z.boolean().optional(),
// Usage token buffer — safety margin added to reported prompt/input token counts.
// Prevents CLI tools from overrunning context windows. Set to 0 to disable.
usageTokenBuffer: z.number().int().min(0).max(50000).optional(),
// Custom CLI agent definitions for ACP
customAgents: z
.array(
z.object({
id: z.string().max(50),
name: z.string().max(100),
binary: z.string().max(200),
versionCommand: z.string().max(300),
providerAlias: z.string().max(50),
spawnArgs: z.array(z.string().max(200)),
protocol: z.enum(["stdio", "http"]),
})
)
.optional(),
// SkillsMP marketplace API key
skillsmpApiKey: z.string().max(200).optional(),
// Active skills provider (single source of truth for skills page)
skillsProvider: z.enum(["skillsmp", "skillssh"]).optional(),
// models.dev sync settings
modelsDevSyncEnabled: z.boolean().optional(),
modelsDevSyncInterval: z.number().int().min(3600000).max(604800000).optional(),
// Vision Bridge settings
visionBridgeEnabled: z.boolean().optional(),
visionBridgeModel: z.string().max(200).optional(),
visionBridgePrompt: z.string().max(5000).optional(),
visionBridgeTimeout: z.number().int().min(1000).max(300000).optional(),
visionBridgeMaxImages: z.number().int().min(1).max(20).optional(),
// Missing settings
lkgpEnabled: z.boolean().optional(),
backgroundDegradation: z.unknown().optional(),
bruteForceProtection: z.boolean().optional(),
// Auto-routing settings
autoRoutingEnabled: z.boolean().optional(),
autoRoutingDefaultVariant: z
.enum(["lkgp", "coding", "fast", "cheap", "offline", "smart"])
.optional(),
});
export const databaseSettingsSchema = z.object(
{

View File

@@ -1,4 +1,9 @@
import type { HideableSidebarItemId } from "@/shared/constants/sidebarVisibility";
import type {
HideableSidebarItemId,
SidebarItemOrder,
SidebarPresetId,
SidebarSectionId,
} from "@/shared/constants/sidebarVisibility";
import type { ResilienceSettings } from "@/lib/resilience/settings";
import type {
AccountFallbackStrategyValue,
@@ -28,6 +33,9 @@ export interface Settings {
showQuickStartOnHome?: boolean;
showProviderTopologyOnHome?: boolean;
hiddenSidebarItems?: HideableSidebarItemId[];
sidebarSectionOrder?: SidebarSectionId[];
sidebarItemOrder?: SidebarItemOrder;
sidebarActivePreset?: SidebarPresetId;
resilienceSettings?: ResilienceSettings;
// LOCAL_ONLY manage-scope bypass policy (DB-stored, hot-reloaded by
// `applyRuntimeSettings` → `applyAuthzBypassSection`). The route guard

View File

@@ -0,0 +1,152 @@
import test from "node:test";
import assert from "node:assert/strict";
const sidebarVisibility = await import("../../src/shared/constants/sidebarVisibility.ts");
const {
HIDEABLE_SIDEBAR_ITEM_IDS,
SIDEBAR_SECTIONS,
SIDEBAR_PRESETS,
applySectionOrder,
applyItemOrder,
normalizeHiddenSidebarItems,
} = sidebarVisibility;
// ─── applySectionOrder ────────────────────────────────────────────────────────
test("applySectionOrder returns original order when order is empty", () => {
const sections = [...SIDEBAR_SECTIONS];
const result = applySectionOrder(sections, []);
assert.deepEqual(
result.map((s) => s.id),
sections.map((s) => s.id)
);
});
test("applySectionOrder reorders sections by provided list", () => {
const sections = [...SIDEBAR_SECTIONS].slice(0, 4);
const ids = sections.map((s) => s.id) as any[];
const reversed = [...ids].reverse();
const result = applySectionOrder(sections, reversed);
assert.deepEqual(
result.map((s) => s.id),
reversed
);
});
test("applySectionOrder ignores unknown section IDs in order", () => {
const sections = [...SIDEBAR_SECTIONS].slice(0, 3);
const ids = sections.map((s) => s.id) as any[];
const orderWithUnknown = ["totally-unknown-section" as any, ids[1], ids[0], ids[2]];
const result = applySectionOrder(sections, orderWithUnknown);
// unknown ID is filtered; remaining IDs applied, then the rest appended
assert.equal(result[0].id, ids[1]);
assert.equal(result[1].id, ids[0]);
assert.equal(result[2].id, ids[2]);
});
test("applySectionOrder appends sections not in order list at end", () => {
const sections = [...SIDEBAR_SECTIONS].slice(0, 3);
const ids = sections.map((s) => s.id) as any[];
// Only order the first two
const result = applySectionOrder(sections, [ids[2], ids[0]]);
assert.equal(result[0].id, ids[2]);
assert.equal(result[1].id, ids[0]);
assert.equal(result[2].id, ids[1]); // appended at end
});
// ─── applyItemOrder ───────────────────────────────────────────────────────────
test("applyItemOrder returns original children when order is empty", () => {
const section = SIDEBAR_SECTIONS.find((s) => s.id === "omni-proxy")!;
const children = [...section.children];
const result = applyItemOrder(children, []);
assert.deepEqual(result.length, children.length);
});
test("applyItemOrder reorders items by provided list", () => {
const section = SIDEBAR_SECTIONS.find((s) => s.id === "help")!;
const children = [...section.children] as any[];
const ids = children.map((c) => c.id);
const reversed = [...ids].reverse();
const result = applyItemOrder(children, reversed) as any[];
assert.deepEqual(
result.map((c) => c.id),
reversed
);
});
test("applyItemOrder ignores unknown IDs in order list", () => {
const section = SIDEBAR_SECTIONS.find((s) => s.id === "help")!;
const children = [...section.children] as any[];
const ids = children.map((c) => c.id);
const orderWithUnknown = ["ghost-item", ids[1], ids[0], ids[2]];
const result = applyItemOrder(children, orderWithUnknown) as any[];
assert.equal(result[0].id, ids[1]);
assert.equal(result[1].id, ids[0]);
assert.equal(result[2].id, ids[2]);
});
// ─── SIDEBAR_PRESETS ──────────────────────────────────────────────────────────
test("SIDEBAR_PRESETS contains all four preset IDs", () => {
const ids = SIDEBAR_PRESETS.map((p) => p.id);
assert.ok(ids.includes("all"), "expected 'all' preset");
assert.ok(ids.includes("minimal"), "expected 'minimal' preset");
assert.ok(ids.includes("developer"), "expected 'developer' preset");
assert.ok(ids.includes("admin"), "expected 'admin' preset");
});
test("SIDEBAR_PRESETS all preset hiddenItems are valid HIDEABLE_SIDEBAR_ITEM_IDS", () => {
const validIds = new Set(HIDEABLE_SIDEBAR_ITEM_IDS);
for (const preset of SIDEBAR_PRESETS) {
for (const id of preset.hiddenItems) {
assert.ok(validIds.has(id as any), `Preset '${preset.id}' contains invalid item ID: '${id}'`);
}
}
});
test("SIDEBAR_PRESETS 'all' preset has no hidden items", () => {
const allPreset = SIDEBAR_PRESETS.find((p) => p.id === "all");
assert.ok(allPreset, "expected 'all' preset to exist");
assert.deepEqual(allPreset.hiddenItems, []);
});
test("SIDEBAR_PRESETS non-all presets have at least one hidden item", () => {
for (const preset of SIDEBAR_PRESETS.filter((p) => p.id !== "all")) {
assert.ok(preset.hiddenItems.length > 0, `Preset '${preset.id}' should hide at least one item`);
}
});
// ─── settings-sidebar ID ─────────────────────────────────────────────────────
test("settings-sidebar is in HIDEABLE_SIDEBAR_ITEM_IDS", () => {
assert.ok(
HIDEABLE_SIDEBAR_ITEM_IDS.includes("settings-sidebar" as any),
"settings-sidebar should be hideable"
);
});
test("settings-sidebar item is present in configuration section", () => {
const configSection = SIDEBAR_SECTIONS.find((s) => s.id === "configuration");
assert.ok(configSection, "configuration section should exist");
const items = configSection.children.flatMap((c) =>
"type" in c && c.type === "group" ? c.items : [c as any]
);
assert.ok(
items.some((item) => item.id === "settings-sidebar"),
"settings-sidebar should be in configuration section children"
);
});
// ─── normalizeHiddenSidebarItems ─────────────────────────────────────────────
test("normalizeHiddenSidebarItems accepts settings-sidebar", () => {
const result = normalizeHiddenSidebarItems(["settings-sidebar"]);
assert.deepEqual(result, ["settings-sidebar"]);
});
test("normalizeHiddenSidebarItems drops unknown IDs", () => {
const result = normalizeHiddenSidebarItems(["settings-sidebar", "ghost-id-xyz"]);
assert.deepEqual(result, ["settings-sidebar"]);
});