From 1c2f92412a8d5ab830321b6413776a43c5caca49 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:19:14 +0200 Subject: [PATCH 1/4] refactor(gui): split Combos workspace for react-doctor Extract ComboWorkspace panels/modals and harden fetch/loading paths for the 0.9.1 doctor rules. --- gui/src/combo-workspace-data.ts | 17 +- gui/src/components/ComboWorkspace.tsx | 838 +----------------- .../components/combo-workspace-add-modal.tsx | 198 +++++ .../components/combo-workspace-controls.tsx | 230 +++++ .../combo-workspace-detail-panel.tsx | 269 ++++++ .../components/combo-workspace-dialogs.tsx | 86 ++ .../combo-workspace-overview-panel.tsx | 58 ++ gui/src/components/combo-workspace-types.ts | 25 + gui/src/components/combo-workspace-utils.ts | 40 + gui/src/pages/Combos.tsx | 10 +- 10 files changed, 938 insertions(+), 833 deletions(-) create mode 100644 gui/src/components/combo-workspace-add-modal.tsx create mode 100644 gui/src/components/combo-workspace-controls.tsx create mode 100644 gui/src/components/combo-workspace-detail-panel.tsx create mode 100644 gui/src/components/combo-workspace-dialogs.tsx create mode 100644 gui/src/components/combo-workspace-overview-panel.tsx create mode 100644 gui/src/components/combo-workspace-types.ts create mode 100644 gui/src/components/combo-workspace-utils.ts diff --git a/gui/src/combo-workspace-data.ts b/gui/src/combo-workspace-data.ts index 5d3f5f0b4..227d3aafc 100644 --- a/gui/src/combo-workspace-data.ts +++ b/gui/src/combo-workspace-data.ts @@ -12,6 +12,19 @@ export interface ComboTarget { provider: string; model: string; weight?: number; + /** UI-only stable key for React lists; never sent to the API. */ + clientKey?: string; +} + +let comboTargetKeySeq = 0; + +export function newComboTarget(partial: Partial = {}): ComboTarget { + return { + provider: partial.provider ?? "", + model: partial.model ?? "", + ...(partial.weight !== undefined ? { weight: partial.weight } : {}), + clientKey: partial.clientKey ?? `ct-${++comboTargetKeySeq}`, + }; } export interface ComboItem { @@ -101,7 +114,7 @@ export function parseComboList(payload: unknown): ComboItem[] { const model = typeof tr.model === "string" ? tr.model.trim() : ""; if (!provider || !model) continue; const weight = normalizeWeight(tr.weight); - targets.push(weight !== undefined ? { provider, model, weight } : { provider, model }); + targets.push(weight !== undefined ? newComboTarget({ provider, model, weight }) : newComboTarget({ provider, model })); } out.push({ id, @@ -275,6 +288,6 @@ export function emptyDraft(id = ""): ComboItem { strategy: "failover", stickyLimit: 1, defaultEffort: null, - targets: [{ provider: "", model: "" }], + targets: [newComboTarget()], }; } diff --git a/gui/src/components/ComboWorkspace.tsx b/gui/src/components/ComboWorkspace.tsx index 13e4d3f17..fa479391e 100644 --- a/gui/src/components/ComboWorkspace.tsx +++ b/gui/src/components/ComboWorkspace.tsx @@ -1,838 +1,20 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { - type ComboEffort, type ComboItem, - type ComboStrategy, - type ComboTarget, - COMBO_EFFORTS, - buildComboAttention, comboModelId, - comboPublicModelId, - draftEquals, emptyDraft, filterCombos, groupCombos, - validateComboDraft, } from "../combo-workspace-data"; -import { - IconAlert, - IconChevron, - IconGrip, - IconPlus, - IconSearch, - IconShuffle, - IconTrash, - IconX, -} from "../icons"; -import { useT } from "../i18n"; -import { Notice } from "../ui"; - -export type ProviderOption = { - name: string; - disabled?: boolean; - hiddenFromPicker?: boolean; - authMode?: string; - adapter?: string; - baseUrl?: string; -}; -export type ModelOption = { provider: string; id: string; namespaced?: string }; - -type DetailTab = "config" | "about"; - -export interface ComboWorkspaceProps { - combos: ComboItem[]; - providers: ProviderOption[]; - models: ModelOption[]; - loading?: boolean; - onRefresh: () => void; - onSave: (item: ComboItem, isCreate: boolean, renameFrom?: string) => Promise<{ ok: boolean; error?: string }>; - onRemove: (id: string) => Promise<{ ok: boolean; error?: string }>; - onAdd: () => void; - adding: boolean; - onCloseAdd: () => void; - onCreated: (id: string) => void; -} - -function enabledProviders(providers: ProviderOption[]): ProviderOption[] { - return providers - .filter((p) => !p.disabled && !p.hiddenFromPicker) - .sort((a, b) => a.name.localeCompare(b.name)); -} - -/** ChatGPT passthrough has no /models catalog — GPT slugs are listed under provider "openai". */ -function isChatGptForwardOption(p: ProviderOption | undefined): boolean { - if (!p) return false; - const id = p.name.toLowerCase(); - if (id !== "openai" && id !== "chatgpt") return false; - if ((p.authMode ?? "").toLowerCase() !== "forward") return false; - if ((p.adapter ?? "").toLowerCase() !== "openai-responses") return false; - const base = (p.baseUrl ?? "").replace(/\/+$/, ""); - return !base || base.includes("chatgpt.com/backend-api/codex"); -} - -function modelsForProvider( - models: ModelOption[], - provider: string, - providers: ProviderOption[], -): string[] { - const keys = new Set([provider]); - const meta = providers.find((p) => p.name === provider); - // Alias chatgpt → openai native GPT rows (forward providers don't publish their own catalog). - if (provider.toLowerCase() === "chatgpt" || isChatGptForwardOption(meta)) { - keys.add("openai"); - } - const ids = models - .filter((m) => keys.has(m.provider)) - .map((m) => m.id) - .filter(Boolean); - return [...new Set(ids)].sort((a, b) => a.localeCompare(b)); -} - -function StrategySeg({ - value, - onChange, - disabled, -}: { - value: ComboStrategy; - onChange: (next: ComboStrategy) => void; - disabled?: boolean; -}) { - const t = useT(); - return ( -
- {([ - ["failover", "cws.strategy.failover"], - ["round-robin", "cws.strategy.roundRobin"], - ] as const).map(([id, key]) => ( - - ))} -
- ); -} - -function EffortSelect({ - id, - value, - onChange, - disabled, -}: { - id: string; - value: ComboEffort | null; - onChange: (next: ComboEffort | null) => void; - disabled?: boolean; -}) { - const t = useT(); - return ( - - ); -} - -function TargetEditor({ - targets, - strategy, - providers, - models, - onChange, -}: { - targets: ComboTarget[]; - strategy: ComboStrategy; - providers: ProviderOption[]; - models: ModelOption[]; - onChange: (next: ComboTarget[]) => void; -}) { - const t = useT(); - const provs = enabledProviders(providers); - const [dragIndex, setDragIndex] = useState(null); - const [overIndex, setOverIndex] = useState(null); - - const update = (index: number, patch: Partial) => { - onChange(targets.map((row, i) => (i === index ? { ...row, ...patch } : row))); - }; - - const reorder = (from: number, to: number) => { - if (from === to || from < 0 || to < 0 || from >= targets.length || to >= targets.length) return; - const copy = [...targets]; - const [moved] = copy.splice(from, 1); - copy.splice(to, 0, moved!); - onChange(copy); - }; - - return ( -
- {targets.map((row, index) => { - const currentProvider = providers.find((provider) => provider.name === row.provider); - const providerOptions = currentProvider && !provs.some((provider) => provider.name === row.provider) - ? [...provs, currentProvider] - : provs; - const modelIds = modelsForProvider(models, row.provider, providers); - const options = row.model && !modelIds.includes(row.model) - ? [row.model, ...modelIds] - : modelIds; - const modelSelectDisabled = !row.provider; - const dragging = dragIndex === index; - const dropTarget = overIndex === index && dragIndex !== null && dragIndex !== index; - return ( -
{ - if (dragIndex === null) return; - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - if (overIndex !== index) setOverIndex(index); - }} - onDrop={(e) => { - e.preventDefault(); - if (dragIndex !== null) reorder(dragIndex, index); - setDragIndex(null); - setOverIndex(null); - }} - onDragEnd={() => { - setDragIndex(null); - setOverIndex(null); - }} - > - - - - {strategy === "round-robin" && ( - update(index, { weight: Number(e.target.value) })} - /> - )} -
- -
-
- ); - })} - -
- ); -} - -function RemoveComboDialog({ - model, - onCancel, - onConfirm, -}: { - model: string; - onCancel: () => void; - onConfirm: () => void; -}) { - const t = useT(); - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") { - e.preventDefault(); - onCancel(); - } - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [onCancel]); - - return ( -
-
e.stopPropagation()}> -

