From 12e5eb6a239ddf680cc56c52fc8eaad3cc6afb8e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 30 Jul 2026 09:47:57 +0800 Subject: [PATCH] fix(ui): clarify model setup flows (#116086) --- .../src/schema/openclaw.test.ts | 3 +- .../gateway-protocol/src/schema/openclaw.ts | 2 + .../setup-inference-auth-options.ts | 11 +- src/system-agent/setup-inference-detect.ts | 45 ++- src/system-agent/setup-inference.test.ts | 115 +++++++ ui/src/components/provider-icon.ts | 3 + ui/src/e2e/model-setup.e2e.test.ts | 272 ++++++++++++++- ui/src/i18n/locales/en.ts | 20 +- .../model-setup/model-setup-page.test.ts | 2 +- ui/src/pages/model-setup/model-setup-page.ts | 4 + ui/src/pages/model-setup/provider-picker.ts | 79 +++++ ui/src/pages/model-setup/success-dialog.ts | 51 +++ ui/src/pages/model-setup/view.test.ts | 312 +++++++++++++++++- ui/src/pages/model-setup/view.ts | 190 +++++++---- ui/src/styles/model-setup.css | 237 +++++++++++-- 15 files changed, 1226 insertions(+), 120 deletions(-) create mode 100644 ui/src/pages/model-setup/provider-picker.ts create mode 100644 ui/src/pages/model-setup/success-dialog.ts diff --git a/packages/gateway-protocol/src/schema/openclaw.test.ts b/packages/gateway-protocol/src/schema/openclaw.test.ts index 64b3a6592074..ea3deeb404b7 100644 --- a/packages/gateway-protocol/src/schema/openclaw.test.ts +++ b/packages/gateway-protocol/src/schema/openclaw.test.ts @@ -114,6 +114,7 @@ describe("OpenClaw setup detection protocol", () => { { id: "ollama", brandId: "ollama", + groupLabel: "Ollama", label: "Ollama", icon: "https://cdn.simpleicons.org/ollama", website: "https://ollama.com/download", @@ -143,7 +144,7 @@ describe("OpenClaw setup detection protocol", () => { ({ brandId: _brandId, ...candidate }) => candidate, ), manualProviders: result.manualProviders.map( - ({ brandId: _brandId, ...provider }) => provider, + ({ brandId: _brandId, groupLabel: _groupLabel, ...provider }) => provider, ), recommendedInstalls: result.recommendedInstalls.map( ({ brandId: _brandId, ...install }) => install, diff --git a/packages/gateway-protocol/src/schema/openclaw.ts b/packages/gateway-protocol/src/schema/openclaw.ts index 321d237f2a85..0e8de26549e3 100644 --- a/packages/gateway-protocol/src/schema/openclaw.ts +++ b/packages/gateway-protocol/src/schema/openclaw.ts @@ -232,6 +232,8 @@ export const SystemAgentSetupDetectResultSchema = closedObject({ id: NonEmptyString, /** Canonical provider identity for clients with bundled brand artwork. */ brandId: Type.Optional(NonEmptyString), + /** Provider family shown above the specific credential method. */ + groupLabel: Type.Optional(NonEmptyString), label: NonEmptyString, hint: Type.Optional(Type.String()), icon: Type.Optional(SetupInferenceHttpsUrl), diff --git a/src/system-agent/setup-inference-auth-options.ts b/src/system-agent/setup-inference-auth-options.ts index 38a24301d3ba..f5a9c5cd4e39 100644 --- a/src/system-agent/setup-inference-auth-options.ts +++ b/src/system-agent/setup-inference-auth-options.ts @@ -6,6 +6,8 @@ export type SetupInferenceManualProvider = { id: string; /** Canonical provider identity for clients with bundled brand artwork. */ brandId?: string; + /** Provider family shown above the specific credential method. */ + groupLabel?: string; label: string; hint?: string; icon?: string; @@ -48,6 +50,7 @@ export function listSetupInferenceManualProviders( choices.set(id, { id, brandId: choice.providerId, + ...(choice.groupLabel?.trim() ? { groupLabel: choice.groupLabel.trim() } : {}), label: choice.choiceLabel, ...(choice.choiceHint?.trim() ? { hint: choice.choiceHint.trim() } : {}), ...(choice.icon ? { icon: choice.icon } : {}), @@ -55,7 +58,13 @@ export function listSetupInferenceManualProviders( }); } return [...choices.values()].toSorted( - (a, b) => a.label.localeCompare(b.label, "en") || a.id.localeCompare(b.id, "en"), + (a, b) => + compareProviderAuthChoiceGroups( + { id: a.brandId ?? a.id, label: a.groupLabel ?? a.label }, + { id: b.brandId ?? b.id, label: b.groupLabel ?? b.label }, + ) || + a.label.localeCompare(b.label, "en") || + a.id.localeCompare(b.id, "en"), ); } diff --git a/src/system-agent/setup-inference-detect.ts b/src/system-agent/setup-inference-detect.ts index 968e82a6d0c3..eab32f39daf2 100644 --- a/src/system-agent/setup-inference-detect.ts +++ b/src/system-agent/setup-inference-detect.ts @@ -1,4 +1,7 @@ +import { normalizeOptionalAgentRuntimeId } from "../agents/agent-runtime-id.js"; import { resolveAgentEffectiveModelPrimary, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { areRuntimeModelRefsEquivalent } from "../agents/model-runtime-aliases.js"; +import { resolveModelRuntimePolicy } from "../agents/model-runtime-policy.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import { detectInferenceBackends } from "../commands/onboard-inference.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -28,6 +31,31 @@ import { } from "./setup-inference-core.js"; import { parseRef } from "./setup-inference-plan-helpers.js"; +function resolveConfiguredCandidateKind( + config: Parameters[0]["config"], + modelRef: string | undefined, +): SetupInferenceCandidate["kind"] | undefined { + if (!modelRef) { + return undefined; + } + const ref = parseRef(modelRef); + const runtime = normalizeOptionalAgentRuntimeId( + resolveModelRuntimePolicy({ + config, + provider: ref.provider, + modelId: ref.model, + agentId: resolveDefaultAgentId(config ?? {}), + }).policy?.id, + ); + if (runtime === "codex") { + return "codex-cli"; + } + if (runtime === "claude-cli") { + return "claude-cli"; + } + return undefined; +} + /** * Manual setup options only — no CLI probing, no credential discovery. Used * when guarded onboarding declines the "look around" step: the option lists @@ -113,7 +141,19 @@ export async function detectSetupInference( "OpenCode CLI is installed, but its ACP harness requires separate setup and is not a reusable guided-setup inference route.", }); } - const raw = detected.filter((candidate) => candidate.kind !== "gemini-cli"); + const configuredModel = detected.find( + (candidate) => candidate.kind === "existing-model", + )?.modelRef; + const configuredCandidateKind = resolveConfiguredCandidateKind(cfg, configuredModel); + const raw = detected.filter( + (candidate) => + candidate.kind !== "gemini-cli" && + !( + candidate.kind === configuredCandidateKind && + configuredModel && + areRuntimeModelRefsEquivalent(candidate.modelRef, configuredModel, { config: cfg }) + ), + ); const { workspace } = await resolveSetupInferenceWorkspace({ configExists: snapshot.exists, configValid: snapshot.valid, @@ -174,9 +214,6 @@ export async function detectSetupInference( resolveCandidatePresentation(candidate, authChoices), ), ); - const configuredModel = candidates.find( - (candidate) => candidate.kind === "existing-model", - )?.modelRef; const discoveryChoices = authChoices.filter( (choice) => choice.appGuidedDiscovery === true && supportsSetupTextInference(choice.onboardingScopes), diff --git a/src/system-agent/setup-inference.test.ts b/src/system-agent/setup-inference.test.ts index 9847fedf010f..9bcfc71767cc 100644 --- a/src/system-agent/setup-inference.test.ts +++ b/src/system-agent/setup-inference.test.ts @@ -626,6 +626,7 @@ describe("detectSetupInference", () => { choiceId: "zeta-api-key", choiceLabel: "Zeta API key", choiceHint: "Direct key", + groupLabel: "Zeta", icon: "https://cdn.example.com/zeta.svg", website: "https://zeta.example.com/keys", optionKey: "zetaApiKey", @@ -638,6 +639,7 @@ describe("detectSetupInference", () => { methodId: "api-key", choiceId: "alpha-api-key", choiceLabel: "Alpha API key", + groupLabel: "Alpha", appGuidedSecret: true, }, { @@ -656,6 +658,7 @@ describe("detectSetupInference", () => { { id: "alpha-api-key", brandId: "alpha", + groupLabel: "Alpha", label: "Alpha API key", }, { @@ -666,6 +669,7 @@ describe("detectSetupInference", () => { { id: "zeta-api-key", brandId: "zeta", + groupLabel: "Zeta", label: "Zeta API key", hint: "Direct key", icon: "https://cdn.example.com/zeta.svg", @@ -809,6 +813,117 @@ describe("detectSetupInference", () => { }); }); + it("does not re-offer the configured Codex route as a setup candidate", async () => { + const { readConfigFileSnapshot } = await import("../config/config.js"); + const config: OpenClawConfig = { + agents: { + defaults: { model: "openai/gpt-5.6-sol" }, + entries: { + main: { + default: true, + models: { + "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } }, + }, + }, + }, + }, + }; + vi.mocked(readConfigFileSnapshot).mockResolvedValueOnce({ + exists: true, + valid: true, + path: "/tmp/openclaw.json", + issues: [], + config, + sourceConfig: config, + runtimeConfig: config, + } as never); + vi.mocked(detectInferenceBackends).mockResolvedValueOnce([ + { + kind: "existing-model", + modelRef: "openai/gpt-5.6-sol", + label: "Current model", + detail: "openai/gpt-5.6-sol — already configured", + credentials: true, + }, + { + kind: "claude-cli", + modelRef: "claude-cli/claude-opus-5", + label: "Claude Code", + detail: "logged in", + credentials: true, + }, + { + kind: "codex-cli", + modelRef: "openai/gpt-5.6-sol", + label: "Codex", + detail: "logged in", + credentials: true, + }, + ]); + + const detection = await detectSetupInference({ + resolveManifestProviderAuthChoices: () => [], + probeLocalCommand: vi.fn(async (command) => ({ command, found: false })), + }); + + expect(detection.candidates.map((candidate) => candidate.kind)).toEqual([ + "existing-model", + "claude-cli", + ]); + }); + + it("keeps a Codex candidate when it would switch the configured model", async () => { + const { readConfigFileSnapshot } = await import("../config/config.js"); + const config: OpenClawConfig = { + agents: { + defaults: { model: "openai/gpt-5.5" }, + entries: { + main: { + default: true, + models: { + "openai/gpt-5.5": { agentRuntime: { id: "codex" } }, + }, + }, + }, + }, + }; + vi.mocked(readConfigFileSnapshot).mockResolvedValueOnce({ + exists: true, + valid: true, + path: "/tmp/openclaw.json", + issues: [], + config, + sourceConfig: config, + runtimeConfig: config, + } as never); + vi.mocked(detectInferenceBackends).mockResolvedValueOnce([ + { + kind: "existing-model", + modelRef: "openai/gpt-5.5", + label: "Current model", + detail: "openai/gpt-5.5 — already configured", + credentials: true, + }, + { + kind: "codex-cli", + modelRef: "openai/gpt-5.6-sol", + label: "Codex", + detail: "logged in", + credentials: true, + }, + ]); + + const detection = await detectSetupInference({ + resolveManifestProviderAuthChoices: () => [], + probeLocalCommand: vi.fn(async (command) => ({ command, found: false })), + }); + + expect(detection.candidates.map((candidate) => candidate.kind)).toEqual([ + "existing-model", + "codex-cli", + ]); + }); + it("omits Gemini CLI because setup verification cannot hard-disable its tools", async () => { vi.mocked(detectInferenceBackends).mockResolvedValueOnce([ { diff --git a/ui/src/components/provider-icon.ts b/ui/src/components/provider-icon.ts index d10c3a566538..ab6454c5c9e8 100644 --- a/ui/src/components/provider-icon.ts +++ b/ui/src/components/provider-icon.ts @@ -76,6 +76,7 @@ const PROVIDER_ICON_ALIASES: Readonly> = { moonshot: "kimi", "opencode-go": "opencodego", "opencode-zen": "opencode", + qwen: "alibaba", xai: "grok", "vertex-ai": "vertexai", "z-ai": "zai", @@ -90,6 +91,8 @@ const PROVIDER_DISPLAY_LABELS: Readonly> = { moonshot: "Moonshot AI", opencode: "OpenCode", openrouter: "OpenRouter", + qwen: "Qwen Cloud", + zai: "Z.AI", }; /** Title-cased fallback label built from the provider id ("z-ai" → "Z Ai"). */ diff --git a/ui/src/e2e/model-setup.e2e.test.ts b/ui/src/e2e/model-setup.e2e.test.ts index b5735a83d568..9e719aa9ba11 100644 --- a/ui/src/e2e/model-setup.e2e.test.ts +++ b/ui/src/e2e/model-setup.e2e.test.ts @@ -104,10 +104,13 @@ describeControlUiE2e("Control UI Model Setup mocked Gateway E2E", () => { const activate = await gateway.waitForRequest("openclaw.setup.activate"); expect(activate.params).toEqual({ kind: "codex-cli", modelRef: "openai/gpt-5" }); - await page.getByText("Your AI is ready").waitFor(); + await page.getByRole("heading", { name: "Connection verified" }).waitFor(); await expect - .poll(async () => page.locator(".model-setup__success").textContent()) - .toContain("openai/gpt-5 · 73 ms"); + .poll(async () => page.locator(".model-setup-success").textContent()) + .toContain("openai/gpt-5"); + await expect + .poll(async () => page.locator(".model-setup-success").textContent()) + .toContain("Verified in 73 ms"); await page.getByRole("button", { name: "Continue setup" }).click(); await expect.poll(() => new URL(page.url()).pathname).toBe("/custodian"); expect(new URL(page.url()).searchParams.get("onboarding")).toBe("1"); @@ -223,9 +226,9 @@ describeControlUiE2e("Control UI Model Setup mocked Gateway E2E", () => { await expect .poll(async () => (await gateway.getRequests("openclaw.setup.detect")).length) .toBe(detectCountBeforeCompletion + 1); - await page.getByText("Your AI is ready").waitFor(); + await page.getByRole("heading", { name: "Connection verified" }).waitFor(); await expect - .poll(async () => page.locator(".model-setup__success").textContent()) + .poll(async () => page.locator(".model-setup-success").textContent()) .toContain("provider/verified-model"); } finally { await context.close(); @@ -245,7 +248,9 @@ describeControlUiE2e("Control UI Model Setup mocked Gateway E2E", () => { "chat.metadata", "chat.startup", "openclaw.setup.detect", + "openclaw.setup.activate", "openclaw.setup.auth.start", + "openclaw.setup.prepare.start", ], methodResponses: { "openclaw.setup.detect": { @@ -264,13 +269,29 @@ describeControlUiE2e("Control UI Model Setup mocked Gateway E2E", () => { ], manualProviders: [ { - id: "openai-api-key", - brandId: "openai", - label: "OpenAI API key", + id: "qwen-cn", + brandId: "qwen", + groupLabel: "Qwen Cloud", + label: "Coding Plan API Key for China (subscription)", + hint: "Endpoint: coding.dashscope.aliyuncs.com", + }, + { + id: "qwen-global", + brandId: "qwen", + groupLabel: "Qwen Cloud", + label: "Coding Plan API Key for Global/Intl (subscription)", + hint: "Endpoint: coding-intl.dashscope.aliyuncs.com", + }, + { + id: "zai-cn", + brandId: "zai", + groupLabel: "Z.AI", + label: "Coding-Plan-CN", }, { id: "gemini-api-key", brandId: "google", + groupLabel: "Google", label: "Google Gemini API key", hint: "Use an AI Studio API key.", }, @@ -288,6 +309,12 @@ describeControlUiE2e("Control UI Model Setup mocked Gateway E2E", () => { workspace: "/tmp/openclaw-e2e", setupComplete: false, }, + "openclaw.setup.activate": { + ok: true, + modelRef: "qwen/qwen3-coder-plus", + latencyMs: 412, + lines: ["Model ready"], + }, "openclaw.setup.auth.start": { sessionId: "gemini-oauth-session", done: false, @@ -303,36 +330,196 @@ describeControlUiE2e("Control UI Model Setup mocked Gateway E2E", () => { await page.getByRole("button", { name: "Sign in with Google" }).waitFor(); await page.getByRole("button", { name: "Use API key" }).waitFor(); + const providerPicker = page.locator(".model-setup-provider-select"); + const providerTrigger = providerPicker.locator(".model-setup-provider-select__trigger"); + const manualProviderIsActive = (providerId: string) => + page + .locator(`[data-manual-provider="${providerId}"]`) + .evaluate((element) => Reflect.get(element, "active") === true); + const manualProviderMenuReady = () => + page + .locator("[data-manual-provider]") + .evaluateAll((options) => + options.some((option) => Reflect.get(option, "active") === true), + ); + const waitForProviderHide = () => + providerPicker.evaluate( + (element) => + new Promise((resolve) => { + element.addEventListener("wa-after-hide", () => resolve(), { once: true }); + }), + ); + const providerIds = await page + .locator("[data-manual-provider]") + .evaluateAll((options) => + options + .map((option) => (option instanceof HTMLElement ? option.dataset.manualProvider : null)) + .filter((value): value is string => Boolean(value)), + ); + const firstProviderId = providerIds[0]!; + const lastProviderId = providerIds.at(-1)!; + await providerTrigger.click(); + await expect + .poll(() => providerPicker.evaluate((element) => element.hasAttribute("open"))) + .toBe(true); + await expect + .poll(() => page.locator('[data-manual-provider="qwen-cn"]').getAttribute("aria-label")) + .toContain("Qwen Cloud"); + await expect + .poll(() => page.locator('[data-manual-provider="zai-cn"]').getAttribute("aria-label")) + .toContain("Z.AI"); + await expect + .poll(() => + page.locator('[data-manual-provider="qwen-cn"] [data-provider-icon="alibaba"]').count(), + ) + .toBe(1); + if (artifactDir) { await mkdir(artifactDir, { recursive: true }); await page.screenshot({ animations: "disabled", fullPage: true, - path: path.join(artifactDir, "after-desktop.png"), + path: path.join(artifactDir, "provider-picker-desktop.png"), }); await page.setViewportSize({ height: 844, width: 390 }); + await providerPicker.scrollIntoViewIfNeeded(); await page.screenshot({ animations: "disabled", - fullPage: true, - path: path.join(artifactDir, "after-mobile.png"), + path: path.join(artifactDir, "provider-picker-mobile.png"), }); await page.setViewportSize({ height: 1000, width: 1440 }); } + const providerHidden = waitForProviderHide(); + await page.keyboard.press("Escape"); + await providerHidden; + await expect + .poll(() => providerPicker.evaluate((element) => element.hasAttribute("open"))) + .toBe(false); const accessValue = page.locator('.model-setup__manual input[type="password"]'); + await expect + .poll(() => providerTrigger.evaluate((element) => element === document.activeElement)) + .toBe(true); + await page.keyboard.press("Enter"); + await expect + .poll(() => providerPicker.evaluate((element) => element.hasAttribute("open"))) + .toBe(true); + await expect.poll(manualProviderMenuReady).toBe(true); + await page.keyboard.press("Home"); + await expect.poll(() => manualProviderIsActive(firstProviderId)).toBe(true); + await page.keyboard.press("End"); + await expect.poll(() => manualProviderIsActive(lastProviderId)).toBe(true); + await page.keyboard.press("Home"); + await expect.poll(() => manualProviderIsActive(firstProviderId)).toBe(true); + await page.keyboard.press("z"); + await expect.poll(() => manualProviderIsActive("zai-cn")).toBe(true); + const zaiProviderHidden = waitForProviderHide(); + await page.keyboard.press("Enter"); + await zaiProviderHidden; + await expect.poll(() => providerTrigger.textContent()).toContain("Z.AI"); + await expect + .poll(() => providerTrigger.evaluate((element) => element === document.activeElement)) + .toBe(true); + + await accessValue.fill("same-provider-secret"); + await providerTrigger.click(); + await expect.poll(manualProviderMenuReady).toBe(true); + const sameProviderHidden = waitForProviderHide(); + await page.locator('[data-manual-provider="zai-cn"]').click(); + await sameProviderHidden; + await expect.poll(() => accessValue.inputValue()).toBe("same-provider-secret"); + await expect + .poll(() => providerTrigger.evaluate((element) => element === document.activeElement)) + .toBe(true); + + await providerTrigger.click(); + await expect.poll(manualProviderMenuReady).toBe(true); + const providerHiddenBackward = waitForProviderHide(); + await page.keyboard.press("Shift+Tab"); + await providerHiddenBackward; + await expect + .poll(() => providerTrigger.evaluate((element) => element === document.activeElement)) + .toBe(true); + + await providerTrigger.click(); + await expect.poll(manualProviderMenuReady).toBe(true); + await page.keyboard.press("Tab"); + await expect + .poll(() => providerPicker.evaluate((element) => element.hasAttribute("open"))) + .toBe(false); + await expect + .poll(() => accessValue.evaluate((element) => element === document.activeElement)) + .toBe(true); + await accessValue.fill("sk-old-provider-secret"); await page.getByRole("button", { name: "Use API key" }).click(); - const provider = page.locator(".model-setup__manual select"); - await expect.poll(() => provider.inputValue()).toBe("gemini-api-key"); + await expect.poll(() => providerTrigger.textContent()).toContain("Google"); await expect.poll(() => accessValue.inputValue()).toBe(""); await expect .poll(() => accessValue.evaluate((element) => element === document.activeElement)) .toBe(true); - await accessValue.fill("gemini-secret"); - await provider.selectOption("openai-api-key"); - await expect.poll(() => accessValue.inputValue()).toBe(""); + await providerTrigger.click(); + const qwenProviderHidden = waitForProviderHide(); + await page.locator('[data-manual-provider="qwen-cn"]').click(); + await qwenProviderHidden; + await expect + .poll(() => providerPicker.evaluate((element) => element.hasAttribute("open"))) + .toBe(false); + await expect.poll(() => providerTrigger.textContent()).toContain("Qwen Cloud"); + await accessValue.fill("qwen-test-secret"); + await expect.poll(() => accessValue.inputValue()).toBe("qwen-test-secret"); + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); + await page.getByRole("button", { name: "Connect & verify" }).click(); + const activate = await gateway.waitForRequest("openclaw.setup.activate"); + expect(activate.params).toEqual({ + kind: "api-key", + authChoice: "qwen-cn", + apiKey: "qwen-test-secret", + }); + await page.getByRole("heading", { name: "Connection verified" }).waitFor(); + await page.getByText("qwen/qwen3-coder-plus", { exact: true }).waitFor(); + + if (artifactDir) { + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: path.join(artifactDir, "success-desktop.png"), + }); + await page.setViewportSize({ height: 844, width: 390 }); + await expect + .poll(() => + page.locator("openclaw-modal-dialog.nav-drawer").evaluate((element) => { + const dialog = element.shadowRoot + ?.querySelector("wa-dialog") + ?.shadowRoot?.querySelector("dialog"); + return dialog?.open ?? false; + }), + ) + .toBe(false); + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: path.join(artifactDir, "success-mobile.png"), + }); + await page.setViewportSize({ height: 1000, width: 1440 }); + } + await expect + .poll(() => page.locator(".model-setup-success").textContent()) + .toContain("Verified in 412 ms"); + + const detectCountBeforeDismiss = (await gateway.getRequests("openclaw.setup.detect")).length; + await page.getByRole("button", { name: "Stay in settings" }).click(); + await expect + .poll(async () => (await gateway.getRequests("openclaw.setup.detect")).length) + .toBe(detectCountBeforeDismiss + 1); await page.getByRole("button", { name: "Use API key" }).click(); + await expect.poll(() => providerTrigger.textContent()).toContain("Google"); const detectCount = (await gateway.getRequests("openclaw.setup.detect")).length; await page @@ -354,6 +541,9 @@ describeControlUiE2e("Control UI Model Setup mocked Gateway E2E", () => { it("verifies the current model connection", async () => { const context = await browser.newContext({ locale: "en-US", + ...(artifactDir + ? { recordVideo: { dir: artifactDir, size: { height: 900, width: 1280 } } } + : {}), serviceWorkers: "block", viewport: { height: 900, width: 1280 }, }); @@ -367,7 +557,26 @@ describeControlUiE2e("Control UI Model Setup mocked Gateway E2E", () => { ], methodResponses: { "openclaw.setup.detect": { - candidates: [], + candidates: [ + { + kind: "existing-model", + brandId: "openai", + label: "Current model", + detail: "openai/gpt-5 — already configured", + modelRef: "openai/gpt-5", + recommended: false, + credentials: true, + }, + { + kind: "claude-cli", + brandId: "claude", + label: "Claude Code", + detail: "logged in", + modelRef: "claude-cli/claude-opus-5", + recommended: false, + credentials: true, + }, + ], manualProviders: [], workspace: "/tmp/openclaw-e2e", configuredModel: "openai/gpt-5", @@ -384,6 +593,35 @@ describeControlUiE2e("Control UI Model Setup mocked Gateway E2E", () => { try { const response = await page.goto(`${server.baseUrl}settings/model-setup`); expect(response?.status()).toBe(200); + await expect + .poll(() => page.locator('[data-candidate-kind="existing-model"]').count()) + .toBe(0); + await expect.poll(() => page.locator('[data-candidate-kind="claude-cli"]').count()).toBe(1); + if (artifactDir) { + await mkdir(artifactDir, { recursive: true }); + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: path.join(artifactDir, "configured-route-dedup-desktop.png"), + }); + await page.setViewportSize({ height: 844, width: 390 }); + await expect + .poll(() => + page.locator("openclaw-modal-dialog.nav-drawer").evaluate((element) => { + const dialog = element.shadowRoot + ?.querySelector("wa-dialog") + ?.shadowRoot?.querySelector("dialog"); + return dialog?.open ?? false; + }), + ) + .toBe(false); + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: path.join(artifactDir, "configured-route-dedup-mobile.png"), + }); + await page.setViewportSize({ height: 900, width: 1280 }); + } await page.getByRole("button", { name: "Verify connection" }).click(); const verify = await gateway.waitForRequest("openclaw.setup.verify"); expect(verify.params).toEqual({}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index bae4757ee8fb..11bad9bd2bd5 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -2014,27 +2014,37 @@ export const en: TranslationMap = { }, prepare: { title: "Set up a local model", - intro: "Download or prepare a local model on this Gateway.", + intro: + "OpenClaw checks the local service, confirms tool support, and helps prepare a compatible model.", button: "Set up / Download model", + ollamaButton: "Check & set up", ollamaLabel: "Ollama", - ollamaHint: "Download a tools-capable model from your Ollama server", + ollamaHint: "Connect to the Ollama service on this Gateway and prepare a tools-capable model", llamaCppLabel: "Local model (llama.cpp)", llamaCppHint: "Download an approximately 5.0 GB local model; requires 16 GB RAM", }, manual: { title: "Connect with an API key or token", - provider: "Provider", + provider: "Provider and access method", selectProvider: "Select a provider", + selectProviderHint: "Choose where this credential comes from", accessValue: "API key or token", + accessValueFor: "{provider} API key or token", accessValuePlaceholder: "Paste an API key or token", connect: "Connect", + connectAndVerify: "Connect & verify", + verifyHint: "OpenClaw verifies a real model reply before marking the connection ready.", required: "Choose a provider and enter an API key or token.", }, success: { - title: "Your AI is ready", + title: "Connection verified", + body: "OpenClaw received a real reply from {modelRef}. You can start chatting now.", + activeModel: "Active model", + latency: "Verified in {latencyMs} ms", detail: "{modelRef} · {latencyMs} ms", - openChat: "Open Chat", + openChat: "Start chatting", continueSetup: "Continue setup", + stayHere: "Stay in settings", configuredModel: "Configured model", }, failure: { diff --git a/ui/src/pages/model-setup/model-setup-page.test.ts b/ui/src/pages/model-setup/model-setup-page.test.ts index 18bc0c4902ac..55a5308c3a5a 100644 --- a/ui/src/pages/model-setup/model-setup-page.test.ts +++ b/ui/src/pages/model-setup/model-setup-page.test.ts @@ -436,7 +436,7 @@ describe("ModelSetupPage catalog icons", () => { page.querySelector('[data-candidate-kind="codex-cli"] button')?.click(); await vi.waitFor(() => { - expect(page.textContent).toContain("Your AI is ready"); + expect(page.textContent).toContain("Connection verified"); expect(page.textContent).toContain("config.get failed after model commit"); }); }); diff --git a/ui/src/pages/model-setup/model-setup-page.ts b/ui/src/pages/model-setup/model-setup-page.ts index e5220faa6a0b..ea36eaa1b995 100644 --- a/ui/src/pages/model-setup/model-setup-page.ts +++ b/ui/src/pages/model-setup/model-setup-page.ts @@ -597,6 +597,10 @@ export class ModelSetupPage extends OpenClawLightDomElement { } this.context.navigate("chat"); }, + onSuccessClose: () => { + this.activationState = { phase: "idle" }; + void this.detect(); + }, onWizardValueChange: (value) => (this.wizardValue = value), onWizardAnswer: (value, includeValue) => void this.wizard.answer(value, includeValue), onWizardCancel: () => void this.wizard.cancel(), diff --git a/ui/src/pages/model-setup/provider-picker.ts b/ui/src/pages/model-setup/provider-picker.ts new file mode 100644 index 000000000000..0aa78c3c57a5 --- /dev/null +++ b/ui/src/pages/model-setup/provider-picker.ts @@ -0,0 +1,79 @@ +export type WebAwesomeSelectEvent = CustomEvent<{ + item: HTMLElement & { checked?: boolean; value?: string }; +}>; + +export function focusSelectedManualProvider(event: Event): void { + const dropdown = event.currentTarget as HTMLElement; + const options = Array.from( + dropdown.querySelectorAll( + "wa-dropdown-item[data-manual-provider]:not([disabled])", + ), + ); + const selected = options.find((option) => option.hasAttribute("data-selected")) ?? options[0]; + if (!selected) { + return; + } + for (const option of options) { + option.active = option === selected; + } + selected.focus({ preventScroll: true }); + selected.scrollIntoView?.({ block: "nearest" }); +} + +export function handleManualProviderKeydown(event: KeyboardEvent): void { + const dropdown = event.currentTarget as HTMLElement & { open: boolean }; + if (!dropdown.open) { + return; + } + if (event.key === "Tab") { + event.preventDefault(); + event.stopPropagation(); + const focusTarget = event.shiftKey + ? dropdown.querySelector('[slot="trigger"]') + : dropdown + .closest(".model-setup__manual") + ?.querySelector('input[type="password"]'); + dropdown.addEventListener("wa-after-hide", () => focusTarget?.focus({ preventScroll: true }), { + once: true, + }); + dropdown.open = false; + return; + } + if (event.key !== "Escape") { + return; + } + // The settings-level Escape shortcut runs before Web Awesome's document + // listener. Claim the event here and restore the durable trigger after hide. + event.preventDefault(); + dropdown.addEventListener( + "wa-after-hide", + () => dropdown.querySelector('[slot="trigger"]')?.focus({ preventScroll: true }), + { once: true }, + ); +} + +export function handleManualProviderSelect( + event: WebAwesomeSelectEvent, + currentProviderId: string, + onChange: (providerId: string) => void, +): void { + const item = event.detail.item; + const dropdown = event.currentTarget as HTMLElement & { open: boolean }; + const value = item.value ?? item.getAttribute("value"); + if (!value) { + return; + } + if (value !== currentProviderId) { + dropdown.addEventListener( + "wa-after-hide", + () => dropdown.querySelector('[slot="trigger"]')?.focus({ preventScroll: true }), + { once: true }, + ); + onChange(value); + return; + } + event.preventDefault(); + item.checked = true; + dropdown.querySelector('[slot="trigger"]')?.focus({ preventScroll: true }); + dropdown.open = false; +} diff --git a/ui/src/pages/model-setup/success-dialog.ts b/ui/src/pages/model-setup/success-dialog.ts new file mode 100644 index 000000000000..baf2ea5992fb --- /dev/null +++ b/ui/src/pages/model-setup/success-dialog.ts @@ -0,0 +1,51 @@ +import { html, nothing } from "lit"; +import { icons } from "../../components/icons.ts"; +import "../../components/modal-dialog.ts"; +import { t } from "../../i18n/index.ts"; +import type { ModelSetupActivationState } from "./state.ts"; + +export function renderModelSetupSuccessDialog( + activation: Extract, + onOpenChat: () => void, + onClose: () => void, + firstRun: boolean, +) { + return html` + +
+ +
+

${t("modelSetup.success.title")}

+

${t("modelSetup.success.body", { modelRef: activation.modelRef })}

+
+ ${activation.warning + ? html`
${activation.warning}
` + : nothing} +
+ ${t("modelSetup.success.activeModel")} + ${activation.modelRef} + ${activation.latencyMs === undefined + ? nothing + : html` + ${t("modelSetup.success.latency", { + latencyMs: String(activation.latencyMs), + })} + `} +
+
+ + +
+
+
+ `; +} diff --git a/ui/src/pages/model-setup/view.test.ts b/ui/src/pages/model-setup/view.test.ts index f267033415a9..1e242e73c603 100644 --- a/ui/src/pages/model-setup/view.test.ts +++ b/ui/src/pages/model-setup/view.test.ts @@ -36,12 +36,14 @@ const detected: SystemAgentSetupDetectResult = { { id: "gemini-api-key", brandId: "google", + groupLabel: "Google", label: "Google Gemini API key", hint: "Use an AI Studio API key.", }, { id: "openai", brandId: "openai", + groupLabel: "OpenAI", label: "OpenAI", hint: "Use a project API key.", icon: "https://cdn.example.com/openai.png", @@ -122,6 +124,7 @@ function props(overrides: Partial = {}): ModelSetupViewProp onMoreSignInToggle: vi.fn(), onIconError: vi.fn(), onOpenChat: vi.fn(), + onSuccessClose: vi.fn(), onWizardValueChange: vi.fn(), onWizardAnswer: vi.fn(), onWizardCancel: vi.fn(), @@ -181,8 +184,11 @@ describe("renderModelSetup", () => { expect(text(container)).toContain("Sign in with a provider"); expect(text(container)).toContain("Set up a local model"); expect(text(container)).toContain("Connect with an API key or token"); - expect(container.querySelector(".model-setup__manual select")?.value).toBe( - "openai", + expect( + container.querySelector('[data-manual-provider="openai"][data-selected]'), + ).not.toBeNull(); + expect(text(container.querySelector(".model-setup-provider-select__trigger")!)).toContain( + "OpenAI", ); expect(container.querySelector('input[type="password"]')).not.toBeNull(); expect(container.querySelector("details")?.open).toBe(false); @@ -205,6 +211,257 @@ describe("renderModelSetup", () => { expect(container.querySelectorAll("img")).toHaveLength(0); }); + it("identifies provider families separately from their credential methods", () => { + const container = mount( + props({ + manualProviderId: "qwen-cn", + page: { + phase: "ready", + result: { + ...detected, + manualProviders: [ + { + id: "qwen-cn", + brandId: "qwen", + groupLabel: "Qwen Cloud", + label: "Coding Plan API Key for China (subscription)", + hint: "Endpoint: coding.dashscope.aliyuncs.com", + }, + { + id: "zai-cn", + brandId: "zai", + groupLabel: "Z.AI", + label: "Coding-Plan-CN", + }, + ], + }, + }, + }), + ); + + expect(text(container.querySelector(".model-setup-provider-select__trigger")!)).toContain( + "Qwen Cloud Coding Plan API Key for China (subscription)", + ); + expect( + container.querySelector('[data-manual-provider="qwen-cn"] [data-provider-icon="alibaba"]'), + ).not.toBeNull(); + expect(text(container.querySelector('[data-manual-provider="zai-cn"]')!)).toContain( + "Z.AI Coding-Plan-CN", + ); + }); + + it("renders the provider picker with the shared Web Awesome primitive", () => { + const container = mount(props()); + const picker = container.querySelector(".model-setup-provider-select"); + const options = Array.from( + container.querySelectorAll( + "wa-dropdown-item[data-manual-provider]", + ), + ); + + expect(picker?.localName).toBe("wa-dropdown"); + expect(options.map((option) => option.value).toSorted()).toEqual(["gemini-api-key", "openai"]); + expect(options.find((option) => option.value === "openai")?.checked).toBe(true); + expect(options.find((option) => option.value === "gemini-api-key")?.checked).toBe(false); + }); + + it("claims Escape before the app shortcut and restores the provider trigger", () => { + const container = mount(props()); + const picker = container.querySelector( + ".model-setup-provider-select", + )!; + const trigger = picker.querySelector('[slot="trigger"]')!; + const appShortcut = vi.fn(); + const handleAppShortcut = (event: KeyboardEvent) => { + if (!event.defaultPrevented) { + appShortcut(); + } + }; + document.addEventListener("keydown", handleAppShortcut); + picker.open = true; + + const event = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Escape", + }); + picker.dispatchEvent(event); + picker.dispatchEvent(new CustomEvent("wa-after-hide")); + document.removeEventListener("keydown", handleAppShortcut); + + expect(picker.open).toBe(true); + expect(event.defaultPrevented).toBe(true); + expect(appShortcut).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(trigger); + }); + + it("moves focus to the credential field after Tab dismisses the picker", () => { + const container = mount(props()); + const picker = container.querySelector( + ".model-setup-provider-select", + )!; + const option = picker.querySelector("[data-manual-provider]")!; + const accessValue = container.querySelector('input[type="password"]')!; + picker.open = true; + option.focus(); + + const event = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Tab", + }); + option.dispatchEvent(event); + picker.dispatchEvent(new CustomEvent("wa-after-hide")); + + expect(event.defaultPrevented).toBe(true); + expect(picker.open).toBe(false); + expect(document.activeElement).toBe(accessValue); + }); + + it("returns focus to the provider trigger after Shift+Tab dismisses the picker", () => { + const container = mount(props()); + const picker = container.querySelector( + ".model-setup-provider-select", + )!; + const trigger = picker.querySelector('[slot="trigger"]')!; + const option = picker.querySelector("[data-manual-provider]")!; + picker.open = true; + option.focus(); + + option.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Tab", + shiftKey: true, + }), + ); + picker.dispatchEvent(new CustomEvent("wa-after-hide")); + + expect(picker.open).toBe(false); + expect(document.activeElement).toBe(trigger); + }); + + it("keeps the credential when the selected provider is chosen again", () => { + const onManualProviderChange = vi.fn(); + const container = mount(props({ onManualProviderChange })); + const picker = container.querySelector( + ".model-setup-provider-select", + )!; + const trigger = picker.querySelector('[slot="trigger"]')!; + const option = picker.querySelector( + '[data-manual-provider="openai"]', + )!; + picker.open = true; + trigger.focus(); + picker.dispatchEvent( + new CustomEvent("wa-select", { + bubbles: true, + cancelable: true, + detail: { item: option }, + }), + ); + + expect(onManualProviderChange).not.toHaveBeenCalled(); + expect(option.checked).toBe(true); + expect(picker.open).toBe(false); + expect(document.activeElement).toBe(trigger); + }); + + it("focuses the selected provider when the shared dropdown opens", () => { + const container = mount( + props({ + manualProviderId: "gemini-api-key", + page: { + phase: "ready", + result: { + ...detected, + manualProviders: [ + ...detected.manualProviders, + { id: "zai", groupLabel: "Z.AI", label: "API key" }, + ], + }, + }, + }), + ); + const picker = container.querySelector(".model-setup-provider-select")!; + picker.dispatchEvent(new CustomEvent("wa-after-show")); + + const options = Array.from( + picker.querySelectorAll("[data-manual-provider]"), + ); + expect( + options.find((option) => option.dataset.manualProvider === "gemini-api-key")?.active, + ).toBe(true); + expect(options.find((option) => option.dataset.manualProvider === "openai")?.active).toBe( + false, + ); + }); + + it("changes provider from the shared dropdown selection event", () => { + const onManualProviderChange = vi.fn(); + const container = mount(props({ onManualProviderChange })); + const picker = container.querySelector(".model-setup-provider-select")!; + const trigger = picker.querySelector('[slot="trigger"]')!; + const option = picker.querySelector('[data-manual-provider="gemini-api-key"]')!; + + picker.dispatchEvent( + new CustomEvent("wa-select", { + bubbles: true, + cancelable: true, + detail: { item: option }, + }), + ); + picker.dispatchEvent(new CustomEvent("wa-after-hide")); + + expect(onManualProviderChange).toHaveBeenCalledWith("gemini-api-key"); + expect(document.activeElement).toBe(trigger); + }); + + it("does not repeat the method when an older gateway omits the provider group", () => { + const container = mount( + props({ + manualProviderId: "legacy", + page: { + phase: "ready", + result: { + ...detected, + manualProviders: [{ id: "legacy", label: "Legacy provider" }], + }, + }, + }), + ); + const trigger = container.querySelector(".model-setup-provider-select__trigger")!; + const option = container.querySelector('[data-manual-provider="legacy"]')!; + + expect(trigger.querySelector("strong")?.textContent?.trim()).toBe("Legacy provider"); + expect(trigger.querySelector(".model-setup-provider-select__copy > span")).toBeNull(); + expect(option.getAttribute("aria-label")).toBe("Legacy provider"); + }); + + it("shows verified connections in an actionable success dialog", () => { + const onOpenChat = vi.fn(); + const onSuccessClose = vi.fn(); + const container = mount( + props({ + activation: { phase: "success", modelRef: "openai/gpt-5.6-sol", latencyMs: 73 }, + onOpenChat, + onSuccessClose, + }), + ); + + const dialog = container.querySelector('openclaw-modal-dialog[label="Connection verified"]'); + expect(dialog).not.toBeNull(); + expect(text(dialog!)).toContain( + "OpenClaw received a real reply from openai/gpt-5.6-sol. You can start chatting now.", + ); + expect(text(dialog!)).toContain("Verified in 73 ms"); + dialog?.querySelector(".primary")?.click(); + expect(onOpenChat).toHaveBeenCalledOnce(); + dialog?.querySelectorAll("button").item(0).click(); + expect(onSuccessClose).toHaveBeenCalledOnce(); + }); + it("offers direct recovery actions for an unavailable provider", () => { const onStartAuth = vi.fn(); const onUseManualProvider = vi.fn(); @@ -238,7 +495,7 @@ describe("renderModelSetup", () => { const llamaCpp = container.querySelector( '[data-prepare-choice="llama-cpp"] button', ); - expect(ollama?.textContent).toContain("Set up / Download model"); + expect(ollama?.textContent).toContain("Check & set up"); expect(llamaCpp).not.toBeNull(); ollama?.click(); expect(onStartPrepare).toHaveBeenCalledWith(expect.objectContaining({ id: "ollama" })); @@ -483,7 +740,7 @@ describe("renderModelSetup", () => { expect(old.querySelector(".settings-section")).toBeNull(); }); - it("renders the success banner and opens chat", () => { + it("keeps setup context behind the success dialog and opens chat", () => { const onOpenChat = vi.fn(); const container = mount( props({ @@ -491,11 +748,11 @@ describe("renderModelSetup", () => { onOpenChat, }), ); - expect(text(container)).toContain("Your AI is ready"); - expect(text(container)).toContain("openai/gpt-5 · 91 ms"); - container.querySelector(".model-setup__success button")?.click(); + expect(text(container)).toContain("Connection verified"); + expect(text(container)).toContain("Verified in 91 ms"); + container.querySelector(".model-setup-success .primary")?.click(); expect(onOpenChat).toHaveBeenCalledOnce(); - expect(container.querySelector(".settings-section")).toBeNull(); + expect(container.querySelector(".settings-section")).not.toBeNull(); }); it("continues first-run setup after the model is ready", () => { @@ -524,6 +781,45 @@ describe("renderModelSetup", () => { expect(onVerify).toHaveBeenCalledOnce(); }); + it("does not repeat the current route among detected candidates", () => { + const container = mount( + props({ + page: { + phase: "ready", + result: { + ...detected, + configuredModel: "openai/gpt-5.6-sol", + setupComplete: true, + candidates: [ + { + kind: "existing-model", + brandId: "openai", + label: "Current model", + detail: "openai/gpt-5.6-sol — already configured", + modelRef: "openai/gpt-5.6-sol", + recommended: false, + credentials: true, + }, + { + kind: "claude-cli", + brandId: "claude", + label: "Claude Code", + detail: "logged in", + modelRef: "claude-cli/claude-opus-5", + recommended: false, + credentials: true, + }, + ], + }, + }, + }), + ); + + expect(container.querySelector('[data-candidate-kind="existing-model"]')).toBeNull(); + expect(container.querySelector('[data-candidate-kind="claude-cli"]')).not.toBeNull(); + expect(text(container)).toContain("Current connection openai/gpt-5.6-sol"); + }); + it("renders connection verification progress", () => { const container = mount( props({ diff --git a/ui/src/pages/model-setup/view.ts b/ui/src/pages/model-setup/view.ts index c12aad4b316e..ff881303e9b7 100644 --- a/ui/src/pages/model-setup/view.ts +++ b/ui/src/pages/model-setup/view.ts @@ -1,13 +1,22 @@ import { html, nothing, type TemplateResult } from "lit"; +import { ref } from "lit/directives/ref.js"; import type { SystemAgentSetupDetectResult } from "../../api/types.ts"; +import { icons } from "../../components/icons.ts"; import { hasProviderBrandIcon, renderProviderBrandIcon, renderProviderFallbackIcon, } from "../../components/provider-icon.ts"; +import { syncDropdownItemRadio } from "../../components/web-awesome.ts"; import { t } from "../../i18n/index.ts"; import "../../styles/model-setup.css"; import { listModelSetupPrepareOptions, type ModelSetupPrepareOption } from "./prepare-options.ts"; +import { + focusSelectedManualProvider, + handleManualProviderKeydown, + handleManualProviderSelect, + type WebAwesomeSelectEvent, +} from "./provider-picker.ts"; import type { ModelSetupActivationState, ModelSetupPageState, @@ -15,10 +24,12 @@ import type { ModelSetupWizardState, } from "./state.ts"; import { activationTargetId } from "./state.ts"; +import { renderModelSetupSuccessDialog } from "./success-dialog.ts"; import { renderModelSetupWizard } from "./wizard-view.ts"; type Candidate = SystemAgentSetupDetectResult["candidates"][number]; type AuthOption = NonNullable[number]; +type ManualProvider = SystemAgentSetupDetectResult["manualProviders"][number]; type SetupIconEntry = { brandId?: string; label: string; @@ -88,6 +99,7 @@ type ModelSetupViewProps = { onMoreSignInToggle: (open: boolean) => void; onIconError: (iconUrl: string) => void; onOpenChat: () => void; + onSuccessClose: () => void; onWizardValueChange: (value: unknown) => void; onWizardAnswer: (value: unknown, includeValue?: boolean) => void; onWizardCancel: () => void; @@ -120,36 +132,13 @@ function failureLabel(status: string): string { return labels[status] ?? labels.unknown!; } -function renderSuccess( - activation: Extract, - onOpenChat: () => void, - firstRun: boolean, -) { - return html` -
-
- ${t("modelSetup.success.title")} -
- ${activation.latencyMs === undefined - ? activation.modelRef - : t("modelSetup.success.detail", { - modelRef: activation.modelRef, - latencyMs: String(activation.latencyMs), - })} -
- ${activation.warning - ? html`
${activation.warning}
` - : nothing} -
- -
- `; -} - function renderCandidateRows(props: ModelSetupViewProps, result: SystemAgentSetupDetectResult) { - if (result.candidates.length === 0) { + // The current connection already owns verification and status. Keep the + // existing-model candidate only for older Gateways that lack that surface. + const candidates = result.configuredModel + ? result.candidates.filter((candidate) => candidate.kind !== "existing-model") + : result.candidates; + if (candidates.length === 0) { return nothing; } return html` @@ -158,7 +147,7 @@ function renderCandidateRows(props: ModelSetupViewProps, result: SystemAgentSetu

${t("modelSetup.candidates.title")}

- ${result.candidates.map((candidate) => { + ${candidates.map((candidate) => { const testing = props.activation.phase === "testing" && props.activation.targetId === activationTargetId(candidate.kind, candidate.modelRef); @@ -440,7 +429,9 @@ function renderPrepare(props: ModelSetupViewProps, result: SystemAgentSetupDetec ?disabled=${props.actionsDisabled} @click=${() => props.onStartPrepare(option)} > - ${t("modelSetup.prepare.button")} + ${option.id === "ollama" + ? t("modelSetup.prepare.ollamaButton") + : t("modelSetup.prepare.button")}
`, @@ -450,6 +441,93 @@ function renderPrepare(props: ModelSetupViewProps, result: SystemAgentSetupDetec `; } +function manualProviderName(provider: ManualProvider): string { + return provider.groupLabel?.trim() || provider.label; +} + +function manualProviderMethod(provider: ManualProvider): string | undefined { + const method = provider.label.trim(); + return method === manualProviderName(provider) ? undefined : method; +} + +function renderManualProviderPicker( + props: ModelSetupViewProps, + result: SystemAgentSetupDetectResult, + provider: ManualProvider | undefined, +) { + const providerMethod = provider ? manualProviderMethod(provider) : undefined; + const triggerLabel = provider + ? [manualProviderName(provider), providerMethod].filter(Boolean).join(", ") + : t("modelSetup.manual.selectProvider"); + return html` + + handleManualProviderSelect(event, props.manualProviderId, props.onManualProviderChange)} + @wa-after-show=${focusSelectedManualProvider} + @keydown=${handleManualProviderKeydown} + > + + ${result.manualProviders.map((entry) => { + const selected = entry.id === props.manualProviderId; + const entryMethod = manualProviderMethod(entry); + const accessibleLabel = [manualProviderName(entry), entryMethod, entry.hint] + .filter(Boolean) + .join(", "); + return html` + syncDropdownItemRadio(element, selected))} + > + + ${renderProviderIcon(props, entry, "model-setup__icon--picker")} + + + ${manualProviderName(entry)} + ${entryMethod ? html`${entryMethod}` : nothing} + ${entry.hint ? html`${entry.hint}` : nothing} + + + `; + })} + + `; +} + function renderManual(props: ModelSetupViewProps, result: SystemAgentSetupDetectResult) { const provider = result.manualProviders.find((entry) => entry.id === props.manualProviderId); const targetId = `manual:${props.manualProviderId}`; @@ -464,31 +542,16 @@ function renderManual(props: ModelSetupViewProps, result: SystemAgentSetupDetect

${t("modelSetup.manual.title")}

-
@@ -527,9 +596,6 @@ function renderManual(props: ModelSetupViewProps, result: SystemAgentSetupDetect } function renderReady(props: ModelSetupViewProps, result: SystemAgentSetupDetectResult) { - if (props.activation.phase === "success") { - return renderSuccess(props.activation, props.onOpenChat, props.firstRun); - } const current = result.configuredModel ? renderCurrentConnection(props, result.configuredModel) : nothing; @@ -600,5 +666,13 @@ export function renderModelSetup(props: ModelSetupViewProps): TemplateResult { onCancel: props.onWizardCancel, onClose: props.onWizardClose, })} + ${props.activation.phase === "success" + ? renderModelSetupSuccessDialog( + props.activation, + props.onOpenChat, + props.onSuccessClose, + props.firstRun, + ) + : nothing} `; } diff --git a/ui/src/styles/model-setup.css b/ui/src/styles/model-setup.css index 3c592c23d442..b6fabdaa7012 100644 --- a/ui/src/styles/model-setup.css +++ b/ui/src/styles/model-setup.css @@ -62,8 +62,7 @@ font-size: 11px; } -.model-setup__provider-copy, -.model-setup__manual-provider { +.model-setup__provider-copy { display: flex; align-items: center; gap: 10px; @@ -78,11 +77,6 @@ gap: 8px; } -.model-setup__manual-provider select { - flex: 1; - min-width: 0; -} - .model-setup__empty > p { margin: 0 0 12px; } @@ -163,8 +157,8 @@ .model-setup__manual { display: grid; - gap: 10px; - max-width: 620px; + gap: 14px; + max-width: 720px; } .model-setup__manual .field { @@ -172,31 +166,213 @@ gap: 6px; } -.model-setup__manual select, .model-setup__manual input { width: 100%; } +.model-setup-provider-select { + display: block; + width: 100%; +} + +.model-setup-provider-select::part(menu) { + width: min(620px, calc(100vw - 32px)); + max-height: min(420px, 58dvh); + overflow-y: auto; + padding: 6px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-lg); + background: var(--bg-elevated); + box-shadow: var(--shadow-lg); + color: var(--text); +} + +.model-setup-provider-select__trigger { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-height: 64px; + padding: 10px 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-accent); + color: var(--text); + font: inherit; + outline: none; + text-align: left; + cursor: pointer; +} + +:root[data-theme-mode="light"] .model-setup-provider-select__trigger { + background: white; +} + +.model-setup-provider-select__trigger:focus-visible { + border-color: var(--accent); + box-shadow: var(--focus-ring); +} + +.model-setup-provider-select__trigger:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.model-setup-provider-select__copy { + display: grid; + flex: 1; + min-width: 0; + gap: 2px; +} + +.model-setup-provider-select__copy strong, +.model-setup-provider-select__copy span, +.model-setup-provider-select__copy small { + overflow-wrap: anywhere; +} + +.model-setup-provider-select__copy > span { + color: var(--muted); + font-size: 12px; +} + +.model-setup-provider-select__copy > small { + color: var(--muted); + font-size: 11px; + line-height: 1.35; +} + +.model-setup-provider-select__chevron, +.model-setup-provider-select__placeholder-icon { + display: inline-flex; + flex: 0 0 auto; + color: var(--muted); +} + +.model-setup-provider-select__chevron svg, +.model-setup-provider-select__placeholder-icon svg, +.model-setup__manual-help svg, +.model-setup-success__icon svg, +.model-setup-success__actions svg { + width: 18px; + height: 18px; +} + +.model-setup-provider-select__option { + color: var(--text); + font: inherit; + font-size: 13px; +} + +.model-setup-provider-select__option::part(label) { + min-width: 0; + width: 100%; +} + +.model-setup-provider-select__option .model-setup-provider-select__copy { + padding: 3px 0; +} + +.model-setup__icon--picker { + width: 28px; + height: 28px; + flex-basis: 28px; +} + +.model-setup__manual-help { + display: flex; + align-items: flex-start; + gap: 8px; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.model-setup__manual-help svg { + flex: 0 0 auto; + color: var(--ok); +} + .model-setup__manual .btn { justify-self: start; } -.model-setup__success { - display: flex; - align-items: center; - justify-content: space-between; +.model-setup-success { + display: grid; + justify-items: center; gap: 18px; - padding: 18px; - border: 1px solid color-mix(in srgb, var(--ok) 50%, var(--border)); + width: min(480px, calc(100vw - 48px)); + padding: 28px; + border: 1px solid var(--border); border-radius: var(--radius-lg); - background: color-mix(in srgb, var(--ok) 10%, var(--panel)); + background: var(--panel); + box-shadow: var(--shadow-lg); + text-align: center; } -.model-setup__success strong { - display: block; - margin-bottom: 4px; +.model-setup-success__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 52px; + height: 52px; + border-radius: 50%; + background: color-mix(in srgb, var(--ok) 14%, var(--panel)); color: var(--ok); - font-size: 16px; +} + +.model-setup-success__icon svg { + width: 28px; + height: 28px; +} + +.model-setup-success__copy h2 { + margin: 0; + font-size: 20px; +} + +.model-setup-success__copy p { + margin: 8px 0 0; + color: var(--muted); + line-height: 1.5; +} + +.model-setup-success__warning { + color: var(--warn); + font-size: 13px; + line-height: 1.5; + text-align: center; +} + +.model-setup-success__summary { + display: grid; + gap: 4px; + width: 100%; + padding: 14px; + border: 1px solid color-mix(in srgb, var(--ok) 32%, var(--border)); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--ok) 7%, var(--panel)); +} + +.model-setup-success__summary span { + color: var(--muted); + font-size: 12px; +} + +.model-setup-success__summary strong { + overflow-wrap: anywhere; + font-size: 14px; +} + +.model-setup-success__actions { + display: flex; + justify-content: center; + gap: 10px; + width: 100%; +} + +.model-setup-success__actions .btn { + justify-content: center; } .model-setup-wizard { @@ -233,18 +409,29 @@ @media (max-width: 640px) { .model-setup__intro, - .model-setup__row, - .model-setup__success { + .model-setup__row { align-items: stretch; flex-direction: column; } - .model-setup__row > .btn, - .model-setup__success > .btn { + .model-setup__row > .btn { align-self: flex-start; } .model-setup__row-actions { justify-content: flex-start; } + + .model-setup-success { + width: calc(100vw - 24px); + padding: 22px 18px; + } + + .model-setup-success__actions { + flex-direction: column-reverse; + } + + .model-setup-success__actions .btn { + width: 100%; + } }