From f260787527234f849b09bb2c81f0990f3bdf2338 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:04:02 +0900 Subject: [PATCH] fix(gui): select Advisor model from the routed catalog --- .../fr/reference/configuration/advisor.md | 2 + .../ja/reference/configuration/advisor.md | 2 + .../ko/reference/configuration/advisor.md | 2 + .../docs/reference/configuration/advisor.md | 2 + .../ru/reference/configuration/advisor.md | 2 + .../tr/reference/configuration/advisor.md | 2 + .../zh-cn/reference/configuration/advisor.md | 2 + .../zh-tw/reference/configuration/advisor.md | 2 + gui/src/advisor-models.ts | 11 ++ gui/src/components/AdvisorModelSelect.tsx | 59 ++++++++ gui/src/i18n/de.ts | 6 +- gui/src/i18n/en.ts | 6 +- gui/src/i18n/fr.ts | 6 +- gui/src/i18n/ja.ts | 6 +- gui/src/i18n/ko.ts | 6 +- gui/src/i18n/pt.ts | 6 +- gui/src/i18n/ru.ts | 6 +- gui/src/i18n/tr.ts | 6 +- gui/src/i18n/vi.ts | 6 +- gui/src/i18n/zh-TW.ts | 6 +- gui/src/i18n/zh.ts | 6 +- gui/src/pages/Advisor.tsx | 9 +- gui/tests/advisor-save-feedback.test.tsx | 129 +++++++++++++++++- structure/advisor.md | 4 + structure/gui-and-management-api.md | 2 +- 25 files changed, 272 insertions(+), 24 deletions(-) create mode 100644 gui/src/advisor-models.ts create mode 100644 gui/src/components/AdvisorModelSelect.tsx diff --git a/docs-site/src/content/docs/fr/reference/configuration/advisor.md b/docs-site/src/content/docs/fr/reference/configuration/advisor.md index baf92fbb1db..09776260947 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/fr/reference/configuration/advisor.md @@ -16,6 +16,8 @@ sidecar côté proxy invisible du client — même un worker qui ne spawn jamais ## Configuration +Sur la page **Advisor**, choisissez le modèle dans le catalogue. Le sélecteur conserve exactement les identifiants natifs, de fournisseur, de compte, personnalisés et de combo. Les modèles désactivés ou en attente de sélection initiale ne sont pas proposés. Un identifiant enregistré absent du catalogue reste visible avec un avertissement, jusqu’à son remplacement ou sa suppression. Un échec de chargement conserve la sélection et propose de réessayer. Choisissez un modèle capable de fournir des conseils textuels : le catalogue ne garantit pas la compatibilité des consultations. La CLI et l’API acceptent toujours les chaînes de modèle prises en charge par le routeur. + ```json { "advisor": { diff --git a/docs-site/src/content/docs/ja/reference/configuration/advisor.md b/docs-site/src/content/docs/ja/reference/configuration/advisor.md index c80ac5da93b..b87ab3fab1c 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/ja/reference/configuration/advisor.md @@ -9,6 +9,8 @@ description: OpenCodex 自身のエキスパート相談サイドカー — 設 ## 設定 +ダッシュボードの **Advisor** ページでカタログからモデルを選択します。ネイティブ、プロバイダー、アカウント指定、カスタム、コンボの識別子をそのまま保存します。無効なモデルと初期選択待ちのモデルは新規選択に表示されません。選択可能な一覧にない保存済み識別子は警告付きで保持され、別のモデルを選ぶか解除するまで変更されません。読み込みに失敗しても選択は保持され、再試行できます。テキストの助言を返せるモデルを選んでください。カタログは相談の互換性を保証しません。CLI と API は引き続きルーター対応のモデル文字列を受け付けます。 + ```json { "advisor": { diff --git a/docs-site/src/content/docs/ko/reference/configuration/advisor.md b/docs-site/src/content/docs/ko/reference/configuration/advisor.md index d0539ae96e1..40ae07864e1 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/ko/reference/configuration/advisor.md @@ -9,6 +9,8 @@ description: OpenCodex가 소유한 전문가 상담 사이드카 — 구성된 ## 구성 +대시보드 **어드바이저** 페이지에서 카탈로그 선택기로 전문가 모델을 선택하세요. 네이티브, 공급자, 계정 지정, 사용자 정의 및 콤보 선택자의 정확한 카탈로그 식별자를 저장합니다. 비활성 모델과 초기 선택 대기 모델은 새 선택에 표시하지 않습니다. 선택 가능한 목록에 없는 저장된 식별자는 경고와 함께 유지되며, 다른 모델을 선택하거나 지우기 전까지 변경되지 않습니다. 카탈로그 로드 실패 시 현재 선택을 보존하고 재시도를 제공합니다. 텍스트 조언을 반환할 수 있는 모델을 선택하세요. 카탈로그가 상담 호환성을 보장하지는 않습니다. CLI와 API는 라우터가 지원하는 모델 문자열을 계속 허용합니다. + ```json { "advisor": { diff --git a/docs-site/src/content/docs/reference/configuration/advisor.md b/docs-site/src/content/docs/reference/configuration/advisor.md index 139256cb206..d91410b6fe1 100644 --- a/docs-site/src/content/docs/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/reference/configuration/advisor.md @@ -16,6 +16,8 @@ never sees — even a worker that never spawns anything can be advised. ## Configuration +On the dashboard **Advisor** page, choose the expert model from the catalog selector. It saves the exact catalog identifier, including native, provider, account-qualified, custom and combo selectors. Disabled models and models awaiting initial selection are not offered as new choices. A saved identifier missing from the selectable catalog remains visible with a warning and is preserved until you choose another model or clear it. Catalog loading failures preserve the selection and provide Retry. Choose a model that can return text advice: the catalog does not guarantee consultation compatibility. The CLI and API continue to accept router-supported model strings. + ```json { "advisor": { diff --git a/docs-site/src/content/docs/ru/reference/configuration/advisor.md b/docs-site/src/content/docs/ru/reference/configuration/advisor.md index 2cd0e396e13..8cb07090d95 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/ru/reference/configuration/advisor.md @@ -9,6 +9,8 @@ description: Принадлежащий OpenCodex sidecar экспертных ## Конфигурация +На странице **Advisor** выберите модель из каталога. Селектор сохраняет точные идентификаторы нативных моделей, провайдеров, аккаунтов, пользовательских моделей и combo. Отключённые модели и модели, ожидающие начального выбора, не предлагаются. Сохранённый идентификатор вне доступного списка остаётся видимым с предупреждением до замены или очистки. Ошибка загрузки сохраняет выбор и позволяет повторить попытку. Выберите модель для текстовых советов: каталог не гарантирует совместимость консультаций. CLI и API по-прежнему принимают строки моделей, поддерживаемые маршрутизатором. + ```json { "advisor": { diff --git a/docs-site/src/content/docs/tr/reference/configuration/advisor.md b/docs-site/src/content/docs/tr/reference/configuration/advisor.md index a2b7de6a13a..f1a1e646f9a 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/tr/reference/configuration/advisor.md @@ -9,6 +9,8 @@ Bu, alt ajan yüzeyinden farklıdır (bkz. [Ajan yapılandırması](/tr/referenc ## Yapılandırma +Kontrol panelindeki **Advisor** sayfasında katalogdan model seçin. Yerel, sağlayıcı, hesap, özel model ve combo tanımlayıcıları aynen kaydedilir. Devre dışı veya ilk seçimi bekleyen modeller yeni seçenek olarak sunulmaz. Listede bulunmayan kayıtlı tanımlayıcı, değiştirilene veya temizlenene kadar uyarıyla korunur. Yükleme hatası seçimi korur ve yeniden deneme sunar. Metin önerisi verebilen bir model seçin; katalog danışma uyumluluğunu garanti etmez. CLI ve API, yönlendiricinin desteklediği model dizelerini kabul etmeye devam eder. + ```json { "advisor": { diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/advisor.md b/docs-site/src/content/docs/zh-cn/reference/configuration/advisor.md index 3ef528c6135..79d590c1835 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/advisor.md @@ -9,6 +9,8 @@ description: OpenCodex 自有的专家咨询 sidecar — 配置的专家模型 ## 配置 +在仪表板 **Advisor** 页面从目录选择专家模型。选择器原样保存原生、提供商、指定账户、自定义和组合模型的标识符。已禁用和等待初始选择的模型不会作为新选项。已保存但不在可选目录中的标识符会保留并显示警告,直到您选择其他模型或清除。目录加载失败时保留当前选择并提供重试。请选择能返回文本建议的模型;目录不保证咨询兼容性。CLI 和 API 仍接受路由器支持的模型字符串。 + ```json { "advisor": { diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/advisor.md b/docs-site/src/content/docs/zh-tw/reference/configuration/advisor.md index 655328acc95..b599f8205c0 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/advisor.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/advisor.md @@ -9,6 +9,8 @@ description: OpenCodex 自有的專家諮詢 sidecar — 設定的專家模型 ## 設定 +在儀表板 **Advisor** 頁面從目錄選擇專家模型。選擇器原樣儲存原生、供應商、指定帳戶、自訂和組合模型的識別碼。已停用和等待初始選擇的模型不會作為新選項。已儲存但不在可選目錄中的識別碼會保留並顯示警告,直到您選擇其他模型或清除。目錄載入失敗時保留目前選擇並提供重試。請選擇能回傳文字建議的模型;目錄不保證諮詢相容性。CLI 和 API 仍接受路由器支援的模型字串。 + ```json { "advisor": { diff --git a/gui/src/advisor-models.ts b/gui/src/advisor-models.ts new file mode 100644 index 00000000000..7ff4ca2f2e8 --- /dev/null +++ b/gui/src/advisor-models.ts @@ -0,0 +1,11 @@ +export interface AdvisorModelRow { + namespaced: string; + disabled?: boolean; + initialSelectionPending?: boolean; +} + +/** Keep routing selectors opaque: labels and model display names are not model IDs. */ +export function advisorModelValues(rows: readonly AdvisorModelRow[]): string[] { + return [...new Set(rows.filter(row => !row.disabled && !row.initialSelectionPending) + .map(row => row.namespaced))]; +} diff --git a/gui/src/components/AdvisorModelSelect.tsx b/gui/src/components/AdvisorModelSelect.tsx new file mode 100644 index 00000000000..75d7a32f635 --- /dev/null +++ b/gui/src/components/AdvisorModelSelect.tsx @@ -0,0 +1,59 @@ +import { useDataSurface } from "../data-surface"; +import { useT } from "../i18n/shared"; +import { Notice, Select } from "../ui"; +import { advisorModelValues, type AdvisorModelRow } from "../advisor-models"; + +export function AdvisorModelSelect({ apiBase, value, disabled, onChange }: { + apiBase: string; + value: string; + disabled: boolean; + onChange: (value: string) => void; +}) { + const t = useT(); + const resource = useDataSurface( + `advisor-models:${apiBase}`, + [apiBase], + async signal => { + const response = await fetch(`${apiBase}/api/models`, { signal }); + if (!response.ok) throw new Error(String(response.status)); + const rows: unknown = await response.json(); + if (!Array.isArray(rows) || !rows.every(row => row && typeof row.namespaced === "string" && row.namespaced.length > 0)) { + throw new Error("Invalid model catalog"); + } + return rows as AdvisorModelRow[]; + }, + { isEmpty: rows => rows.length === 0 }, + ); + const { state } = resource; + const values = advisorModelValues(state.data ?? []); + const unavailable = value !== "" && !values.includes(value); + const options = [ + { value: "", label: t("advisor.modelClear") }, + ...(unavailable ? [{ value, label: value }] : []), + ...values.map(model => ({ value: model, label: model })), + ]; + return ( +
+ edit({ model: event.target.value })} + onChange={model => edit({ model })} />
diff --git a/gui/tests/advisor-save-feedback.test.tsx b/gui/tests/advisor-save-feedback.test.tsx index 9c614444d37..d879b9515bc 100644 --- a/gui/tests/advisor-save-feedback.test.tsx +++ b/gui/tests/advisor-save-feedback.test.tsx @@ -4,6 +4,8 @@ import { act } from "react"; import type { Root } from "react-dom/client"; import { LanguageProvider } from "../src/i18n/provider"; import { AdvisorEditor, type AdvisorSettings } from "../src/pages/Advisor"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { advisorModelValues } from "../src/advisor-models"; const globals = [ "document", @@ -27,6 +29,7 @@ const initialSettings: AdvisorSettings = { }; beforeEach(() => { + clearClientResourceStoresForTests(); previousGlobals = Object.fromEntries( globals.map(key => [key, Reflect.get(globalThis, key)]), ) as typeof previousGlobals; @@ -42,6 +45,7 @@ beforeEach(() => { }); afterEach(() => { + clearClientResourceStoresForTests(); globalThis.fetch = originalFetch; testWindow.close(); for (const key of globals) { @@ -49,7 +53,15 @@ afterEach(() => { } }); -async function mountEditor(initial = initialSettings): Promise<{ root: Root; container: HTMLElement }> { +const catalogValues = ["expert/gpt-first", "expert/gpt-second", "expert/model-1", "expert/model-2", "expert/gpt-unmount", + "gpt-6-astra", "devin/swe-2", "personal/gpt-6-astra", "combo/review", "custom/vendor-model"]; + +async function mountEditor(initial = initialSettings, loadCatalog: () => Response | Promise = () => + Response.json(catalogValues.map(namespaced => ({ namespaced }))), +): Promise<{ root: Root; container: HTMLElement }> { + const saveFetch = globalThis.fetch; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => + String(input).endsWith("/api/models") ? Promise.resolve(loadCatalog()) : saveFetch(input, init)) as typeof fetch; const container = testWindow.document.createElement("div") as unknown as HTMLElement; testWindow.document.body.append(container as never); const { createRoot } = await import("react-dom/client"); @@ -66,17 +78,20 @@ async function mountEditor(initial = initialSettings): Promise<{ root: Root; con } async function setModel(container: HTMLElement, value: string): Promise { - const input = container.querySelector("#advisor-model")!; - expect(input).toBeTruthy(); + const trigger = container.querySelector("#advisor-model")!; + expect(trigger).toBeTruthy(); + await act(async () => { trigger.click(); }); + const option = [...testWindow.document.querySelectorAll("[role=option]")] + .find(item => value === "" ? item.textContent === "Clear model" : item.textContent === value); + expect(option).toBeTruthy(); await act(async () => { - Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")! - .set!.call(input, value); - input.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + option!.click(); }); } async function clickSave(container: HTMLElement): Promise { - const button = container.querySelector("button.btn")!; + const button = [...container.querySelectorAll("button.btn")] + .find(item => item.textContent === "Save advisor settings")!; expect(button).toBeTruthy(); await act(async () => { button.click(); @@ -214,3 +229,103 @@ test("Case 3: component unmount with a pending timer cleans up without post-unmo // No error thrown and unmount cleanup succeeded }); + +test("catalog selection saves exact native, provider, account, combo and custom identifiers and can clear", async () => { + const submitted: AdvisorSettings[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const settings = JSON.parse(init!.body as string) as AdvisorSettings; + submitted.push(settings); + return Response.json({ settings, runnable: !!settings.model }); + }) as typeof fetch; + const { root, container } = await mountEditor(); + expect(container.querySelector('input[type="text"]')).toBeNull(); + for (const value of ["gpt-6-astra", "devin/swe-2", "personal/gpt-6-astra", "combo/review", "custom/vendor-model", ""]) { + await setModel(container, value); + await clickSave(container); + expect(submitted.at(-1)).toEqual({ ...initialSettings, model: value }); + } + await act(async () => root.unmount()); +}); + +test("unlisted saved identifier survives catalog load and unrelated setting save", async () => { + let submitted: AdvisorSettings | undefined; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + submitted = JSON.parse(init!.body as string); + return Response.json({ settings: submitted, runnable: true }); + }) as typeof fetch; + const initial = { ...initialSettings, model: "Gpt-6-Astra" }; + const { root, container } = await mountEditor(initial); + expect(container.querySelector("#advisor-model")?.textContent).toContain(initial.model); + expect(container.textContent).toContain("saved identifier is preserved"); + expect(submitted).toBeUndefined(); + await act(async () => { + const policy = container.querySelector("#advisor-policy")!; + policy.value = "preflight"; + policy.dispatchEvent(new testWindow.Event("change", { bubbles: true })); + }); + await clickSave(container); + expect(submitted).toEqual({ ...initial, policy: "preflight" }); + await act(async () => root.unmount()); +}); + +test("catalog failure preserves current selection and retry loads canonical choices", async () => { + let attempts = 0; + const { root, container } = await mountEditor(initialSettings, () => { + attempts++; + return attempts === 1 ? new Response(null, { status: 503 }) : Response.json([{ namespaced: "gpt-6-astra" }]); + }); + expect(container.textContent).toContain("Could not load models"); + expect(container.textContent).not.toContain("saved identifier is preserved"); + expect(container.querySelector("#advisor-model")?.textContent).toContain(initialSettings.model); + await act(async () => { + [...container.querySelectorAll("button")].find(button => button.textContent === "Retry")!.click(); + }); + expect(attempts).toBe(2); + expect(container.textContent).not.toContain("Could not load models"); + await setModel(container, "gpt-6-astra"); + expect(container.querySelector("#advisor-model")?.textContent).toBe("gpt-6-astra"); + await act(async () => root.unmount()); +}); + +test("disabled and pending rows are excluded without guessing eligibility from model names", () => { + expect(advisorModelValues([ + { namespaced: "gpt-6-astra" }, { namespaced: "gpt-6-astra" }, + { namespaced: "disabled/model", disabled: true }, + { namespaced: "pending/model", initialSelectionPending: true }, + { namespaced: "provider/image-named-text-model" }, + ])).toEqual(["gpt-6-astra", "provider/image-named-text-model"]); +}); + +test("empty catalog keeps the saved value and exposes retry and clear", async () => { + const { root, container } = await mountEditor(initialSettings, () => Response.json([])); + expect(container.textContent).toContain("No selectable models"); + expect(container.querySelector("#advisor-model")?.textContent).toContain(initialSettings.model); + await setModel(container, ""); + expect(container.querySelector("#advisor-model")?.textContent).toBe("Clear model"); + await act(async () => root.unmount()); +}); + +test("cold catalog loading disables selection without replacing the saved identifier", async () => { + let resolve!: (response: Response) => void; + const pending = new Promise(done => { resolve = done; }); + const { root, container } = await mountEditor(initialSettings, () => pending); + expect(container.querySelector("#advisor-model")?.disabled).toBe(true); + expect(container.querySelector("#advisor-model")?.textContent).toContain(initialSettings.model); + await act(async () => { + const policy = container.querySelector("#advisor-policy")!; + policy.value = "preflight"; + policy.dispatchEvent(new testWindow.Event("change", { bubbles: true })); + }); + await act(async () => { resolve(Response.json([{ namespaced: "gpt-6-astra" }])); }); + expect(container.querySelector("#advisor-model")?.disabled).toBe(false); + expect(container.querySelector("#advisor-model")?.textContent).toContain(initialSettings.model); + expect(container.querySelector("#advisor-policy")?.value).toBe("preflight"); + await act(async () => root.unmount()); +}); + +test("malformed catalog is a load error and preserves the current identifier", async () => { + const { root, container } = await mountEditor(initialSettings, () => Response.json({ models: [] })); + expect(container.textContent).toContain("Could not load models"); + expect(container.querySelector("#advisor-model")?.textContent).toContain(initialSettings.model); + await act(async () => root.unmount()); +}); diff --git a/structure/advisor.md b/structure/advisor.md index abe14fb5042..b0e782c7e7f 100644 --- a/structure/advisor.md +++ b/structure/advisor.md @@ -178,6 +178,10 @@ worker model; a client with no stable identity is excluded from it on purpose. A restart the table is empty, so a task in progress may receive one more preflight attempt — fail-open for correctness and only one extra expert call. +## Dashboard model selection + +`gui/src/components/AdvisorModelSelect.tsx` loads `/api/models` independently of the settings editor and uses the shared `Select`. Values are exact `namespaced` routing selectors, never display names or lowercased input. Disabled and initial-selection-pending rows are excluded from new choices. A current value outside the selectable catalog remains visible with a warning; load failures preserve it and expose retry. Clearing saves the existing empty-model state. The catalog provides no text-consultation eligibility guarantee; the operator chooses a text-capable model. API and CLI model-string validation remains unchanged. + ## Policies - `manual` (default): only an explicit worker `advisor()` call consults. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 961a55f07ed..c5762c65c8b 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -32,7 +32,7 @@ Automatic activation retains its existing settings controls; dashboard quota que The companion settings contract in `src/companion/` persists menu-bar and widget preferences. `src/server/management/companion-routes.ts` exposes them and the usage timeline from `src/usage/timeline.ts`; its query and missing-measurement behavior follows [companion usage](companion.md). -Only `/api/advisor/settings` lazily loads `advisor-routes.ts`, refreshing the config-scoped factory after a successful save; default requests stay outside [the optional Advisor subsystem](advisor.md). +Only `/api/advisor/settings` lazily loads `advisor-routes.ts`, refreshing the config-scoped factory after a successful save; default requests stay outside [the optional Advisor subsystem](advisor.md). The dashboard selector follows the [catalog selection contract](advisor.md#dashboard-model-selection), preserving unknown saved selectors and catalog failure feedback. Native result continuations and function-result injection follow [the mode-specific result and control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Explicit Codex CLI installation observation is a local CLI surface, not a management API or GUI update permission. See the [read-only observation contract](runtime.md#explicit-codex-cli-installation-observation).