- {t("cws.removeConfirmTitle", { model })} -

-

{t("cws.removeConfirmDesc")}

-
- - -
-
-
- ); -} - -function UnsavedLeaveDialog({ - onKeep, - onDiscard, -}: { - onKeep: () => void; - onDiscard: () => void; -}) { - const t = useT(); - return ( -
-
e.stopPropagation()}> -

{t("cws.unsavedTitle")}

-

{t("cws.unsavedDesc")}

-
- - -
-
-
- ); -} - -export function AddComboModal({ - existingIds, - existingAliases, - providerMap, - providers, - models, - onClose, - onSubmit, -}: { - existingIds: string[]; - existingAliases: string[]; - providerMap: Readonly>; - providers: ProviderOption[]; - models: ModelOption[]; - onClose: () => void; - onSubmit: (item: ComboItem) => Promise<{ ok: boolean; error?: string }>; -}) { - const t = useT(); - const [draft, setDraft] = useState(() => emptyDraft()); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape" && !busy) { - e.preventDefault(); - onClose(); - } - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [busy, onClose]); - - const submit = async () => { - const code = validateComboDraft(draft, { - existingIds, - existingAliases, - isCreate: true, - providers: providerMap, - }); - if (code) { - setError(t(`cws.err.${code}`)); - return; - } - setBusy(true); - setError(""); - const id = draft.id.trim(); - const alias = draft.alias?.trim() || null; - const res = await onSubmit({ ...draft, id, alias, model: comboPublicModelId(id, alias) }); - setBusy(false); - if (!res.ok) { - setError(res.error || t("cws.saveFailed")); - return; - } - }; - - return ( -
{ if (!busy) onClose(); }}> -
e.stopPropagation()}> -
-

