improve(setup): clarify local provider actions

This commit is contained in:
Vincent Koc
2026-08-01 00:26:40 +08:00
parent 73aa80a632
commit 0e24d596c0
22 changed files with 89 additions and 35 deletions

View File

@@ -89,6 +89,7 @@ extension OnboardingAISetupModel {
let id: String
let label: String
let hint: String?
let actionLabel: String?
let brandId: String?
let icon: String?
let website: String?
@@ -117,6 +118,7 @@ extension OnboardingAISetupModel {
id: "ollama",
label: "Ollama",
hint: "Download a tools-capable model from your Ollama server",
actionLabel: nil,
brandId: "ollama",
icon: nil,
website: nil),
@@ -124,6 +126,7 @@ extension OnboardingAISetupModel {
id: "llama-cpp",
label: "Local model (llama.cpp)",
hint: "Download an approximately 5.0 GB local model; requires 16 GB RAM",
actionLabel: nil,
brandId: "llama-cpp",
icon: nil,
website: nil),

View File

@@ -534,7 +534,7 @@ struct OnboardingAISetupView: View {
}
}
Spacer(minLength: 0)
Text("Connect / Set up")
Text(option.actionLabel ?? "Connect / Set up")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.accentColor)
}

View File

@@ -254,17 +254,20 @@ private func detectedSetupResponse(
"id": "ollama",
"brandId": "ollama",
"label": "Ollama",
"hint": "Connect to an Ollama server and select a cloud or local model"
"hint": "Connect to an Ollama server and select a cloud or local model",
"actionLabel": "Choose connection"
}, {
"id": "llama-cpp",
"brandId": "llama-cpp",
"label": "Local model (llama.cpp)",
"hint": "Download and run a private GGUF model"
"hint": "Download and run a private GGUF model",
"actionLabel": "Review download"
}, {
"id": "lmstudio",
"brandId": "lmstudio",
"label": "LM Studio",
"hint": "Connect to a running LM Studio server and use an already loaded model",
"actionLabel": "Connect server",
"icon": "https://cdn.simpleicons.org/lmstudio",
"website": "https://lmstudio.ai/download"
}],
@@ -681,6 +684,7 @@ struct OnboardingAISetupTests {
id: "ollama",
label: "Wire Ollama",
hint: "Wire hint",
actionLabel: "Choose connection",
brandId: "ollama",
icon: "https://cdn.simpleicons.org/ollama",
website: "https://ollama.com/download"),
@@ -688,6 +692,7 @@ struct OnboardingAISetupTests {
id: "llama-cpp",
label: "Local model (llama.cpp)",
hint: "Private GGUF model",
actionLabel: "Review download",
brandId: "llama-cpp",
icon: nil,
website: nil),
@@ -695,6 +700,7 @@ struct OnboardingAISetupTests {
id: "lmstudio-local",
label: "LM Studio",
hint: "Running local service",
actionLabel: "Connect server",
brandId: "lmstudio",
icon: "https://cdn.simpleicons.org/lmstudio",
website: "https://lmstudio.ai/download"),
@@ -706,6 +712,7 @@ struct OnboardingAISetupTests {
#expect(options.map(\.id) == ["llama-cpp"])
#expect(options.first?.label == "Local model (llama.cpp)")
#expect(options.first?.actionLabel == "Review download")
#expect(OnboardingAISetupModel.ProviderWizardKind.prepare.startMethod ==
"openclaw.setup.prepare.start")
}

View File

