feat: refine pluggable web search configure flow
This commit is contained in:
parent
8542194901
commit
034798b101
@ -121,6 +121,11 @@ describe("runConfigureWizard", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
vi.stubEnv("GEMINI_API_KEY", "");
|
||||
vi.stubEnv("XAI_API_KEY", "");
|
||||
vi.stubEnv("MOONSHOT_API_KEY", "");
|
||||
vi.stubEnv("PERPLEXITY_API_KEY", "");
|
||||
mocks.clackIntro.mockReset();
|
||||
mocks.clackOutro.mockReset();
|
||||
mocks.clackSelect.mockReset();
|
||||
@ -212,7 +217,7 @@ describe("runConfigureWizard", () => {
|
||||
mocks.clackOutro.mockResolvedValue(undefined);
|
||||
mocks.clackConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
|
||||
mocks.clackSelect.mockImplementation(async (params: { message: string }) => {
|
||||
if (params.message === "Choose web search provider") {
|
||||
if (params.message === "Choose active web search provider") {
|
||||
return "tavily";
|
||||
}
|
||||
if (params.message.startsWith("Search depth")) {
|
||||
@ -325,7 +330,7 @@ describe("runConfigureWizard", () => {
|
||||
mocks.clackOutro.mockResolvedValue(undefined);
|
||||
mocks.clackConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
|
||||
mocks.clackSelect.mockImplementation(async (params: { message: string }) => {
|
||||
if (params.message === "Choose web search provider") {
|
||||
if (params.message === "Choose active web search provider") {
|
||||
return "tavily";
|
||||
}
|
||||
if (params.message.startsWith("Search depth")) {
|
||||
@ -344,10 +349,14 @@ describe("runConfigureWizard", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(mocks.note).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Api Key"),
|
||||
"Invalid plugin config",
|
||||
);
|
||||
expect(
|
||||
mocks.note.mock.calls.some(
|
||||
([message, title]) =>
|
||||
title === "Invalid plugin config" &&
|
||||
typeof message === "string" &&
|
||||
message.includes("Api Key"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(mocks.writeConfigFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
plugins: expect.objectContaining({
|
||||
@ -450,7 +459,7 @@ describe("runConfigureWizard", () => {
|
||||
mocks.clackOutro.mockResolvedValue(undefined);
|
||||
mocks.clackConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
|
||||
mocks.clackSelect.mockImplementation(async (params: { message: string }) => {
|
||||
if (params.message === "Choose web search provider") {
|
||||
if (params.message === "Choose active web search provider") {
|
||||
return "__install_plugin__";
|
||||
}
|
||||
if (params.message.startsWith("Search depth")) {
|
||||
|
||||
@ -189,12 +189,8 @@ async function promptWebToolsConfig(
|
||||
confirm: async (params) => guardCancel(await confirm(params), runtime),
|
||||
progress: () => ({ update: () => {}, stop: () => {} }),
|
||||
};
|
||||
const {
|
||||
applySearchProviderChoice,
|
||||
SEARCH_PROVIDER_OPTIONS,
|
||||
buildSearchProviderPickerModel,
|
||||
resolveSearchProviderPickerEntries,
|
||||
} = await import("./onboard-search.js");
|
||||
const { resolveSearchProviderPickerEntries, promptSearchProviderFlow } =
|
||||
await import("./onboard-search.js");
|
||||
const providerEntries = await resolveSearchProviderPickerEntries(nextConfig, workspaceDir);
|
||||
|
||||
note(
|
||||
@ -220,39 +216,18 @@ async function promptWebToolsConfig(
|
||||
};
|
||||
|
||||
if (enableSearch) {
|
||||
type ProviderChoice = string;
|
||||
const pickerModel = buildSearchProviderPickerModel({
|
||||
const applied = await promptSearchProviderFlow({
|
||||
config: nextConfig,
|
||||
providerEntries,
|
||||
includeSkipOption: false,
|
||||
});
|
||||
const providerChoice = guardCancel(
|
||||
await select<ProviderChoice>({
|
||||
message: "Choose web search provider",
|
||||
options: pickerModel.options.filter((option) => option.value !== "__skip__"),
|
||||
initialValue:
|
||||
pickerModel.initialValue === "__skip__"
|
||||
? SEARCH_PROVIDER_OPTIONS[0].value
|
||||
: pickerModel.initialValue,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
|
||||
if (providerChoice === "__keep_current__") {
|
||||
nextSearch = { ...nextSearch, provider: existingSearch?.provider };
|
||||
} else {
|
||||
const applied = await applySearchProviderChoice({
|
||||
config: nextConfig,
|
||||
choice: providerChoice,
|
||||
runtime,
|
||||
prompter,
|
||||
opts: {
|
||||
workspaceDir,
|
||||
},
|
||||
});
|
||||
nextConfig = applied;
|
||||
nextSearch = { ...applied.tools?.web?.search };
|
||||
}
|
||||
prompter,
|
||||
opts: {
|
||||
workspaceDir,
|
||||
},
|
||||
includeSkipOption: true,
|
||||
skipHint: "Leave the current web search setup unchanged",
|
||||
});
|
||||
nextConfig = applied;
|
||||
nextSearch = { ...applied.tools?.web?.search };
|
||||
}
|
||||
|
||||
const enableFetch = guardCancel(
|
||||
|
||||
@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import { validateConfigObjectWithPlugins } from "../config/config.js";
|
||||
import * as noteModule from "../terminal/note.js";
|
||||
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
||||
import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js";
|
||||
@ -179,6 +180,138 @@ describe("doctor config flow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("removes invalid plugin config leaves and disables the affected plugin on repair", async () => {
|
||||
const tavilyPath = path.join(process.cwd(), "extensions", "tavily-search");
|
||||
const result = await runDoctorConfigWithInput({
|
||||
repair: true,
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [tavilyPath] },
|
||||
allow: ["tavily-search"],
|
||||
entries: {
|
||||
"tavily-search": {
|
||||
enabled: true,
|
||||
config: {
|
||||
apiKey: "◇ Enable web_search?",
|
||||
searchDepth: "basic",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
provider: "brave",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
run: loadAndMaybeMigrateDoctorConfig,
|
||||
});
|
||||
|
||||
const cfg = result.cfg as {
|
||||
plugins?: {
|
||||
entries?: Record<string, { enabled?: boolean; config?: Record<string, unknown> }>;
|
||||
};
|
||||
tools?: { web?: { search?: { provider?: string } } };
|
||||
};
|
||||
expect(cfg.plugins?.entries?.["tavily-search"]?.enabled).toBe(false);
|
||||
expect(cfg.plugins?.entries?.["tavily-search"]?.config).toBeUndefined();
|
||||
expect(cfg.tools?.web?.search?.provider).toBe("brave");
|
||||
|
||||
const validated = validateConfigObjectWithPlugins(cfg);
|
||||
expect(validated.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("does not delete missing plugin entries during repair", async () => {
|
||||
const result = await runDoctorConfigWithInput({
|
||||
repair: true,
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["webchat"],
|
||||
entries: {
|
||||
webchat: {
|
||||
enabled: true,
|
||||
config: {
|
||||
port: 3000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
run: loadAndMaybeMigrateDoctorConfig,
|
||||
});
|
||||
|
||||
const cfg = result.cfg as {
|
||||
plugins?: {
|
||||
allow?: string[];
|
||||
entries?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
expect(cfg.plugins?.entries?.webchat).toBeDefined();
|
||||
expect(cfg.plugins?.allow).toContain("webchat");
|
||||
|
||||
const validated = validateConfigObjectWithPlugins(cfg);
|
||||
expect(validated.ok).toBe(false);
|
||||
expect(
|
||||
validated.warnings.some(
|
||||
(warning) =>
|
||||
warning.path === "plugins.entries.webchat" &&
|
||||
warning.message.includes("plugin not found"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
validated.issues.some(
|
||||
(issue) => issue.path === "plugins.allow" && issue.message.includes("plugin not found"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("clears active web search provider when it points at a repaired plugin", async () => {
|
||||
const tavilyPath = path.join(process.cwd(), "extensions", "tavily-search");
|
||||
const result = await runDoctorConfigWithInput({
|
||||
repair: true,
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [tavilyPath] },
|
||||
allow: ["tavily-search"],
|
||||
entries: {
|
||||
"tavily-search": {
|
||||
enabled: true,
|
||||
config: {
|
||||
apiKey: "not-a-real-key",
|
||||
searchDepth: "basic",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
provider: "tavily",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
run: loadAndMaybeMigrateDoctorConfig,
|
||||
});
|
||||
|
||||
const cfg = result.cfg as {
|
||||
plugins?: {
|
||||
entries?: Record<string, { enabled?: boolean; config?: Record<string, unknown> }>;
|
||||
};
|
||||
tools?: { web?: { search?: { provider?: string } } };
|
||||
};
|
||||
expect(cfg.plugins?.entries?.["tavily-search"]?.enabled).toBe(false);
|
||||
expect(cfg.plugins?.entries?.["tavily-search"]?.config).toBeUndefined();
|
||||
expect(cfg.tools?.web?.search?.provider).toBeUndefined();
|
||||
|
||||
const validated = validateConfigObjectWithPlugins(cfg);
|
||||
expect(validated.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves discord streaming intent while stripping unsupported keys on repair", async () => {
|
||||
const result = await runDoctorConfigWithInput({
|
||||
repair: true,
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
listTelegramAccountIds,
|
||||
resolveTelegramAccount,
|
||||
} from "../../extensions/telegram/src/accounts.js";
|
||||
import { isBuiltinWebSearchProviderId } from "../agents/tools/web-search-provider-catalog.js";
|
||||
import {
|
||||
isNumericTelegramUserId,
|
||||
normalizeTelegramAllowFromEntry,
|
||||
@ -16,7 +17,12 @@ import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gatewa
|
||||
import { getChannelsCommandSecretTargetIds } from "../cli/command-secret-targets.js";
|
||||
import { listRouteBindings } from "../config/bindings.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { CONFIG_PATH, migrateLegacyConfig, readConfigFileSnapshot } from "../config/config.js";
|
||||
import {
|
||||
CONFIG_PATH,
|
||||
migrateLegacyConfig,
|
||||
readConfigFileSnapshot,
|
||||
validateConfigObjectWithPlugins,
|
||||
} from "../config/config.js";
|
||||
import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js";
|
||||
import { formatConfigIssueLines } from "../config/issue-format.js";
|
||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||
@ -79,6 +85,181 @@ function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parseConfigPath(pathLabel: string): Array<string | number> | null {
|
||||
if (!pathLabel || pathLabel === "<root>") {
|
||||
return [];
|
||||
}
|
||||
const parts: Array<string | number> = [];
|
||||
let token = "";
|
||||
for (let i = 0; i < pathLabel.length; i += 1) {
|
||||
const ch = pathLabel[i];
|
||||
if (ch === ".") {
|
||||
if (token) {
|
||||
parts.push(token);
|
||||
token = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === "[") {
|
||||
if (token) {
|
||||
parts.push(token);
|
||||
token = "";
|
||||
}
|
||||
const end = pathLabel.indexOf("]", i);
|
||||
if (end === -1) {
|
||||
return null;
|
||||
}
|
||||
const indexText = pathLabel.slice(i + 1, end);
|
||||
const index = Number.parseInt(indexText, 10);
|
||||
if (!Number.isInteger(index)) {
|
||||
return null;
|
||||
}
|
||||
parts.push(index);
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
token += ch;
|
||||
}
|
||||
if (token) {
|
||||
parts.push(token);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function deleteConfigPath(root: unknown, path: Array<string | number>): boolean {
|
||||
if (path.length === 0) {
|
||||
return false;
|
||||
}
|
||||
let current: unknown = root;
|
||||
for (let i = 0; i < path.length - 1; i += 1) {
|
||||
const part = path[i];
|
||||
if (typeof part === "number") {
|
||||
if (!Array.isArray(current) || part < 0 || part >= current.length) {
|
||||
return false;
|
||||
}
|
||||
current = current[part];
|
||||
continue;
|
||||
}
|
||||
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
||||
return false;
|
||||
}
|
||||
const record = current as Record<string, unknown>;
|
||||
if (!(part in record)) {
|
||||
return false;
|
||||
}
|
||||
current = record[part];
|
||||
}
|
||||
|
||||
const leaf = path[path.length - 1];
|
||||
if (typeof leaf === "number") {
|
||||
if (!Array.isArray(current) || leaf < 0 || leaf >= current.length) {
|
||||
return false;
|
||||
}
|
||||
current.splice(leaf, 1);
|
||||
return true;
|
||||
}
|
||||
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
||||
return false;
|
||||
}
|
||||
const record = current as Record<string, unknown>;
|
||||
if (!(leaf in record)) {
|
||||
return false;
|
||||
}
|
||||
delete record[leaf];
|
||||
return true;
|
||||
}
|
||||
|
||||
function maybeRepairInvalidPluginConfig(candidate: OpenClawConfig): {
|
||||
config: OpenClawConfig;
|
||||
changes: string[];
|
||||
} {
|
||||
const validation = validateConfigObjectWithPlugins(candidate);
|
||||
if (validation.ok) {
|
||||
return { config: candidate, changes: [] };
|
||||
}
|
||||
|
||||
const next = structuredClone(candidate);
|
||||
const changes: string[] = [];
|
||||
const affectedPluginIds = new Set<string>();
|
||||
for (const issue of validation.issues) {
|
||||
if (!issue.path.startsWith("plugins.entries.")) {
|
||||
continue;
|
||||
}
|
||||
if (!issue.message.startsWith("invalid config:")) {
|
||||
continue;
|
||||
}
|
||||
const parts = parseConfigPath(issue.path);
|
||||
if (!parts || parts.length <= 4) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
parts[0] !== "plugins" ||
|
||||
parts[1] !== "entries" ||
|
||||
typeof parts[2] !== "string" ||
|
||||
parts[3] !== "config"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const pluginId = parts[2];
|
||||
if (deleteConfigPath(next, parts)) {
|
||||
affectedPluginIds.add(pluginId);
|
||||
changes.push(
|
||||
`- Removed invalid plugin config value at ${issue.path}; re-run configure to re-enter a valid value if you still want this plugin enabled.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.length === 0) {
|
||||
return { config: candidate, changes: [] };
|
||||
}
|
||||
|
||||
const revalidated = validateConfigObjectWithPlugins(next);
|
||||
if (!revalidated.ok) {
|
||||
for (const pluginId of affectedPluginIds) {
|
||||
const configRoot = `plugins.entries.${pluginId}.config`;
|
||||
const stillInvalid = revalidated.issues.some((issue) => issue.path.startsWith(configRoot));
|
||||
if (!stillInvalid) {
|
||||
continue;
|
||||
}
|
||||
const pluginEntry = next.plugins?.entries?.[pluginId];
|
||||
if (pluginEntry) {
|
||||
delete pluginEntry.config;
|
||||
pluginEntry.enabled = false;
|
||||
}
|
||||
changes.push(
|
||||
`- Disabled plugin ${pluginId} and cleared its config because required plugin settings were still incomplete after removing invalid values.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const finalValidation = validateConfigObjectWithPlugins(next);
|
||||
if (!finalValidation.ok) {
|
||||
const activeProvider = next.tools?.web?.search?.provider?.trim().toLowerCase();
|
||||
const hasProviderIssue = finalValidation.issues.some(
|
||||
(issue) =>
|
||||
issue.path === "tools.web.search.provider" &&
|
||||
issue.message.startsWith("unknown web search provider:"),
|
||||
);
|
||||
if (hasProviderIssue && activeProvider && !isBuiltinWebSearchProviderId(activeProvider)) {
|
||||
if (next.tools?.web?.search) {
|
||||
delete next.tools.web.search.provider;
|
||||
changes.push(
|
||||
`- Cleared tools.web.search.provider because it referenced repaired plugin provider "${activeProvider}", which is no longer available after the config cleanup.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { config: next, changes };
|
||||
}
|
||||
|
||||
function maybeRepairMissingPluginEntries(candidate: OpenClawConfig): {
|
||||
config: OpenClawConfig;
|
||||
changes: string[];
|
||||
} {
|
||||
return { config: candidate, changes: [] };
|
||||
}
|
||||
|
||||
function normalizeBindingChannelKey(raw?: string | null): string {
|
||||
const normalized = normalizeChatChannelId(raw);
|
||||
if (normalized) {
|
||||
@ -1824,6 +2005,22 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
if (safeBinProfileRepair.warnings.length > 0) {
|
||||
note(safeBinProfileRepair.warnings.join("\n"), "Doctor warnings");
|
||||
}
|
||||
|
||||
const missingPluginEntryRepair = maybeRepairMissingPluginEntries(candidate);
|
||||
if (missingPluginEntryRepair.changes.length > 0) {
|
||||
note(missingPluginEntryRepair.changes.join("\n"), "Doctor changes");
|
||||
candidate = missingPluginEntryRepair.config;
|
||||
pendingChanges = true;
|
||||
cfg = missingPluginEntryRepair.config;
|
||||
}
|
||||
|
||||
const invalidPluginConfigRepair = maybeRepairInvalidPluginConfig(candidate);
|
||||
if (invalidPluginConfigRepair.changes.length > 0) {
|
||||
note(invalidPluginConfigRepair.changes.join("\n"), "Doctor changes");
|
||||
candidate = invalidPluginConfigRepair.config;
|
||||
pendingChanges = true;
|
||||
cfg = invalidPluginConfigRepair.config;
|
||||
}
|
||||
} else {
|
||||
const hits = scanTelegramAllowFromUsernameEntries(candidate);
|
||||
if (hits.length > 0) {
|
||||
|
||||
@ -6,6 +6,9 @@ import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
const loadOpenClawPlugins = vi.hoisted(() =>
|
||||
vi.fn(() => ({ searchProviders: [] as unknown[], plugins: [] as unknown[] })),
|
||||
);
|
||||
const loadPluginManifestRegistry = vi.hoisted(() =>
|
||||
vi.fn(() => ({ plugins: [] as unknown[], diagnostics: [] as unknown[] })),
|
||||
);
|
||||
const ensureOnboardingPluginInstalled = vi.hoisted(() =>
|
||||
vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg, installed: false })),
|
||||
);
|
||||
@ -15,6 +18,10 @@ vi.mock("../plugins/loader.js", () => ({
|
||||
loadOpenClawPlugins,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/manifest-registry.js", () => ({
|
||||
loadPluginManifestRegistry,
|
||||
}));
|
||||
|
||||
vi.mock("./onboarding/plugin-install.js", () => ({
|
||||
ensureOnboardingPluginInstalled,
|
||||
reloadOnboardingPluginRegistry,
|
||||
@ -30,7 +37,11 @@ const runtime: RuntimeEnv = {
|
||||
}) as RuntimeEnv["exit"],
|
||||
};
|
||||
|
||||
function createPrompter(params: { selectValue?: string; textValue?: string }): {
|
||||
function createPrompter(params: {
|
||||
selectValue?: string;
|
||||
actionValue?: string;
|
||||
textValue?: string;
|
||||
}): {
|
||||
prompter: WizardPrompter;
|
||||
notes: Array<{ title?: string; message: string }>;
|
||||
} {
|
||||
@ -41,9 +52,12 @@ function createPrompter(params: { selectValue?: string; textValue?: string }): {
|
||||
note: vi.fn(async (message: string, title?: string) => {
|
||||
notes.push({ title, message });
|
||||
}),
|
||||
select: vi.fn(
|
||||
async () => params.selectValue ?? "perplexity",
|
||||
) as unknown as WizardPrompter["select"],
|
||||
select: vi.fn(async (promptParams: { message?: string }) => {
|
||||
if (promptParams?.message === "Web search setup") {
|
||||
return params.actionValue ?? "__switch_active__";
|
||||
}
|
||||
return params.selectValue ?? "perplexity";
|
||||
}) as unknown as WizardPrompter["select"],
|
||||
multiselect: vi.fn(async () => []) as unknown as WizardPrompter["multiselect"],
|
||||
text: vi.fn(async () => params.textValue ?? ""),
|
||||
confirm: vi.fn(async () => true),
|
||||
@ -96,8 +110,15 @@ describe("setupSearch", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
vi.stubEnv("GEMINI_API_KEY", "");
|
||||
vi.stubEnv("XAI_API_KEY", "");
|
||||
vi.stubEnv("MOONSHOT_API_KEY", "");
|
||||
vi.stubEnv("PERPLEXITY_API_KEY", "");
|
||||
loadOpenClawPlugins.mockReset();
|
||||
loadOpenClawPlugins.mockReturnValue({ searchProviders: [], plugins: [] });
|
||||
loadPluginManifestRegistry.mockReset();
|
||||
loadPluginManifestRegistry.mockReturnValue({ plugins: [], diagnostics: [] });
|
||||
ensureOnboardingPluginInstalled.mockReset();
|
||||
ensureOnboardingPluginInstalled.mockImplementation(
|
||||
async ({ cfg }: { cfg: OpenClawConfig }) => ({
|
||||
@ -139,7 +160,10 @@ describe("setupSearch", () => {
|
||||
const { prompter } = createPrompter({ selectValue: "__skip__" });
|
||||
await setupSearch(cfg, runtime, prompter);
|
||||
|
||||
expect(prompter.select).toHaveBeenCalledWith(
|
||||
const providerSelectCall = (prompter.select as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||
(call) => call[0]?.message === "Choose active web search provider",
|
||||
);
|
||||
expect(providerSelectCall?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
options: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
@ -247,14 +271,16 @@ describe("setupSearch", () => {
|
||||
|
||||
await setupSearch(cfg, runtime, prompter);
|
||||
|
||||
const options = (prompter.select as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]?.options;
|
||||
const options = (prompter.select as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||
(call) => call[0]?.message === "Choose active web search provider",
|
||||
)?.[0]?.options;
|
||||
expect(options[0]).toMatchObject({
|
||||
value: "tavily",
|
||||
hint: "Plugin search · Third-party plugin · Configured · current",
|
||||
hint: "Plugin search · Third-party plugin · Active now",
|
||||
});
|
||||
expect(options[1]).toMatchObject({
|
||||
value: "brave",
|
||||
hint: "Structured results · country/language/time filters · Configured",
|
||||
hint: "Structured results · country/language/time filters · Built-in · Configured",
|
||||
});
|
||||
});
|
||||
|
||||
@ -633,6 +659,153 @@ describe("setupSearch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("continues into plugin config prompts even when the newly installed provider cannot register yet", async () => {
|
||||
loadOpenClawPlugins.mockImplementation(({ config }: { config: OpenClawConfig }) => {
|
||||
const hasApiKey = Boolean(config.plugins?.entries?.["tavily-search"]?.config?.apiKey);
|
||||
return hasApiKey
|
||||
? {
|
||||
searchProviders: [
|
||||
{
|
||||
pluginId: "tavily-search",
|
||||
provider: {
|
||||
id: "tavily",
|
||||
name: "Tavily Search",
|
||||
description: "Plugin search",
|
||||
configFieldOrder: ["apiKey", "searchDepth"],
|
||||
search: async () => ({ content: "ok" }),
|
||||
},
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
{
|
||||
id: "tavily-search",
|
||||
name: "Tavily Search",
|
||||
description: "External Tavily plugin",
|
||||
origin: "workspace",
|
||||
source: "/tmp/tavily-search",
|
||||
configJsonSchema: {
|
||||
type: "object",
|
||||
required: ["apiKey"],
|
||||
properties: {
|
||||
apiKey: { type: "string", minLength: 1, pattern: "^tvly-\\S+$" },
|
||||
searchDepth: { type: "string", enum: ["basic", "advanced"] },
|
||||
},
|
||||
},
|
||||
configUiHints: {
|
||||
apiKey: {
|
||||
label: "Tavily API key",
|
||||
placeholder: "tvly-...",
|
||||
sensitive: true,
|
||||
},
|
||||
searchDepth: {
|
||||
label: "Search depth",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
searchProviders: [],
|
||||
plugins: [
|
||||
{
|
||||
id: "tavily-search",
|
||||
name: "Tavily Search",
|
||||
description: "External Tavily plugin",
|
||||
origin: "workspace",
|
||||
source: "/tmp/tavily-search",
|
||||
configJsonSchema: {
|
||||
type: "object",
|
||||
required: ["apiKey"],
|
||||
properties: {
|
||||
apiKey: { type: "string", minLength: 1, pattern: "^tvly-\\S+$" },
|
||||
searchDepth: { type: "string", enum: ["basic", "advanced"] },
|
||||
},
|
||||
},
|
||||
configUiHints: {
|
||||
apiKey: {
|
||||
label: "Tavily API key",
|
||||
placeholder: "tvly-...",
|
||||
sensitive: true,
|
||||
},
|
||||
searchDepth: {
|
||||
label: "Search depth",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
loadPluginManifestRegistry.mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "tavily-search",
|
||||
name: "Tavily Search",
|
||||
description: "External Tavily plugin",
|
||||
origin: "workspace",
|
||||
source: "/tmp/tavily-search",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
required: ["apiKey"],
|
||||
properties: {
|
||||
apiKey: { type: "string", minLength: 1, pattern: "^tvly-\\S+$" },
|
||||
searchDepth: { type: "string", enum: ["basic", "advanced"] },
|
||||
},
|
||||
},
|
||||
configUiHints: {
|
||||
apiKey: {
|
||||
label: "Tavily API key",
|
||||
placeholder: "tvly-...",
|
||||
sensitive: true,
|
||||
},
|
||||
searchDepth: {
|
||||
label: "Search depth",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
ensureOnboardingPluginInstalled.mockImplementation(
|
||||
async ({ cfg }: { cfg: OpenClawConfig }) => ({
|
||||
cfg: {
|
||||
...cfg,
|
||||
plugins: {
|
||||
...cfg.plugins,
|
||||
entries: {
|
||||
...cfg.plugins?.entries,
|
||||
"tavily-search": {
|
||||
...(cfg.plugins?.entries?.["tavily-search"] as Record<string, unknown> | undefined),
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
installed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const { prompter, notes } = createPrompter({
|
||||
selectValue: "__install_plugin__",
|
||||
textValue: "tvly-installed-key",
|
||||
});
|
||||
(prompter.select as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce("__install_plugin__")
|
||||
.mockResolvedValueOnce("advanced");
|
||||
|
||||
const result = await setupSearch({}, runtime, prompter, {
|
||||
workspaceDir: "/tmp/workspace-search",
|
||||
});
|
||||
|
||||
expect(
|
||||
notes.some((note) => note.message.includes("could not load its web search provider yet")),
|
||||
).toBe(false);
|
||||
expect(result.tools?.web?.search?.provider).toBe("tavily");
|
||||
expect(result.plugins?.entries?.["tavily-search"]?.config).toEqual({
|
||||
apiKey: "tvly-installed-key",
|
||||
searchDepth: "advanced",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows missing-key note when no key is provided and no env var", async () => {
|
||||
const original = process.env.BRAVE_API_KEY;
|
||||
delete process.env.BRAVE_API_KEY;
|
||||
|
||||
@ -14,8 +14,9 @@ import {
|
||||
} from "../config/types.secrets.js";
|
||||
import { enablePluginInConfig } from "../plugins/enable.js";
|
||||
import { loadOpenClawPlugins } from "../plugins/loader.js";
|
||||
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
|
||||
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
|
||||
import type { PluginConfigUiHint, PluginOrigin, SearchProviderPlugin } from "../plugins/types.js";
|
||||
import type { PluginConfigUiHint, PluginOrigin } from "../plugins/types.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
import type { SecretInputMode } from "./onboard-types.js";
|
||||
@ -35,6 +36,8 @@ export const SEARCH_PROVIDER_OPTIONS = BUILTIN_WEB_SEARCH_PROVIDER_OPTIONS;
|
||||
const SEARCH_PROVIDER_INSTALL_SENTINEL = "__install_plugin__" as const;
|
||||
const SEARCH_PROVIDER_KEEP_CURRENT_SENTINEL = "__keep_current__" as const;
|
||||
const SEARCH_PROVIDER_SKIP_SENTINEL = "__skip__" as const;
|
||||
const SEARCH_PROVIDER_SWITCH_ACTIVE_SENTINEL = "__switch_active__" as const;
|
||||
const SEARCH_PROVIDER_CONFIGURE_SENTINEL = "__configure_provider__" as const;
|
||||
|
||||
type PluginSearchProviderEntry = {
|
||||
kind: "plugin";
|
||||
@ -49,7 +52,6 @@ type PluginSearchProviderEntry = {
|
||||
configFieldOrder?: string[];
|
||||
configJsonSchema?: Record<string, unknown>;
|
||||
configUiHints?: Record<string, PluginConfigUiHint>;
|
||||
provider: SearchProviderPlugin;
|
||||
};
|
||||
|
||||
export type SearchProviderPickerEntry =
|
||||
@ -57,6 +59,7 @@ export type SearchProviderPickerEntry =
|
||||
| PluginSearchProviderEntry;
|
||||
|
||||
type SearchProviderPickerChoice = string;
|
||||
type SearchProviderFlowIntent = "switch-active" | "configure-provider";
|
||||
|
||||
type PluginPromptableField =
|
||||
| {
|
||||
@ -409,7 +412,6 @@ export async function resolveSearchProviderPickerEntries(
|
||||
configFieldOrder: registration.provider.configFieldOrder,
|
||||
configJsonSchema: pluginRecord.configJsonSchema,
|
||||
configUiHints: pluginRecord.configUiHints,
|
||||
provider: registration.provider,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as PluginSearchProviderEntry[];
|
||||
@ -432,6 +434,40 @@ export async function resolveSearchProviderPickerEntry(
|
||||
return entries.find((entry) => entry.value === providerId);
|
||||
}
|
||||
|
||||
function buildPluginSearchProviderEntryFromManifest(params: {
|
||||
config: OpenClawConfig;
|
||||
installEntry: InstallableSearchProviderPluginCatalogEntry;
|
||||
workspaceDir?: string;
|
||||
}): PluginSearchProviderEntry | undefined {
|
||||
const registry = loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
cache: false,
|
||||
});
|
||||
const pluginRecord = registry.plugins.find((plugin) => plugin.id === params.installEntry.id);
|
||||
if (!pluginRecord) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "plugin",
|
||||
value: params.installEntry.providerId,
|
||||
label: params.installEntry.meta.label,
|
||||
hint: [
|
||||
params.installEntry.description || pluginRecord.description || "Plugin-provided web search",
|
||||
formatPluginSourceHint(pluginRecord.origin),
|
||||
].join(" · "),
|
||||
configured: false,
|
||||
pluginId: pluginRecord.id,
|
||||
origin: pluginRecord.origin,
|
||||
description: params.installEntry.description || pluginRecord.description,
|
||||
docsUrl: undefined,
|
||||
configFieldOrder: undefined,
|
||||
configJsonSchema: pluginRecord.configSchema,
|
||||
configUiHints: pluginRecord.configUiHints,
|
||||
};
|
||||
}
|
||||
|
||||
async function promptSearchProviderPluginInstallChoice(
|
||||
installableEntries: InstallableSearchProviderPluginCatalogEntry[],
|
||||
prompter: WizardPrompter,
|
||||
@ -485,17 +521,40 @@ async function installSearchProviderPlugin(params: {
|
||||
cfg: result.cfg,
|
||||
runtime: params.runtime,
|
||||
workspaceDir: params.workspaceDir,
|
||||
suppressOpenAllowlistWarning: true,
|
||||
});
|
||||
return result.cfg;
|
||||
}
|
||||
|
||||
async function resolveInstalledSearchProviderEntry(params: {
|
||||
config: OpenClawConfig;
|
||||
installEntry: InstallableSearchProviderPluginCatalogEntry;
|
||||
workspaceDir?: string;
|
||||
}): Promise<PluginSearchProviderEntry | undefined> {
|
||||
const installedProvider = await resolveSearchProviderPickerEntry(
|
||||
params.config,
|
||||
params.installEntry.providerId,
|
||||
params.workspaceDir,
|
||||
);
|
||||
if (installedProvider?.kind === "plugin") {
|
||||
return installedProvider;
|
||||
}
|
||||
return buildPluginSearchProviderEntryFromManifest({
|
||||
config: params.config,
|
||||
installEntry: params.installEntry,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
}
|
||||
|
||||
export async function applySearchProviderChoice(params: {
|
||||
config: OpenClawConfig;
|
||||
choice: SearchProviderPickerChoice;
|
||||
intent?: SearchProviderFlowIntent;
|
||||
runtime: RuntimeEnv;
|
||||
prompter: WizardPrompter;
|
||||
opts?: SetupSearchOptions;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const intent = params.intent ?? "switch-active";
|
||||
if (
|
||||
params.choice === SEARCH_PROVIDER_SKIP_SENTINEL ||
|
||||
params.choice === SEARCH_PROVIDER_KEEP_CURRENT_SENTINEL
|
||||
@ -526,11 +585,11 @@ export async function applySearchProviderChoice(params: {
|
||||
if (installedConfig === params.config) {
|
||||
return params.config;
|
||||
}
|
||||
const installedProvider = await resolveSearchProviderPickerEntry(
|
||||
installedConfig,
|
||||
selectedInstallEntry.providerId,
|
||||
params.opts?.workspaceDir,
|
||||
);
|
||||
const installedProvider = await resolveInstalledSearchProviderEntry({
|
||||
config: installedConfig,
|
||||
installEntry: selectedInstallEntry,
|
||||
workspaceDir: params.opts?.workspaceDir,
|
||||
});
|
||||
if (!installedProvider) {
|
||||
await params.prompter.note(
|
||||
[
|
||||
@ -541,18 +600,20 @@ export async function applySearchProviderChoice(params: {
|
||||
);
|
||||
return installedConfig;
|
||||
}
|
||||
return configureSearchProviderSelection(
|
||||
installedConfig,
|
||||
selectedInstallEntry.providerId,
|
||||
params.prompter,
|
||||
params.opts,
|
||||
);
|
||||
const enabled = enablePluginInConfig(installedConfig, installedProvider.pluginId);
|
||||
let next =
|
||||
intent === "switch-active"
|
||||
? setWebSearchProvider(enabled.config, installedProvider.value)
|
||||
: enabled.config;
|
||||
next = await promptPluginSearchProviderConfig(next, installedProvider, params.prompter);
|
||||
return preserveSearchProviderIntent(installedConfig, next, intent, installedProvider.value);
|
||||
}
|
||||
|
||||
return configureSearchProviderSelection(
|
||||
params.config,
|
||||
params.choice,
|
||||
params.prompter,
|
||||
intent,
|
||||
params.opts,
|
||||
);
|
||||
}
|
||||
@ -570,6 +631,7 @@ type SearchProviderPickerModel = {
|
||||
options: Array<{ value: SearchProviderPickerChoice; label: string; hint?: string }>;
|
||||
initialValue: SearchProviderPickerChoice;
|
||||
configuredCount: number;
|
||||
activeProvider?: string;
|
||||
};
|
||||
|
||||
function formatPickerEntryHint(params: {
|
||||
@ -578,23 +640,21 @@ function formatPickerEntryHint(params: {
|
||||
configuredCount: number;
|
||||
}): string {
|
||||
const { entry, isActive, configuredCount } = params;
|
||||
const baseHint =
|
||||
const baseParts =
|
||||
entry.kind === "plugin"
|
||||
? [
|
||||
entry.description?.trim() || "Plugin-provided web search",
|
||||
formatPluginSourceHint(entry.origin),
|
||||
].join(" · ")
|
||||
: entry.hint;
|
||||
]
|
||||
: [entry.hint, "Built-in"];
|
||||
|
||||
if (configuredCount <= 1) {
|
||||
return isActive && !entry.configured ? `${baseHint} · current` : baseHint;
|
||||
if (configuredCount > 1) {
|
||||
if (entry.configured) {
|
||||
baseParts.push(isActive ? "Active now" : "Configured");
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.configured) {
|
||||
return isActive ? `${baseHint} · Configured · current` : `${baseHint} · Configured`;
|
||||
}
|
||||
|
||||
return isActive ? `${baseHint} · current` : baseHint;
|
||||
return baseParts.join(" · ");
|
||||
}
|
||||
|
||||
export function buildSearchProviderPickerModel(
|
||||
@ -691,6 +751,7 @@ export function buildSearchProviderPickerModel(
|
||||
? SEARCH_PROVIDER_KEEP_CURRENT_SENTINEL
|
||||
: defaultProvider,
|
||||
configuredCount,
|
||||
activeProvider: activeLoadedProvider,
|
||||
};
|
||||
}
|
||||
|
||||
@ -698,31 +759,48 @@ export async function configureSearchProviderSelection(
|
||||
config: OpenClawConfig,
|
||||
choice: string,
|
||||
prompter: WizardPrompter,
|
||||
intent: SearchProviderFlowIntent = "switch-active",
|
||||
opts?: SetupSearchOptions,
|
||||
): Promise<OpenClawConfig> {
|
||||
const providerEntries = await resolveSearchProviderPickerEntries(config, opts?.workspaceDir);
|
||||
const selectedEntry = providerEntries.find((entry) => entry.value === choice);
|
||||
if (selectedEntry?.kind === "plugin") {
|
||||
const enabled = enablePluginInConfig(config, selectedEntry.pluginId);
|
||||
let next = setWebSearchProvider(enabled.config, selectedEntry.value);
|
||||
let next =
|
||||
intent === "switch-active"
|
||||
? setWebSearchProvider(enabled.config, selectedEntry.value)
|
||||
: enabled.config;
|
||||
if (selectedEntry.configured) {
|
||||
return preserveSearchProviderIntent(config, next, intent, selectedEntry.value);
|
||||
}
|
||||
if (opts?.quickstartDefaults && selectedEntry.configured) {
|
||||
return preserveDisabledState(config, next);
|
||||
return preserveSearchProviderIntent(config, next, intent, selectedEntry.value);
|
||||
}
|
||||
next = await promptPluginSearchProviderConfig(next, selectedEntry, prompter);
|
||||
return preserveDisabledState(config, next);
|
||||
return preserveSearchProviderIntent(config, next, intent, selectedEntry.value);
|
||||
}
|
||||
|
||||
const builtinChoice = choice as SearchProvider;
|
||||
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === builtinChoice)!;
|
||||
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === builtinChoice);
|
||||
if (!entry) {
|
||||
return config;
|
||||
}
|
||||
const existingKey = resolveExistingKey(config, builtinChoice);
|
||||
const keyConfigured = hasExistingKey(config, builtinChoice);
|
||||
const envAvailable = hasKeyInEnv(entry);
|
||||
|
||||
if (intent === "switch-active" && (keyConfigured || envAvailable)) {
|
||||
const result = existingKey
|
||||
? applySearchKey(config, builtinChoice, existingKey)
|
||||
: applyProviderOnly(config, builtinChoice);
|
||||
return preserveSearchProviderIntent(config, result, intent, builtinChoice);
|
||||
}
|
||||
|
||||
if (opts?.quickstartDefaults && (keyConfigured || envAvailable)) {
|
||||
const result = existingKey
|
||||
? applySearchKey(config, builtinChoice, existingKey)
|
||||
: applyProviderOnly(config, builtinChoice);
|
||||
return preserveDisabledState(config, result);
|
||||
return preserveSearchProviderIntent(config, result, intent, builtinChoice);
|
||||
}
|
||||
|
||||
const useSecretRefMode = opts?.secretInputMode === "ref"; // pragma: allowlist secret
|
||||
@ -740,7 +818,12 @@ export async function configureSearchProviderSelection(
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
);
|
||||
return applySearchKey(config, builtinChoice, ref);
|
||||
return preserveSearchProviderIntent(
|
||||
config,
|
||||
applySearchKey(config, builtinChoice, ref),
|
||||
intent,
|
||||
builtinChoice,
|
||||
);
|
||||
}
|
||||
|
||||
const keyInput = await prompter.text({
|
||||
@ -755,15 +838,30 @@ export async function configureSearchProviderSelection(
|
||||
const key = keyInput?.trim() ?? "";
|
||||
if (key) {
|
||||
const secretInput = resolveSearchSecretInput(builtinChoice, key, opts?.secretInputMode);
|
||||
return applySearchKey(config, builtinChoice, secretInput);
|
||||
return preserveSearchProviderIntent(
|
||||
config,
|
||||
applySearchKey(config, builtinChoice, secretInput),
|
||||
intent,
|
||||
builtinChoice,
|
||||
);
|
||||
}
|
||||
|
||||
if (existingKey) {
|
||||
return preserveDisabledState(config, applySearchKey(config, builtinChoice, existingKey));
|
||||
return preserveSearchProviderIntent(
|
||||
config,
|
||||
applySearchKey(config, builtinChoice, existingKey),
|
||||
intent,
|
||||
builtinChoice,
|
||||
);
|
||||
}
|
||||
|
||||
if (keyConfigured || envAvailable) {
|
||||
return preserveDisabledState(config, applyProviderOnly(config, builtinChoice));
|
||||
return preserveSearchProviderIntent(
|
||||
config,
|
||||
applyProviderOnly(config, builtinChoice),
|
||||
intent,
|
||||
builtinChoice,
|
||||
);
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
@ -775,19 +873,154 @@ export async function configureSearchProviderSelection(
|
||||
"Web search",
|
||||
);
|
||||
|
||||
return {
|
||||
...config,
|
||||
tools: {
|
||||
...config.tools,
|
||||
web: {
|
||||
...config.tools?.web,
|
||||
search: {
|
||||
...config.tools?.web?.search,
|
||||
provider: builtinChoice,
|
||||
return preserveSearchProviderIntent(
|
||||
config,
|
||||
{
|
||||
...config,
|
||||
tools: {
|
||||
...config.tools,
|
||||
web: {
|
||||
...config.tools?.web,
|
||||
search: {
|
||||
...config.tools?.web?.search,
|
||||
provider: builtinChoice,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
intent,
|
||||
builtinChoice,
|
||||
);
|
||||
}
|
||||
|
||||
function preserveSearchProviderIntent(
|
||||
original: OpenClawConfig,
|
||||
result: OpenClawConfig,
|
||||
intent: SearchProviderFlowIntent,
|
||||
selectedProvider: string,
|
||||
): OpenClawConfig {
|
||||
if (intent !== "configure-provider") {
|
||||
return preserveDisabledState(original, result);
|
||||
}
|
||||
|
||||
const currentProvider = original.tools?.web?.search?.provider;
|
||||
let next = result;
|
||||
if (currentProvider && currentProvider !== selectedProvider) {
|
||||
next = {
|
||||
...next,
|
||||
tools: {
|
||||
...next.tools,
|
||||
web: {
|
||||
...next.tools?.web,
|
||||
search: {
|
||||
...next.tools?.web?.search,
|
||||
provider: currentProvider,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return preserveDisabledState(original, next);
|
||||
}
|
||||
|
||||
async function promptSearchProviderIntent(params: {
|
||||
prompter: WizardPrompter;
|
||||
includeSkipOption: boolean;
|
||||
configuredCount: number;
|
||||
}): Promise<SearchProviderFlowIntent | typeof SEARCH_PROVIDER_SKIP_SENTINEL> {
|
||||
if (params.configuredCount <= 1) {
|
||||
return "switch-active";
|
||||
}
|
||||
return await params.prompter.select<
|
||||
SearchProviderFlowIntent | typeof SEARCH_PROVIDER_SKIP_SENTINEL
|
||||
>({
|
||||
message: "Web search setup",
|
||||
options: [
|
||||
{
|
||||
value: SEARCH_PROVIDER_CONFIGURE_SENTINEL,
|
||||
label: "Configure a provider",
|
||||
hint: "Update keys or plugin settings without changing the active provider",
|
||||
},
|
||||
{
|
||||
value: SEARCH_PROVIDER_SWITCH_ACTIVE_SENTINEL,
|
||||
label: "Switch active provider",
|
||||
hint: "Change which provider web_search uses right now",
|
||||
},
|
||||
...(params.includeSkipOption
|
||||
? [
|
||||
{
|
||||
value: SEARCH_PROVIDER_SKIP_SENTINEL,
|
||||
label: "Skip for now",
|
||||
hint: "Configure later with openclaw configure --section web",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
initialValue: SEARCH_PROVIDER_CONFIGURE_SENTINEL,
|
||||
});
|
||||
}
|
||||
|
||||
export async function promptSearchProviderFlow(params: {
|
||||
config: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
prompter: WizardPrompter;
|
||||
opts?: SetupSearchOptions;
|
||||
includeSkipOption: boolean;
|
||||
skipHint?: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const providerEntries = await resolveSearchProviderPickerEntries(
|
||||
params.config,
|
||||
params.opts?.workspaceDir,
|
||||
);
|
||||
const pickerModel = buildSearchProviderPickerModel({
|
||||
config: params.config,
|
||||
providerEntries,
|
||||
includeSkipOption: params.includeSkipOption,
|
||||
skipHint: params.skipHint,
|
||||
});
|
||||
const action = await promptSearchProviderIntent({
|
||||
prompter: params.prompter,
|
||||
includeSkipOption: params.includeSkipOption,
|
||||
configuredCount: pickerModel.configuredCount,
|
||||
});
|
||||
if (action === SEARCH_PROVIDER_SKIP_SENTINEL) {
|
||||
return params.config;
|
||||
}
|
||||
const intent: SearchProviderFlowIntent =
|
||||
action === SEARCH_PROVIDER_CONFIGURE_SENTINEL ? "configure-provider" : "switch-active";
|
||||
const choice = await params.prompter.select<SearchProviderPickerChoice>({
|
||||
message:
|
||||
intent === "switch-active"
|
||||
? "Choose active web search provider"
|
||||
: "Choose provider to configure",
|
||||
options: pickerModel.options
|
||||
.filter((option) => {
|
||||
if (intent === "configure-provider") {
|
||||
return option.value !== SEARCH_PROVIDER_KEEP_CURRENT_SENTINEL;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((option) =>
|
||||
intent === "switch-active" && option.value === pickerModel.activeProvider
|
||||
? { ...option, label: `[Active] ${option.label}` }
|
||||
: option,
|
||||
),
|
||||
initialValue:
|
||||
intent === "switch-active"
|
||||
? pickerModel.initialValue
|
||||
: (pickerModel.options.find(
|
||||
(option) => option.value !== SEARCH_PROVIDER_KEEP_CURRENT_SENTINEL,
|
||||
)?.value ?? pickerModel.initialValue),
|
||||
});
|
||||
|
||||
return applySearchProviderChoice({
|
||||
config: params.config,
|
||||
choice,
|
||||
intent,
|
||||
runtime: params.runtime,
|
||||
prompter: params.prompter,
|
||||
opts: params.opts,
|
||||
});
|
||||
}
|
||||
|
||||
export function hasKeyInEnv(entry: SearchProviderEntry): boolean {
|
||||
@ -931,24 +1164,12 @@ export async function setupSearch(
|
||||
"Web search",
|
||||
);
|
||||
|
||||
const providerEntries = await resolveSearchProviderPickerEntries(config, opts?.workspaceDir);
|
||||
const pickerModel = buildSearchProviderPickerModel({
|
||||
return promptSearchProviderFlow({
|
||||
config,
|
||||
providerEntries,
|
||||
includeSkipOption: true,
|
||||
skipHint: "Configure later with openclaw configure --section web",
|
||||
});
|
||||
const choice = await prompter.select<SearchProviderPickerChoice>({
|
||||
message: "Search provider",
|
||||
options: pickerModel.options,
|
||||
initialValue: pickerModel.initialValue,
|
||||
});
|
||||
|
||||
return applySearchProviderChoice({
|
||||
config,
|
||||
choice,
|
||||
runtime,
|
||||
prompter,
|
||||
opts,
|
||||
includeSkipOption: true,
|
||||
skipHint: "Configure later with openclaw configure --section web",
|
||||
});
|
||||
}
|
||||
|
||||
@ -235,6 +235,7 @@ export function reloadOnboardingPluginRegistry(params: {
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
workspaceDir?: string;
|
||||
suppressOpenAllowlistWarning?: boolean;
|
||||
}): void {
|
||||
clearPluginDiscoveryCache();
|
||||
const workspaceDir =
|
||||
@ -245,5 +246,6 @@ export function reloadOnboardingPluginRegistry(params: {
|
||||
workspaceDir,
|
||||
cache: false,
|
||||
logger: createPluginLoaderLogger(log),
|
||||
suppressOpenAllowlistWarning: params.suppressOpenAllowlistWarning,
|
||||
});
|
||||
}
|
||||
|
||||
@ -467,7 +467,7 @@ describe("finalizeOnboardingWizard", () => {
|
||||
const webSearchNote = noteCalls.find((call) => call?.[1] === "Web search");
|
||||
expect(webSearchNote?.[0]).toContain("Active provider: Tavily Search");
|
||||
expect(webSearchNote?.[0]).toContain(
|
||||
"Multiple web search providers are configured; this is the active provider for web_search.",
|
||||
"Multiple web search providers are configured; the others remain available to switch to later via configure.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -530,7 +530,7 @@ export async function finalizeOnboardingWizard(
|
||||
...(sourceLine ? [sourceLine] : []),
|
||||
...(configuredProviderCount > 1
|
||||
? [
|
||||
"Multiple web search providers are configured; this is the active provider for web_search.",
|
||||
"Multiple web search providers are configured; the others remain available to switch to later via configure.",
|
||||
]
|
||||
: []),
|
||||
"Plugin-managed providers may use plugin config or plugin-specific credentials instead of the built-in API key fields.",
|
||||
@ -548,7 +548,7 @@ export async function finalizeOnboardingWizard(
|
||||
`Active provider: ${label}`,
|
||||
...(configuredProviderCount > 1
|
||||
? [
|
||||
"Multiple web search providers are configured; this is the active provider for web_search.",
|
||||
"Multiple web search providers are configured; the others remain available to switch to later via configure.",
|
||||
]
|
||||
: []),
|
||||
"Plugin-managed providers may use plugin config or plugin-specific credentials instead of the built-in API key fields.",
|
||||
@ -565,7 +565,7 @@ export async function finalizeOnboardingWizard(
|
||||
...(sourceLine ? [sourceLine] : []),
|
||||
...(configuredProviderCount > 1
|
||||
? [
|
||||
"Multiple web search providers are configured; this is the active provider for web_search.",
|
||||
"Multiple web search providers are configured; the others remain available to switch to later via configure.",
|
||||
]
|
||||
: []),
|
||||
...(keySource ? [keySource] : []),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user