{t("cws.addTitle")}

- -
-

{t("cws.addSubtitle")}

- {error && {error}} -
-
- - setDraft((d) => ({ - ...d, - id: e.target.value, - model: comboPublicModelId(e.target.value, d.alias), - }))} - /> -

- {t("cws.field.idInternalHint")} -

-
-
- - setDraft((d) => ({ - ...d, - alias: e.target.value.trim() ? e.target.value : null, - model: comboPublicModelId(d.id, e.target.value), - }))} - /> -

- {t("cws.field.aliasHint")} -

-

- {t("cws.field.idHint", { - model: draft.id.trim() ? comboPublicModelId(draft.id, draft.alias) : "…", - })} -

-
-
- - setDraft((d) => ({ ...d, strategy }))} - /> -

- {draft.strategy === "failover" ? t("cws.strategy.failoverHint") : t("cws.strategy.roundRobinHint")} -

-
-
- - setDraft((d) => ({ ...d, defaultEffort }))} - /> -

- {t("cws.field.defaultEffortHint")} -

-
- {draft.strategy === "round-robin" && ( -
- - setDraft((d) => ({ ...d, stickyLimit: Number(e.target.value) }))} - /> -

- {t("cws.field.stickyLimitHint")} -

-
- )} -
- -

- {draft.strategy === "failover" ? t("cws.targets.failoverHint") : t("cws.targets.roundRobinHint")} -

- setDraft((d) => ({ ...d, targets }))} - /> -
-
-
- - -
-
-
- ); -} - -function OverviewPanel({ - combos, - onSelect, - onAdd, -}: { - combos: ComboItem[]; - onSelect: (id: string) => void; - onAdd: () => void; -}) { - const t = useT(); - const sections = groupCombos(combos); - const attention = buildComboAttention(combos); - - return ( -
-
-

{t("cws.overviewTitle")}

- -
-

{t("cws.overviewBlurb")}

-
-
{combos.length}{t("cws.count.total")}
-
{sections.failover.length}{t("cws.count.failover")}
-
{sections.roundRobin.length}{t("cws.count.roundRobin")}
-
- -
-

{t("cws.howTitle")}

-

{t("cws.howBody")}

-
- - {attention.length > 0 && ( -
-

{t("cws.attentionTitle")}

-
- {attention.map((item) => ( - - ))} -
-
- )} -
- ); -} - -function DetailPanel({ - baseline, - isCreate = false, - otherIds, - otherAliases, - providerMap, - providers, - models, - onBack, - onSaved, - onRequestRemove, - onSave, - onDirtyChange, -}: { - baseline: ComboItem; - isCreate?: boolean; - /** Ids of all OTHER combos — rename collisions validate against these. */ - otherIds: string[]; - /** Aliases of all OTHER combos — alias uniqueness validates against these. */ - otherAliases: string[]; - providerMap: Readonly>; - providers: ProviderOption[]; - models: ModelOption[]; - onBack?: () => void; - onSaved: (item: ComboItem) => void; - onRequestRemove?: () => void; - onSave: (item: ComboItem, isCreate: boolean, renameFrom?: string) => Promise<{ ok: boolean; error?: string }>; - onDirtyChange: (dirty: boolean) => void; -}) { - const t = useT(); - const [tab, setTab] = useState("config"); - const [draft, setDraft] = useState(baseline); - const [busy, setBusy] = useState(false); - const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); - const [copied, setCopied] = useState(false); - const dirty = !draftEquals(draft, baseline); - const baselineSyncKey = `${baseline.id}:${baseline.alias ?? ""}:${baseline.strategy}:${baseline.stickyLimit}:${baseline.defaultEffort}:${baseline.targets.map((t) => `${t.provider}/${t.model}:${t.weight ?? 1}`).join(",")}`; - - useEffect(() => { - const timer = window.setTimeout(() => { - setDraft(baseline); - setMsg(null); - setTab("config"); - }, 0); - return () => window.clearTimeout(timer); - // Sync when server/local baseline content changes for this combo (same id after save/refresh). - // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: key captures baseline payload - }, [baselineSyncKey]); - - useEffect(() => { - onDirtyChange(dirty); - }, [dirty, onDirtyChange]); - - const copyModel = async () => { - try { - await navigator.clipboard.writeText(baseline.model); - setCopied(true); - window.setTimeout(() => setCopied(false), 1200); - } catch { - /* ignore */ - } - }; - - const save = async () => { - const code = validateComboDraft(draft, { - existingIds: otherIds, - existingAliases: otherAliases, - isCreate, - providers: providerMap, - }); - if (code) { - setMsg({ ok: false, text: t(`cws.err.${code}`) }); - return; - } - setBusy(true); - const trimmedId = draft.id.trim(); - const alias = draft.alias?.trim() || null; - const item = { - ...draft, - id: trimmedId, - alias, - model: comboPublicModelId(trimmedId, alias), - }; - const renameFrom = !isCreate && trimmedId !== baseline.id ? baseline.id : undefined; - const res = await onSave(item, isCreate, renameFrom); - setBusy(false); - if (!res.ok) { - setMsg({ ok: false, text: res.error || t("cws.saveFailed") }); - return; - } - setMsg({ - ok: true, - text: isCreate ? t("cws.created", { model: item.model }) : t("cws.saved"), - }); - onSaved(item); - }; - - const headerModel = isCreate - ? (draft.id.trim() ? comboPublicModelId(draft.id, draft.alias) : t("cws.addTitle")) - : baseline.model; - - return ( -
-
- {onBack && ( - - )} -

{headerModel}

- {!isCreate && ( - - )} -
- {!isCreate && onRequestRemove && ( - - )} - -
-
- - {msg && {msg.text}} - -
- - -
- -
- {tab === "config" ? ( -
-
- - setDraft((d) => ({ - ...d, - id: e.target.value, - model: comboPublicModelId(e.target.value, d.alias), - }))} - /> -

- {isCreate - ? t("cws.field.idInternalHint") - : t("cws.field.idHintEdit", { model: comboPublicModelId(draft.id, draft.alias) })} -

-
-
- - setDraft((d) => ({ - ...d, - alias: e.target.value.trim() ? e.target.value : null, - model: comboPublicModelId(d.id, e.target.value), - }))} - /> -

- {t("cws.field.aliasHint")} -

-
-
- - setDraft((d) => ({ ...d, strategy }))} - /> -

- {draft.strategy === "failover" ? t("cws.strategy.failoverHint") : t("cws.strategy.roundRobinHint")} -

-
-
- - setDraft((d) => ({ ...d, defaultEffort }))} - /> -

