Skip to content

Commit 69c56e3

Browse files
lidge-junclaude
andcommitted
feat(gui): Models page allowlist editor + search/pagination for large providers (#52)
Wire the Models page to /api/selected-models: per-provider "Only selected" toggle reveals a checkbox allowlist (empty = all), plus a per-provider search box and a 60-row render cap with "show more" so a provider exposing thousands of models stays usable. i18n for en/ko/zh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 099faec commit 69c56e3

4 files changed

Lines changed: 106 additions & 2 deletions

File tree

gui/src/i18n/en.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,11 @@ export const en = {
145145
"models.noRouted": "No routed models",
146146
"models.noRoutedHint": "Log into a provider or add one first.",
147147
"models.loading": "Loading…",
148+
"models.search": "Search models…",
149+
"models.showMore": "Show {n} more",
150+
"models.allowlistLabel": "Only selected",
151+
"models.allowlistHint": "Only checked models ship to the catalog (empty = all). Useful for providers exposing thousands of models.",
152+
"models.selectedCount": "{n} selected",
148153

149154
// subagents
150155
"sub.subtitle": "Codex's {cmd} advertises only the first 5 models (by priority) as overrides. Pick up to 5 here — native gpt or routed — and opencodex sets their catalog priority so exactly these lead. Any other model is still callable by its exact name; this only controls what's shown.",

gui/src/i18n/ko.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,11 @@ export const ko: Record<TKey, string> = {
145145
"models.noRouted": "라우팅된 모델 없음",
146146
"models.noRoutedHint": "먼저 프로바이더에 로그인하거나 추가하세요.",
147147
"models.loading": "불러오는 중…",
148+
"models.search": "모델 검색…",
149+
"models.showMore": "{n}개 더 보기",
150+
"models.allowlistLabel": "선택만 노출",
151+
"models.allowlistHint": "체크한 모델만 카탈로그에 노출돼요 (비우면 전체). 수천 개 모델을 노출하는 프로바이더에 유용해요.",
152+
"models.selectedCount": "{n}개 선택",
148153

149154
// subagents
150155
"sub.subtitle": "Codex의 {cmd} 는 우선순위 상위 5개 모델만 오버라이드로 노출합니다. 여기서 최대 5개를 선택하면 — 네이티브 gpt 또는 라우팅된 모델 — opencodex가 카탈로그 우선순위를 설정해 정확히 이들이 앞에 옵니다. 다른 모델도 정확한 이름으로 호출할 수 있으며, 이 설정은 표시 항목만 제어합니다.",

gui/src/i18n/zh.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,11 @@ export const zh: Record<TKey, string> = {
145145
"models.noRouted": "没有已路由的模型",
146146
"models.noRoutedHint": "请先登录提供方或添加一个。",
147147
"models.loading": "加载中…",
148+
"models.search": "搜索模型…",
149+
"models.showMore": "再显示 {n} 个",
150+
"models.allowlistLabel": "仅所选",
151+
"models.allowlistHint": "仅勾选的模型进入目录(留空 = 全部)。适用于暴露成千上万模型的提供商。",
152+
"models.selectedCount": "已选 {n} 个",
148153

149154
// subagents
150155
"sub.subtitle": "Codex 的 {cmd} 仅将优先级最高的前 5 个模型作为覆盖项公开。在此最多选择 5 个 — 原生 gpt 或已路由模型 — opencodex 会设置它们的目录优先级,使其正好排在前面。其他模型仍可按确切名称调用;此设置仅控制显示项。",

gui/src/pages/Models.tsx

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,14 @@ interface ProviderContextCapsResponse {
1919
caps?: Record<string, number>;
2020
}
2121

22+
interface SelectedModelsResponse {
23+
selected?: Record<string, string[]>;
24+
available?: Record<string, string[]>;
25+
}
26+
2227
const CAP_OPTIONS = Array.from({ length: 18 }, (_, i) => 100_000 + i * 50_000); // 100k … 950k
2328
const CUSTOM_OPTION = "custom";
29+
const PAGE = 60; // rows rendered per provider before a "show more" (keeps 1000s-of-models providers usable)
2430

2531
function fmtK(n: number): string {
2632
if (!Number.isFinite(n) || n <= 0) return String(n);
@@ -31,6 +37,10 @@ export default function Models({ apiBase }: { apiBase: string }) {
3137
const t = useT();
3238
const [models, setModels] = useState<ModelRow[]>([]);
3339
const [disabled, setDisabled] = useState<Set<string>>(new Set());
40+
const [selected, setSelected] = useState<Record<string, string[]>>({});
41+
const [allowlistOn, setAllowlistOn] = useState<Set<string>>(new Set());
42+
const [search, setSearch] = useState<Record<string, string>>({});
43+
const [limit, setLimit] = useState<Record<string, number>>({});
3444
const [contextCaps, setContextCaps] = useState<Record<string, number>>({});
3545
const [contextCapValue, setContextCapValue] = useState(350_000);
3646
const [customCap, setCustomCap] = useState("");
@@ -44,9 +54,10 @@ export default function Models({ apiBase }: { apiBase: string }) {
4454

4555
const load = async () => {
4656
try {
47-
const [data, capsData] = await Promise.all([
57+
const [data, capsData, sel] = await Promise.all([
4858
fetch(`${apiBase}/api/models`).then(r => r.json()) as Promise<ModelRow[]>,
4959
fetch(`${apiBase}/api/provider-context-caps`).then(r => r.json()) as Promise<ProviderContextCapsResponse>,
60+
fetch(`${apiBase}/api/selected-models`).then(r => r.json()).catch(() => ({})) as Promise<SelectedModelsResponse>,
5061
]);
5162
setModels(data);
5263
setDisabled(new Set(data.filter(m => m.disabled).map(m => m.namespaced)));
@@ -55,6 +66,15 @@ export default function Models({ apiBase }: { apiBase: string }) {
5566
: (typeof capsData.cap === "number" && Number.isFinite(capsData.cap) && capsData.cap > 0 ? capsData.cap : undefined);
5667
if (value !== undefined) setContextCapValue(value);
5768
setContextCaps(capsData.caps ?? {});
69+
const selMap = sel.selected ?? {};
70+
setSelected(selMap);
71+
// Reveal the allowlist editor for providers that already have a selection; keep any the user
72+
// toggled on this session (don't clobber a just-opened, still-empty editor on the 10s refresh).
73+
setAllowlistOn(prev => {
74+
const next = new Set(prev);
75+
for (const [p, ids] of Object.entries(selMap)) if (ids.length > 0) next.add(p);
76+
return next;
77+
});
5878
} catch {
5979
setOk(false); setStatus(t("models.loadFail"));
6080
} finally {
@@ -101,6 +121,39 @@ export default function Models({ apiBase }: { apiBase: string }) {
101121
if (next.has(ns)) next.delete(ns); else next.add(ns);
102122
apply(next);
103123
};
124+
125+
const putSelected = async (provider: string, ids: string[]) => {
126+
setBusy(true);
127+
busyRef.current = true;
128+
setStatus("");
129+
try {
130+
const r = await fetch(`${apiBase}/api/selected-models`, {
131+
method: "PUT",
132+
headers: { "Content-Type": "application/json" },
133+
body: JSON.stringify({ provider, models: ids }),
134+
});
135+
if (r.ok) { setSelected(prev => ({ ...prev, [provider]: ids })); setOk(true); setStatus(t("models.applied")); }
136+
else { setOk(false); setStatus(t("models.saveFailed")); }
137+
} catch {
138+
setOk(false); setStatus(t("models.networkError"));
139+
} finally {
140+
setBusy(false);
141+
busyRef.current = false;
142+
}
143+
};
144+
145+
const toggleAllowlist = (provider: string) => {
146+
const wasOn = allowlistOn.has(provider);
147+
setAllowlistOn(prev => { const n = new Set(prev); if (n.has(provider)) n.delete(provider); else n.add(provider); return n; });
148+
if (wasOn) void putSelected(provider, []); // turning the allowlist off clears it (revert to all)
149+
};
150+
151+
const toggleSelected = (provider: string, id: string) => {
152+
const cur = new Set(selected[provider] ?? []);
153+
if (cur.has(id)) cur.delete(id); else cur.add(id);
154+
void putSelected(provider, [...cur]);
155+
};
156+
104157
const toggleProviderCap = async (provider: string) => {
105158
setBusy(true);
106159
busyRef.current = true;
@@ -237,23 +290,52 @@ export default function Models({ apiBase }: { apiBase: string }) {
237290
const isCollapsed = collapsed.has(provider);
238291
const activeCount = rows.filter(m => !disabled.has(m.namespaced)).length;
239292
const capOn = contextCaps[provider] === contextCapValue;
293+
const isAllowlist = allowlistOn.has(provider);
294+
const sel = new Set(selected[provider] ?? []);
295+
const q = (search[provider] ?? "").trim().toLowerCase();
296+
const filtered = q ? rows.filter(m => m.id.toLowerCase().includes(q)) : rows;
297+
const shown = limit[provider] ?? PAGE;
298+
const visible = filtered.slice(0, shown);
299+
const remaining = filtered.length - visible.length;
240300
return (
241301
<div key={provider} className="card" style={{ marginBottom: 8, overflow: "hidden" }}>
242302
<div onClick={() => toggleCollapse(provider)}
243303
className="row" style={{ padding: "10px 12px", background: "var(--raised)", cursor: "pointer" }}>
244304
<IconChevron style={{ width: 14, height: 14, color: "var(--muted)", transform: isCollapsed ? "none" : "rotate(90deg)", transition: "transform .12s" }} />
245305
<span style={{ fontWeight: 600, fontSize: 14 }}>{provider}</span>
246306
<span className="muted mono" style={{ fontSize: 12 }}>{t("models.active", { active: activeCount, total: rows.length })}</span>
307+
{isAllowlist && sel.size > 0 && <span className="muted mono" style={{ fontSize: 12 }}>· {t("models.selectedCount", { n: sel.size })}</span>}
247308
<div style={{ flex: 1 }} />
248309
<div className="row" onClick={e => e.stopPropagation()} style={{ gap: 6 }}>
310+
<Switch on={isAllowlist} onClick={() => toggleAllowlist(provider)} disabled={busy} label={t("models.allowlistLabel")} />
311+
<span className="muted mono" style={{ fontSize: 12 }}>{t("models.allowlistLabel")}</span>
249312
<Switch on={capOn} onClick={() => toggleProviderCap(provider)} disabled={busy} label={t("models.capValue", { value: fmtK(contextCapValue) })} />
250313
<span className="muted mono" style={{ fontSize: 12 }}>{t("models.capValue", { value: fmtK(contextCapValue) })}</span>
251314
</div>
252315
</div>
253316
{!isCollapsed && (
254317
<div style={{ padding: "6px 12px" }}>
255-
{rows.map(m => {
318+
{rows.length > PAGE / 2 && (
319+
<input
320+
className="input"
321+
style={{ width: "100%", marginBottom: 6 }}
322+
placeholder={t("models.search")}
323+
value={search[provider] ?? ""}
324+
onChange={e => setSearch(prev => ({ ...prev, [provider]: e.target.value }))}
325+
/>
326+
)}
327+
{isAllowlist && <p className="muted" style={{ fontSize: 12, margin: "2px 0 6px" }}>{t("models.allowlistHint")}</p>}
328+
{visible.map(m => {
256329
const off = disabled.has(m.namespaced);
330+
if (isAllowlist) {
331+
const on = sel.has(m.id);
332+
return (
333+
<label key={m.namespaced} className="row" style={{ padding: "5px 0", cursor: "pointer", gap: 8 }}>
334+
<input type="checkbox" checked={on} disabled={busy} onChange={() => toggleSelected(provider, m.id)} />
335+
<code className="mono" style={{ fontSize: 13, color: on ? "var(--text)" : "var(--faint)" }}>{m.id}</code>
336+
</label>
337+
);
338+
}
257339
return (
258340
<div key={m.namespaced} className="row" style={{ padding: "5px 0" }}>
259341
<Switch on={!off} onClick={() => toggle(m.namespaced)} disabled={busy} label={m.id} />
@@ -262,6 +344,13 @@ export default function Models({ apiBase }: { apiBase: string }) {
262344
</div>
263345
);
264346
})}
347+
{remaining > 0 && (
348+
<button
349+
onClick={() => setLimit(prev => ({ ...prev, [provider]: shown + PAGE }))}
350+
className="btn btn-ghost btn-sm"
351+
style={{ marginTop: 4 }}
352+
>{t("models.showMore", { n: remaining })}</button>
353+
)}
265354
</div>
266355
)}
267356
</div>

0 commit comments

Comments
 (0)