diff --git a/.github/pr-assets/fast-rows-setting-toggle.png b/.github/pr-assets/fast-rows-setting-toggle.png new file mode 100644 index 00000000000..756ca0b9c16 Binary files /dev/null and b/.github/pr-assets/fast-rows-setting-toggle.png differ diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 8ed7b97e698..4fe559bed98 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -67,8 +67,9 @@ service tier — the same Fast the Codex app exposes through its picker toggle. listed, so the row is an addition rather than a replacement. Set `"fastRows": false` to hide generated Fast selectors. Malformed values also disable them. -Refresh the client model list or regenerate/refresh an existing managed client configuration to -receive the new entries. Connected clients use the serving proxy's availability metadata; older +The Models Dashboard exposes the same setting and refreshes connected integrations when possible. +If an external picker does not change after saving, refresh its integration or client catalog. +Connected clients use the serving proxy's availability metadata; older proxies without that metadata do not gain guessed Fast entries. Codex keeps its native Fast toggle. The suffix is `--fast`, with two hyphens, because a terminal `-fast` is already a real model id for diff --git a/gui/src/components/FastRowsSetting.tsx b/gui/src/components/FastRowsSetting.tsx new file mode 100644 index 00000000000..a0ee9a24f71 --- /dev/null +++ b/gui/src/components/FastRowsSetting.tsx @@ -0,0 +1,181 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { readJsonOrThrow } from "../fetch-json"; +import { startVisibilityPoll } from "../visibility-poll"; +import { createBoundedFetch } from "../bounded-fetch"; +import { useT } from "../i18n/shared"; +import type { NoticeTone } from "../ui"; + +type Feedback = { tone: NoticeTone; message: string } | null; + +/** Dashboard toggle for showing or hiding synthetic Fast selector rows. */ +export default function FastRowsSetting({ + apiBase, + onSaved, +}: { + apiBase: string; + onSaved?: () => void; +}) { + const t = useT(); + const [enabled, setEnabled] = useState(true); + const [hydrated, setHydrated] = useState(false); + const [saving, setSaving] = useState(false); + const [loadError, setLoadError] = useState(false); + const [feedback, setFeedback] = useState(null); + const enabledRef = useRef(true); + const savingRef = useRef(false); + const loadGenerationRef = useRef(0); + + const load = useCallback(async () => { + if (savingRef.current) return; + const generation = ++loadGenerationRef.current; + const bounded = createBoundedFetch(15_000); + try { + const response = await fetch(`${apiBase}/api/settings`, { signal: bounded.signal }); + if (!response.ok) throw new Error("load"); + const payload = await response.json() as { fastRows?: unknown }; + if (savingRef.current || generation !== loadGenerationRef.current) return; + if (typeof payload.fastRows !== "boolean") throw new Error("shape"); + enabledRef.current = payload.fastRows; + setEnabled(payload.fastRows); + setHydrated(true); + setLoadError(false); + } catch { + if (!savingRef.current && generation === loadGenerationRef.current) { + setLoadError(true); + } + } finally { + bounded.clear(); + } + }, [apiBase]); + + useEffect(() => { + const timeout = window.setTimeout(() => { void load(); }, 0); + const stop = startVisibilityPoll(() => { void load(); }, 30_000); + return () => { + window.clearTimeout(timeout); + stop(); + }; + }, [load]); + + const toggle = useCallback(async () => { + if (savingRef.current || !hydrated) return; + const previous = enabledRef.current; + const requested = !previous; + enabledRef.current = requested; + setEnabled(requested); + savingRef.current = true; + setSaving(true); + setFeedback(null); + loadGenerationRef.current += 1; + const bounded = createBoundedFetch(15_000); + try { + const response = await fetch(`${apiBase}/api/settings`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ fastRows: requested }), + signal: bounded.signal, + }); + const payload = (await readJsonOrThrow<{ + ok?: unknown; + fastRows?: unknown; + catalogRefreshPending?: unknown; + }>(response)) ?? {}; + if (payload.ok !== true || typeof payload.fastRows !== "boolean") { + throw new Error("unconfirmed"); + } + enabledRef.current = payload.fastRows; + setEnabled(payload.fastRows); + setHydrated(true); + setLoadError(false); + setFeedback(payload.catalogRefreshPending === true + ? { tone: "warn", message: t("models.fastRows.refreshHint") } + : { tone: "ok", message: t(payload.fastRows ? "models.fastRows.enabled" : "models.fastRows.disabled") }); + onSaved?.(); + } catch { + const reconciliation = createBoundedFetch(15_000); + try { + const response = await fetch(`${apiBase}/api/settings`, { signal: reconciliation.signal }); + if (!response.ok) throw new Error("reconcile"); + const payload = await response.json() as { fastRows?: unknown }; + if (typeof payload.fastRows !== "boolean") throw new Error("shape"); + enabledRef.current = payload.fastRows; + setEnabled(payload.fastRows); + setHydrated(true); + setLoadError(false); + setFeedback(payload.fastRows === requested + ? { tone: "warn", message: t("models.fastRows.refreshHint") } + : { tone: "err", message: t("models.fastRows.updateFailed") }); + } catch { + enabledRef.current = previous; + setEnabled(previous); + setFeedback({ tone: "err", message: t("models.fastRows.updateFailed") }); + } finally { + reconciliation.clear(); + onSaved?.(); + } + } finally { + bounded.clear(); + savingRef.current = false; + setSaving(false); + } + }, [apiBase, hydrated, onSaved, t]); + + const initialLoadFailed = loadError && !hydrated; + + return ( +
+
+ {t("models.fastRows.title")} +
+ {initialLoadFailed + ? t("models.fastRows.loadFailed") + : !hydrated + ? t("common.loading") + : t("models.fastRows.desc")} +
+ {hydrated && loadError && ( +
+ {t("models.fastRows.loadFailed")} +
+ )} +
+
+ {loadError && ( + + )} + {hydrated && ( + + )} +
+ {feedback && ( +
+ {feedback.message} +
+ )} +
+ ); +} diff --git a/gui/src/components/ModelCatalogSettingsPanels.tsx b/gui/src/components/ModelCatalogSettingsPanels.tsx new file mode 100644 index 00000000000..f02b14f6fc5 --- /dev/null +++ b/gui/src/components/ModelCatalogSettingsPanels.tsx @@ -0,0 +1,16 @@ +import type { ComponentProps } from "react"; +import ModelPickerOrderEditor from "./ModelPickerOrderEditor"; +import FastRowsSetting from "./FastRowsSetting"; + +type Props = ComponentProps & { + showOrderEditor: boolean; + onSaved: () => void; +}; + +/** Keep conditional picker ordering and always-visible Fast rows as sibling panels. */ +export default function ModelCatalogSettingsPanels({ showOrderEditor, onSaved, ...picker }: Props) { + return <> + {showOrderEditor && } + + ; +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 4c97e8fea87..a68a1b933e3 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -51,6 +51,13 @@ export const de: Record = { "models.pickerOrder.loadFailed": "Auswahleinstellungen konnten nicht geladen werden.", "models.pickerOrder.retry": "Erneut versuchen", "models.pickerOrder.hint": "Speichert geroutete Modelle für Codex- und Claude-Listen. Prioritätsbereiche bevorzugter/nativer Modelle bleiben erhalten. Nutzung ist eine Momentaufnahme; nativ angebotene Optionen können sich ändern.", + "models.fastRows.title": "Schnelle Modellzeilen anzeigen", + "models.fastRows.desc": "Fügt berechtigte „Model Fast“-Auswahlmöglichkeiten zu externen Client-Modellauswahlen hinzu. Dies ändert nicht die Optionen für den Denkaufwand des Basismodells. Beim Speichern werden verbundene Integrationen nach Möglichkeit aktualisiert; andernfalls aktualisieren Sie die Integration oder den Client-Katalog.", + "models.fastRows.refreshHint": "Gespeichert. Katalogaktualisierung steht aus.", + "models.fastRows.enabled": "Schnelle Modellzeilen aktiviert.", + "models.fastRows.disabled": "Schnelle Modellzeilen deaktiviert.", + "models.fastRows.loadFailed": "Einstellung für schnelle Zeilen konnte nicht geladen werden.", + "models.fastRows.updateFailed": "Einstellung für schnelle Zeilen konnte nicht aktualisiert werden.", "codexAuth.quotaAutoRefreshAllHint": "Schaltet die unterstützten 5-Stunden- und Wochenfenster aller aktuellen Konten gemeinsam um. Im Pool-Modus wird nach jedem Reset eine kleine Anfrage gesendet, die Kontingent verbraucht.", "codexAuth.quotaAutoRefreshMixed": "Einige Fenster sind aktiviert.", "codexAuth.quotaAutoRefreshEmpty": "Keine unterstützten Kontingentfenster. Aktualisieren Sie die Kontingente der Konten.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index c6cd78c3f59..8caebec35f0 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -52,6 +52,13 @@ export const en = { "models.pickerOrder.loadFailed": "Could not load picker settings.", "models.pickerOrder.retry": "Retry", "models.pickerOrder.hint": "Saves routed order for Codex and Claude discovery. Featured/native bands stay in place; Most used is a snapshot. Native advertised choices may change.", + "models.fastRows.title": "Show Fast model rows", + "models.fastRows.desc": "Adds eligible “Model Fast” selectors to external client model pickers. This does not change reasoning-effort options on the base model. Saving refreshes connected integrations when possible; otherwise refresh the integration or client catalog.", + "models.fastRows.refreshHint": "Saved. Catalog refresh is pending.", + "models.fastRows.enabled": "Fast model rows enabled.", + "models.fastRows.disabled": "Fast model rows disabled.", + "models.fastRows.loadFailed": "Could not load Fast rows setting.", + "models.fastRows.updateFailed": "Failed to update Fast rows setting.", "codexAuth.quotaAutoRefreshAllHint": "Controls the supported 5-hour and weekly windows for all current accounts together. In Pool mode, a small request is sent after each reset and uses quota.", "codexAuth.quotaAutoRefreshMixed": "Some windows are enabled.", "codexAuth.quotaAutoRefreshEmpty": "No supported quota windows. Refresh account quotas to check again.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 2b8dd360908..d6fe2bf5970 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -50,6 +50,13 @@ export const fr: Record = { "models.pickerOrder.loadFailed": "Impossible de charger les réglages du sélecteur.", "models.pickerOrder.retry": "Réessayer", "models.pickerOrder.hint": "Enregistre l’ordre des modèles routés pour Codex et la découverte Claude. Les plages prioritaires et natives sont conservées. Les usages sont un instantané ; les choix annoncés nativement peuvent changer.", + "models.fastRows.title": "Afficher les lignes de modèles rapides", + "models.fastRows.desc": "Ajoute les sélecteurs « Model Fast » éligibles aux sélecteurs de modèles des clients externes. Cela ne modifie pas les options d'effort de raisonnement sur le modèle de base. L'enregistrement actualise les intégrations connectées lorsque c'est possible ; sinon, actualisez l'intégration ou le catalogue du client.", + "models.fastRows.refreshHint": "Enregistré. L'actualisation du catalogue est en attente.", + "models.fastRows.enabled": "Lignes de modèles rapides activées.", + "models.fastRows.disabled": "Lignes de modèles rapides désactivées.", + "models.fastRows.loadFailed": "Impossible de charger le paramètre des lignes rapides.", + "models.fastRows.updateFailed": "Échec de la mise à jour du paramètre des lignes rapides.", "codexAuth.quotaAutoRefreshAllHint": "Active ou désactive ensemble, pour tous les comptes actuels, les fenêtres de quota prises en charge par chaque compte : 5 heures et hebdomadaire. En mode Groupe, une petite requête consommant du quota est envoyée après chaque réinitialisation.", "codexAuth.quotaAutoRefreshMixed": "Certaines fenêtres sont activées.", "codexAuth.quotaAutoRefreshEmpty": "Aucune fenêtre de quota prise en charge. Actualisez les quotas des comptes.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 744f9b55785..189149d37e9 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -50,6 +50,13 @@ export const ja: Record = { "models.pickerOrder.loadFailed": "モデル選択設定を読み込めませんでした。", "models.pickerOrder.retry": "再試行", "models.pickerOrder.hint": "CodexとClaudeの検出一覧のルーティングモデル順を保存します。優先・ネイティブの順位帯は維持されます。使用量順はスナップショットで、ネイティブツールの候補表示は変わる場合があります。", + "models.fastRows.title": "高速モデル行を表示", + "models.fastRows.desc": "外部クライアントのモデル選択メニューに対象の「Model Fast」項目を追加します。ベースモデルの推論エフォート設定には影響しません。保存時に可能な範囲で接続済みインテグレーションを更新します。反映されない場合は、インテグレーションまたはクライアントのカタログを更新してください。", + "models.fastRows.refreshHint": "保存しました。カタログの更新を待機しています。", + "models.fastRows.enabled": "高速モデル行を有効にしました。", + "models.fastRows.disabled": "高速モデル行を無効にしました。", + "models.fastRows.loadFailed": "高速行設定を読み込めませんでした。", + "models.fastRows.updateFailed": "高速行設定の更新に失敗しました。", "codexAuth.quotaAutoRefreshAllHint": "現在の全アカウントで、対応する5時間・週間枠をまとめて切り替えます。プールモードではリセット後に少量の利用枠を消費するリクエストを送信します。", "codexAuth.quotaAutoRefreshMixed": "一部の枠が有効です。", "codexAuth.quotaAutoRefreshEmpty": "対応する利用枠がありません。アカウントの利用枠を更新してください。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 3c6bd87b70b..2155fb5bbca 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -50,6 +50,13 @@ export const ko: Record = { "models.pickerOrder.loadFailed": "모델 선택 설정을 불러오지 못했습니다.", "models.pickerOrder.retry": "다시 시도", "models.pickerOrder.hint": "Codex·Claude 검색 목록의 라우팅 모델 순서를 저장합니다. 지정 모델·네이티브 모델의 우선순위 구간은 유지됩니다. 사용량순은 스냅샷이며, 네이티브 도구에 표시되는 후보는 달라질 수 있습니다.", + "models.fastRows.title": "빠른 모델 행 표시", + "models.fastRows.desc": "외부 클라이언트 모델 선택기에 지원되는 'Model Fast' 선택기를 추가합니다. 기본 모델의 추론 강도 옵션은 변경되지 않습니다. 저장할 때 가능한 연결된 통합을 새로 고치며, 반영되지 않으면 통합 또는 클라이언트 카탈로그를 새로 고치세요.", + "models.fastRows.refreshHint": "저장되었습니다. 카탈로그 새로고침 대기 중입니다.", + "models.fastRows.enabled": "빠른 모델 행이 활성화되었습니다.", + "models.fastRows.disabled": "빠른 모델 행이 비활성화되었습니다.", + "models.fastRows.loadFailed": "빠른 행 설정을 불러올 수 없습니다.", + "models.fastRows.updateFailed": "빠른 행 설정 업데이트에 실패했습니다.", "codexAuth.quotaAutoRefreshAllHint": "현재 등록된 모든 계정의 5시간·주간 할당량을 한 번에 켜거나 끕니다. 지원하는 창에만 적용하며, 풀 모드에서 리셋 후 소량의 할당량을 쓰는 요청을 보냅니다.", "codexAuth.quotaAutoRefreshMixed": "일부만 켜져 있습니다.", "codexAuth.quotaAutoRefreshEmpty": "지원하는 할당량 창이 없습니다. 계정 할당량을 새로고침해 주세요.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 0d77aa50b7c..c25eb206a8d 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -50,6 +50,13 @@ export const ru: Record = { "models.pickerOrder.loadFailed": "Не удалось загрузить настройки выбора.", "models.pickerOrder.retry": "Повторить", "models.pickerOrder.hint": "Сохраняет порядок маршрутизируемых моделей для Codex и обнаружения Claude. Диапазоны приоритетных и нативных моделей сохраняются. Использование — снимок; нативно объявляемые варианты могут измениться.", + "models.fastRows.title": "Показывать строки быстрых моделей", + "models.fastRows.desc": "Добавляет подходящие селекторы «Model Fast» во внешние меню выбора моделей. Это не меняет параметры глубины рассуждений базовой модели. При сохранении подключённые интеграции обновляются по возможности; иначе обновите интеграцию или каталог клиента.", + "models.fastRows.refreshHint": "Сохранено. Ожидается обновление каталога.", + "models.fastRows.enabled": "Строки быстрых моделей включены.", + "models.fastRows.disabled": "Строки быстрых моделей отключены.", + "models.fastRows.loadFailed": "Не удалось загрузить настройку быстрых строк.", + "models.fastRows.updateFailed": "Не удалось обновить настройку быстрых строк.", "codexAuth.quotaAutoRefreshAllHint": "Общее переключение поддерживаемых 5-часовых и недельных окон всех текущих аккаунтов. В режиме пула после сброса отправляется небольшой запрос, расходующий квоту.", "codexAuth.quotaAutoRefreshMixed": "Включены некоторые окна.", "codexAuth.quotaAutoRefreshEmpty": "Нет поддерживаемых окон квоты. Обновите квоты аккаунтов.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 855f80eed02..cfb5ffc5b53 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -51,6 +51,13 @@ export const tr: Record = { "models.pickerOrder.loadFailed": "Seçici ayarları yüklenemedi.", "models.pickerOrder.retry": "Yeniden dene", "models.pickerOrder.hint": "Codex ve Claude keşfi için yönlendirilen model sırasını kaydeder. Öne çıkan/yerel öncelik aralıkları korunur. Kullanım bir anlık görüntüdür; yerel araçta sunulan seçenekler değişebilir.", + "models.fastRows.title": "Hızlı model satırlarını göster", + "models.fastRows.desc": "Harici istemci model seçicilerine uygun „Model Fast“ seçicilerini ekler. Bu, temel modeldeki akıl yürütme çabası seçeneklerini değiştirmez. Kaydetme işlemi mümkün olduğunda bağlı entegrasyonları yeniler; aksi durumda entegrasyonu veya istemci kataloğunu yenileyin.", + "models.fastRows.refreshHint": "Kaydedildi. Katalog yenilemesi bekleniyor.", + "models.fastRows.enabled": "Hızlı model satırları etkinleştirildi.", + "models.fastRows.disabled": "Hızlı model satırları devre dışı bırakıldı.", + "models.fastRows.loadFailed": "Hızlı satır ayarı yüklenemedi.", + "models.fastRows.updateFailed": "Hızlı satır ayarı güncellenemedi.", "codexAuth.quotaAutoRefreshAllHint": "Mevcut tüm hesapların desteklenen 5 saatlik ve haftalık pencerelerini birlikte açıp kapatır. Havuz modunda her sıfırlamadan sonra az miktarda kota kullanan bir istek gönderilir.", "codexAuth.quotaAutoRefreshMixed": "Bazı pencereler etkin.", "codexAuth.quotaAutoRefreshEmpty": "Desteklenen kota penceresi yok. Hesap kotalarını yenileyin.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index afe2bd6f2e2..53b1d451804 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2843,4 +2843,11 @@ export const vi: Record = { "nav.codexAuth": "Codex Auth", "pws.noModelMatch": "Không có model nào khớp bộ lọc.", "quota.fiveHourLimit": "Giới hạn 5 giờ", + "models.fastRows.title": "Hiển thị hàng model Fast", + "models.fastRows.desc": "Thêm các bộ chọn “Model Fast” đủ điều kiện vào trình chọn model của client bên ngoài. Việc này không thay đổi tùy chọn reasoning-effort của model gốc. Khi lưu, các tích hợp đang kết nối sẽ được làm mới nếu có thể; nếu không, hãy làm mới tích hợp hoặc catalog của client.", + "models.fastRows.refreshHint": "Đã lưu. Đang chờ làm mới catalog.", + "models.fastRows.enabled": "Đã bật hàng model Fast.", + "models.fastRows.disabled": "Đã tắt hàng model Fast.", + "models.fastRows.loadFailed": "Không thể tải cài đặt hàng Fast.", + "models.fastRows.updateFailed": "Không thể cập nhật cài đặt hàng Fast.", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index a70bb1f8fb4..7f91a508391 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -48,6 +48,13 @@ export const zhTW: Record = { "models.pickerOrder.loadFailed": "無法載入模型選擇設定。", "models.pickerOrder.retry": "重試", "models.pickerOrder.hint": "儲存 Codex 與 Claude 探索清單中的路由模型順序。保留精選與原生模型的優先級區間;使用量排序是快照,原生工具顯示的候選可能改變。", + "models.fastRows.title": "顯示快速模型項目", + "models.fastRows.desc": "在外部客戶端模型選擇器中加入適用的「Model Fast」項目。這不會影響基礎模型上的推理力度選項。儲存時會盡可能重新整理已連線的整合;若未生效,請重新整理整合或客戶端目錄。", + "models.fastRows.refreshHint": "已儲存。目錄重新整理處理中。", + "models.fastRows.enabled": "已啟用快速模型項目。", + "models.fastRows.disabled": "已停用快速模型項目。", + "models.fastRows.loadFailed": "無法載入快速模型項目設定。", + "models.fastRows.updateFailed": "更新快速模型項目設定失敗。", "codexAuth.quotaAutoRefreshAllHint": "統一切換目前所有帳戶各自支援的 5 小時與每週額度視窗。在帳戶池模式下,重設後會傳送消耗少量額度的請求。", "codexAuth.quotaAutoRefreshMixed": "部分視窗已啟用。", "codexAuth.quotaAutoRefreshEmpty": "沒有支援的額度視窗。請重新整理帳戶額度。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 58602ad91ed..36acf63b185 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -50,6 +50,13 @@ export const zh: Record = { "models.pickerOrder.loadFailed": "无法加载模型选择设置。", "models.pickerOrder.retry": "重试", "models.pickerOrder.hint": "保存 Codex 和 Claude 发现列表中的路由模型顺序。保留精选与原生模型的优先级区间;使用量排序是快照,原生工具显示的候选可能变化。", + "models.fastRows.title": "显示快速模型项目", + "models.fastRows.desc": "在外部客户端模型选择器中添加符合条件的“Model Fast”选项。这不会改变基础模型上的推理力度选项。保存时会尽可能刷新已连接的集成;若未生效,请刷新集成或客户端目录。", + "models.fastRows.refreshHint": "已保存。目录刷新正在处理中。", + "models.fastRows.enabled": "已启用快速模型项目。", + "models.fastRows.disabled": "已禁用快速模型项目。", + "models.fastRows.loadFailed": "无法加载快速模型项目设置。", + "models.fastRows.updateFailed": "更新快速模型项目设置失败。", "codexAuth.quotaAutoRefreshAllHint": "统一开关当前所有账户各自支持的 5 小时和每周额度窗口。在账户池模式下,重置后会发送消耗少量额度的请求。", "codexAuth.quotaAutoRefreshMixed": "部分窗口已启用。", "codexAuth.quotaAutoRefreshEmpty": "没有支持的额度窗口。请刷新账户额度。", diff --git a/gui/src/main.tsx b/gui/src/main.tsx index 30dbc41a1bd..362c5bb351d 100644 --- a/gui/src/main.tsx +++ b/gui/src/main.tsx @@ -5,6 +5,7 @@ import { LanguageProvider } from "./i18n/provider"; import "./styles.css"; import "./styles/usage-chart-accessibility.css"; import "./styles/sidebar-brand.css"; +import "./styles/fast-rows-setting.css"; ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index c05ab4c2310..352da32a89a 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -1,5 +1,5 @@ import { CodexStaleBanner } from "../components/codex-stale-banner"; -import ModelPickerOrderEditor from "../components/ModelPickerOrderEditor"; +import ModelCatalogSettingsPanels from "../components/ModelCatalogSettingsPanels"; import ModelDisplayNameDialog from "../components/ModelDisplayNameDialog"; import ModelPriceDialog from "../components/ModelPriceDialog"; import { fetchCodexAppServerState } from "../codex-app-server-state"; @@ -2155,8 +2155,8 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; } {t("models.pickerOrder.hint")} - {pickerMode === "custom" && acceptPickerOrder(data, true)} />} + acceptPickerOrder(data, true)} onSaved={() => catalogResource.refresh()} /> {(() => { diff --git a/gui/src/styles/fast-rows-setting.css b/gui/src/styles/fast-rows-setting.css new file mode 100644 index 00000000000..2dca31efbed --- /dev/null +++ b/gui/src/styles/fast-rows-setting.css @@ -0,0 +1,14 @@ +.fast-rows-card { gap: 16px; flex-wrap: wrap; margin-top: 16px; } +.fast-rows-copy { flex: 1 1 34rem; min-width: 0; } +.fast-rows-copy .card-sub { padding: 4px 0 0; } +.fast-rows-controls { display: flex; align-items: center; gap: 12px; flex: 0 0 auto; margin-left: auto; } +.fast-rows-feedback { + flex: 1 0 100%; + margin-top: -8px; + font-size: var(--text-label); + line-height: var(--leading-body); + text-align: right; +} +.fast-rows-feedback.is-ok { color: var(--green); } +.fast-rows-feedback.is-warn { color: var(--amber); } +.fast-rows-feedback.is-err { color: var(--red); } diff --git a/gui/tests/fast-rows-setting.test.tsx b/gui/tests/fast-rows-setting.test.tsx new file mode 100644 index 00000000000..dde84332d35 --- /dev/null +++ b/gui/tests/fast-rows-setting.test.tsx @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import FastRowsSetting from "../src/components/FastRowsSetting"; +import { LanguageProvider } from "../src/i18n/provider"; + +const domGlobals = ["document", "window", "navigator", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousDomGlobals: Record<(typeof domGlobals)[number], unknown>; +let testWindow: Window; +let mountedRoot: Root | null; + +function deferred(): { promise: Promise; resolve(value: T): void } { + let resolve!: (value: T) => void; + const promise = new Promise(res => { resolve = res; }); + return { promise, resolve }; +} + +async function flush(): Promise { + await Promise.resolve(); + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + await Promise.resolve(); +} + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("FastRowsSetting", () => { + beforeEach(() => { + previousDomGlobals = Object.fromEntries( + domGlobals.map(key => [key, Reflect.get(globalThis, key)]), + ) as typeof previousDomGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + mountedRoot = null; + }); + + afterEach(async () => { + if (mountedRoot) { + await act(async () => { mountedRoot?.unmount(); }); + mountedRoot = null; + } + for (const key of domGlobals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousDomGlobals[key] }); + } + await testWindow.happyDOM?.close?.(); + }); + + async function mount(fetchMock: typeof fetch, onSaved?: () => void): Promise { + globalThis.fetch = fetchMock; + const host = testWindow.document.createElement("div"); + testWindow.document.body.appendChild(host as never); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + mountedRoot = createRoot(host); + mountedRoot.render( + + + , + ); + }); + await act(async () => { await flush(); }); + return host; + } + + function toggle(host: ParentNode): HTMLButtonElement { + const button = host.querySelector("button.toggle"); + if (!button) throw new Error("toggle missing"); + return button; + } + + test("loads fastRows: true by default and renders description", async () => { + const settings = deferred(); + const host = await mount((async () => settings.promise) as typeof fetch); + + expect(host.textContent).toContain("Show Fast model rows"); + expect(host.querySelector("button.toggle")).toBeNull(); + + await act(async () => { + settings.resolve(response({ fastRows: true })); + await flush(); + }); + expect(toggle(host).getAttribute("aria-pressed")).toBe("true"); + expect(host.textContent).toContain("Adds eligible “Model Fast” selectors to external client model pickers"); + }); + + test("reflects false state when fastRows is disabled", async () => { + const host = await mount((async () => response({ fastRows: false })) as typeof fetch); + + expect(toggle(host).getAttribute("aria-pressed")).toBe("false"); + }); + + test("serializes rapid clicks, sends PUT, and triggers onSaved callback", async () => { + const pendingPut = deferred(); + let puts = 0; + let savedCalled = false; + const host = await mount( + (async (_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") { + puts += 1; + return pendingPut.promise; + } + return response({ fastRows: true }); + }) as typeof fetch, + () => { savedCalled = true; }, + ); + + expect(toggle(host).getAttribute("aria-pressed")).toBe("true"); + + act(() => { + toggle(host).click(); + toggle(host).click(); + }); + expect(puts).toBe(1); + expect(toggle(host).disabled).toBe(true); + expect(toggle(host).getAttribute("aria-pressed")).toBe("false"); + + await act(async () => { + pendingPut.resolve(response({ + ok: true, + fastRows: false, + catalogRefreshPending: false, + })); + await flush(); + }); + expect(toggle(host).disabled).toBe(false); + expect(toggle(host).getAttribute("aria-pressed")).toBe("false"); + expect(savedCalled).toBe(true); + expect(host.textContent).toContain("Fast model rows disabled."); + }); + + test("renders catalog refresh pending as an amber warning", async () => { + const host = await mount((async (_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") { + return response({ + ok: true, + fastRows: false, + catalogRefreshPending: true, + }); + } + return response({ fastRows: true }); + }) as typeof fetch); + + await act(async () => { + toggle(host).click(); + await flush(); + }); + const warning = host.querySelector(".fast-rows-feedback.is-warn"); + expect(warning?.textContent).toContain("Catalog refresh is pending"); + expect(warning?.getAttribute("role")).toBe("status"); + }); + + test("failed saves revert the optimistic toggle and show error feedback", async () => { + const host = await mount((async (_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") return response({ error: "server error" }, 500); + return response({ fastRows: true }); + }) as typeof fetch); + + await act(async () => { + toggle(host).click(); + await flush(); + }); + expect(toggle(host).getAttribute("aria-pressed")).toBe("true"); + expect(host.querySelector('[role="alert"]')?.textContent).toContain("Failed to update"); + }); + + test("reconciles an ambiguous save from server truth and refreshes the catalog", async () => { + let request = 0; + let savedCalled = false; + const host = await mount((async (_input: RequestInfo | URL, init?: RequestInit) => { + request += 1; + if (init?.method === "PUT") throw new TypeError("response lost"); + return response({ fastRows: request === 1 }); + }) as typeof fetch, () => { savedCalled = true; }); + + await act(async () => { + toggle(host).click(); + await flush(); + }); + expect(request).toBe(3); + expect(toggle(host).getAttribute("aria-pressed")).toBe("false"); + expect(savedCalled).toBe(true); + expect(host.textContent).toContain("Catalog refresh is pending"); + }); + + test("contains an initial load failure and recovers on retry", async () => { + let shouldFail = true; + const host = await mount((async () => { + if (shouldFail) return response({ error: "server error" }, 500); + return response({ fastRows: true }); + }) as typeof fetch); + + expect(host.querySelector("button.toggle")).toBeNull(); + expect(host.querySelector('[role="status"]')?.textContent).toContain("Could not load Fast rows setting"); + + shouldFail = false; + const retry = Array.from(host.querySelectorAll("button")).find(button => + button.textContent === "Retry" + ); + expect(retry).toBeTruthy(); + await act(async () => { + retry?.click(); + await flush(); + }); + expect(toggle(host).getAttribute("aria-pressed")).toBe("true"); + }); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index c361c4c0c86..bae860738ef 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1302,6 +1302,7 @@ "session-affinity.test.ts": "server", "session-lane-recall-harness.test.ts": "server", "settings-desktop-switch-apply.test.ts": "config", + "settings-fast-rows.test.ts": "config", "settings-main-account-hard-lock.test.ts": "config", "settings-oauth-open-browser.test.ts": "config", "settings-startup-health-seam.test.ts": "config", diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 75a106cd845..74b7398f773 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -186,7 +186,8 @@ interface ClientIntegrationSyncOutcome { export async function syncEnabledClientIntegrations( port: number | undefined, config: OcxConfig, - deps: Pick = {}, + deps: Pick = {}, ): Promise { if (port === undefined) return []; const { claudeDesktopIntegrationEnabled, grokIntegrationEnabled } = await import("../../codex/desired-state"); @@ -235,7 +236,8 @@ export async function syncEnabledClientIntegrations( } const { refreshOwnedCatalogIntegrations } = await import("../../integrations/catalog-refresh"); - out.push(...await refreshOwnedCatalogIntegrations({ + const refreshOwned = deps.refreshOwnedCatalogIntegrations ?? refreshOwnedCatalogIntegrations; + out.push(...await refreshOwned({ models: async () => { const { loadExportModels } = await import("./model-rows"); return loadExportModels(config); @@ -324,6 +326,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise = async () => ( + startupHealthFixture() +); + +function baseConfig(): OcxConfig { + return { + port: 10100, + defaultProvider: "openai", + providers: { + openai: { adapter: "openai-chat", baseUrl: "https://api.example.test/v1", apiKey: "sk-x", defaultModel: "gpt-test" }, + }, + }; +} + +function settingsRequest(config: OcxConfig, body?: unknown, deps?: Partial): Promise { + const req = body === undefined + ? new Request("http://127.0.0.1:10100/api/settings", { headers: { host: "127.0.0.1:10100" } }) + : new Request("http://127.0.0.1:10100/api/settings", { + method: "PUT", + headers: { "content-type": "application/json", host: "127.0.0.1:10100" }, + body: JSON.stringify(body), + }); + return handleManagementAPI(req, new URL(req.url), config, { + getCachedStartupHealth: readTestStartupHealth, + ...deps, + }); +} + +beforeEach(() => { + invalidateStartupHealthCache(); + TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-settings-fastrows-")); + process.env.OPENCODEX_HOME = TEST_DIR; +}); + +afterEach(() => { + invalidateStartupHealthCache(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (TEST_DIR && existsSync(TEST_DIR)) { + try { removeTreeWithRetry(TEST_DIR); } catch { /* Windows handle retention */ } + } +}); + +describe("/api/settings fastRows", () => { + test("an unconfigured install reports fastRows enabled by default", async () => { + const res = await settingsRequest(baseConfig()); + const body = await res!.json() as { fastRows?: boolean }; + expect(body.fastRows).toBe(true); + }); + + test("disabling persists to disk and survives a reload", async () => { + const config = baseConfig(); + saveConfig(config); + const put = await settingsRequest(config, { fastRows: false }); + expect(put!.status).toBe(200); + expect(await put!.json()).toMatchObject({ ok: true, fastRows: false }); + // The live object and the file must agree + expect(config.fastRows).toBe(false); + expect(loadConfig().fastRows).toBe(false); + + const get = await settingsRequest(config); + expect(await get!.json()).toMatchObject({ fastRows: false }); + }); + + test("it can be set on its own, without resending the other settings", async () => { + const config = baseConfig(); + saveConfig(config); + const res = await settingsRequest(config, { fastRows: false }); + expect(res!.status).toBe(200); + expect(await res!.json()).toMatchObject({ streamMode: "auto", codexAutoStart: true }); + }); + + test("a non-boolean is rejected instead of being coerced", async () => { + const res = await settingsRequest(baseConfig(), { fastRows: "disabled" }); + expect(res!.status).toBe(400); + expect(await res!.json()).toMatchObject({ error: "fastRows boolean is required" }); + }); + + test("turning it back on deletes the key and restores default true", async () => { + const config = baseConfig(); + config.fastRows = false; + saveConfig(config); + const res = await settingsRequest(config, { fastRows: true }); + expect(res!.status).toBe(200); + expect(await res!.json()).toMatchObject({ ok: true, fastRows: true }); + expect(loadConfig().fastRows).toBe(true); + const raw = JSON.parse(readFileSync(join(TEST_DIR, "config.json"), "utf8")) as Record; + expect(Object.hasOwn(raw, "fastRows")).toBe(false); + }); + + test("converges Codex and refreshes enabled client integrations when fastRows changes", async () => { + let converged = 0; + let integrationsRefreshed = 0; + const config = baseConfig(); + config.clientIntegrations = { grok: false, "claude-desktop": false }; + saveConfig(config); + const res = await settingsRequest(config, { fastRows: false }, { + createManagementConvergeCodex: catalogConvergenceFactory(() => { + converged += 1; + }), + readRuntimePort: pid => ({ pid, port: 12345 }), + refreshOwnedCatalogIntegrations: async input => { + expect(input.port).toBe(12345); + integrationsRefreshed += 1; + return []; + }, + }); + expect(res!.status).toBe(200); + expect(converged).toBe(1); + expect(integrationsRefreshed).toBe(1); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d59eeb0c836..af418333919 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1130,6 +1130,7 @@ "session-affinity.test.ts": "server", "session-lane-recall-harness.test.ts": "server", "settings-desktop-switch-apply.test.ts": "config", + "settings-fast-rows.test.ts": "config", "settings-main-account-hard-lock.test.ts": "config", "settings-oauth-open-browser.test.ts": "config", "settings-startup-health-seam.test.ts": "config",