- {t("cws.field.defaultEffortHint")} -

-
- {draft.strategy === "round-robin" && ( -
- - setDraft((d) => ({ ...d, stickyLimit: Number(e.target.value) }))} - /> -
- )} -
- -

- {draft.strategy === "failover" ? t("cws.targets.failoverHint") : t("cws.targets.roundRobinHint")} -

- setDraft((d) => ({ ...d, targets }))} - /> -
-
- ) : ( -
-

{t("cws.aboutTitle")}

-

{t("cws.aboutBody")}

-
- )} -
-
- ); -} +import { IconChevron, IconPlus, IconSearch, IconShuffle } from "../icons"; +import { useT } from "../i18n/shared"; +import { AddComboModal } from "./combo-workspace-add-modal"; +import { DetailPanel } from "./combo-workspace-detail-panel"; +import { RemoveComboDialog, UnsavedLeaveDialog } from "./combo-workspace-dialogs"; +import { OverviewPanel } from "./combo-workspace-overview-panel"; +import type { ComboWorkspaceProps } from "./combo-workspace-types"; + +export type { ModelOption, ProviderOption, ComboWorkspaceProps } from "./combo-workspace-types"; export default function ComboWorkspace({ combos, diff --git a/gui/src/components/combo-workspace-add-modal.tsx b/gui/src/components/combo-workspace-add-modal.tsx new file mode 100644 index 000000000..acd07095f --- /dev/null +++ b/gui/src/components/combo-workspace-add-modal.tsx @@ -0,0 +1,198 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + type ComboItem, + comboPublicModelId, + emptyDraft, + validateComboDraft, +} from "../combo-workspace-data"; +import { IconX } from "../icons"; +import { useT } from "../i18n/shared"; +import { Notice } from "../ui"; +import type { ModelOption, ProviderOption } from "./combo-workspace-types"; +import { EffortSelect, StrategySeg, TargetEditor } from "./combo-workspace-controls"; + +export function AddComboModal({ + existingIds, + existingAliases, + providerMap, + providers, + models, + onClose, + onSubmit, +}: { + existingIds: string[]; + existingAliases: string[]; + providerMap: Readonly>; + providers: ProviderOption[]; + models: ModelOption[]; + onClose: () => void; + onSubmit: (item: ComboItem) => Promise<{ ok: boolean; error?: string }>; +}) { + const t = useT(); + const dialogRef = useRef(null); + const [draft, setDraft] = useState(() => emptyDraft()); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + const dialog = dialogRef.current; + if (dialog && !dialog.open) dialog.showModal(); + }, []); + + const requestClose = useCallback(() => { + if (!busy) onClose(); + }, [busy, onClose]); + + const handleCancel = useCallback((e: React.SyntheticEvent) => { + e.preventDefault(); + requestClose(); + }, [requestClose]); + + const submit = async () => { + const code = validateComboDraft(draft, { + existingIds, + existingAliases, + isCreate: true, + providers: providerMap, + }); + if (code) { + setError(t(`cws.err.${code}`)); + return; + } + setBusy(true); + setError(""); + const id = draft.id.trim(); + const alias = draft.alias?.trim() || null; + try { + const res = await onSubmit({ ...draft, id, alias, model: comboPublicModelId(id, alias) }); + if (!res.ok) { + setError(res.error || t("cws.saveFailed")); + return; + } + } finally { + setBusy(false); + } + }; + + return ( + +
e.stopPropagation()}> +
+

