| 1 | import { useId, useLayoutEffect, useRef, useState } from "react"; |
| 2 | import { createPortal } from "react-dom"; |
| 3 | import { LockKeyhole, RotateCcw } from "lucide-react"; |
| 4 | import { useProviderT as useT } from "../lib/providerSettingsLocale"; |
| 5 | import { imageInputHardBlocked, imageInputState } from "../lib/providerImageInput"; |
| 6 | import { ModalCloseButton } from "./ModalCloseButton"; |
| 7 | import type { ProviderModelCapabilityView } from "../lib/types"; |
| 8 | import { modelDraftError } from "../lib/providerModelDraft"; |
| 9 | |
| 10 | export interface ModelDetailsDraft { model: string; contextWindow: string; maxOutputTokens: number; vision: boolean | null; supportedEfforts: string[]; defaultEffort: string; } |
| 11 | export default function ProviderModelDialog({ initial, candidates, contextDefault, capability, baseURL, busy, effortOptions, onClose, onApply, onDelete }: { |
| 12 | initial?: ModelDetailsDraft; candidates: string[]; contextDefault?: number; |
| 13 | effortOptions?: string[]; effortDefault?: string; |
| 14 | capability?: ProviderModelCapabilityView; baseURL?: string; busy: boolean; onClose: () => void; onApply: (draft: ModelDetailsDraft) => void; |
| 15 | onDelete?: () => void; |
| 16 | }) { |
| 17 | const t = useT(), titleId = useId(); |
| 18 | const dialog = useRef<HTMLDialogElement>(null); |
| 19 | const [model, setModel] = useState(initial?.model ?? ""); |
| 20 | const [context, setContext] = useState(initial?.contextWindow ?? ""); |
| 21 | const [output, setOutput] = useState(initial?.maxOutputTokens ? String(Math.max(-1, initial.maxOutputTokens)) : ""); |
| 22 | const [vision, setVision] = useState(initial?.vision == null ? "auto" : String(initial.vision)); |
| 23 | const [effort, setEffort] = useState(initial?.defaultEffort ?? ""); |
| 24 | const [explicitEfforts, setExplicitEfforts] = useState(Boolean(initial?.supportedEfforts?.length)); |
| 25 | const [selectedEfforts, setSelectedEfforts] = useState<string[]>(initial?.supportedEfforts?.length ? initial.supportedEfforts : effortOptions ?? []); |
| 26 | const [error, setError] = useState(false); |
| 27 | useLayoutEffect(() => { |
| 28 | const previous = document.activeElement as HTMLElement | null; |
| 29 | dialog.current?.showModal(); |
| 30 | dialog.current?.querySelector<HTMLInputElement>("input:not(:disabled)")?.focus(); |
| 31 | return () => { if (previous?.isConnected) previous.focus(); }; |
| 32 | }, []); |
| 33 | const validation = modelDraftError({model, context, output, vision: "auto"}, candidates, initial?.model); |
| 34 | const imageBlocked = imageInputHardBlocked(baseURL, model, capability); |
| 35 | const imageState = imageBlocked ? "unsupported" : imageInputState(vision === "auto" ? "auto" : vision === "true" ? "on" : "off", capability); |
| 36 | const toggleEffort = (option: string, checked: boolean) => { |
| 37 | setExplicitEfforts(true); |
| 38 | setSelectedEfforts(current => { |
| 39 | const next = checked ? [...current, option] : current.filter(item => item !== option); |
| 40 | if (!checked && effort === option) setEffort(""); |
| 41 | return next; |
| 42 | }); |
| 43 | }; |
| 44 | return createPortal(<dialog ref={dialog} className="provider-model-dialog" data-app-overlay="" aria-labelledby={titleId} |
| 45 | onCancel={event => { event.preventDefault(); if (!busy) onClose(); }}> |
| 46 | <form onSubmit={event => { event.preventDefault(); if (validation) { setError(true); return; } const defaultEffort = effort && selectedEfforts.includes(effort) ? effort : ""; onApply({ model:model.trim(), contextWindow:context.trim(), maxOutputTokens:Number(output) || 0, vision:vision === "auto" ? null : !imageBlocked && vision === "true", supportedEfforts: explicitEfforts ? selectedEfforts : [], defaultEffort }); }}> |
| 47 | <header><h2 id={titleId}>{t(initial ? "settings.modelDialog.edit" : "settings.models.add")}</h2><ModalCloseButton label={t("common.close")} disabled={busy} onClick={onClose}/></header> |
| 48 | <label className="provider-model-dialog__id">{t("settings.modelDialog.id")} |
| 49 | {initial ? <span><LockKeyhole size={16}/>{model}</span> : <input autoFocus className="mem-input" value={model} disabled={busy} onChange={e=>setModel(e.target.value)} placeholder="deepseek-v4-flash"/>} |
| 50 | </label> |
| 51 | <div className="provider-model-dialog__columns"> |
| 52 | <section><h3>{t("settings.modelDialog.parameters")}</h3> |
| 53 | <label htmlFor={`${titleId}-context`}>{t("settings.modelContextWindow")}<button type="button" className="btn provider-icon-action" title={t("settings.modelDialog.reset")} aria-label={t("settings.modelDialog.resetContext")} disabled={busy} onClick={()=>setContext("")}><RotateCcw size={16}/></button> |
| 54 | <input id={`${titleId}-context`} className="mem-input" type="number" min={1} value={context} disabled={busy} placeholder={t("settings.models.inherit")} onChange={e=>setContext(e.target.value)}/> |
| 55 | </label> |
| 56 | <p>{t("settings.models.inherit")}{contextDefault ? ` · ${contextDefault.toLocaleString()}` : ""}</p> |
| 57 | <label htmlFor={`${titleId}-output`}>{t("settings.modelDialog.outputLimit")}<button type="button" className="btn provider-icon-action" title={t("settings.modelDialog.reset")} aria-label={t("settings.modelDialog.resetOutput")} disabled={busy} onClick={()=>setOutput("")}><RotateCcw size={16}/></button> |
| 58 | <input id={`${titleId}-output`} className="mem-input" type="number" min={-1} value={output} disabled={busy} placeholder={t("settings.models.inherit")} onChange={e=>setOutput(e.target.value)}/> |
| 59 | </label> |
| 60 | <p>{t("settings.modelDialog.outputHint")}</p> |
| 61 | {effortOptions && effortOptions.length > 0 && <div className="provider-model-dialog__effort-card"> |
| 62 | <label>{t("settings.modelDialog.reasoningEffortOptions")} |
| 63 | <button type="button" className="btn provider-icon-action" title={t("settings.modelDialog.reset")} aria-label={t("settings.modelDialog.resetReasoningEffort")} disabled={busy} onClick={()=>{setEffort("");setExplicitEfforts(false);setSelectedEfforts(effortOptions ?? []);}}><RotateCcw size={16}/></button> |
| 64 | </label> |
| 65 | <div className="provider-model-dialog__chips"> |
| 66 | {effortOptions.map(option => <label key={option}><input type="checkbox" checked={selectedEfforts.includes(option)} disabled={busy} onChange={event=>toggleEffort(option, event.target.checked)}/>{option}</label>)} |
| 67 | </div> |
| 68 | <label htmlFor={`${titleId}-effort`}>{t("settings.modelDialog.reasoningEffortDefault")}</label> |
| 69 | <select id={`${titleId}-effort`} className="mem-select" value={effort} disabled={busy || selectedEfforts.length === 0} onChange={event=>setEffort(event.target.value)}> |
| 70 | <option value="">{t("settings.modelDialog.automatic")}</option> |
| 71 | {selectedEfforts.map(option => <option key={option} value={option}>{option}</option>)} |
| 72 | </select> |
| 73 | <p>{t("settings.modelDialog.reasoningEffortHint")}</p> |
| 74 | </div>} |
| 75 | {capability?.reasoning?.state === "unknown" && <p>{t("settings.modelDialog.reasoningUnknown")}</p>} |
| 76 | {capability?.reasoning?.state === "unsupported" && <p>{t("settings.modelDialog.reasoningUnsupported")}</p>} |
| 77 | {capability?.reasoning?.error && <p role="alert">{capability.reasoning.error}</p>} |
| 78 | </section> |
| 79 | <aside><h3>{t("settings.modelDialog.capabilities")}</h3> |
| 80 | <div className="provider-model-dialog__capability-title">{t("settings.modelDialog.input")}</div> |
| 81 | <div className="provider-model-dialog__chips"> |
| 82 | <label><input type="checkbox" checked disabled/>{t("settings.textInput")}<LockKeyhole size={14}/></label> |
| 83 | <label><input type="checkbox" checked={imageState === "supported"} disabled={busy || imageBlocked} onChange={event=>setVision(String(event.target.checked))}/>{t("settings.modelDialog.image")}</label> |
| 84 | <label title={t("settings.modelDialog.unavailable")}><input type="checkbox" checked={false} disabled/>{t("settings.modelDialog.video")}</label> |
| 85 | <label title={t("settings.modelDialog.unavailable")}><input type="checkbox" checked={false} disabled/>PDF</label> |
| 86 | </div> |
| 87 | <p>{t("settings.modelDialog.unavailable")}</p> |
| 88 | <div className="provider-model-dialog__capability-title">{t("settings.modelDialog.output")}</div> |
| 89 | <div className="provider-model-dialog__chips"><label><input type="checkbox" checked disabled/>{t("settings.textInput")}<LockKeyhole size={14}/></label></div> |
| 90 | <dl><div><dt>{t("settings.modelDialog.source")}</dt><dd>{vision !== "auto" ? t("settings.modelDialog.manual") : capability ? t("settings.modelDialog.metadata") : t("settings.imageInputUnknown")}</dd></div></dl> |
| 91 | <button type="button" className="btn" disabled={busy || vision === "auto"} onClick={()=>setVision("auto")}>{t("settings.modelDialog.restore")}</button> |
| 92 | <p>{t("settings.modelDialog.overrideHint")}</p> |
| 93 | </aside> |
| 94 | </div> |
| 95 | {error && validation && <p role="alert">{t(`providerUI.validation.${validation}`)}</p>} |
| 96 | <footer>{initial && onDelete && <button type="button" className="btn btn--danger" disabled={busy} onClick={onDelete}>{t("providerUI.deleteModel")}</button>}<small>{t("settings.modelDialog.draftHint")}</small><button type="button" className="btn" disabled={busy} onClick={onClose}>{t("common.cancel")}</button><button className="btn btn--primary" disabled={busy}>{t(initial ? "settings.modelDialog.apply" : "settings.modelDialog.add")}</button></footer> |
| 97 | </form> |
| 98 | </dialog>, document.body); |
| 99 | } |
| 100 |