@@ -385,30 +385,31 @@ If a tool has no `toolMetadata`, OpenClaw preserves the existing behavior and lo
Each `providerAuthChoices` entry describes one onboarding or auth choice. OpenClaw reads this before provider runtime loads. Provider setup lists use these manifest choices, descriptor-derived setup choices, and install-catalog metadata without loading provider runtime.
| Field | Required | Type | What it means |
| --------------------- | -------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `provider` | Yes | `string` | Provider id this choice belongs to. |
| `method` | Yes | `string` | Auth method id to dispatch to. |
| `choiceId` | Yes | `string` | Stable auth-choice id used by onboarding and CLI flows. |
| `choiceLabel` | No | `string` | User-facing label. If omitted, OpenClaw falls back to `choiceId`. |
| `choiceHint` | No | `string` | Short helper text for the picker. |
| `icon` | No | HTTPS URL | Artwork shown beside this choice in supported onboarding clients. |
| `website` | No | HTTPS URL | Product, sign-in, or installation page shown by supported onboarding clients. |
| `assistantPriority` | No | `number` | Lower values sort earlier in assistant-driven interactive pickers. |
| `assistantVisibility` | No | `"visible"` \| `"manual-only"` | Hide the choice from assistant pickers while still allowing manual CLI selection. |
| `deprecatedChoiceIds` | No | `string[]` | Legacy choice ids that should redirect users to this replacement choice. |
| `groupId` | No | `string` | Optional group id for grouping related choices. |
| `groupLabel` | No | `string` | User-facing label for that group. |
| `groupHint` | No | `string` | Short helper text for the group. |
| `onboardingFeatured` | No | `boolean` | Surface this group in the featured tier of the interactive onboarding picker, before the "More..." entry. |
| `optionKey` | No | `string` | Internal option key for simple one-flag auth flows. |
| `cliFlag` | No | `string` | CLI flag name, such as `--openrouter-api-key`. |
| `cliOption` | No | `string` | Full CLI option shape, such as `--openrouter-api-key <key>`. |
| `cliDescription` | No | `string` | Description used in CLI help. |
| `appGuidedSecret` | No | `boolean` | One pasted secret plus provider defaults is sufficient for app-guided setup. |
| `appGuidedDiscovery` | No | `boolean` | The matching runtime auth method owns read-only local discovery through `appGuidedSetup`. |
| `appGuidedAuth` | No | `"oauth"` \| `"device-code"` | Provider-owned interactive login that native setup clients can render generically. |
| `onboardingScopes` | No | `Array<"text-inference" \| "image-generation" \| "music-generation">` | Which onboarding surfaces this choice should appear in. If omitted, it defaults to `["text-inference"]`. |
| Field | Required | Type | What it means |
| ---------------------- | -------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `provider` | Yes | `string` | Provider id this choice belongs to. |
| `method` | Yes | `string` | Auth method id to dispatch to. |
| `choiceId` | Yes | `string` | Stable auth-choice id used by onboarding and CLI flows. |
| `choiceLabel` | No | `string` | User-facing label. If omitted, OpenClaw falls back to `choiceId`. |
| `choiceHint` | No | `string` | Short helper text for the picker. |
| `icon` | No | HTTPS URL | Artwork shown beside this choice in supported onboarding clients. |
| `website` | No | HTTPS URL | Product, sign-in, or installation page shown by supported onboarding clients. |
| `assistantPriority` | No | `number` | Lower values sort earlier in assistant-driven interactive pickers. |
| `assistantVisibility` | No | `"visible"` \| `"manual-only"` | Hide the choice from assistant pickers while still allowing manual CLI selection. |
| `deprecatedChoiceIds` | No | `string[]` | Legacy choice ids that should redirect users to this replacement choice. |
| `groupId` | No | `string` | Optional group id for grouping related choices. |
| `groupLabel` | No | `string` | User-facing label for that group. |
| `groupHint` | No | `string` | Short helper text for the group. |
| `onboardingFeatured` | No | `boolean` | Surface this group in the featured tier of the interactive onboarding picker, before the "More..." entry. |
| `optionKey` | No | `string` | Internal option key for simple one-flag auth flows. |
| `cliFlag` | No | `string` | CLI flag name, such as `--openrouter-api-key`. |
| `cliOption` | No | `string` | Full CLI option shape, such as `--openrouter-api-key <key>`. |
| `cliDescription` | No | `string` | Description used in CLI help. |
| `appGuidedSecret` | No | `boolean` | One pasted secret plus provider defaults is sufficient for app-guided setup. |
| `appGuidedActionLabel` | No | `string` | Short command label shown when starting provider-owned app-guided setup. |
| `appGuidedDiscovery` | No | `boolean` | The matching runtime auth method owns read-only local discovery through `appGuidedSetup`. |
| `appGuidedAuth` | No | `"oauth"` \| `"device-code"` | Provider-owned interactive login that native setup clients can render generically. |
| `onboardingScopes` | No | `Array<"text-inference" \| "image-generation" \| "music-generation">` | Which onboarding surfaces this choice should appear in. If omitted, it defaults to `["text-inference"]`. |
When `appGuidedDiscovery` is true, the matching provider auth method must expose
`appGuidedSetup.detect` and `appGuidedSetup.prepare`. Detection must be

View File

@@ -29,6 +29,7 @@
"method": "local",
"choiceId": "llama-cpp",
"appGuidedDiscovery": true,
"appGuidedActionLabel": "Review download",
"choiceLabel": "Local model (llama.cpp)",
"choiceHint": "Downloads an approximately 5.0 GB local model; requires 16 GB RAM",
"groupId": "llama-cpp",