{t("cws.addTitle")}

+ +
+

{t("cws.addSubtitle")}

+ {error && {error}} +
+
+ + setDraft((d) => ({ + ...d, + id: e.target.value, + model: comboPublicModelId(e.target.value, d.alias), + }))} + /> +

+ {t("cws.field.idInternalHint")} +

+
+
+ + setDraft((d) => ({ + ...d, + alias: e.target.value.trim() ? e.target.value : null, + model: comboPublicModelId(d.id, e.target.value), + }))} + /> +

+ {t("cws.field.aliasHint")} +

+

+ {t("cws.field.idHint", { + model: draft.id.trim() ? comboPublicModelId(draft.id, draft.alias) : "…", + })} +

+
+
+ {t("cws.strategy")} + setDraft((d) => ({ ...d, strategy }))} + /> +

+ {draft.strategy === "failover" ? t("cws.strategy.failoverHint") : t("cws.strategy.roundRobinHint")} +

+
+
+ + setDraft((d) => ({ ...d, defaultEffort }))} + /> +

+ {t("cws.field.defaultEffortHint")} +

+
+ {draft.strategy === "round-robin" && ( +
+ + setDraft((d) => ({ ...d, stickyLimit: Number(e.target.value) }))} + /> +

+ {t("cws.field.stickyLimitHint")} +

+
+ )} +
+ {t("cws.targets")} +

+ {draft.strategy === "failover" ? t("cws.targets.failoverHint") : t("cws.targets.roundRobinHint")} +

