fix(ui): clarify model setup flows (#116086)

This commit is contained in:
Vincent Koc
2026-07-30 09:47:57 +08:00
committed by GitHub
parent 21db50efc7
commit 12e5eb6a23
15 changed files with 1226 additions and 120 deletions

View File

@@ -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,

View File

@@ -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),

View File

@@ -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"),
);
}

View File

@@ -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<typeof resolveModelRuntimePolicy>[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),

View File

@@ -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([
{

View File

@@ -76,6 +76,7 @@ const PROVIDER_ICON_ALIASES: Readonly<Record<string, string>> = {
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<Record<string, string>> = {
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"). */

View File

@@ -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<void>((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<void>((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({});

View File

@@ -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: {

View File

@@ -436,7 +436,7 @@ describe("ModelSetupPage catalog icons", () => {
page.querySelector<HTMLButtonElement>('[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");
});
});

View File

@@ -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(),

View File

@@ -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<HTMLElement & { active: boolean }>(
"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<HTMLElement>('[slot="trigger"]')
: dropdown
.closest(".model-setup__manual")
?.querySelector<HTMLElement>('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<HTMLElement>('[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<HTMLElement>('[slot="trigger"]')?.focus({ preventScroll: true }),
{ once: true },
);
onChange(value);
return;
}
event.preventDefault();
item.checked = true;
dropdown.querySelector<HTMLElement>('[slot="trigger"]')?.focus({ preventScroll: true });
dropdown.open = false;
}

View File

@@ -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<ModelSetupActivationState, { phase: "success" }>,
onOpenChat: () => void,
onClose: () => void,
firstRun: boolean,
) {
return html`
<openclaw-modal-dialog
label=${t("modelSetup.success.title")}
description=${t("modelSetup.success.body", { modelRef: activation.modelRef })}
@modal-cancel=${onClose}
>
<section class="model-setup-success" role="status">
<div class="model-setup-success__icon" aria-hidden="true">${icons.shieldCheck}</div>
<div class="model-setup-success__copy">
<h2>${t("modelSetup.success.title")}</h2>
<p>${t("modelSetup.success.body", { modelRef: activation.modelRef })}</p>
</div>
${activation.warning
? html`<div class="model-setup-success__warning">${activation.warning}</div>`
: nothing}
<div class="model-setup-success__summary">
<span>${t("modelSetup.success.activeModel")}</span>
<strong>${activation.modelRef}</strong>
${activation.latencyMs === undefined
? nothing
: html`<span>
${t("modelSetup.success.latency", {
latencyMs: String(activation.latencyMs),
})}
</span>`}
</div>
<footer class="model-setup-success__actions">
<button type="button" class="btn" @click=${onClose}>
${t("modelSetup.success.stayHere")}
</button>
<button type="button" class="btn primary" autofocus @click=${onOpenChat}>
${icons.messageSquare}
${t(firstRun ? "modelSetup.success.continueSetup" : "modelSetup.success.openChat")}
</button>
</footer>
</section>
</openclaw-modal-dialog>
`;
}

View File

@@ -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<ModelSetupViewProps> = {}): 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<HTMLSelectElement>(".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<HTMLElement & { checked: boolean; value: string }>(
"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<HTMLElement & { open: boolean }>(
".model-setup-provider-select",
)!;
const trigger = picker.querySelector<HTMLElement>('[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<HTMLElement & { open: boolean }>(
".model-setup-provider-select",
)!;
const option = picker.querySelector<HTMLElement>("[data-manual-provider]")!;
const accessValue = container.querySelector<HTMLElement>('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<HTMLElement & { open: boolean }>(
".model-setup-provider-select",
)!;
const trigger = picker.querySelector<HTMLElement>('[slot="trigger"]')!;
const option = picker.querySelector<HTMLElement>("[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<HTMLElement & { open: boolean }>(
".model-setup-provider-select",
)!;
const trigger = picker.querySelector<HTMLElement>('[slot="trigger"]')!;
const option = picker.querySelector<HTMLElement & { checked: boolean }>(
'[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<HTMLElement & { active: boolean }>("[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<HTMLElement>('[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<HTMLButtonElement>(".primary")?.click();
expect(onOpenChat).toHaveBeenCalledOnce();
dialog?.querySelectorAll<HTMLButtonElement>("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<HTMLButtonElement>(
'[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<HTMLButtonElement>(".model-setup__success button")?.click();
expect(text(container)).toContain("Connection verified");
expect(text(container)).toContain("Verified in 91 ms");
container.querySelector<HTMLButtonElement>(".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({

View File

@@ -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<SystemAgentSetupDetectResult["authOptions"]>[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<ModelSetupActivationState, { phase: "success" }>,
onOpenChat: () => void,
firstRun: boolean,
) {
return html`
<div class="model-setup__success" role="status">
<div>
<strong>${t("modelSetup.success.title")}</strong>
<div>
${activation.latencyMs === undefined
? activation.modelRef
: t("modelSetup.success.detail", {
modelRef: activation.modelRef,
latencyMs: String(activation.latencyMs),
})}
</div>
${activation.warning
? html`<div class="model-setup__success-warning">${activation.warning}</div>`
: nothing}
</div>
<button type="button" class="btn primary" @click=${onOpenChat}>
${t(firstRun ? "modelSetup.success.continueSetup" : "modelSetup.success.openChat")}
</button>
</div>
`;
}
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
<h2>${t("modelSetup.candidates.title")}</h2>
</div>
<div class="model-setup__rows">
${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")}
</button>
</div>
`,
@@ -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`
<wa-dropdown
class="model-setup-provider-select"
placement="bottom-start"
aria-label=${t("modelSetup.manual.provider")}
@wa-select=${(event: WebAwesomeSelectEvent) =>
handleManualProviderSelect(event, props.manualProviderId, props.onManualProviderChange)}
@wa-after-show=${focusSelectedManualProvider}
@keydown=${handleManualProviderKeydown}
>
<button
slot="trigger"
type="button"
class="model-setup-provider-select__trigger"
aria-label=${`${t("modelSetup.manual.provider")}: ${triggerLabel}`}
?disabled=${props.actionsDisabled || result.manualProviders.length === 0}
>
${provider
? renderProviderIcon(props, provider, "model-setup__icon--picker")
: html`<span class="model-setup-provider-select__placeholder-icon" aria-hidden="true">
${icons.key}
</span>`}
<span class="model-setup-provider-select__copy">
<strong>
${provider ? manualProviderName(provider) : t("modelSetup.manual.selectProvider")}
</strong>
${provider
? providerMethod
? html`<span>${providerMethod}</span>`
: nothing
: html`<span>${t("modelSetup.manual.selectProviderHint")}</span>`}
</span>
<span class="model-setup-provider-select__chevron" aria-hidden="true">
${icons.chevronDown}
</span>
</button>
${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`
<wa-dropdown-item
class="model-setup-provider-select__option"
data-manual-provider=${entry.id}
?data-selected=${selected}
aria-label=${accessibleLabel}
.value=${entry.id}
type="checkbox"
.checked=${selected}
?disabled=${props.actionsDisabled}
${ref((element) => syncDropdownItemRadio(element, selected))}
>
<span slot="icon">
${renderProviderIcon(props, entry, "model-setup__icon--picker")}
</span>
<span class="model-setup-provider-select__copy">
<strong>${manualProviderName(entry)}</strong>
${entryMethod ? html`<span>${entryMethod}</span>` : nothing}
${entry.hint ? html`<small>${entry.hint}</small>` : nothing}
</span>
</wa-dropdown-item>
`;
})}
</wa-dropdown>
`;
}
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
<h2>${t("modelSetup.manual.title")}</h2>
</div>
<div class="model-setup__manual">
<label class="field">
<div class="field">
<span>${t("modelSetup.manual.provider")}</span>
<div class="model-setup__manual-provider">
${provider ? renderProviderIcon(props, provider) : nothing}
<select
?disabled=${props.actionsDisabled}
@change=${(event: Event) =>
props.onManualProviderChange((event.currentTarget as HTMLSelectElement).value)}
>
<option value="" ?selected=${!props.manualProviderId}>
${t("modelSetup.manual.selectProvider")}
</option>
${result.manualProviders.map(
(entry) => html`
<option value=${entry.id} ?selected=${entry.id === props.manualProviderId}>
${entry.label}
</option>
`,
)}
</select>
</div>
</label>
${provider?.hint ? html`<div class="muted">${provider.hint}</div>` : nothing}
${renderManualProviderPicker(props, result, provider)}
</div>
<label class="field">
<span>${t("modelSetup.manual.accessValue")}</span>
<span>
${provider
? t("modelSetup.manual.accessValueFor", { provider: manualProviderName(provider) })
: t("modelSetup.manual.accessValue")}
</span>
<input
class="input"
type="password"
@@ -500,6 +563,10 @@ function renderManual(props: ModelSetupViewProps, result: SystemAgentSetupDetect
props.onManualApiKeyChange((event.currentTarget as HTMLInputElement).value)}
/>
</label>
<div class="model-setup__manual-help">
${icons.shieldCheck}
<span>${t("modelSetup.manual.verifyHint")}</span>
</div>
${props.manualError
? html`<div class="callout danger" role="alert">${props.manualError}</div>`
: nothing}
@@ -519,7 +586,9 @@ function renderManual(props: ModelSetupViewProps, result: SystemAgentSetupDetect
?disabled=${props.actionsDisabled || !props.manualProviderId}
@click=${props.onManualConnect}
>
${testing ? t("modelSetup.candidates.testingButton") : t("modelSetup.manual.connect")}
${testing
? t("modelSetup.candidates.testingButton")
: t("modelSetup.manual.connectAndVerify")}
</button>
</div>
</section>
@@ -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}
`;
}

View File

@@ -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%;
}
}