View File

@@ -44,6 +44,7 @@
"method": "custom",
"choiceId": "lmstudio",
"appGuidedDiscovery": true,
"appGuidedActionLabel": "Connect server",
"appGuidedSecret": true,
"choiceLabel": "LM Studio",
"choiceHint": "Connect to a running LM Studio server and use an already loaded model",

View File

@@ -58,6 +58,7 @@
"method": "local",
"choiceId": "ollama",
"appGuidedDiscovery": true,
"appGuidedActionLabel": "Choose connection",
"choiceLabel": "Ollama",
"choiceHint": "Connect to an Ollama server and select a cloud or local model",
"icon": "https://cdn.simpleicons.org/ollama",

View File

@@ -127,6 +127,7 @@ describe("OpenClaw setup detection protocol", () => {
brandId: "lmstudio",
label: "LM Studio",
hint: "Local/self-hosted LM Studio server",
actionLabel: "Connect server",
icon: "https://cdn.simpleicons.org/lmstudio",
website: "https://lmstudio.ai/download",
},

View File

@@ -266,6 +266,7 @@ export const SystemAgentSetupDetectResultSchema = closedObject({
brandId: Type.Optional(NonEmptyString),
label: NonEmptyString,
hint: Type.Optional(Type.String()),
actionLabel: Type.Optional(NonEmptyString),
icon: Type.Optional(SetupInferenceHttpsUrl),
website: Type.Optional(SetupInferenceHttpsUrl),
}),

View File

@@ -1258,6 +1258,7 @@ describe("loadPluginManifestRegistry", () => {
assistantPriority: 10,
assistantVisibility: "visible",
appGuidedSecret: true,
appGuidedActionLabel: "Connect account",
appGuidedDiscovery: true,
},
],
@@ -1326,6 +1327,7 @@ describe("loadPluginManifestRegistry", () => {
assistantPriority: 10,
assistantVisibility: "visible",
appGuidedSecret: true,
appGuidedActionLabel: "Connect account",
appGuidedDiscovery: true,
},
]);

View File

@@ -321,6 +321,7 @@ export function normalizeProviderAuthChoices(
const cliOption = normalizeOptionalString(entry.cliOption) ?? "";
const cliDescription = normalizeOptionalString(entry.cliDescription) ?? "";
const appGuidedSecret = entry.appGuidedSecret === true;
const appGuidedActionLabel = normalizeOptionalString(entry.appGuidedActionLabel) ?? "";
const appGuidedAuth =
entry.appGuidedAuth === "oauth" || entry.appGuidedAuth === "device-code"
? entry.appGuidedAuth
@@ -351,6 +352,7 @@ export function normalizeProviderAuthChoices(
...(cliOption ? { cliOption } : {}),
...(cliDescription ? { cliDescription } : {}),
...(appGuidedSecret ? { appGuidedSecret: true } : {}),
...(appGuidedActionLabel ? { appGuidedActionLabel } : {}),
...(appGuidedAuth ? { appGuidedAuth } : {}),
...(onboardingScopes.length > 0 ? { onboardingScopes } : {}),
});

View File