+ setDraft((d) => ({ ...d, targets }))} + /> +
+
+
+ + +
+
+
+ ); +} diff --git a/gui/src/components/combo-workspace-controls.tsx b/gui/src/components/combo-workspace-controls.tsx new file mode 100644 index 000000000..817500df3 --- /dev/null +++ b/gui/src/components/combo-workspace-controls.tsx @@ -0,0 +1,230 @@ +import { useState } from "react"; +import type { ComboEffort, ComboStrategy, ComboTarget } from "../combo-workspace-data"; +import { COMBO_EFFORTS, newComboTarget } from "../combo-workspace-data"; +import { IconGrip, IconPlus, IconTrash } from "../icons"; +import { useT } from "../i18n/shared"; +import type { ModelOption, ProviderOption } from "./combo-workspace-types"; +import { enabledProviders, modelsForProvider } from "./combo-workspace-utils"; + +export function StrategySeg({ + value, + onChange, + disabled, +}: { + value: ComboStrategy; + onChange: (next: ComboStrategy) => void; + disabled?: boolean; +}) { + const t = useT(); + return ( +
+ {([ + ["failover", "cws.strategy.failover"], + ["round-robin", "cws.strategy.roundRobin"], + ] as const).map(([id, key]) => ( + + ))} +
+ ); +} + +export function EffortSelect({ + id, + value, + onChange, + disabled, +}: { + id: string; + value: ComboEffort | null; + onChange: (next: ComboEffort | null) => void; + disabled?: boolean; +}) { + const t = useT(); + return ( + + ); +} + +export function TargetEditor({ + targets, + strategy, + providers, + models, + onChange, +}: { + targets: ComboTarget[]; + strategy: ComboStrategy; + providers: ProviderOption[]; + models: ModelOption[]; + onChange: (next: ComboTarget[]) => void; +}) { + const t = useT(); + const provs = enabledProviders(providers); + const [dragIndex, setDragIndex] = useState(null); + const [overIndex, setOverIndex] = useState(null); + + const update = (index: number, patch: Partial) => { + onChange(targets.map((row, i) => (i === index ? { ...row, ...patch } : row))); + }; + + const reorder = (from: number, to: number) => { + if (from === to || from < 0 || to < 0 || from >= targets.length || to >= targets.length) return; + const copy = [...targets]; + const [moved] = copy.splice(from, 1); + copy.splice(to, 0, moved!); + onChange(copy); + }; + + return ( +
+ {targets.map((row, index) => { + const currentProvider = providers.find((provider) => provider.name === row.provider); + const providerOptions = currentProvider && !provs.some((provider) => provider.name === row.provider) + ? [...provs, currentProvider] + : provs; + const modelIds = modelsForProvider(models, row.provider, providers); + const options = row.model && !modelIds.includes(row.model) + ? [row.model, ...modelIds] + : modelIds; + const modelSelectDisabled = !row.provider; + const dragging = dragIndex === index; + const dropTarget = overIndex === index && dragIndex !== null && dragIndex !== index; + return ( +
{ + if (dragIndex === null) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + if (overIndex !== index) setOverIndex(index); + }} + onDrop={(e) => { + e.preventDefault(); + if (dragIndex !== null) reorder(dragIndex, index); + setDragIndex(null); + setOverIndex(null); + }} + onDragEnd={() => { + setDragIndex(null); + setOverIndex(null); + }} + > + + + + {strategy === "round-robin" && ( + { + const raw = e.target.value; + if (raw === "") return; + const weight = Number(raw); + if (!Number.isFinite(weight)) return; + update(index, { weight }); + }} + /> + )} +
+ +
+
+ ); + })} + +
+ ); +} diff --git a/gui/src/components/combo-workspace-detail-panel.tsx b/gui/src/components/combo-workspace-detail-panel.tsx new file mode 100644 index 000000000..2f630e6a4 --- /dev/null +++ b/gui/src/components/combo-workspace-detail-panel.tsx @@ -0,0 +1,269 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + type ComboItem, + comboModelId, + comboPublicModelId, + draftEquals, + validateComboDraft, +} from "../combo-workspace-data"; +import { IconChevron, IconTrash } from "../icons"; +import { useT } from "../i18n/shared"; +import { Notice } from "../ui"; +import type { ModelOption, ProviderOption } from "./combo-workspace-types"; +import { EffortSelect, StrategySeg, TargetEditor } from "./combo-workspace-controls"; + +type DetailTab = "config" | "about"; + +export function DetailPanel({ + baseline, + isCreate = false, + otherIds, + otherAliases, + providerMap, + providers, + models, + onBack, + onSaved, + onRequestRemove, + onSave, + onDirtyChange, +}: { + baseline: ComboItem; + isCreate?: boolean; + /** Ids of all OTHER combos — rename collisions validate against these. */ + otherIds: string[]; + /** Aliases of all OTHER combos — alias uniqueness validates against these. */ + otherAliases: string[]; + providerMap: Readonly>; + providers: ProviderOption[]; + models: ModelOption[]; + onBack?: () => void; + onSaved: (item: ComboItem) => void; + onRequestRemove?: () => void; + onSave: (item: ComboItem, isCreate: boolean, renameFrom?: string) => Promise<{ ok: boolean; error?: string }>; + onDirtyChange: (dirty: boolean) => void; +}) { + const t = useT(); + const [tab, setTab] = useState("config"); + const [draft, setDraft] = useState(baseline); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); + const [copied, setCopied] = useState(false); + const dirty = !draftEquals(draft, baseline); + const baselineSyncKey = `${baseline.id}:${baseline.alias ?? ""}:${baseline.strategy}:${baseline.stickyLimit}:${baseline.defaultEffort}:${baseline.targets.map((t) => `${t.provider}/${t.model}:${t.weight ?? 1}`).join(",")}`; + + const onDirtyChangeRef = useRef(onDirtyChange); + useEffect(() => { + onDirtyChangeRef.current = onDirtyChange; + }, [onDirtyChange]); + + const updateDraft = useCallback((next: ComboItem | ((prev: ComboItem) => ComboItem)) => { + setDraft(prev => { + const resolved = typeof next === "function" ? next(prev) : next; + onDirtyChangeRef.current(!draftEquals(resolved, baseline)); + return resolved; + }); + }, [baseline]); + + useEffect(() => { + const timer = window.setTimeout(() => { + setDraft(baseline); + setMsg(null); + setTab("config"); + onDirtyChangeRef.current(false); + }, 0); + return () => window.clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: key captures baseline payload + }, [baselineSyncKey]); + + const copyModel = async () => { + try { + await navigator.clipboard.writeText(baseline.model); + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + } catch { + /* ignore */ + } + }; + + const save = async () => { + const code = validateComboDraft(draft, { + existingIds: otherIds, + existingAliases: otherAliases, + isCreate, + providers: providerMap, + }); + if (code) { + setMsg({ ok: false, text: t(`cws.err.${code}`) }); + return; + } + setBusy(true); + const trimmedId = draft.id.trim(); + const alias = draft.alias?.trim() || null; + const item = { + ...draft, + id: trimmedId, + alias, + model: comboPublicModelId(trimmedId, alias), + }; + const renameFrom = !isCreate && trimmedId !== baseline.id ? baseline.id : undefined; + try { + const res = await onSave(item, isCreate, renameFrom); + if (!res.ok) { + setMsg({ ok: false, text: res.error || t("cws.saveFailed") }); + return; + } + setMsg({ + ok: true, + text: isCreate ? t("cws.created", { model: item.model }) : t("cws.saved"), + }); + onSaved(item); + } finally { + setBusy(false); + } + }; + + const headerModel = isCreate + ? (draft.id.trim() ? comboPublicModelId(draft.id, draft.alias) : t("cws.addTitle")) + : baseline.model; + + return ( +
+
+ {onBack && ( + + )} +

{headerModel}

+ {!isCreate && ( + + )} +
+ {!isCreate && onRequestRemove && ( + + )} + +
+
+ + {msg && {msg.text}} + +
+ + +
+ +
+ {tab === "config" ? ( +
+
+ + updateDraft((d) => ({ + ...d, + id: e.target.value, + model: comboPublicModelId(e.target.value, d.alias), + }))} + /> +

+ {isCreate + ? t("cws.field.idInternalHint") + : t("cws.field.idHintEdit", { model: comboPublicModelId(draft.id, draft.alias) })} +

+
+
+ + updateDraft((d) => ({ + ...d, + alias: e.target.value.trim() ? e.target.value : null, + model: comboPublicModelId(d.id, e.target.value), + }))} + /> +

+ {t("cws.field.aliasHint")} +

+
+
+ {t("cws.strategy")} + updateDraft((d) => ({ ...d, strategy }))} + /> +

