2031 lines
77 KiB
TypeScript
2031 lines
77 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-only
|
|
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
import {
|
|
Alert,
|
|
AlertDescription,
|
|
AlertTitle,
|
|
} from "@/components/ui/alert";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import {
|
|
InputGroup,
|
|
InputGroupAddon,
|
|
InputGroupInput,
|
|
} from "@/components/ui/input-group";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from "@/components/ui/sheet";
|
|
import { Slider } from "@/components/ui/slider";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { useIsMobile } from "@/hooks/use-mobile";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
ArrowDown01Icon,
|
|
ArrowTurnBackwardIcon,
|
|
InformationCircleIcon,
|
|
LayoutAlignRightIcon,
|
|
} from "@hugeicons/core-free-icons";
|
|
import { HugeiconsIcon } from "@hugeicons/react";
|
|
import { ChevronDown } from "lucide-react";
|
|
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
|
import { Fragment, type ReactNode } from "react";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { toast } from "@/lib/toast";
|
|
import { OpenAICodeExecSection } from "./components/openai-code-exec-section";
|
|
import {
|
|
type ExternalProviderConfig,
|
|
getExternalProviderApiKey,
|
|
parseExternalModelId,
|
|
supportsProviderPromptCaching,
|
|
supportsProviderPromptCacheTtl,
|
|
} from "./external-providers";
|
|
import {
|
|
BUILTIN_PRESETS,
|
|
BUILTIN_PRESET_NAMES,
|
|
applyPresetParams,
|
|
getBuiltinVariantName,
|
|
getOrderedPresets,
|
|
getPresetSaveState,
|
|
getPresetSource,
|
|
isSamePresetConfig,
|
|
toPresetParams,
|
|
} from "./presets/preset-policy";
|
|
import {
|
|
EXTERNAL_MAX_OUTPUT_TOKENS,
|
|
type ProviderCapabilities,
|
|
getExternalMinOutputTokens,
|
|
providerSupportsBuiltinCodeExecution,
|
|
} from "./provider-capabilities";
|
|
import { useAui } from "@assistant-ui/react";
|
|
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
|
|
import type { InferenceParams } from "./types/runtime";
|
|
import type { RagMode, RagSource } from "./api/chat-settings-api";
|
|
import { DocumentRow } from "@/features/rag/components/document-row";
|
|
import { KBCreateDialog } from "@/features/rag/components/kb-create-dialog";
|
|
import { useKnowledgeBases } from "@/features/rag/hooks/use-knowledge-bases";
|
|
import { useThreadDocuments } from "@/features/rag/hooks/use-kb-documents";
|
|
import { useRagStore } from "@/features/rag/stores/rag-store";
|
|
import type {
|
|
ChunkingStrategy as RagChunkingStrategy,
|
|
KBMode,
|
|
} from "@/features/rag/api/rag-api";
|
|
import { Add01Icon, Delete02Icon } from "@hugeicons/core-free-icons";
|
|
|
|
function ragSourceLabel(
|
|
source: RagSource,
|
|
kbs: { id: string; name: string }[],
|
|
): string {
|
|
if (source.kind === "off") return "Off";
|
|
if (source.kind === "thread") return "This thread's documents";
|
|
const kb = kbs.find((k) => k.id === source.kbId);
|
|
return kb ? kb.name : "Knowledge base (missing)";
|
|
}
|
|
|
|
export { defaultInferenceParams, type Preset } from "./presets/preset-policy";
|
|
export type { InferenceParams } from "./types/runtime";
|
|
|
|
function canUseStorage(): boolean {
|
|
return typeof window !== "undefined";
|
|
}
|
|
|
|
export function InfoHint({ children }: { children: ReactNode }) {
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
aria-label="More info"
|
|
className="inline-flex size-4 shrink-0 cursor-help items-center justify-center rounded-full text-muted-foreground/70 transition-colors hover:text-[#383835] dark:hover:text-[#e8e8e8] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<HugeiconsIcon
|
|
icon={InformationCircleIcon}
|
|
strokeWidth={1.75}
|
|
className="size-3.5"
|
|
/>
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent
|
|
side="left"
|
|
sideOffset={8}
|
|
className="tooltip-compact [&_span>svg]:hidden! duration-0 max-w-64"
|
|
>
|
|
{children}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Editable numeric value display.
|
|
*
|
|
* Renders as a single <input> that *looks* like text by default —
|
|
* transparent background, no border, no ring — and only shows a faint
|
|
* surface tint on hover/focus to signal editability. When unfocused,
|
|
* the input shows the formatted display string (`displayValue ?? value`,
|
|
* so labels like "Off" / "Max" still render); on focus, it switches to
|
|
* the raw numeric value, selects it, and accepts free text input.
|
|
* Commit happens on blur or Enter; Escape reverts. The clamp-to-range
|
|
* happens on commit so users can type intermediate values without the
|
|
* input fighting them mid-keystroke. Single component shared by every
|
|
* slider value and the Context Length input so the click-to-edit
|
|
* affordance is consistent across the panel.
|
|
*/
|
|
function snapToStep(
|
|
value: number,
|
|
step: number,
|
|
min?: number,
|
|
max?: number,
|
|
): number {
|
|
const lo = min ?? Number.NEGATIVE_INFINITY;
|
|
const hi = max ?? Number.POSITIVE_INFINITY;
|
|
const clamped = Math.min(Math.max(value, lo), hi);
|
|
const stepStr = String(step);
|
|
const decimals = stepStr.includes(".") ? stepStr.split(".")[1].length : 0;
|
|
const base = Number.isFinite(lo) ? lo : 0;
|
|
const snapped = base + Math.round((clamped - base) / step) * step;
|
|
const reclamped = Math.min(Math.max(snapped, lo), hi);
|
|
return Number(reclamped.toFixed(decimals));
|
|
}
|
|
|
|
function NumericValueInput({
|
|
value,
|
|
min,
|
|
max,
|
|
step,
|
|
onChange,
|
|
displayValue,
|
|
className,
|
|
ariaLabel,
|
|
size: sizeAttr,
|
|
}: {
|
|
value: number;
|
|
min?: number;
|
|
max?: number;
|
|
step: number;
|
|
onChange: (v: number) => void;
|
|
displayValue?: string;
|
|
className?: string;
|
|
ariaLabel?: string;
|
|
size?: number;
|
|
}) {
|
|
const [focused, setFocused] = useState(false);
|
|
const [draft, setDraft] = useState("");
|
|
const cancelBlurCommitRef = useRef(false);
|
|
|
|
const commit = (raw: string) => {
|
|
const parsed = Number.parseFloat(raw);
|
|
if (!Number.isFinite(parsed)) {
|
|
return;
|
|
}
|
|
const final = snapToStep(parsed, step, min, max);
|
|
if (final !== value) {
|
|
onChange(final);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<input
|
|
type="text"
|
|
inputMode="decimal"
|
|
size={sizeAttr}
|
|
value={focused ? draft : (displayValue ?? String(value))}
|
|
aria-label={ariaLabel}
|
|
onFocus={(e) => {
|
|
cancelBlurCommitRef.current = false;
|
|
setDraft(String(value));
|
|
setFocused(true);
|
|
// Defer the select() so it runs after the value swap above.
|
|
const target = e.currentTarget;
|
|
requestAnimationFrame(() => target.select());
|
|
}}
|
|
onBlur={() => {
|
|
if (cancelBlurCommitRef.current) {
|
|
cancelBlurCommitRef.current = false;
|
|
} else {
|
|
commit(draft);
|
|
}
|
|
setFocused(false);
|
|
}}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.currentTarget.blur();
|
|
} else if (e.key === "Escape") {
|
|
cancelBlurCommitRef.current = true;
|
|
setDraft(String(value));
|
|
e.currentTarget.blur();
|
|
}
|
|
}}
|
|
className={cn("panel-number-input", className)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ParamSlider({
|
|
label,
|
|
value,
|
|
min,
|
|
max,
|
|
step,
|
|
onChange,
|
|
displayValue,
|
|
info,
|
|
valueSize,
|
|
}: {
|
|
label: string;
|
|
value: number;
|
|
min: number;
|
|
max: number;
|
|
step: number;
|
|
onChange: (v: number) => void;
|
|
displayValue?: string;
|
|
info?: ReactNode;
|
|
valueSize?: number;
|
|
}) {
|
|
return (
|
|
<div className="space-y-3.5">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
|
|
{label}
|
|
</span>
|
|
{info && <InfoHint>{info}</InfoHint>}
|
|
</div>
|
|
<NumericValueInput
|
|
value={value}
|
|
min={min}
|
|
max={max}
|
|
step={step}
|
|
onChange={onChange}
|
|
displayValue={displayValue}
|
|
ariaLabel={label}
|
|
size={valueSize ?? 6}
|
|
/>
|
|
</div>
|
|
<Slider
|
|
min={min}
|
|
max={max}
|
|
step={step}
|
|
value={[value]}
|
|
onValueChange={([v]) => onChange(snapToStep(v, step, min, max))}
|
|
className="panel-slider"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const COLLAPSIBLE_STATE_KEY = "unsloth_chat_collapsible_state";
|
|
|
|
function loadCollapsibleState(): Record<string, boolean> {
|
|
if (!canUseStorage()) return {};
|
|
try {
|
|
const raw = localStorage.getItem(COLLAPSIBLE_STATE_KEY);
|
|
if (!raw) return {};
|
|
const parsed = JSON.parse(raw);
|
|
if (
|
|
typeof parsed !== "object" ||
|
|
parsed === null ||
|
|
Array.isArray(parsed)
|
|
) {
|
|
return {};
|
|
}
|
|
return Object.fromEntries(
|
|
Object.entries(parsed).filter(
|
|
(entry): entry is [string, boolean] => typeof entry[1] === "boolean",
|
|
),
|
|
);
|
|
} catch (error) {
|
|
console.warn("Failed to load collapsible state from localStorage:", error);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function saveCollapsibleOpen(label: string, open: boolean) {
|
|
if (!canUseStorage()) return;
|
|
try {
|
|
const state = loadCollapsibleState();
|
|
state[label] = open;
|
|
localStorage.setItem(COLLAPSIBLE_STATE_KEY, JSON.stringify(state));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function CollapsibleSection({
|
|
label,
|
|
children,
|
|
defaultOpen = false,
|
|
first = false,
|
|
}: {
|
|
label: string;
|
|
children?: ReactNode;
|
|
defaultOpen?: boolean;
|
|
first?: boolean;
|
|
}) {
|
|
const [open, setOpen] = useState(() => {
|
|
const saved = loadCollapsibleState();
|
|
return Object.hasOwn(saved, label) ? saved[label] : defaultOpen;
|
|
});
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
!first &&
|
|
"border-t border-black/[0.13] dark:border-white/[0.09]",
|
|
)}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const next = !open;
|
|
setOpen(next);
|
|
saveCollapsibleOpen(label, next);
|
|
}}
|
|
className={cn(
|
|
"flex w-full cursor-pointer items-center justify-between text-[12px] font-medium normal-case tracking-[0.04em] text-nav-fg-muted transition-colors hover:text-nav-fg focus-visible:outline-none focus-visible:ring-0",
|
|
first ? "pt-4 pb-5" : "py-5",
|
|
)}
|
|
>
|
|
<span className="leading-none">{label}</span>
|
|
<span className="flex shrink-0 items-center leading-none">
|
|
<ChevronDown
|
|
className={cn("size-3.5", open ? "rotate-0" : "-rotate-90")}
|
|
/>
|
|
</span>
|
|
</button>
|
|
{open && <div className="pb-7">{children}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ChatSettingsPanelProps {
|
|
open: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
params: InferenceParams;
|
|
onParamsChange: (params: InferenceParams) => void;
|
|
isExternalModel?: boolean;
|
|
/**
|
|
* Sampling-param capability set for the active external provider, or `null`
|
|
* for local models (in which case every knob is rendered). Drives the
|
|
* per-param visibility in the sampling section.
|
|
*/
|
|
providerCapabilities?: ProviderCapabilities | null;
|
|
activeExternalProvider?: ExternalProviderConfig | null;
|
|
onExternalProviderChange?: (provider: ExternalProviderConfig) => void;
|
|
/**
|
|
* Backend provider type for the active external model (e.g. "kimi",
|
|
* "anthropic", "openai"), or `null` for local models. Drives the
|
|
* per-provider Max Tokens floor in the slider.
|
|
*/
|
|
externalProviderType?: string | null;
|
|
onReloadModel?: () => void;
|
|
}
|
|
|
|
export function ChatSettingsPanel({
|
|
open,
|
|
onOpenChange,
|
|
params,
|
|
onParamsChange,
|
|
isExternalModel = false,
|
|
providerCapabilities = null,
|
|
activeExternalProvider = null,
|
|
onExternalProviderChange,
|
|
externalProviderType = null,
|
|
onReloadModel,
|
|
}: ChatSettingsPanelProps) {
|
|
// For non-external (local) models we show every knob — providerCapabilities
|
|
// is only consulted when `isExternalModel` is true. An external model with an
|
|
// unknown provider falls back to the OpenAI-compat shape via
|
|
// getProviderCapabilities, so these flags never undercount support.
|
|
const showTemperature =
|
|
!isExternalModel || Boolean(providerCapabilities?.temperature);
|
|
const showTopP = !isExternalModel || Boolean(providerCapabilities?.topP);
|
|
const showTopK = !isExternalModel || Boolean(providerCapabilities?.topK);
|
|
const showMinP = !isExternalModel || Boolean(providerCapabilities?.minP);
|
|
const showRepetitionPenalty =
|
|
!isExternalModel || Boolean(providerCapabilities?.repetitionPenalty);
|
|
const showPresencePenalty =
|
|
!isExternalModel || Boolean(providerCapabilities?.presencePenalty);
|
|
const isMobile = useIsMobile();
|
|
const isGguf = useChatRuntimeStore((s) => s.activeGgufVariant) != null;
|
|
const ragSource = useChatRuntimeStore((s) => s.ragSource);
|
|
const setRagSource = useChatRuntimeStore((s) => s.setRagSource);
|
|
const ragMode = useChatRuntimeStore((s) => s.ragMode);
|
|
const setRagMode = useChatRuntimeStore((s) => s.setRagMode);
|
|
const ragToolEnabled = useChatRuntimeStore((s) => s.ragToolEnabled);
|
|
const enableRerank = useChatRuntimeStore((s) => s.enableRerank);
|
|
const setEnableRerank = useChatRuntimeStore((s) => s.setEnableRerank);
|
|
const ragTopK = useChatRuntimeStore((s) => s.ragTopK);
|
|
const setRagTopK = useChatRuntimeStore((s) => s.setRagTopK);
|
|
const ragMinScore = useChatRuntimeStore((s) => s.ragMinScore);
|
|
const setRagMinScore = useChatRuntimeStore((s) => s.setRagMinScore);
|
|
const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
|
|
const { knowledgeBases, deleteKB } = useKnowledgeBases();
|
|
const { documents: threadDocs, remove: removeThreadDoc } = useThreadDocuments(
|
|
ragSource.kind === "thread" ? activeThreadId : null,
|
|
);
|
|
const clearThreadIndex = useRagStore((s) => s.clearThreadIndex);
|
|
const reingestThread = useRagStore((s) => s.reingestThread);
|
|
const threadSettings = useRagStore((s) =>
|
|
activeThreadId ? s.threadSettings[activeThreadId] : undefined,
|
|
);
|
|
const loadThreadSettings = useRagStore((s) => s.loadThreadSettings);
|
|
const updateThreadSettings = useRagStore((s) => s.updateThreadSettings);
|
|
const ragDefaults = useRagStore((s) => s.defaults);
|
|
|
|
// Load thread RAG settings once per threadId. Ref-guarded; keep
|
|
// `threadSettings` out of deps to avoid post-load update loops.
|
|
const threadSettingsLoadedRef = useRef<string | null>(null);
|
|
useEffect(() => {
|
|
if (
|
|
ragSource.kind === "thread"
|
|
&& activeThreadId
|
|
&& threadSettingsLoadedRef.current !== activeThreadId
|
|
) {
|
|
threadSettingsLoadedRef.current = activeThreadId;
|
|
void loadThreadSettings(activeThreadId);
|
|
}
|
|
}, [ragSource.kind, activeThreadId, loadThreadSettings]);
|
|
|
|
const effectiveThreadChunking: RagChunkingStrategy =
|
|
threadSettings?.chunking_strategy ??
|
|
ragDefaults?.chunking_strategy ??
|
|
"standard";
|
|
const effectiveThreadMode: KBMode =
|
|
threadSettings?.mode ?? ragDefaults?.mode ?? "text";
|
|
|
|
const aui = useAui();
|
|
// Brand-new chat has no backend thread yet — initialize the local
|
|
// assistant-ui thread to mint a remoteId so per-thread RAG settings
|
|
// can be saved before the user sends a message or attaches a file.
|
|
const ensureThreadId = async (): Promise<string | null> => {
|
|
const stored = useChatRuntimeStore.getState().activeThreadId;
|
|
if (stored) return stored;
|
|
try {
|
|
const runtime = aui.threads().__internal_getAssistantRuntime?.();
|
|
if (!runtime) return null;
|
|
const localId = runtime.threads.getState().mainThreadId;
|
|
if (!localId) return null;
|
|
const { remoteId } = await runtime.threads
|
|
.getItemById(localId)
|
|
.initialize();
|
|
useChatRuntimeStore.getState().setActiveThreadId(remoteId);
|
|
return remoteId;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const applyThreadSettingChange = (
|
|
patch: { chunking_strategy?: RagChunkingStrategy; mode?: KBMode },
|
|
) => {
|
|
void (async () => {
|
|
const threadId = await ensureThreadId();
|
|
if (!threadId) return;
|
|
if (threadDocs.length === 0) {
|
|
void updateThreadSettings(threadId, patch);
|
|
return;
|
|
}
|
|
const ok = window.confirm(
|
|
`Re-index ${threadDocs.length} document${threadDocs.length === 1 ? "" : "s"} ` +
|
|
`with the new settings? Existing chunks will be deleted and rebuilt.`,
|
|
);
|
|
if (ok) {
|
|
void reingestThread(threadId, patch);
|
|
} else {
|
|
// User declined: refresh so the select snaps back.
|
|
void loadThreadSettings(threadId);
|
|
}
|
|
})();
|
|
};
|
|
const [kbCreateOpen, setKbCreateOpen] = useState(false);
|
|
const ragEnabled = ragSource.kind !== "off";
|
|
const activeKbId = ragSource.kind === "kb" ? ragSource.kbId : null;
|
|
const hasModelContent =
|
|
!isExternalModel && (isGguf || Boolean(params.checkpoint));
|
|
const speculativeType = useChatRuntimeStore((s) => s.speculativeType);
|
|
const setSpeculativeType = useChatRuntimeStore((s) => s.setSpeculativeType);
|
|
const loadedSpeculativeType = useChatRuntimeStore(
|
|
(s) => s.loadedSpeculativeType,
|
|
);
|
|
const specDraftNMax = useChatRuntimeStore((s) => s.specDraftNMax);
|
|
const setSpecDraftNMax = useChatRuntimeStore((s) => s.setSpecDraftNMax);
|
|
const loadedSpecDraftNMax = useChatRuntimeStore(
|
|
(s) => s.loadedSpecDraftNMax,
|
|
);
|
|
const modelRequiresTrustRemoteCode = useChatRuntimeStore(
|
|
(s) => s.modelRequiresTrustRemoteCode,
|
|
);
|
|
const currentCheckpoint = params.checkpoint;
|
|
const currentModelIsMultimodal = useChatRuntimeStore((s) => {
|
|
if (s.loadedIsMultimodal) return true;
|
|
const m = s.models.find((m) => m.id === currentCheckpoint);
|
|
return (
|
|
Boolean(m?.isVision) ||
|
|
Boolean(m?.isAudio) ||
|
|
Boolean(m?.hasAudioInput) ||
|
|
m?.audioType === "audio_vlm"
|
|
);
|
|
});
|
|
const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength);
|
|
const ggufMaxContextLength = useChatRuntimeStore(
|
|
(s) => s.ggufMaxContextLength,
|
|
);
|
|
const ggufNativeContextLength = useChatRuntimeStore(
|
|
(s) => s.ggufNativeContextLength,
|
|
);
|
|
const kvCacheDtype = useChatRuntimeStore((s) => s.kvCacheDtype);
|
|
const setKvCacheDtype = useChatRuntimeStore((s) => s.setKvCacheDtype);
|
|
const loadedKvCacheDtype = useChatRuntimeStore((s) => s.loadedKvCacheDtype);
|
|
const customContextLength = useChatRuntimeStore((s) => s.customContextLength);
|
|
const setCustomContextLength = useChatRuntimeStore(
|
|
(s) => s.setCustomContextLength,
|
|
);
|
|
const setActivePresetSource = useChatRuntimeStore(
|
|
(s) => s.setActivePresetSource,
|
|
);
|
|
const activePresetSource = useChatRuntimeStore((s) => s.activePresetSource);
|
|
const customPresets = useChatRuntimeStore((s) => s.customPresets);
|
|
const setCustomPresets = useChatRuntimeStore((s) => s.setCustomPresets);
|
|
const activePreset = useChatRuntimeStore((s) => s.activePreset);
|
|
const setActivePreset = useChatRuntimeStore((s) => s.setActivePreset);
|
|
const settingsHydrated = useChatRuntimeStore((s) => s.settingsHydrated);
|
|
|
|
const ctxDisplayValue = customContextLength ?? ggufContextLength ?? "";
|
|
const ctxMaxValue = ggufNativeContextLength ?? ggufContextLength ?? null;
|
|
const kvDirty = kvCacheDtype !== loadedKvCacheDtype;
|
|
const ctxDirty = customContextLength !== null;
|
|
const specDirty = speculativeType !== loadedSpeculativeType;
|
|
const specDraftDirty = specDraftNMax !== loadedSpecDraftNMax;
|
|
const modelSettingsDirty = kvDirty || ctxDirty || specDirty || specDraftDirty;
|
|
const chatTemplateOverride = useChatRuntimeStore(
|
|
(s) => s.chatTemplateOverride,
|
|
);
|
|
const loadedChatTemplateOverride = useChatRuntimeStore(
|
|
(s) => s.loadedChatTemplateOverride,
|
|
);
|
|
const setChatTemplateOverride = useChatRuntimeStore(
|
|
(s) => s.setChatTemplateOverride,
|
|
);
|
|
const templateDirty = chatTemplateOverride !== loadedChatTemplateOverride;
|
|
const [presetNameInput, setPresetNameInput] = useState(activePreset);
|
|
const [systemPromptEditorOpen, setSystemPromptEditorOpen] = useState(false);
|
|
const [systemPromptDraft, setSystemPromptDraft] = useState("");
|
|
const [activePresetBaseline, setActivePresetBaseline] = useState(params);
|
|
const presets = useMemo(() => {
|
|
return getOrderedPresets(customPresets);
|
|
}, [customPresets]);
|
|
const activePresetDefinition = useMemo(
|
|
() => presets.find((preset) => preset.name === activePreset) ?? null,
|
|
[activePreset, presets],
|
|
);
|
|
const activeCustomPreset = useMemo(
|
|
() => customPresets.find((preset) => preset.name === activePreset) ?? null,
|
|
[activePreset, customPresets],
|
|
);
|
|
const activeBuiltinPreset = useMemo(
|
|
() =>
|
|
BUILTIN_PRESETS.find((preset) => preset.name === activePreset) ?? null,
|
|
[activePreset],
|
|
);
|
|
const hasUnsavedPresetChanges = useMemo(
|
|
() => {
|
|
if (activePresetDefinition == null) {
|
|
return false;
|
|
}
|
|
if (activePresetDefinition.name === "Default") {
|
|
return activePresetSource === "modified";
|
|
}
|
|
return !isSamePresetConfig(activePresetDefinition.params, params);
|
|
},
|
|
[activePresetDefinition, activePresetSource, params],
|
|
);
|
|
const presetSaveState = useMemo(
|
|
() =>
|
|
getPresetSaveState({
|
|
rawName: presetNameInput,
|
|
activePreset,
|
|
presets,
|
|
hasUnsavedPresetChanges,
|
|
}),
|
|
[activePreset, hasUnsavedPresetChanges, presetNameInput, presets],
|
|
);
|
|
const systemPromptEditorDirty = systemPromptDraft !== params.systemPrompt;
|
|
const trustRemoteCodeMissing =
|
|
Boolean(currentCheckpoint) &&
|
|
modelRequiresTrustRemoteCode &&
|
|
!(params.trustRemoteCode ?? false);
|
|
const showPromptCacheTtlControl = Boolean(
|
|
activeExternalProvider &&
|
|
supportsProviderPromptCacheTtl(activeExternalProvider.providerType),
|
|
);
|
|
const showPromptCachingControl =
|
|
activeExternalProvider != null &&
|
|
supportsProviderPromptCaching(activeExternalProvider.providerType);
|
|
const promptCachingEnabled =
|
|
activeExternalProvider?.enablePromptCaching !== false;
|
|
const externalSelection = currentCheckpoint
|
|
? parseExternalModelId(currentCheckpoint)
|
|
: null;
|
|
const showOpenAICodeExecSection =
|
|
activeExternalProvider != null &&
|
|
providerSupportsBuiltinCodeExecution(
|
|
activeExternalProvider.providerType,
|
|
externalSelection?.modelId,
|
|
activeExternalProvider.baseUrl,
|
|
) &&
|
|
activeExternalProvider.providerType === "openai";
|
|
const openAiApiKeyForSection = activeExternalProvider
|
|
? getExternalProviderApiKey(activeExternalProvider.id) || null
|
|
: null;
|
|
|
|
function set<K extends keyof InferenceParams>(key: K) {
|
|
return (v: InferenceParams[K]) => {
|
|
const nextParams = { ...params, [key]: v };
|
|
const nextSource = isSamePresetConfig(activePresetBaseline, nextParams)
|
|
? getPresetSource(activePreset)
|
|
: "modified";
|
|
setActivePresetSource(nextSource);
|
|
onParamsChange(nextParams);
|
|
};
|
|
}
|
|
|
|
function applyPreset(name: string) {
|
|
if (!settingsHydrated) {
|
|
return;
|
|
}
|
|
const p = presets.find((pr) => pr.name === name);
|
|
if (p) {
|
|
onParamsChange({
|
|
...applyPresetParams(params, p.params),
|
|
});
|
|
setActivePreset(name);
|
|
setActivePresetSource(getPresetSource(name));
|
|
}
|
|
}
|
|
|
|
function savePresetWithName(rawName: string) {
|
|
if (!settingsHydrated) {
|
|
return;
|
|
}
|
|
const trimmed = rawName.trim();
|
|
if (!trimmed) {
|
|
toast.error("Enter a preset name");
|
|
return;
|
|
}
|
|
const usedNames = new Set([
|
|
...BUILTIN_PRESET_NAMES,
|
|
...customPresets.map((preset) => preset.name),
|
|
]);
|
|
const saveName = BUILTIN_PRESET_NAMES.has(trimmed)
|
|
? getBuiltinVariantName(trimmed, usedNames)
|
|
: trimmed;
|
|
const next = customPresets.filter((p) => p.name !== saveName);
|
|
const merged = [
|
|
...next,
|
|
{ name: saveName, params: toPresetParams(params) },
|
|
];
|
|
setCustomPresets(merged);
|
|
setActivePreset(saveName);
|
|
setActivePresetSource("custom");
|
|
setPresetNameInput(saveName);
|
|
}
|
|
|
|
function deletePreset(name: string) {
|
|
if (!settingsHydrated) {
|
|
return;
|
|
}
|
|
const hasCustomPreset = customPresets.some(
|
|
(preset) => preset.name === name,
|
|
);
|
|
if (!hasCustomPreset) {
|
|
return;
|
|
}
|
|
const fallbackPreset =
|
|
BUILTIN_PRESETS.find((preset) => preset.name === "Default") ??
|
|
null;
|
|
const next = customPresets.filter((preset) => preset.name !== name);
|
|
setCustomPresets(next);
|
|
if (activePreset === name) {
|
|
if (fallbackPreset) {
|
|
onParamsChange({
|
|
...applyPresetParams(params, fallbackPreset.params),
|
|
});
|
|
setActivePreset(fallbackPreset.name);
|
|
setActivePresetSource("builtin-default");
|
|
}
|
|
}
|
|
}
|
|
|
|
function openSystemPromptEditor() {
|
|
setSystemPromptDraft(params.systemPrompt);
|
|
setSystemPromptEditorOpen(true);
|
|
}
|
|
|
|
function saveSystemPromptEditor() {
|
|
set("systemPrompt")(systemPromptDraft);
|
|
setSystemPromptEditorOpen(false);
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (activePresetSource !== "modified") {
|
|
setActivePresetBaseline(params);
|
|
}
|
|
}, [activePresetSource, params]);
|
|
|
|
useEffect(() => {
|
|
if (!settingsHydrated) {
|
|
return;
|
|
}
|
|
if (presets.some((preset) => preset.name === activePreset)) {
|
|
const expectedSource = getPresetSource(activePreset);
|
|
if (
|
|
activePresetSource !== "modified" &&
|
|
activePresetSource !== expectedSource
|
|
) {
|
|
setActivePresetSource(expectedSource);
|
|
}
|
|
return;
|
|
}
|
|
setActivePreset("Default");
|
|
setActivePresetSource("builtin-default");
|
|
}, [
|
|
activePreset,
|
|
activePresetSource,
|
|
presets,
|
|
setActivePreset,
|
|
setActivePresetSource,
|
|
settingsHydrated,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
setPresetNameInput(activePreset);
|
|
}, [activePreset]);
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setSystemPromptEditorOpen(false);
|
|
}
|
|
}, [open]);
|
|
|
|
const settingsContent = (
|
|
<>
|
|
<div className="aui-thread-viewport relative h-full overflow-y-auto">
|
|
<div className="sticky top-0 z-10 flex h-[48px] items-start gap-2 bg-panel-surface pl-[18px] pr-[14px] pt-[11px]">
|
|
{isMobile ? (
|
|
<span className="flex h-[34px] flex-1 items-center text-[15px] font-semibold tracking-[-0.01em] dark:tracking-[0.015em] text-nav-fg">
|
|
Configuration
|
|
</span>
|
|
) : (
|
|
<>
|
|
<span className="flex h-[34px] flex-1 items-center text-[15px] font-semibold tracking-[-0.01em] dark:tracking-[0.015em] text-nav-fg">
|
|
Configuration
|
|
</span>
|
|
<Tooltip>
|
|
<TooltipPrimitive.Trigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpenChange?.(false)}
|
|
className="flex h-[34px] w-[34px] items-center justify-center rounded-[12px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
aria-label="Close configuration"
|
|
>
|
|
<HugeiconsIcon
|
|
icon={LayoutAlignRightIcon}
|
|
strokeWidth={1.75}
|
|
className="size-icon"
|
|
/>
|
|
</button>
|
|
</TooltipPrimitive.Trigger>
|
|
<TooltipContent
|
|
side="bottom"
|
|
sideOffset={6}
|
|
className="tooltip-compact"
|
|
>
|
|
Close configuration
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="px-[18px] pt-3">
|
|
{hasModelContent && (
|
|
<CollapsibleSection label="Model" defaultOpen={true} first>
|
|
<div className="flex flex-col gap-4 pt-1">
|
|
{isGguf && (
|
|
<>
|
|
<div className="space-y-3.5">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
|
|
Context Length
|
|
</span>
|
|
<NumericValueInput
|
|
value={
|
|
typeof ctxDisplayValue === "number"
|
|
? ctxDisplayValue
|
|
: (ggufContextLength ?? 0)
|
|
}
|
|
min={128}
|
|
max={ctxMaxValue ?? undefined}
|
|
step={1}
|
|
onChange={(v) => {
|
|
setCustomContextLength(
|
|
v === (ggufContextLength ?? 0) ? null : v,
|
|
);
|
|
}}
|
|
ariaLabel="Context Length"
|
|
size={8}
|
|
/>
|
|
</div>
|
|
<Slider
|
|
min={1024}
|
|
max={ctxMaxValue ?? 4096}
|
|
step={1024}
|
|
value={[
|
|
Math.min(
|
|
typeof ctxDisplayValue === "number"
|
|
? ctxDisplayValue
|
|
: (ggufContextLength ?? 4096),
|
|
ctxMaxValue ?? 4096,
|
|
),
|
|
]}
|
|
onValueChange={([v]) => {
|
|
const snapped = Math.round(v);
|
|
setCustomContextLength(
|
|
snapped === (ggufContextLength ?? 0) ? null : snapped,
|
|
);
|
|
}}
|
|
className="panel-slider"
|
|
/>
|
|
{ggufMaxContextLength != null &&
|
|
typeof ctxDisplayValue === "number" &&
|
|
ctxDisplayValue > ggufMaxContextLength && (
|
|
<p className="text-[11px] text-amber-500">
|
|
Exceeds estimated VRAM capacity (
|
|
{ggufMaxContextLength.toLocaleString()} tokens). The
|
|
model may use system RAM.
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
|
|
KV Cache Dtype
|
|
</span>
|
|
<InfoHint>
|
|
Lower KV cache precision to save VRAM at the cost of some
|
|
quality. f16/bf16 are full precision; q8_0/q5_1/q4_1 are
|
|
quantized.
|
|
</InfoHint>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
<Select
|
|
value={kvCacheDtype ?? "f16"}
|
|
onValueChange={(v) => {
|
|
setKvCacheDtype(v === "f16" ? null : v);
|
|
}}
|
|
>
|
|
<SelectTrigger
|
|
animateRadius={false}
|
|
icon={ArrowDown01Icon}
|
|
iconClassName="size-3.5"
|
|
className="grid h-7 w-[60px] min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[10px] border-transparent bg-black/[0.04] dark:bg-white/[0.05] hover:bg-black/[0.06] dark:hover:bg-white/[0.07] px-2 py-0 text-[13px]! font-medium text-nav-fg focus-visible:ring-0 focus-visible:border-transparent [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate [&>svg]:shrink-0"
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent className="menu-soft-surface ring-0 border-0 rounded-lg">
|
|
<SelectItem value="f16">f16</SelectItem>
|
|
<SelectItem value="bf16">bf16</SelectItem>
|
|
<SelectItem value="q8_0">q8_0</SelectItem>
|
|
<SelectItem value="q5_1">q5_1</SelectItem>
|
|
<SelectItem value="q4_1">q4_1</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
|
|
Speculative Decoding
|
|
</span>
|
|
<InfoHint>
|
|
Faster generation with 0% accuracy hit. Auto picks
|
|
MTP / ngram-mod based on the model and platform.
|
|
Pick MTP, Ngram, or MTP+Ngram to force a specific
|
|
strategy on both GPU and CPU.
|
|
</InfoHint>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
<Select
|
|
value={speculativeType ?? "auto"}
|
|
onValueChange={(v) => {
|
|
setSpeculativeType(v);
|
|
if (v !== "mtp" && v !== "mtp+ngram") {
|
|
setSpecDraftNMax(null);
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger
|
|
animateRadius={false}
|
|
icon={ArrowDown01Icon}
|
|
iconClassName="size-3.5"
|
|
className="grid h-7 w-[120px] min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[10px] border-transparent bg-black/[0.04] dark:bg-white/[0.05] hover:bg-black/[0.06] dark:hover:bg-white/[0.07] px-2 py-0 text-[13px]! font-medium text-nav-fg focus-visible:ring-0 focus-visible:border-transparent [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate [&>svg]:shrink-0"
|
|
data-test-id="speculative-type-select"
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent className="menu-soft-surface ring-0 border-0 rounded-lg">
|
|
<SelectItem value="auto">Auto</SelectItem>
|
|
<SelectItem value="mtp">MTP</SelectItem>
|
|
<SelectItem value="ngram">Ngram</SelectItem>
|
|
<SelectItem value="mtp+ngram">MTP+Ngram</SelectItem>
|
|
<SelectItem value="off">Off</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
{(speculativeType === "mtp" ||
|
|
speculativeType === "mtp+ngram") && (
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
|
|
Draft Tokens
|
|
</span>
|
|
<InfoHint>
|
|
Max MTP draft tokens per step
|
|
(--spec-draft-n-max). Lower = less wasted
|
|
draft decode; higher = bigger speedup when
|
|
acceptance stays high. Default: 2 on GPU,
|
|
3 on CPU/Mac.
|
|
</InfoHint>
|
|
</div>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={16}
|
|
step={1}
|
|
value={specDraftNMax ?? ""}
|
|
placeholder="auto"
|
|
onChange={(e) => {
|
|
const raw = e.target.value;
|
|
if (raw === "") {
|
|
setSpecDraftNMax(null);
|
|
return;
|
|
}
|
|
const parsed = Number.parseInt(raw, 10);
|
|
if (Number.isFinite(parsed)) {
|
|
const clamped = Math.max(1, Math.min(16, parsed));
|
|
setSpecDraftNMax(clamped);
|
|
}
|
|
}}
|
|
data-test-id="spec-draft-n-max-input"
|
|
aria-label="Speculative decoding draft tokens"
|
|
className="h-7 w-[72px] rounded-[10px] border-transparent bg-black/[0.04] dark:bg-white/[0.05] hover:bg-black/[0.06] dark:hover:bg-white/[0.07] px-2 py-0 text-[13px] font-medium text-nav-fg outline-none focus-visible:ring-0"
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
{!isGguf && params.checkpoint && (
|
|
<>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
|
|
Enable custom code
|
|
</span>
|
|
<InfoHint>
|
|
Run custom Python from the model repo (e.g. Nemotron).
|
|
Only enable for trusted sources.
|
|
</InfoHint>
|
|
</div>
|
|
<Switch
|
|
className="panel-switch shrink-0"
|
|
checked={params.trustRemoteCode ?? false}
|
|
onCheckedChange={set("trustRemoteCode")}
|
|
/>
|
|
</div>
|
|
{trustRemoteCodeMissing && (
|
|
<Alert className="rounded-[14px] border-amber-200/70 bg-amber-50/70 px-3 py-2 text-amber-950 dark:border-amber-900/70 dark:bg-amber-950/35 dark:text-amber-100">
|
|
<AlertTitle className="text-[12px] font-medium">
|
|
Keep custom code enabled for this model
|
|
</AlertTitle>
|
|
<AlertDescription className="text-[11.5px] leading-[1.45] text-amber-800 dark:text-amber-200">
|
|
This model requires custom code to load. You can edit the
|
|
toggle, but loading will stay blocked until it is turned
|
|
back on.
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</>
|
|
)}
|
|
<ChatTemplateFields />
|
|
{(modelSettingsDirty || templateDirty) && (
|
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
|
<Button
|
|
type="button"
|
|
onClick={() => onReloadModel?.()}
|
|
size="sm"
|
|
className="h-7 px-3 text-[12px] font-medium tracking-nav bg-primary/92 text-primary-foreground hover:bg-primary"
|
|
>
|
|
Apply
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setCustomContextLength(null);
|
|
setKvCacheDtype(loadedKvCacheDtype);
|
|
setSpeculativeType(loadedSpeculativeType);
|
|
setSpecDraftNMax(loadedSpecDraftNMax);
|
|
setChatTemplateOverride(loadedChatTemplateOverride);
|
|
}}
|
|
className="h-7 px-3 text-[12px] font-medium tracking-nav text-muted-foreground"
|
|
>
|
|
Reset
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
<CollapsibleSection
|
|
label="Preset"
|
|
defaultOpen={true}
|
|
first={!hasModelContent}
|
|
>
|
|
<div className="flex flex-col gap-3 pt-1">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<div
|
|
className="w-full min-w-0 cursor-pointer outline-none focus-visible:outline-none"
|
|
aria-label="Open preset list"
|
|
>
|
|
<InputGroup className="panel-input-group">
|
|
<InputGroupInput
|
|
id="inference-preset-name"
|
|
value={presetNameInput}
|
|
onChange={(e) => setPresetNameInput(e.target.value)}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onKeyDown={(e) => {
|
|
if (
|
|
e.key === "Enter" &&
|
|
settingsHydrated &&
|
|
presetSaveState.canSubmit
|
|
) {
|
|
e.preventDefault();
|
|
savePresetWithName(presetNameInput);
|
|
}
|
|
e.stopPropagation();
|
|
}}
|
|
placeholder="Preset name"
|
|
maxLength={80}
|
|
autoComplete="off"
|
|
className={cn(
|
|
"!h-9 min-h-0 min-w-0 self-stretch !pl-3.5 !pr-2 py-0 text-[13px] font-medium leading-9 text-nav-fg md:text-[13px]",
|
|
presetSaveState.isSaveReady &&
|
|
"placeholder:text-primary/50",
|
|
)}
|
|
aria-label="Inference preset name"
|
|
/>
|
|
<InputGroupAddon
|
|
align="inline-end"
|
|
className="min-h-0 shrink-0 gap-0 self-stretch border-0 py-0 pl-0 !pr-1 has-[>button]:mr-0"
|
|
>
|
|
<span
|
|
className="!h-7 min-h-7 !w-7 min-w-7 shrink-0 self-center inline-flex items-center justify-center rounded-full border-0 px-0 text-[#a0a097] dark:text-nav-fg pointer-events-none"
|
|
aria-hidden="true"
|
|
>
|
|
<HugeiconsIcon
|
|
icon={ArrowDown01Icon}
|
|
className="size-3.5"
|
|
strokeWidth={2}
|
|
/>
|
|
</span>
|
|
</InputGroupAddon>
|
|
</InputGroup>
|
|
</div>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="start"
|
|
sideOffset={6}
|
|
className="menu-soft-surface ring-0 border-0 rounded-lg p-1.5"
|
|
>
|
|
{presets.map((p, index) => (
|
|
<Fragment key={p.name}>
|
|
<DropdownMenuItem
|
|
disabled={!settingsHydrated}
|
|
onSelect={(event) => {
|
|
if (!settingsHydrated) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
applyPreset(p.name);
|
|
}}
|
|
className="flex min-h-9 items-center px-3 py-0 text-[13px] font-medium leading-[1.4] tracking-nav"
|
|
>
|
|
{p.name}
|
|
</DropdownMenuItem>
|
|
{index === BUILTIN_PRESETS.length - 1 &&
|
|
presets.length > BUILTIN_PRESETS.length && (
|
|
<DropdownMenuSeparator className="mx-3 my-1.5 h-px bg-black/8 dark:bg-white/8" />
|
|
)}
|
|
</Fragment>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Button
|
|
type="button"
|
|
onClick={() => savePresetWithName(presetNameInput)}
|
|
disabled={!(settingsHydrated && presetSaveState.canSubmit)}
|
|
variant={presetSaveState.isSaveReady ? "default" : "outline"}
|
|
size="sm"
|
|
className={cn(
|
|
"h-9 w-full rounded-[10px] text-[13px] font-medium tracking-nav",
|
|
presetSaveState.isSaveReady &&
|
|
"bg-primary text-primary-foreground hover:bg-primary/90",
|
|
)}
|
|
title={presetSaveState.title}
|
|
aria-label={presetSaveState.title}
|
|
>
|
|
{presetSaveState.buttonLabel}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={() => deletePreset(activePreset)}
|
|
disabled={!(settingsHydrated && activeCustomPreset)}
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-9 w-full rounded-[10px] text-[13px] font-medium tracking-nav text-muted-foreground"
|
|
title={
|
|
activeCustomPreset
|
|
? activeBuiltinPreset
|
|
? "Reset selected preset to built-in defaults"
|
|
: "Delete selected preset"
|
|
: "No saved override to delete"
|
|
}
|
|
>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CollapsibleSection>
|
|
|
|
{showPromptCachingControl && activeExternalProvider ? (
|
|
<CollapsibleSection label="Provider" defaultOpen={true}>
|
|
<div className="flex items-center justify-between gap-3 pt-1">
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
|
|
Prompt caching
|
|
</span>
|
|
<InfoHint>
|
|
Reuse compatible prompt prefixes for lower latency and cost.
|
|
</InfoHint>
|
|
</div>
|
|
<Switch
|
|
className="panel-switch shrink-0"
|
|
checked={promptCachingEnabled}
|
|
onCheckedChange={(checked) => {
|
|
onExternalProviderChange?.({
|
|
...activeExternalProvider,
|
|
enablePromptCaching: checked,
|
|
});
|
|
}}
|
|
aria-label="Enable prompt caching"
|
|
/>
|
|
</div>
|
|
{showPromptCacheTtlControl && promptCachingEnabled ? (
|
|
<div className="flex items-center justify-between gap-3 pt-3">
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
|
|
Cache TTL
|
|
</span>
|
|
<InfoHint>
|
|
Anthropic exposes a 5 minute and a 1 hour ephemeral
|
|
cache pool. The 1 hour pool costs 2x base input on
|
|
write vs 1.25x for 5 minute, but reads stay 0.1x for
|
|
both, so a single read landing more than 5 minutes
|
|
after the write pays off the premium.
|
|
</InfoHint>
|
|
</div>
|
|
<Select
|
|
value={activeExternalProvider.promptCacheTtl ?? "5m"}
|
|
onValueChange={(value) => {
|
|
if (value !== "5m" && value !== "1h") return;
|
|
onExternalProviderChange?.({
|
|
...activeExternalProvider,
|
|
promptCacheTtl: value,
|
|
});
|
|
}}
|
|
>
|
|
<SelectTrigger
|
|
className="panel-select-trigger h-8 w-[124px] shrink-0"
|
|
aria-label="Prompt cache TTL"
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="5m">5 minutes</SelectItem>
|
|
<SelectItem value="1h">1 hour</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
) : null}
|
|
</CollapsibleSection>
|
|
) : null}
|
|
|
|
{showOpenAICodeExecSection && activeExternalProvider ? (
|
|
<CollapsibleSection label="Code Execution" defaultOpen={false}>
|
|
<OpenAICodeExecSection
|
|
provider={activeExternalProvider}
|
|
apiKey={openAiApiKeyForSection}
|
|
activeThreadId={activeThreadId}
|
|
onProviderChange={(p) => onExternalProviderChange?.(p)}
|
|
/>
|
|
</CollapsibleSection>
|
|
) : null}
|
|
|
|
{ragToolEnabled ? (
|
|
<CollapsibleSection label="Retrieval" defaultOpen={true}>
|
|
<div className="flex flex-col gap-3 pt-1">
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[12px] font-medium text-muted-foreground">
|
|
Knowledge base
|
|
</label>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild={true}>
|
|
<Button variant="outline" className="w-full justify-between">
|
|
<span className="truncate">
|
|
{ragSourceLabel(ragSource, knowledgeBases)}
|
|
</span>
|
|
<ChevronDown className="size-4 opacity-60" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="start"
|
|
className="w-[var(--radix-dropdown-menu-trigger-width)]"
|
|
>
|
|
<DropdownMenuItem
|
|
onSelect={() => setRagSource({ kind: "thread" })}
|
|
>
|
|
This thread's documents
|
|
</DropdownMenuItem>
|
|
{knowledgeBases.length > 0 ? (
|
|
<DropdownMenuSeparator />
|
|
) : null}
|
|
{knowledgeBases.map((kb) => {
|
|
const isActive = kb.id === activeKbId;
|
|
const isLate = kb.chunking_strategy === "late";
|
|
const isMultimodal = kb.mode === "multimodal";
|
|
return (
|
|
<DropdownMenuItem
|
|
key={kb.id}
|
|
className={cn(
|
|
"flex items-center justify-between gap-2",
|
|
isActive && "bg-accent text-accent-foreground",
|
|
)}
|
|
onSelect={() =>
|
|
setRagSource({ kind: "kb", kbId: kb.id })
|
|
}
|
|
>
|
|
<span className="flex min-w-0 items-center gap-1.5 truncate">
|
|
<span className="truncate">{kb.name}</span>
|
|
{isLate ? (
|
|
<span
|
|
className="rounded-sm bg-amber-500/15 px-1 text-[10px] font-medium text-amber-700 dark:text-amber-300"
|
|
title="Late chunking enabled"
|
|
>
|
|
⚡ Late
|
|
</span>
|
|
) : null}
|
|
{isMultimodal ? (
|
|
<span
|
|
className="rounded-sm bg-violet-500/15 px-1 text-[10px] font-medium text-violet-700 dark:text-violet-300"
|
|
title="Multimodal mode — text + image embeddings"
|
|
>
|
|
🖼️ MM
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
aria-label={`Delete ${kb.name}`}
|
|
className="ml-2 inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
if (
|
|
window.confirm(
|
|
`Delete "${kb.name}" and all its documents?`,
|
|
)
|
|
) {
|
|
void deleteKB(kb.id).then(() => {
|
|
if (isActive) {
|
|
setRagSource({ kind: "thread" });
|
|
}
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
<HugeiconsIcon icon={Delete02Icon} size={12} />
|
|
</button>
|
|
</DropdownMenuItem>
|
|
);
|
|
})}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onSelect={() => setKbCreateOpen(true)}
|
|
className="text-muted-foreground"
|
|
>
|
|
<HugeiconsIcon icon={Add01Icon} size={12} className="mr-2" />
|
|
Create knowledge base…
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
Each message retrieves matching context from the selected
|
|
source before sending.
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[12px] font-medium text-muted-foreground">
|
|
Search mode
|
|
</label>
|
|
<Select
|
|
value={ragMode}
|
|
onValueChange={(v) => setRagMode(v as RagMode)}
|
|
disabled={!ragEnabled}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="hybrid">
|
|
Hybrid (BM25 + semantic)
|
|
</SelectItem>
|
|
<SelectItem value="dense">Semantic only</SelectItem>
|
|
<SelectItem value="bm25">BM25 (lexical) only</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
Hybrid blends keyword (BM25) with vector similarity — best
|
|
default. Semantic-only ignores exact terms; BM25-only ignores
|
|
meaning.
|
|
</p>
|
|
</div>
|
|
<KBCreateDialog
|
|
open={kbCreateOpen}
|
|
onOpenChange={setKbCreateOpen}
|
|
onCreated={(kb) => setRagSource({ kind: "kb", kbId: kb.id })}
|
|
/>
|
|
{ragSource.kind === "thread" ? (
|
|
<>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[11px] font-medium text-muted-foreground">
|
|
Mode
|
|
</label>
|
|
<Select
|
|
value={effectiveThreadMode}
|
|
onValueChange={(v) => {
|
|
const next = v as KBMode;
|
|
if (next === effectiveThreadMode) return;
|
|
applyThreadSettingChange({ mode: next });
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="text">Text only</SelectItem>
|
|
<SelectItem
|
|
value="multimodal"
|
|
disabled={effectiveThreadChunking === "late"}
|
|
title={
|
|
effectiveThreadChunking === "late"
|
|
? "Multimodal cannot be combined with late chunking"
|
|
: undefined
|
|
}
|
|
>
|
|
Multimodal
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[11px] font-medium text-muted-foreground">
|
|
Chunking
|
|
</label>
|
|
<Select
|
|
value={effectiveThreadChunking}
|
|
onValueChange={(v) => {
|
|
const next = v as RagChunkingStrategy;
|
|
if (next === effectiveThreadChunking) return;
|
|
applyThreadSettingChange({ chunking_strategy: next });
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="standard">Standard</SelectItem>
|
|
<SelectItem
|
|
value="late"
|
|
disabled={effectiveThreadMode === "multimodal"}
|
|
title={
|
|
effectiveThreadMode === "multimodal"
|
|
? "Late chunking cannot be combined with multimodal mode"
|
|
: undefined
|
|
}
|
|
>
|
|
Late
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
Changing either setting will re-index this thread's existing
|
|
documents.
|
|
</p>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[12px] font-medium text-muted-foreground">
|
|
Documents in this thread
|
|
</label>
|
|
{threadDocs.length === 0 ? (
|
|
<p className="text-[11px] text-muted-foreground italic">
|
|
Attach a file using the + button in the composer to add
|
|
documents to this thread.
|
|
</p>
|
|
) : (
|
|
<>
|
|
<div className="flex flex-col gap-1.5">
|
|
{threadDocs.map((doc) => (
|
|
<DocumentRow
|
|
key={doc.id}
|
|
doc={doc}
|
|
onDelete={() => {
|
|
void removeThreadDoc(doc.id);
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
if (!activeThreadId) return;
|
|
if (
|
|
window.confirm(
|
|
`Re-index all ${threadDocs.length} document${threadDocs.length === 1 ? "" : "s"}? Existing chunks will be deleted and rebuilt; search will be unavailable until ingestion finishes.`,
|
|
)
|
|
) {
|
|
void reingestThread(activeThreadId);
|
|
}
|
|
}}
|
|
>
|
|
Re-index
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
onClick={() => {
|
|
if (!activeThreadId) return;
|
|
if (
|
|
window.confirm(
|
|
`Delete all ${threadDocs.length} document${threadDocs.length === 1 ? "" : "s"} from this thread? This cannot be undone.`,
|
|
)
|
|
) {
|
|
void clearThreadIndex(activeThreadId);
|
|
}
|
|
}}
|
|
>
|
|
Clear thread index
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : null}
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-[12px] font-medium text-muted-foreground">
|
|
RAG top K
|
|
</label>
|
|
<span className="text-[12px] tabular-nums text-muted-foreground">
|
|
{ragTopK}
|
|
</span>
|
|
</div>
|
|
<Slider
|
|
value={[ragTopK]}
|
|
min={1}
|
|
max={20}
|
|
step={1}
|
|
onValueChange={([v]) => v != null && setRagTopK(v)}
|
|
disabled={!ragEnabled}
|
|
/>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
Number of retrieved chunks passed to the model as context
|
|
(distinct from the sampling Top K below). Higher = more
|
|
grounding, more tokens.
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-[12px] font-medium text-muted-foreground">
|
|
Min relevance
|
|
</label>
|
|
<span className="text-[12px] tabular-nums text-muted-foreground">
|
|
{ragMinScore === 0 ? "off" : ragMinScore.toFixed(2)}
|
|
</span>
|
|
</div>
|
|
<Slider
|
|
value={[ragMinScore]}
|
|
min={0}
|
|
max={1}
|
|
step={0.05}
|
|
onValueChange={([v]) => v != null && setRagMinScore(v)}
|
|
disabled={!ragEnabled}
|
|
/>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
Cosine-similarity floor for retrieved chunks (0 = off).
|
|
Set above 0 so unrelated docs are dropped — useful when
|
|
your query is off-topic from what's indexed. Try 0.3 as
|
|
a starting point.
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex flex-col">
|
|
<span className="text-[13px] font-medium">
|
|
Use reranker
|
|
</span>
|
|
<span className="text-[11px] text-muted-foreground">
|
|
Slower; uses GPU. Improves quality for fact-heavy questions.
|
|
</span>
|
|
</div>
|
|
<Switch
|
|
checked={enableRerank}
|
|
onCheckedChange={setEnableRerank}
|
|
disabled={!ragEnabled}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CollapsibleSection>
|
|
) : null}
|
|
|
|
<CollapsibleSection label="System Prompt" defaultOpen={true}>
|
|
<button
|
|
type="button"
|
|
onClick={openSystemPromptEditor}
|
|
aria-label="Edit system prompt"
|
|
className={cn(
|
|
"panel-text-surface mt-1 flex w-full h-20 overflow-hidden cursor-pointer items-start px-3.5 py-2.5 text-left text-[13px] font-medium leading-relaxed corner-squircle focus-visible:outline-none focus-visible:border-ring focus-visible:ring-[1px] focus-visible:ring-ring/40",
|
|
params.systemPrompt
|
|
? "text-nav-fg"
|
|
: "text-muted-foreground",
|
|
)}
|
|
>
|
|
<span className="block line-clamp-3 whitespace-pre-wrap break-words">
|
|
{params.systemPrompt ||
|
|
"Example: You are a helpful assistant..."}
|
|
</span>
|
|
</button>
|
|
</CollapsibleSection>
|
|
|
|
<CollapsibleSection label="Sampling" defaultOpen={true}>
|
|
<div className="flex flex-col gap-5 pt-1">
|
|
{showTemperature ? (
|
|
<ParamSlider
|
|
label="Temperature"
|
|
value={params.temperature}
|
|
min={0}
|
|
max={2}
|
|
step={0.01}
|
|
onChange={set("temperature")}
|
|
info="Controls randomness. Lower values make output focused and deterministic; higher values increase variety and creativity."
|
|
/>
|
|
) : null}
|
|
{showTopP ? (
|
|
<ParamSlider
|
|
label="Top P"
|
|
value={params.topP}
|
|
min={0}
|
|
max={1}
|
|
step={0.05}
|
|
onChange={set("topP")}
|
|
displayValue={params.topP === 1 ? "Off" : undefined}
|
|
info="Nucleus sampling. Restricts choices to the smallest set of tokens whose cumulative probability reaches this threshold. 1.0 = off."
|
|
/>
|
|
) : null}
|
|
{showTopK ? (
|
|
<ParamSlider
|
|
label="Top K"
|
|
value={params.topK}
|
|
min={0}
|
|
max={100}
|
|
step={1}
|
|
onChange={set("topK")}
|
|
displayValue={params.topK === 0 ? "Off" : undefined}
|
|
info="Limits sampling to the K most likely tokens at each step. 0 = off."
|
|
/>
|
|
) : null}
|
|
{showMinP ? (
|
|
<ParamSlider
|
|
label="Min P"
|
|
value={params.minP}
|
|
min={0}
|
|
max={1}
|
|
step={0.01}
|
|
onChange={set("minP")}
|
|
info="Drops tokens whose probability is below this fraction of the top token's probability. Filters unlikely candidates."
|
|
/>
|
|
) : null}
|
|
{showRepetitionPenalty ? (
|
|
<ParamSlider
|
|
label="Repetition Penalty"
|
|
value={params.repetitionPenalty}
|
|
min={1}
|
|
max={2}
|
|
step={0.05}
|
|
onChange={set("repetitionPenalty")}
|
|
displayValue={
|
|
params.repetitionPenalty === 1 ? "Off" : undefined
|
|
}
|
|
info="Down-weights tokens that have already appeared, reducing repetition. 1.0 = off; higher values penalize more strongly."
|
|
/>
|
|
) : null}
|
|
{showPresencePenalty ? (
|
|
<ParamSlider
|
|
label="Presence Penalty"
|
|
value={params.presencePenalty}
|
|
min={0}
|
|
max={2}
|
|
step={0.1}
|
|
onChange={set("presencePenalty")}
|
|
displayValue={params.presencePenalty === 0 ? "Off" : undefined}
|
|
info="Penalizes any token that has already appeared at least once, encouraging the model to introduce new topics. 0 = off."
|
|
/>
|
|
) : null}
|
|
{!isExternalModel && !isGguf && (
|
|
<ParamSlider
|
|
label="Max Seq Length"
|
|
value={params.maxSeqLength}
|
|
min={128}
|
|
max={32768}
|
|
step={128}
|
|
onChange={set("maxSeqLength")}
|
|
info="Maximum context window size in tokens — input prompt plus generated output combined. Capped by the model's trained limit."
|
|
/>
|
|
)}
|
|
<ParamSlider
|
|
label="Max Tokens"
|
|
value={params.maxTokens}
|
|
min={
|
|
isExternalModel
|
|
? getExternalMinOutputTokens(externalProviderType)
|
|
: 64
|
|
}
|
|
max={
|
|
isExternalModel
|
|
? EXTERNAL_MAX_OUTPUT_TOKENS
|
|
: isGguf && ggufContextLength
|
|
? ggufContextLength
|
|
: 32768
|
|
}
|
|
step={64}
|
|
onChange={set("maxTokens")}
|
|
displayValue={
|
|
isGguf &&
|
|
ggufContextLength &&
|
|
params.maxTokens >= ggufContextLength
|
|
? "Max"
|
|
: undefined
|
|
}
|
|
info="Maximum number of tokens to generate per response. Generation stops at this limit or when the model emits an end-of-sequence token."
|
|
/>
|
|
</div>
|
|
</CollapsibleSection>
|
|
|
|
{!isExternalModel ? (
|
|
<CollapsibleSection label="Tools">
|
|
<div className="flex flex-col gap-5 pt-1">
|
|
<AutoHealToolCallsToggle />
|
|
<MaxToolCallsSlider />
|
|
<ToolCallTimeoutSlider />
|
|
</div>
|
|
</CollapsibleSection>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<Dialog
|
|
open={systemPromptEditorOpen}
|
|
onOpenChange={(nextOpen) => {
|
|
setSystemPromptEditorOpen(nextOpen);
|
|
}}
|
|
>
|
|
<DialogContent
|
|
className="corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-3xl"
|
|
overlayClassName="bg-background/35 supports-backdrop-filter:backdrop-blur-[1px]"
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle>Edit System Prompt</DialogTitle>
|
|
<DialogDescription>
|
|
This prompt is part of the current configuration and saves with
|
|
the preset.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-2">
|
|
<div className="space-y-0.5 px-0.5">
|
|
<div className="text-[11px] font-medium">Prompt editor</div>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
Use this for longer edits. Save writes back to the active
|
|
configuration only.
|
|
</p>
|
|
</div>
|
|
<Textarea
|
|
value={systemPromptDraft}
|
|
onChange={(event) => setSystemPromptDraft(event.target.value)}
|
|
placeholder="You are a helpful assistant..."
|
|
fieldSizing="fixed"
|
|
className="min-h-[24rem] max-h-[50vh] overflow-y-auto text-sm leading-6 corner-squircle focus-visible:border-input focus-visible:ring-0"
|
|
rows={14}
|
|
/>
|
|
</div>
|
|
<DialogFooter className="flex-wrap gap-2 sm:justify-between">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setSystemPromptDraft(params.systemPrompt);
|
|
setSystemPromptEditorOpen(false);
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={saveSystemPromptEditor}
|
|
disabled={!systemPromptEditorDirty}
|
|
>
|
|
Save
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
<SheetContent side="right" className="w-[18rem] p-0 font-heading">
|
|
<SheetHeader className="sr-only">
|
|
<SheetTitle>Configuration</SheetTitle>
|
|
<SheetDescription>Chat inference settings</SheetDescription>
|
|
</SheetHeader>
|
|
<div className="flex h-full flex-col">{settingsContent}</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<aside
|
|
className={`relative z-50 shrink-0 h-full overflow-hidden bg-panel-surface text-panel-surface-fg font-heading ${open ? "w-[17rem] border-l border-sidebar-border" : "w-0"}`}
|
|
>
|
|
<div className="h-full w-full">{settingsContent}</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function MaxToolCallsSlider() {
|
|
const maxToolCalls = useChatRuntimeStore((s) => s.maxToolCallsPerMessage);
|
|
const setMaxToolCalls = useChatRuntimeStore(
|
|
(s) => s.setMaxToolCallsPerMessage,
|
|
);
|
|
|
|
// Slider range 0-41; 41 maps to 9999 ("Max")
|
|
const sliderValue = maxToolCalls >= 9999 ? 41 : Math.min(maxToolCalls, 40);
|
|
|
|
return (
|
|
<ParamSlider
|
|
label="Max Tool Calls Per Message"
|
|
value={sliderValue}
|
|
min={0}
|
|
max={41}
|
|
step={1}
|
|
onChange={(v) => setMaxToolCalls(v >= 41 ? 9999 : v)}
|
|
displayValue={
|
|
sliderValue >= 41 ? "Max" : sliderValue === 0 ? "Off" : undefined
|
|
}
|
|
info="Cap on tool/function calls the model may invoke within a single response. 0 disables tool use; Max removes the cap."
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ToolCallTimeoutSlider() {
|
|
const timeout = useChatRuntimeStore((s) => s.toolCallTimeout);
|
|
const setTimeout_ = useChatRuntimeStore((s) => s.setToolCallTimeout);
|
|
|
|
// Slider 1-31; 31 maps to 9999 ("Max")
|
|
const sliderValue = timeout >= 9999 ? 31 : Math.min(Math.max(timeout, 1), 30);
|
|
|
|
const displayValue =
|
|
sliderValue >= 31
|
|
? "Max"
|
|
: sliderValue === 1
|
|
? "1 minute"
|
|
: `${sliderValue} minutes`;
|
|
|
|
return (
|
|
<ParamSlider
|
|
label="Max Tool Call Duration"
|
|
value={sliderValue}
|
|
min={1}
|
|
max={31}
|
|
step={1}
|
|
onChange={(v) => setTimeout_(v >= 31 ? 9999 : v)}
|
|
displayValue={displayValue}
|
|
valueSize={10}
|
|
info="Per-call wall-clock limit. Long-running tool executions are terminated when this elapses; the model continues with what completed."
|
|
/>
|
|
);
|
|
}
|
|
|
|
function AutoHealToolCallsToggle() {
|
|
const autoHealToolCalls = useChatRuntimeStore((s) => s.autoHealToolCalls);
|
|
const setAutoHealToolCalls = useChatRuntimeStore(
|
|
(s) => s.setAutoHealToolCalls,
|
|
);
|
|
|
|
return (
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
<span className="min-w-0 text-[13px] font-medium leading-[1.25] tracking-nav text-nav-fg">
|
|
Auto-Healing Tool Calls
|
|
</span>
|
|
<InfoHint>
|
|
Unsloth auto-fixes broken tool calls so inference output is never
|
|
broken.
|
|
</InfoHint>
|
|
</div>
|
|
<Switch
|
|
className="panel-switch"
|
|
checked={autoHealToolCalls}
|
|
onCheckedChange={setAutoHealToolCalls}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChatTemplateFields() {
|
|
const defaultTemplate = useChatRuntimeStore((s) => s.defaultChatTemplate);
|
|
const override = useChatRuntimeStore((s) => s.chatTemplateOverride);
|
|
const setOverride = useChatRuntimeStore((s) => s.setChatTemplateOverride);
|
|
const [editorOpen, setEditorOpen] = useState(false);
|
|
const [draft, setDraft] = useState("");
|
|
|
|
if (!defaultTemplate) return null;
|
|
|
|
const displayValue = override ?? defaultTemplate;
|
|
const isModified = override !== null;
|
|
const draftDirty = draft !== displayValue;
|
|
|
|
const openEditor = () => {
|
|
setDraft(displayValue);
|
|
setEditorOpen(true);
|
|
};
|
|
const saveEditor = () => {
|
|
setOverride(
|
|
draft.trim().length === 0 || draft === defaultTemplate ? null : draft,
|
|
);
|
|
setEditorOpen(false);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-[13px] font-medium tracking-nav text-nav-fg">
|
|
Chat Template
|
|
</span>
|
|
{isModified && (
|
|
<Tooltip>
|
|
<TooltipPrimitive.Trigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOverride(null)}
|
|
className="nav-icon-btn text-nav-icon-idle hover:bg-panel-surface-hover hover:text-black dark:hover:text-white"
|
|
aria-label="Revert chat template"
|
|
>
|
|
<HugeiconsIcon
|
|
icon={ArrowTurnBackwardIcon}
|
|
strokeWidth={1.75}
|
|
className="size-4"
|
|
/>
|
|
</button>
|
|
</TooltipPrimitive.Trigger>
|
|
<TooltipContent
|
|
side="top"
|
|
sideOffset={6}
|
|
className="tooltip-compact"
|
|
>
|
|
Revert changes
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={openEditor}
|
|
aria-label="Edit chat template"
|
|
className="panel-text-surface mt-1 flex w-full h-20 overflow-hidden cursor-pointer items-start px-3.5 py-2.5 text-left text-[13px] font-medium leading-relaxed text-nav-fg corner-squircle focus-visible:outline-none focus-visible:border-ring focus-visible:ring-[1px] focus-visible:ring-ring/40"
|
|
>
|
|
<span className="block line-clamp-3 whitespace-pre-wrap break-words">
|
|
{displayValue}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
|
|
<DialogContent
|
|
className="corner-squircle border border-border/60 bg-background/98 shadow-none sm:max-w-3xl"
|
|
overlayClassName="bg-background/35 supports-backdrop-filter:backdrop-blur-[1px]"
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle>Edit Chat Template</DialogTitle>
|
|
<DialogDescription>
|
|
Override the model's chat template. The change applies on the
|
|
next model reload.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-2">
|
|
<div className="space-y-0.5 px-0.5">
|
|
<div className="text-[11px] font-medium">Template editor</div>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
Jinja syntax. Save matching the default clears the override.
|
|
</p>
|
|
</div>
|
|
<Textarea
|
|
value={draft}
|
|
onChange={(event) => setDraft(event.target.value)}
|
|
fieldSizing="fixed"
|
|
className="min-h-[24rem] max-h-[50vh] overflow-y-auto font-mono text-xs leading-5 corner-squircle focus-visible:border-input focus-visible:ring-0"
|
|
rows={14}
|
|
spellCheck={false}
|
|
/>
|
|
</div>
|
|
<DialogFooter className="flex-wrap gap-2 sm:justify-between">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => setEditorOpen(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button type="button" onClick={saveEditor} disabled={!draftDirty}>
|
|
Save
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|