@@ -550,6 +550,8 @@ export type PluginManifestProviderAuthChoice = {
cliDescription?: string;
/** One pasted secret plus provider defaults is sufficient for app-guided setup. */
appGuidedSecret?: boolean;
/** Short provider-owned command label for starting app-guided setup. */
appGuidedActionLabel?: string;
/** Provider-owned interactive login that native setup clients can render generically. */
appGuidedAuth?: "oauth" | "device-code";
/**

View File

@@ -322,6 +322,7 @@ describe("provider auth choice manifest helpers", () => {
cliFlag: "--openai-api-key",
cliOption: "--openai-api-key <key>",
appGuidedSecret: true,
appGuidedActionLabel: "Connect account",
appGuidedDiscovery: true,
},
],
@@ -368,6 +369,7 @@ describe("provider auth choice manifest helpers", () => {
cliFlag: "--openai-api-key",
cliOption: "--openai-api-key <key>",
appGuidedSecret: true,
appGuidedActionLabel: "Connect account",
appGuidedDiscovery: true,
},
]);

View File

@@ -31,6 +31,7 @@ export type ProviderAuthChoiceMetadata = {
cliOption?: string;
cliDescription?: string;
appGuidedSecret?: boolean;
appGuidedActionLabel?: string;
appGuidedDiscovery?: boolean;
appGuidedAuth?: "oauth" | "device-code";
onboardingScopes?: ("text-inference" | "image-generation" | "music-generation")[];
@@ -113,6 +114,7 @@ function toProviderAuthChoiceCandidate(params: {
...(choice.cliOption ? { cliOption: choice.cliOption } : {}),
...(choice.cliDescription ? { cliDescription: choice.cliDescription } : {}),
...(choice.appGuidedSecret ? { appGuidedSecret: true } : {}),
...(choice.appGuidedActionLabel ? { appGuidedActionLabel: choice.appGuidedActionLabel } : {}),
...(choice.appGuidedDiscovery ? { appGuidedDiscovery: true } : {}),
...(choice.appGuidedAuth ? { appGuidedAuth: choice.appGuidedAuth } : {}),
...(choice.onboardingScopes ? { onboardingScopes: choice.onboardingScopes } : {}),

View File

@@ -35,6 +35,7 @@ export type SetupInferencePrepareOption = {
brandId?: string;
label: string;
hint?: string;
actionLabel?: string;
icon?: string;
website?: string;
};
@@ -158,6 +159,9 @@ export function listSetupInferencePrepareOptions(
brandId: choice.providerId,
label: choice.choiceLabel,
...(choice.choiceHint?.trim() ? { hint: choice.choiceHint.trim() } : {}),
...(choice.appGuidedActionLabel?.trim()
? { actionLabel: choice.appGuidedActionLabel.trim() }
: {}),
...(choice.icon ? { icon: choice.icon } : {}),
...(choice.website ? { website: choice.website } : {}),
},

View File

@@ -540,6 +540,7 @@ describe("detectSetupInference", () => {
methodId: "ambient",
choiceId: "local-model",
choiceLabel: "Local Server",
appGuidedActionLabel: "Connect server",
appGuidedDiscovery: true,
icon: "https://cdn.example.com/local.svg",
website: "https://local.example.com/download",
@@ -569,6 +570,14 @@ describe("detectSetupInference", () => {
]);
expect(detect).toHaveBeenCalledOnce();
expect(prepare).not.toHaveBeenCalled();
expect(detection.prepareOptions).toEqual([
expect.objectContaining({
id: "local-model",
brandId: "local",
label: "Local Server",
actionLabel: "Connect server",
}),
]);
});
it("surfaces an invalid existing config instead of treating it as fresh", async () => {

View File

@@ -22,18 +22,21 @@ const prepareOptions = [
brandId: "ollama",
label: "Ollama",
hint: "Connect to an Ollama server and select a cloud or local model",
actionLabel: "Choose connection",
},
{
id: "llama-cpp",
brandId: "llama-cpp",
label: "Local model (llama.cpp)",
hint: "Download and run a private GGUF model",
actionLabel: "Review download",
},
{
id: "lmstudio",
brandId: "lmstudio",
label: "LM Studio",
hint: "Connect to a running LM Studio server and use an already loaded model",
actionLabel: "Connect server",
icon: "https://cdn.simpleicons.org/lmstudio",
website: "https://lmstudio.ai/download",
},
@@ -137,7 +140,7 @@ describeControlUiE2e("Control UI llama.cpp setup mocked Gateway E2E", () => {
const response = await page.goto(`${server.baseUrl}settings/model-setup`);
expect(response?.status()).toBe(200);
const llamaCppRow = page.locator('[data-prepare-choice="llama-cpp"]');
await llamaCppRow.getByRole("button", { name: "Check & set up" }).waitFor();
await llamaCppRow.getByRole("button", { name: "Review download" }).waitFor();
await expect
.poll(() => llamaCppRow.locator('[data-provider-icon="llamacpp"]').count())
.toBe(1);
@@ -154,7 +157,7 @@ describeControlUiE2e("Control UI llama.cpp setup mocked Gateway E2E", () => {
});
}
await llamaCppRow.getByRole("button", { name: "Check & set up" }).click();
await llamaCppRow.getByRole("button", { name: "Review download" }).click();
const start = await gateway.waitForRequest("openclaw.setup.prepare.start");
expect(start.params).toMatchObject({ authChoice: "llama-cpp" });
await page.getByRole("heading", { name: "Set up a local model" }).waitFor();

View File

@@ -22,6 +22,7 @@ const prepareOptions = [
brandId: "lmstudio",
label: "LM Studio",
hint: "Connect to a running LM Studio server and use an already loaded model",
actionLabel: "Connect server",
icon: "https://cdn.simpleicons.org/lmstudio",
website: "https://lmstudio.ai/download",
},
@@ -138,7 +139,7 @@ describeControlUiE2e("Control UI LM Studio setup mocked Gateway E2E", () => {
const response = await page.goto(`${server.baseUrl}settings/model-setup`);
expect(response?.status()).toBe(200);
const lmStudioRow = page.locator('[data-prepare-choice="lmstudio"]');
await lmStudioRow.getByRole("button", { name: "Check & set up" }).waitFor();
await lmStudioRow.getByRole("button", { name: "Connect server" }).waitFor();
await expect
.poll(() => lmStudioRow.locator('[data-provider-icon="lmstudio"]').count())
.toBe(1);
@@ -152,7 +153,7 @@ describeControlUiE2e("Control UI LM Studio setup mocked Gateway E2E", () => {
});
}
await lmStudioRow.getByRole("button", { name: "Check & set up" }).click();
await lmStudioRow.getByRole("button", { name: "Connect server" }).click();
const start = await gateway.waitForRequest("openclaw.setup.prepare.start");
expect(start.params).toMatchObject({ authChoice: "lmstudio" });
await expect

View File

@@ -22,18 +22,21 @@ const localPrepareOptions = [
brandId: "ollama",
label: "Ollama",
hint: "Connect to an Ollama server and select a cloud or local model",
actionLabel: "Choose connection",
},
{
id: "llama-cpp",
brandId: "llama-cpp",
label: "Local model (llama.cpp)",
hint: "Download and run a private GGUF model",
actionLabel: "Review download",
},
{
id: "lmstudio",
brandId: "lmstudio",
label: "LM Studio",
hint: "Connect to a running LM Studio server and use an already loaded model",
actionLabel: "Connect server",
icon: "https://cdn.simpleicons.org/lmstudio",
website: "https://lmstudio.ai/download",
},
@@ -383,7 +386,7 @@ describeControlUiE2e("Control UI Model Setup mocked Gateway E2E", () => {
expect(new Set(localProviderIconColors).size).toBe(1);
await page
.locator('[data-prepare-choice="ollama"]')
.getByRole("button", { name: "Check & set up" })
.getByRole("button", { name: "Choose connection" })
.click();
const start = await gateway.waitForRequest("openclaw.setup.prepare.start");

View File

@@ -6,6 +6,7 @@ export type ModelSetupPrepareOption = {
brandId?: string;
label: string;
hint?: string;
actionLabel?: string;
icon?: string;
website?: string;
};

View File

@@ -69,6 +69,7 @@ const detected: SystemAgentSetupDetectResult = {
brandId: "ollama",
label: "Ollama",
hint: "Connect to an Ollama server and select a cloud or local model",
actionLabel: "Choose connection",
icon: "https://cdn.simpleicons.org/ollama",
website: "https://ollama.com/download",
},
@@ -77,6 +78,7 @@ const detected: SystemAgentSetupDetectResult = {
brandId: "lmstudio",
label: "LM Studio",
hint: "Connect to a running LM Studio server and use an already loaded model",
actionLabel: "Connect server",
icon: "https://cdn.simpleicons.org/lmstudio",
website: "https://lmstudio.ai/download",
},
@@ -85,6 +87,7 @@ const detected: SystemAgentSetupDetectResult = {
brandId: "llama-cpp",
label: "Local model (llama.cpp)",
hint: "Download and run a private GGUF model",
actionLabel: "Review download",
},
],
recommendedInstalls: [
@@ -498,8 +501,12 @@ describe("renderModelSetup", () => {
const llamaCpp = container.querySelector<HTMLButtonElement>(
'[data-prepare-choice="llama-cpp"] button',
);
expect(ollama?.textContent).toContain("Check & set up");
expect(llamaCpp?.textContent).toContain("Check & set up");
expect(ollama?.textContent).toContain("Choose connection");
expect(llamaCpp?.textContent).toContain("Review download");
expect(
container.querySelector<HTMLButtonElement>('[data-prepare-choice="lmstudio"] button')
?.textContent,
).toContain("Connect server");
const llamaCppRow = container.querySelector('[data-prepare-choice="llama-cpp"]');
expect(llamaCppRow?.querySelector('[data-provider-icon="llamacpp"]')).not.toBeNull();
expect(text(llamaCppRow!)).toContain("llama.cpp");

View File

@@ -436,7 +436,7 @@ function renderPrepare(props: ModelSetupViewProps, result: SystemAgentSetupDetec
?disabled=${props.actionsDisabled}
@click=${() => props.onStartPrepare(option)}
>
${t("modelSetup.prepare.ollamaButton")}
${option.actionLabel ?? t("modelSetup.prepare.ollamaButton")}
</button>
</div>
`,