+ {draft.strategy === "failover" ? t("cws.strategy.failoverHint") : t("cws.strategy.roundRobinHint")} +

+
+
+ + updateDraft((d) => ({ ...d, defaultEffort }))} + /> +

+ {t("cws.field.defaultEffortHint")} +

+
+ {draft.strategy === "round-robin" && ( +
+ + updateDraft((d) => ({ ...d, stickyLimit: Number(e.target.value) }))} + /> +
+ )} +
+ {t("cws.targets")} +

+ {draft.strategy === "failover" ? t("cws.targets.failoverHint") : t("cws.targets.roundRobinHint")} +

+ updateDraft((d) => ({ ...d, targets }))} + /> +
+
+ ) : ( +
+

{t("cws.aboutTitle")}

+

{t("cws.aboutBody")}

+
+ )} +
+
+ ); +} diff --git a/gui/src/components/combo-workspace-dialogs.tsx b/gui/src/components/combo-workspace-dialogs.tsx new file mode 100644 index 000000000..61b13b73b --- /dev/null +++ b/gui/src/components/combo-workspace-dialogs.tsx @@ -0,0 +1,86 @@ +import { useCallback, useEffect, useRef } from "react"; +import { useT } from "../i18n/shared"; + +export function RemoveComboDialog({ + model, + onCancel, + onConfirm, +}: { + model: string; + onCancel: () => void; + onConfirm: () => void; +}) { + const t = useT(); + const dialogRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (dialog && !dialog.open) dialog.showModal(); + }, []); + + const handleCancel = useCallback((e: React.SyntheticEvent) => { + e.preventDefault(); + onCancel(); + }, [onCancel]); + + return ( + + + + + + + ); +} + +export function UnsavedLeaveDialog({ + onKeep, + onDiscard, +}: { + onKeep: () => void; + onDiscard: () => void; +}) { + const t = useT(); + const dialogRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (dialog && !dialog.open) dialog.showModal(); + }, []); + + const handleCancel = useCallback((e: React.SyntheticEvent) => { + e.preventDefault(); + onKeep(); + }, [onKeep]); + + return ( + + + + + + + ); +} diff --git a/gui/src/components/combo-workspace-overview-panel.tsx b/gui/src/components/combo-workspace-overview-panel.tsx new file mode 100644 index 000000000..463041ef0 --- /dev/null +++ b/gui/src/components/combo-workspace-overview-panel.tsx @@ -0,0 +1,58 @@ +import type { ComboItem } from "../combo-workspace-data"; +import { buildComboAttention, groupCombos } from "../combo-workspace-data"; +import { IconAlert, IconChevron, IconPlus } from "../icons"; +import { useT } from "../i18n/shared"; + +export function OverviewPanel({ + combos, + onSelect, + onAdd, +}: { + combos: ComboItem[]; + onSelect: (id: string) => void; + onAdd: () => void; +}) { + const t = useT(); + const sections = groupCombos(combos); + const attention = buildComboAttention(combos); + + return ( +
+
+

{t("cws.overviewTitle")}

+ +
+

{t("cws.overviewBlurb")}

+
+
{combos.length}{t("cws.count.total")}
+
{sections.failover.length}{t("cws.count.failover")}
+
{sections.roundRobin.length}{t("cws.count.roundRobin")}
+
+ +
+

{t("cws.howTitle")}

+

{t("cws.howBody")}

+
+ + {attention.length > 0 && ( +
+

{t("cws.attentionTitle")}

+
+ {attention.map((item) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/gui/src/components/combo-workspace-types.ts b/gui/src/components/combo-workspace-types.ts new file mode 100644 index 000000000..5e8bed72c --- /dev/null +++ b/gui/src/components/combo-workspace-types.ts @@ -0,0 +1,25 @@ +import type { ComboItem } from "../combo-workspace-data"; + +export type ProviderOption = { + name: string; + disabled?: boolean; + hiddenFromPicker?: boolean; + authMode?: string; + adapter?: string; + baseUrl?: string; +}; +export type ModelOption = { provider: string; id: string; namespaced?: string }; + +export interface ComboWorkspaceProps { + combos: ComboItem[]; + providers: ProviderOption[]; + models: ModelOption[]; + loading?: boolean; + onRefresh: () => void; + onSave: (item: ComboItem, isCreate: boolean, renameFrom?: string) => Promise<{ ok: boolean; error?: string }>; + onRemove: (id: string) => Promise<{ ok: boolean; error?: string }>; + onAdd: () => void; + adding: boolean; + onCloseAdd: () => void; + onCreated: (id: string) => void; +} diff --git a/gui/src/components/combo-workspace-utils.ts b/gui/src/components/combo-workspace-utils.ts new file mode 100644 index 000000000..eccfd043e --- /dev/null +++ b/gui/src/components/combo-workspace-utils.ts @@ -0,0 +1,40 @@ +import type { ModelOption, ProviderOption } from "./combo-workspace-types"; + +export function enabledProviders(providers: ProviderOption[]): ProviderOption[] { + return providers + .filter((p) => !p.disabled && !p.hiddenFromPicker) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** ChatGPT passthrough has no /models catalog — GPT slugs are listed under provider "openai". */ +export function isChatGptForwardOption(p: ProviderOption | undefined): boolean { + if (!p) return false; + const id = p.name.toLowerCase(); + if (id !== "openai" && id !== "chatgpt") return false; + if ((p.authMode ?? "").toLowerCase() !== "forward") return false; + if ((p.adapter ?? "").toLowerCase() !== "openai-responses") return false; + const base = (p.baseUrl ?? "").replace(/\/+$/, ""); + return !base || base.includes("chatgpt.com/backend-api/codex"); +} + +export function modelsForProvider( + models: ModelOption[], + provider: string, + providers: ProviderOption[], +): string[] { + const keys = new Set([provider]); + const meta = providers.find((p) => p.name === provider); + // Alias chatgpt → openai native GPT rows (forward providers don't publish their own catalog). + if (provider.toLowerCase() === "chatgpt" || isChatGptForwardOption(meta)) { + keys.add("openai"); + } + const ids: string[] = []; + const seen = new Set(); + for (const m of models) { + if (!keys.has(m.provider) || !m.id) continue; + if (seen.has(m.id)) continue; + seen.add(m.id); + ids.push(m.id); + } + return ids.toSorted((a, b) => a.localeCompare(b)); +} diff --git a/gui/src/pages/Combos.tsx b/gui/src/pages/Combos.tsx index 86686b6dc..ae356ddaf 100644 --- a/gui/src/pages/Combos.tsx +++ b/gui/src/pages/Combos.tsx @@ -8,7 +8,7 @@ import { } from "../combo-workspace-data"; import { hideRedundantChatGptForwardProviders } from "../provider-workspace/catalog"; import { Notice } from "../ui"; -import { useT } from "../i18n"; +import { useT } from "../i18n/shared"; type ProviderOption = { name: string; @@ -148,7 +148,9 @@ export default function Combos({ apiBase }: { apiBase: string }) { headers: { "content-type": "application/json" }, body: JSON.stringify(toPutBody(item, renameFrom ? { renameFrom } : {})), }); - const data = await res.json() as unknown; + const data = res.ok + ? await res.json() as unknown + : await res.json().catch(() => null) as unknown; const serverError = responseError(data); if (!res.ok || serverError || !responseSucceeded(data)) { const err = serverError || t("cws.saveFailed"); @@ -173,7 +175,9 @@ export default function Combos({ apiBase }: { apiBase: string }) { const removeCombo = async (id: string) => { try { const res = await fetch(`${apiBase}/api/combos?id=${encodeURIComponent(id)}`, { method: "DELETE" }); - const data = await res.json() as unknown; + const data = res.ok + ? await res.json() as unknown + : await res.json().catch(() => null) as unknown; const serverError = responseError(data); if (!res.ok || serverError || !responseSucceeded(data)) { const err = serverError || t("cws.removeFailed"); From 76a0fc1366684332310acd139abda2e0095bc725 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:04:20 +0200 Subject: [PATCH 2/4] fix(gui): address Combos review blockers for clientKey and dialog dismissal Keep UI-only clientKey in the parsed view model with contract tests that prove toPutBody strips it, and restore backdrop click dismissal on combo dialogs. --- .../components/combo-workspace-add-modal.tsx | 7 +++++-- gui/src/components/combo-workspace-dialogs.tsx | 8 ++++++-- tests/combo-workspace-data.test.ts | 17 ++++++++++++++--- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/gui/src/components/combo-workspace-add-modal.tsx b/gui/src/components/combo-workspace-add-modal.tsx index acd07095f..6c1e9eda1 100644 --- a/gui/src/components/combo-workspace-add-modal.tsx +++ b/gui/src/components/combo-workspace-add-modal.tsx @@ -80,11 +80,14 @@ export function AddComboModal({ className="modal-overlay" aria-labelledby="cwi-add-title" onCancel={handleCancel} + onClick={(e) => { + if (e.target === e.currentTarget) requestClose(); + }} >
e.stopPropagation()}>

{t("cws.addTitle")}

-
@@ -187,7 +190,7 @@ export function AddComboModal({
- + diff --git a/gui/src/components/combo-workspace-dialogs.tsx b/gui/src/components/combo-workspace-dialogs.tsx index 61b13b73b..8899c6961 100644 --- a/gui/src/components/combo-workspace-dialogs.tsx +++ b/gui/src/components/combo-workspace-dialogs.tsx @@ -1,6 +1,10 @@ import { useCallback, useEffect, useRef } from "react"; import { useT } from "../i18n/shared"; +function dismissIfBackdrop(e: React.MouseEvent, onDismiss: () => void) { + if (e.target === e.currentTarget) onDismiss(); +} + export function RemoveComboDialog({ model, onCancel, @@ -29,8 +33,8 @@ export function RemoveComboDialog({ className="modal-overlay" aria-labelledby="cwi-remove-title" onCancel={handleCancel} + onClick={(e) => dismissIfBackdrop(e, onCancel)} > - +
+ + +