Spaces:
Paused
Paused
| import { useT } from "@shared/i18n/context"; | |
| import { CopyButton } from "./CopyButton"; | |
| import { useCallback, useState, useEffect, useRef } from "preact/hooks"; | |
| import type { ModelFamily } from "@shared/hooks/use-status"; | |
| interface ApiConfigProps { | |
| baseUrl: string; | |
| apiKey: string; | |
| models: string[]; | |
| selectedModel: string; | |
| onModelChange: (model: string) => void; | |
| modelFamilies: ModelFamily[]; | |
| selectedEffort: string; | |
| onEffortChange: (effort: string) => void; | |
| selectedSpeed: string | null; | |
| onSpeedChange: (speed: string | null) => void; | |
| } | |
| const EFFORT_LABELS: Record<string, string> = { | |
| none: "None", | |
| minimal: "Minimal", | |
| low: "Low", | |
| medium: "Medium", | |
| high: "High", | |
| xhigh: "XHigh", | |
| }; | |
| export function ApiConfig({ | |
| baseUrl, | |
| apiKey, | |
| models, | |
| selectedModel, | |
| onModelChange, | |
| modelFamilies, | |
| selectedEffort, | |
| onEffortChange, | |
| selectedSpeed, | |
| onSpeedChange, | |
| }: ApiConfigProps) { | |
| const t = useT(); | |
| const getBaseUrl = useCallback(() => baseUrl, [baseUrl]); | |
| const getApiKey = useCallback(() => apiKey, [apiKey]); | |
| const [open, setOpen] = useState(false); | |
| const dropdownRef = useRef<HTMLDivElement>(null); | |
| // Close dropdown on click outside | |
| useEffect(() => { | |
| if (!open) return; | |
| const handler = (e: MouseEvent) => { | |
| if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { | |
| setOpen(false); | |
| } | |
| }; | |
| document.addEventListener("mousedown", handler); | |
| return () => document.removeEventListener("mousedown", handler); | |
| }, [open]); | |
| // When a family is selected, update model + snap effort to default if current effort is unsupported | |
| const handleFamilySelect = useCallback( | |
| (family: ModelFamily) => { | |
| onModelChange(family.id); | |
| setOpen(false); | |
| const supportedEfforts = family.efforts.map((e) => e.reasoningEffort); | |
| if (!supportedEfforts.includes(selectedEffort)) { | |
| onEffortChange(family.defaultEffort); | |
| } | |
| }, | |
| [onModelChange, onEffortChange, selectedEffort], | |
| ); | |
| // Find the currently selected family's supported efforts | |
| const currentFamily = modelFamilies.find((f) => f.id === selectedModel); | |
| const currentEfforts = currentFamily?.efforts ?? []; | |
| const showMatrix = modelFamilies.length > 0; | |
| return ( | |
| <section class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-5 shadow-sm transition-colors"> | |
| <div class="flex items-center justify-between mb-6 border-b border-slate-100 dark:border-border-dark pb-4"> | |
| <div class="flex items-center gap-2"> | |
| <svg class="size-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z" /> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> | |
| </svg> | |
| <h2 class="text-[0.95rem] font-bold">{t("apiConfig")}</h2> | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| {/* Base URL */} | |
| <div class="space-y-1.5"> | |
| <label class="text-xs font-semibold text-slate-700 dark:text-text-main">{t("baseProxyUrl")}</label> | |
| <div class="relative flex items-center"> | |
| <input | |
| class="w-full pl-3 pr-10 py-2.5 bg-slate-100 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] font-mono text-slate-500 dark:text-text-dim outline-none cursor-default select-all" | |
| type="text" | |
| value={baseUrl} | |
| readOnly | |
| /> | |
| <CopyButton getText={getBaseUrl} class="absolute right-2" titleKey="copyUrl" /> | |
| </div> | |
| </div> | |
| {/* Model selector — matrix or flat fallback */} | |
| <div class="space-y-1.5"> | |
| <label class="text-xs font-semibold text-slate-700 dark:text-text-main">{t("defaultModel")}</label> | |
| {showMatrix ? ( | |
| <div ref={dropdownRef} class="relative"> | |
| {/* Trigger button */} | |
| <button | |
| onClick={() => setOpen(!open)} | |
| class="w-full flex items-center justify-between px-3 py-2.5 bg-white dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] text-slate-700 dark:text-text-main font-medium focus:ring-1 focus:ring-primary focus:border-primary outline-none cursor-pointer transition-colors" | |
| > | |
| <span>{currentFamily?.displayName ?? selectedModel}</span> | |
| <svg class={`size-[18px] text-slate-500 dark:text-text-dim transition-transform ${open ? "rotate-180" : ""}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" /> | |
| </svg> | |
| </button> | |
| {/* Dropdown list */} | |
| {open && ( | |
| <div class="absolute z-10 mt-1 w-full border border-gray-200 dark:border-border-dark rounded-lg overflow-hidden bg-white dark:bg-card-dark shadow-lg"> | |
| <div class="max-h-[200px] overflow-y-auto"> | |
| {modelFamilies.map((f) => ( | |
| <button | |
| key={f.id} | |
| onClick={() => handleFamilySelect(f)} | |
| class={`w-full text-left px-3 py-2 text-[0.78rem] font-medium border-b border-gray-100 dark:border-border-dark last:border-b-0 transition-colors ${ | |
| selectedModel === f.id | |
| ? "bg-primary/10 text-primary dark:bg-primary/20" | |
| : "text-slate-700 dark:text-text-main hover:bg-slate-50 dark:hover:bg-[#21262d]" | |
| }`} | |
| > | |
| {f.displayName} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Reasoning effort buttons — always visible */} | |
| {currentEfforts.length > 1 && ( | |
| <div class="flex gap-1.5 mt-2 flex-wrap"> | |
| {currentEfforts.map((e) => ( | |
| <button | |
| key={e.reasoningEffort} | |
| onClick={() => onEffortChange(e.reasoningEffort)} | |
| title={e.description} | |
| class={`px-2.5 py-1 text-[0.7rem] font-semibold rounded transition-all ${ | |
| selectedEffort === e.reasoningEffort | |
| ? "bg-primary text-white shadow-sm" | |
| : "bg-white dark:bg-[#21262d] text-slate-600 dark:text-text-dim border border-gray-200 dark:border-border-dark hover:border-primary/50" | |
| }`} | |
| > | |
| {EFFORT_LABELS[e.reasoningEffort] ?? e.reasoningEffort} | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| {/* Speed toggle — Standard / Fast */} | |
| <div class="flex items-center gap-1.5 mt-2"> | |
| <span class="text-[0.68rem] font-medium text-slate-500 dark:text-text-dim mr-1">{t("speed")}</span> | |
| <button | |
| onClick={() => onSpeedChange(null)} | |
| class={`px-2.5 py-1 text-[0.7rem] font-semibold rounded transition-all ${ | |
| selectedSpeed === null | |
| ? "bg-primary text-white shadow-sm" | |
| : "bg-white dark:bg-[#21262d] text-slate-600 dark:text-text-dim border border-gray-200 dark:border-border-dark hover:border-primary/50" | |
| }`} | |
| > | |
| {t("speedStandard")} | |
| </button> | |
| <button | |
| onClick={() => onSpeedChange("fast")} | |
| class={`px-2.5 py-1 text-[0.7rem] font-semibold rounded transition-all ${ | |
| selectedSpeed === "fast" | |
| ? "bg-primary text-white shadow-sm" | |
| : "bg-white dark:bg-[#21262d] text-slate-600 dark:text-text-dim border border-gray-200 dark:border-border-dark hover:border-primary/50" | |
| }`} | |
| > | |
| {t("speedFast")} | |
| </button> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div class="relative"> | |
| <select | |
| class="w-full appearance-none pl-3 pr-10 py-2.5 bg-white dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] text-slate-700 dark:text-text-main font-medium focus:ring-1 focus:ring-primary focus:border-primary outline-none cursor-pointer transition-colors" | |
| value={selectedModel} | |
| onChange={(e) => onModelChange((e.target as HTMLSelectElement).value)} | |
| > | |
| {models.map((m) => ( | |
| <option key={m} value={m}>{m}</option> | |
| ))} | |
| </select> | |
| <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-slate-500 dark:text-text-dim"> | |
| <svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" /> | |
| </svg> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* API Key */} | |
| <div class="space-y-1.5 md:col-span-2"> | |
| <label class="text-xs font-semibold text-slate-700 dark:text-text-main">{t("yourApiKey")}</label> | |
| <div class="relative flex items-center"> | |
| <div class="absolute left-3 text-slate-400 dark:text-text-dim"> | |
| <svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" /> | |
| </svg> | |
| </div> | |
| <input | |
| class="w-full pl-10 pr-10 py-2.5 bg-slate-100 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] font-mono text-slate-500 dark:text-text-dim outline-none cursor-default select-all tracking-wider" | |
| type="text" | |
| value={apiKey} | |
| readOnly | |
| /> | |
| <CopyButton getText={getApiKey} class="absolute right-2" titleKey="copyApiKey" /> | |
| </div> | |
| <p class="text-xs text-slate-400 dark:text-text-dim mt-1">{t("apiKeyHint")}</p> | |
| </div> | |
| </div> | |
| </section> | |
| ); | |
| } | |