"use client"; import { useState } from "react"; import { CheckCircle2, ChevronDown, Eye, EyeOff, Info, Loader2, Pencil, Plus, Terminal, Trash2, } from "lucide-react"; import { useTranslation } from "react-i18next"; import ProviderIcon from "@/components/common/ProviderIcon"; import { type CatalogModel, type CatalogProfile, type LlmContextWindowDetection, type ServiceName, getActiveModel, getActiveProfile, useSettings, } from "./SettingsContext"; import { DimensionField } from "./DimensionField"; import { nextProfileName } from "./profile-naming"; import { searchProviderFields } from "./search-providers"; import { activeProfileDetail, deprecatedSearchProviders, formatContextWindowSource, inputClass, selectClass, selectOptionClass, stringifyExtraHeaders, supportedSearchProviders, } from "./shared"; const SERVICE_LABEL: Record = { llm: "LLM", embedding: "Embedding", search: "Search", tts: "Text-to-Speech", stt: "Speech-to-Text", imagegen: "Image Generation", videogen: "Video Generation", }; export function ServiceConfigEditor({ service }: { service: ServiceName }) { const { t } = useTranslation(); const { draft, catalogEditable, settingsError, providers, language, embeddingCapabilities, embeddingDefaultDim, logs, testRunning, mutateCatalog, addProfile, removeActiveProfile, addModel, removeActiveModel, updateProfileField, updateModelField, updateModelBoolField, updateContextWindowField, llmContextDetection, applyDetectedContextWindow, runDetailedTest, } = useSettings(); const activeProfile = getActiveProfile(draft, service); const activeModel = getActiveModel(draft, service); const [showApiKey, setShowApiKey] = useState(false); const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); const [editingModelId, setEditingModelId] = useState(null); const [editingModelName, setEditingModelName] = useState(""); const [editingProfileId, setEditingProfileId] = useState(null); const [editingProfileName, setEditingProfileName] = useState(""); // Reset API-key visibility whenever we land on a different profile or // switch services — same effect the old code had, but using React's // documented "store previous prop in state" pattern so it happens during // render rather than in a useEffect (which the linter forbids). const profileKey = `${service}:${activeProfile?.id ?? "none"}`; const [lastProfileKey, setLastProfileKey] = useState(profileKey); if (lastProfileKey !== profileKey) { setLastProfileKey(profileKey); if (showApiKey) setShowApiKey(false); } const searchProviderRaw = service === "search" ? (activeProfile?.provider || "").trim().toLowerCase() : ""; const showSearchProviderWarning = service === "search" && Boolean(searchProviderRaw); const isDeprecatedSearchProvider = deprecatedSearchProviders.has(searchProviderRaw); const isSupportedSearchProvider = supportedSearchProviders.includes( searchProviderRaw as (typeof supportedSearchProviders)[number], ); const isPerplexityMissingKey = service === "search" && searchProviderRaw === "perplexity" && !String(activeProfile?.api_key || "").trim(); const activeLlmDetection = service === "llm" && llmContextDetection?.profileId === draft.services.llm.active_profile_id && llmContextDetection?.modelId === draft.services.llm.active_model_id ? llmContextDetection : null; const startModelRename = (model: CatalogModel) => { setEditingModelId(model.id); setEditingModelName(model.name || model.model || ""); }; const commitModelRename = (modelId: string) => { const fallbackIndex = activeProfile?.models.findIndex((model) => model.id === modelId) ?? -1; const fallbackName = defaultModelLabel(language, fallbackIndex + 1); const nextName = editingModelName.trim() || fallbackName; mutateCatalog((next) => { const profile = getActiveProfile(next, service); const model = profile?.models.find((item) => item.id === modelId); if (model) model.name = nextName; }); setEditingModelId(null); setEditingModelName(""); }; const cancelModelRename = () => { setEditingModelId(null); setEditingModelName(""); }; const startProfileRename = (profile: CatalogProfile) => { setEditingProfileId(profile.id); setEditingProfileName(profile.name || ""); }; const commitProfileRename = (profileId: string) => { const nextName = editingProfileName.trim(); if (nextName) { mutateCatalog((next) => { const profile = next.services[service].profiles.find( (item) => item.id === profileId, ); if (profile) profile.name = nextName; }); } setEditingProfileId(null); setEditingProfileName(""); }; const cancelProfileRename = () => { setEditingProfileId(null); setEditingProfileName(""); }; if (!catalogEditable) { // catalogEditable=false covers two unrelated cases: settings fetch failed, // or multi-user grant denied. Split them so a Docker user without the // 8001 port mapped does not see an "assigned by administrator" hint. if (settingsError) { return (
{t( "Backend unreachable — model endpoints will appear once the connection is restored. See the banner above for details.", )}
); } return (
{t( "Model endpoints are assigned by your administrator. You can still personalize theme and language here.", )}
); } return (
{activeProfile ? (
{/* ── Profile list (sticky so it stays put while the editor scrolls) ── */} {/* ── Editor ── */}
{t("Provider connection")}
{service !== "search" && (
{t("Models")}
{activeProfile.models.length > 0 && (
{activeProfile.models.map((model, index) => { const isActive = model.id === draft.services[service].active_model_id; const label = (model.name || "").trim() || defaultModelLabel(language, index + 1); const metric = service === "llm" ? formatCompactTokens(model.context_window) : service === "embedding" ? formatDimensionBadge(model.dimension) : service === "tts" ? formatVoiceBadge(model.voice) : ""; return (
{editingModelId === model.id ? ( setEditingModelName(e.target.value) } onBlur={() => commitModelRename(model.id)} onKeyDown={(e) => { if (e.key === "Enter") { e.currentTarget.blur(); } if (e.key === "Escape") { e.preventDefault(); cancelModelRename(); } }} aria-label={t("Rename model")} /> ) : ( )}
); })}
)} {activeModel && (
{t("Model ID")}
updateModelField(service, "model", e.target.value) } placeholder="gpt-4o" />
{service === "llm" && ( <>
{t("Context Window")}
updateContextWindowField(e.target.value) } placeholder="65536" />
)} {service === "embedding" && (
{t("Dimension")}
updateModelField(service, "dimension", value) } />
)} {service === "tts" && ( <>
{t("Voice")}
updateModelField(service, "voice", e.target.value) } placeholder="alloy" />

{t( "Provider-specific voice name, e.g. alloy (OpenAI) or model:voice (SiliconFlow).", )}

{t("Output format")}
)} {service === "imagegen" && ( <>
{t("Image size")}
updateModelField(service, "size", e.target.value) } placeholder="1024x1024" />

{t( "Default pixel size sent with each request. Leave empty for the provider default.", )}

{t("Quality / Style")}
updateModelField( service, "quality", e.target.value, ) } placeholder={t("quality (e.g. hd)")} /> updateModelField( service, "style", e.target.value, ) } placeholder={t("style (e.g. vivid)")} />
)} {service === "videogen" && ( <>
{t("Aspect ratio")}
updateModelField( service, "aspect_ratio", e.target.value, ) } placeholder="16:9" />

{t( "Defaults sent with each request. Leave empty for the provider default.", )}

{t("Duration / Resolution")}
updateModelField( service, "duration", e.target.value, ) } placeholder={t("seconds")} /> updateModelField( service, "resolution", e.target.value, ) } placeholder="720p" />
)}
)}
)} {/* ── Diagnostics — per-service, inline ── */}
{diagnosticsOpen && (

{t( "Streams config snapshot, request target, response summary, and service-specific validation for the active {{service}} profile.", { service: t(SERVICE_LABEL[service]) }, )}

                    {testRunning === service || logs
                      ? logs
                      : t("Waiting for test run...")}
                  
)}
) : (
{t("No profiles configured. Add a profile to start.")}
)}
); } function defaultModelLabel(language: "en" | "zh", index: number): string { const safeIndex = index > 0 ? index : 1; return language === "zh" ? `模型${safeIndex}` : `Model ${safeIndex}`; } function formatCompactTokens(value: string | number | undefined): string { if (value === undefined || value === "") return ""; const parsed = typeof value === "number" ? value : Number.parseInt(String(value).replace(/[^\d]/g, ""), 10); if (!Number.isFinite(parsed) || parsed <= 0) return ""; if (parsed >= 1_000_000) { const m = parsed / 1_000_000; return `${m >= 10 ? m.toFixed(0) : m.toFixed(1).replace(/\.0$/, "")}M`; } if (parsed >= 1_000) { const k = parsed / 1_000; return `${k >= 10 ? k.toFixed(0) : k.toFixed(1).replace(/\.0$/, "")}K`; } return String(parsed); } function formatVoiceBadge(value: string | undefined): string { const voice = (value || "").trim(); if (!voice) return ""; // "model:voice" → show just the voice segment to keep the chip compact. const tail = voice.includes(":") ? voice.slice(voice.lastIndexOf(":") + 1) : voice; return tail.length > 14 ? `${tail.slice(0, 13)}…` : tail; } function formatDimensionBadge(value: string | number | undefined): string { if (value === undefined || value === "") return ""; const parsed = typeof value === "number" ? value : Number.parseInt(String(value).replace(/[^\d]/g, ""), 10); if (!Number.isFinite(parsed) || parsed <= 0) return ""; return `${parsed}d`; } function formatIsoLocal(value: string | undefined): string { if (!value) return ""; const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) return value; const pad = (n: number) => String(n).padStart(2, "0"); return ( `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())} ` + `${pad(parsed.getHours())}:${pad(parsed.getMinutes())}` ); } function ContextWindowMeta({ model }: { model: CatalogModel }) { const { t } = useTranslation(); if (!model.context_window) return null; const source = formatContextWindowSource(model.context_window_source, t); const updatedAt = formatIsoLocal(model.context_window_detected_at); return (
{t("Source")}: {source} {updatedAt && ( <> · {updatedAt} )}
); } function ContextWindowDetectionBanner({ model, detection, onApply, }: { model: CatalogModel; detection: LlmContextWindowDetection | null; onApply: () => void; }) { const { t } = useTranslation(); if (!detection) return null; const currentRaw = Number.parseInt( String(model.context_window || "").replace(/[^\d]/g, ""), 10, ); const matches = Number.isFinite(currentRaw) && currentRaw === detection.contextWindow; const detectedFormatted = detection.contextWindow.toLocaleString("en-US"); const detectedAt = formatIsoLocal(detection.detectedAt); const source = formatContextWindowSource(detection.source, t); if (matches) { return (
{t("Detected value matches your current setting")} ({detectedFormatted} · {source})
); } return (
{t("Detected")}: {detectedFormatted} · {source} {detectedAt && ( · {detectedAt} )}
); } function ProfileFields({ service, profile, showApiKey, setShowApiKey, showSearchProviderWarning, isSupportedSearchProvider, isDeprecatedSearchProvider, isPerplexityMissingKey, }: { service: ServiceName; profile: CatalogProfile; showApiKey: boolean; setShowApiKey: (next: boolean | ((prev: boolean) => boolean)) => void; showSearchProviderWarning: boolean; isSupportedSearchProvider: boolean; isDeprecatedSearchProvider: boolean; isPerplexityMissingKey: boolean; }) { const { t } = useTranslation(); const { providers, updateProfileField, updateModelField } = useSettings(); const [extraOpen, setExtraOpen] = useState(false); const providerValue = service === "search" ? profile.provider || "" : profile.binding || ""; // Only the search service hides fields by provider. LLM/embedding always // expose Base URL and API Key, so default both to shown for them. const fields = service === "search" ? searchProviderFields(profile.provider) : { apiKey: true, baseUrl: true, baseUrlRequired: false }; const searxngMissingBaseUrl = fields.baseUrlRequired && !String(profile.base_url || "").trim(); return (
{t("Provider")}
{providerValue && ( )}
{showSearchProviderWarning && (

{isSupportedSearchProvider ? isPerplexityMissingKey ? t( "Perplexity requires API key. It will fail hard without credentials.", ) : t("Supported provider.") : isDeprecatedSearchProvider ? t( "Deprecated provider. Switch to brave/tavily/jina/searxng/duckduckgo/perplexity.", ) : t( "Unsupported provider. Use brave/tavily/jina/searxng/duckduckgo/perplexity.", )}

)}
{fields.baseUrl && (
{service === "embedding" ? t("Endpoint URL") : t("Base URL")}
updateProfileField(service, "base_url", e.target.value) } placeholder={ service === "embedding" ? "https://api.openai.com/v1/embeddings" : service === "search" ? "http://localhost:8888" : "https://api.openai.com/v1" } /> {service === "embedding" && (

{t( "Embedding requests are sent to this URL exactly; DeepTutor does not append /embeddings or /api/embed at request time.", )}

)} {searxngMissingBaseUrl && (

{t("Required — without it, search falls back to DuckDuckGo.")}

)}
)} {fields.apiKey && (
{t("API Key")}
updateProfileField(service, "api_key", e.target.value) } placeholder="sk-..." />
)}
{extraOpen && (
{t("API Version")}
updateProfileField(service, "api_version", e.target.value) } placeholder={t("Optional")} />
{service === "search" ? (
{t("Proxy")}
updateProfileField(service, "proxy", e.target.value) } placeholder={t("http://127.0.0.1:7890 (optional)")} />
) : (
{t("Extra Headers (JSON)")}