mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 16:31:34 +00:00
fix(ui): clarify model setup flows (#116086)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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"). */
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
79
ui/src/pages/model-setup/provider-picker.ts
Normal file
79
ui/src/pages/model-setup/provider-picker.ts
Normal 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;
|
||||
}
|
||||
51
ui/src/pages/model-setup/success-dialog.ts
Normal file
51
ui/src/pages/model-setup/success-dialog.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user